From f1c06075da6cd13a95e7f8181f5fa6a4da0dff95 Mon Sep 17 00:00:00 2001 From: Mathieu2301 <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:30:11 +0000 Subject: [PATCH 1/4] docs: write the guide an agent needs to build a home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolchain shipped before the document that explains it. An agent handed someone's house could read the SDK reference and the template, but nothing stated the judgment neither of them carries: that the coordinator is the only place authorization happens, that component requirements and coordinator grants intersect silently so a mismatch renders a hole instead of raising, and that a call whose outcome is unknown must never be retried because the lamp may already have moved. Everything here is taken from the code it describes: the UI vocabulary and limits from packages/component, the command surface and the exit-code table from packages/cli, the ordering and authorization rules from templates/home. The two CI lessons are the ones this repository paid for on #10 — a check that exists is not a check that runs, and a local run under the wrong Bun proves nothing about the runner. Closes the second deliverable of workstream F. --- README.md | 6 + docs/agent-guide.md | 326 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 docs/agent-guide.md diff --git a/README.md b/README.md index 8611a8f..dbf8085 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,12 @@ without bundling server-runtime or coordinator-only dependencies. Version 4 is a complete replacement for the legacy callback-based MiakAPI 3 client. It is currently an alpha while the Miakapp 4 stack is being completed. +**Building a home with a coding agent?** Read +[docs/agent-guide.md](docs/agent-guide.md) first. It covers the judgment the +reference below does not: what the coordinator must authorize, why component +requirements and coordinator grants intersect silently, and which failures must +never be retried. Start from [`templates/home`](templates/home). + ## Coordinator requirements - Bun 1.2.23 or newer (primary coordinator runtime) diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..5b1ff93 --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,326 @@ +# Building a Miakapp home + +This guide is written for a coding agent — Claude Code, Codex, or any successor — +that has been handed someone's house and asked to make it work. You are expected +to read the existing installation, write a coordinator, write an interface, test +both, and publish. The owner is not a home-automation developer and should not +have to become one. + +Everything below is true of the code in this repository. Where a rule exists for +a reason that is not obvious, the reason is given, because a rule whose purpose +you cannot see is one you will optimize away. + +## 1. What you are building + +Three artifacts, and no more: + +| Artifact | Where it runs | What it owns | +| --- | --- | --- | +| Coordinator | the owner's machine, under Bun | state, events, functions, **authorization** | +| Component | a sandboxed Worker in the household's browser | the interface | +| `miakapp.yaml` | neither; it is the contract | what the component may ask for | + +The coordinator is trusted. The component is not. The relay between them is +platform-untrusted but not blind: it terminates TLS, stores plaintext state and +enforces routing, so self-hosting it does not give end-to-end confidentiality. +Do not tell the owner otherwise. + +Start from `templates/home`. It is a complete working home — one lamp, one +temperature — with the three files already in the right relationship, and its +own `README.md` covering the mechanics of copying it out. This guide covers the +judgment the template cannot. + +## 2. The division of responsibility + +**The coordinator authorizes everything.** The relay proves *who* is calling and +attaches non-spoofable caller metadata. Deciding whether that person *may* act is +the coordinator's job and nobody else's. In the template that decision is the +first line of the function body: + +```ts +if (call.source.kind !== 'user' || call.source.id !== options.ownerUserId) { + throw new ApplicationCallError(2003, 'Only the owner may drive the lights'); +} +``` + +Removing that check does not produce an error anywhere. It produces a home that +anyone enrolled can drive. There is no second layer that will catch it for you. + +**The component trusts nothing it was not given.** It has no network, no storage +and no DOM. It cannot reach the lamp except through a call the coordinator +declared, and it cannot read a state path the coordinator did not grant. This is +why it is safe to let a component be rewritten often and reviewed lightly, and +why it is not safe to move a decision into it. + +**You own the UI; the coordinator owns the facts.** The coordinator exposes state +and actions. What the household actually sees — the layout, the wording, the +language, which controls are prominent — is yours to design from the semantic +vocabulary in §5. Do not push presentation choices into the coordinator, and do +not push authorization into the component. + +## 3. Before you write anything: characterize the house + +You are almost never starting from an empty building. Read what is already there +before you design anything: + +- existing hubs and brokers — Node-RED, MQTT, Zigbee/Z-Wave coordinators, an + HTTP-speaking hub; +- what each device actually reports, and how often; +- which values are *measurements* (temperature, power) and which are + *commanded* (a lamp, a valve) — they have different failure modes; +- which actions are physically consequential: anything that heats, locks, + unlocks, opens or closes. + +Write down what you found before you write the configuration. The state paths you +choose become a disclosure boundary and a public interface at the same time, and +renaming one after the household has used it is not free. + +## 4. The coordinator + +`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +**pure function of its options**, so it can be tested without a relay, a control +plane or a network. `coordinator/main.ts` is the only file that touches the +outside world. Keep that split. It is what makes `test/home.test.ts` possible, +and the authorization rules are exactly the thing you want under test. + +Four declarations: + +```ts +{ + state: { 'zone.salon.light.on': false }, // paths and initial values + stateAccess: [{ userId, patterns: ['zone.salon.*'] }], + events: [{ topic, directions: EventDirection.publishToUsers }], + eventAccess: [{ userId, publish: [], subscribe: [topic] }], + functions: { async 'lighting.set'(call) { /* authorize, act, return */ } }, +} +``` + +`stateAccess[].patterns` is the disclosure boundary. A user sees exactly those +paths and nothing else. Widen it deliberately, one path at a time, and never with +a wildcard that happens to be convenient. + +Three ordering and failure rules that are easy to get wrong: + +**State first, event second.** Write the state, *then* publish the event: + +```ts +await coordinator.state.set([{ path: STATE.lightOn, value: on }]); +await coordinator.events.publish(EVENT_LIGHT_CHANGED, { on }); +``` + +A subscriber that reacts to the event and immediately reads the state must never +see the old value. The reverse order is a race that will reproduce once a month +and waste a day. + +**Reject bad arguments with a typed application error.** `ApplicationCallError` +carries a numeric code the component can branch on. Do not throw a bare `Error` +for a caller mistake; the distinction between "you asked wrongly" and "something +broke" is the one the interface needs most. + +**A home has several coordinators.** Names are namespace sharding by convention. +The relay keeps ownership tables for topics, state paths and functions, detects +collisions and rejects with `4409`. If you claim `lighting.set` for the whole +house, you have taken a name another integration may need; scope what you own. + +## 5. The component + +Import from `@miakapp/component`. The whole public surface is one module, and the +whole rendering vocabulary is `ui.*`: + +``` +screen stack grid section layout +text status progress media output +button toggle input select interaction +``` + +You return one complete semantic tree per render; the trusted host draws it with +its own components. You do not ship CSS, you do not ship a DOM, and you cannot +style your way around the host. This is a constraint worth accepting rather than +fighting: it is what lets the host stay accessible, themed and localized without +auditing your code. + +Handlers are functions, not identifiers to wire up by hand: + +```ts +ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: asBoolean(home.state.get(LIGHT_ON)), + disabled: home.staging || !healthy, + pending, + onChange: (next) => void setLight(next), +}) +``` + +Handlers are registered for the render that created them, so a stale tree cannot +fire an action against new state. + +The limits are real and enforced: 1 024 nodes, depth 32, 30 renders per second, +32 outstanding calls, 8 KiB per text node. If you are approaching any of them you +are building a dashboard the household will not read. + +### Two states the interface must never hide + +**Staleness.** `home.state.stale` means the snapshot may no longer reflect the +house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat +reading that is silently forty minutes old is worse than one labelled uncertain. + +**Staging.** `home.staging` is true while a release is staged: rendering works, +calls and events do not. Disable the controls and say why, as the template does. +An interface that looks live and silently does nothing is the worst outcome +available. + +### An unknown outcome is never retried + +This is the rule that will most tempt you to break it, so it is stated plainly: + +```ts +try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); +} catch (error) { + // Deliberately not retried. The call may already have reached the lamp, + // and the next state snapshot settles the question. + failure = error instanceof Error ? error.message : 'La commande a échoué'; +} +``` + +A failed call is not a call that did not happen. `CallOutcomeUnknownError` exists +precisely to name the case where the effect is undetermined, and the physical +world does not have a rollback. Surface it, let the next state snapshot settle +it, and let the person decide. The same principle has a CLI counterpart: exit +code 7. + +## 6. The intersection rule + +Every name a component may touch appears in **two** places, and the effective +grant is the intersection: + +| `miakapp.yaml` → `requires` | `coordinator/home.ts` | +| --- | --- | +| `state_read` | `stateAccess[].patterns` | +| `event_subscribe` | `eventAccess[].subscribe` | +| `event_publish` | `eventAccess[].publish` | +| `call` | `functions` | + +Asking for more than the coordinator grants **does not fail loudly at +publication**. The component simply never receives that path, and the interface +renders a hole — an empty card, a control that does nothing, a temperature that +is permanently unavailable. This is the single most common way a Miakapp home +breaks, and it breaks quietly. + +So: assert the correspondence in a test. `templates/home/test/home.test.ts` does +exactly this, and it is the reason a mismatch fails in CI rather than in +someone's living room. When you add a path, change four things in one commit — +the state declaration, the access pattern, the `requires` entry, and the test. + +## 7. The loop + +```bash +bun run check # typecheck → bundle → test → validate the artifact offline +``` + +Run it before every publication and in CI. It costs nothing and catches the +artifact rules the runtime would reject anyway. + +Do not treat a check that exists as a check that runs. On 2026-09-14 this +repository had a template and a component example that were both verified +locally and built by nobody, because the workflow called `bun run check` and not +`check:packages`; when CI finally exercised them they failed three times in a +row on defects that had been sitting there invisibly. If you add a package, add +it to the job that runs in CI, then watch one run go green before believing it. + +Reproduce CI with the toolchain version it pins, not the one you have. Bun 1.4 +self-references the root package and resolves `bunx` from `node_modules`; the +pinned 1.2.23 does neither, so a green local run proves nothing about the runner. +Install the pinned version alongside yours: + +```bash +BUN_INSTALL=/tmp/bun1223 curl -fsSL https://bun.sh/install | bash -s bun-v1.2.23 +``` + +Note also that `bun test foo/` is a **substring filter**, not a directory scope. + +## 8. Publishing + +The CLI builds, validates, publishes and rolls back. **It never owns Git.** It +writes no history, rewrites no source it did not generate, and invents no +control-plane endpoint. The repository is the owner's. + +```bash +miakapp init --home --control-plane +miakapp check +miakapp publish --expected-generation +miakapp activate --sha256 --expected-generation +miakapp rollback --sha256 --expected-generation # alias of activate +miakapp release # read one finalized release +miakapp upload # reconcile a lost request +``` + +`--expected-generation` is a compare-and-swap on the home's component pointer: the +generation you believe it currently holds. It is `0` for a home that has never +published. It is required, and it is what stops two agents from silently +overwriting each other. + +Rollback is `activate` pointed at a digest you already trust. Keep the digest of +every release you shipped; a rollback you can perform in one command is worth +more than an incident you can explain. + +### Driving the CLI as a program + +Every failure maps to exactly one stable exit code and one stable +machine-readable kind. New kinds may be added; existing codes never change +meaning. Pass `--json` and you get exactly one closed object on stdout. + +| Code | Kind | Meaning | +| --- | --- | --- | +| 0 | `success` | | +| 1 | `usage` | the invocation was wrong | +| 2 | `project` | `miakapp.yaml` is missing or invalid | +| 3 | `artifact` | the built bytes violate an artifact rule | +| 4 | `authorization` | the Home Key is missing, wrong or unscoped | +| 5 | `contract` | the control plane rejected the request | +| 6 | `conflict` | `--expected-generation` did not match; re-read, do not retry | +| 7 | `unknown_outcome` | **effect undetermined — reconcile with a read** | + +Branch on the code, not on the prose. On 7, call `miakapp upload ` or +`miakapp release ` and reconcile before acting again. Never retry a 7 +with a fresh capability. + +## 9. Secrets + +`MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line +argument, never printed, never written into a project file. No command accepts +one, deliberately: an argument lands in shell history, in a process listing and +in most CI logs. + +```bash +export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +``` + +If you are about to write a secret into `miakapp.yaml` so something works, stop: +that is the failure this design exists to prevent. + +## 10. Before you tell the owner you are done + +- [ ] Every physically consequential function authorizes its caller on its first + line, and a test proves an unauthorized caller is refused. +- [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test + asserts the correspondence. +- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] No call path retries an unknown outcome. +- [ ] State is written before its event is published. +- [ ] `bun run check` is green, and CI ran it — not just you. +- [ ] The Home Key exists only in the environment. +- [ ] You recorded the digest of what you published, so rollback is one command. + +## Where to read further + +- `templates/home/README.md` — the mechanics of the template itself. +- `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. +- `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is + the authority for the guest ABI, not the RFC**: `component-runtime/src/runtime-broker.ts` + and `contract.ts` define the exact payloads, and the broker terminates the + instance rather than answering when a field is wrong. +- `docs/rfcs/0003` — the coordinator SDK surface. +- `docs/rfcs/0004` — Home Key bootstrap, scopes, publication. +- `docs/rfcs/0005` — the trusted browser client. From ace2b8823ca84e08e9cd000172011cc3c8ec8429 Mon Sep 17 00:00:00 2001 From: Mathieu2301 <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:13:27 +0000 Subject: [PATCH 2/4] feat: let an agent read the house before it designs one The guide tells an agent to characterize an existing installation before writing anything, and then leaves it to do that by hand. Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes, and their flows export already answers most of the questions: which brokers exist, which topics the devices actually use, which variables were committed, which actions users could invoke and who was allowed to invoke them. `miakapp discover --flows ` reads that export offline and turns it into an inventory. It needs no project file and no Home Key, because the house it reads has no V4 project yet. Three things it will not do. It never opens a socket or writes back into the export. It never drops a node silently: every type it does not model is counted and reported, so the reader knows what the inventory missed. And it never guesses which actions are physically consequential -- it lists them all and leaves that judgement to the reader. Four findings decide work that is otherwise discovered late: - the v3 initMiakapi node declares coordSecret in its defaults rather than its credentials, so Node-RED stores that secret in cleartext in flows.json instead of the encrypted flows_cred.json. If it is there, it is leaked. - the v3 handler allows an action outright when its node lists no group, so an empty allowedGroups is a grant to every signed-in user, not a deny. - a v3 name that V4 would reject has to be renamed while nobody depends on it. - a topic holding # or + is a subscription pattern, not one device. Field names come from the two real schemas: Node-RED core mqtt in, mqtt out and mqtt-broker, and the node-red-contrib-MiakAPI v3 node definitions. No real export was available to test against, so the parser recognizes nodes by type and reads only fields it knows. --- docs/agent-guide.md | 39 ++ packages/cli/README.md | 15 + packages/cli/src/discovery.ts | 532 ++++++++++++++++++++++++++++ packages/cli/src/main.ts | 78 ++++ packages/cli/test/discovery.test.ts | 192 ++++++++++ packages/cli/test/support/flows.ts | 74 ++++ 6 files changed, 930 insertions(+) create mode 100644 packages/cli/src/discovery.ts create mode 100644 packages/cli/test/discovery.test.ts create mode 100644 packages/cli/test/support/flows.ts diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 5b1ff93..f247720 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -75,6 +75,45 @@ Write down what you found before you write the configuration. The state paths yo choose become a disclosure boundary and a public interface at the same time, and renaming one after the household has used it is not free. +### Reading a V3 house you inherited + +Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes. Ask +the owner for the `flows.json` Node-RED writes, or for an *Export > All flows* +download, and read it before you read anything else: + +``` +miakapp discover --flows ~/node-red/flows.json +miakapp discover --flows ~/node-red/flows.json --json +``` + +The command is offline and read-only: it opens no socket, contacts no broker and +never writes back into the export. It reports the tabs, the MQTT brokers with the +topics their nodes actually reach, the `initMiakapi` home bindings, every +`commitVariables` path as a state candidate, every `onUserAction` id as a +function candidate with the groups allowed to invoke it, and every node type it +does not model — so you know what the inventory missed rather than assuming it +missed nothing. + +Four of its findings decide work you would otherwise discover late: + +- **`secret_in_export`.** The v3 `initMiakapi` node declares `coordSecret` in its + `defaults`, not in its `credentials`, so Node-RED stores that secret in + cleartext in `flows.json` rather than in the encrypted `flows_cred.json`. If + the export has one, treat it as leaked: rotate it, and keep the file out of + Git. §9 is the V4 rule that replaces it. +- **`unrestricted_action`.** The v3 handler allows an action outright when its + node lists no group, so an empty `allowedGroups` is a grant to every signed-in + user, not a deny. Each one needs a deliberate V4 rule before you port it. +- **`name_needs_rename`.** A v3 variable path or action id that is not a legal V4 + dotted name has to be renamed now, while nobody depends on it. +- **`wildcard_subscription`.** A topic holding `#` or `+` is a subscription + pattern, not one device. Enumerate what it actually matches. + +The command deliberately does not tell you which actions are physically +consequential. It lists every action it found; deciding which of them heats, +locks, unlocks, opens or closes is a judgement you make with the owner, and no +keyword list should make it for you. + ## 4. The coordinator `templates/home/coordinator/home.ts` is the shape to copy: the configuration is a diff --git a/packages/cli/README.md b/packages/cli/README.md index aefa23e..b96c1fc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | Command | What it does | | --- | --- | | `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `discover` | Offline. Inventories a Node-RED installation from its flows export. | | `check` | Offline. Parses the project, verifies the artifact, prints the digest. | | `publish` | Capability → delivery → finalization → activation, in one run. | | `activate` | Activates an already finalized digest at a new generation. | @@ -68,6 +69,20 @@ nothing, touches no network and catches the four artifact rules the broker's pinned parser would reject anyway: module syntax, dynamic `import`, a source-map directive and the ABI 1 token ceiling. +`discover` is the command to run *before* `init`, on a house that already exists: + +``` +miakapp discover --flows ~/node-red/flows.json --json +``` + +It needs no project file and no Home Key. It reads the bytes it was given — +opening no socket, contacting no broker, writing nothing back — and reports the +flows, the MQTT brokers with the topics their nodes actually reach, the v3 +MiakAPI surface as V4 state and function candidates, and every node type it does +not model, so the reader knows what the inventory missed. It reports that a +coordinator secret is present in the export; it never prints the secret itself. +`docs/agent-guide.md` §3 explains what to do with each finding. + ## Authorization The Home Key is read from `MIAKAPP_HOME_KEY` and from nowhere else. No command diff --git a/packages/cli/src/discovery.ts b/packages/cli/src/discovery.ts new file mode 100644 index 0000000..6b3c5ed --- /dev/null +++ b/packages/cli/src/discovery.ts @@ -0,0 +1,532 @@ +/** + * Reading an installation that already exists. + * + * `docs/agent-guide.md` §3 tells an agent to characterize the house before + * designing anything. This module is the part of that work a program can do: + * it turns a Node-RED `flows.json` export into an inventory of brokers, flows, + * topics and the V3 MiakAPI surface, and it reports which V3 names are already + * legal V4 names. + * + * Three properties keep it honest: + * + * - **Offline and read-only.** It parses bytes handed to it. It opens no + * socket, contacts no broker and writes nothing back into the export. + * - **It never drops a node silently.** Every unrecognized `type` is counted + * and reported, because the value of an inventory is knowing what it missed. + * - **It never guesses semantics.** It reports what a node declares. Which + * actions are physically consequential is a judgement the reader makes from + * the listed surface; no keyword list decides it here. + * + * Field names come from the two schemas involved: Node-RED core `mqtt in`, + * `mqtt out` and `mqtt-broker` node definitions, and the `node-red-contrib- + * MiakAPI` v3 node definitions in `miakapi.html`. + */ +import { projectError } from './errors.js'; +import { isDottedName, utf8Bytes } from './internal/names.js'; + +/** + * A generous ceiling for a local export. The strict parser in `internal/json.ts` + * is bounded for untrusted control-plane responses at 2,048 values, which a real + * house blows through in the first tab; a flows export is an operator-supplied + * local file, so the bound here is on bytes rather than on structure. + */ +export const MAXIMUM_FLOWS_BYTES = 33_554_432; + +/** Keys that would poison a prototype if a record were ever spread. */ +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + +const MIAKAPI_V3_TYPES = new Set([ + 'initMiakapi', + 'getHomeUsers', + 'commitVariables', + 'onHomeReady', + 'onHomeUpdate', + 'onUserLogin', + 'onUserAction', + 'sendPushNotif', + 'reconnectMiakapi', +]); + +export type FindingKind = + /** A coordinator secret sits in cleartext in the export. */ + | 'secret_in_export' + /** An action any signed-in user may invoke, because no group was listed. */ + | 'unrestricted_action' + /** A V3 name that is not a legal V4 dotted name and has to be renamed. */ + | 'name_needs_rename' + /** A subscription pattern rather than one device's topic. */ + | 'wildcard_subscription' + /** A broker reached without TLS. */ + | 'broker_without_tls' + /** A node type this inventory does not model. */ + | 'unmodelled_node'; + +export type FindingSeverity = 'critical' | 'attention' | 'note'; + +export interface Finding { + readonly kind: FindingKind; + readonly severity: FindingSeverity; + readonly detail: string; + readonly nodeId: string | undefined; +} + +export interface Broker { + readonly id: string; + readonly name: string; + readonly host: string; + readonly port: number | undefined; + readonly tls: boolean; + readonly subscribes: readonly string[]; + readonly publishes: readonly string[]; +} + +export interface FlowTab { + readonly id: string; + readonly label: string; + readonly disabled: boolean; + readonly nodeCount: number; +} + +export interface HomeBinding { + readonly nodeId: string; + readonly homeId: string; + readonly coordinatorId: string; + readonly secretInExport: boolean; +} + +/** One `commitVariables` entry: a V3 variable path and where its value came from. */ +export interface StateCandidate { + readonly path: string; + readonly source: 'jsonata' | 'env' | 'literal'; + readonly nodeId: string; + readonly legalV4Name: boolean; +} + +/** One `onUserAction` handler: the V3 shape of what becomes a V4 function. */ +export interface ActionCandidate { + readonly inputId: string; + readonly allowedGroups: readonly string[]; + readonly nodeId: string; + readonly legalV4Name: boolean; +} + +/** One `sendPushNotif` node: the V3 shape of what becomes a V4 published event. */ +export interface NotificationCandidate { + readonly nodeId: string; + readonly name: string; + readonly adminOnly: boolean; + readonly group: string; +} + +export interface Inventory { + readonly nodeCount: number; + readonly flows: readonly FlowTab[]; + readonly brokers: readonly Broker[]; + readonly homes: readonly HomeBinding[]; + readonly state: readonly StateCandidate[]; + readonly actions: readonly ActionCandidate[]; + readonly notifications: readonly NotificationCandidate[]; + /** Every type this module does not model, with how many nodes carry it. */ + readonly unmodelled: readonly { readonly type: string; readonly count: number }[]; + readonly findings: readonly Finding[]; +} + +type Record_ = Readonly>; + +function field(node: Record_, key: string): unknown { + return Object.hasOwn(node, key) ? node[key] : undefined; +} + +function text(node: Record_, key: string): string { + const value = field(node, key); + return typeof value === 'string' ? value : ''; +} + +function flag(node: Record_, key: string): boolean { + return field(node, key) === true; +} + +/** Node-RED writes `port` as either a number or a numeric string. */ +function port(node: Record_): number | undefined { + const value = field(node, 'port'); + if (typeof value === 'number' && Number.isSafeInteger(value)) return value; + if (typeof value === 'string' && /^[0-9]{1,5}$/.test(value)) return Number(value); + return undefined; +} + +function finding( + kind: FindingKind, + severity: FindingSeverity, + detail: string, + nodeId?: string, +): Finding { + return Object.freeze({ kind, severity, detail, nodeId }); +} + +/** + * Parses the export. + * + * A flows export is a flat array of node records; tabs, config nodes and wired + * nodes all sit at the same level and refer to each other by `id`. + */ +function parseFlows(source: Uint8Array): readonly Record_[] { + if (source.byteLength > MAXIMUM_FLOWS_BYTES) { + throw projectError( + `The flows export is larger than ${MAXIMUM_FLOWS_BYTES} bytes`, + 'Export one Node-RED instance at a time.', + ); + } + let decoded: string; + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(source); + } catch { + throw projectError('The flows export is not readable as UTF-8 text'); + } + let parsed: unknown; + try { + parsed = JSON.parse(decoded) as unknown; + } catch { + throw projectError( + 'The flows export is not valid JSON', + 'Use the file Node-RED writes, or the Export > All flows download, not a screenshot of it.', + ); + } + if (!Array.isArray(parsed)) { + throw projectError( + 'The flows export is not a JSON array of nodes', + 'A Node-RED export is a flat array; an object here is usually a single copied node.', + ); + } + const nodes: Record_[] = []; + for (const entry of parsed) { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue; + // Values are only ever read through `Object.hasOwn`, never spread, so a + // poisoned key cannot reach a prototype; it is dropped here regardless. + if (Reflect.ownKeys(entry).some((key) => FORBIDDEN_KEYS.has(String(key)))) continue; + if (typeof (entry as Record_)['type'] !== 'string') continue; + nodes.push(entry as Record_); + } + return nodes; +} + +function collectTabs(nodes: readonly Record_[]): readonly FlowTab[] { + const counts = new Map(); + for (const node of nodes) { + const parent = text(node, 'z'); + if (parent !== '') counts.set(parent, (counts.get(parent) ?? 0) + 1); + } + return nodes + .filter((node) => node['type'] === 'tab') + .map((node) => { + const id = text(node, 'id'); + return Object.freeze({ + id, + label: text(node, 'label'), + disabled: flag(node, 'disabled'), + nodeCount: counts.get(id) ?? 0, + }); + }); +} + +function collectBrokers(nodes: readonly Record_[], findings: Finding[]): readonly Broker[] { + const subscribes = new Map>(); + const publishes = new Map>(); + + for (const node of nodes) { + const type = node['type']; + if (type !== 'mqtt in' && type !== 'mqtt out') continue; + const broker = text(node, 'broker'); + const topic = text(node, 'topic'); + if (broker === '') continue; + if (topic === '') { + // `mqtt in` with `topicType: dynamic` takes its topic from a message, so + // the export cannot say which devices it will reach. + findings.push(finding( + 'unmodelled_node', + 'attention', + `${String(type)} node has no static topic; its subscription is set at runtime`, + text(node, 'id'), + )); + continue; + } + const into = type === 'mqtt in' ? subscribes : publishes; + const set = into.get(broker) ?? new Set(); + set.add(topic); + into.set(broker, set); + if (type === 'mqtt in' && (topic.includes('#') || topic.includes('+'))) { + findings.push(finding( + 'wildcard_subscription', + 'note', + `Subscription ${topic} is a pattern, not one device; enumerate what it actually matches`, + text(node, 'id'), + )); + } + } + + return nodes + .filter((node) => node['type'] === 'mqtt-broker') + .map((node) => { + const id = text(node, 'id'); + const host = text(node, 'broker'); + const tls = flag(node, 'usetls'); + if (!tls) { + findings.push(finding( + 'broker_without_tls', + 'attention', + `Broker ${host === '' ? id : host} is configured without TLS`, + id, + )); + } + return Object.freeze({ + id, + name: text(node, 'name'), + host, + port: port(node), + tls, + subscribes: [...subscribes.get(id) ?? []].sort(), + publishes: [...publishes.get(id) ?? []].sort(), + }); + }); +} + +function collectHomes(nodes: readonly Record_[], findings: Finding[]): readonly HomeBinding[] { + return nodes + .filter((node) => node['type'] === 'initMiakapi') + .map((node) => { + const nodeId = text(node, 'id'); + // `coordSecret` is declared in the node's `defaults`, not in its + // `credentials`, so Node-RED stores it in `flows.json` itself rather than + // in the encrypted `flows_cred.json`. + const secretInExport = text(node, 'coordSecret') !== ''; + if (secretInExport) { + findings.push(finding( + 'secret_in_export', + 'critical', + 'A coordinator secret is stored in cleartext in this export; treat it as leaked, ' + + 'rotate it, and keep the export out of Git', + nodeId, + )); + } + return Object.freeze({ + nodeId, + homeId: text(node, 'home'), + coordinatorId: text(node, 'coordID'), + secretInExport, + }); + }); +} + +/** + * Why a V3 name is not a legal V4 dotted name, in the words of the rule it + * breaks. `isDottedName` answers yes or no; a reader who has to rename a path + * needs to know which constraint bit them. + */ +function illegalNameReason(value: string): string { + if (value === '') return 'it is empty'; + if (value.includes('*')) return 'it contains *, which V4 reserves for the trailing .* suffix'; + if (/\p{Cc}/u.test(value)) return 'it contains a control character'; + if (value.split('.').some((segment) => segment === '')) { + return 'it has an empty dotted segment'; + } + return `it is ${utf8Bytes(value)} UTF-8 bytes, outside the 1..256 range`; +} + +function variableSource(type: unknown): 'jsonata' | 'env' | 'literal' { + if (type === 'jsonata') return 'jsonata'; + if (type === 'env') return 'env'; + return 'literal'; +} + +function collectState(nodes: readonly Record_[], findings: Finding[]): readonly StateCandidate[] { + const candidates: StateCandidate[] = []; + for (const node of nodes) { + if (node['type'] !== 'commitVariables') continue; + const values = field(node, 'values'); + if (values === null || typeof values !== 'object' || Array.isArray(values)) continue; + const nodeId = text(node, 'id'); + for (const path of Object.keys(values)) { + if (FORBIDDEN_KEYS.has(path)) continue; + const entry = (values as Record_)[path]; + const type = entry !== null && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as Record_)['type'] + : undefined; + const legalV4Name = isDottedName(path); + if (!legalV4Name) { + findings.push(finding( + 'name_needs_rename', + 'attention', + `Variable path ${path} is not a legal V4 state path: ${illegalNameReason(path)}; ` + + 'rename it before the household depends on it', + nodeId, + )); + } + candidates.push(Object.freeze({ + path, + source: variableSource(type), + nodeId, + legalV4Name, + })); + } + } + return candidates.sort((left, right) => (left.path < right.path ? -1 : 1)); +} + +function collectActions(nodes: readonly Record_[], findings: Finding[]): readonly ActionCandidate[] { + const candidates: ActionCandidate[] = []; + for (const node of nodes) { + if (node['type'] !== 'onUserAction') continue; + const nodeId = text(node, 'id'); + const inputId = text(node, 'inputID'); + const raw = field(node, 'allowedGroups'); + const allowedGroups = Array.isArray(raw) + ? raw.filter((group): group is string => typeof group === 'string') + : []; + // The v3 handler allows the action outright when no group is listed, so an + // empty list is a grant to every signed-in user, not a deny. + if (allowedGroups.length === 0) { + findings.push(finding( + 'unrestricted_action', + 'critical', + `Action ${inputId === '' ? nodeId : inputId} lists no group, so every signed-in user ` + + 'may invoke it; V4 needs an explicit rule for it', + nodeId, + )); + } + const legalV4Name = isDottedName(inputId); + if (!legalV4Name) { + findings.push(finding( + 'name_needs_rename', + 'attention', + `Action id ${inputId === '' ? '(empty)' : inputId} is not a legal V4 function name: ` + + illegalNameReason(inputId), + nodeId, + )); + } + candidates.push(Object.freeze({ inputId, allowedGroups, nodeId, legalV4Name })); + } + return candidates.sort((left, right) => (left.inputId < right.inputId ? -1 : 1)); +} + +function collectNotifications(nodes: readonly Record_[]): readonly NotificationCandidate[] { + return nodes + .filter((node) => node['type'] === 'sendPushNotif') + .map((node) => Object.freeze({ + nodeId: text(node, 'id'), + name: text(node, 'name'), + adminOnly: flag(node, 'adminOnly'), + group: text(node, 'group'), + })); +} + +function collectUnmodelled( + nodes: readonly Record_[], + findings: Finding[], +): readonly { readonly type: string; readonly count: number }[] { + const modelled = new Set(['tab', 'mqtt in', 'mqtt out', 'mqtt-broker', ...MIAKAPI_V3_TYPES]); + const counts = new Map(); + for (const node of nodes) { + const type = node['type'] as string; + if (modelled.has(type)) continue; + counts.set(type, (counts.get(type) ?? 0) + 1); + } + const unmodelled = [...counts] + .map(([type, count]) => Object.freeze({ type, count })) + .sort((left, right) => right.count - left.count || (left.type < right.type ? -1 : 1)); + if (unmodelled.length > 0) { + findings.push(finding( + 'unmodelled_node', + 'note', + `${unmodelled.length} node type(s) are not modelled by this inventory; ` + + 'read them yourself before assuming the house is fully described', + )); + } + return unmodelled; +} + +/** + * Builds the inventory for one Node-RED export. + * + * The result is a pure function of the bytes: the same export always produces + * the same report, which is what makes it usable as a migration baseline that + * can be diffed between two runs. + */ +export function discoverFlows(source: Uint8Array): Inventory { + const nodes = parseFlows(source); + const findings: Finding[] = []; + const flows = collectTabs(nodes); + const brokers = collectBrokers(nodes, findings); + const homes = collectHomes(nodes, findings); + const state = collectState(nodes, findings); + const actions = collectActions(nodes, findings); + const notifications = collectNotifications(nodes); + const unmodelled = collectUnmodelled(nodes, findings); + const order: Record = { critical: 0, attention: 1, note: 2 }; + return Object.freeze({ + nodeCount: nodes.length, + flows, + brokers, + homes, + state, + actions, + notifications, + unmodelled, + findings: findings.sort((left, right) => order[left.severity] - order[right.severity]), + }); +} + +/** The JSON body of `miakapp discover --json`, with no secret value in it. */ +export function inventoryJson(inventory: Inventory): Record { + return { + node_count: inventory.nodeCount, + flows: inventory.flows.map((tab) => ({ + id: tab.id, + label: tab.label, + disabled: tab.disabled, + node_count: tab.nodeCount, + })), + brokers: inventory.brokers.map((broker) => ({ + id: broker.id, + name: broker.name, + host: broker.host, + ...(broker.port === undefined ? {} : { port: broker.port }), + tls: broker.tls, + subscribes: broker.subscribes, + publishes: broker.publishes, + })), + homes: inventory.homes.map((home) => ({ + node_id: home.nodeId, + home_id: home.homeId, + coordinator_id: home.coordinatorId, + // The flag says a secret is present. The secret itself is never read out. + secret_in_export: home.secretInExport, + })), + state: inventory.state.map((candidate) => ({ + path: candidate.path, + source: candidate.source, + node_id: candidate.nodeId, + legal_v4_name: candidate.legalV4Name, + })), + actions: inventory.actions.map((candidate) => ({ + input_id: candidate.inputId, + allowed_groups: candidate.allowedGroups, + node_id: candidate.nodeId, + legal_v4_name: candidate.legalV4Name, + })), + notifications: inventory.notifications.map((candidate) => ({ + node_id: candidate.nodeId, + name: candidate.name, + admin_only: candidate.adminOnly, + group: candidate.group, + })), + unmodelled: inventory.unmodelled.map((entry) => ({ + type: entry.type, + count: entry.count, + })), + findings: inventory.findings.map((item) => ({ + kind: item.kind, + severity: item.severity, + detail: item.detail, + ...(item.nodeId === undefined || item.nodeId === '' ? {} : { node_id: item.nodeId }), + })), + }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 17b84e8..137806f 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -16,6 +16,7 @@ */ import { prepareArtifact, type Artifact } from './artifact.js'; import { exchangePublisherToken, fetchDiscovery } from './control-plane.js'; +import { discoverFlows, inventoryJson, type Inventory } from './discovery.js'; import { CliError, EXIT_CODE, @@ -84,6 +85,7 @@ Usage Commands init Write ${PROJECT_FILE} in the current directory + discover Inventory an existing Node-RED installation offline check Validate the project and the artifact offline publish Upload, finalize and activate the built artifact activate Activate an already finalized digest at a new generation @@ -107,6 +109,9 @@ activate / rollback options --expected-generation Generation the pointer is expected to hold (required) --generation Generation to publish (default: expected + 1) +discover options + --flows Node-RED flows export to read (required) + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -128,6 +133,7 @@ const GLOBAL_OPTIONS = ['project'] as const; const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], + discover: ['flows'], check: [], publish: ['expected-generation', 'generation', 'release'], activate: ['sha256', 'expected-generation', 'generation'], @@ -390,6 +396,76 @@ async function runInit(host: CliHost, invocation: Invocation): Promise { + const path = requiredOption(invocation, 'flows'); + const filesystem = await files(host); + if (!await filesystem.exists(path)) { + throw projectError( + `No flows export at ${path}`, + 'Point --flows at the Node-RED flows.json, or at an Export > All flows download.', + ); + } + const inventory = discoverFlows(await filesystem.read(path)); + return { + summary: discoverSummary(inventory), + fields: discoverFields(inventory), + json: inventoryJson(inventory), + }; +} + +function counted(count: number, singular: string, plural: string): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function discoverSummary(inventory: Inventory): string { + const critical = inventory.findings.filter((item) => item.severity === 'critical').length; + const census = [ + counted(inventory.nodeCount, 'node', 'nodes'), + counted(inventory.flows.length, 'flow', 'flows'), + counted(inventory.brokers.length, 'broker', 'brokers'), + counted(inventory.state.length, 'state path', 'state paths'), + counted(inventory.actions.length, 'action', 'actions'), + ].join(', '); + return critical === 0 + ? census + : `${census} — ${counted(critical, 'finding', 'findings')} to settle before migrating`; +} + +function discoverFields(inventory: Inventory): readonly Field[] { + const fields: Field[] = []; + for (const home of inventory.homes) { + fields.push([`home.${home.homeId}`, `coordinator ${home.coordinatorId}`]); + } + for (const broker of inventory.brokers) { + const address = broker.port === undefined ? broker.host : `${broker.host}:${broker.port}`; + fields.push([ + `broker.${broker.name === '' ? broker.id : broker.name}`, + `${address} tls=${broker.tls} in=${broker.subscribes.length} out=${broker.publishes.length}`, + ]); + } + for (const tab of inventory.flows) { + fields.push([`flow.${tab.label === '' ? tab.id : tab.label}`, `${tab.nodeCount} nodes`]); + } + if (inventory.state.length > 0) { + fields.push(['state', inventory.state.map((entry) => entry.path)]); + } + if (inventory.actions.length > 0) { + fields.push(['actions', inventory.actions.map((entry) => entry.inputId)]); + } + if (inventory.unmodelled.length > 0) { + fields.push(['unmodelled', inventory.unmodelled.map((entry) => `${entry.type}×${entry.count}`)]); + } + for (const item of inventory.findings) { + fields.push([item.severity, item.detail]); + } + return fields; +} + async function runCheck(host: CliHost, invocation: Invocation): Promise { const project = await loadProject(host, invocation); const artifact = await loadArtifact(host, project); @@ -530,6 +606,8 @@ async function dispatch(host: CliHost, invocation: Invocation): Promise item.kind); +} + +describe('reading a flows export', () => { + test('an export that is not JSON is a project failure, not a crash', () => { + expect(() => discoverFlows(new TextEncoder().encode('not json'))).toThrow(/not valid JSON/); + }); + + test('a single copied node is rejected with the reason', () => { + expect(() => discoverFlows(new TextEncoder().encode('{"id":"a","type":"tab"}'))) + .toThrow(/not a JSON array/); + }); + + test('an empty export inventories nothing rather than failing', () => { + const empty = inventory('[]'); + expect(empty.nodeCount).toBe(0); + expect(empty.flows).toEqual([]); + expect(empty.findings).toEqual([]); + }); + + test('an export above the byte ceiling is refused before it is parsed', () => { + const oversize = new Uint8Array(MAXIMUM_FLOWS_BYTES + 1); + expect(() => discoverFlows(oversize)).toThrow(/larger than/); + }); + + test('a node without a string type is skipped, not counted', () => { + expect(inventory('[{"id":"a"},{"id":"b","type":7},{"id":"c","type":"tab"}]').nodeCount).toBe(1); + }); + + test('a prototype-polluting key never reaches the inventory', () => { + const poisoned = '[{"id":"a","type":"tab","__proto__":{"polluted":true}}]'; + expect(inventory(poisoned).nodeCount).toBe(0); + expect(({} as Record)['polluted']).toBeUndefined(); + }); +}); + +describe('the inventory of a house', () => { + test('every tab is reported with how many nodes it holds', () => { + const tabs = inventory().flows; + expect(tabs.map((tab) => tab.label)).toEqual(['Salon', 'Chauffage']); + expect(tabs[0]?.nodeCount).toBe(5); + expect(tabs[1]?.disabled).toBe(true); + }); + + test('a broker carries the topics its nodes actually reach', () => { + const broker = inventory().brokers[0]; + expect(broker?.host).toBe('192.168.1.10'); + expect(broker?.port).toBe(1883); + expect(broker?.subscribes).toEqual(['maison/salon/#', 'maison/salon/temperature']); + expect(broker?.publishes).toEqual(['maison/salon/lampe/set']); + }); + + test('the home binding is reported without reading the secret out', () => { + const home = inventory().homes[0]; + expect(home?.homeId).toBe('maison-colmon'); + expect(home?.coordinatorId).toBe('coord-1'); + expect(home?.secretInExport).toBe(true); + expect(JSON.stringify(inventory())).not.toContain('s3cr3t-in-the-file'); + }); + + test('committed variables become state candidates, sorted and name-checked', () => { + const state = inventory().state; + expect(state.map((entry) => entry.path)).toEqual([ + 'chauffage.consigne', + 'salon.*.on', + 'salon.lampe.on', + 'salon.temperature', + 'salon/humidite', + ]); + expect(state.find((entry) => entry.path === 'salon.temperature')?.source).toBe('jsonata'); + expect(state.find((entry) => entry.path === 'chauffage.consigne')?.source).toBe('env'); + expect(state.find((entry) => entry.path === 'salon.lampe.on')?.source).toBe('literal'); + expect(state.find((entry) => entry.path === 'salon/humidite')?.legalV4Name).toBe(true); + }); + + test('user actions become function candidates with their groups', () => { + const actions = inventory().actions; + expect(actions.map((entry) => entry.inputId)).toEqual(['chauffage.set', 'salon.lampe.toggle']); + expect(actions[0]?.allowedGroups).toEqual(['adultes']); + expect(actions[1]?.allowedGroups).toEqual([]); + }); + + test('notifications are reported with the audience they were sent to', () => { + const notification = inventory().notifications[0]; + expect(notification?.adminOnly).toBe(true); + expect(notification?.group).toBe(''); + }); + + test('a node type the inventory does not model is counted, never dropped', () => { + const unmodelled = inventory().unmodelled; + expect(unmodelled).toEqual([ + { type: 'function', count: 2 }, + { type: 'inject', count: 1 }, + ]); + }); +}); + +describe('what the inventory refuses to leave unsaid', () => { + test('a coordinator secret in the export is reported as critical', () => { + const secret = inventory().findings.find((item) => item.kind === 'secret_in_export'); + expect(secret?.severity).toBe('critical'); + expect(secret?.detail).toContain('rotate'); + }); + + test('an action with no group is reported as reachable by every user', () => { + const open = inventory().findings.find((item) => item.kind === 'unrestricted_action'); + expect(open?.severity).toBe('critical'); + expect(open?.detail).toContain('salon.lampe.toggle'); + }); + + test('a V3 name that V4 would reject is reported for rename', () => { + const rename = inventory().findings.filter((item) => item.kind === 'name_needs_rename'); + expect(rename.map((item) => item.detail).join(' ')).toContain('salon.*.on'); + }); + + test('a wildcard subscription is separated from a device topic', () => { + const wildcard = inventory().findings.find((item) => item.kind === 'wildcard_subscription'); + expect(wildcard?.detail).toContain('maison/salon/#'); + }); + + test('a broker without TLS is reported', () => { + expect(findingKinds()).toContain('broker_without_tls'); + }); + + test('critical findings sort ahead of notes', () => { + const severities = inventory().findings.map((item) => item.severity); + expect(severities).toEqual([...severities].sort( + (left, right) => ['critical', 'attention', 'note'].indexOf(left) + - ['critical', 'attention', 'note'].indexOf(right), + )); + }); + + test('a house with nothing wrong reports no finding', () => { + const clean = inventory(JSON.stringify([ + { id: 't1', type: 'tab', label: 'Salon' }, + { id: 'b1', type: 'mqtt-broker', name: 'local', broker: 'mqtt.example.test', port: '8883', usetls: true }, + { id: 'i1', type: 'initMiakapi', z: 't1', home: 'maison', coordID: 'c1', coordSecret: '' }, + { id: 'a1', type: 'onUserAction', z: 't1', inputID: 'salon.lampe.toggle', allowedGroups: ['adultes'] }, + ])); + expect(clean.findings).toEqual([]); + }); +}); + +describe('the discover command', () => { + test('it reports the house without needing a project file', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH], host)).toBe(EXIT_CODE.success); + expect(host.stdout()).toContain('5 state paths'); + expect(host.stdout()).toContain('192.168.1.10:1883'); + }); + + test('--json emits one closed object an agent can branch on', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH, '--json'], host)).toBe(EXIT_CODE.success); + const report = host.json(); + expect(report['ok']).toBe(true); + expect(report['command']).toBe('discover'); + expect(report['node_count']).toBe(15); + expect((report['homes'] as Record[])[0]?.['secret_in_export']).toBe(true); + expect(JSON.stringify(report)).not.toContain('s3cr3t-in-the-file'); + }); + + test('a missing export is a project failure with the path in it', async () => { + const host = testHost({ files: new MemoryFiles() }); + expect(await run(['discover', '--flows', '/tmp/absent.json'], host)).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain('/tmp/absent.json'); + }); + + test('discover without --flows is a usage failure', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover'], host)).toBe(EXIT_CODE.usage); + expect(host.stderr()).toContain('--flows is required'); + }); + + test('discover rejects a publication option', async () => { + const host = testHost({ files: flowsProject() }); + expect(await run(['discover', '--flows', FLOWS_PATH, '--generation', '2'], host)) + .toBe(EXIT_CODE.usage); + }); +}); diff --git a/packages/cli/test/support/flows.ts b/packages/cli/test/support/flows.ts new file mode 100644 index 0000000..552094d --- /dev/null +++ b/packages/cli/test/support/flows.ts @@ -0,0 +1,74 @@ +import { MemoryFiles } from './host.js'; + +export const FLOWS_PATH = '/home/mathieu/node-red/flows.json'; + +/** + * A synthetic V3 house, written to exercise every branch of the inventory: two + * tabs of which one is disabled, one broker without TLS, a device topic beside a + * wildcard subscription, a coordinator secret sitting in the export, variables + * committed from all three value types, an action restricted to a group beside + * one restricted to nobody, and node types the inventory does not model. + * + * Field names follow the two real schemas: Node-RED core `mqtt in`, `mqtt out` + * and `mqtt-broker`, and the `node-red-contrib-MiakAPI` v3 nodes. + */ +export const FLOWS_EXPORT = JSON.stringify([ + { id: 't1', type: 'tab', label: 'Salon' }, + { id: 't2', type: 'tab', label: 'Chauffage', disabled: true }, + { + id: 'b1', + type: 'mqtt-broker', + name: 'maison', + broker: '192.168.1.10', + port: '1883', + usetls: false, + cleansession: true, + }, + { + id: 'i1', + type: 'initMiakapi', + z: 't1', + home: 'maison-colmon', + coordID: 'coord-1', + coordSecret: 's3cr3t-in-the-file', + }, + { id: 'm1', type: 'mqtt in', z: 't1', broker: 'b1', topic: 'maison/salon/temperature', qos: '2' }, + { id: 'm2', type: 'mqtt in', z: 't1', broker: 'b1', topic: 'maison/salon/#', qos: '0' }, + { id: 'm3', type: 'mqtt out', z: 't1', broker: 'b1', topic: 'maison/salon/lampe/set', retain: '' }, + { + id: 'cv1', + type: 'commitVariables', + z: 't1', + name: 'Salon', + values: { + 'salon.temperature': { type: 'jsonata', value: 'payload.temp' }, + 'salon.lampe.on': { type: 'str', value: 'false' }, + 'salon/humidite': { type: 'str', value: '0' }, + }, + }, + { id: 'a1', type: 'onUserAction', z: 't2', inputID: 'salon.lampe.toggle', allowedGroups: [] }, + { + id: 'a2', + type: 'onUserAction', + z: 't2', + inputID: 'chauffage.set', + allowedGroups: ['adultes'], + }, + { + id: 'cv2', + type: 'commitVariables', + z: 't2', + values: { + 'chauffage.consigne': { type: 'env', value: 'CONSIGNE_DEFAUT' }, + 'salon.*.on': { type: 'str', value: 'false' }, + }, + }, + { id: 'n1', type: 'sendPushNotif', z: 't2', title: 'Alerte', body: 'x', adminOnly: true }, + { id: 'f1', type: 'function', z: 't2', func: 'return msg;' }, + { id: 'f2', type: 'function', z: 't2', func: 'return msg;' }, + { id: 'inj1', type: 'inject', z: 't2', repeat: '60' }, +]); + +export function flowsProject(): MemoryFiles { + return new MemoryFiles({ [FLOWS_PATH]: FLOWS_EXPORT }); +} From 6b24fcc49d61afff6846293ce8efa4f44abf75c6 Mon Sep 17 00:00:00 2001 From: Mathieu2301 <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 02:53:45 +0000 Subject: [PATCH 3/4] feat: let an agent drive the CLI without a shell An agent that speaks MCP natively had to spawn a shell, quote arguments and parse output to publish a component. `miakapp mcp` serves the same commands as tools over newline-delimited JSON-RPC on stdio. The server is a translation layer and nothing else: a tool call becomes the exact argv a person would have typed, then runs the same dispatch. There is no second implementation of an option, a default or a validation rule, and a test proves the tool surface exposes every CLI option and invents none. Two deliberate departures from the command line: - publish, activate and rollback refuse to run without `confirm: true`. The guard is checked before anything else and never reaches the argv, so a model that hallucinated a publication spends the mistake on an argument check rather than on a generation; - a command failure comes back as a tool result carrying `isError`, not as a JSON-RPC error. A protocol error means the call never happened; a publication that reached the control plane and failed did happen, and only `kind` tells the caller whether to reconcile. Nothing but framed JSON-RPC reaches stdout: the dispatch runs against a host that captures its own rendering, because one stray line would desynchronize the stream for the rest of the session. Zero new dependencies. 37 tests, including a live handshake shape, a message split across chunks, and a guarded publish proving the control plane was never contacted. --- docs/agent-guide.md | 21 + packages/cli/README.md | 47 +++ packages/cli/bin/miakapp.js | 1 + packages/cli/src/main.ts | 38 +- packages/cli/src/mcp.ts | 619 ++++++++++++++++++++++++++++++ packages/cli/test/mcp.test.ts | 426 ++++++++++++++++++++ packages/cli/test/support/host.ts | 2 + 7 files changed, 1150 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/mcp.ts create mode 100644 packages/cli/test/mcp.test.ts diff --git a/docs/agent-guide.md b/docs/agent-guide.md index f247720..e178d31 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -325,6 +325,27 @@ Branch on the code, not on the prose. On 7, call `miakapp upload ` or `miakapp release ` and reconcile before acting again. Never retry a 7 with a fresh capability. +### If you speak MCP instead of shell + +`miakapp mcp` serves the same commands as tools over JSON-RPC on stdio. It is the +same code: a tool call becomes the argv a person would have typed and runs the +same dispatch, so everything above still holds — the same defaults, the same +validation, the same `kind` on every failure. + +Three differences are worth knowing before you call anything: + +- `miakapp_publish`, `miakapp_activate` and `miakapp_rollback` refuse to run + without `confirm: true`. Set it when the owner asked for that publication, and + not to get past an error; +- a failure arrives as a tool result with `isError: true`, carrying the same + closed object, not as a JSON-RPC error. A JSON-RPC error means your call never + happened; `isError` means it ran and failed, and `kind` says what to do next; +- a tool argument is the option name with `_` instead of `-`. An argument the + tool does not declare is refused, never ignored. + +`packages/cli/README.md` lists the tools. The exit codes above are the +`exit_code` field in every result, so branch on the same table either way. + ## 9. Secrets `MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line diff --git a/packages/cli/README.md b/packages/cli/README.md index b96c1fc..7396242 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -63,6 +63,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | `rollback` | Alias of `activate`, for returning to a known-good digest. | | `release ` | Reads one finalized release record. | | `upload ` | Reads one upload status, to reconcile a lost request. | +| `mcp` | Serves every command above over MCP on stdio. | `check` is the command to run in CI and before every publication. It costs nothing, touches no network and catches the four artifact rules the broker's @@ -83,6 +84,52 @@ not model, so the reader knows what the inventory missed. It reports that a coordinator secret is present in the export; it never prints the secret itself. `docs/agent-guide.md` §3 explains what to do with each finding. +## MCP + +An agent that already runs a shell does not need this. An agent that speaks the +Model Context Protocol natively does: `miakapp mcp` serves the same commands as +tools over newline-delimited JSON-RPC on stdio. + +```json +{ + "mcpServers": { + "miakapp": { + "command": "bunx", + "args": ["@miakapp/cli", "mcp"], + "env": { "MIAKAPP_HOME_KEY": "${MIAKAPP_HOME_KEY}" } + } + } +} +``` + +| Tool | Command | | +| --- | --- | --- | +| `miakapp_discover` | `discover` | read-only, offline | +| `miakapp_check` | `check` | read-only, offline | +| `miakapp_release` | `release` | read-only | +| `miakapp_upload` | `upload` | read-only | +| `miakapp_init` | `init` | writes `miakapp.yaml`, never overwrites | +| `miakapp_publish` | `publish` | **moves the pointer — needs `confirm: true`** | +| `miakapp_activate` | `activate` | **moves the pointer — needs `confirm: true`** | +| `miakapp_rollback` | `rollback` | **moves the pointer — needs `confirm: true`** | + +The server is a translation layer: a tool call becomes the exact argv a person +would have typed and runs the same dispatch, so a tool and a command line cannot +drift apart. A tool argument is the option name with `_` for `-` +(`expected_generation` → `--expected-generation`); an argument the tool does not +declare is refused rather than ignored. + +The three pointer-moving tools additionally require `confirm: true`. It is +checked before anything else and never reaches the command line, so a model that +hallucinated a publication spends the mistake on an argument check instead of on +a generation. + +A command that fails comes back as a tool result carrying `isError: true` and +the same closed object the CLI prints — `kind`, `exit_code`, `message` and a +remedy — not as a JSON-RPC error. That distinction matters: a protocol error +means the call never happened, while a publication that reached the control +plane and failed did happen, and only `kind` says whether to reconcile. + ## Authorization The Home Key is read from `MIAKAPP_HOME_KEY` and from nowhere else. No command diff --git a/packages/cli/bin/miakapp.js b/packages/cli/bin/miakapp.js index b32e688..f48ba44 100755 --- a/packages/cli/bin/miakapp.js +++ b/packages/cli/bin/miakapp.js @@ -6,4 +6,5 @@ process.exitCode = await run(process.argv.slice(2), { writeError: (text) => void process.stderr.write(text), cwd: () => process.cwd(), env: (name) => process.env[name], + input: process.stdin, }); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 137806f..e93442d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -61,17 +61,19 @@ export interface CliHost { files?: FileSystem; /** Injected by tests; defaults to the platform `fetch`. */ fetch?: FetchLike; + /** Read only by `mcp`, which serves a request stream instead of one command. */ + input?: AsyncIterable; } type Field = readonly [key: string, value: string | number | readonly string[]]; -interface CommandResult { +export interface CommandResult { readonly summary: string; readonly fields: readonly Field[]; readonly json: Record; } -interface Invocation { +export interface Invocation { readonly command: string; readonly options: ReadonlyMap; readonly flags: ReadonlySet; @@ -92,6 +94,7 @@ Commands rollback Alias of activate, for returning to a known-good digest release Read one finalized release record upload Read one upload status, to reconcile a lost request + mcp Serve these commands over MCP on stdio help Print this text version Print the CLI version @@ -112,6 +115,11 @@ activate / rollback options discover options --flows Node-RED flows export to read (required) +mcp options + (none) Reads JSON-RPC on stdin, writes it on stdout. Every + command above becomes one tool; publish, activate + and rollback additionally require confirm: true. + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -131,7 +139,8 @@ Exit codes const GLOBAL_FLAGS = ['json'] as const; const GLOBAL_OPTIONS = ['project'] as const; -const COMMAND_OPTIONS: Record = { +/** Exported so the MCP surface can be proved to expose every option, and no other. */ +export const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], discover: ['flows'], check: [], @@ -140,6 +149,7 @@ const COMMAND_OPTIONS: Record = { rollback: ['sha256', 'expected-generation', 'generation'], release: [], upload: [], + mcp: [], help: [], version: [], }; @@ -602,7 +612,13 @@ async function runUpload(host: CliHost, invocation: Invocation): Promise { +/** + * Runs one parsed invocation. + * + * Exported for `mcp`, which reaches the same commands without a process: a + * tool call and a command line must not be able to diverge. + */ +export async function dispatch(host: CliHost, invocation: Invocation): Promise { switch (invocation.command) { case 'init': return await runInit(host, invocation); @@ -666,6 +682,20 @@ export async function run(argv: readonly string[], host: CliHost): Promise All flows download.', + }], + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_init', + title: 'Write the project file', + command: 'init', + description: + 'Write miakapp.yaml in the project directory. Refuses to overwrite an existing one, so it ' + + 'is safe to call when unsure. Declares no requirements: grant them one at a time, as the ' + + 'component earns them.', + args: [ + { + name: 'home', + type: 'string', + required: true, + description: 'Home ID the component is published to.', + }, + { + name: 'control_plane', + type: 'string', + required: true, + description: 'Control-plane issuer, an https URL.', + }, + { + name: 'artifact', + type: 'string', + required: false, + description: 'Built artifact path. Defaults to dist/component.js.', + }, + { + name: 'release', + type: 'string', + required: false, + description: 'Initial release name. Defaults to 0.1.0.', + }, + PROJECT_ARGUMENT, + ], + readOnly: false, + guarded: false, + }, + { + name: 'miakapp_check', + title: 'Validate the project and the artifact', + command: 'check', + description: + 'Parse miakapp.yaml, verify the built artifact against the four ABI 1 rules the broker ' + + 'would reject anyway, and report the digest a publication would bind. Offline and free: ' + + 'run it in CI and before every publication. It never builds the component itself.', + args: [PROJECT_ARGUMENT], + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_release', + title: 'Read one finalized release', + command: 'release', + description: + 'Read the finalized release record for one digest: release name, ABI, size, requirements ' + + 'and finalization instant. This is the reconciliation read after a lost finalize ' + + 'response — call it before deciding that a publication did not happen.', + args: [PROJECT_ARGUMENT], + positional: { + name: 'sha256', + description: 'Artifact digest, 43 base64url characters.', + }, + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_upload', + title: 'Read one upload status', + command: 'upload', + description: + 'Read the status of one upload: awaiting_upload, delivered or finalized. This is the read ' + + 'that tells a lost PUT from an upload that never arrived. Call it after any ' + + 'unknown_outcome, before touching the control plane again.', + args: [PROJECT_ARGUMENT], + positional: { + name: 'upload_id', + description: 'Upload ID returned when the capability was issued, 22 characters.', + }, + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_publish', + title: 'Publish and activate the built artifact', + command: 'publish', + description: + 'Upload the built artifact, finalize it and activate it as the new generation, in one run. ' + + 'Changes what every device in the home runs. Requires MIAKAPP_HOME_KEY in the ' + + 'environment. Run miakapp_check first; this tool does not build the component.', + args: [ + EXPECTED_GENERATION, + GENERATION, + { + name: 'release', + type: 'string', + required: false, + description: 'Release name for this publication. Defaults to component.release.', + }, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, + { + name: 'miakapp_activate', + title: 'Activate an already finalized digest', + command: 'activate', + description: + 'Point the home at a digest that was already finalized, at a new generation. Uploads ' + + 'nothing. Use it to promote a release that was published but not activated.', + args: [ + { + name: 'sha256', + type: 'string', + required: true, + description: 'Finalized artifact digest, 43 base64url characters.', + }, + EXPECTED_GENERATION, + GENERATION, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, + { + name: 'miakapp_rollback', + title: 'Return the home to a known-good digest', + command: 'rollback', + description: + 'The same operation as miakapp_activate, named for the moment it matters: put the home ' + + 'back on a digest that was working. A rollback is a forward activation of an older ' + + 'artifact, so it takes a new generation too — generations never go backwards.', + args: [ + { + name: 'sha256', + type: 'string', + required: true, + description: 'Digest of the release to return to, 43 base64url characters.', + }, + EXPECTED_GENERATION, + GENERATION, + PROJECT_ARGUMENT, + CONFIRM, + ], + readOnly: false, + guarded: true, + }, +]; + +/** The CLI option a tool argument stands for. */ +export function optionName(argument: string): string { + return argument.replaceAll('_', '-'); +} + +function schemaProperty(argument: ToolArgument): Record { + if (argument.type === 'integer') { + return { type: 'integer', minimum: 0, description: argument.description }; + } + if (argument.type === 'boolean') { + return { type: 'boolean', description: argument.description }; + } + return { type: 'string', minLength: 1, description: argument.description }; +} + +export function inputSchema(tool: ToolDefinition): Record { + const properties: Record = {}; + const required: string[] = []; + if (tool.positional !== undefined) { + properties[tool.positional.name] = { + type: 'string', + minLength: 1, + description: tool.positional.description, + }; + required.push(tool.positional.name); + } + for (const argument of tool.args) { + properties[argument.name] = schemaProperty(argument); + if (argument.required) required.push(argument.name); + } + return { + type: 'object', + properties, + required, + additionalProperties: false, + }; +} + +function descriptor(tool: ToolDefinition): Record { + return { + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: inputSchema(tool), + annotations: { + title: tool.title, + readOnlyHint: tool.readOnly, + destructiveHint: tool.guarded, + idempotentHint: false, + openWorldHint: !tool.readOnly || tool.command === 'release' || tool.command === 'upload', + }, + }; +} + +/** + * Turns tool arguments into the argv a person would have typed. + * + * Unknown keys are rejected here rather than dropped: a model that invented an + * argument has misunderstood the tool, and silently ignoring it would publish + * something other than what it asked for. + */ +export function buildArgv( + tool: ToolDefinition, + args: Record, +): readonly string[] { + const byName = new Map(tool.args.map((argument) => [argument.name, argument])); + const argv: string[] = [tool.command]; + const positional = tool.positional; + + for (const key of Object.keys(args)) { + if (key === positional?.name) continue; + if (!byName.has(key)) { + throw usageError( + `Unknown argument ${key} for ${tool.name}`, + `${tool.name} accepts ${[...byName.keys()].join(', ')}.`, + ); + } + } + + if (positional !== undefined) { + const value = args[positional.name]; + if (typeof value !== 'string' || value === '') { + throw usageError(`${positional.name} is required and must be a non-empty string`); + } + argv.push(value); + } + + for (const argument of tool.args) { + const value = args[argument.name]; + if (argument.type === 'boolean') { + // A guard is not an option: it is checked here and never reaches the argv, + // so the command line keeps exactly the shape it had before MCP existed. + if (value !== true) { + throw usageError( + `${argument.name} must be set to true`, + 'This tool changes what every device in the home runs, and will not act without an ' + + 'explicit confirmation from the caller.', + ); + } + continue; + } + if (value === undefined) { + if (argument.required) throw usageError(`${argument.name} is required`); + continue; + } + if (argument.type === 'integer') { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw usageError(`${argument.name} must be a non-negative integer`); + } + argv.push(`--${optionName(argument.name)}`, String(value)); + continue; + } + if (typeof value !== 'string' || value === '') { + throw usageError(`${argument.name} must be a non-empty string`); + } + argv.push(`--${optionName(argument.name)}`, value); + } + return argv; +} + +function failureJson(error: CliError): Record { + return { + ok: false, + kind: error.kind, + exit_code: error.exitCode, + message: error.message, + ...(error.remedy === undefined ? {} : { remedy: error.remedy }), + }; +} + +function toolResult(payload: Record, isError: boolean): Record { + return { + content: [{ type: 'text', text: JSON.stringify(payload) }], + structuredContent: payload, + isError, + }; +} + +/** + * Runs one tool and returns its MCP result. + * + * The host handed to the dispatch captures output instead of writing it: the + * command's own rendering never reaches stdout, which belongs to the protocol. + */ +export async function callTool( + host: CliHost, + name: unknown, + rawArguments: unknown, +): Promise> { + const tool = TOOLS.find((candidate) => candidate.name === name); + if (tool === undefined) { + return toolResult( + failureJson(usageError( + `Unknown tool: ${typeof name === 'string' ? name : 'a non-string name'}`, + `This server exposes ${TOOLS.map((item) => item.name).join(', ')}.`, + )), + true, + ); + } + const args = rawArguments === undefined || rawArguments === null ? {} : rawArguments; + if (typeof args !== 'object' || Array.isArray(args)) { + return toolResult(failureJson(usageError('arguments must be a JSON object')), true); + } + + let result: CommandResult; + try { + const argv = buildArgv(tool, args as Record); + result = await dispatch(silentHost(host), parseArguments(argv)); + } catch (error) { + if (error instanceof CliError) return toolResult(failureJson(error), true); + const message = error instanceof Error ? error.message : 'Unrecognized failure'; + return toolResult( + failureJson(new CliError( + 'unknown_outcome', + `The command ended in an unhandled failure: ${message}`, + 'Reconcile with miakapp_release or miakapp_upload before publishing again.', + )), + true, + ); + } + return toolResult( + { ok: true, command: tool.command, summary: result.summary, ...result.json }, + false, + ); +} + +/** The dispatch never prints; the protocol owns both streams of this process. */ +function silentHost(host: CliHost): CliHost { + return { ...host, write: () => {}, writeError: () => {} }; +} + +export interface Message { + /** + * Declared because every conforming client sends it, and not policed: the + * method name is what routes a message, so rejecting a mislabelled version + * would buy an interop failure and no safety property. + */ + readonly jsonrpc?: unknown; + readonly id?: unknown; + readonly method?: unknown; + readonly params?: unknown; +} + +function response(id: unknown, result: Record): Record { + return { jsonrpc: '2.0', id, result }; +} + +function errorResponse(id: unknown, code: number, message: string): Record { + return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; +} + +const INSTRUCTIONS = + 'Publish and roll back one Miakapp home component. Start with miakapp_discover on a house ' + + 'that already exists, then miakapp_check before every publication. A publication is a ' + + 'compare-and-set on the home generation: when a call fails with kind "conflict" the state ' + + 'moved under you, so read it again rather than retrying. When a call fails with kind ' + + '"unknown_outcome" the effect is undetermined — call miakapp_upload or miakapp_release to ' + + 'find out what happened before acting. Every result is a closed JSON object with a stable ' + + `"kind"; branch on that, never on the prose. Publishing needs ${HOME_KEY_VARIABLE} in this ` + + 'server\'s environment; it is never an argument and never printed.'; + +/** + * Handles one decoded message. + * + * Returns the response to write, or `undefined` for a notification — a + * JSON-RPC notification carries no id and must never be answered, not even to + * report that it was not understood. + */ +export async function handleMessage( + host: CliHost, + message: Message, +): Promise | undefined> { + const { method, id } = message; + const isNotification = id === undefined || id === null; + if (typeof method !== 'string') { + return isNotification ? undefined : errorResponse(id, -32600, 'Missing method'); + } + if (isNotification) return undefined; + + switch (method) { + case 'initialize': + return response(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, title: 'Miakapp', version: CLI_VERSION }, + instructions: INSTRUCTIONS, + }); + case 'ping': + return response(id, {}); + case 'tools/list': + return response(id, { tools: TOOLS.map(descriptor) }); + case 'tools/call': { + const params = message.params; + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + return errorResponse(id, -32602, 'tools/call requires a params object'); + } + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown }; + return response(id, await callTool(host, name, args)); + } + default: + return errorResponse(id, -32601, `Unknown method: ${method}`); + } +} + +/** + * Splits a byte stream into JSON-RPC messages on newline boundaries. + * + * A message longer than {@link MAXIMUM_MESSAGE_BYTES} ends the session instead + * of growing the buffer: the peer is either broken or hostile, and neither is + * worth the memory. + */ +export async function* messages( + input: AsyncIterable, +): AsyncGenerator { + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + for await (const chunk of input) { + buffer += decoder.decode(chunk, { stream: true }); + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line !== '') yield line; + newline = buffer.indexOf('\n'); + } + if (buffer.length > MAXIMUM_MESSAGE_BYTES) { + throw new CliError( + 'contract', + `A single JSON-RPC message exceeded ${MAXIMUM_MESSAGE_BYTES} bytes`, + ); + } + } + const last = buffer.trim(); + if (last !== '') yield last; +} + +/** + * Serves MCP until the input stream ends. + * + * Returns an exit code, like every other command. A closed stdin is the normal + * way an MCP client shuts a server down, so it is success, not failure. + */ +export async function serve( + host: CliHost, + input: AsyncIterable, +): Promise { + const write = (payload: Record): void => { + host.write(`${JSON.stringify(payload)}\n`); + }; + try { + for await (const line of messages(input)) { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + write(errorResponse(null, -32700, 'Parse error')); + continue; + } + if (typeof message !== 'object' || message === null || Array.isArray(message)) { + write(errorResponse(null, -32600, 'A JSON-RPC message must be an object')); + continue; + } + const reply = await handleMessage(host, message as Message); + if (reply !== undefined) write(reply); + } + return EXIT_CODE.success; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unrecognized failure'; + host.writeError(`miakapp: mcp: ${message}\n`); + return error instanceof CliError ? error.exitCode : EXIT_CODE.unknown_outcome; + } +} diff --git a/packages/cli/test/mcp.test.ts b/packages/cli/test/mcp.test.ts new file mode 100644 index 0000000..e08d6e5 --- /dev/null +++ b/packages/cli/test/mcp.test.ts @@ -0,0 +1,426 @@ +import { describe, expect, test } from 'bun:test'; +import { EXIT_CODE } from '../src/errors.js'; +import { COMMAND_OPTIONS, HOME_KEY_VARIABLE, run } from '../src/main.js'; +import { + MCP_PROTOCOL_VERSION, + TOOLS, + buildArgv, + callTool, + handleMessage, + inputSchema, + messages, + optionName, + serve, +} from '../src/mcp.js'; +import { digestOf, fakeControlPlane, homeKey } from './support/control-plane.js'; +import { ARTIFACT_SOURCE, MemoryFiles, PROJECT_ROOT, standardProject, testHost } from './support/host.js'; +import { FLOWS_PATH, flowsProject } from './support/flows.js'; + +const HOME_ID = 'test-home'; +const ARTIFACT_DIGEST = digestOf(new TextEncoder().encode(ARTIFACT_SOURCE)); + +function publisherEnvironment(): Record { + return { [HOME_KEY_VARIABLE]: homeKey() }; +} + +/** Feeds a server one chunk per string, the way a pipe delivers them. */ +function stream(...chunks: readonly string[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + const encoder = new TextEncoder(); + for (const chunk of chunks) yield encoder.encode(chunk); + }, + }; +} + +function line(payload: Record): string { + return `${JSON.stringify(payload)}\n`; +} + +function frames(text: string): Record[] { + return text + .split('\n') + .filter((entry) => entry !== '') + .map((entry) => JSON.parse(entry) as Record); +} + +function tool(name: string) { + const found = TOOLS.find((candidate) => candidate.name === name); + if (found === undefined) throw new Error(`No such tool: ${name}`); + return found; +} + +function payload(result: Record): Record { + return result['structuredContent'] as Record; +} + +describe('the tool surface mirrors the command surface', () => { + test('every command except help, version and mcp itself is a tool', () => { + const commands = Object.keys(COMMAND_OPTIONS) + .filter((name) => !['help', 'version', 'mcp'].includes(name)) + .sort(); + expect(TOOLS.map((entry) => entry.command).sort()).toEqual(commands); + }); + + test('no tool hides an option the command accepts', () => { + for (const entry of TOOLS) { + const exposed = new Set(entry.args.map((argument) => optionName(argument.name))); + for (const option of COMMAND_OPTIONS[entry.command] ?? []) { + expect([entry.name, option, exposed.has(option)]).toEqual([entry.name, option, true]); + } + } + }); + + test('no tool invents an option the command would reject', () => { + for (const entry of TOOLS) { + const allowed = new Set([...COMMAND_OPTIONS[entry.command] ?? [], 'project']); + for (const argument of entry.args) { + if (argument.type === 'boolean') continue; // confirm never reaches the argv + const option = optionName(argument.name); + expect([entry.name, option, allowed.has(option)]).toEqual([entry.name, option, true]); + } + } + }); + + test('exactly the pointer-moving tools are guarded and declared destructive', () => { + const guarded = TOOLS.filter((entry) => entry.guarded).map((entry) => entry.command).sort(); + expect(guarded).toEqual(['activate', 'publish', 'rollback']); + for (const entry of TOOLS) { + const confirms = entry.args.some((argument) => argument.name === 'confirm'); + expect([entry.name, confirms]).toEqual([entry.name, entry.guarded]); + expect([entry.name, entry.readOnly && entry.guarded]).toEqual([entry.name, false]); + } + }); + + test('each schema declares every argument and requires the mandatory ones', () => { + const listed = TOOLS.map((entry) => entry.name); + expect(new Set(listed).size).toBe(listed.length); + for (const entry of TOOLS) { + const schema = inputSchema(entry); + const declared = Object.keys(schema['properties'] as Record).sort(); + const expected = entry.args.map((argument) => argument.name); + if (entry.positional !== undefined) expected.push(entry.positional.name); + expect([entry.name, declared]).toEqual([entry.name, expected.sort()]); + + const required = entry.args.filter((argument) => argument.required).map((a) => a.name); + if (entry.positional !== undefined) required.unshift(entry.positional.name); + expect([entry.name, schema['required']]).toEqual([entry.name, required]); + } + }); +}); + +describe('argument translation', () => { + test('an integer becomes the decimal option the parser expects', () => { + expect(buildArgv(tool('miakapp_publish'), { expected_generation: 4, confirm: true })) + .toEqual(['publish', '--expected-generation', '4']); + }); + + test('an underscore in a tool argument is the CLI hyphen', () => { + expect(optionName('expected_generation')).toBe('expected-generation'); + expect(buildArgv(tool('miakapp_init'), { + home: 'lumiere', + control_plane: 'https://control.example.test/api', + })).toEqual([ + 'init', + '--home', 'lumiere', + '--control-plane', 'https://control.example.test/api', + ]); + }); + + test('a positional argument is passed as a positional, not an option', () => { + expect(buildArgv(tool('miakapp_release'), { sha256: ARTIFACT_DIGEST })) + .toEqual(['release', ARTIFACT_DIGEST]); + }); + + test('an invented argument is refused rather than dropped', () => { + expect(() => buildArgv(tool('miakapp_check'), { force: true })).toThrow(/Unknown argument/); + }); + + test('a missing required argument is refused before anything runs', () => { + expect(() => buildArgv(tool('miakapp_publish'), { confirm: true })).toThrow(/required/); + }); + + test('a negative generation is refused before the parser sees it', () => { + expect(() => buildArgv(tool('miakapp_publish'), { expected_generation: -1, confirm: true })) + .toThrow(/non-negative integer/); + }); + + test('a generation given as a string is refused, not coerced', () => { + expect(() => buildArgv(tool('miakapp_publish'), { expected_generation: '4', confirm: true })) + .toThrow(/non-negative integer/); + }); +}); + +describe('protocol', () => { + test('initialize announces the protocol revision and the tools capability', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 1, method: 'initialize' }); + const result = reply?.['result'] as Record; + expect(result['protocolVersion']).toBe(MCP_PROTOCOL_VERSION); + expect(result['capabilities']).toEqual({ tools: { listChanged: false } }); + expect((result['serverInfo'] as Record)['name']).toBe('miakapp'); + expect(result['instructions']).toContain('unknown_outcome'); + }); + + test('tools/list describes every tool with a closed schema', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const tools = (reply?.['result'] as { tools: Record[] }).tools; + expect(tools).toHaveLength(TOOLS.length); + for (const descriptor of tools) { + const schema = descriptor['inputSchema'] as Record; + expect(schema['type']).toBe('object'); + expect(schema['additionalProperties']).toBe(false); + expect(descriptor['description']).toBeString(); + expect((descriptor['annotations'] as Record)['readOnlyHint']).toBeBoolean(); + } + }); + + test('a notification is never answered', async () => { + const host = testHost(); + expect(await handleMessage(host, { jsonrpc: '2.0', method: 'notifications/initialized' })) + .toBeUndefined(); + }); + + test('an unknown method is a method-not-found error', async () => { + const host = testHost(); + const reply = await handleMessage(host, { jsonrpc: '2.0', id: 3, method: 'resources/list' }); + expect((reply?.['error'] as Record)['code']).toBe(-32601); + }); + + test('unparseable input is a parse error that does not end the session', async () => { + const host = testHost(); + const code = await serve(host, stream('{not json\n', line({ jsonrpc: '2.0', id: 1, method: 'ping' }))); + expect(code).toBe(EXIT_CODE.success); + const replies = frames(host.stdout()); + expect((replies[0]?.['error'] as Record)['code']).toBe(-32700); + expect(replies[1]?.['result']).toEqual({}); + }); + + test('a message split across chunks is reassembled', async () => { + const host = testHost(); + const request = line({ jsonrpc: '2.0', id: 7, method: 'ping' }); + await serve(host, stream(request.slice(0, 10), request.slice(10))); + expect(frames(host.stdout())[0]?.['id']).toBe(7); + }); + + test('a final message without a trailing newline is still served', async () => { + const host = testHost(); + await serve(host, stream('{"jsonrpc":"2.0","id":9,"method":"ping"}')); + expect(frames(host.stdout())[0]?.['id']).toBe(9); + }); + + test('the message reader yields one entry per line and ignores blanks', async () => { + const seen: string[] = []; + for await (const entry of messages(stream('a\n\n \nb\n'))) seen.push(entry); + expect(seen).toEqual(['a', 'b']); + }); + + test('a closed stream is a clean shutdown, not a failure', async () => { + const host = testHost(); + expect(await serve(host, stream())).toBe(EXIT_CODE.success); + expect(host.stdout()).toBe(''); + }); +}); + +describe('read-only tools', () => { + test('check returns the digest a publication would bind', async () => { + const host = testHost({ files: standardProject() }); + const result = await callTool(host, 'miakapp_check', { project: PROJECT_ROOT }); + expect(result['isError']).toBe(false); + expect(payload(result)['sha256']).toBe(ARTIFACT_DIGEST); + expect(payload(result)['command']).toBe('check'); + }); + + test('a result carries the same object in text and in structuredContent', async () => { + const host = testHost({ files: standardProject() }); + const result = await callTool(host, 'miakapp_check', { project: PROJECT_ROOT }); + const content = (result['content'] as { type: string; text: string }[])[0]; + expect(content?.type).toBe('text'); + expect(JSON.parse(content?.text ?? '')).toEqual(payload(result)); + }); + + test('discover inventories a flows export without a project or a key', async () => { + const host = testHost({ files: flowsProject() }); + const result = await callTool(host, 'miakapp_discover', { flows: FLOWS_PATH }); + expect(result['isError']).toBe(false); + expect(payload(result)['flows']).toBeArray(); + }); + + test('a failing command is a tool result with isError, not a protocol error', async () => { + const host = testHost({ files: new MemoryFiles({}) }); + const reply = await handleMessage(host, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'miakapp_check', arguments: { project: PROJECT_ROOT } }, + }); + expect(reply?.['error']).toBeUndefined(); + const result = reply?.['result'] as Record; + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('project'); + expect(payload(result)['exit_code']).toBe(EXIT_CODE.project); + }); + + test('an unknown tool fails as a usage result the caller can read', async () => { + const host = testHost(); + const result = await callTool(host, 'miakapp_deploy_everything', {}); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('usage'); + }); +}); + +describe('guarded tools', () => { + test('publish without confirm touches nothing', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('usage'); + expect(payload(result)['message']).toContain('confirm'); + expect(payload(result)['remedy']).toContain('explicit confirmation'); + expect(plane.requests).toEqual([]); + expect(plane.generation).toBe(0); + }); + + test('publish with confirm false is refused, not treated as absent', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: false, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['message']).toContain('confirm'); + expect(plane.requests).toEqual([]); + }); + + test('publish with confirm walks the whole publication', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(false); + expect(payload(result)['generation']).toBe(1); + expect(payload(result)['sha256']).toBe(ARTIFACT_DIGEST); + expect(plane.generation).toBe(1); + }); + + test('a stale expected generation is a conflict the caller must re-read', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 3 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(true); + expect(payload(result)['kind']).toBe('conflict'); + expect(plane.generation).toBe(3); + }); + + test('rollback activates a finalized digest at a new generation', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + const result = await callTool(host, 'miakapp_rollback', { + sha256: ARTIFACT_DIGEST, + expected_generation: 1, + confirm: true, + project: PROJECT_ROOT, + }); + expect(result['isError']).toBe(false); + expect(payload(result)['generation']).toBe(2); + }); + + test('a missing Home Key is an authorization failure, and the key never appears', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ files: standardProject(), fetch: plane.fetch }); + const result = await callTool(host, 'miakapp_publish', { + expected_generation: 0, + confirm: true, + project: PROJECT_ROOT, + }); + expect(payload(result)['kind']).toBe('authorization'); + expect(JSON.stringify(result)).not.toContain(homeKey()); + }); +}); + +describe('the mcp command', () => { + test('mcp serves the stream given on the host input', async () => { + const host = testHost({ + files: standardProject(), + input: stream(line({ jsonrpc: '2.0', id: 1, method: 'tools/list' })), + }); + expect(await run(['mcp'], host)).toBe(EXIT_CODE.success); + const tools = (frames(host.stdout())[0]?.['result'] as { tools: unknown[] }).tools; + expect(tools).toHaveLength(TOOLS.length); + }); + + test('mcp writes nothing but framed JSON-RPC to stdout', async () => { + const host = testHost({ + files: standardProject(), + input: stream( + line({ jsonrpc: '2.0', method: 'notifications/initialized' }), + line({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'miakapp_check', arguments: { project: PROJECT_ROOT } }, + }), + ), + }); + await run(['mcp'], host); + const replies = frames(host.stdout()); + expect(replies).toHaveLength(1); + expect(replies[0]?.['id']).toBe(2); + expect(host.stderr()).toBe(''); + }); + + test('mcp without an input stream is a usage failure, not a hang', async () => { + const host = testHost({ files: standardProject() }); + expect(await run(['mcp'], host)).toBe(EXIT_CODE.usage); + expect(host.stderr()).toContain('stdin'); + }); + + test('mcp rejects --json rather than corrupting the stream', async () => { + const host = testHost({ files: standardProject(), input: stream() }); + expect(await run(['mcp', '--json'], host)).toBe(EXIT_CODE.usage); + }); + + test('mcp takes no options of its own', async () => { + const host = testHost({ input: stream() }); + expect(await run(['mcp', '--flows', 'x'], host)).toBe(EXIT_CODE.usage); + }); +}); diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts index ac79033..4f32e5b 100644 --- a/packages/cli/test/support/host.ts +++ b/packages/cli/test/support/host.ts @@ -66,6 +66,7 @@ export function testHost(options: { fetch?: FetchLike; env?: Record; cwd?: string; + input?: AsyncIterable; } = {}): TestHost { const out: string[] = []; const err: string[] = []; @@ -79,6 +80,7 @@ export function testHost(options: { env: (name) => environment[name], ...(options.files === undefined ? {} : { files: options.files }), ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + ...(options.input === undefined ? {} : { input: options.input }), stdout: () => out.join(''), stderr: () => err.join(''), json: () => JSON.parse(out.join('')) as Record, From 84e12a2e948d677a02e85af515bdd3b08e09c221 Mon Sep 17 00:00:00 2001 From: Mathieu Colmon <21021423+Mathieu2301@users.noreply.github.com> Date: Mon, 14 Sep 2026 03:34:08 +0000 Subject: [PATCH 4/4] feat: install the pack so the next agent arrives knowing the rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolchain, the guide and the contract all existed, and none of them were where a coding agent actually looks. An agent opening the owner's home repository reads AGENTS.md or CLAUDE.md, and finds whatever the owner wrote there — which is not this. `miakapp agent-pack` puts the knowledge in the repository: the guide as a file under .miakapp/, a pointer to it in both instruction files, and the MCP server registered in .mcp.json so the tools are wired rather than described. The CLI's rule that it never rewrites what it did not generate is what shapes every merge. The guide is a file the command owns outright. The instruction files are edited only between markers it wrote, so prose above and below survives byte for byte and a second run rewrites the block in place instead of stacking another copy. .mcp.json is merged as a structure, one key by name: every other server survives, and a file that does not parse is refused rather than replaced with a valid one. Two new FileSystem methods carry this — `replace` is deliberately separate from `write`, so overwriting stays something a command asks for rather than something it falls into. The guide ships as a package asset, and a test asserts it is byte-equal to docs/agent-guide.md. Editing the doc without copying it across is a red test, not a pack that teaches an agent rules the CLI no longer has. Verified against the built binary, not only through injected tests: the pack installed into a repository with its own AGENTS.md and its own .mcp.json kept both, `claude mcp get miakapp` reads the generated entry back as a project-scope stdio server, a second run reported every file unchanged, and miakapp_agent_pack installed a pack over real MCP stdio. --- docs/agent-guide.md | 22 ++ packages/cli/README.md | 35 +++ packages/cli/assets/agent-guide.md | 408 +++++++++++++++++++++++++++ packages/cli/package.json | 1 + packages/cli/src/agent-pack.ts | 255 +++++++++++++++++ packages/cli/src/main.ts | 80 ++++++ packages/cli/src/mcp.ts | 20 ++ packages/cli/test/agent-pack.test.ts | 249 ++++++++++++++++ packages/cli/test/support/host.ts | 13 +- 9 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 packages/cli/assets/agent-guide.md create mode 100644 packages/cli/src/agent-pack.ts create mode 100644 packages/cli/test/agent-pack.test.ts diff --git a/docs/agent-guide.md b/docs/agent-guide.md index e178d31..d71ded3 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -30,6 +30,25 @@ temperature — with the three files already in the right relationship, and its own `README.md` covering the mechanics of copying it out. This guide covers the judgment the template cannot. +### Leave the repository readable by the next agent + +You are probably not the last agent to open this repository, and the next one +will not have this conversation. Run the pack once, in the repository root: + +```bash +miakapp agent-pack +``` + +It copies this guide to `.miakapp/agent-guide.md`, points `AGENTS.md` and +`CLAUDE.md` at it, and registers `miakapp mcp` in `.mcp.json` so the tools are +wired rather than described. It edits instead of replacing: prose outside the +`` markers is kept, other MCP servers are kept by name, +and running it twice changes nothing. + +If you are reading this file *as* `.miakapp/agent-guide.md`, someone already +ran it. Run it again after upgrading the CLI, and commit what changes — a guide +that contradicts the CLI installed beside it is worse than no guide. + ## 2. The division of responsibility **The coordinator authorizes everything.** The relay proves *who* is calling and @@ -286,6 +305,7 @@ writes no history, rewrites no source it did not generate, and invents no control-plane endpoint. The repository is the owner's. ```bash +miakapp agent-pack # once, per repository miakapp init --home --control-plane miakapp check miakapp publish --expected-generation @@ -375,6 +395,8 @@ that is the failure this design exists to prevent. ## Where to read further +- `packages/cli/README.md` — every command and every tool, including what the + pack writes and what it refuses to overwrite. - `templates/home/README.md` — the mechanics of the template itself. - `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. - `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is diff --git a/packages/cli/README.md b/packages/cli/README.md index 7396242..0a7c9ce 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | Command | What it does | | --- | --- | | `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `agent-pack` | Offline. Installs the guide and the MCP wiring into a repository. | | `discover` | Offline. Inventories a Node-RED installation from its flows export. | | `check` | Offline. Parses the project, verifies the artifact, prints the digest. | | `publish` | Capability → delivery → finalization → activation, in one run. | @@ -84,6 +85,39 @@ not model, so the reader knows what the inventory missed. It reports that a coordinator secret is present in the export; it never prints the secret itself. `docs/agent-guide.md` §3 explains what to do with each finding. +## The agent pack + +`agent-pack` is the command to run *once* in a home repository, so that the next +agent to open it arrives already knowing the rules: + +``` +miakapp agent-pack # or --dir /path/to/the/repository +``` + +It writes four files and reports what it did to each one: + +| File | Why | +| --- | --- | +| `.miakapp/agent-guide.md` | The full guide, copied out of this package. No network, no stale bookmark. | +| `AGENTS.md` | The instruction file Codex reads. | +| `CLAUDE.md` | The instruction file Claude Code reads. | +| `.mcp.json` | Project-scope MCP configuration, registering `miakapp mcp`. | + +The repository is yours, so the pack edits rather than replaces. The guide is a +file it owns outright. The instruction files are touched only between +`` and ``: prose above and below the +markers is copied through byte for byte, and a second run rewrites the block in +place instead of appending another copy. `.mcp.json` is merged as a structure — +one key, by name — so every other server in it survives, and a file that does +not parse is refused rather than replaced with a valid one. + +The server is registered as the bare `miakapp` command rather than an absolute +path, because the file is committed and the next machine to check it out will +not have this one's directory layout. + +Run it again whenever the CLI is upgraded: an unchanged file is reported +`unchanged`, and a guide that moved on is reported `updated`. + ## MCP An agent that already runs a shell does not need this. An agent that speaks the @@ -109,6 +143,7 @@ tools over newline-delimited JSON-RPC on stdio. | `miakapp_release` | `release` | read-only | | `miakapp_upload` | `upload` | read-only | | `miakapp_init` | `init` | writes `miakapp.yaml`, never overwrites | +| `miakapp_agent_pack` | `agent-pack` | offline, writes the pack into a repository | | `miakapp_publish` | `publish` | **moves the pointer — needs `confirm: true`** | | `miakapp_activate` | `activate` | **moves the pointer — needs `confirm: true`** | | `miakapp_rollback` | `rollback` | **moves the pointer — needs `confirm: true`** | diff --git a/packages/cli/assets/agent-guide.md b/packages/cli/assets/agent-guide.md new file mode 100644 index 0000000..d71ded3 --- /dev/null +++ b/packages/cli/assets/agent-guide.md @@ -0,0 +1,408 @@ +# Building a Miakapp home + +This guide is written for a coding agent — Claude Code, Codex, or any successor — +that has been handed someone's house and asked to make it work. You are expected +to read the existing installation, write a coordinator, write an interface, test +both, and publish. The owner is not a home-automation developer and should not +have to become one. + +Everything below is true of the code in this repository. Where a rule exists for +a reason that is not obvious, the reason is given, because a rule whose purpose +you cannot see is one you will optimize away. + +## 1. What you are building + +Three artifacts, and no more: + +| Artifact | Where it runs | What it owns | +| --- | --- | --- | +| Coordinator | the owner's machine, under Bun | state, events, functions, **authorization** | +| Component | a sandboxed Worker in the household's browser | the interface | +| `miakapp.yaml` | neither; it is the contract | what the component may ask for | + +The coordinator is trusted. The component is not. The relay between them is +platform-untrusted but not blind: it terminates TLS, stores plaintext state and +enforces routing, so self-hosting it does not give end-to-end confidentiality. +Do not tell the owner otherwise. + +Start from `templates/home`. It is a complete working home — one lamp, one +temperature — with the three files already in the right relationship, and its +own `README.md` covering the mechanics of copying it out. This guide covers the +judgment the template cannot. + +### Leave the repository readable by the next agent + +You are probably not the last agent to open this repository, and the next one +will not have this conversation. Run the pack once, in the repository root: + +```bash +miakapp agent-pack +``` + +It copies this guide to `.miakapp/agent-guide.md`, points `AGENTS.md` and +`CLAUDE.md` at it, and registers `miakapp mcp` in `.mcp.json` so the tools are +wired rather than described. It edits instead of replacing: prose outside the +`` markers is kept, other MCP servers are kept by name, +and running it twice changes nothing. + +If you are reading this file *as* `.miakapp/agent-guide.md`, someone already +ran it. Run it again after upgrading the CLI, and commit what changes — a guide +that contradicts the CLI installed beside it is worse than no guide. + +## 2. The division of responsibility + +**The coordinator authorizes everything.** The relay proves *who* is calling and +attaches non-spoofable caller metadata. Deciding whether that person *may* act is +the coordinator's job and nobody else's. In the template that decision is the +first line of the function body: + +```ts +if (call.source.kind !== 'user' || call.source.id !== options.ownerUserId) { + throw new ApplicationCallError(2003, 'Only the owner may drive the lights'); +} +``` + +Removing that check does not produce an error anywhere. It produces a home that +anyone enrolled can drive. There is no second layer that will catch it for you. + +**The component trusts nothing it was not given.** It has no network, no storage +and no DOM. It cannot reach the lamp except through a call the coordinator +declared, and it cannot read a state path the coordinator did not grant. This is +why it is safe to let a component be rewritten often and reviewed lightly, and +why it is not safe to move a decision into it. + +**You own the UI; the coordinator owns the facts.** The coordinator exposes state +and actions. What the household actually sees — the layout, the wording, the +language, which controls are prominent — is yours to design from the semantic +vocabulary in §5. Do not push presentation choices into the coordinator, and do +not push authorization into the component. + +## 3. Before you write anything: characterize the house + +You are almost never starting from an empty building. Read what is already there +before you design anything: + +- existing hubs and brokers — Node-RED, MQTT, Zigbee/Z-Wave coordinators, an + HTTP-speaking hub; +- what each device actually reports, and how often; +- which values are *measurements* (temperature, power) and which are + *commanded* (a lamp, a valve) — they have different failure modes; +- which actions are physically consequential: anything that heats, locks, + unlocks, opens or closes. + +Write down what you found before you write the configuration. The state paths you +choose become a disclosure boundary and a public interface at the same time, and +renaming one after the household has used it is not free. + +### Reading a V3 house you inherited + +Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes. Ask +the owner for the `flows.json` Node-RED writes, or for an *Export > All flows* +download, and read it before you read anything else: + +``` +miakapp discover --flows ~/node-red/flows.json +miakapp discover --flows ~/node-red/flows.json --json +``` + +The command is offline and read-only: it opens no socket, contacts no broker and +never writes back into the export. It reports the tabs, the MQTT brokers with the +topics their nodes actually reach, the `initMiakapi` home bindings, every +`commitVariables` path as a state candidate, every `onUserAction` id as a +function candidate with the groups allowed to invoke it, and every node type it +does not model — so you know what the inventory missed rather than assuming it +missed nothing. + +Four of its findings decide work you would otherwise discover late: + +- **`secret_in_export`.** The v3 `initMiakapi` node declares `coordSecret` in its + `defaults`, not in its `credentials`, so Node-RED stores that secret in + cleartext in `flows.json` rather than in the encrypted `flows_cred.json`. If + the export has one, treat it as leaked: rotate it, and keep the file out of + Git. §9 is the V4 rule that replaces it. +- **`unrestricted_action`.** The v3 handler allows an action outright when its + node lists no group, so an empty `allowedGroups` is a grant to every signed-in + user, not a deny. Each one needs a deliberate V4 rule before you port it. +- **`name_needs_rename`.** A v3 variable path or action id that is not a legal V4 + dotted name has to be renamed now, while nobody depends on it. +- **`wildcard_subscription`.** A topic holding `#` or `+` is a subscription + pattern, not one device. Enumerate what it actually matches. + +The command deliberately does not tell you which actions are physically +consequential. It lists every action it found; deciding which of them heats, +locks, unlocks, opens or closes is a judgement you make with the owner, and no +keyword list should make it for you. + +## 4. The coordinator + +`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +**pure function of its options**, so it can be tested without a relay, a control +plane or a network. `coordinator/main.ts` is the only file that touches the +outside world. Keep that split. It is what makes `test/home.test.ts` possible, +and the authorization rules are exactly the thing you want under test. + +Four declarations: + +```ts +{ + state: { 'zone.salon.light.on': false }, // paths and initial values + stateAccess: [{ userId, patterns: ['zone.salon.*'] }], + events: [{ topic, directions: EventDirection.publishToUsers }], + eventAccess: [{ userId, publish: [], subscribe: [topic] }], + functions: { async 'lighting.set'(call) { /* authorize, act, return */ } }, +} +``` + +`stateAccess[].patterns` is the disclosure boundary. A user sees exactly those +paths and nothing else. Widen it deliberately, one path at a time, and never with +a wildcard that happens to be convenient. + +Three ordering and failure rules that are easy to get wrong: + +**State first, event second.** Write the state, *then* publish the event: + +```ts +await coordinator.state.set([{ path: STATE.lightOn, value: on }]); +await coordinator.events.publish(EVENT_LIGHT_CHANGED, { on }); +``` + +A subscriber that reacts to the event and immediately reads the state must never +see the old value. The reverse order is a race that will reproduce once a month +and waste a day. + +**Reject bad arguments with a typed application error.** `ApplicationCallError` +carries a numeric code the component can branch on. Do not throw a bare `Error` +for a caller mistake; the distinction between "you asked wrongly" and "something +broke" is the one the interface needs most. + +**A home has several coordinators.** Names are namespace sharding by convention. +The relay keeps ownership tables for topics, state paths and functions, detects +collisions and rejects with `4409`. If you claim `lighting.set` for the whole +house, you have taken a name another integration may need; scope what you own. + +## 5. The component + +Import from `@miakapp/component`. The whole public surface is one module, and the +whole rendering vocabulary is `ui.*`: + +``` +screen stack grid section layout +text status progress media output +button toggle input select interaction +``` + +You return one complete semantic tree per render; the trusted host draws it with +its own components. You do not ship CSS, you do not ship a DOM, and you cannot +style your way around the host. This is a constraint worth accepting rather than +fighting: it is what lets the host stay accessible, themed and localized without +auditing your code. + +Handlers are functions, not identifiers to wire up by hand: + +```ts +ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: asBoolean(home.state.get(LIGHT_ON)), + disabled: home.staging || !healthy, + pending, + onChange: (next) => void setLight(next), +}) +``` + +Handlers are registered for the render that created them, so a stale tree cannot +fire an action against new state. + +The limits are real and enforced: 1 024 nodes, depth 32, 30 renders per second, +32 outstanding calls, 8 KiB per text node. If you are approaching any of them you +are building a dashboard the household will not read. + +### Two states the interface must never hide + +**Staleness.** `home.state.stale` means the snapshot may no longer reflect the +house. Show it. Per RFC 0002 §12.2 it is exposed, never hidden — a thermostat +reading that is silently forty minutes old is worse than one labelled uncertain. + +**Staging.** `home.staging` is true while a release is staged: rendering works, +calls and events do not. Disable the controls and say why, as the template does. +An interface that looks live and silently does nothing is the worst outcome +available. + +### An unknown outcome is never retried + +This is the rule that will most tempt you to break it, so it is stated plainly: + +```ts +try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); +} catch (error) { + // Deliberately not retried. The call may already have reached the lamp, + // and the next state snapshot settles the question. + failure = error instanceof Error ? error.message : 'La commande a échoué'; +} +``` + +A failed call is not a call that did not happen. `CallOutcomeUnknownError` exists +precisely to name the case where the effect is undetermined, and the physical +world does not have a rollback. Surface it, let the next state snapshot settle +it, and let the person decide. The same principle has a CLI counterpart: exit +code 7. + +## 6. The intersection rule + +Every name a component may touch appears in **two** places, and the effective +grant is the intersection: + +| `miakapp.yaml` → `requires` | `coordinator/home.ts` | +| --- | --- | +| `state_read` | `stateAccess[].patterns` | +| `event_subscribe` | `eventAccess[].subscribe` | +| `event_publish` | `eventAccess[].publish` | +| `call` | `functions` | + +Asking for more than the coordinator grants **does not fail loudly at +publication**. The component simply never receives that path, and the interface +renders a hole — an empty card, a control that does nothing, a temperature that +is permanently unavailable. This is the single most common way a Miakapp home +breaks, and it breaks quietly. + +So: assert the correspondence in a test. `templates/home/test/home.test.ts` does +exactly this, and it is the reason a mismatch fails in CI rather than in +someone's living room. When you add a path, change four things in one commit — +the state declaration, the access pattern, the `requires` entry, and the test. + +## 7. The loop + +```bash +bun run check # typecheck → bundle → test → validate the artifact offline +``` + +Run it before every publication and in CI. It costs nothing and catches the +artifact rules the runtime would reject anyway. + +Do not treat a check that exists as a check that runs. On 2026-09-14 this +repository had a template and a component example that were both verified +locally and built by nobody, because the workflow called `bun run check` and not +`check:packages`; when CI finally exercised them they failed three times in a +row on defects that had been sitting there invisibly. If you add a package, add +it to the job that runs in CI, then watch one run go green before believing it. + +Reproduce CI with the toolchain version it pins, not the one you have. Bun 1.4 +self-references the root package and resolves `bunx` from `node_modules`; the +pinned 1.2.23 does neither, so a green local run proves nothing about the runner. +Install the pinned version alongside yours: + +```bash +BUN_INSTALL=/tmp/bun1223 curl -fsSL https://bun.sh/install | bash -s bun-v1.2.23 +``` + +Note also that `bun test foo/` is a **substring filter**, not a directory scope. + +## 8. Publishing + +The CLI builds, validates, publishes and rolls back. **It never owns Git.** It +writes no history, rewrites no source it did not generate, and invents no +control-plane endpoint. The repository is the owner's. + +```bash +miakapp agent-pack # once, per repository +miakapp init --home --control-plane +miakapp check +miakapp publish --expected-generation +miakapp activate --sha256 --expected-generation +miakapp rollback --sha256 --expected-generation # alias of activate +miakapp release # read one finalized release +miakapp upload # reconcile a lost request +``` + +`--expected-generation` is a compare-and-swap on the home's component pointer: the +generation you believe it currently holds. It is `0` for a home that has never +published. It is required, and it is what stops two agents from silently +overwriting each other. + +Rollback is `activate` pointed at a digest you already trust. Keep the digest of +every release you shipped; a rollback you can perform in one command is worth +more than an incident you can explain. + +### Driving the CLI as a program + +Every failure maps to exactly one stable exit code and one stable +machine-readable kind. New kinds may be added; existing codes never change +meaning. Pass `--json` and you get exactly one closed object on stdout. + +| Code | Kind | Meaning | +| --- | --- | --- | +| 0 | `success` | | +| 1 | `usage` | the invocation was wrong | +| 2 | `project` | `miakapp.yaml` is missing or invalid | +| 3 | `artifact` | the built bytes violate an artifact rule | +| 4 | `authorization` | the Home Key is missing, wrong or unscoped | +| 5 | `contract` | the control plane rejected the request | +| 6 | `conflict` | `--expected-generation` did not match; re-read, do not retry | +| 7 | `unknown_outcome` | **effect undetermined — reconcile with a read** | + +Branch on the code, not on the prose. On 7, call `miakapp upload ` or +`miakapp release ` and reconcile before acting again. Never retry a 7 +with a fresh capability. + +### If you speak MCP instead of shell + +`miakapp mcp` serves the same commands as tools over JSON-RPC on stdio. It is the +same code: a tool call becomes the argv a person would have typed and runs the +same dispatch, so everything above still holds — the same defaults, the same +validation, the same `kind` on every failure. + +Three differences are worth knowing before you call anything: + +- `miakapp_publish`, `miakapp_activate` and `miakapp_rollback` refuse to run + without `confirm: true`. Set it when the owner asked for that publication, and + not to get past an error; +- a failure arrives as a tool result with `isError: true`, carrying the same + closed object, not as a JSON-RPC error. A JSON-RPC error means your call never + happened; `isError` means it ran and failed, and `kind` says what to do next; +- a tool argument is the option name with `_` instead of `-`. An argument the + tool does not declare is refused, never ignored. + +`packages/cli/README.md` lists the tools. The exit codes above are the +`exit_code` field in every result, so branch on the same table either way. + +## 9. Secrets + +`MIAKAPP_HOME_KEY` comes from the environment. It is never a command-line +argument, never printed, never written into a project file. No command accepts +one, deliberately: an argument lands in shell history, in a process listing and +in most CI logs. + +```bash +export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +``` + +If you are about to write a secret into `miakapp.yaml` so something works, stop: +that is the failure this design exists to prevent. + +## 10. Before you tell the owner you are done + +- [ ] Every physically consequential function authorizes its caller on its first + line, and a test proves an unauthorized caller is refused. +- [ ] Every name in `miakapp.yaml` is covered by the coordinator, and a test + asserts the correspondence. +- [ ] `home.state.stale` and `home.staging` are visible in the interface. +- [ ] No call path retries an unknown outcome. +- [ ] State is written before its event is published. +- [ ] `bun run check` is green, and CI ran it — not just you. +- [ ] The Home Key exists only in the environment. +- [ ] You recorded the digest of what you published, so rollback is one command. + +## Where to read further + +- `packages/cli/README.md` — every command and every tool, including what the + pack writes and what it refuses to overwrite. +- `templates/home/README.md` — the mechanics of the template itself. +- `docs/rfcs/0001` (Miakapp-V3) — wire protocol: ownership, collisions, `4409`. +- `docs/rfcs/0002` — component runtime and the staleness rule. **The broker is + the authority for the guest ABI, not the RFC**: `component-runtime/src/runtime-broker.ts` + and `contract.ts` define the exact payloads, and the broker terminates the + instance rather than answering when a field is wrong. +- `docs/rfcs/0003` — the coordinator SDK surface. +- `docs/rfcs/0004` — Home Key bootstrap, scopes, publication. +- `docs/rfcs/0005` — the trusted browser client. diff --git a/packages/cli/package.json b/packages/cli/package.json index eb2ef8b..1067494 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ "miakapp": "./bin/miakapp.js" }, "files": [ + "assets", "bin", "dist", "LICENSE", diff --git a/packages/cli/src/agent-pack.ts b/packages/cli/src/agent-pack.ts new file mode 100644 index 0000000..bebb578 --- /dev/null +++ b/packages/cli/src/agent-pack.ts @@ -0,0 +1,255 @@ +/** + * The installable pack. + * + * A coding agent handed someone's house does not arrive knowing how a Miakapp + * home is built. It arrives in the owner's repository, with whatever files are + * already there. This command puts three things in that repository: + * + * - the guide, as a file, so the knowledge survives without a network; + * - a pointer to it in the instruction file each client actually reads — + * `AGENTS.md` for Codex, `CLAUDE.md` for Claude Code; + * - the MCP server entry, so the tools are wired rather than described. + * + * The repository belongs to the owner, and the CLI's rule that it never + * rewrites what it did not generate holds here too. That rule is what shapes + * every merge below: the guide is a file this command owns outright, the + * instruction files are edited only between markers this command wrote, and + * `.mcp.json` is edited as a structure — one key, by name — never as text. + * Bytes outside those regions are copied through untouched. + */ +import { projectError } from './errors.js'; + +/** Directory the pack owns inside the owner's repository. */ +export const PACK_DIRECTORY = '.miakapp'; + +/** The guide, copied out of the package so it is readable offline. */ +export const GUIDE_FILE = `${PACK_DIRECTORY}/agent-guide.md`; + +/** Project-scope MCP configuration. Claude Code reads this file by name. */ +export const MCP_FILE = '.mcp.json'; + +/** The name the server is registered under, and the tool-name prefix. */ +export const SERVER_NAME = 'miakapp'; + +/** + * Instruction files, by the client that reads each one. Both are written: + * a repository is handed to whichever agent the owner has, and an unused + * pointer costs a paragraph. + */ +export const INSTRUCTION_FILES: readonly { readonly path: string; readonly client: string }[] = [ + { path: 'AGENTS.md', client: 'Codex' }, + { path: 'CLAUDE.md', client: 'Claude Code' }, +]; + +export const BEGIN_MARKER = ''; +export const END_MARKER = ''; + +/** What happened to one file, reported per file rather than summed. */ +export type PackAction = 'created' | 'updated' | 'unchanged'; + +export interface PackedFile { + readonly path: string; + readonly action: PackAction; +} + +export interface FileStore { + read(path: string): Promise; + write(path: string, bytes: Uint8Array): Promise; + replace(path: string, bytes: Uint8Array): Promise; + exists(path: string): Promise; + makeDirectory(path: string): Promise; +} + +const encoder = new TextEncoder(); + +function decode(bytes: Uint8Array, path: string): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw projectError( + `${path} is not readable as UTF-8 text`, + 'The pack edits text files in place; move this one aside and run the command again.', + ); + } +} + +/** + * Writes `bytes` at `path`, creating or replacing. + * + * Returns `unchanged` when the bytes already on disk are identical, so a second + * run of the pack reports honestly instead of claiming work it did not do. + */ +async function put( + files: FileStore, + path: string, + content: string, +): Promise { + const bytes = encoder.encode(content); + if (!await files.exists(path)) { + await files.write(path, bytes); + return { path, action: 'created' }; + } + if (decode(await files.read(path), path) === content) return { path, action: 'unchanged' }; + await files.replace(path, bytes); + return { path, action: 'updated' }; +} + +/** + * The block written into each instruction file. + * + * It is short on purpose. Everything an agent needs to know is in the guide, + * and a summary that drifts from the guide is worse than no summary: the agent + * would believe the stale copy it read first. + */ +export function instructionBlock(client: string): string { + return `${BEGIN_MARKER} +## Miakapp + +This repository is a Miakapp home: a coordinator that runs on the owner's +machine and owns state, events and authorization, and a component that runs +sandboxed in the household's browser and owns nothing but the interface. + +**Read \`${GUIDE_FILE}\` before writing or publishing anything here.** It is the +full guide, copied into this repository so it is readable offline, and it is the +source of truth for the rules below. + +The \`${SERVER_NAME}\` MCP server in \`${MCP_FILE}\` exposes the toolchain to ${client}: +inventory an existing installation, validate the project, publish, and roll back. +The same commands exist as \`${SERVER_NAME}\` on the command line; they are one +implementation, so neither surface can drift from the other. + +Three rules the guide explains and this file repeats because getting them wrong +is expensive: + +- \`publish\`, \`activate\` and \`rollback\` change what every device in the home + runs. Over MCP they refuse to act without \`confirm: true\`. Set it when the + owner asked for that publication, never to get past an error. +- Every failure carries a stable \`kind\`. Branch on it, not on the message. + \`conflict\` means re-read the pointer; \`unknown_outcome\` means the effect is + undetermined — reconcile with \`${SERVER_NAME} release\` or \`${SERVER_NAME} upload\` + before acting again, and never retry it. +- The Home Key lives in \`MIAKAPP_HOME_KEY\` in the environment. No command + accepts it as an argument, and it belongs in no file in this repository. +${END_MARKER}`; +} + +/** + * Merges the block into an instruction file. + * + * Three cases, and the owner's prose survives all three: no file, a file with + * no block, and a file with a block from an earlier run. The block is appended + * rather than prepended because the top of an instruction file is where the + * owner put what matters to them. + */ +export function mergeInstructions(existing: string | undefined, block: string): string { + if (existing === undefined || existing.trim() === '') return `${block}\n`; + + const start = existing.indexOf(BEGIN_MARKER); + if (start === -1) { + const separator = existing.endsWith('\n') ? '\n' : '\n\n'; + return `${existing}${separator}${block}\n`; + } + + const end = existing.indexOf(END_MARKER, start); + if (end === -1) { + throw projectError( + `An unterminated ${BEGIN_MARKER} block is open in this file`, + `Close it with ${END_MARKER}, or delete the block and run the pack again.`, + ); + } + return existing.slice(0, start) + block + existing.slice(end + END_MARKER.length); +} + +/** + * The server entry, as a client expects to find it. + * + * `command` is the bare binary name: the pack is written into a repository that + * may be opened on another machine, where an absolute path from this one would + * resolve to nothing. + */ +export function serverEntry(): Record { + return { + type: 'stdio', + command: SERVER_NAME, + args: ['mcp'], + }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Merges the server entry into an existing `.mcp.json`. + * + * Parsed and re-serialized rather than patched as text: every other server in + * the file is carried across by name, and a file that does not parse is refused + * instead of being overwritten with a valid one. An owner who hand-edited that + * file into a syntax error still wants their edit back. + */ +export function mergeMcpConfig(existing: string | undefined): string { + let document: Record = {}; + if (existing !== undefined && existing.trim() !== '') { + let parsed: unknown; + try { + parsed = JSON.parse(existing); + } catch (error) { + throw projectError( + `${MCP_FILE} is not valid JSON: ${error instanceof Error ? error.message : 'parse failed'}`, + 'The pack merges one entry into this file and will not replace it. Fix the JSON first.', + ); + } + if (!isPlainObject(parsed)) { + throw projectError( + `${MCP_FILE} must hold a JSON object`, + 'The pack merges one entry into this file and will not replace it.', + ); + } + document = parsed; + } + + const servers = document['mcpServers']; + if (servers !== undefined && !isPlainObject(servers)) { + throw projectError( + `${MCP_FILE} has an "mcpServers" key that is not an object`, + 'The pack merges one entry into this file and will not replace it.', + ); + } + + const merged = { ...(servers ?? {}), [SERVER_NAME]: serverEntry() }; + return `${JSON.stringify({ ...document, mcpServers: merged }, null, 2)}\n`; +} + +export interface PackResult { + readonly root: string; + readonly files: readonly PackedFile[]; +} + +/** + * Installs the pack under `root`. + * + * `guide` is passed in rather than read here so the caller decides where the + * guide comes from: the packaged asset in production, a fixture in a test. + */ +export async function installPack( + files: FileStore, + root: string, + guide: string, +): Promise { + const written: PackedFile[] = []; + + await files.makeDirectory(`${root}/${PACK_DIRECTORY}`); + written.push(await put(files, `${root}/${GUIDE_FILE}`, guide)); + + for (const { path, client } of INSTRUCTION_FILES) { + const full = `${root}/${path}`; + const existing = await files.exists(full) ? decode(await files.read(full), full) : undefined; + written.push(await put(files, full, mergeInstructions(existing, instructionBlock(client)))); + } + + const mcpPath = `${root}/${MCP_FILE}`; + const config = await files.exists(mcpPath) ? decode(await files.read(mcpPath), mcpPath) : undefined; + written.push(await put(files, mcpPath, mergeMcpConfig(config))); + + return { root, files: written }; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index e93442d..758b58d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -14,6 +14,7 @@ * {@link EXIT_CODE}), so a wrapper decides without parsing prose; * - `--json`, which prints exactly one closed object on stdout. */ +import { installPack } from './agent-pack.js'; import { prepareArtifact, type Artifact } from './artifact.js'; import { exchangePublisherToken, fetchDiscovery } from './control-plane.js'; import { discoverFlows, inventoryJson, type Inventory } from './discovery.js'; @@ -48,8 +49,19 @@ export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY'; export interface FileSystem { read(path: string): Promise; + /** Creates. Fails if the path exists: no command may clobber by accident. */ write(path: string, bytes: Uint8Array): Promise; + /** + * Creates or overwrites. + * + * Separate from {@link FileSystem.write} so overwriting is never the default + * a command falls into. Only `agent-pack` calls it, and only after merging + * the existing bytes, so what it replaces is a file it generated. + */ + replace(path: string, bytes: Uint8Array): Promise; exists(path: string): Promise; + /** Creates the directory and its parents. Succeeds if it already exists. */ + makeDirectory(path: string): Promise; } export interface CliHost { @@ -87,6 +99,7 @@ Usage Commands init Write ${PROJECT_FILE} in the current directory + agent-pack Install the guide and the MCP wiring into a repository discover Inventory an existing Node-RED installation offline check Validate the project and the artifact offline publish Upload, finalize and activate the built artifact @@ -120,6 +133,9 @@ mcp options command above becomes one tool; publish, activate and rollback additionally require confirm: true. +agent-pack options + --dir Repository to install into (default: cwd) + init options --home Home ID to write into ${PROJECT_FILE} (required) --control-plane Control-plane issuer (required) @@ -142,6 +158,7 @@ const GLOBAL_OPTIONS = ['project'] as const; /** Exported so the MCP surface can be proved to expose every option, and no other. */ export const COMMAND_OPTIONS: Record = { init: ['home', 'control-plane', 'artifact', 'release'], + 'agent-pack': ['dir'], discover: ['flows'], check: [], publish: ['expected-generation', 'generation', 'release'], @@ -277,6 +294,12 @@ async function nodeFileSystem(): Promise { async write(path, bytes) { await fs.writeFile(path, bytes, { flag: 'wx' }); }, + async replace(path, bytes) { + await fs.writeFile(path, bytes); + }, + async makeDirectory(path) { + await fs.mkdir(path, { recursive: true }); + }, async exists(path) { try { await fs.access(path); @@ -406,6 +429,61 @@ async function runInit(host: CliHost, invocation: Invocation): Promise { + const { fileURLToPath } = await import('node:url'); + return fileURLToPath(new URL('../assets/agent-guide.md', import.meta.url)); +} + +/** + * Installs the agent pack. Like `discover`, it loads no project file: the + * repository it prepares is usually one that has no V4 project yet. + */ +async function runAgentPack(host: CliHost, invocation: Invocation): Promise { + const filesystem = await files(host); + const root = invocation.options.get('dir') ?? host.cwd(); + if (!await filesystem.exists(root)) { + throw projectError( + `No directory at ${root}`, + 'Point --dir at the repository to install into, or run the command inside it.', + ); + } + + const guidePath = await guideAssetPath(); + let guide: string; + try { + guide = new TextDecoder('utf-8', { fatal: true }).decode(await filesystem.read(guidePath)); + } catch { + throw projectError( + `The packaged guide is missing or unreadable at ${guidePath}`, + 'Reinstall @miakapp/cli: the pack copies the guide out of the package, never off the network.', + ); + } + + const result = await installPack(filesystem, root, guide); + const changed = result.files.filter((entry) => entry.action !== 'unchanged').length; + return { + summary: changed === 0 + ? `The pack in ${root} is already current` + : `Installed the agent pack in ${root}`, + fields: result.files.map((entry) => [entry.path, entry.action] as Field), + json: { + root: result.root, + changed, + files: result.files.map((entry) => ({ path: entry.path, action: entry.action })), + }, + }; +} + /** * Reads an existing installation. `discover` never loads the project file: an * agent runs it on a house that has no V4 project yet, which is the whole point @@ -622,6 +700,8 @@ export async function dispatch(host: CliHost, invocation: Invocation): Promise = {}): Promise { + const files = new MemoryFiles(entries); + await files.makeDirectory(PROJECT_ROOT); + await files.write(await guideAssetPath(), new TextEncoder().encode(GUIDE_TEXT)); + return files; +} + +function config(files: MemoryFiles): Record { + return JSON.parse(files.text(`${PROJECT_ROOT}/${MCP_FILE}`)); +} + +/** A failure prints its one object on stderr, which is where `--json` puts it. */ +function failure(host: { stderr(): string }): Record { + return JSON.parse(host.stderr()) as Record; +} + +describe('the pack installs into an empty repository', () => { + test('it writes the guide, both instruction files and the server entry', async () => { + const files = await repository(); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + const result = host.json(); + expect(result['root']).toBe(PROJECT_ROOT); + expect(result['changed']).toBe(4); + expect((result['files'] as { path: string; action: string }[]).map((entry) => entry.action)) + .toEqual(['created', 'created', 'created', 'created']); + + expect(files.text(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(GUIDE_TEXT); + for (const { path } of INSTRUCTION_FILES) { + expect([path, files.text(`${PROJECT_ROOT}/${path}`).includes(GUIDE_FILE)]).toEqual([path, true]); + } + expect(config(files)['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('the server is launched by bare name, so the repository is portable', () => { + const entry = serverEntry(); + expect(entry['command']).toBe(SERVER_NAME); + expect(entry['args']).toEqual(['mcp']); + expect(JSON.stringify(entry)).not.toContain('/'); + }); + + test('it installs where --dir points, not where the process happens to be', async () => { + const files = await repository(); + await files.makeDirectory('/srv/another-home'); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--dir', '/srv/another-home', '--json'], host)) + .toBe(EXIT_CODE.success); + + expect(await files.exists(`/srv/another-home/${GUIDE_FILE}`)).toBe(true); + expect(await files.exists(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(false); + }); + + test('a directory that does not exist is a project error, not a silent mkdir', async () => { + const host = testHost({ files: await repository() }); + + expect(await run(['agent-pack', '--dir', '/srv/absent', '--json'], host)) + .toBe(EXIT_CODE.project); + expect(failure(host)['kind']).toBe('project'); + }); + + test('a missing packaged guide fails loudly instead of installing an empty one', async () => { + const files = new MemoryFiles(); + await files.makeDirectory(PROJECT_ROOT); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.project); + expect(failure(host)['message']).toContain('guide'); + expect(await files.exists(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe(false); + }); +}); + +describe('the pack keeps what the owner wrote', () => { + test('prose already in an instruction file survives, and the block is appended', async () => { + const existing = '# Our house\n\nRun the tests before you touch the heating.\n'; + const files = await repository({ [`${PROJECT_ROOT}/AGENTS.md`]: existing }); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + const written = files.text(`${PROJECT_ROOT}/AGENTS.md`); + expect(written.startsWith(existing)).toBe(true); + expect(written).toContain(BEGIN_MARKER); + expect(written).toContain(END_MARKER); + }); + + test('a second run updates the block in place instead of stacking copies', () => { + const first = mergeInstructions('# Our house\n', instructionBlock('Codex')); + const stale = first.replace('## Miakapp', '## Miakapp (an older pack wrote this)'); + const second = mergeInstructions(stale, instructionBlock('Codex')); + + expect(second.split(BEGIN_MARKER).length - 1).toBe(1); + expect(second).not.toContain('an older pack wrote this'); + expect(second.startsWith('# Our house\n')).toBe(true); + expect(second).toBe(first); + }); + + test('text after the block is carried across untouched', () => { + const withTail = `${instructionBlock('Codex')}\n\n## After\n\nKept.\n`; + const merged = mergeInstructions(withTail, instructionBlock('Claude Code')); + + expect(merged).toContain('## After\n\nKept.\n'); + expect(merged).toContain('Claude Code'); + }); + + test('an unterminated block is refused rather than guessed at', () => { + expect(() => mergeInstructions(`# House\n\n${BEGIN_MARKER}\nhalf a block\n`, 'x')) + .toThrow(/unterminated/i); + }); + + test('other servers and other keys in .mcp.json are kept by name', () => { + const merged = JSON.parse(mergeMcpConfig(JSON.stringify({ + $schema: 'https://example.test/mcp.json', + mcpServers: { + sentry: { type: 'http', url: 'https://mcp.sentry.dev/mcp' }, + }, + }))); + + expect(merged['$schema']).toBe('https://example.test/mcp.json'); + expect(merged['mcpServers']['sentry']).toEqual({ type: 'http', url: 'https://mcp.sentry.dev/mcp' }); + expect(merged['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('an earlier miakapp entry is replaced, not duplicated', () => { + const merged = JSON.parse(mergeMcpConfig(JSON.stringify({ + mcpServers: { [SERVER_NAME]: { type: 'stdio', command: '/opt/old/miakapp', args: ['serve'] } }, + }))); + + expect(Object.keys(merged['mcpServers'])).toEqual([SERVER_NAME]); + expect(merged['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); + + test('a .mcp.json that does not parse is refused and left on disk', async () => { + const broken = '{ "mcpServers": { oops\n'; + const files = await repository({ [`${PROJECT_ROOT}/${MCP_FILE}`]: broken }); + const host = testHost({ files }); + + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.project); + expect(failure(host)['kind']).toBe('project'); + expect(files.text(`${PROJECT_ROOT}/${MCP_FILE}`)).toBe(broken); + }); + + test('an mcpServers key of the wrong shape is refused', () => { + expect(() => mergeMcpConfig('{"mcpServers": []}')).toThrow(/not an object/); + expect(() => mergeMcpConfig('["a list"]')).toThrow(/JSON object/); + }); + + test('an empty file is treated as an empty document, not as a parse failure', () => { + expect(JSON.parse(mergeMcpConfig(''))['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + expect(JSON.parse(mergeMcpConfig(undefined))['mcpServers'][SERVER_NAME]).toEqual(serverEntry()); + }); +}); + +describe('running the pack twice is safe', () => { + test('the second run changes nothing and says so', async () => { + const files = await repository(); + + expect(await run(['agent-pack', '--json'], testHost({ files }))).toBe(EXIT_CODE.success); + const second = testHost({ files }); + expect(await run(['agent-pack', '--json'], second)).toBe(EXIT_CODE.success); + + const result = second.json(); + expect(result['changed']).toBe(0); + expect((result['files'] as { action: string }[]).every((entry) => entry.action === 'unchanged')) + .toBe(true); + + const prose = testHost({ files }); + expect(await run(['agent-pack'], prose)).toBe(EXIT_CODE.success); + expect(prose.stdout()).toContain('already current'); + }); + + test('a guide that moved on is rewritten, and reported as updated', async () => { + const files = await repository(); + expect(await run(['agent-pack', '--json'], testHost({ files }))).toBe(EXIT_CODE.success); + + await files.replace(await guideAssetPath(), new TextEncoder().encode('# Newer guide\n')); + const host = testHost({ files }); + expect(await run(['agent-pack', '--json'], host)).toBe(EXIT_CODE.success); + + expect(files.text(`${PROJECT_ROOT}/${GUIDE_FILE}`)).toBe('# Newer guide\n'); + const guide = (host.json()['files'] as { path: string; action: string }[]) + .find((entry) => entry.path.endsWith(GUIDE_FILE)); + expect(guide?.action).toBe('updated'); + }); +}); + +describe('the block tells an agent what it must not get wrong', () => { + test('it names the guide, the server and the confirmation rule', () => { + const block = instructionBlock('Codex'); + expect(block).toContain(GUIDE_FILE); + expect(block).toContain(MCP_FILE); + expect(block).toContain('confirm: true'); + expect(block).toContain('MIAKAPP_HOME_KEY'); + expect(block).toContain('unknown_outcome'); + }); + + test('each instruction file names the client that reads it', () => { + for (const { path, client } of INSTRUCTION_FILES) { + expect([path, instructionBlock(client).includes(client)]).toEqual([path, true]); + } + expect(INSTRUCTION_FILES.map((entry) => entry.path)).toEqual(['AGENTS.md', 'CLAUDE.md']); + }); +}); + +describe('the packaged guide is the repository guide', () => { + test('the asset the command reads is byte-identical to docs/agent-guide.md', async () => { + const shipped = await readFile(await guideAssetPath()); + const source = await readFile(fileURLToPath(new URL('../../../docs/agent-guide.md', import.meta.url))); + + // If this fails, docs/agent-guide.md changed and packages/cli/assets did + // not. Copy it across: the pack installs the asset, so a stale asset ships + // an agent the wrong rules. + expect(shipped.equals(source)).toBe(true); + }); + + test('the guide is served as a tool and reaches the command as plain argv', () => { + const tool = TOOLS.find((entry) => entry.name === 'miakapp_agent_pack'); + expect(tool?.command).toBe('agent-pack'); + expect(tool?.guarded).toBe(false); + expect(tool?.readOnly).toBe(false); + expect(buildArgv(tool!, { dir: '/srv/home' })).toEqual(['agent-pack', '--dir', '/srv/home']); + expect(buildArgv(tool!, {})).toEqual(['agent-pack']); + }); +}); diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts index 4f32e5b..f5b0fc5 100644 --- a/packages/cli/test/support/host.ts +++ b/packages/cli/test/support/host.ts @@ -24,11 +24,14 @@ component: export class MemoryFiles implements FileSystem { readonly entries: Map; + /** Directories, tracked separately so `exists` answers for both kinds. */ + readonly directories: Set; constructor(entries: Record = {}) { this.entries = new Map( Object.entries(entries).map(([path, text]) => [path, new TextEncoder().encode(text)]), ); + this.directories = new Set(); } async read(path: string): Promise { @@ -42,8 +45,16 @@ export class MemoryFiles implements FileSystem { this.entries.set(path, bytes); } + async replace(path: string, bytes: Uint8Array): Promise { + this.entries.set(path, bytes); + } + + async makeDirectory(path: string): Promise { + this.directories.add(path); + } + async exists(path: string): Promise { - return this.entries.has(path); + return this.entries.has(path) || this.directories.has(path); } text(path: string): string {