diff --git a/specs/mops-candid.md b/specs/mops-candid.md new file mode 100644 index 00000000..4be69188 --- /dev/null +++ b/specs/mops-candid.md @@ -0,0 +1,173 @@ +# Spec: `mops generate candid` + +Status: Draft / proposal + +> Sibling to [`specs/mops-deployed.md`](mops-deployed.md). That spec +> handles `.most` (post-deploy snapshot); this one handles `.did` +> (build-input contract). + +`mops generate` is a noun-namespace for source-derived artifacts. v1 ships +one member (`candid`); future members like `mops generate most` or `mops +generate migration` can join later without restructuring. + +## User story + +A Motoko canister's Candid interface lives in two places: + +- **Embedded in the wasm** as `candid:service` metadata, written by every + `mops build` (the deployed canister self-describes). +- **As an on-disk `.did` file** that downstream tooling reads — most + importantly `@icp-sdk/bindgen` (the Vite plugin generates frontend + TypeScript clients from a file path, not the wasm). The `icp-cli` + `hello-world` template demonstrates the canonical pattern: one committed + `backend.did` shared by recipe (build input) and frontend (bindgen + input). + +mops surfaces this via **`[canisters.].candid`** — an optional path. +When set, `mops build` subtype-checks the auto-generated interface against +that file and **embeds the file** verbatim into the wasm as +`candid:service` (`cli/commands/build.ts:184-200`). It's a *curated, +ahead-of-code contract*. + +What's missing is **maintenance**. When the Motoko interface changes, +someone has to write the new `.did` by hand. When the field is unset, +there's no committed `.did` at all — bindgen has only +`.mops/.build/.did`, which is private and unstable. + +**`mops generate candid `** fills that gap. It (re)generates +the curated `.did` from current source: + +- if `[canisters.].candid` is **set** → overwrite that file; +- if **unset** → write to a default path (`.did` next to + `main`) *and* set the field in `mops.toml`. + +The lifecycle is **interface-change-driven**, not deploy-driven. Refresh +when the interface changes; commit; bindgen and `mops build` pick up the +new file on the next read. + +## Non-goals + +- **No interface diffing / approval workflow.** Always overwrites. Hand + edits (doc comments, reordering) are lost — edit by hand when drift is + intentional. +- **No standalone drift command.** CI drift detection lives in + `mops check`, not here (see § "Interaction with `mops check`"). +- **No wasm metadata interaction.** Embedding is `mops build`'s job; + unchanged. +- **No on-chain reads.** Generated from local source, never fetched from + the deployed canister. +- **No deploy coupling.** Independent of `mops deployed`. + +## Command surface + +``` +mops generate candid [canisters...] # (re)generate curated .did from source +``` + +Flags: + +- `-o ` / `--output ` — one-off override (single-canister + only). Writes to `` and does **not** touch `mops.toml`. Use for + ad-hoc generation to a non-tracked location; ignore it for the normal + flow. Without this flag, the field-or-default logic above applies. + +Canister selection mirrors `mops build`: no argument → all `[canisters]` +entries; named → only those (unknown names error). + +## How the `.did` is generated + +Invokes `moc --idl` directly (no `.wasm` / `.most` / `ic-wasm` side +effects). The moc-argument logic — sources, packages, actor aliases, +canister `args`, migration flags — must be **extracted from `build.ts` +into a shared helper** (e.g. `buildMocArgs(canister)`) and called from +both code paths. DRY: a single source of truth for "how moc is invoked +for canister X" prevents the two flows from drifting (which would risk +`mops build` failing the subtype check on a freshly-regenerated `.did`). + +The `mops.toml` write (when needed) uses the same `writeConfig` / +`TOML.stringify` machinery as `mops deployed init`; see the TOML-rewrite +caveat there. + +## Destination path + +Priority order: + +1. `-o ` if given — write there; never updates `mops.toml`. If the + field is also set, the field-pointed file is left untouched (and now + stale relative to source — caller's responsibility). +2. `[canisters.].candid` if set — overwrite in place; no toml + update. +3. Default: `.did` in `dirname([canisters.].main)` — write + file *and* set the field. + +Any resolved path inside `.mops/` is rejected — see Edge cases. The +"next to `main`" default works for any layout: `main = src/Backend.mo` +→ `src/backend.did`, `main = backend/app.mo` → `backend/backend.did`, +`main = Main.mo` → `backend.did` at project root. + +## Interaction with `mops check` + +A committed `[canisters.].candid` can drift from the current Motoko +source (someone edited the source, forgot to regenerate). The natural +home for that drift check is **`mops check`**, not a sibling +`mops generate candid check` — mops already has one place for "is this +project healthy?" and a Candid drift gate fits alongside type-checking +and stable-compatibility. + +> **Follow-up: extend `mops check`.** If drift detection isn't already +> in `mops check`, add it: compare `[canisters.].candid` against +> what moc would auto-generate from current source; warn (or error +> under `--strict`) when they differ. Subsumes the standalone CI-gate +> role we'd otherwise need here. Out of scope for this spec; flagged so +> it's not forgotten. + +## Lifecycle + +``` +# initial setup (per canister), once +mops generate candid backend # writes /backend.did, sets [canisters.backend].candid + +# every interface change +# (edit Motoko source...) +mops generate candid backend # refresh from source +git add src/backend.did mops.toml + +# CI +mops check # also catches stale .did (see "Interaction with mops check") +``` + +`mops generate candid` is independent of `mops deployed`: + +| When | Command | +|---|---| +| Motoko interface changes | `mops generate candid ` (refresh curated `.did`) | +| Just deployed to chain | `mops deployed ` (snapshot `.most`) | +| First-time setup | `mops generate candid ` + `mops deployed init ` | + +## Edge cases + +- **No `main`** → error before invoking moc (same check as `mops build`). +- **Resolved destination inside `.mops/`** → reject, regardless of source + (`-o`, field, default). The point is a user-visible, committable path; + no exception even if the user mis-set the field. +- **`-o` collides with another canister's `candid`** → warn. Sharing a + `.did` path between canisters is almost always a mistake. +- **moc fails** → don't touch destination or `mops.toml`. Surface the + error verbatim. +- **`candid` set but file doesn't exist** → overwrite (create) at that + path. The field is source of truth for *where*, not *whether*. +- **Multi-canister + `-o`** → error. `-o` is single-canister only. + +## Out of scope + +- **`[candid].dir` config.** Global directory for `.did` files + (`did/.did`), analogous to `[deployed].dir`. Default + (next to `main`) keeps `.did` co-located with source, which is what + we want for now. +- **Preserving curated edits.** Users may hand-curate `.did` with doc + comments / method ordering moc doesn't preserve. v1 overwrites; a + future `--merge` mode could detect and warn. +- **Future `mops generate` members.** `mops generate most`, `mops + generate migration `, etc. share this namespace; each gets its + own spec when it lands and inherits the conventions (canister + selection, `-o`, error semantics). diff --git a/specs/mops-deployed.md b/specs/mops-deployed.md new file mode 100644 index 00000000..dc4c72d2 --- /dev/null +++ b/specs/mops-deployed.md @@ -0,0 +1,181 @@ +# Spec: `mops deployed` + +Status: Draft / proposal + +> Sibling to [`specs/mops-candid.md`](mops-candid.md). That spec handles +> `.did` (build-input contract); this one handles `.most` (post-deploy +> snapshot). + +## User story + +After a Motoko canister is deployed (today: via `icp deploy` or another +deployment tool), mops needs to know about it — there's bookkeeping to do +before the **next** dev cycle so the in-repo state still corresponds to +what's on chain. **`mops deployed [canisters...]` is the post-deploy hook** +the user — or the icp-cli `sync` step — calls to communicate that fact. + +In v1, the only bookkeeping is **promoting the just-built `.most` into a +committed reference path** so `mops check-stable` has the right baseline on +the next build. Future versions may also record a deploy manifest (wasm +hash, `moc` version, timestamp — see Open Questions), but the entry point +stays the same. The command is named for the situation it marks, not the +action — "I just deployed" is the story; the `.most` copy is the v1 mechanic. + +### Why `.most` is in scope + +`mops check-stable` compares the new code's stable signature against the +currently-deployed one. For the check to be meaningful, the on-disk +reference must advance exactly when a deploy succeeds — drift either +silently corrupts upgrade safety (false passes) or blocks valid upgrades +(false failures). Today it's maintained by hand; forget the copy and the +reference rots. + +### Why `.did` is *not* in scope + +`[canisters.].candid` is a curated **build-input contract**, not a +deploy artifact: `mops build` subtype-checks the auto-generated interface +against it and embeds the *curated* file into the wasm +(`cli/commands/build.ts:184-200`). The same file is what `@icp-sdk/bindgen` +reads for frontend bindings (see the `icp-cli` `hello-world` template, +where one committed `backend.did` is shared between recipe and frontend). + +Refreshing `.did` is interface-change-driven, not deploy-driven — and when +`candid` is set, the auto-generated `.did` mops would copy here differs +from the curated one actually embedded in the deployed wasm, so the +"snapshot" framing would be misleading. The `.did` lifecycle lives in +[`mops-candid.md`](mops-candid.md). + +## Non-goals + +- **No regeneration / compilation.** Copies the `.most` left by `mops + build`; errors if missing. Keeps the reference atomically tied to the + deployed wasm. +- **No reading from chain or wasm metadata.** Local file management only. +- **No `.did` handling** — see `mops-candid.md`. +- **No network/environment dimension** (Open Questions). + +## The deployed directory + +```toml +[deployed] +dir = "deployed" # optional; default "deployed" (relative to mops.toml) +``` + +`mops deployed` writes `/.most` per canister. Overridable per +invocation with `--dir `. All canisters share the one directory. + +### Synergy with `check-stable` (not coupling) + +`mops deployed` does not read `[canisters..check-stable].path`. +They're wired together by `init`, which sets that field to +`/.most` so the file `mops deployed` writes is exactly the +file `check-stable` reads. If the user later points the field elsewhere, +both `mops deployed` and `init` **warn** — the on-disk reference and the +configured baseline have diverged; `check-stable` won't see updates from +the hook. + +## Command surface + +``` +mops deployed [canisters...] # post-deploy hook: promote .most → +mops deployed init [canisters...] # baseline .most + [check-stable].path setup +``` + +Subcommands are resolved before positional args (Commander), so `init` +is effectively a reserved canister name in this command — `mops deployed +init` always means the subcommand. Not worth working around for v1; flag +in docs if it becomes a real conflict. + +### `mops deployed [canisters...]` + +For each selected canister, copy `/.most` → +`/.most`. + +- **Source** (``): `[build].outputDir ?? .mops/.build`, + overridable with `--output ` (mirrors `mops build --output`). +- **Destination** (``): `[deployed].dir ?? deployed`, overridable with + `--dir `. +- **Copy-or-error**: missing source errors (`No built .most at . Run + \`mops build \` first.`). Never regenerates. +- Creates the destination dir (`mkdir -p`); always overwrites. +- Warns when `[canisters..check-stable].path` differs from + `/.most`. + +### `mops deployed init [canisters...]` + +Pre-first-deploy bootstrap — separate subcommand because it also writes to +`mops.toml`. For each selected canister: + +1. If `/.most` does not exist, create it with an empty-actor + baseline (`// Version: 1.0.0\nactor { };`). +2. If `[canisters..check-stable].path` is unset, set it to + `/.most`. If already set elsewhere, leave it and **warn**. + +Idempotent: re-running is a no-op when both checks already hold. + +> **Caveat — TOML rewrite.** mops writes config via `writeConfig` → +> `TOML.stringify` (`cli/mops.ts:229`), which reserializes the whole file +> and drops comments / custom formatting. Already accepted behavior for +> `mops add` / `mops remove`; `init` must only write when config actually +> changes. + +> **Follow-up: refresh `mops init`.** `mops init` today predates +> `[canisters]` scaffolding — no canister entry, no `[check-stable]` +> wiring, no `[toolchain]` pin, still dfx-centric (fetches default packages +> keyed off `dfx --version`). A modern project setup should create a +> `[canisters.]` block and then call `mops deployed init` so the +> stable-check loop is wired out of the box. Refreshing `mops init` is +> out of scope for this spec; flagged here so it's not forgotten. + +## Canister selection + +Identical to `mops build` / `mops check`: no argument → all `[canisters]` +entries; named → only those (unknown names error). The icp-cli integration +always uses the named form — `icp` expands the recipe once per canister, so +the `sync` step runs per canister with its own name: + +```yaml +# recipes/motoko/recipe.hbs (sync phase) +sync: + steps: + - type: script + commands: + - mops deployed "{{ _.canister.name }}" --output .mops/.build +``` + +## Lifecycle + +``` +# once, before first deploy +mops deployed init backend # writes empty .most baseline + sets [check-stable].path + +# every change +mops check backend # new vs committed .most baseline +mops build backend # produces .mops/.build/backend.{wasm,did,most} +icp deploy # installs the wasm, then sync runs: + mops deployed backend # post-deploy hook: promotes .most into deployed/ +git add deployed/ && git commit +``` + +## Edge cases + +- **Migrations**: `mops build` injects `--enhanced-migration` when + `[canisters..migrations]` is set, so the emitted `.most` already + reflects it; the command copies as-is. +- **`--output` mismatch**: both `mops build` and `mops deployed` default to + `[build].outputDir ?? .mops/.build`; the recipe passes `--output` + explicitly to guarantee a match. +- **Stale/empty output dir**: copy-or-error, by design — prevents promoting + an artifact that doesn't correspond to a fresh build. + +## Open questions + +1. **Per-canister directory override.** Global `[deployed].dir` + `--dir` is + the v1 surface. A per-canister override could be added later if needed. +2. **Network/environment dimension.** `icp` environments can deploy + different versions to `staging` vs `ic`; the reference is single + (effectively "latest / prod"). A future per-environment directory may + be needed. +3. **Deployed manifest** (speculative). Record wasm hash / `moc` version / + timestamp per canister to make "what is deployed" auditable from the + repo without touching the chain.