diff --git a/bun.lock b/bun.lock index cbaa9c1..d3ac0dc 100644 --- a/bun.lock +++ b/bun.lock @@ -15,9 +15,18 @@ "typescript": "7.0.2", }, }, + "packages/app": { + "name": "@miakapp/app", + "version": "4.0.0-alpha.0", + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2", + }, + }, "packages/cli": { "name": "@miakapp/cli", - "version": "4.0.0-alpha.3", + "version": "4.0.0-alpha.5", "bin": { "miakapp": "./bin/miakapp.js", }, @@ -38,6 +47,8 @@ }, }, "packages": { + "@miakapp/app": ["@miakapp/app@workspace:packages/app"], + "@miakapp/cli": ["@miakapp/cli@workspace:packages/cli"], "@miakapp/component": ["@miakapp/component@workspace:packages/component"], diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6c0f1ce..1687ea1 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -10,6 +10,43 @@ 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. +## 0. What "done" means + +The owner asked for a home they can use, not for a picture of one. You are done +when the household can open the published interface, read real values from it +and use every control the owner authorized — and you checked that yourself. +Concretely: + +- **Publish, then verify.** A build that only runs on your machine, a mockup, a + prototype kept offline or a screenshot is an intermediate result, never the + deliverable. Do not stop to show one and ask whether to continue: the owner + asked for the home, and publication is reversible in one command (§8). +- **Hand over a link that works.** Give the owner the `home_url` that + `miakapp publish` and `miakapp status` print — the Miakapp address where the + home opens for its members — and say what you verified there. The + `artifact_url` (JSON `url`) is the raw bundle, not an interface: never send it + as "the link". If the control plane advertises no `home_url`, say so instead of + inventing one, and say what `miakapp status` proved. +- **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. + +Two scopes must not be confused: + +| In scope by default | Needs the owner's explicit, separate request | +| --- | --- | +| reading the existing installation (§3) | changing flows, automations, brokers or devices that already run the house | +| writing and testing a coordinator and a component | granting a physical action (heat, lock, unlock, open, close) that the owner has not named | +| publishing, verifying and rolling back the V4 interface | anything on the owner's own machines beyond the coordinator you were asked to run | + +Caution about the second column is not a reason to withhold the first. A new V4 +interface that only reads is still a useful, publishable V1; say plainly which +controls are not offered and why. + ## 1. What you are building Three artifacts, and no more: @@ -17,10 +54,16 @@ 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 | +| Interface | an isolated frame (app) or sandboxed Worker (component) in the household's browser | what residents see and touch | +| `miakapp.yaml` | neither; it is the contract | what the interface may ask for | + +The interface is, by default, a **whole-house application** (`app:` in +`miakapp.yaml`, ABI `miakapp.app/1`): your own layout, styles, navigation and +libraries, bundled into one file. A semantic **component** (`component:`, +`miakapp.component/1`) is still supported for a minimal tree Miakapp draws for +you. §5 covers both. -The coordinator is trusted. The component is not. The relay between them is +The coordinator is trusted. The interface 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. @@ -94,6 +137,37 @@ Write down what you found before you write the configuration. The state paths yo choose become a disclosure boundary and a public interface at the same time, and renaming one after the household has used it is not free. +### The inventory comes before the design + +Commit the inventory as `docs/inventory.md` in the home repository, one line per +datum or action, before you design a single screen: + +| Field | Example | +| --- | --- | +| source | `zigbee2mqtt/salon_thermo` over the house broker | +| what it is | temperature, °C, one decimal | +| how fresh | reports every ~60 s; last seen 2 min ago | +| who may read it | every member / owner only | +| command | none — a measurement, not a control | + +Then design **from** it: the V1 shows every datum the household would care +about and every action that is both technically possible and authorized, +grouped the way people live in the house — by room or by use, not by device +or protocol. Anything you leave out, leave out on purpose, and say why in the +inventory. A V1 that shows three values when the house reports twenty is not a +first version; it is an unfinished one. + +What the interface must never do: + +- **invent data.** A value with no source is shown as absent, never filled in + with a plausible number or a demo fixture; +- **blur freshness.** Distinguish *current*, *stale* (with its age) and + *unavailable* for every value, in words a resident understands; +- **spend space on plumbing.** No protocol or platform vocabulary + (`ACCEPTED`, "relay connected", ABI, schema names, IDs), no banner restating + that a view is read-only, no title repeating the home's name on every card. + Every line of text is either information or an action. + ### Reading a V3 house you inherited Most houses arriving at V4 already run Node-RED with the v3 MiakAPI nodes. Ask @@ -180,7 +254,43 @@ 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 +## 5. The interface + +### 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 +`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: + +```bash +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`: + +```ts +import { callErrorCode, connect } from '@miakapp/app'; + +const home = connect(); +home.subscribe((state) => render(state)); // also called once immediately +await home.call('lighting.set', { on: true }); // rejects with a closed code +``` + +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 +while it is true, and handle `outcome_unknown` exactly as below. + +**The bundle is not private.** Any signed-in user who learns its digest can +fetch it. Put no household data in it — no names, rooms, devices or accounts — +and read everything from state, which the coordinator filters per resident. + +### A semantic component (compatible) Import from `@miakapp/component`. The whole public surface is one module, and the whole rendering vocabulary is `ui.*`: @@ -305,25 +415,35 @@ 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 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 check -miakapp publish --expected-generation -miakapp activate --sha256 --expected-generation -miakapp rollback --sha256 --expected-generation # alias of activate +miakapp status # what is live now: generation, release, digest +miakapp publish # upload, finalize, activate +miakapp status # verify the new digest is what is live +miakapp activate --sha256 # promote an already finalized digest +miakapp rollback --sha256 # 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. +Activation is a compare-and-set on the home's component pointer. Without +`--expected-generation` the CLI reads the live generation first and activates +the next one; the read takes no lock, so another publication landing in between +still fails with `conflict` instead of being overwritten. Pass +`--expected-generation ` when you want to assert the state you reviewed. 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. +`miakapp status` after `publish` is the minimum verification: the live `sha256` +must be the one you just built. It proves what the home runs, not that the +interface is right — open its `home_url` as a member would before telling the +owner it works. + ### Driving the CLI as a program Every failure maps to exactly one stable exit code and one stable @@ -366,22 +486,68 @@ Three differences are worth knowing before you call anything: `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 +## 9. Access: pairing and contexts + +You never sign in as the owner and never ask for a password, a Google login or +an existing key. The owner grants access to one home, in their own browser: + +1. Ask the owner to open **https://miakapp.com/pair**, sign in to their own + account, choose the home (or create it), confirm the access it grants, and + send you the one-time code it shows. A code works once, for ten minutes. +2. Redeem it. The code is read from stdin and never echoed: -`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 + printf '%s\n' "" | miakapp pair # add --issuer if the page shows one + ``` + + Over MCP, call `miakapp_pair` with `code`. Never repeat the code back in a + message, and never retry a refused code — ask for a new one. +3. `pair` stores a **new, separately revocable** Home Key for this machine as a + context in `~/.miakapp`, keeps every other context, makes the new one + current, and proves the key can publish. It prints the key ID and label, + never the key. ```bash -export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +miakapp context list # every home this machine can publish to +miakapp context show [name] # one context; the key is never printed +miakapp context use # make a context current +miakapp context remove # delete it locally (the owner revokes it server-side) ``` -If you are about to write a secret into `miakapp.yaml` so something works, stop: -that is the failure this design exists to prevent. +`~/.miakapp/config.json` holds names, homes, issuers and key IDs; +`credentials.json` holds the keys. Both are mode 0600 in a 0700 directory and +are replaced atomically; the CLI refuses a credentials file other accounts can +read. `MIAKAPP_CONFIG_DIR` relocates the directory. + +Which key a command uses, highest precedence first: + +1. `--context `, then `MIAKAPP_CONTEXT=`; +2. `MIAKAPP_HOME_KEY`, a raw key for CI and compatibility; +3. the stored context paired with the home and issuer in `miakapp.yaml` — the + current context breaks a tie between two keys for that same home. + +A context, or a known key, for a different home than `miakapp.yaml` names is +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`). + +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 +prevent. ## 10. Before you tell the owner you are done +- [ ] `docs/inventory.md` lists every source, datum and action you found, and + the interface covers it or says why not. +- [ ] Every value shows whether it is current, stale or unavailable; nothing is + invented, and no technical vocabulary is on screen. +- [ ] The interface is published, `miakapp status` shows your digest live, and + you opened it as a member would — or you say precisely what you could not + check. +- [ ] The owner received a working link and the rollback digest, not a + screenshot or a mockup. - [ ] 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 @@ -390,11 +556,16 @@ that is the failure this design exists to prevent. - [ ] 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. +- [ ] The Home Key exists only in `~/.miakapp` or the environment, never in the + repository. - [ ] You recorded the digest of what you published, so rollback is one command. ## Where to read further +Everything a home needs is above, in the CLI and in the template. 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. diff --git a/package.json b/package.json index 1ed23cb..c013d5b 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,16 @@ "check": "bun run typecheck && bun run build && bun run test && bun run smoke:node && bun run smoke:browser && bun run test:contract && bun run pack:check", "check:cli": "bun --filter @miakapp/cli check", "check:component": "bun --filter @miakapp/component check", - "check:packages": "bun run check:cli && bun run check:component && bun run check:template", - "check:template": "bun run build && bun --filter @miakapp/component build && bun --filter @miakapp/cli build && cd templates/home && rm -rf node_modules && bun install --frozen-lockfile && bun run check", + "check:packages": "bun run check:cli && bun run check:component && bun run check:app && bun run check:template", + "check:template": "bun run build && bun --filter @miakapp/component build && bun --filter @miakapp/app build && bun --filter @miakapp/cli build && cd templates/home && rm -rf node_modules && bun install --frozen-lockfile && bun run check", "pack:check": "npm pack --dry-run", "prepublishOnly": "bun run check", "smoke:browser": "node scripts/check-browser-bundle.mjs", "smoke:node": "node test/node-smoke.mjs", "test": "bun test test/*.test.ts examples/*.test.ts packages/*/test/*.test.ts", "test:contract": "bun run build:contract && node scripts/check-contract.mjs", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:app": "bun --filter @miakapp/app check" }, "repository": { "type": "git", diff --git a/packages/app/LICENSE b/packages/app/LICENSE new file mode 100644 index 0000000..c5247b7 --- /dev/null +++ b/packages/app/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 Mathieu Colmon + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/app/README.md b/packages/app/README.md new file mode 100644 index 0000000..167917a --- /dev/null +++ b/packages/app/README.md @@ -0,0 +1,48 @@ +# @miakapp/app + +Typed SDK for a Miakapp **whole-house application** (`miakapp.app/1`). + +A house application is your home's own interface: any DOM, CSS, router or UI +library, bundled as **one classic-script IIFE** and published with +`miakapp publish`. Residents open it at the `home_url` the CLI prints. Miakapp +runs it in an isolated frame — no network, no storage, no access to the +resident's account or other homes — behind a consent screen and a permanent +Miakapp bar. Its only window on the home is `window.miakapp`; this package +types it. + +```ts +import { callErrorCode, connect } from '@miakapp/app'; + +const home = connect(); + +home.subscribe((state) => { + document.body.dataset.stale = String(state.stale); + for (const path of home.paths('room')) { + // render home.get(path) however you like + } +}); + +async function setLight(on: boolean): Promise { + try { + await home.call('lighting.set', { on }); + } catch (error) { + // 'outcome_unknown' means the home may have acted: never retry, wait for state. + console.warn(callErrorCode(error)); + } +} +``` + +## Rules the shell enforces + +- Declare every state path (exact or `prefix.*`) and every call in + `miakapp.yaml` under `app.requires.state_read` and `app.requires.call`. + Events, media and `miakapp.*` functions are refused. +- The coordinator still decides per resident: a path a resident may not see + never reaches the frame, and a call they may not make is refused. +- Bundle everything (≤ 2 MiB): images and fonts as `data:`/`blob:`. No + `import()`, no source maps, no external URLs. +- The artifact is public to signed-in users who know its digest. Put no + household data in it; read it from state. +- Show `state.stale`; never present old values as current. + +Build with Bun: `bun build app/main.ts --format=iife --minify --outfile dist/app.js`. diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 0000000..4f8a05b --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,50 @@ +{ + "name": "@miakapp/app", + "version": "4.0.0-alpha.0", + "description": "Typed SDK for Miakapp whole-house applications (miakapp.app/1): granted state, calls and preferences inside the isolated house frame", + "type": "module", + "engines": { + "bun": ">=1.2.23", + "node": ">=22.9" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test test/", + "typecheck": "tsc --noEmit", + "check": "bun run build && bun run typecheck && bun run test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Miakapp/MiakAPI.git", + "directory": "packages/app" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "Miakapp", + "MiakAPI", + "app", + "smart-home", + "iframe" + ], + "author": "Mathieu Colmon", + "license": "ISC", + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2" + } +} diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts new file mode 100644 index 0000000..02d7cfd --- /dev/null +++ b/packages/app/src/index.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/app/test/app.test.ts b/packages/app/test/app.test.ts new file mode 100644 index 0000000..f6aa404 --- /dev/null +++ b/packages/app/test/app.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test'; + +import { + APP_ABI, + MiakappUnavailableError, + callErrorCode, + connect, + type HomeState, + type MiakappHost, +} from '../src/index.js'; + +/** A stand-in for the object the trusted shell's bootstrap installs. */ +function fakeHost(values: Record, stale = false) { + let state: HomeState = { values, revision: 1, stale }; + const listeners = new Set<(state: HomeState) => void>(); + const calls: Array<{ name: string; args: unknown }> = []; + let ready = 0; + const host: MiakappHost = { + abi: APP_ABI, + release: 'r1', + home: { id: 'maison-a', name: 'Maison A' }, + locale: 'fr', + theme: 'dark', + onThemeChange: () => () => undefined, + state: { + get: (path) => state.values[path], + values: () => state.values, + get revision() { + return state.revision; + }, + get stale() { + return state.stale; + }, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + can: { + read: (path) => path.startsWith('room.'), + call: (name) => name === 'lighting.set', + }, + call: async (name, args) => { + calls.push({ name, args }); + if (name !== 'lighting.set') throw Object.assign(new Error('x'), { code: 'denied' }); + return { applied: true }; + }, + ready: () => { + ready += 1; + }, + }; + return { + host, + calls, + readyCount: () => ready, + emit(next: HomeState) { + state = next; + for (const listener of listeners) listener(next); + }, + }; +} + +describe('@miakapp/app', () => { + test('connects only inside a house frame', () => { + expect(() => connect({})).toThrow(MiakappUnavailableError); + expect(() => connect({ miakapp: { abi: 'miakapp.component/1' } })).toThrow(MiakappUnavailableError); + }); + + test('exposes the granted view, its paths and staleness', () => { + const reading = { kind: 'temperature', value: 21.5, unit: '°C', fresh: true }; + const fake = fakeHost({ 'room.kitchen.temperature': reading, 'room.kitchen.humidity': 48, 'security.doors.front': false }); + const home = connect({ miakapp: fake.host }); + expect(home.name).toBe('Maison A'); + expect(home.theme()).toBe('dark'); + expect(home.get('room.kitchen.temperature')).toEqual(reading); + expect(home.paths('room')).toEqual(['room.kitchen.humidity', 'room.kitchen.temperature']); + expect(home.paths('room.sal')).toEqual([]); + expect(home.canRead('security.doors.front')).toBe(false); + + const seen: HomeState[] = []; + const stop = home.subscribe((state) => seen.push(state)); + expect(seen).toHaveLength(1); + fake.emit({ values: {}, revision: 2, stale: true }); + expect(seen[1]).toEqual({ values: {}, revision: 2, stale: true }); + stop(); + fake.emit({ values: {}, revision: 3, stale: false }); + expect(seen).toHaveLength(2); + }); + + test('calls through the shell and reports the closed failure reason', async () => { + const fake = fakeHost({}); + const home = connect({ miakapp: fake.host }); + await expect(home.call('lighting.set', { on: true })).resolves.toEqual({ applied: true }); + const failure = await home.call('door.unlock').catch((error: unknown) => error); + expect(callErrorCode(failure)).toBe('denied'); + expect(callErrorCode(new Error('plain'))).toBeUndefined(); + expect(fake.calls).toEqual([ + { name: 'lighting.set', args: { on: true } }, + { name: 'door.unlock', args: null }, + ]); + home.ready(); + expect(fake.readyCount()).toBe(1); + }); +}); diff --git a/packages/app/tsconfig.build.json b/packages/app/tsconfig.build.json new file mode 100644 index 0000000..6103c93 --- /dev/null +++ b/packages/app/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "stripInternal": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json new file mode 100644 index 0000000..5a4e4e7 --- /dev/null +++ b/packages/app/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "bun", + "node" + ], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 0a7c9ce..7b8fb81 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # @miakapp/cli -Build, validate, publish and roll back one Miakapp home component. +Pair, build, validate, publish and roll back independent Miakapp house applications. The CLI is the deployment mechanism, not the source of truth. **Git belongs to you**: this tool never commits, never rewrites sources it did not generate and @@ -12,11 +12,19 @@ stable exit code and one stable failure kind, and `--json` prints exactly one object on stdout. ```bash -bunx @miakapp/cli init --home my-home --control-plane https://control.miakapp.app +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 publish --expected-generation 0 +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. + ## The project file `miakapp.yaml` sits at the root of your repository: @@ -24,24 +32,25 @@ bunx @miakapp/cli publish --expected-generation 0 ```yaml schema: miakapp.project/1 home: my-home -control_plane: https://control.miakapp.app +control_plane: https://control.example.test -component: - artifact: dist/component.js - release: 2026-09-13.1 +app: + artifact: dist/app.js + release: 2026-10-03.1 requires: state_read: - climate.living_room.temperature - event_subscribe: [] - event_publish: [] call: - lighting.set - presentation: [] coordinator: entry: coordinator/main.ts ``` +`app` declares only `state_read` and `call`; events, media and reserved +`miakapp.*` calls are unavailable. Use `miakapp init --kind component` for the +semantic component ABI instead. Choose exactly one of `app` and `component`. + `requires` is the closed RFC 0002 capability object. Lists are de-duplicated and sorted before they are bound into an upload capability, so the value the CLI sends is byte-identical on every later reconciliation read. @@ -55,10 +64,13 @@ duplicate keys — is rejected with the offending line rather than guessed at. | Command | What it does | | --- | --- | +| `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. | | `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. | +| `status` | Reads the live generation, release and digest of the home. | | `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. | @@ -111,9 +123,9 @@ 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. +The generated server entry uses `npx -y @miakapp/cli@ mcp`, +so a new machine gets the same executable as the bundled guide without relying +on a global binary or this machine'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`. @@ -129,8 +141,7 @@ tools over newline-delimited JSON-RPC on stdio. "mcpServers": { "miakapp": { "command": "bunx", - "args": ["@miakapp/cli", "mcp"], - "env": { "MIAKAPP_HOME_KEY": "${MIAKAPP_HOME_KEY}" } + "args": ["@miakapp/cli", "mcp"] } } } @@ -138,6 +149,12 @@ tools over newline-delimited JSON-RPC on stdio. | Tool | Command | | | --- | --- | --- | +| `miakapp_pair` | `pair` | stores a new context; the code is a tool argument | +| `miakapp_context_list` | `context list` | read-only, offline | +| `miakapp_context_show` | `context show` | read-only, offline | +| `miakapp_context_use` | `context use` | offline, changes the current context | +| `miakapp_context_remove` | `context remove` | **deletes a stored key — needs `confirm: true`** | +| `miakapp_status` | `status` | read-only | | `miakapp_discover` | `discover` | read-only, offline | | `miakapp_check` | `check` | read-only, offline | | `miakapp_release` | `release` | read-only | @@ -154,7 +171,8 @@ 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 +The three pointer-moving tools and `miakapp_context_remove` 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. @@ -165,20 +183,69 @@ 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 +## Access: pairing and contexts + +The owner grants access in their own browser; the agent never signs in. They +open `https://miakapp.com/pair`, choose the home, confirm, and send the +one-time code, which `pair` trades for a **new, separately revocable** Home Key: + +```bash +printf '%s\n' "$CODE" | miakapp pair # or run it in a terminal: hidden prompt +miakapp pair --code "$CODE" # allowed, but lands in shell history +``` + +| Option | Default | +| --- | --- | +| `--issuer ` | `https://control.miakapp.com` | +| `--label