Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@ mops generate candid backend -o <path> # single canister, ad-hoc path

(Re)generates the curated `.did` from current Motoko source. With `[canisters.<name>].candid` set, overwrites that file. Without it, writes `<name>.did` next to `main` (e.g. `main = "src/Backend.mo"` → `src/backend.did`) and sets `[canisters.<name>].candid` in `mops.toml`. Run after every interface change; commit `.did` + `mops.toml` together. Same moc invocation as `mops build`, so the result always passes `mops build`'s subtype check.

### `mops generate bindings`

```bash
mops generate bindings # all [bindings.*]
mops generate bindings ICRC # one named binding
mops generate bindings foo.did -o out.mo # ad-hoc (mops.toml optional)
```

Generates Motoko binding modules from committed `.did` files for runtime `actor(id) : Foo.Self` calls. Configure in `mops.toml`:

```toml
[bindings.ICRC]
did = "candid/icrc.did"
# out = "bindings/ICRC.mo" # optional; default <dir(did)>/<name>.mo
```

Commit the `.did` (source of truth) and the generated `.mo`. Prefer `canister:` + `--actor-env-alias` when the target principal is a single fixed canister. `.did` `import`s are rejected — flatten into a self-contained interface.

### `mops toolchain`

```bash
Expand Down
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Next

- `mops generate bindings` generates Motoko modules from committed `.did` files (same `candid_parser` Motoko bindgen as `didc bind -t mo`, embedded in mops; `.did` imports not supported). Declare `[bindings.<name>]` with `did` (and optional `out`) in `mops.toml`, or pass a `.did` path with `-o` for ad-hoc generation. Use for runtime-chosen principals (`actor(id) : Foo.Self`); for a single fixed target prefer `canister:` + `--actor-env-alias`.

## 2.19.2

- Fix local path dependencies being written into `mops.lock` as absolute filesystem paths, which made committed lockfiles non-portable across machines. Local deps are now stored root-relative (e.g. `./packages/shared`, `../lib`). Regenerate an existing absolute lock with `mops install --lock update` (a plain `mops install` will not rewrite it). After regenerating, all environments need a CLI that includes this fix — older CLIs treat relative lock paths as cwd-relative and break from subdirectories.
Expand Down
25 changes: 24 additions & 1 deletion cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { docsCoverage } from "./commands/docs-coverage.js";
import { docs } from "./commands/docs.js";
import { format } from "./commands/format.js";
import { generateCandid } from "./commands/generate.js";
import { generateBindings } from "./commands/generate-bindings.js";
import { info } from "./commands/info.js";
import { init } from "./commands/init.js";
import { lint } from "./commands/lint.js";
Expand Down Expand Up @@ -939,7 +940,9 @@ program.addCommand(migrateCommand);

// generate
const generateCommand = new Command("generate")
.description("Generate source-derived artifacts (Candid, ...)")
.description(
"Generate source-derived artifacts (Candid, Motoko bindings, ...)",
)
.showHelpAfterError();

generateCommand
Expand Down Expand Up @@ -973,6 +976,26 @@ generateCommand
});
});

generateCommand
.command("bindings [targets...]")
.description(
"Generate Motoko binding modules from committed `.did` interfaces (for runtime `actor(id) : Foo.Self`). With no names, generates all `[bindings.*]` entries. Pass a `.did` path with `--output` for ad-hoc generation (mops.toml optional).",
)
.addOption(
new Option(
"--output, -o <output>",
"Write the generated .mo to <output> (single binding or ad-hoc .did only; does not touch mops.toml)",
),
)
.addOption(new Option("--verbose", "Verbose console output"))
.action(async (targets, options) => {
const adHoc = targets.some((t: string) => String(t).endsWith(".did"));
if (!adHoc) {
checkConfigFile(true);
}
await generateBindings(targets.length ? targets : undefined, options);
});

program.addCommand(generateCommand);

// self
Expand Down
225 changes: 225 additions & 0 deletions cli/commands/generate-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import chalk from "chalk";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { cliError } from "../error.js";
import { bindMotoko } from "../helpers/bind-motoko.js";
import { getRootDir, readConfig, resolveConfigPath } from "../mops.js";
import { BindingConfig } from "../types.js";

export interface GenerateBindingsOptions {
output?: string;
verbose?: boolean;
}

export async function generateBindings(
targets: string[] | undefined,
options: GenerateBindingsOptions,
): Promise<void> {
if (targets?.length === 0) {
cliError("No bindings specified");
}

// Ad-hoc: `mops generate bindings path.did -o out.mo`
if (targets?.length === 1 && looksLikeDidPath(targets[0]!)) {
await generateAdHoc(targets[0]!, options);
return;
}
if (targets?.some(looksLikeDidPath)) {
if (options.output) {
cliError(
"Ad-hoc generation accepts only one .did path at a time:\n" +
" mops generate bindings candid/foo.did -o bindings/Foo.mo",
);
}
cliError(
"Ad-hoc generation takes a single .did path and requires --output / -o:\n" +
" mops generate bindings candid/foo.did -o bindings/Foo.mo",
);
}

const config = readConfig();
const bindings = config.bindings ?? {};
const names = Object.keys(bindings);

if (!names.length) {
cliError(
"No [bindings.*] entries in mops.toml.\n" +
"Add one, e.g.:\n" +
" [bindings.ICRC]\n" +
' did = "candid/icrc.did"\n' +
"Or generate ad-hoc:\n" +
" mops generate bindings candid/icrc.did -o bindings/ICRC.mo",
);
}

const selected = targets?.length
? targets.map((name) => {
if (!bindings[name]) {
cliError(
`Binding ${JSON.stringify(name)} not found in mops.toml [bindings.*]`,
);
}
return name;
})
: names;

if (options.output && selected.length > 1) {
cliError(
"--output / -o is only supported when generating a single binding",
);
}

const rootDir = getRootDir();

for (const name of selected) {
const entry = bindings[name]!;
validateBindingEntry(name, entry);
const didFs = resolveConfigPath(entry.did);
const dest = resolveBindingDestination(
name,
entry,
options.output,
rootDir,
);

console.log(
chalk.blue("generate bindings"),
chalk.bold(name),
chalk.gray(`← ${entry.did}`),
chalk.gray(`→ ${dest.display}`),
);

const didText = await readDidFile(didFs);
let mo: string;
try {
mo = bindMotoko(didText);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cliError(`Failed to generate Motoko bindings for ${name}: ${msg}`);
}

await mkdir(path.dirname(dest.fsPath), { recursive: true });
await writeFile(dest.fsPath, mo, "utf8");

if (options.verbose) {
console.log(chalk.gray(`wrote ${dest.fsPath} (${mo.length} bytes)`));
}
}

console.log(
chalk.green(
`\n✓ Generated Motoko bindings for ${selected.length} interface${selected.length === 1 ? "" : "s"}`,
),
);
}

async function generateAdHoc(
didPath: string,
options: GenerateBindingsOptions,
): Promise<void> {
if (!options.output) {
cliError(
"Ad-hoc generation requires --output / -o:\n" +
` mops generate bindings ${didPath} -o <out.mo>`,
);
}

const rootDir = getRootDir();
const didFs = path.isAbsolute(didPath)
? didPath
: path.resolve(process.cwd(), didPath);
const dest = resolveOutputPath(options.output, rootDir);

console.log(
chalk.blue("generate bindings"),
chalk.gray(`← ${didPath}`),
chalk.gray(`→ ${dest.display}`),
);

const didText = await readDidFile(didFs);
let mo: string;
try {
mo = bindMotoko(didText);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cliError(`Failed to generate Motoko bindings: ${msg}`);
}

await mkdir(path.dirname(dest.fsPath), { recursive: true });
await writeFile(dest.fsPath, mo, "utf8");

if (options.verbose) {
console.log(chalk.gray(`wrote ${dest.fsPath} (${mo.length} bytes)`));
}

console.log(chalk.green(`\n✓ Generated Motoko bindings`));
}

function looksLikeDidPath(arg: string): boolean {
return arg.endsWith(".did");
}

function validateBindingEntry(name: string, entry: BindingConfig): void {
if (!entry || typeof entry !== "object") {
cliError(
`[bindings.${name}] must be a table with a did field, e.g. did = "candid/foo.did"`,
);
}
if (!entry.did || typeof entry.did !== "string") {
cliError(`[bindings.${name}] is missing required field "did"`);
}
if (entry.out != null && typeof entry.out !== "string") {
cliError(`[bindings.${name}].out must be a string path`);
}
}

interface Destination {
fsPath: string;
display: string;
}

function resolveBindingDestination(
name: string,
entry: BindingConfig,
outputFlag: string | undefined,
rootDir: string,
): Destination {
if (outputFlag) {
return resolveOutputPath(outputFlag, rootDir);
}
if (entry.out) {
return resolveOutputPath(resolveConfigPath(entry.out), rootDir, entry.out);
}
const didDir = path.dirname(entry.did).replace(/\\/g, "/");
const projectRel =
didDir === "." || didDir === "" ? `${name}.mo` : `${didDir}/${name}.mo`;
return resolveOutputPath(resolveConfigPath(projectRel), rootDir, projectRel);
}

function resolveOutputPath(
fsPath: string,
rootDir: string,
display?: string,
): Destination {
const absPath = path.resolve(fsPath);
const projectRoot = rootDir || process.cwd();
const dotMopsDir = path.resolve(projectRoot, ".mops");
if (absPath === dotMopsDir || absPath.startsWith(dotMopsDir + path.sep)) {
cliError(
`Refusing to write Motoko bindings inside .mops/ (private build cache): ${fsPath}\n` +
"Choose a path outside .mops/ — it should be importable source.",
);
}
return {
fsPath,
display: display ?? fsPath,
};
}

async function readDidFile(fsPath: string): Promise<string> {
try {
return await readFile(fsPath, "utf8");
} catch {
cliError(`Candid file not found: ${fsPath}`);
}
}
5 changes: 5 additions & 0 deletions cli/helpers/bind-motoko.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getWasmBindings } from "../wasm.js";

export function bindMotoko(did: string): string {
return getWasmBindings().bind_motoko(did);
}
47 changes: 47 additions & 0 deletions cli/tests/__snapshots__/generate.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`generate bindings configured bindings: default out next to .did 1`] = `
{
"exitCode": 0,
"stderr": "",
"stdout": "generate bindings Ledger ← candid/ledger.did → candid/Ledger.mo

✓ Generated Motoko bindings for 1 interface",
}
`;

exports[`generate bindings configured bindings: default out next to .did: candid/Ledger.mo 1`] = `
"// This is a generated Motoko binding.
// Please use \`import service "ic:canister_id"\` instead to call canisters on the IC if possible.

module {
public type Account = { owner : Principal; subaccount : ?Blob };
public type TransferArgs = { to : Account; amount : Nat };
public type TransferResult = { #Ok : Nat; #Err : Text };
public type Self = actor {
icrc1_balance_of : shared query Account -> async Nat;
icrc1_transfer : shared TransferArgs -> async TransferResult;
}
}
"
`;

exports[`generate bindings configured out path is used 1`] = `
{
"exitCode": 0,
"stderr": "",
"stdout": "generate bindings CustomOut ← candid/custom.did → src/Custom.mo

✓ Generated Motoko bindings for 1 interface",
}
`;

exports[`generate bindings no names: generates all bindings 1`] = `
{
"exitCode": 0,
"stderr": "",
"stdout": "generate bindings Ledger ← candid/ledger.did → candid/Ledger.mo
generate bindings CustomOut ← candid/custom.did → src/Custom.mo

✓ Generated Motoko bindings for 2 interfaces",
}
`;

exports[`generate candid configured path: overwrites in place and does not touch mops.toml 1`] = `
{
"exitCode": 0,
Expand Down
Loading
Loading