Skip to content
Merged
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
173 changes: 173 additions & 0 deletions specs/mops-candid.md
Original file line number Diff line number Diff line change
@@ -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.<name>].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/<name>.did`, which is private and unstable.

**`mops generate candid <canister>`** fills that gap. It (re)generates
the curated `.did` from current source:

- if `[canisters.<name>].candid` is **set** → overwrite that file;
- if **unset** → write to a default path (`<name>.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 <path>` / `--output <path>` — one-off override (single-canister
only). Writes to `<path>` 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 <path>` 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.<name>].candid` if set — overwrite in place; no toml
update.
3. Default: `<name>.did` in `dirname([canisters.<name>].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.<name>].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.<name>].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 <dirname(main)>/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 <name>` (refresh curated `.did`) |
| Just deployed to chain | `mops deployed <name>` (snapshot `.most`) |
| First-time setup | `mops generate candid <name>` + `mops deployed init <name>` |

## 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/<name>.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 <name>`, etc. share this namespace; each gets its
own spec when it lands and inherits the conventions (canister
selection, `-o`, error semantics).
181 changes: 181 additions & 0 deletions specs/mops-deployed.md
Original file line number Diff line number Diff line change
@@ -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.<name>].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 `<dir>/<name>.most` per canister. Overridable per
invocation with `--dir <path>`. All canisters share the one directory.

### Synergy with `check-stable` (not coupling)

`mops deployed` does not read `[canisters.<name>.check-stable].path`.
They're wired together by `init`, which sets that field to
`<dir>/<name>.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 → <dir>
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 `<outputDir>/<name>.most` →
`<dir>/<name>.most`.

- **Source** (`<outputDir>`): `[build].outputDir ?? .mops/.build`,
overridable with `--output <dir>` (mirrors `mops build --output`).
- **Destination** (`<dir>`): `[deployed].dir ?? deployed`, overridable with
`--dir <path>`.
- **Copy-or-error**: missing source errors (`No built .most at <path>. Run
\`mops build <name>\` first.`). Never regenerates.
- Creates the destination dir (`mkdir -p`); always overwrites.
- Warns when `[canisters.<name>.check-stable].path` differs from
`<dir>/<name>.most`.

### `mops deployed init [canisters...]`

Pre-first-deploy bootstrap — separate subcommand because it also writes to
`mops.toml`. For each selected canister:

1. If `<dir>/<name>.most` does not exist, create it with an empty-actor
baseline (`// Version: 1.0.0\nactor { };`).
2. If `[canisters.<name>.check-stable].path` is unset, set it to
`<dir>/<name>.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.<name>]` 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.<name>.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.
Loading