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..d71ded3 --- /dev/null +++ b/docs/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/README.md b/packages/cli/README.md index aefa23e..0a7c9ce 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,18 +56,115 @@ 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. | | `activate` | Activates an already finalized digest at a new generation. | | `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 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. + +## 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 +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_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`** | + +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/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/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/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/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..758b58d 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -14,8 +14,10 @@ * {@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'; import { CliError, EXIT_CODE, @@ -47,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 { @@ -60,17 +73,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; @@ -84,12 +99,15 @@ 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 activate Activate an already finalized digest at a new generation 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 @@ -107,6 +125,17 @@ 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) + +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. + +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) @@ -126,14 +155,18 @@ 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'], + 'agent-pack': ['dir'], + discover: ['flows'], check: [], publish: ['expected-generation', 'generation', 'release'], activate: ['sha256', 'expected-generation', 'generation'], rollback: ['sha256', 'expected-generation', 'generation'], release: [], upload: [], + mcp: [], help: [], version: [], }; @@ -261,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); @@ -390,6 +429,131 @@ 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 + * of the command. + */ +async function runDiscover(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); @@ -526,10 +690,20 @@ 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); + case 'agent-pack': + return await runAgentPack(host, invocation); + case 'discover': + return await runDiscover(host, invocation); case 'check': return await runCheck(host, invocation); case 'publish': @@ -588,6 +762,20 @@ export async function run(argv: readonly string[], host: CliHost): Promise All flows download.', + }], + readOnly: true, + guarded: false, + }, + { + name: 'miakapp_agent_pack', + title: 'Install the pack into a repository', + command: 'agent-pack', + description: + 'Install the Miakapp agent pack into a repository: the full guide as a file under ' + + '.miakapp/, a pointer to it in AGENTS.md and CLAUDE.md, and this MCP server in .mcp.json. ' + + 'Run it once in a home repository that does not have it, so the next agent opening that ' + + 'repository finds the rules and the tools already wired. It edits rather than replaces: ' + + 'prose outside the miakapp markers is kept, and every other server in .mcp.json is kept ' + + 'by name. Safe to run twice — a file already current is reported unchanged.', + args: [{ + name: 'dir', + type: 'string', + required: false, + description: 'Repository to install into. Defaults to the working directory.', + }], + readOnly: false, + 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/agent-pack.test.ts b/packages/cli/test/agent-pack.test.ts new file mode 100644 index 0000000..c1d98ea --- /dev/null +++ b/packages/cli/test/agent-pack.test.ts @@ -0,0 +1,249 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'bun:test'; +import { + BEGIN_MARKER, + END_MARKER, + GUIDE_FILE, + INSTRUCTION_FILES, + MCP_FILE, + SERVER_NAME, + instructionBlock, + mergeInstructions, + mergeMcpConfig, + serverEntry, +} from '../src/agent-pack.js'; +import { EXIT_CODE } from '../src/errors.js'; +import { guideAssetPath, run } from '../src/main.js'; +import { TOOLS, buildArgv } from '../src/mcp.js'; +import { MemoryFiles, PROJECT_ROOT, testHost } from './support/host.js'; + +const GUIDE_TEXT = '# Building a Miakapp home\n\nThe packaged guide.\n'; + +/** A repository with the packaged guide in place and whatever else is given. */ +async function repository(entries: Record = {}): 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/discovery.test.ts b/packages/cli/test/discovery.test.ts new file mode 100644 index 0000000..3b9fa59 --- /dev/null +++ b/packages/cli/test/discovery.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'bun:test'; +import { discoverFlows, MAXIMUM_FLOWS_BYTES } from '../src/discovery.js'; +import { EXIT_CODE } from '../src/errors.js'; +import { run } from '../src/main.js'; +import { MemoryFiles, testHost } from './support/host.js'; +import { FLOWS_EXPORT, FLOWS_PATH, flowsProject } from './support/flows.js'; + +function inventory(export_ = FLOWS_EXPORT) { + return discoverFlows(new TextEncoder().encode(export_)); +} + +function findingKinds(export_ = FLOWS_EXPORT): string[] { + return inventory(export_).findings.map((item) => 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/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/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 }); +} diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts index ac79033..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 { @@ -66,6 +77,7 @@ export function testHost(options: { fetch?: FetchLike; env?: Record; cwd?: string; + input?: AsyncIterable; } = {}): TestHost { const out: string[] = []; const err: string[] = []; @@ -79,6 +91,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,