diff --git a/README.md b/README.md index dbf8085..fe1d332 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ client. It is currently an alpha while the Miakapp 4 stack is being completed. [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). +never be retried. Start a home interface with the published CLI: +`npx @miakapp/cli init --starter app`. [`templates/home`](templates/home) is an +optional developer example that builds against this repository's sources. ## Coordinator requirements diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 1687ea1..41025ba 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -30,10 +30,11 @@ Concretely: - **Ask once, at the start, and only for what you cannot find.** Access comes from pairing (§9), the existing installation from inventory (§3). Repeated permission questions in the middle of the work are a failure of the work. -- **You need nothing from the platform's source.** This guide, the CLI, the - public packages and `templates/home` are the whole toolchain. Never edit or - redeploy the Miakapp platform to make one house work; if a capability is - missing, report it to the owner as missing instead of patching around it. +- **You need nothing from the platform's source.** This guide and the published + CLI — including the app starter `miakapp init --starter app` writes — are the + whole toolchain for the interface. Never edit or redeploy the Miakapp + platform to make one house work; if a capability is missing, report it to the + owner as missing instead of patching around it. Two scopes must not be confused: @@ -68,10 +69,26 @@ 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. +Start the interface with the starter the CLI ships, in an empty directory: + +```bash +miakapp init --starter app # home and issuer from the paired context +bun run build && bun run check # Bun >= 1.2.23 bundles; Node >= 22.9 runs the CLI +``` + +It writes `miakapp.yaml`, `app/main.ts` (the interface to edit), +`app/miakapp.ts` (the bridge to the home, copied from this CLI release), +`package.json`, `tsconfig.json` and `app/README.md`. It needs no source +repository and no unpublished package, and it refuses — writing nothing — if +any of those files already exists. As generated it shows only what the home +shares, with an honest empty state; it is where your work starts, never what +you publish. Without `--starter`, `init` writes `miakapp.yaml` alone. + +`templates/home` in the MiakAPI source repository is an optional developer +example: a coordinator, an app and a component wired to the repository with +`file:` dependencies, some unpublished. Read it for ideas if you have it; +nothing in it is needed to build or publish a home. This guide covers the +judgment no starter can. ### Leave the repository readable by the next agent @@ -96,7 +113,7 @@ that contradicts the CLI installed beside it is worse than no guide. **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 +the coordinator's job and nobody else's. In the developer example that decision is the first line of the function body: ```ts @@ -209,7 +226,8 @@ keyword list should make it for you. ## 4. The coordinator -`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +The developer example's `coordinator/home.ts` (in `templates/home` of the MiakAPI +repository) shows the shape: 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, @@ -258,11 +276,11 @@ house, you have taken a name another integration may need; scope what you own. ### A whole-house application (default) -`miakapp init` writes an `app:` section; `templates/home/app/` is a working -example. You own the document: build any DOM, ship your own CSS, route with +`miakapp init --starter app` writes an `app:` section and a buildable app to +edit. You own the document: build any DOM, ship your own CSS, route with `location.hash`, use React, Svelte, Vue, charts or WebAssembly — anything a bundler can inline. The deliverable is **one classic-script IIFE** of at most -2 MiB, with no `import()` and no source map: +2 MiB, with no `import()` and no source map; the starter's `bun run build` is: ```bash bun build app/main.ts --format=iife --minify --outfile dist/app.js @@ -271,16 +289,20 @@ bun build app/main.ts --format=iife --minify --outfile dist/app.js Miakapp runs it in an isolated, opaque-origin frame below a permanent Miakapp bar, after the resident agreed to open the home. The frame has no network, no cookies or storage, no popups and no fullscreen; assets go in the bundle as -`data:`/`blob:` URLs. Its only way to the home is `@miakapp/app`: +`data:`/`blob:` URLs. Its only way to the home is `window.miakapp`, through the +bridge the starter writes as `app/miakapp.ts` — the source of the `@miakapp/app` +SDK, which is not on npm yet, so import the local file: ```ts -import { callErrorCode, connect } from '@miakapp/app'; +import { callErrorCode, connect } from './miakapp.js'; const home = connect(); home.subscribe((state) => render(state)); // also called once immediately await home.call('lighting.set', { on: true }); // rejects with a closed code ``` +Do not edit the bridge; a newer CLI's starter carries any change to it. + Declare every state path (exact or `prefix.*`) under `app.requires.state_read` and every function under `app.requires.call`. Events, media and `miakapp.*` functions are refused by `miakapp check`. Show `state.stale`, disable controls @@ -334,7 +356,7 @@ 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. +calls and events do not. Disable the controls and say why. An interface that looks live and silently does nothing is the worst outcome available. @@ -376,8 +398,8 @@ renders a hole — an empty card, a control that does nothing, a temperature tha 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 +So: assert the correspondence in a test. The developer example's +`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. @@ -418,7 +440,7 @@ control-plane endpoint. The repository is the owner's. miakapp docs start # this guide, from the installed CLI miakapp pair # once per home and machine (§9) miakapp agent-pack # once, per repository -miakapp init # app: by default; --kind component for a semantic tree +miakapp init --starter app # manifest + buildable app; plain init writes the manifest only miakapp check miakapp status # what is live now: generation, release, digest miakapp publish # upload, finalize, activate @@ -531,7 +553,7 @@ refused before any request: a key can never publish into the wrong house. Prefer one key per agent or machine, so the owner can revoke one without breaking the others. A coordinator running on the owner's machine still reads -its own key from its environment (`templates/home/README.md`). +its own key from its environment (the developer example's `coordinator/main.ts`). If you are about to write a secret into `miakapp.yaml` or any file in the repository so something works, stop: that is the failure this design exists to @@ -562,13 +584,14 @@ prevent. ## Where to read further -Everything a home needs is above, in the CLI and in the template. The RFCs and +Everything a home needs is above and in the CLI with its starter. The RFCs and platform paths below explain *why* the platform behaves as it does; you never need them, or the platform repository, to build and publish a home. - `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. +- `templates/home/README.md` (MiakAPI repository, optional) — the developer + example: a coordinator and both interface kinds. - `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` diff --git a/packages/cli/README.md b/packages/cli/README.md index 9ccdc94..139438c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13,17 +13,43 @@ object on stdout. ```bash printf '%s\n' "$CODE" | bunx @miakapp/cli pair --issuer "$MIAKAPP_ISSUER" -bunx @miakapp/cli init # home and issuer from the paired context -bunx @miakapp/cli check +bunx @miakapp/cli init --starter app # manifest + app to edit; home and issuer from the context +bun run build && bun run check # after the inventory and adapting app/main.ts bunx @miakapp/cli publish bunx @miakapp/cli status # verify what is live ``` Use the exact issuer command shown by your deployment's `/pair` page; do not -guess a staging issuer. `init` writes the project manifest, not an application: -build your house UI as one classic-script IIFE (the `@miakapp/app` SDK is -optional), then run `check` and `publish`. Open and verify the returned -`home_url` as a resident before handing it over. +guess a staging issuer. Open and verify the returned `home_url` as a resident +before handing it over. + +## The app starter + +`miakapp init --starter app` writes, beside `miakapp.yaml`, everything needed to +build a whole-house application with nothing but this package and Bun: + +| File | | +| --- | --- | +| `app/main.ts` | the interface to edit: shows what the home shares, grouped, with stale and empty states | +| `app/miakapp.ts` | the bridge to `window.miakapp` — byte for byte the `@miakapp/app` SDK source, which is not on npm yet | +| `package.json` | `build` (Bun IIFE to the manifest's artifact), `typecheck`, `check`; pins this CLI and TypeScript | +| `tsconfig.json` | strict TypeScript for `app/` | +| `app/README.md` | the loop, the prerequisites and what to do before publishing | + +Prerequisites: Node.js ≥ 22.9 for the CLI, Bun ≥ 1.2.23 for `bun run build` +(TypeScript, from `bun install`, only for `typecheck`). The starter accepts +`--artifact` (a plain relative `.js` path), `--release`, `--home`, +`--control-plane` and `--context`; it cannot be combined with +`--kind component`. + +It never overwrites anything: every target is checked first, and if any one +exists the command fails with exit 2 and writes nothing. Without `--starter`, +`init` writes `miakapp.yaml` alone, as before. + +The starter invents no value and offers no control. `app.requires` starts +empty, so a fresh build shows only an empty state: inventory the house, declare +the paths and calls, give them resident-facing names and a real layout, then +publish. ## The project file @@ -66,7 +92,7 @@ duplicate keys — is rejected with the offending line rather than guessed at. | --- | --- | | `pair` | Redeems a one-time pairing code into a new stored context. | | `context list\|show\|use\|remove` | Manages stored contexts. Never prints a key. | -| `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `init` | Writes `miakapp.yaml`; with `--starter app`, also a buildable app. Never overwrites. | | `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. | @@ -159,7 +185,7 @@ tools over newline-delimited JSON-RPC on stdio. | `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_init` | `init` | writes `miakapp.yaml` (and the app starter with `starter: "app"`), 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`** | @@ -300,5 +326,6 @@ talks to is deployed; publishing it to npm is a deliberate, separate step. Run `miakapp help` or append `--help` / `-h` to any command, including `miakapp pair --help`. Help never prompts for a code or executes the command. -`miakapp init` defaults to a whole-house app at `dist/app.js`; use -`--kind component` for a semantic component at `dist/component.js`. +`miakapp init` defaults to a whole-house app at `dist/app.js`; add +`--starter app` for the buildable starter, or use `--kind component` for a +semantic component at `dist/component.js`. diff --git a/packages/cli/assets/agent-guide.md b/packages/cli/assets/agent-guide.md index 1687ea1..41025ba 100644 --- a/packages/cli/assets/agent-guide.md +++ b/packages/cli/assets/agent-guide.md @@ -30,10 +30,11 @@ Concretely: - **Ask once, at the start, and only for what you cannot find.** Access comes from pairing (§9), the existing installation from inventory (§3). Repeated permission questions in the middle of the work are a failure of the work. -- **You need nothing from the platform's source.** This guide, the CLI, the - public packages and `templates/home` are the whole toolchain. Never edit or - redeploy the Miakapp platform to make one house work; if a capability is - missing, report it to the owner as missing instead of patching around it. +- **You need nothing from the platform's source.** This guide and the published + CLI — including the app starter `miakapp init --starter app` writes — are the + whole toolchain for the interface. Never edit or redeploy the Miakapp + platform to make one house work; if a capability is missing, report it to the + owner as missing instead of patching around it. Two scopes must not be confused: @@ -68,10 +69,26 @@ 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. +Start the interface with the starter the CLI ships, in an empty directory: + +```bash +miakapp init --starter app # home and issuer from the paired context +bun run build && bun run check # Bun >= 1.2.23 bundles; Node >= 22.9 runs the CLI +``` + +It writes `miakapp.yaml`, `app/main.ts` (the interface to edit), +`app/miakapp.ts` (the bridge to the home, copied from this CLI release), +`package.json`, `tsconfig.json` and `app/README.md`. It needs no source +repository and no unpublished package, and it refuses — writing nothing — if +any of those files already exists. As generated it shows only what the home +shares, with an honest empty state; it is where your work starts, never what +you publish. Without `--starter`, `init` writes `miakapp.yaml` alone. + +`templates/home` in the MiakAPI source repository is an optional developer +example: a coordinator, an app and a component wired to the repository with +`file:` dependencies, some unpublished. Read it for ideas if you have it; +nothing in it is needed to build or publish a home. This guide covers the +judgment no starter can. ### Leave the repository readable by the next agent @@ -96,7 +113,7 @@ that contradicts the CLI installed beside it is worse than no guide. **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 +the coordinator's job and nobody else's. In the developer example that decision is the first line of the function body: ```ts @@ -209,7 +226,8 @@ keyword list should make it for you. ## 4. The coordinator -`templates/home/coordinator/home.ts` is the shape to copy: the configuration is a +The developer example's `coordinator/home.ts` (in `templates/home` of the MiakAPI +repository) shows the shape: 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, @@ -258,11 +276,11 @@ house, you have taken a name another integration may need; scope what you own. ### A whole-house application (default) -`miakapp init` writes an `app:` section; `templates/home/app/` is a working -example. You own the document: build any DOM, ship your own CSS, route with +`miakapp init --starter app` writes an `app:` section and a buildable app to +edit. You own the document: build any DOM, ship your own CSS, route with `location.hash`, use React, Svelte, Vue, charts or WebAssembly — anything a bundler can inline. The deliverable is **one classic-script IIFE** of at most -2 MiB, with no `import()` and no source map: +2 MiB, with no `import()` and no source map; the starter's `bun run build` is: ```bash bun build app/main.ts --format=iife --minify --outfile dist/app.js @@ -271,16 +289,20 @@ bun build app/main.ts --format=iife --minify --outfile dist/app.js Miakapp runs it in an isolated, opaque-origin frame below a permanent Miakapp bar, after the resident agreed to open the home. The frame has no network, no cookies or storage, no popups and no fullscreen; assets go in the bundle as -`data:`/`blob:` URLs. Its only way to the home is `@miakapp/app`: +`data:`/`blob:` URLs. Its only way to the home is `window.miakapp`, through the +bridge the starter writes as `app/miakapp.ts` — the source of the `@miakapp/app` +SDK, which is not on npm yet, so import the local file: ```ts -import { callErrorCode, connect } from '@miakapp/app'; +import { callErrorCode, connect } from './miakapp.js'; const home = connect(); home.subscribe((state) => render(state)); // also called once immediately await home.call('lighting.set', { on: true }); // rejects with a closed code ``` +Do not edit the bridge; a newer CLI's starter carries any change to it. + Declare every state path (exact or `prefix.*`) under `app.requires.state_read` and every function under `app.requires.call`. Events, media and `miakapp.*` functions are refused by `miakapp check`. Show `state.stale`, disable controls @@ -334,7 +356,7 @@ 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. +calls and events do not. Disable the controls and say why. An interface that looks live and silently does nothing is the worst outcome available. @@ -376,8 +398,8 @@ renders a hole — an empty card, a control that does nothing, a temperature tha 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 +So: assert the correspondence in a test. The developer example's +`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. @@ -418,7 +440,7 @@ control-plane endpoint. The repository is the owner's. miakapp docs start # this guide, from the installed CLI miakapp pair # once per home and machine (§9) miakapp agent-pack # once, per repository -miakapp init # app: by default; --kind component for a semantic tree +miakapp init --starter app # manifest + buildable app; plain init writes the manifest only miakapp check miakapp status # what is live now: generation, release, digest miakapp publish # upload, finalize, activate @@ -531,7 +553,7 @@ refused before any request: a key can never publish into the wrong house. Prefer one key per agent or machine, so the owner can revoke one without breaking the others. A coordinator running on the owner's machine still reads -its own key from its environment (`templates/home/README.md`). +its own key from its environment (the developer example's `coordinator/main.ts`). If you are about to write a secret into `miakapp.yaml` or any file in the repository so something works, stop: that is the failure this design exists to @@ -562,13 +584,14 @@ prevent. ## Where to read further -Everything a home needs is above, in the CLI and in the template. The RFCs and +Everything a home needs is above and in the CLI with its starter. The RFCs and platform paths below explain *why* the platform behaves as it does; you never need them, or the platform repository, to build and publish a home. - `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. +- `templates/home/README.md` (MiakAPI repository, optional) — the developer + example: a coordinator and both interface kinds. - `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` diff --git a/packages/cli/assets/starter/app/app/README.md b/packages/cli/assets/starter/app/app/README.md new file mode 100644 index 0000000..fd2f75b --- /dev/null +++ b/packages/cli/assets/starter/app/app/README.md @@ -0,0 +1,73 @@ +# This home's interface + +Written by `miakapp init --starter app`. It is a Miakapp **whole-house +application** (`miakapp.app/1`): your own document, styles and navigation, +bundled into one classic-script file that Miakapp runs in an isolated frame for +the home's residents. + +``` +miakapp.yaml home, control plane, artifact, and what the app may read and call +package.json build, typecheck and check scripts +tsconfig.json strict TypeScript for app/ +app/main.ts the interface — edit this +app/miakapp.ts the bridge to the home (window.miakapp), copied from this CLI release +app/README.md this file +``` + +Nothing here depends on an unpublished package or on the MiakAPI source +repository. + +## Prerequisites + +- **Node.js ≥ 22.9** to run the `miakapp` CLI. +- **Bun ≥ 1.2.23** to bundle `app/main.ts` (`bun build … --format=iife`). Any + bundler that emits one classic-script IIFE with no `import()` works too. +- TypeScript only for `bun run typecheck` (`bun install` fetches it). + +## The loop + +```bash +bun install # the pinned @miakapp/cli and TypeScript, for the scripts below +bun run build # app/main.ts → the artifact named in miakapp.yaml +bun run check # build, then `miakapp check` validates it offline +npx miakapp publish # only once the interface is real (see below) +npx miakapp status # verify the digest you built is the one live +``` + +`npx miakapp` runs the CLI that `bun install` pinned in this project; a global +`miakapp` works the same way. + +`publish` prints `home_url`: the address residents open. Open it as a member +would and check it before handing it to anyone. The printed `artifact_url` is +the raw bundle, never a link to give. + +## Before the first publication + +As generated, the app shows every value the home shares with it — grouped by +the first segment of its path — and an honest empty state while nothing is +shared. `app.requires` in `miakapp.yaml` starts empty, so the first build +shows only that empty state. It invents no value and offers no control. That +is a starting point, not a deliverable: + +1. **Inventory the house first** (`miakapp docs start`, §3): every source, what + each value means, how fresh it is, who may see it, which actions exist. +2. **Declare what the app needs** under `app.requires.state_read` (exact paths + or `prefix.*`) and `app.requires.call`. The coordinator still decides per + resident; a path it does not share never reaches the app. +3. **Make it a home, not a path list.** Fill `LABELS` with resident-facing + names, then replace the generic layout with one organized by room or use. + No technical vocabulary on screen. +4. **Add controls only for actions the owner authorized.** Disable them while + `state.stale` is true. A call that fails with `outcome_unknown` may have + acted: never retry it, wait for the next state. + +## Rules the frame enforces + +- No network, no cookies or storage, no popups. Bundle images and fonts as + `data:`/`blob:` URLs. At most 2 MiB, no `import()`, no source map. +- The bundle is public to signed-in users who know its digest: put no household + data in it. Read everything from state. +- Never present stale values as current; never show a reading the home did not + send. +- Do not edit `app/miakapp.ts`. It is the canonical bridge source shipped with + the CLI that generated it, so a newer CLI starter carries any change. diff --git a/packages/cli/assets/starter/app/app/main.ts b/packages/cli/assets/starter/app/app/main.ts new file mode 100644 index 0000000..a9a1963 --- /dev/null +++ b/packages/cli/assets/starter/app/app/main.ts @@ -0,0 +1,106 @@ +/** + * This home's interface: a Miakapp whole-house application (miakapp.app/1). + * + * A starting point, not a finished home. As written it shows exactly what the + * home shares with this interface — every granted state path, grouped by its + * first segment, with an honest notice when the view is stale — and nothing + * else: no invented value, no control. Before publishing it for residents: + * + * 1. inventory the house (sources, values, freshness, possible actions); + * 2. declare the paths and calls you need in miakapp.yaml (app.requires); + * 3. give every path a resident-facing name in LABELS, and replace this layout + * with one organized the way people live in the house; + * 4. add controls only for calls the owner authorized, and never retry one + * that failed with `outcome_unknown`. + * + * You own the whole document: any DOM, CSS, router or library a bundler can + * inline. `bun run build` emits one classic-script IIFE. The only way to the + * home is `./miakapp.ts`, the bridge copied from this CLI release. + */ +import { connect, type HomeState } from './miakapp.js'; + +/** Resident-facing names, from your inventory. A path without one shows as the path. */ +const LABELS: Readonly> = {}; + +const home = connect(); +const french = home.locale.toLowerCase().startsWith('fr'); +const TEXT = french + ? { + empty: 'Rien de cette maison n’est encore partagé avec cette interface.', + stale: 'Dernières valeurs connues ; en attente de la maison.', + yes: 'Oui', + no: 'Non', + missing: '—', + } + : { + empty: 'Nothing from this home is shared with this interface yet.', + stale: 'Showing the last known values; waiting for the home.', + yes: 'Yes', + no: 'No', + missing: '—', + }; + +const style = document.createElement('style'); +style.textContent = ` + :root { color-scheme: light dark; font-family: system-ui, sans-serif; } + body { margin: 0; background: Canvas; color: CanvasText; } + h1 { margin: 0; padding: 20px 20px 8px; font-size: 1.5rem; } + main { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); padding: 16px; } + section { padding: 18px; border-radius: 18px; background: color-mix(in srgb, CanvasText 6%, Canvas); } + h2 { margin: 0 0 8px; font-size: 1rem; text-transform: capitalize; } + dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 6px 12px; } + dd { margin: 0; font-weight: 600; text-align: end; } + .notice { margin: 0 16px; padding: 10px 14px; border-radius: 12px; background: #ffe2a8; color: #4a3000; } + .empty { padding: 0 20px; opacity: .7; } + .stale dd { opacity: .55; } +`; +document.head.append(style); + +const title = document.createElement('h1'); +const notice = document.createElement('p'); +const main = document.createElement('main'); +document.body.append(title, notice, main); + +function element(tag: string, text?: string, className?: string): HTMLElement { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className !== undefined) node.className = className; + return node; +} + +function format(value: unknown): string { + if (value === undefined || value === null) return TEXT.missing; + if (typeof value === 'boolean') return value ? TEXT.yes : TEXT.no; + if (typeof value === 'number') return Number.isFinite(value) ? value.toLocaleString(home.locale) : TEXT.missing; + if (typeof value === 'string') return value; + return JSON.stringify(value); +} + +function render(state: HomeState): void { + title.textContent = home.name; + notice.className = state.stale ? 'notice' : ''; + notice.textContent = state.stale ? TEXT.stale : ''; + + const paths = home.paths(); + if (paths.length === 0) { + main.replaceChildren(element('p', TEXT.empty, 'empty')); + return; + } + const groups = new Map(); + for (const path of paths) { + const group = path.split('.')[0] ?? path; + groups.set(group, [...groups.get(group) ?? [], path]); + } + main.replaceChildren(...[...groups].map(([group, members]) => { + const section = element('section', undefined, state.stale ? 'stale' : undefined); + const list = element('dl'); + for (const path of members) { + list.append(element('dt', LABELS[path] ?? path), element('dd', format(state.values[path]))); + } + section.append(element('h2', group), list); + return section; + })); +} + +home.subscribe(render); +home.ready(); diff --git a/packages/cli/assets/starter/app/app/miakapp.ts b/packages/cli/assets/starter/app/app/miakapp.ts new file mode 100644 index 0000000..02d7cfd --- /dev/null +++ b/packages/cli/assets/starter/app/app/miakapp.ts @@ -0,0 +1,156 @@ +/** + * Typed access to a Miakapp whole-house application's one window on its home. + * + * A `miakapp.app/1` release is a single classic-script bundle that owns its + * whole document: any DOM, CSS, router or UI library. It runs in an isolated + * opaque-origin frame with no network and no storage, and the trusted shell + * installs exactly one object before the bundle runs: `window.miakapp`. This + * package types that object and adds the few helpers every house needs, so a + * house reads state and calls functions without reaching for `any`. + * + * Nothing here grants anything. What the house may read and call is its + * release's `requires`, narrowed by the coordinator per resident; a path the + * resident may not see never reaches the frame. + */ + +export const APP_ABI = 'miakapp.app/1' as const; + +export const CALL_ERROR_CODES = [ + 'denied', + 'unavailable', + 'failed', + 'outcome_unknown', + 'timeout', + 'busy', +] as const; + +/** + * Why a call did not complete. `outcome_unknown` means the home may have acted: + * never retry it, wait for the next state instead. + */ +export type CallErrorCode = (typeof CALL_ERROR_CODES)[number]; + +export type Theme = 'system' | 'light' | 'dark'; + +export interface HomeState { + readonly values: Readonly>; + readonly revision: number; + /** True when Miakapp no longer has a current view: show it, never hide it. */ + readonly stale: boolean; +} + +/** The object the trusted shell installs as `window.miakapp`. */ +export interface MiakappHost { + readonly abi: typeof APP_ABI; + readonly release: string; + readonly home: { readonly id: string; readonly name: string }; + readonly locale: string; + readonly theme: string; + onThemeChange(listener: (theme: string) => void): () => void; + readonly state: { + get(path: string): unknown; + values(): Readonly>; + readonly revision: number; + readonly stale: boolean; + subscribe(listener: (state: HomeState) => void): () => void; + }; + readonly can: { read(path: string): boolean; call(name: string): boolean }; + call(name: string, args?: unknown, options?: { timeoutMs?: number }): Promise; + ready(): void; +} + +export class MiakappUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'MiakappUnavailableError'; + } +} + +export interface Home { + readonly id: string; + readonly name: string; + readonly release: string; + /** BCP 47 language of the resident, e.g. `fr`. */ + readonly locale: string; + theme(): Theme; + onThemeChange(listener: (theme: Theme) => void): () => void; + /** The current granted view, immutable. */ + state(): HomeState; + get(path: string): T | undefined; + /** Granted paths, optionally under one dotted prefix, sorted. */ + paths(prefix?: string): string[]; + /** + * Calls `listener` with every new view. With `immediate` (the default) it is + * also called once now, so a render function can be the only listener. + */ + subscribe(listener: (state: HomeState) => void, options?: { immediate?: boolean }): () => void; + canRead(path: string): boolean; + canCall(name: string): boolean; + /** Rejects with an error whose `callErrorCode()` is one of `CALL_ERROR_CODES`. */ + call(name: string, args?: unknown, options?: { timeoutMs?: number }): Promise; + /** Lifts the shell's loading screen before the script finishes, if you want. */ + ready(): void; +} + +function theme(value: string): Theme { + return value === 'light' || value === 'dark' ? value : 'system'; +} + +function isHost(value: unknown): value is MiakappHost { + if (value === null || typeof value !== 'object') return false; + const candidate = value as Partial; + return candidate.abi === APP_ABI + && typeof candidate.call === 'function' + && typeof candidate.state === 'object' + && typeof candidate.state?.subscribe === 'function'; +} + +/** + * Connects to the home this frame was opened for. Throws when not running + * inside a Miakapp house frame — for example in a unit test or a plain browser + * tab — so a mistake fails loudly instead of rendering an empty home. + */ +export function connect(scope: { readonly miakapp?: unknown } = globalThis as { miakapp?: unknown }): Home { + const host = scope.miakapp; + if (!isHost(host)) { + throw new MiakappUnavailableError( + 'window.miakapp is missing: this bundle runs only inside a Miakapp house frame', + ); + } + const snapshot = (): HomeState => ({ + values: host.state.values(), + revision: host.state.revision, + stale: host.state.stale, + }); + return Object.freeze({ + id: host.home.id, + name: host.home.name, + release: host.release, + locale: host.locale, + theme: () => theme(host.theme), + onThemeChange: (listener: (value: Theme) => void) => host.onThemeChange((value) => listener(theme(value))), + state: snapshot, + get: (path: string) => host.state.get(path) as T | undefined, + paths: (prefix?: string) => Object.keys(host.state.values()) + .filter((path) => prefix === undefined || path === prefix || path.startsWith(`${prefix}.`)) + .sort(), + subscribe: (listener: (state: HomeState) => void, options: { immediate?: boolean } = {}) => { + const unsubscribe = host.state.subscribe(listener); + if (options.immediate ?? true) listener(snapshot()); + return unsubscribe; + }, + canRead: (path: string) => host.can.read(path), + canCall: (name: string) => host.can.call(name), + call: async (name: string, args?: unknown, options?: { timeoutMs?: number }) => ( + await host.call(name, args ?? null, options ?? {}) as T + ), + ready: () => host.ready(), + }); +} + +/** The closed reason a call failed, or undefined for anything else. */ +export function callErrorCode(error: unknown): CallErrorCode | undefined { + if (error === null || typeof error !== 'object' || !('code' in error)) return undefined; + const code = (error as { code: unknown }).code; + return (CALL_ERROR_CODES as readonly unknown[]).includes(code) ? code as CallErrorCode : undefined; +} diff --git a/packages/cli/assets/starter/app/tsconfig.json b/packages/cli/assets/starter/app/tsconfig.json new file mode 100644 index 0000000..04d8921 --- /dev/null +++ b/packages/cli/assets/starter/app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": ["app/**/*.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index d9c1a4f..b67f7df 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@miakapp/cli", - "version": "4.0.0-alpha.7", + "version": "4.0.0-alpha.8", "description": "Agent-first Miakapp command line: check, build, publish, activate and roll back home interfaces", "type": "module", "engines": { diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 182d595..4c82b5b 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -48,6 +48,13 @@ import { redeemPairingCode, validateLabel, } from './pairing.js'; +import { + STARTERS, + STARTER_FILES, + isStarter, + starterFiles, + validateStarterArtifact, +} from './starter.js'; import { PROJECT_FILE, PROJECT_SCHEMA, findProjectFile, parseProject, type Project } from './project.js'; import { activateRelease, @@ -141,7 +148,7 @@ Commands context show [name] Show one context, the current one by default context use Make a context current context remove Delete a context and its stored key - init Write ${PROJECT_FILE} in the current directory + init Write ${PROJECT_FILE} (add --starter app for a buildable app) 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 @@ -198,6 +205,12 @@ init options --artifact Built artifact path (default: dist/app.js for app, dist/component.js for component) --release Initial release name (default: 0.1.0) + --starter app Also write a self-contained app to edit: app/main.ts, + the app/miakapp.ts bridge, package.json (build and + check scripts), tsconfig.json and app/README.md. + Needs Bun >= 1.2.23 to build. Refuses, writing + nothing, if any of these files already exists. + Without it, init writes ${PROJECT_FILE} only. Credentials, highest precedence first 1. --context a context stored by miakapp pair @@ -224,7 +237,7 @@ export const COMMAND_OPTIONS: Record = { docs: [], pair: ['code', 'issuer', 'label', 'name'], context: [], - init: ['home', 'control-plane', 'artifact', 'release', 'context', 'kind'], + init: ['home', 'control-plane', 'artifact', 'release', 'context', 'kind', 'starter'], 'agent-pack': ['dir'], discover: ['flows'], check: [], @@ -648,38 +661,86 @@ async function runInit(host: CliHost, invocation: Invocation): Promise 0) { + throw projectError( + `${collisions.map((relative) => `${root}/${relative}`).join(', ')} already ` + + `${collisions.length === 1 ? 'exists' : 'exist'}; nothing was written`, + 'The CLI never overwrites a file. Run init in a new directory, or move these files aside.', + ); + } + const target = await initTarget(host, invocation); const source = projectTemplate({ home: target.home, controlPlane: target.controlPlane, - artifact: invocation.options.get('artifact') ?? (kind === 'app' ? 'dist/app.js' : 'dist/component.js'), + artifact, release, kind, }); // Parsed before it is written, so init can never emit a file check rejects. parseProject(root, source); + + const written: string[] = []; + if (starter !== undefined) { + const starterSources = await starterFiles({ home: target.home, artifact }); + try { + await filesystem.makeDirectory(`${root}/app`); + for (const [relative, bytes] of starterSources) { + await filesystem.write(`${root}/${relative}`, bytes); + written.push(relative); + } + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown failure'; + throw projectError( + `The starter could not be written (${reason}); ${PROJECT_FILE} was not written` + + (written.length === 0 ? '' : ` and these files were: ${written.join(', ')}`), + 'Remove the files listed, fix the directory, and run init again.', + ); + } + } + // The manifest goes last: a directory holding it always holds the whole starter. await filesystem.write(path, new TextEncoder().encode(source)); + written.push(PROJECT_FILE); + return { - summary: `Wrote ${path}`, + summary: starter === undefined + ? `Wrote ${path}` + : `Wrote an app starter in ${root}: inventory the house, declare app.requires, then build`, fields: [ ['project', path], ['home', target.home], ['control_plane', target.controlPlane], ...(target.context === undefined ? [] : [['context', target.context] as Field]), + ...(starter === undefined ? [] : [ + ['starter', starter] as Field, + ['files', written] as Field, + ['next', 'read app/README.md; bun run build; bun run check'] as Field, + ]), ], json: { project: path, @@ -688,6 +749,10 @@ async function runInit(host: CliHost, invocation: Invocation): Promise=1.2.23', node: '>=22.9' }, + scripts: { + build: `bun build app/main.ts --format=iife --minify --outfile ${artifact}`, + typecheck: 'tsc --noEmit', + check: 'bun run build && miakapp check', + }, + devDependencies: { + [PACKAGE_NAME]: CLI_VERSION, + typescript: STARTER_TYPESCRIPT, + }, + }, null, 2)}\n`; +} + +/** The starter's files, as bytes, keyed by their path relative to the project root. */ +export async function starterFiles(options: { + readonly home: string; + readonly artifact: string; +}): Promise> { + const files: Array = [ + ['package.json', new TextEncoder().encode(packageJson(options.home, options.artifact))], + ]; + for (const relative of STARTER_ASSETS) { + files.push([relative, new Uint8Array(await readFile(starterAssetPath(relative)))]); + } + return files; +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 781acc3..3bfc02f 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -12,4 +12,4 @@ */ export const PACKAGE_NAME = '@miakapp/cli'; -export const CLI_VERSION = '4.0.0-alpha.7'; +export const CLI_VERSION = '4.0.0-alpha.8'; diff --git a/packages/cli/test/package.test.ts b/packages/cli/test/package.test.ts index 9e05137..4d17e8a 100644 --- a/packages/cli/test/package.test.ts +++ b/packages/cli/test/package.test.ts @@ -36,5 +36,11 @@ describe('the published package', () => { expect(files).toContain('assets/agent-guide.md'); expect(files).toContain('bin/miakapp.js'); expect(files).toContain('dist/main.js'); + // The app starter is copied out of the installed package, so every file it + // writes must ship in the tarball. + for (const asset of ['tsconfig.json', 'app/main.ts', 'app/miakapp.ts', 'app/README.md']) { + expect(files).toContain(`assets/starter/app/${asset}`); + } + expect(files).toContain('dist/starter.js'); }); }); diff --git a/packages/cli/test/starter.test.ts b/packages/cli/test/starter.test.ts new file mode 100644 index 0000000..622f6ae --- /dev/null +++ b/packages/cli/test/starter.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { mutateContexts } from '../src/contexts.js'; +import { EXIT_CODE } from '../src/errors.js'; +import { run } from '../src/main.js'; +import { TOOLS, buildArgv, inputSchema } from '../src/mcp.js'; +import { STARTER_ASSETS, STARTER_FILES, starterAssetPath } from '../src/starter.js'; +import { CLI_VERSION } from '../src/version.js'; +import { homeKey } from './support/control-plane.js'; +import { MemoryFiles, PROJECT_ROOT, testHost } from './support/host.js'; + +const REPOSITORY = resolve(import.meta.dir, '../../..'); +const TSC = join(REPOSITORY, 'node_modules/.bin/tsc'); +const TARGET = ['--home', 'demo-home', '--control-plane', 'https://control.example.test']; + +let directory: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'miakapp-starter-')); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function listing(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) + .sort(); +} + +async function initStarter(extra: readonly string[] = [], root = directory) { + const host = testHost({ cwd: root }); + const code = await run(['init', '--starter', 'app', ...TARGET, '--json', ...extra], host); + return { code, host }; +} + +describe('init without --starter is unchanged', () => { + test('it writes the manifest and nothing else', async () => { + const files = new MemoryFiles(); + const host = testHost({ files }); + expect(await run(['init', ...TARGET, '--json'], host)).toBe(EXIT_CODE.success); + expect([...files.entries.keys()]).toEqual([`${PROJECT_ROOT}/miakapp.yaml`]); + expect(files.directories.size).toBe(0); + expect(host.json()['starter']).toBeNull(); + }); +}); + +describe('init --starter app', () => { + test('writes the complete starter and a manifest check accepts', async () => { + const { code, host } = await initStarter(); + expect(code).toBe(EXIT_CODE.success); + expect(await listing(directory)).toEqual([...STARTER_FILES, 'miakapp.yaml'].sort()); + expect(host.json()).toMatchObject({ kind: 'app', starter: 'app', artifact: 'dist/app.js', release: '0.1.0' }); + + const manifest = await readFile(join(directory, 'miakapp.yaml'), 'utf8'); + expect(manifest).toContain('home: demo-home\ncontrol_plane: https://control.example.test\n'); + expect(manifest).toContain(' state_read: []\n call: []\n'); + + const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { + scripts: Record; + dependencies?: unknown; + devDependencies: Record; + }; + expect(pkg.scripts['build']).toBe('bun build app/main.ts --format=iife --minify --outfile dist/app.js'); + // Only published packages, pinned: no source repository, no unpublished SDK. + expect(pkg.dependencies).toBeUndefined(); + expect(pkg.devDependencies).toEqual({ '@miakapp/cli': CLI_VERSION, typescript: '7.0.2' }); + for (const relative of STARTER_ASSETS) { + const text = await readFile(join(directory, relative), 'utf8'); + expect([relative, text.includes('file:'), /@miakapp\/(app|component)['"]/.test(text)]) + .toEqual([relative, false, false]); + } + }); + + test('a custom artifact and release reach both the manifest and the build script', async () => { + const { code } = await initStarter(['--artifact', 'build/house.js', '--release', '2026-10-03.1']); + expect(code).toBe(EXIT_CODE.success); + const manifest = await readFile(join(directory, 'miakapp.yaml'), 'utf8'); + expect(manifest).toContain(' artifact: build/house.js\n release: 2026-10-03.1\n'); + const pkg = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { scripts: Record }; + expect(pkg.scripts['build']).toEndWith('--outfile build/house.js'); + }); + + test('home and issuer come from the selected context', async () => { + const store = join(directory, 'store'); + const key = homeKey(); + await mutateContexts(store, (state) => { + state.contexts.set('cabin', { + name: 'cabin', + issuer: 'https://control.example.test/api', + homeId: 'cabin-home', + keyId: key.slice(5, 27), + label: 'test', + createdAt: new Date().toISOString(), + }); + state.keys.set('cabin', key); + state.current = 'cabin'; + }); + const project = join(directory, 'project'); + await mkdir(project); + const host = testHost({ cwd: project, env: { MIAKAPP_CONFIG_DIR: store } }); + expect(await run(['init', '--starter', 'app', '--json'], host)).toBe(EXIT_CODE.success); + expect(host.json()).toMatchObject({ home_id: 'cabin-home', context: 'cabin' }); + expect(await readFile(join(project, 'miakapp.yaml'), 'utf8')) + .toContain('home: cabin-home\ncontrol_plane: https://control.example.test/api\n'); + expect(JSON.parse(await readFile(join(project, 'package.json'), 'utf8'))['name']).toBe('miakapp-home-cabin-home'); + }); + + for (const existing of [...STARTER_FILES, 'miakapp.yaml']) { + test(`an existing ${existing} refuses the whole starter and writes nothing`, async () => { + await mkdir(join(directory, 'app'), { recursive: true }); + await writeFile(join(directory, existing), 'owner bytes\n'); + const before = await listing(directory); + + const { code, host } = await initStarter(); + expect(code).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain(`${existing} already exists; nothing was written`); + expect(await listing(directory)).toEqual(before); + expect(await readFile(join(directory, existing), 'utf8')).toBe('owner bytes\n'); + }); + } + + test('every collision is named at once, and no directory is created', async () => { + const files = new MemoryFiles({ + [`${PROJECT_ROOT}/package.json`]: '{}', + [`${PROJECT_ROOT}/app/main.ts`]: '// mine', + }); + const host = testHost({ files }); + expect(await run(['init', '--starter', 'app', ...TARGET], host)).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain(`${PROJECT_ROOT}/package.json, ${PROJECT_ROOT}/app/main.ts already exist`); + expect([...files.entries.keys()].sort()).toEqual([`${PROJECT_ROOT}/app/main.ts`, `${PROJECT_ROOT}/package.json`]); + expect(files.directories.size).toBe(0); + }); + + for (const [label, argv] of [ + ['an unknown starter', ['--starter', 'component']], + ['a component kind', ['--starter', 'app', '--kind', 'component']], + ['an artifact needing shell quoting', ['--starter', 'app', '--artifact', 'dist/my app.js']], + ['an artifact outside the project', ['--starter', 'app', '--artifact', '../app.js']], + ['an artifact that is a starter file', ['--starter', 'app', '--artifact', 'app/main.ts']], + ['a non-script artifact', ['--starter', 'app', '--artifact', 'dist/app.mjs']], + ] as const) { + test(`${label} is a usage failure that writes nothing`, async () => { + const host = testHost({ cwd: directory }); + expect(await run(['init', ...TARGET, ...argv], host)).toBe(EXIT_CODE.usage); + expect(await readdir(directory)).toEqual([]); + }); + } + + test('help documents the starter and runs nothing', async () => { + const host = testHost({ cwd: directory }); + expect(await run(['init', '--starter', 'app', '--help'], host)).toBe(EXIT_CODE.success); + expect(host.stdout()).toContain('--starter app'); + expect(host.stdout()).toContain('Bun >= 1.2.23'); + expect(await readdir(directory)).toEqual([]); + }); + + test('MCP exposes the same option', () => { + const tool = TOOLS.find((entry) => entry.name === 'miakapp_init'); + expect(tool).toBeDefined(); + expect(Object.keys(inputSchema(tool!)['properties'] as object)).toContain('starter'); + expect(buildArgv(tool!, { starter: 'app' })).toEqual(['init', '--starter', 'app']); + }); +}); + +describe('the bridge is the canonical SDK', () => { + test('app/miakapp.ts is byte-identical to packages/app/src/index.ts', async () => { + const shipped = await readFile(starterAssetPath('app/miakapp.ts')); + const canonical = await readFile(join(REPOSITORY, 'packages/app/src/index.ts')); + // If this fails, the @miakapp/app source changed and the starter copy did + // not: copy it across, so new homes get the bridge the shell expects. + expect(shipped.equals(canonical)).toBe(true); + }); +}); + +/** Just enough DOM for the starter: no network, no storage, nothing else. */ +class FakeElement { + children: Array = []; + className = ''; + constructor(readonly tagName: string) {} + set textContent(value: string) { + this.children = [String(value)]; + } + get textContent(): string { + return this.children.map((child) => (typeof child === 'string' ? child : child.textContent)).join(''); + } + append(...nodes: Array): void { + this.children.push(...nodes); + } + replaceChildren(...nodes: Array): void { + this.children = [...nodes]; + } + find(tag: string): FakeElement[] { + return this.children.flatMap((child) => (typeof child === 'string' + ? [] + : [...(child.tagName === tag ? [child] : []), ...child.find(tag)])); + } +} + +function frame(values: Record, options: { stale?: boolean; locale?: string } = {}) { + const listeners: Array<(state: unknown) => void> = []; + let current = { values, stale: options.stale ?? false, revision: 1 }; + let readyCalls = 0; + const host = { + abi: 'miakapp.app/1', + release: '0.1.0', + home: { id: 'demo-home', name: 'Maison Test' }, + locale: options.locale ?? 'en', + theme: 'system', + onThemeChange: () => () => {}, + state: { + get: (path: string) => current.values[path], + values: () => current.values, + get revision() { return current.revision; }, + get stale() { return current.stale; }, + subscribe: (listener: (state: unknown) => void) => { + listeners.push(listener); + return () => {}; + }, + }, + can: { read: (path: string) => path in current.values, call: () => false }, + call: async () => { throw new Error('the starter must not call anything'); }, + ready: () => { readyCalls += 1; }, + }; + const document = { + head: new FakeElement('head'), + body: new FakeElement('body'), + createElement: (tag: string) => new FakeElement(tag), + }; + return { + context: { miakapp: host, document }, + body: document.body, + readyCalls: () => readyCalls, + push(next: Record, stale = false) { + current = { values: next, stale, revision: current.revision + 1 }; + for (const listener of listeners) listener({ values: next, stale, revision: current.revision }); + }, + }; +} + +describe('the generated starter builds, typechecks, checks and runs', () => { + let bundle: string; + + beforeEach(async () => { + expect((await initStarter()).code).toBe(EXIT_CODE.success); + const build = Bun.spawnSync(['bun', 'run', 'build'], { cwd: directory, stdout: 'pipe', stderr: 'pipe' }); + expect(build.exitCode).toBe(0); + bundle = await readFile(join(directory, 'dist/app.js'), 'utf8'); + }); + + test('strict TypeScript accepts it and miakapp check accepts the artifact', async () => { + const typecheck = Bun.spawnSync([TSC, '-p', directory], { stdout: 'pipe', stderr: 'pipe' }); + expect(typecheck.stdout.toString() + typecheck.stderr.toString()).toBe(''); + expect(typecheck.exitCode).toBe(0); + + const host = testHost({ cwd: directory }); + expect(await run(['check', '--json'], host)).toBe(EXIT_CODE.success); + expect(host.json()).toMatchObject({ abi: 'miakapp.app/1', home_id: 'demo-home' }); + }); + + test('with nothing shared it shows an honest empty state and no value', () => { + const fixture = frame({}); + runInNewContext(bundle, fixture.context); + expect(fixture.body.textContent).toContain('Maison Test'); + expect(fixture.body.textContent).toContain('Nothing from this home is shared with this interface yet.'); + expect(fixture.body.find('dd')).toEqual([]); + expect(fixture.readyCalls()).toBe(1); + }); + + test('it shows exactly the shared values, grouped, and marks a stale view', () => { + const fixture = frame({ + 'climate.salon.temperature': 25.3, + 'climate.entree.temperature': null, + 'zone.salon.light.on': true, + }, { locale: 'fr' }); + runInNewContext(bundle, fixture.context); + expect(fixture.body.find('h2').map((node) => node.textContent)).toEqual(['climate', 'zone']); + expect(fixture.body.find('dd').map((node) => node.textContent)).toEqual(['—', '25,3', 'Oui']); + + fixture.push({ 'climate.salon.temperature': 24.1 }, true); + expect(fixture.body.textContent).toContain('Dernières valeurs connues ; en attente de la maison.'); + expect(fixture.body.find('dd').map((node) => node.textContent)).toEqual(['24,1']); + expect(fixture.body.find('section')[0]?.className).toBe('stale'); + }); + + test('outside a Miakapp frame it fails loudly instead of rendering an empty home', () => { + const fixture = frame({}); + expect(() => runInNewContext(bundle, { document: fixture.context.document })) + .toThrow(/window\.miakapp is missing/); + }); +}); + +describe('the docs send a new agent through the starter', () => { + test('the bundled guide and the CLI README name the starter and never an unpublished import', async () => { + const guide = await readFile(join(import.meta.dir, '../assets/agent-guide.md'), 'utf8'); + const readme = await readFile(join(import.meta.dir, '../README.md'), 'utf8'); + for (const [name, text] of [['guide', guide], ['README', readme]] as const) { + expect([name, text.includes('init --starter app')]).toEqual([name, true]); + expect([name, text.includes('app/miakapp.ts')]).toEqual([name, true]); + expect([name, /from '@miakapp\/app'/.test(text)]).toEqual([name, false]); + } + expect(guide).toContain('optional developer'); + }); +}); diff --git a/templates/home/README.md b/templates/home/README.md index fe9db8d..09c0b1e 100644 --- a/templates/home/README.md +++ b/templates/home/README.md @@ -1,29 +1,38 @@ -# A Miakapp home - -A complete, working home: a coordinator that owns the logic, a component that is -the interface, and a project file that ties them to a control plane. It controls -one lamp and reports one temperature. Replace those with yours. +# A Miakapp home — developer example + +> **Building a home? Do not start here.** The public path needs only the +> published CLI and Bun: `npx @miakapp/cli init --starter app` writes a +> buildable app, its bridge, its build configuration and a README, with no +> source repository and no unpublished package. See `miakapp docs start`. +> +> This directory is a developer example that lives inside the MiakAPI +> repository and builds against its sources. It is verified in this +> repository's CI; it is not a supported way to create a home. + +A working example of the three parts of a home: a coordinator that owns the +logic and authorization, a whole-house application that is the interface, and a +project file that ties them to a control plane. It controls one lamp and +reports one temperature. ``` coordinator/home.ts what this home is, as data — testable without a network coordinator/main.ts the only file that touches the outside world -component/main.ts the interface, running in a sandboxed Worker +app/main.ts the interface: a whole-house application (miakapp.app/1) +app/view.ts what the interface shows, as a pure function +component/main.ts the same home as a semantic component (miakapp.component/1) miakapp.yaml home, control plane, artifact, capability requirements test/home.test.ts the coordinator's authorization, tested without a relay +test/app.test.ts the interface's decisions, tested without a browser ``` -## Copying this out of the MiakAPI repository - -The three Miakapp dependencies use `file:` paths so the template stays verified -inside the repository. Replace them with published versions: - -```bash -bun remove miakapi @miakapp/component @miakapp/cli -bun add miakapi @miakapp/component -bun add -d @miakapp/cli -``` +## Why it does not leave this repository as is -Nothing else in the template refers to the repository. +Its Miakapp dependencies are `file:` paths into this repository — +`miakapi`, `@miakapp/component`, `@miakapp/cli` and `@miakapp/app` — and +`@miakapp/app` is not published on npm. Copied elsewhere, `bun install` fails. +For a new home's interface, use `miakapp init --starter app`, whose +`app/miakapp.ts` is the same bridge source. For a coordinator, the published +`miakapi` package and `coordinator/` here are the reference. ## Running it