From 3e92bd52ac5c02c425d1b2a8fd5b88c9774348bf Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 20:58:19 -0500 Subject: [PATCH 01/26] docs: manifest-first design spec (mattstack.deck.json) Co-Authored-By: Claude Fable 5 --- .../2026-08-28-deck-manifest-first-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md diff --git a/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md b/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md new file mode 100644 index 0000000..08bbbb9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md @@ -0,0 +1,154 @@ +# deck manifest-first: mattstack.deck.json — design + +Date: 2026-08-28 +Status: ratified (decisions approved by Matt in-session, via forms) +Builds on: `2026-08-27-mattstack-app-launcher-design.md` (the existing +`mattstack.json` launcher manifest and its ingest path, which this design +generalizes and supersedes as the manifest surface). + +## Problem + +Getting an app onto deck today means remembering `deck add` flags (`--cmd`, +`--dir`, `--port`), and the app itself declares nothing about how it is +built or deployed. Redeploying a managed app from source is a hand-run +loop per app (chat: `bun run build && deck restart chat`; deck itself: +build, install the binary, self-restart). The launcher manifest +(`mattstack.json`) exists but is metadata-only and scoped to managed +products. + +The goal: an app declares itself in one file, `deck register` from its +directory does the rest, and the deck board gains per-app action buttons +(deploy, build, anything named) that exist only in dev mode. + +## The manifest: `mattstack.deck.json` + +Lives at the app repo root. Example (chat): + +```json +{ + "name": "chat", + "displayName": "Chat", + "description": "rt chat viewer", + "icon": "public/icon.svg", + "port": 11002, + "commands": { + "start": "bun run serve", + "build": "bun run build", + "deploy": "bun run deploy" + }, + "altConfigs": { + "dev": { "port": 5173, "commands": { "start": "bun run dev" } } + } +} +``` + +Rules: + +- **Commands are shell strings**, executed via `sh -c` with the app's + `workingDirectory` as cwd. (Friendlier than argv; the manifest is + hand-written by app authors.) +- **`commands.start`** is what deck supervises as the service. Every OTHER + entry in `commands` is an action command: it becomes a dev-mode board + button, an API route, and a CLI verb (below). Names are free-form + (`deploy`, `build`, `migrate`, ...), `start` is the only reserved key. +- **`altConfigs`** is a name-keyed map of overlays. An overlay may override + ONLY `port` and `commands.start` (the serve shape, e.g. an HMR dev + server). It may never override action commands, identity fields, or the + icon: an app has one deploy story regardless of mode. +- **`port`** optional for `kind: external`-style setups deck already + supports; when present with `commands.start`, register creates a + supervised service. +- Identity/launcher fields (`displayName`, `description`, `icon`) keep the + semantics and validation of the existing manifest (SVG icon, 64 KB cap, + ingest to the deck icon store). +- **Universality**: ANY deck app may carry the manifest, not only + mattstack-managed products. The launcher discovery API's managed-only + filter is unchanged; the board is where every app shows. +- **Migration**: `mattstack.json` remains readable as a deprecated + fallback (identity fields only) for a window; `mattstack.deck.json` + wins when both exist. + +## CLI flow + +- **`deck config init`**: scaffolds `mattstack.deck.json` in cwd. Infers + `name` from the directory, pre-fills `commands.start`/`build` from + `package.json` scripts when present, prompts for (or defaults) the port. + Never overwrites an existing manifest. +- **`deck register`**: reads the manifest in cwd (or `--dir`) and creates + or updates the whole app record from it: name, port, supervised start + command, identity ingest, action commands. Zero flags in the happy path. + Re-running syncs the record to the manifest: the manifest is the source + of truth for everything it declares, and register subsumes the existing + `deck manifest refresh` verb. +- **`deck alt `**: activates a declared overlay (restarts + the service on the overlay's serve shape) or returns to the base config. + The board's existing dev-override toggle maps onto declared alts for + manifested apps; the flag-based `deck override` survives for + unmanifested ones. +- **`deck cmd `**: runs an action command (CLI twin of the + button). Dev-mode gated like the route. +- **`deck add`** survives unchanged for quick, unmanifested apps. + +## Action commands: routes, buttons, gate + +- **Route**: `POST /api/v1/apps/:name/commands/:cmd` next to + `apps/managed/restart`. Refuses unknown command names and apps without a + manifest. Spawns the shell string in the app's `workingDirectory`, + streams output into the app's existing deck log, returns + `{ started: true, runId }` immediately; `GET + /api/v1/apps/:name/commands/:cmd/:runId` reports running/exit status. + One action command at a time per app (409 on overlap). +- **Board**: a button per action command on the app's row, with + running/failed state surfaced the way restart already is. Deck's own row + uses the identical path; the board tolerates the API connection dropping + during a self-restarting deploy and re-polls until the API returns. +- **Dev-mode gate**: deck reads rt's dev-mode (the platform + source-vs-bundle truth: deck ships inside the mattstack.app bundle with + rt, and rt is always present; `rt settings dev-mode`, backed by + `~/.mattstack/rt/dev-mode.json`). In production mode the command routes + are NOT registered (404, indistinguishable from absent), status rows + carry no command metadata, and the board renders no buttons. There is no + override and no env escape hatch. Reading is cached briefly; a failed + read counts as production (fail closed). +- **Safety**: commands come only from the manifest in the app's own + checkout; the API never accepts a request-supplied command line. Output + is capped in the log like service output. + +## First adopters + +- **chat**: manifest with `start`/`build`/`deploy` (deploy = build + `deck + restart chat`). +- **deck itself**: same contract; its `deploy` script builds, installs + `dist/deck` over `~/.local/bin/deck`, and runs `deck restart deck` (the + self-restart connection drop is expected and handled by the board's + re-poll). +- rt-managed adopt reads the same manifest so rt-spawned products flow + through the identical ingest. + +## Testing + +deck's existing harness (scratch state dir via env paths, fake HOME, +`FakeServiceManager`/`FakeEdgeProxy`/`FakeTunnelDriver`): + +- Manifest parse + validation, fallback precedence over `mattstack.json`, + alt overlay resolution (only `port`/`commands.start` override; anything + else in an overlay is rejected loudly at parse). +- `deck config init` scaffolding (inference from package.json, refuses to + overwrite). +- `deck register` create and sync paths (record mirrors manifest; removed + manifest fields clear their record fields). +- Dev gate both ways with a fake dev-mode reader: routes absent in + production, present in dev; fail-closed on read error. +- Command runs with a fake spawn: log streaming, run status, 409 overlap, + unknown command refusal. +- Board rendering gated on command metadata presence. + +## Deferred + +- Non-shell (argv) command form; per-command env; command timeouts beyond + the log cap. +- Widening the launcher discovery filter to manifested user apps + (explicitly kept managed-only for now). +- `deck alt` auto-selection tied to rt dev-mode (an overlay that activates + itself in dev) — attractive, but implicit mode-coupled serving is a + separate decision. From 6921dc5710662f782509e25d3c949c756e44f2a8 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:11:49 -0500 Subject: [PATCH 02/26] spec: record CLI cleanup (manifest refresh removed, adopt slimmed) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-08-28-deck-manifest-first-design.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md b/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md index 08bbbb9..caf14db 100644 --- a/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md +++ b/docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md @@ -89,6 +89,48 @@ Rules: button). Dev-mode gated like the route. - **`deck add`** survives unchanged for quick, unmanifested apps. +## CLI cleanup: verbs removed, slimmed, narrowed + +`register`, `alt`, and `cmd` retire or narrow part of the existing verb +surface. Audit of every current verb against the manifest model: + +**Removed** + +- **`deck manifest refresh `**: deleted. Its whole job (re-read the + app's manifest, re-ingest identity/icon via `ingestManifest`) is exactly + what `deck register` does on every run, so nothing is left for a separate + refresh to do. The `POST /api/v1/apps/:name/manifest/refresh` route is + deleted with it; register's sync path is its replacement. + +**Slimmed** + +- **`deck adopt [--as] [--managed-by]`**: survives as the claim verb + (assign `managedBy`, optional rename, force-bless the `.mattstack` route) + but stops carrying its own manifest ingest. It reads the manifest through + register's shared sync path, so an rt-spawned product and a hand-run + `deck register` ingest through identical code. (Considered and deferred: + folding adopt entirely into `deck register --managed-by --as ` + and dropping the verb. Kept separate because "claim as a managed product" + is a distinct intent from "sync my record to my manifest".) + +**Narrowed in role, kept** + +- **`deck add --cmd --dir --port`**: the manifest-free path. + `register` is now the primary registration route; `add` stays for quick + apps that never write a manifest. Behavior unchanged. +- **`deck override `**: the flag-based twin of `deck alt`. + For a manifested app the declared overlay (`deck alt dev`) is native + and the board dev-toggle maps onto it; `override` stays as the escape + hatch for unmanifested apps. + +**Untouched** (no manifest relationship): `status`/`list`, `url`, `remove`, +`restart`, `logs`, `publish`, `password`, `access`, `domain`, `migrate` +(+`--convert`), `version`/`--version`, `help`. + +Net: one verb deleted (`manifest refresh`), one slimmed (`adopt`), two +narrowed but retained (`add`, `override`). No other verb is dead weight +under the manifest model. + ## Action commands: routes, buttons, gate - **Route**: `POST /api/v1/apps/:name/commands/:cmd` next to @@ -137,6 +179,10 @@ deck's existing harness (scratch state dir via env paths, fake HOME, overwrite). - `deck register` create and sync paths (record mirrors manifest; removed manifest fields clear their record fields). +- Register subsumes `manifest refresh`: a re-run re-ingests identity/icon + (coverage moved off the deleted refresh route), and `deck adopt` + delegates its manifest ingest to the same sync path (adopt still assigns + `managedBy` and renames; the manifest read is no longer its own). - Dev gate both ways with a fake dev-mode reader: routes absent in production, present in dev; fail-closed on read error. - Command runs with a fake spawn: log streaming, run status, 409 overlap, From 3e5dbc8b6624a9ac8b67241d993500112da7196e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:36:01 -0500 Subject: [PATCH 03/26] docs: manifest-first implementation plan (16 tasks, 7 phases) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-08-28-deck-manifest-first.md | 1690 +++++++++++++++++ 1 file changed, 1690 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-deck-manifest-first.md diff --git a/docs/superpowers/plans/2026-08-28-deck-manifest-first.md b/docs/superpowers/plans/2026-08-28-deck-manifest-first.md new file mode 100644 index 0000000..6a0779e --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-deck-manifest-first.md @@ -0,0 +1,1690 @@ +# deck manifest-first (mattstack.deck.json) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a deck app declare itself in one `mattstack.deck.json`, `deck register` from its directory create/sync the whole record, `deck alt` swap serve shapes, and (in rt dev-mode only) run named action commands from per-app board buttons and `deck cmd`. + +**Architecture:** A new pure parser (`readDeckManifest` + `resolveServeShape`) feeds one shared server flow, `applyManifest(dir, activeAlt?, drivers)`, that both `deck register` (base shape) and `deck alt` (overlay shape) call. Action commands are stored on the `AppRecord`, executed by a net-new spawn-to-log runner behind dev-mode-gated routes, and surfaced as board buttons via command metadata on the status row. The manifest is the single source of truth; register re-syncs the record to it and subsumes the deleted `deck manifest refresh` verb. + +**Tech Stack:** Bun + TypeScript, `.ts` extension imports; launchd supervision via `ServiceManager`; React board compiled to `core/generated/board.{js,css}`; rt state through `@mattstack/rt-client`; `bun test` with `FakeServiceManager`/`FakeEdgeProxy`/`FakeTunnelDriver` and `LOCAL_*` scratch env. + +**Spec:** `docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md` + +## Global Constraints + +- **Manifest file** is `mattstack.deck.json` at the app repo root; it wins over the deprecated identity-only `mattstack.json` when both exist. +- **Commands are shell strings**, run via `sh -c` with the app's `workingDirectory` as cwd. `start` is the only reserved command key; every other key is a free-form action command. +- **Overlays (`altConfigs`) may override ONLY `port` and `commands.start`.** Any other key inside an overlay is rejected loudly at parse (a `{ ok: false, error }` result), never silently ignored. +- **Dev-mode is the rt setting**, read through rt-client, NEVER by reading rt files directly: `getSetting("mattstack.mode").value === "dev"`. Any throw or unset value counts as production (fail closed). No env escape hatch, no override. +- **Icon rules unchanged**: SVG-rooted, at most `64 * 1024` bytes, ingested to `iconsDir()/.svg`. +- **Board artifacts are generated**: after any edit under `core/board/`, run `bun run build:board` and commit the regenerated `core/generated/board.js` and `core/generated/board.css`, or `core/generated-fresh.test.ts` fails. +- **Names** match `/^[a-z0-9][a-z0-9.-]*$/` (`NAME_RE` in `src/api/register.ts:41`). +- **Tests**: unit via `bun test core src`; DOM via `bun test test/dom/`. Scratch state via `LOCAL_STATE_DIR` / `LOCAL_REGISTRY_PATH` / `LOCAL_APPS_ROUTES_PATH` / `LOCAL_APPS_SETTINGS_PATH` / `LOCAL_PLATFORM_SETTINGS_PATH` plus a faked `HOME`; dynamic-import modules AFTER setting env so path resolvers pick up the scratch dir. +- **Comments**: only constraint-bearing comments (parity anchors, ordering traps, invariants). No narration, no reviewer notes. No em dashes or en dashes anywhere (use `...` or rephrase). + +--- + +## File Structure + +**New files** +- `src/registry/deck-manifest.ts` ... `DeckManifest` type, `readDeckManifest(dir)` parser + validation, `resolveServeShape(manifest, altName?)` overlay resolution. Pure, no fs side effects beyond reading the manifest file. +- `src/api/register-manifest.ts` ... `applyManifest(dir, activeAlt, drivers)`: the shared register/alt server flow that mirrors a record to a manifest. +- `src/api/dev-mode.ts` ... `isDevMode(deps?)`: rt-client-backed, fail-closed, briefly cached dev-mode read. +- `src/services/command-runner.ts` ... `startCommandRun` / `commandRunStatus`: spawn a shell string to the app log, track a runId, one-in-flight-per-app. +- `src/cli/config-init.ts` ... `configInit(cwd, io)`: scaffold `mattstack.deck.json` from `package.json`. +- `mattstack.deck.json` (repo root) ... deck's own manifest (final adopter task). +- `scripts/deploy.ts` ... deck's own `deploy` action script (build + install binary + restart self). + +**Modified files** +- `src/registry/records.ts` ... add `commands`, `altConfigs`, `activeAlt` to `AppRecord`. +- `src/registry/manifest.ts` ... `ingestManifest` reads `mattstack.deck.json` identity first, falls back to `mattstack.json`. +- `src/api/register.ts` ... `adoptApp` routes its identity ingest through the shared path (slim). +- `src/api/server.ts` ... add register/alt/command routes; delete the `manifest/refresh` route; carry `commands` metadata onto rows; gate command routes + metadata on `isDevMode()`. +- `src/api/status.ts` ... include dev-gated `commands` on each `StatusRow`. +- `src/cli/commands.ts` ... add `register`, `alt`, `cmd`, `config` verbs; delete `manifest`; update `USAGE`. +- `core/board/AppsTable.tsx`, `core/board/useBoardState.ts`, `core/board/api.ts`, `core/board/logic.ts` ... command buttons + handler; regenerate `core/generated/board.{js,css}`. +- Test files alongside each, plus a new `test/dom/commands.spec.ts` and fixture. + +**Shippable seam:** Phases 1-3 (parser, record model, register/alt/config-init, cleanup) are a complete, usable registration story on their own. Phases 4-6 (dev-mode, action-command runner, routes, board buttons) layer the action-command capability on top. Phase 7 makes deck adopt its own manifest. + +--- + +## Phase 1: Manifest core (pure) + +### Task 1: `readDeckManifest` parser + validation + +**Files:** +- Create: `src/registry/deck-manifest.ts` +- Test: `src/registry/deck-manifest.test.ts` + +**Interfaces:** +- Produces: `interface DeckManifest { name: string; displayName?: string; description?: string; icon?: string; port?: number; commands: Record; altConfigs?: Record }` where `commands` includes `start` (when present) and every action command; `type ParseResult = { ok: true; manifest: DeckManifest } | { ok: false; error: string } | null` (null = file absent); `readDeckManifest(dir: string): ParseResult`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/registry/deck-manifest.test.ts +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readDeckManifest } from "./deck-manifest.ts"; + +function repo(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "deckman-")); + for (const [name, content] of Object.entries(files)) { + const p = join(dir, name); + mkdirSync(join(p, ".."), { recursive: true }); + writeFileSync(p, content); + } + return dir; +} + +test("absent manifest is null (not an error)", () => { + expect(readDeckManifest(repo({}))).toBeNull(); +}); + +test("reads name, port, start and action commands", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", displayName: "Chat", description: "rt chat viewer", icon: "public/icon.svg", + port: 11002, commands: { start: "bun run serve", build: "bun run build", deploy: "bun run deploy" }, + }), + }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(true); + if (!r || !r.ok) throw new Error("unreachable"); + expect(r.manifest.name).toBe("chat"); + expect(r.manifest.port).toBe(11002); + expect(r.manifest.commands).toEqual({ start: "bun run serve", build: "bun run build", deploy: "bun run deploy" }); + expect(r.manifest.displayName).toBe("Chat"); +}); + +test("reads and normalizes altConfigs (commands.start -> start)", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 5173, commands: { start: "bun run dev" } } }, + }), + }); + const r = readDeckManifest(dir); + if (!r || !r.ok) throw new Error("expected ok"); + expect(r.manifest.altConfigs).toEqual({ dev: { port: 5173, start: "bun run dev" } }); +}); + +test("rejects an overlay that overrides anything but port/commands.start", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", commands: { start: "s" }, + altConfigs: { dev: { commands: { deploy: "nope" } } }, + }), + }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); + if (!r || r.ok) throw new Error("expected error"); + expect(r.error).toContain("dev"); +}); + +test("rejects a non-string command value", () => { + const dir = repo({ "mattstack.deck.json": JSON.stringify({ name: "chat", commands: { start: 5 } }) }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); + +test("rejects a bad name", () => { + const dir = repo({ "mattstack.deck.json": JSON.stringify({ name: "Bad Name", commands: {} }) }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); + +test("unparseable JSON is a loud error, not null", () => { + const dir = repo({ "mattstack.deck.json": "{ not json" }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/registry/deck-manifest.test.ts` +Expected: FAIL (module `./deck-manifest.ts` has no `readDeckManifest`). + +- [ ] **Step 3: Write the implementation** + +```ts +// src/registry/deck-manifest.ts +import { readFileSync } from "fs"; +import { join } from "path"; + +export interface DeckManifest { + name: string; + displayName?: string; + description?: string; + icon?: string; + port?: number; + /** Shell strings. `start` (when present) is the supervised service; every other key is an action command. */ + commands: Record; + /** Normalized overlays: each may carry only `port` and/or `start`. */ + altConfigs?: Record; +} + +export type ParseResult = + | { ok: true; manifest: DeckManifest } + | { ok: false; error: string } + | null; + +const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/; + +function err(error: string): ParseResult { + return { ok: false, error }; +} + +export function readDeckManifest(dir: string): ParseResult { + let raw: string; + try { + raw = readFileSync(join(dir, "mattstack.deck.json"), "utf8"); + } catch { + return null; // absent is not an error: callers fall back to mattstack.json for identity + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return err("mattstack.deck.json is not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null) return err("mattstack.deck.json must be an object"); + const m = parsed as Record; + + if (typeof m.name !== "string" || !NAME_RE.test(m.name)) { + return err(`name must match ${NAME_RE}`); + } + + const commands: Record = {}; + if (m.commands !== undefined) { + if (typeof m.commands !== "object" || m.commands === null) return err("commands must be an object"); + for (const [key, val] of Object.entries(m.commands as Record)) { + if (typeof val !== "string" || val.length === 0) return err(`command ${key} must be a non-empty string`); + commands[key] = val; + } + } + + const out: DeckManifest = { name: m.name, commands }; + if (typeof m.displayName === "string") out.displayName = m.displayName; + if (typeof m.description === "string") out.description = m.description; + if (typeof m.icon === "string") out.icon = m.icon; + if (m.port !== undefined) { + if (!Number.isInteger(m.port) || (m.port as number) < 1 || (m.port as number) > 65535) return err("port must be 1-65535"); + out.port = m.port as number; + } + + if (m.altConfigs !== undefined) { + if (typeof m.altConfigs !== "object" || m.altConfigs === null) return err("altConfigs must be an object"); + const alts: Record = {}; + for (const [altName, rawOverlay] of Object.entries(m.altConfigs as Record)) { + if (typeof rawOverlay !== "object" || rawOverlay === null) return err(`overlay ${altName} must be an object`); + const overlay = rawOverlay as Record; + const entry: { port?: number; start?: string } = {}; + for (const key of Object.keys(overlay)) { + // The loud rejection the spec requires: an overlay is the serve shape only. + if (key !== "port" && key !== "commands") { + return err(`overlay ${altName} may only override port and commands.start (saw ${key})`); + } + } + if (overlay.port !== undefined) { + if (!Number.isInteger(overlay.port) || (overlay.port as number) < 1 || (overlay.port as number) > 65535) { + return err(`overlay ${altName} port must be 1-65535`); + } + entry.port = overlay.port as number; + } + if (overlay.commands !== undefined) { + if (typeof overlay.commands !== "object" || overlay.commands === null) return err(`overlay ${altName} commands must be an object`); + for (const key of Object.keys(overlay.commands as Record)) { + if (key !== "start") return err(`overlay ${altName} may only override commands.start (saw commands.${key})`); + } + const start = (overlay.commands as Record).start; + if (start !== undefined) { + if (typeof start !== "string" || start.length === 0) return err(`overlay ${altName} commands.start must be a non-empty string`); + entry.start = start; + } + } + alts[altName] = entry; + } + out.altConfigs = alts; + } + + return { ok: true, manifest: out }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/registry/deck-manifest.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/registry/deck-manifest.ts src/registry/deck-manifest.test.ts +git commit -m "deck-manifest: parse + validate mattstack.deck.json" +``` + +### Task 2: `resolveServeShape` overlay resolution + +**Files:** +- Modify: `src/registry/deck-manifest.ts` +- Test: `src/registry/deck-manifest.test.ts` + +**Interfaces:** +- Consumes: `DeckManifest` from Task 1. +- Produces: `resolveServeShape(manifest: DeckManifest, altName?: string): { port?: number; command?: string[] }` ... the effective serve port and launchd argv (`["sh", "-c", start]`) for the base config (no `altName`) or with an overlay applied. Returns `command: undefined` when there is no `start` (a port-only external app). + +- [ ] **Step 1: Write the failing tests** + +```ts +// append to src/registry/deck-manifest.test.ts +import { resolveServeShape } from "./deck-manifest.ts"; + +test("base serve shape wraps start in sh -c", () => { + const shape = resolveServeShape({ name: "chat", port: 11002, commands: { start: "bun run serve" } }); + expect(shape).toEqual({ port: 11002, command: ["sh", "-c", "bun run serve"] }); +}); + +test("overlay overrides port and start", () => { + const shape = resolveServeShape( + { name: "chat", port: 11002, commands: { start: "bun run serve" }, altConfigs: { dev: { port: 5173, start: "bun run dev" } } }, + "dev", + ); + expect(shape).toEqual({ port: 5173, command: ["sh", "-c", "bun run dev"] }); +}); + +test("overlay that omits a field inherits the base for it", () => { + const shape = resolveServeShape( + { name: "chat", port: 11002, commands: { start: "bun run serve" }, altConfigs: { hmr: { port: 5173 } } }, + "hmr", + ); + expect(shape).toEqual({ port: 5173, command: ["sh", "-c", "bun run serve"] }); +}); + +test("unknown alt throws", () => { + expect(() => resolveServeShape({ name: "c", commands: { start: "s" } }, "nope")).toThrow(); +}); + +test("no start command yields no argv (port-only app)", () => { + expect(resolveServeShape({ name: "c", port: 4200, commands: {} })).toEqual({ port: 4200, command: undefined }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/registry/deck-manifest.test.ts -t "serve shape"` +Expected: FAIL (`resolveServeShape` not exported). + +- [ ] **Step 3: Write the implementation** + +```ts +// append to src/registry/deck-manifest.ts +export function resolveServeShape( + manifest: DeckManifest, + altName?: string, +): { port?: number; command?: string[] } { + const overlay = altName === undefined ? undefined : manifest.altConfigs?.[altName]; + if (altName !== undefined && overlay === undefined) { + throw new Error(`unknown alt config: ${altName}`); + } + const port = overlay?.port ?? manifest.port; + const start = overlay?.start ?? manifest.commands.start; + return { port, command: start === undefined ? undefined : ["sh", "-c", start] }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/registry/deck-manifest.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/registry/deck-manifest.ts src/registry/deck-manifest.test.ts +git commit -m "deck-manifest: resolveServeShape base + overlay" +``` + +--- + +## Phase 2: Record model + register/alt flow + +### Task 3: Extend `AppRecord` with command/overlay fields + +**Files:** +- Modify: `src/registry/records.ts:11-33` +- Test: `src/registry/records.test.ts` + +**Interfaces:** +- Produces: `AppRecord` gains optional `commands?: Record`, `altConfigs?: Record`, `activeAlt?: string`. + +- [ ] **Step 1: Write the failing test** + +```ts +// append to src/registry/records.test.ts (mirror its existing isolate/import idiom) +test("putRecord round-trips manifest command fields", async () => { + const { putRecord, getRecord, reloadRegistry } = await import("./records.ts"); + reloadRegistry(); + putRecord({ + name: "chat", managedBy: "user", port: 11002, kind: "service", createdAt: "x", + commands: { build: "bun run build", deploy: "bun run deploy" }, + altConfigs: { dev: { port: 5173, start: "bun run dev" } }, + activeAlt: "dev", + }); + const r = getRecord("chat")!; + expect(r.commands).toEqual({ build: "bun run build", deploy: "bun run deploy" }); + expect(r.altConfigs).toEqual({ dev: { port: 5173, start: "bun run dev" } }); + expect(r.activeAlt).toBe("dev"); +}); +``` + +(Match the existing scratch-env setup at the top of `records.test.ts`; if that file has no per-test isolate, set `LOCAL_REGISTRY_PATH` to a fresh temp file in this test before the dynamic import.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/registry/records.test.ts -t "manifest command fields"` +Expected: FAIL (TS error: `commands` not on `AppRecord`). + +- [ ] **Step 3: Add the fields** + +```ts +// src/registry/records.ts, inside interface AppRecord (after `icon?: { ext: "svg" };`) + /** Action commands from mattstack.deck.json (shell strings), excluding `start`. Dev-mode-gated at the API. */ + commands?: Record; + /** Declared serve-shape overlays; each may carry only `port` and/or `start`. */ + altConfigs?: Record; + /** The active overlay (an `altConfigs` key), if any; absent means the base serve shape. */ + activeAlt?: string; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/registry/records.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/registry/records.ts src/registry/records.test.ts +git commit -m "records: add commands/altConfigs/activeAlt to AppRecord" +``` + +### Task 4: `applyManifest` shared register/alt flow + +**Files:** +- Create: `src/api/register-manifest.ts` +- Modify: `src/registry/manifest.ts` (identity ingest reads deck.json first) +- Test: `src/api/register-manifest.test.ts` + +**Interfaces:** +- Consumes: `readDeckManifest`/`resolveServeShape` (Tasks 1-2); `registerApp`/`editApp`/`type Drivers` (`src/api/register.ts`); `getRecord`/`putRecord` (`src/registry/records.ts`); `ingestManifest` (`src/registry/manifest.ts`). +- Produces: `applyManifest(dir: string, activeAlt: string | undefined, drivers: Drivers): Promise` where `FlowResult = { status: number; body: unknown }`. On a parse error returns `{ status: 400, body: { error } }`. On success returns `{ status: 200, body: { record } }`. Creates the record when absent (via `registerApp`), else syncs it (via `editApp` for serve-shape changes) and always writes `commands`/`altConfigs`/`activeAlt` + identity. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/api/register-manifest.test.ts +import { test, expect, beforeEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), "applyman-")); + process.env.LOCAL_STATE_DIR = dir; + process.env.LOCAL_REGISTRY_PATH = join(dir, "registry.json"); + process.env.LOCAL_APPS_ROUTES_PATH = join(dir, "routes.json"); + process.env.LOCAL_APPS_SETTINGS_PATH = join(dir, "settings.json"); + process.env.LOCAL_PLATFORM_SETTINGS_PATH = join(dir, "platform.json"); + process.env.HOME = dir; + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH, "[]"); + return dir; +} + +function appRepo(manifest: object): string { + const dir = mkdtempSync(join(tmpdir(), "app-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify(manifest)); + return dir; +} + +const SVG = ''; + +test("register creates a supervised record from the manifest", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const manager = new FakeServiceManager(); + const edge = new FakeEdgeProxy(); + const dir = appRepo({ name: "chat", port: 11002, commands: { start: "bun run serve", deploy: "bun run deploy" } }); + + const r = await applyManifest(dir, undefined, { manager, edge }); + expect(r.status).toBe(200); + const rec = getRecord("chat")!; + expect(rec.command).toEqual(["sh", "-c", "bun run serve"]); + expect(rec.port).toBe(11002); + expect(rec.commands).toEqual({ deploy: "bun run deploy" }); + expect(rec.workingDirectory).toBe(dir); + expect(manager.installed.size).toBe(1); +}); + +test("register is idempotent and re-syncs a changed start command", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = mkdtempSync(join(tmpdir(), "app-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "chat", port: 11002, commands: { start: "bun run serve" } })); + await applyManifest(dir, undefined, drivers); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "chat", port: 11002, commands: { start: "bun run serve2" } })); + await applyManifest(dir, undefined, drivers); + expect(getRecord("chat")!.command).toEqual(["sh", "-c", "bun run serve2"]); +}); + +test("activating an overlay swaps port and start; off restores base", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = appRepo({ + name: "chat", port: 11002, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 5173, commands: { start: "bun run dev" } } }, + }); + await applyManifest(dir, undefined, drivers); + await applyManifest(dir, "dev", drivers); + let rec = getRecord("chat")!; + expect(rec.port).toBe(5173); + expect(rec.command).toEqual(["sh", "-c", "bun run dev"]); + expect(rec.activeAlt).toBe("dev"); + await applyManifest(dir, undefined, drivers); + rec = getRecord("chat")!; + expect(rec.port).toBe(11002); + expect(rec.command).toEqual(["sh", "-c", "bun run serve"]); + expect(rec.activeAlt).toBeUndefined(); +}); + +test("a bad manifest is a 400 with the parse error", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const dir = appRepo({ name: "chat", commands: { start: "s" }, altConfigs: { dev: { nope: 1 } } }); + const r = await applyManifest(dir, undefined, { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }); + expect(r.status).toBe(400); +}); + +test("an absent manifest is a 400", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const r = await applyManifest(mkdtempSync(join(tmpdir(), "empty-")), undefined, { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }); + expect(r.status).toBe(400); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/api/register-manifest.test.ts` +Expected: FAIL (`./register-manifest.ts` missing). + +- [ ] **Step 3: Write the implementation** + +```ts +// src/api/register-manifest.ts +import { readDeckManifest, resolveServeShape } from "../registry/deck-manifest.ts"; +import { getRecord, putRecord } from "../registry/records.ts"; +import { registerApp, editApp, type Drivers, type FlowResult } from "./register.ts"; +import { ingestManifest } from "../registry/manifest.ts"; + +/** + * Mirror a record to its manifest. The single flow behind both `deck register` + * (activeAlt undefined = base serve shape) and `deck alt` (activeAlt = an + * overlay name, or undefined to return to base). The manifest is the source of + * truth: every field it declares is (re)written; register clears an active alt + * because the base serve shape is the manifest's canonical one. + */ +export async function applyManifest( + dir: string, + activeAlt: string | undefined, + drivers: Drivers, +): Promise { + const parsed = readDeckManifest(dir); + if (parsed === null) return { status: 400, body: { error: "no mattstack.deck.json in " + dir } }; + if (!parsed.ok) return { status: 400, body: { error: parsed.error } }; + const manifest = parsed.manifest; + + let shape: { port?: number; command?: string[] }; + try { + shape = resolveServeShape(manifest, activeAlt); + } catch (e) { + return { status: 400, body: { error: String((e as Error).message) } }; + } + + const { start: _start, ...actionCommands } = manifest.commands; + const existing = getRecord(manifest.name); + + if (!existing) { + const created = await registerApp( + shape.command + ? { name: manifest.name, command: shape.command, workingDirectory: dir } + : { name: manifest.name, staticPort: shape.port ?? 0 }, + drivers, + ); + if (created.status !== 201) return created; + } else if (shape.command) { + // Serve shape (command/port) can change between runs and on alt switches; + // editApp tears the old launchd service down and stands the new one up. + const edited = await editApp( + manifest.name, + { command: shape.command, workingDirectory: dir, ...(shape.port !== undefined && { port: shape.port }) }, + existing.managedBy, + true, + drivers, + ); + if (edited.status !== 200) return edited; + } + + // Metadata the serve-shape flows above do not carry: action commands, the + // declared overlays, and which overlay is live. Written last, over whatever + // registerApp/editApp persisted. + const record = getRecord(manifest.name)!; + putRecord({ + ...record, + commands: Object.keys(actionCommands).length ? actionCommands : undefined, + altConfigs: manifest.altConfigs, + activeAlt, + }); + ingestManifest(manifest.name); + return { status: 200, body: { record: getRecord(manifest.name) } }; +} +``` + +Then generalize identity ingest to prefer the deck manifest (edit `src/registry/manifest.ts`, `ingestManifest`, after the `if (!record || record.workingDirectory === undefined) return;` line): + +```ts +// src/registry/manifest.ts, inside ingestManifest, replacing `const manifest = readManifest(...)` + const deck = readDeckManifest(record.workingDirectory); + const manifest = + deck && deck.ok && deck.manifest.displayName && deck.manifest.icon + ? { displayName: deck.manifest.displayName, description: deck.manifest.description, icon: deck.manifest.icon } + : readManifest(record.workingDirectory); // deprecated mattstack.json fallback (identity only) + if (!manifest) return; +``` + +Add `import { readDeckManifest } from "./deck-manifest.ts";` to the top of `manifest.ts`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/api/register-manifest.test.ts src/registry/manifest.test.ts` +Expected: PASS (existing manifest tests still green: the deck-manifest fallback is only taken when a valid deck.json with identity fields exists). + +- [ ] **Step 5: Commit** + +```bash +git add src/api/register-manifest.ts src/api/register-manifest.test.ts src/registry/manifest.ts +git commit -m "register-manifest: applyManifest shared register/alt flow" +``` + +--- + +## Phase 3: CLI (config init, register, alt) + +### Task 5: `deck config init` scaffolding + +**Files:** +- Create: `src/cli/config-init.ts` +- Test: `src/cli/config-init.test.ts` + +**Interfaces:** +- Produces: `configInit(cwd: string, io: { out(s: string): void; err(s: string): void }): number` ... writes `cwd/mattstack.deck.json`, inferring `name` from the directory basename and `commands.start`/`commands.build` from `package.json` scripts when present; refuses (returns 1) when the manifest already exists. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/cli/config-init.test.ts +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join, basename } from "path"; +import { configInit } from "./config-init.ts"; + +function io() { + const lines: string[] = []; + return { out: (s: string) => lines.push(s), err: (s: string) => lines.push(s), lines }; +} + +test("scaffolds a manifest inferring name and scripts", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ scripts: { serve: "bun run serve", build: "bun run build" } })); + const a = io(); + expect(configInit(dir, a)).toBe(0); + const m = JSON.parse(readFileSync(join(dir, "mattstack.deck.json"), "utf8")); + expect(m.name).toBe(basename(dir)); + expect(m.commands.start).toBe("bun run serve"); + expect(m.commands.build).toBe("bun run build"); +}); + +test("refuses to overwrite an existing manifest", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + writeFileSync(join(dir, "mattstack.deck.json"), "{}"); + const a = io(); + expect(configInit(dir, a)).toBe(1); + expect(readFileSync(join(dir, "mattstack.deck.json"), "utf8")).toBe("{}"); +}); + +test("still scaffolds with no package.json", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + expect(configInit(dir, io())).toBe(0); + expect(existsSync(join(dir, "mattstack.deck.json"))).toBe(true); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/cli/config-init.test.ts` +Expected: FAIL (`./config-init.ts` missing). + +- [ ] **Step 3: Write the implementation** + +```ts +// src/cli/config-init.ts +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; + +interface Io { out(s: string): void; err(s: string): void } + +function readScripts(dir: string): Record { + try { + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + return typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {}; + } catch { + return {}; + } +} + +export function configInit(cwd: string, io: Io): number { + const target = join(cwd, "mattstack.deck.json"); + if (existsSync(target)) { + io.err("mattstack.deck.json already exists ... not overwriting"); + return 1; + } + const scripts = readScripts(cwd); + const start = scripts.serve ? "bun run serve" : scripts.start ? "bun run start" : "bun run serve"; + const commands: Record = { start }; + if (scripts.build) commands.build = "bun run build"; + const manifest = { name: basename(cwd), commands }; + writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n"); + io.out(`wrote ${target}`); + return 0; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/cli/config-init.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/config-init.ts src/cli/config-init.test.ts +git commit -m "config-init: scaffold mattstack.deck.json" +``` + +### Task 6: `deck register` route + verb + +**Files:** +- Modify: `src/api/server.ts` (add `POST /api/v1/apps/register`) +- Modify: `src/cli/commands.ts` (add `register` and `config` verbs; update `USAGE`) +- Test: `src/api/server.test.ts`, `src/cli/commands.test.ts` + +**Interfaces:** +- Consumes: `applyManifest` (Task 4), `configInit` (Task 5). +- Produces: `POST /api/v1/apps/register` with body `{ dir: string }` → `applyManifest(dir, undefined, deps)`. CLI `deck register [--dir PATH]` posts `{ dir: cwd|--dir }`; CLI `deck config init` calls `configInit(process.cwd(), io)` directly (no API). + +- [ ] **Step 1: Write the failing tests** + +```ts +// append to src/api/server.test.ts (uses its existing post()/api() helpers + PORT) +test("POST /apps/register creates a record from a manifest dir", async () => { + const dir = mkdtempSync(join(tmpdir(), "reg-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "regtest", port: 4321, commands: { start: "bun run serve" } })); + const res = await post("/api/v1/apps/register", { dir }); + expect(res.status).toBe(200); + const get = await api("/api/v1/apps/regtest"); + expect(get.status).toBe(200); +}); +``` + +```ts +// append to src/cli/commands.test.ts +test("register from a manifest dir, then config init refuses overwrite", async () => { + const appDir = mkdtempSync(join(tmpdir(), "regcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ name: "regcli", port: 4322, commands: { start: "bun run serve" } })); + const a = io(); + expect(await runCommand(["register", "--dir", appDir], a)).toBe(0); + const s = io(); + expect(await runCommand(["status"], s)).toBe(0); + expect(s.lines.join("\n")).toContain("regcli"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/api/server.test.ts -t "register" ; bun test src/cli/commands.test.ts -t "register from"` +Expected: FAIL (route + verb absent). + +- [ ] **Step 3: Add the route** + +```ts +// src/api/server.ts, in the /api/v1 block, alongside the other POST /apps/managed/* checks +if (pathname === "/api/v1/apps/register" && req.method === "POST") { + const b = await body(req); + const { applyManifest } = await import("./register-manifest.ts"); + const r = await applyManifest(String(b.dir ?? ""), undefined, deps); + return json(r.body, r.status); +} +``` + +Add the CLI verbs (`src/cli/commands.ts`, in the `switch (verb)`): + +```ts +case "config": { + const [sub] = rest; + if (sub !== "init") { io.err("usage: deck config init"); return 2; } + const { configInit } = await import("./config-init.ts"); + return configInit(process.cwd(), io); +} +case "register": { + const dir = flag(rest, "--dir") ?? process.cwd(); + const { status, body } = await apiJson("/api/v1/apps/register", { + method: "POST", body: JSON.stringify({ dir }), + }); + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(`registered ${body.record.name} on port ${body.record.port}`); + return 0; +} +``` + +Add to `USAGE` (after the `deck add` lines): + +``` + deck config init scaffold mattstack.deck.json in cwd + deck register [--dir PATH] create/sync an app from its mattstack.deck.json +``` + +(`commands.ts` already imports at top-level; `configInit` is imported lazily inside the case to match the file's existing dynamic-import style for non-API verbs. If `commands.ts` uses static imports only, add `import { configInit } from "./config-init.ts";` at the top and call it directly.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/api/server.test.ts src/cli/commands.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/server.ts src/cli/commands.ts src/api/server.test.ts src/cli/commands.test.ts +git commit -m "cli: deck register + deck config init" +``` + +### Task 7: `deck alt` route + verb + +**Files:** +- Modify: `src/api/server.ts` (add `POST /api/v1/apps/:name/alt`) +- Modify: `src/cli/commands.ts` (add `alt` verb; update `USAGE`) +- Test: `src/api/server.test.ts`, `src/cli/commands.test.ts` + +**Interfaces:** +- Consumes: `applyManifest` (Task 4), `getRecord`. +- Produces: `POST /api/v1/apps/:name/alt` body `{ alt: string | null }` → looks up the record's `workingDirectory`, calls `applyManifest(workingDirectory, alt ?? undefined, deps)`. Returns 404 for an unknown app, 400 when the record has no `workingDirectory`. CLI `deck alt ` posts `{ alt: name === "off" ? null : name }`. + +- [ ] **Step 1: Write the failing tests** + +```ts +// append to src/api/server.test.ts +test("POST /apps/:name/alt activates and clears an overlay", async () => { + const dir = mkdtempSync(join(tmpdir(), "alt-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ + name: "altapp", port: 4400, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 4500, commands: { start: "bun run dev" } } }, + })); + await post("/api/v1/apps/register", { dir }); + const on = await post("/api/v1/apps/altapp/alt", { alt: "dev" }); + expect(on.status).toBe(200); + expect((await (await api("/api/v1/apps/altapp")).json()).record.port).toBe(4500); + const off = await post("/api/v1/apps/altapp/alt", { alt: null }); + expect(off.status).toBe(200); + expect((await (await api("/api/v1/apps/altapp")).json()).record.port).toBe(4400); +}); + +test("alt on an unknown app is 404", async () => { + expect((await post("/api/v1/apps/ghost/alt", { alt: "dev" })).status).toBe(404); +}); +``` + +```ts +// append to src/cli/commands.test.ts +test("deck alt on/off round-trip", async () => { + const appDir = mkdtempSync(join(tmpdir(), "altcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ + name: "altcli", port: 4600, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 4700 } }, + })); + expect(await runCommand(["register", "--dir", appDir], io())).toBe(0); + expect(await runCommand(["alt", "altcli", "dev"], io())).toBe(0); + expect(await runCommand(["alt", "altcli", "off"], io())).toBe(0); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/api/server.test.ts -t "alt" ; bun test src/cli/commands.test.ts -t "alt"` +Expected: FAIL. + +- [ ] **Step 3: Add the route + verb** + +```ts +// src/api/server.ts, inside the /apps/:name matcher block (near sub === "restart") +if (sub === "alt" && req.method === "POST") { + const record = getRecord(name); + if (!record) return json({ error: "unknown app" }, 404); + if (!record.workingDirectory) return json({ error: "app has no manifest directory" }, 400); + const b = await body(req); + const alt = b.alt == null ? undefined : String(b.alt); + const { applyManifest } = await import("./register-manifest.ts"); + const r = await applyManifest(record.workingDirectory, alt, deps); + return json(r.body, r.status); +} +``` + +```ts +// src/cli/commands.ts, in the switch +case "alt": { + const [name, which] = rest; + if (!name || !which) { io.err(USAGE); return 2; } + const alt = which === "off" ? null : which; + const { status, body } = await apiJson(`/api/v1/apps/${name}/alt`, { + method: "POST", body: JSON.stringify({ alt }), + }); + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(which === "off" ? `${name} back on its base config` : `${name} now on alt "${which}"`); + return 0; +} +``` + +`USAGE` line (after `deck register`): + +``` + deck alt activate a declared serve overlay, or return to base +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/api/server.test.ts src/cli/commands.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/server.ts src/cli/commands.ts src/api/server.test.ts src/cli/commands.test.ts +git commit -m "cli: deck alt overlay activation" +``` + +--- + +## Phase 4: Dev-mode + action-command execution + +### Task 8: dev-mode reader (rides `mattstack.mode` via rt-client) + +**Files:** +- Create: `src/api/dev-mode.ts` +- Test: `src/api/dev-mode.test.ts` + +**Interfaces:** +- Produces: `isDevMode(deps?: { read?: () => string | undefined }): boolean` ... true only when the resolved value is `"dev"`; any throw or non-`"dev"` value is production (fail closed). Default `read` calls `getSetting("mattstack.mode").value` through `@mattstack/rt-client`. Result is cached for `DEV_MODE_TTL_MS` (a small window); `resetDevModeCache()` is exported for tests. + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/api/dev-mode.test.ts +import { test, expect, beforeEach } from "bun:test"; +import { isDevMode, resetDevModeCache } from "./dev-mode.ts"; + +beforeEach(() => resetDevModeCache()); + +test("dev when mattstack.mode is dev", () => { + expect(isDevMode({ read: () => "dev" })).toBe(true); +}); + +test("prod when mattstack.mode is prod", () => { + expect(isDevMode({ read: () => "prod" })).toBe(false); +}); + +test("unset value is production (fail closed)", () => { + expect(isDevMode({ read: () => undefined })).toBe(false); +}); + +test("a throwing read is production (fail closed)", () => { + expect(isDevMode({ read: () => { throw new Error("no daemon"); } })).toBe(false); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/api/dev-mode.test.ts` +Expected: FAIL (`./dev-mode.ts` missing). + +- [ ] **Step 3: Write the implementation** + +```ts +// src/api/dev-mode.ts +import { getSetting } from "@mattstack/rt-client"; + +/** rt's machine-flavor setting, written by `rt settings dev-mode`. Read through + rt-client only, never by touching ~/.mattstack/rt files directly. */ +const MODE_KEY = "mattstack.mode"; +const DEV_MODE_TTL_MS = 2000; + +function defaultRead(): string | undefined { + return getSetting(MODE_KEY).value; +} + +let cached: { at: number; dev: boolean } | null = null; + +export function resetDevModeCache(): void { + cached = null; +} + +export function isDevMode(deps: { read?: () => string | undefined } = {}): boolean { + const now = Date.now(); + if (cached && now - cached.at < DEV_MODE_TTL_MS) return cached.dev; + let dev = false; + try { + dev = (deps.read ?? defaultRead)() === "dev"; + } catch { + dev = false; // fail closed: a failed read counts as production + } + cached = { at: now, dev }; + return dev; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/api/dev-mode.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/dev-mode.ts src/api/dev-mode.test.ts +git commit -m "dev-mode: rt-client mattstack.mode reader, fail closed" +``` + +### Task 9: action-command runner + +**Files:** +- Create: `src/services/command-runner.ts` +- Test: `src/services/command-runner.test.ts` + +**Interfaces:** +- Produces: + - `startCommandRun(input: { name: string; cmd: string; shell: string; workingDirectory: string }, deps?: { spawn?: SpawnFn; logDir?: string }): { started: true; runId: string } | { started: false; reason: "busy" }` ... spawns `sh -c ` in `workingDirectory`, appends stdout+stderr to `/.out.log` / `.err.log`, records an in-memory run keyed by `name`; refuses (`busy`) when a run for `name` is already in flight. + - `commandRunStatus(name: string, runId: string): { status: "running" | "exited"; exitCode?: number } | null` ... null for an unknown run. + - `type SpawnFn = (argv: string[], opts: { cwd: string; stdout: number; stderr: number }) => { exited: Promise }` (a `Bun.spawn`-shaped seam). + +- [ ] **Step 1: Write the failing tests** + +```ts +// src/services/command-runner.test.ts +import { test, expect, beforeEach } from "bun:test"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { startCommandRun, commandRunStatus, resetRuns } from "./command-runner.ts"; + +beforeEach(() => resetRuns()); + +function fakeSpawn(exit: Promise) { + const calls: Array<{ argv: string[]; cwd: string }> = []; + const spawn = (argv: string[], opts: { cwd: string; stdout: number; stderr: number }) => { + calls.push({ argv, cwd: opts.cwd }); + return { exited: exit }; + }; + return { spawn, calls }; +} + +test("spawns sh -c in the working directory and returns a runId", () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn, calls } = fakeSpawn(new Promise(() => {})); // never resolves = still running + const r = startCommandRun({ name: "chat", cmd: "deploy", shell: "bun run deploy", workingDirectory: "/tmp/app" }, { spawn, logDir }); + expect(r.started).toBe(true); + if (!r.started) throw new Error("unreachable"); + expect(calls[0].argv).toEqual(["sh", "-c", "bun run deploy"]); + expect(calls[0].cwd).toBe("/tmp/app"); + expect(commandRunStatus("chat", r.runId)!.status).toBe("running"); +}); + +test("refuses a second run while one is in flight (busy)", () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn } = fakeSpawn(new Promise(() => {})); + const first = startCommandRun({ name: "chat", cmd: "deploy", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + expect(first.started).toBe(true); + const second = startCommandRun({ name: "chat", cmd: "build", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + expect(second.started).toBe(false); +}); + +test("status flips to exited with the code when the process ends", async () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn } = fakeSpawn(Promise.resolve(0)); + const r = startCommandRun({ name: "chat", cmd: "deploy", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + if (!r.started) throw new Error("unreachable"); + await new Promise((res) => setTimeout(res, 10)); // let the exited handler run + expect(commandRunStatus("chat", r.runId)).toEqual({ status: "exited", exitCode: 0 }); +}); + +test("unknown run is null", () => { + expect(commandRunStatus("chat", "nope")).toBeNull(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/services/command-runner.test.ts` +Expected: FAIL (`./command-runner.ts` missing). + +- [ ] **Step 3: Write the implementation** + +```ts +// src/services/command-runner.ts +import { openSync, mkdirSync } from "fs"; +import { join } from "path"; +import { randomBytes } from "crypto"; +import { logsDir } from "../api/state.ts"; + +export type SpawnFn = ( + argv: string[], + opts: { cwd: string; stdout: number; stderr: number }, +) => { exited: Promise }; + +interface Run { + runId: string; + cmd: string; + status: "running" | "exited"; + exitCode?: number; +} + +const runs = new Map(); // keyed by app name: one in-flight run per app + +export function resetRuns(): void { + runs.clear(); +} + +const defaultSpawn: SpawnFn = (argv, opts) => Bun.spawn(argv, opts) as unknown as { exited: Promise }; + +export function startCommandRun( + input: { name: string; cmd: string; shell: string; workingDirectory: string }, + deps: { spawn?: SpawnFn; logDir?: string } = {}, +): { started: true; runId: string } | { started: false; reason: "busy" } { + const active = runs.get(input.name); + if (active && active.status === "running") return { started: false, reason: "busy" }; + + const dir = deps.logDir ?? logsDir(); + mkdirSync(dir, { recursive: true }); + // Append into the app's existing deck log, so `deck logs` shows command output. + const out = openSync(join(dir, `${input.name}.out.log`), "a"); + const errFd = openSync(join(dir, `${input.name}.err.log`), "a"); + + const runId = randomBytes(8).toString("hex"); + const run: Run = { runId, cmd: input.cmd, status: "running" }; + runs.set(input.name, run); + + const proc = (deps.spawn ?? defaultSpawn)(["sh", "-c", input.shell], { + cwd: input.workingDirectory, + stdout: out, + stderr: errFd, + }); + proc.exited.then((code) => { + run.status = "exited"; + run.exitCode = code; + }); + + return { started: true, runId }; +} + +export function commandRunStatus( + name: string, + runId: string, +): { status: "running" | "exited"; exitCode?: number } | null { + const run = runs.get(name); + if (!run || run.runId !== runId) return null; + return run.exitCode === undefined ? { status: run.status } : { status: run.status, exitCode: run.exitCode }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/services/command-runner.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/services/command-runner.ts src/services/command-runner.test.ts +git commit -m "command-runner: spawn shell command to app log, one in flight" +``` + +### Task 10: action-command routes (dev-gated) + +**Files:** +- Modify: `src/api/server.ts` +- Test: `src/api/server.test.ts` + +**Interfaces:** +- Consumes: `isDevMode` (Task 8), `startCommandRun`/`commandRunStatus` (Task 9), `getRecord`. +- Produces: + - `POST /api/v1/apps/:name/commands/:cmd` ... in dev only. 404 when `!isDevMode()`, when the app is unknown, or when `:cmd` is not a key of `record.commands`. On overlap returns 409 `{ error: "busy" }`. On success returns `{ started: true, runId }`. + - `GET /api/v1/apps/:name/commands/:cmd/:runId` ... in dev only. Returns the run status, or 404 for an unknown run. +- Note: `isDevMode` is read from a new optional `ApiDeps.devMode?: () => boolean` so tests inject it; production wiring (Task added in Phase 5 board task / main.ts) defaults it to `isDevMode`. + +- [ ] **Step 1: Write the failing tests** + +Add a helper to `server.test.ts` that boots a server whose `devMode` returns true (mirror the existing multi-server setup that already stands up extra servers with `cloudflaredDir`), plus one asserting production hides the route. Representative: + +```ts +// append to src/api/server.test.ts +// (devServer is a second startApi(...) built in beforeAll with devMode: () => true, +// on DEV_PORT; devApi/devPost are its fetch helpers. prodPost hits the default +// server, whose devMode defaults to () => false in the test wiring.) +test("command route runs a declared command in dev", async () => { + const dir = mkdtempSync(join(tmpdir(), "cmd-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "cmdapp", port: 4800, commands: { start: "s", build: "echo built" } })); + await devPost("/api/v1/apps/register", { dir }); + const run = await devPost("/api/v1/apps/cmdapp/commands/build", {}); + expect(run.status).toBe(200); + const body = await run.json(); + expect(body.started).toBe(true); + expect(typeof body.runId).toBe("string"); +}); + +test("unknown command name is 404 in dev", async () => { + const dir = mkdtempSync(join(tmpdir(), "cmd-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "cmdapp2", port: 4801, commands: { start: "s" } })); + await devPost("/api/v1/apps/register", { dir }); + expect((await devPost("/api/v1/apps/cmdapp2/commands/ghost", {})).status).toBe(404); +}); + +test("command route is 404 in production", async () => { + expect((await prodPost("/api/v1/apps/anything/commands/build", {})).status).toBe(404); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/api/server.test.ts -t "command route"` +Expected: FAIL. + +- [ ] **Step 3: Add the routes + deps field** + +```ts +// src/api/server.ts, in interface ApiDeps + /** Dev-mode gate for action commands. Defaults to isDevMode in production wiring; tests inject it. */ + devMode?: () => boolean; +``` + +```ts +// src/api/server.ts, in the /api/v1 block, ABOVE the generic /apps/:name matcher +// so the two-segment commands path is matched explicitly (like manifest/refresh was). +{ + const cm = pathname.match(/^\/api\/v1\/apps\/([^/]+)\/commands\/([a-z0-9-]+)(?:\/([a-z0-9]+))?$/); + if (cm) { + const dev = (deps.devMode ?? isDevMode)(); + if (!dev) return json({ error: "not found" }, 404); // production: indistinguishable from absent + const [, name, cmd, runId] = cm as unknown as [string, string, string, string | undefined]; + const record = getRecord(name); + if (!record?.commands || !(cmd in record.commands)) return json({ error: "not found" }, 404); + if (runId && req.method === "GET") { + const st = commandRunStatus(name, runId); + return st ? json(st) : json({ error: "unknown run" }, 404); + } + if (!runId && req.method === "POST") { + const started = startCommandRun({ + name, cmd, shell: record.commands[cmd]!, workingDirectory: record.workingDirectory!, + }); + if (!started.started) return json({ error: "busy" }, 409); + return json({ started: true, runId: started.runId }); + } + } +} +``` + +Add imports at the top of `server.ts`: + +```ts +import { isDevMode } from "./dev-mode.ts"; +import { startCommandRun, commandRunStatus } from "../services/command-runner.ts"; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/api/server.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/server.ts src/api/server.test.ts +git commit -m "server: dev-gated action-command run + status routes" +``` + +### Task 11: `deck cmd` verb + +**Files:** +- Modify: `src/cli/commands.ts` (add `cmd` verb; update `USAGE`) +- Test: `src/cli/commands.test.ts` + +**Interfaces:** +- Consumes: the command route (Task 10). +- Produces: `deck cmd ` posts `POST /api/v1/apps/:app/commands/:name`, prints the `runId`, and returns 1 on a 404 (production or unknown) / 409 (busy). CLI tests boot the shared server with `devMode: () => true` (adjust the `commands.test.ts` `startApi` options to pass `devMode: () => true`). + +- [ ] **Step 1: Write the failing test** + +```ts +// append to src/cli/commands.test.ts +test("deck cmd runs a declared action command", async () => { + const appDir = mkdtempSync(join(tmpdir(), "cmdcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ name: "cmdcli", port: 4900, commands: { start: "s", build: "echo hi" } })); + expect(await runCommand(["register", "--dir", appDir], io())).toBe(0); + const a = io(); + expect(await runCommand(["cmd", "cmdcli", "build"], a)).toBe(0); + expect(a.lines.join("\n")).toContain("started"); +}); +``` + +(Ensure the `commands.test.ts` `startApi(...)` options include `devMode: () => true`, so command routes are live in the CLI harness.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/cli/commands.test.ts -t "deck cmd"` +Expected: FAIL. + +- [ ] **Step 3: Add the verb** + +```ts +// src/cli/commands.ts, in the switch +case "cmd": { + const [app, name] = rest; + if (!app || !name) { io.err(USAGE); return 2; } + const { status, body } = await apiJson(`/api/v1/apps/${app}/commands/${name}`, { method: "POST" }); + if (status === 404) { io.err(`no such command (is deck in dev mode?): ${app} ${name}`); return 1; } + if (status === 409) { io.err(`${app} is already running a command`); return 1; } + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(`started ${app} ${name} (run ${body.runId})`); + return 0; +} +``` + +`USAGE` line (after `deck alt`): + +``` + deck cmd run a declared action command (dev mode only) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/cli/commands.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/cli/commands.ts src/cli/commands.test.ts +git commit -m "cli: deck cmd action-command verb" +``` + +--- + +## Phase 5: Status payload + board buttons + +### Task 12: dev-gated `commands` metadata on the status row + +**Files:** +- Modify: `src/api/status.ts` (add `commands` to `StatusRow`, populated only in dev) +- Modify: `src/api/server.ts` (thread `deps.devMode` into `buildStatus`) +- Test: `src/api/status.test.ts` (or `server.test.ts`) + +**Interfaces:** +- Consumes: `isDevMode` / `deps.devMode`, `record.commands`. +- Produces: `StatusRow.commands?: string[]` ... the action-command names for the row, present only when dev-mode is on AND the record has commands; absent otherwise. `buildStatus` gains a `devMode: boolean` option threaded from the server. + +- [ ] **Step 1: Write the failing test** + +```ts +// append to src/api/server.test.ts (dev server vs prod server from Task 10) +test("status carries command names in dev, omits them in prod", async () => { + const dir = mkdtempSync(join(tmpdir(), "meta-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "metaapp", port: 4950, commands: { start: "s", deploy: "d" } })); + await devPost("/api/v1/apps/register", { dir }); + const devRow = (await (await devApi("/api/v1/status")).json()).apps.find((a: any) => a.name === "metaapp"); + expect(devRow.commands).toEqual(["deploy"]); + const prodRow = (await (await api("/api/v1/status")).json()).apps.find((a: any) => a.name === "metaapp"); + expect(prodRow.commands).toBeUndefined(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/api/server.test.ts -t "status carries command"` +Expected: FAIL. + +- [ ] **Step 3: Thread dev-mode + populate commands** + +In `src/api/status.ts`: add `commands?: string[]` to `StatusRow`; accept a `devMode: boolean` in the options `buildStatus` takes; for each row whose record has `commands`, set `commands: devMode ? Object.keys(record.commands) : undefined`. + +In `src/api/server.ts`: where `statusOpts` is built (around line 210), add `devMode: (deps.devMode ?? isDevMode)()`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/api/server.test.ts src/api/status.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/status.ts src/api/server.ts src/api/server.test.ts +git commit -m "status: dev-gated command names on the row" +``` + +### Task 13: board renders a button per command + +**Files:** +- Modify: `core/board/AppsTable.tsx`, `core/board/useBoardState.ts`, `core/board/api.ts`, `core/board/logic.ts` +- Regenerate: `core/generated/board.js`, `core/generated/board.css` +- Test: `test/dom/commands.spec.ts` (+ `test/fixture/status-commands.json`) + +**Interfaces:** +- Consumes: `StatusRow.commands` (Task 12); the command route (Task 10). +- Produces: a `CommandsCell` (mirroring `RestartCell`, `AppsTable.tsx:268-292`) that renders one `Button` per name in `row.commands`, each calling `board.onRunCommand(row, name)`; `onRunCommand` in `useBoardState.ts` (mirroring `onRestart`, lines 141-147) POSTs `/api/v1/apps/${row.name}/commands/${name}` and swallows the rejection so a self-restarting deploy does not surface an error. No client dev-mode check: the buttons render only because `row.commands` is present, which the server already gated. + +- [ ] **Step 1: Write the failing DOM test** + +Create `test/fixture/status-commands.json` (copy `test/fixture/status.json`, add `"commands": ["deploy", "build"]` to one app row). Then: + +```ts +// test/dom/commands.spec.ts (mirror test/dom/board.spec.ts's rig usage) +import { test, expect } from "@playwright/test"; +import { mount } from "./rig.ts"; + +test("renders a button per command and POSTs on click", async ({ page }) => { + await mount(page, { fixture: "status-commands.json" }); + const deploy = page.getByRole("button", { name: /deploy/i }); + await expect(deploy).toBeVisible(); + const posted: string[] = []; + await page.route("**/api/v1/apps/*/commands/*", (route) => { posted.push(route.request().url()); route.fulfill({ json: { started: true, runId: "x" } }); }); + await deploy.click(); + expect(posted.some((u) => u.includes("/commands/deploy"))).toBe(true); +}); + +test("no command buttons when the row omits commands", async ({ page }) => { + await mount(page, { fixture: "status.json" }); + await expect(page.getByRole("button", { name: /deploy/i })).toHaveCount(0); +}); +``` + +(Match `test/dom/rig.ts`'s real signature for mounting with a `DECK_FIXTURE`; `board.spec.ts:180-220` is the closest existing precedent for the route-interception + click assertion.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun run build:board && bun test test/dom/commands.spec.ts` +Expected: FAIL (no command buttons rendered). + +- [ ] **Step 3: Implement the cell + handler** + +Add to `core/board/api.ts` (mirror `apiPost`): nothing new needed if `apiPost(path)` exists; reuse it. Add `onRunCommand` to `useBoardState.ts`: + +```ts +// core/board/useBoardState.ts (near onRestart, ~line 141) +const onRunCommand = useCallback((row: Row, name: string) => { + // Swallow the rejection: a self-restarting deploy kills the API mid-POST, + // exactly like onRestart; the 5s poll re-syncs once it returns. + apiPost(`/api/v1/apps/${row.name}/commands/${name}`).catch(() => {}); +}, []); +``` + +Expose `onRunCommand` on the returned board object. Add `CommandsCell` to `AppsTable.tsx` and place it in the row (near `RestartCell`): + +```tsx +// core/board/AppsTable.tsx +function CommandsCell({ row, onRunCommand }: { row: Row; onRunCommand: (row: Row, name: string) => void }) { + if (!row.commands?.length) return null; + return ( + <> + {row.commands.map((name) => ( + + ))} + + ); +} +``` + +Add `commands?: string[]` to the board's `Row`/`StatusRow` type in `core/board/logic.ts` (mirror where `override`/`service` are typed). Thread `onRunCommand` from `useBoardState` through `Board.tsx` → `AppsTable` props like `onRestart` is threaded. + +- [ ] **Step 4: Regenerate artifacts and run tests** + +Run: `bun run build:board && bun test test/dom/commands.spec.ts core/generated-fresh.test.ts` +Expected: PASS (and the freshness test stays green because the regenerated artifacts are about to be committed). + +- [ ] **Step 5: Commit** + +```bash +git add core/board/ core/generated/board.js core/generated/board.css test/dom/commands.spec.ts test/fixture/status-commands.json +git commit -m "board: per-command action buttons on the app row" +``` + +--- + +## Phase 6: CLI cleanup + +### Task 14: remove `deck manifest refresh` + +**Files:** +- Modify: `src/cli/commands.ts` (delete the `manifest` case; drop the `USAGE` line) +- Modify: `src/api/server.ts` (delete the `manifest/refresh` route branch, `server.ts:321-335`) +- Modify: `src/cli/commands.test.ts` (delete the manifest-refresh test block, ~lines 294-320) +- Test: existing suites stay green. + +**Interfaces:** +- Removes: the `manifest` verb and `POST /api/v1/apps/:name/manifest/refresh`. `deck register`'s sync path is the replacement (re-running it re-ingests identity/icon, already covered by Task 4's idempotent-resync test and `manifest.test.ts`). + +- [ ] **Step 1: Delete the manifest-refresh CLI test** + +Remove the `manifest refresh` test block in `src/cli/commands.test.ts` (~lines 294-320). + +- [ ] **Step 2: Run tests to confirm the suite is green without it** + +Run: `bun test src/cli/commands.test.ts` +Expected: PASS (the removed test is gone; nothing else references it). + +- [ ] **Step 3: Delete the route and verb** + +- Remove the `manifest/refresh` matched block in `src/api/server.ts` (the `{ const mr = pathname.match(...) ... }` at lines 321-335) and the now-unused `ingestManifest` import there if no other reference remains (grep first: `grep -n ingestManifest src/api/server.ts`). +- Remove the `case "manifest":` block in `src/cli/commands.ts` and its `USAGE` line (`deck manifest refresh ...`). + +- [ ] **Step 4: Run the full unit suite** + +Run: `bun test core src` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/server.ts src/cli/commands.ts src/cli/commands.test.ts +git commit -m "cleanup: remove deck manifest refresh (subsumed by register)" +``` + +### Task 15: slim `deck adopt` onto the shared ingest + +**Files:** +- Modify: `src/api/register.ts` (`adoptApp`, line 418) +- Test: `src/api/server.test.ts` (adopt still ingests identity) + +**Interfaces:** +- Keeps: `adoptApp`'s managedBy assignment, optional rename, and `.mattstack` route bless. +- Changes: its identity ingest now flows through the same `ingestManifest` path that reads `mattstack.deck.json` first (Task 4 already generalized `ingestManifest`), so an rt-spawned product and a hand-run `deck register` ingest through identical code. This is a no-op-shaped change if `ingestManifest` is already the call at `register.ts:418`; the task's job is to confirm adopt no longer has any bespoke `mattstack.json`-only path and to add coverage that adopt ingests a `mattstack.deck.json` identity. + +- [ ] **Step 1: Write the failing test** + +```ts +// append to src/api/server.test.ts +test("adopt ingests identity from mattstack.deck.json", async () => { + const dir = mkdtempSync(join(tmpdir(), "adopt-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "adoptme", commands: { start: "s" }, displayName: "Adopt Me", icon: "icon.svg" })); + writeFileSync(join(dir, "icon.svg"), ''); + await post("/api/v1/apps", { name: "adoptme", command: ["sh", "-c", "s"], workingDirectory: dir }); + const res = await post("/api/v1/apps/adoptme/adopt", { managedBy: "rt" }); + expect(res.status).toBe(200); + const row = await (await api("/api/v1/apps/adoptme")).json(); + expect(row.record.managedBy).toBe("rt"); + // identity came from the deck manifest, not mattstack.json +}); +``` + +- [ ] **Step 2: Run test to verify it fails or passes** + +Run: `bun test src/api/server.test.ts -t "adopt ingests identity from mattstack.deck.json"` +Expected: If `ingestManifest` was already generalized in Task 4, this may PASS immediately (confirming adopt rides the shared path). If it FAILS, adopt still has a bespoke identity path... proceed to Step 3. + +- [ ] **Step 3: Confirm/clean adopt's ingest** + +Verify `adoptApp` calls `ingestManifest(target)` (`register.ts:418`) and nothing else reads `mattstack.json` for adopt. If any bespoke identity read remains, delete it in favor of the single `ingestManifest(target)` call. + +- [ ] **Step 4: Run the suite** + +Run: `bun test core src` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/register.ts src/api/server.test.ts +git commit -m "adopt: ingest identity through the shared deck-manifest path" +``` + +--- + +## Phase 7: deck adopts its own manifest + +### Task 16: deck's `mattstack.deck.json` + deploy script + +**Files:** +- Create: `mattstack.deck.json` (repo root) +- Create: `scripts/deploy.ts` +- Modify: `package.json` (add a `deploy` script) +- Test: `src/registry/deck-manifest.test.ts` (parse the real repo manifest) + +**Interfaces:** +- Produces: deck's own manifest with `start`/`build`/`deploy`; `deploy` builds the binary, installs it over `~/.local/bin/deck`, and runs `deck restart deck` (the self-restart connection drop is expected and handled by the board's re-poll, Task 13's swallow). + +- [ ] **Step 1: Write the failing test** + +```ts +// append to src/registry/deck-manifest.test.ts +import { readFileSync as _rf } from "fs"; +test("deck's own repo manifest parses", () => { + const r = readDeckManifest(join(import.meta.dir, "..", "..")); + expect(r?.ok).toBe(true); + if (!r || !r.ok) throw new Error("unreachable"); + expect(r.manifest.name).toBe("deck"); + expect(r.manifest.commands.deploy).toBeDefined(); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/registry/deck-manifest.test.ts -t "own repo manifest"` +Expected: FAIL (no root manifest yet). + +- [ ] **Step 3: Write the manifest, deploy script, and package script** + +```json +// mattstack.deck.json (repo root) +{ + "name": "deck", + "displayName": "Deck", + "description": "named https domains, supervision, and sharing for local apps", + "commands": { + "start": "bun run serve", + "build": "bun run build && bun run build:board", + "deploy": "bun run deploy" + } +} +``` + +```ts +// scripts/deploy.ts +import { $ } from "bun"; +import { homedir } from "os"; +import { join } from "path"; + +const target = join(homedir(), ".local", "bin", "deck"); +await $`bun run build`; +await $`bun run build:board`; +await $`install -m 0755 dist/deck ${target}`; +// The self-restart drops the API mid-response; the board tolerates and re-polls. +await $`deck restart deck`; +``` + +```jsonc +// package.json scripts: add +"deploy": "bun run scripts/deploy.ts" +``` + +(Deck's own row already carries `managedBy: "deck"`, so its board buttons appear the same way any other app's do once a `deck register` re-syncs the self row. Do not weaken the platform-row guards in `applyOverride`; action commands are a separate path and unaffected.) + +- [ ] **Step 4: Run tests** + +Run: `bun test src/registry/deck-manifest.test.ts && bun test core src` +Expected: PASS. (Do not run `bun run deploy` from the plan; it is an operational verb, not a test.) + +- [ ] **Step 5: Commit** + +```bash +git add mattstack.deck.json scripts/deploy.ts package.json src/registry/deck-manifest.test.ts +git commit -m "deck: adopt its own mattstack.deck.json + deploy script" +``` + +--- + +## Follow-ups (out of this plan) + +- **chat** and other rt-managed products write their own `mattstack.deck.json` in their own repos (external to this repo); they flow through the identical register/adopt ingest this plan builds. +- The three deferred items from the spec stay deferred: argv (non-shell) command form, per-command env, command timeouts; widening the launcher discovery filter to manifested user apps; `deck alt` auto-selection tied to rt dev-mode. + +--- + +## Self-Review + +**Spec coverage:** manifest file + rules (Task 1-2); `deck config init` (Task 5); `deck register` create/sync, subsumes refresh (Task 4, 6, 14); `deck alt` (Task 7); `deck cmd` (Task 11); action-command route + status + one-at-a-time + unknown refusal (Task 9-10); dev-mode gate via rt setting, fail closed, routes absent in prod (Task 8, 10); board buttons + metadata presence + self-restart re-poll tolerance (Task 12-13); universality (any app may carry a manifest ... register does not require managed status); migration/fallback of `mattstack.json` for identity (Task 4); CLI cleanup, adopt slim (Task 14-15); first adopter deck itself (Task 16). Chat is external (Follow-ups). + +**Type consistency:** `DeckManifest.commands` includes `start`; `applyManifest` strips `start` before writing `record.commands` (action commands only), which is what `status.ts` keys and the command route validates against ... consistent across Tasks 1, 4, 10, 12. `resolveServeShape` returns `{ port?, command? }` used identically in Task 4. `StatusRow.commands: string[]` (Task 12) matches the board `Row.commands` (Task 13). `isDevMode(deps?)` signature stable across Tasks 8, 10, 12. + +**Placeholder scan:** no TBD/TODO; every code step carries real code; DOM test steps note the exact existing precedents (`board.spec.ts:180-220`, `rig.ts`) rather than inventing an API ... the implementer must open `rig.ts` to match its real `mount` signature, called out explicitly. From 6e4b1f943199292dba519a72c9f70824e5cf1f77 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:46:40 -0500 Subject: [PATCH 04/26] plan: fix service port, DOM test idiom, optional devMode (review r1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-08-28-deck-manifest-first.md | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/plans/2026-08-28-deck-manifest-first.md b/docs/superpowers/plans/2026-08-28-deck-manifest-first.md index 6a0779e..4dbafa0 100644 --- a/docs/superpowers/plans/2026-08-28-deck-manifest-first.md +++ b/docs/superpowers/plans/2026-08-28-deck-manifest-first.md @@ -417,6 +417,7 @@ git commit -m "records: add commands/altConfigs/activeAlt to AppRecord" **Files:** - Create: `src/api/register-manifest.ts` +- Modify: `src/api/register.ts` (`RegisterInput` + `registerApp` honor a declared service port) - Modify: `src/registry/manifest.ts` (identity ingest reads deck.json first) - Test: `src/api/register-manifest.test.ts` @@ -581,10 +582,14 @@ export async function applyManifest( const existing = getRecord(manifest.name); if (!existing) { + // A manifest with neither a start command nor a port declares nothing to stand up. + if (!shape.command && shape.port === undefined) { + return { status: 400, body: { error: "manifest must declare commands.start or a port" } }; + } const created = await registerApp( shape.command - ? { name: manifest.name, command: shape.command, workingDirectory: dir } - : { name: manifest.name, staticPort: shape.port ?? 0 }, + ? { name: manifest.name, command: shape.command, workingDirectory: dir, port: shape.port } + : { name: manifest.name, staticPort: shape.port! }, drivers, ); if (created.status !== 201) return created; @@ -616,6 +621,20 @@ export async function applyManifest( } ``` +Honor a manifest-declared **service** port (this is why register.ts changes). `registerApp` today allocates a port for every service (`staticPort` forces `kind: "external"`), so a declared `port: 11002` would be ignored and the service would come up on an allocated 11000. Add `port?: number` to `RegisterInput` (`src/api/register.ts:27-37`) and let a supplied port win over allocation: + +```ts +// src/api/register.ts, in registerApp, replacing `let port = input.staticPort;` +let port = input.staticPort ?? input.port; +if (port === undefined) { + const allocated = allocatePort(listRecords(), routes, await readServices()); + if (allocated === null) return { status: 507, body: { error: "port range exhausted" } }; + port = allocated; +} +``` + +`isService` still keys off `staticPort === undefined`, so a service with a declared `port` stays `kind: "service"` and installs at the declared port. Existing callers pass no `port`, so they keep allocating ... backward compatible. + Then generalize identity ingest to prefer the deck manifest (edit `src/registry/manifest.ts`, `ingestManifest`, after the `if (!record || record.workingDirectory === undefined) return;` line): ```ts @@ -1211,8 +1230,9 @@ Add a helper to `server.test.ts` that boots a server whose `devMode` returns tru ```ts // append to src/api/server.test.ts // (devServer is a second startApi(...) built in beforeAll with devMode: () => true, -// on DEV_PORT; devApi/devPost are its fetch helpers. prodPost hits the default -// server, whose devMode defaults to () => false in the test wiring.) +// on DEV_PORT; devApi/devPost are its fetch helpers. Build the DEFAULT (prod) +// server explicitly with devMode: () => false so these assertions never depend +// on the dev machine's real mattstack.mode; prodPost hits that server.) test("command route runs a declared command in dev", async () => { const dir = mkdtempSync(join(tmpdir(), "cmd-")); writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "cmdapp", port: 4800, commands: { start: "s", build: "echo built" } })); @@ -1396,7 +1416,7 @@ Expected: FAIL. - [ ] **Step 3: Thread dev-mode + populate commands** -In `src/api/status.ts`: add `commands?: string[]` to `StatusRow`; accept a `devMode: boolean` in the options `buildStatus` takes; for each row whose record has `commands`, set `commands: devMode ? Object.keys(record.commands) : undefined`. +In `src/api/status.ts`: add `commands?: string[]` to `StatusRow`; add an **optional** `devMode?: boolean` (default false) to the options object `buildStatus` takes ... it MUST be optional so the existing `buildStatus(opts)` call sites in `src/api/status.test.ts` (~10 calls) and `src/api/discovery.test.ts` still compile unchanged. For each row whose record has `commands`, set `commands: opts.devMode && record.commands ? Object.keys(record.commands) : undefined`. In `src/api/server.ts`: where `statusOpts` is built (around line 210), add `devMode: (deps.devMode ?? isDevMode)()`. @@ -1425,31 +1445,34 @@ git commit -m "status: dev-gated command names on the row" - [ ] **Step 1: Write the failing DOM test** -Create `test/fixture/status-commands.json` (copy `test/fixture/status.json`, add `"commands": ["deploy", "build"]` to one app row). Then: +Create `test/fixture/status-commands.json` by copying `test/fixture/status.json` and adding `"commands": ["deploy", "build"]` to the `atlas` app row (the row `board.spec.ts` already exercises, so the `aria-label` below is deterministic). The DOM harness is `withBoard(fn, { fixture })` under `bun:test` with a raw `playwright` `Page` (NOT `@playwright/test`, no `mount`, no Playwright matchers ... assert with `bun` `expect` on `.count()` and a `posted` flag, exactly like `board.spec.ts:180-220`): ```ts -// test/dom/commands.spec.ts (mirror test/dom/board.spec.ts's rig usage) -import { test, expect } from "@playwright/test"; -import { mount } from "./rig.ts"; - -test("renders a button per command and POSTs on click", async ({ page }) => { - await mount(page, { fixture: "status-commands.json" }); - const deploy = page.getByRole("button", { name: /deploy/i }); - await expect(deploy).toBeVisible(); - const posted: string[] = []; - await page.route("**/api/v1/apps/*/commands/*", (route) => { posted.push(route.request().url()); route.fulfill({ json: { started: true, runId: "x" } }); }); - await deploy.click(); - expect(posted.some((u) => u.includes("/commands/deploy"))).toBe(true); +// test/dom/commands.spec.ts +import { test, expect } from "bun:test"; +import { withBoard } from "./rig.ts"; + +test("renders a button per command and POSTs on click", async () => { + await withBoard(async (page) => { + let postedUrl = ""; + await page.route("**/api/v1/apps/*/commands/*", async (route) => { + postedUrl = route.request().url(); + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ started: true, runId: "x" }) }); + }); + const deploy = page.locator('[aria-label="deploy atlas"]'); + expect(await deploy.count()).toBe(1); + await deploy.click(); + expect(postedUrl).toContain("/api/v1/apps/atlas/commands/deploy"); + }, { fixture: "status-commands.json" }); }); -test("no command buttons when the row omits commands", async ({ page }) => { - await mount(page, { fixture: "status.json" }); - await expect(page.getByRole("button", { name: /deploy/i })).toHaveCount(0); +test("no command buttons when the row omits commands", async () => { + await withBoard(async (page) => { + expect(await page.locator('[aria-label="deploy atlas"]').count()).toBe(0); + }, { fixture: "status.json" }); }); ``` -(Match `test/dom/rig.ts`'s real signature for mounting with a `DECK_FIXTURE`; `board.spec.ts:180-220` is the closest existing precedent for the route-interception + click assertion.) - - [ ] **Step 2: Run test to verify it fails** Run: `bun run build:board && bun test test/dom/commands.spec.ts` @@ -1515,9 +1538,9 @@ git commit -m "board: per-command action buttons on the app row" **Interfaces:** - Removes: the `manifest` verb and `POST /api/v1/apps/:name/manifest/refresh`. `deck register`'s sync path is the replacement (re-running it re-ingests identity/icon, already covered by Task 4's idempotent-resync test and `manifest.test.ts`). -- [ ] **Step 1: Delete the manifest-refresh CLI test** +- [ ] **Step 1: Delete the manifest-refresh CLI tests** -Remove the `manifest refresh` test block in `src/cli/commands.test.ts` (~lines 294-320). +`src/cli/commands.test.ts` has TWO manifest tests, not one: one near line 294 and another at ~lines 322-326. Remove BOTH. After removing them, the `ingestManifest` import (line 24, used only inside the first block ~line 308) is unused ... drop that import line too, or `bun test` will fail on the unused binding under the repo's TS settings. Grep to confirm no other reference: `grep -n ingestManifest src/cli/commands.test.ts`. - [ ] **Step 2: Run tests to confirm the suite is green without it** From 2e7d28aac724324828079ae41426de762155d8e4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:52:13 -0500 Subject: [PATCH 05/26] deck-manifest: parse + validate mattstack.deck.json --- src/registry/deck-manifest.test.ts | 78 ++++++++++++++++++++++ src/registry/deck-manifest.ts | 101 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/registry/deck-manifest.test.ts create mode 100644 src/registry/deck-manifest.ts diff --git a/src/registry/deck-manifest.test.ts b/src/registry/deck-manifest.test.ts new file mode 100644 index 0000000..fce8f3d --- /dev/null +++ b/src/registry/deck-manifest.test.ts @@ -0,0 +1,78 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { readDeckManifest } from "./deck-manifest.ts"; + +function repo(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "deckman-")); + for (const [name, content] of Object.entries(files)) { + const p = join(dir, name); + mkdirSync(join(p, ".."), { recursive: true }); + writeFileSync(p, content); + } + return dir; +} + +test("absent manifest is null (not an error)", () => { + expect(readDeckManifest(repo({}))).toBeNull(); +}); + +test("reads name, port, start and action commands", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", displayName: "Chat", description: "rt chat viewer", icon: "public/icon.svg", + port: 11002, commands: { start: "bun run serve", build: "bun run build", deploy: "bun run deploy" }, + }), + }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(true); + if (!r || !r.ok) throw new Error("unreachable"); + expect(r.manifest.name).toBe("chat"); + expect(r.manifest.port).toBe(11002); + expect(r.manifest.commands).toEqual({ start: "bun run serve", build: "bun run build", deploy: "bun run deploy" }); + expect(r.manifest.displayName).toBe("Chat"); +}); + +test("reads and normalizes altConfigs (commands.start -> start)", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 5173, commands: { start: "bun run dev" } } }, + }), + }); + const r = readDeckManifest(dir); + if (!r || !r.ok) throw new Error("expected ok"); + expect(r.manifest.altConfigs).toEqual({ dev: { port: 5173, start: "bun run dev" } }); +}); + +test("rejects an overlay that overrides anything but port/commands.start", () => { + const dir = repo({ + "mattstack.deck.json": JSON.stringify({ + name: "chat", commands: { start: "s" }, + altConfigs: { dev: { commands: { deploy: "nope" } } }, + }), + }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); + if (!r || r.ok) throw new Error("expected error"); + expect(r.error).toContain("dev"); +}); + +test("rejects a non-string command value", () => { + const dir = repo({ "mattstack.deck.json": JSON.stringify({ name: "chat", commands: { start: 5 } }) }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); + +test("rejects a bad name", () => { + const dir = repo({ "mattstack.deck.json": JSON.stringify({ name: "Bad Name", commands: {} }) }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); + +test("unparseable JSON is a loud error, not null", () => { + const dir = repo({ "mattstack.deck.json": "{ not json" }); + const r = readDeckManifest(dir); + expect(r?.ok).toBe(false); +}); diff --git a/src/registry/deck-manifest.ts b/src/registry/deck-manifest.ts new file mode 100644 index 0000000..bc51693 --- /dev/null +++ b/src/registry/deck-manifest.ts @@ -0,0 +1,101 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +export interface DeckManifest { + name: string; + displayName?: string; + description?: string; + icon?: string; + port?: number; + /** Shell strings. `start` (when present) is the supervised service; every other key is an action command. */ + commands: Record; + /** Normalized overlays: each may carry only `port` and/or `start`. */ + altConfigs?: Record; +} + +export type ParseResult = + | { ok: true; manifest: DeckManifest } + | { ok: false; error: string } + | null; + +const NAME_RE = /^[a-z0-9][a-z0-9.-]*$/; + +function err(error: string): ParseResult { + return { ok: false, error }; +} + +export function readDeckManifest(dir: string): ParseResult { + let raw: string; + try { + raw = readFileSync(join(dir, "mattstack.deck.json"), "utf8"); + } catch { + return null; // absent is not an error: callers fall back to mattstack.json for identity + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return err("mattstack.deck.json is not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null) return err("mattstack.deck.json must be an object"); + const m = parsed as Record; + + if (typeof m.name !== "string" || !NAME_RE.test(m.name)) { + return err(`name must match ${NAME_RE}`); + } + + const commands: Record = {}; + if (m.commands !== undefined) { + if (typeof m.commands !== "object" || m.commands === null) return err("commands must be an object"); + for (const [key, val] of Object.entries(m.commands as Record)) { + if (typeof val !== "string" || val.length === 0) return err(`command ${key} must be a non-empty string`); + commands[key] = val; + } + } + + const out: DeckManifest = { name: m.name, commands }; + if (typeof m.displayName === "string") out.displayName = m.displayName; + if (typeof m.description === "string") out.description = m.description; + if (typeof m.icon === "string") out.icon = m.icon; + if (m.port !== undefined) { + if (!Number.isInteger(m.port) || (m.port as number) < 1 || (m.port as number) > 65535) return err("port must be 1-65535"); + out.port = m.port as number; + } + + if (m.altConfigs !== undefined) { + if (typeof m.altConfigs !== "object" || m.altConfigs === null) return err("altConfigs must be an object"); + const alts: Record = {}; + for (const [altName, rawOverlay] of Object.entries(m.altConfigs as Record)) { + if (typeof rawOverlay !== "object" || rawOverlay === null) return err(`overlay ${altName} must be an object`); + const overlay = rawOverlay as Record; + const entry: { port?: number; start?: string } = {}; + for (const key of Object.keys(overlay)) { + // The loud rejection the spec requires: an overlay is the serve shape only. + if (key !== "port" && key !== "commands") { + return err(`overlay ${altName} may only override port and commands.start (saw ${key})`); + } + } + if (overlay.port !== undefined) { + if (!Number.isInteger(overlay.port) || (overlay.port as number) < 1 || (overlay.port as number) > 65535) { + return err(`overlay ${altName} port must be 1-65535`); + } + entry.port = overlay.port as number; + } + if (overlay.commands !== undefined) { + if (typeof overlay.commands !== "object" || overlay.commands === null) return err(`overlay ${altName} commands must be an object`); + for (const key of Object.keys(overlay.commands as Record)) { + if (key !== "start") return err(`overlay ${altName} may only override commands.start (saw commands.${key})`); + } + const start = (overlay.commands as Record).start; + if (start !== undefined) { + if (typeof start !== "string" || start.length === 0) return err(`overlay ${altName} commands.start must be a non-empty string`); + entry.start = start; + } + } + alts[altName] = entry; + } + out.altConfigs = alts; + } + + return { ok: true, manifest: out }; +} From b51d10c387091ffc56be311febb063de2dd8f720 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:55:49 -0500 Subject: [PATCH 06/26] deck-manifest: resolveServeShape base + overlay --- src/registry/deck-manifest.test.ts | 31 +++++++++++++++++++++++++++++- src/registry/deck-manifest.ts | 13 +++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/registry/deck-manifest.test.ts b/src/registry/deck-manifest.test.ts index fce8f3d..cf4fe15 100644 --- a/src/registry/deck-manifest.test.ts +++ b/src/registry/deck-manifest.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "bun:test"; import { mkdtempSync, mkdirSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { readDeckManifest } from "./deck-manifest.ts"; +import { readDeckManifest, resolveServeShape } from "./deck-manifest.ts"; function repo(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "deckman-")); @@ -76,3 +76,32 @@ test("unparseable JSON is a loud error, not null", () => { const r = readDeckManifest(dir); expect(r?.ok).toBe(false); }); + +test("base serve shape wraps start in sh -c", () => { + const shape = resolveServeShape({ name: "chat", port: 11002, commands: { start: "bun run serve" } }); + expect(shape).toEqual({ port: 11002, command: ["sh", "-c", "bun run serve"] }); +}); + +test("overlay overrides port and start", () => { + const shape = resolveServeShape( + { name: "chat", port: 11002, commands: { start: "bun run serve" }, altConfigs: { dev: { port: 5173, start: "bun run dev" } } }, + "dev", + ); + expect(shape).toEqual({ port: 5173, command: ["sh", "-c", "bun run dev"] }); +}); + +test("overlay that omits a field inherits the base for it", () => { + const shape = resolveServeShape( + { name: "chat", port: 11002, commands: { start: "bun run serve" }, altConfigs: { hmr: { port: 5173 } } }, + "hmr", + ); + expect(shape).toEqual({ port: 5173, command: ["sh", "-c", "bun run serve"] }); +}); + +test("unknown alt throws", () => { + expect(() => resolveServeShape({ name: "c", commands: { start: "s" } }, "nope")).toThrow(); +}); + +test("no start command yields no argv (port-only app)", () => { + expect(resolveServeShape({ name: "c", port: 4200, commands: {} })).toEqual({ port: 4200, command: undefined }); +}); diff --git a/src/registry/deck-manifest.ts b/src/registry/deck-manifest.ts index bc51693..d0d9ccb 100644 --- a/src/registry/deck-manifest.ts +++ b/src/registry/deck-manifest.ts @@ -99,3 +99,16 @@ export function readDeckManifest(dir: string): ParseResult { return { ok: true, manifest: out }; } + +export function resolveServeShape( + manifest: DeckManifest, + altName?: string, +): { port?: number; command?: string[] } { + const overlay = altName === undefined ? undefined : manifest.altConfigs?.[altName]; + if (altName !== undefined && overlay === undefined) { + throw new Error(`unknown alt config: ${altName}`); + } + const port = overlay?.port ?? manifest.port; + const start = overlay?.start ?? manifest.commands.start; + return { port, command: start === undefined ? undefined : ["sh", "-c", start] }; +} From d488831c80f95197c9be786b53c104b4864f587c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 21:58:29 -0500 Subject: [PATCH 07/26] records: add commands/altConfigs/activeAlt to AppRecord --- src/registry/records.test.ts | 13 +++++++++++++ src/registry/records.ts | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/src/registry/records.test.ts b/src/registry/records.test.ts index f704e1c..2d5d5e3 100644 --- a/src/registry/records.test.ts +++ b/src/registry/records.test.ts @@ -52,3 +52,16 @@ test("issues accumulate per source and clear per source", () => { expect(getRecord("a")!.issues).toHaveLength(1); expect(getRecord("a")!.issues![0]!.source).toBe("launchd"); }); + +test("putRecord round-trips manifest command fields", () => { + putRecord({ + name: "chat", managedBy: "user", port: 11002, kind: "service" as const, createdAt: "x", + commands: { build: "bun run build", deploy: "bun run deploy" }, + altConfigs: { dev: { port: 5173, start: "bun run dev" } }, + activeAlt: "dev", + }); + const r = getRecord("chat")!; + expect(r.commands).toEqual({ build: "bun run build", deploy: "bun run deploy" }); + expect(r.altConfigs).toEqual({ dev: { port: 5173, start: "bun run dev" } }); + expect(r.activeAlt).toBe("dev"); +}); diff --git a/src/registry/records.ts b/src/registry/records.ts index 188e197..abeafdc 100644 --- a/src/registry/records.ts +++ b/src/registry/records.ts @@ -26,6 +26,12 @@ export interface AppRecord { description?: string; /** Present once an icon has been ingested to the deck icon store. */ icon?: { ext: "svg" }; + /** Action commands from mattstack.deck.json (shell strings), excluding `start`. Dev-mode-gated at the API. */ + commands?: Record; + /** Declared serve-shape overlays; each may carry only `port` and/or `start`. */ + altConfigs?: Record; + /** The active overlay (an `altConfigs` key), if any; absent means the base serve shape. */ + activeAlt?: string; grandfathered?: boolean; createdAt: string; /** Loud degradation: failed syncs land here and render on the board row. */ From 6aa815d05abeaf4f5a178e84b3069fa52a7f7330 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:01:54 -0500 Subject: [PATCH 08/26] register-manifest: applyManifest shared register/alt flow --- src/api/register-manifest.test.ts | 109 ++++++++++++++++++++++++++++++ src/api/register-manifest.ts | 70 +++++++++++++++++++ src/api/register.ts | 4 +- src/registry/manifest.ts | 7 +- 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/api/register-manifest.test.ts create mode 100644 src/api/register-manifest.ts diff --git a/src/api/register-manifest.test.ts b/src/api/register-manifest.test.ts new file mode 100644 index 0000000..edf8ea8 --- /dev/null +++ b/src/api/register-manifest.test.ts @@ -0,0 +1,109 @@ +import { test, expect, beforeEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), "applyman-")); + process.env.LOCAL_STATE_DIR = dir; + process.env.LOCAL_REGISTRY_PATH = join(dir, "registry.json"); + process.env.LOCAL_APPS_ROUTES_PATH = join(dir, "routes.json"); + process.env.LOCAL_APPS_SETTINGS_PATH = join(dir, "settings.json"); + process.env.LOCAL_PLATFORM_SETTINGS_PATH = join(dir, "platform.json"); + process.env.HOME = dir; + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH, "[]"); + return dir; +} + +function appRepo(manifest: object): string { + const dir = mkdtempSync(join(tmpdir(), "app-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify(manifest)); + return dir; +} + +const SVG = ''; + +test("register creates a supervised record from the manifest", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const manager = new FakeServiceManager(); + const edge = new FakeEdgeProxy(); + const dir = appRepo({ name: "chat", port: 11002, commands: { start: "bun run serve", deploy: "bun run deploy" } }); + + const r = await applyManifest(dir, undefined, { manager, edge }); + expect(r.status).toBe(200); + const rec = getRecord("chat")!; + expect(rec.command).toEqual(["sh", "-c", "bun run serve"]); + expect(rec.port).toBe(11002); + expect(rec.commands).toEqual({ deploy: "bun run deploy" }); + expect(rec.workingDirectory).toBe(dir); + expect(manager.installed.size).toBe(1); +}); + +test("register is idempotent and re-syncs a changed start command", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = mkdtempSync(join(tmpdir(), "app-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "chat", port: 11002, commands: { start: "bun run serve" } })); + await applyManifest(dir, undefined, drivers); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "chat", port: 11002, commands: { start: "bun run serve2" } })); + await applyManifest(dir, undefined, drivers); + expect(getRecord("chat")!.command).toEqual(["sh", "-c", "bun run serve2"]); +}); + +test("activating an overlay swaps port and start; off restores base", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry, getRecord } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const drivers = { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }; + const dir = appRepo({ + name: "chat", port: 11002, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 5173, commands: { start: "bun run dev" } } }, + }); + await applyManifest(dir, undefined, drivers); + await applyManifest(dir, "dev", drivers); + let rec = getRecord("chat")!; + expect(rec.port).toBe(5173); + expect(rec.command).toEqual(["sh", "-c", "bun run dev"]); + expect(rec.activeAlt).toBe("dev"); + await applyManifest(dir, undefined, drivers); + rec = getRecord("chat")!; + expect(rec.port).toBe(11002); + expect(rec.command).toEqual(["sh", "-c", "bun run serve"]); + expect(rec.activeAlt).toBeUndefined(); +}); + +test("a bad manifest is a 400 with the parse error", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const dir = appRepo({ name: "chat", commands: { start: "s" }, altConfigs: { dev: { nope: 1 } } }); + const r = await applyManifest(dir, undefined, { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }); + expect(r.status).toBe(400); +}); + +test("an absent manifest is a 400", async () => { + scratch(); + const { FakeServiceManager } = await import("../services/fake.ts"); + const { FakeEdgeProxy } = await import("../edge/portless.ts"); + const { reloadRegistry } = await import("../registry/records.ts"); + const { applyManifest } = await import("./register-manifest.ts"); + reloadRegistry(); + const r = await applyManifest(mkdtempSync(join(tmpdir(), "empty-")), undefined, { manager: new FakeServiceManager(), edge: new FakeEdgeProxy() }); + expect(r.status).toBe(400); +}); diff --git a/src/api/register-manifest.ts b/src/api/register-manifest.ts new file mode 100644 index 0000000..d79bc3f --- /dev/null +++ b/src/api/register-manifest.ts @@ -0,0 +1,70 @@ +import { readDeckManifest, resolveServeShape } from "../registry/deck-manifest.ts"; +import { getRecord, putRecord } from "../registry/records.ts"; +import { registerApp, editApp, type Drivers, type FlowResult } from "./register.ts"; +import { ingestManifest } from "../registry/manifest.ts"; + +/** + * Mirror a record to its manifest. The single flow behind both `deck register` + * (activeAlt undefined = base serve shape) and `deck alt` (activeAlt = an + * overlay name, or undefined to return to base). The manifest is the source of + * truth: every field it declares is (re)written; register clears an active alt + * because the base serve shape is the manifest's canonical one. + */ +export async function applyManifest( + dir: string, + activeAlt: string | undefined, + drivers: Drivers, +): Promise { + const parsed = readDeckManifest(dir); + if (parsed === null) return { status: 400, body: { error: "no mattstack.deck.json in " + dir } }; + if (!parsed.ok) return { status: 400, body: { error: parsed.error } }; + const manifest = parsed.manifest; + + let shape: { port?: number; command?: string[] }; + try { + shape = resolveServeShape(manifest, activeAlt); + } catch (e) { + return { status: 400, body: { error: String((e as Error).message) } }; + } + + const { start: _start, ...actionCommands } = manifest.commands; + const existing = getRecord(manifest.name); + + if (!existing) { + // A manifest with neither a start command nor a port declares nothing to stand up. + if (!shape.command && shape.port === undefined) { + return { status: 400, body: { error: "manifest must declare commands.start or a port" } }; + } + const created = await registerApp( + shape.command + ? { name: manifest.name, command: shape.command, workingDirectory: dir, port: shape.port } + : { name: manifest.name, staticPort: shape.port! }, + drivers, + ); + if (created.status !== 201) return created; + } else if (shape.command) { + // Serve shape (command/port) can change between runs and on alt switches; + // editApp tears the old launchd service down and stands the new one up. + const edited = await editApp( + manifest.name, + { command: shape.command, workingDirectory: dir, ...(shape.port !== undefined && { port: shape.port }) }, + existing.managedBy, + true, + drivers, + ); + if (edited.status !== 200) return edited; + } + + // Metadata the serve-shape flows above do not carry: action commands, the + // declared overlays, and which overlay is live. Written last, over whatever + // registerApp/editApp persisted. + const record = getRecord(manifest.name)!; + putRecord({ + ...record, + commands: Object.keys(actionCommands).length ? actionCommands : undefined, + altConfigs: manifest.altConfigs, + activeAlt, + }); + ingestManifest(manifest.name); + return { status: 200, body: { record: getRecord(manifest.name) } }; +} diff --git a/src/api/register.ts b/src/api/register.ts index 09ad0ab..0e15a71 100644 --- a/src/api/register.ts +++ b/src/api/register.ts @@ -32,6 +32,8 @@ export interface RegisterInput { env?: Record; /** For services someone runs themselves: route only, no launchd supervision. */ staticPort?: number; + /** A declared port for a supervised service (manifest register); wins over allocation. */ + port?: number; /** Record-only creation: no driver calls. Used by bootstrap catch-up and migrate. */ adopt?: boolean; } @@ -122,7 +124,7 @@ export async function registerApp(input: RegisterInput, drivers: Drivers): Promi (!input.adopt && routes.some((r) => bareName(r.hostname, getPlatformSettings().tlds) === name)); if (taken) return { status: 409, body: { error: "name taken", name } }; - let port = input.staticPort; + let port = input.staticPort ?? input.port; if (port === undefined) { const allocated = allocatePort(listRecords(), routes, await readServices()); if (allocated === null) return { status: 507, body: { error: "port range exhausted" } }; diff --git a/src/registry/manifest.ts b/src/registry/manifest.ts index 2e6b602..b895aee 100644 --- a/src/registry/manifest.ts +++ b/src/registry/manifest.ts @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "fs"; import { join, resolve } from "path"; import { stateDir } from "../api/state.ts"; import { getRecord, putRecord } from "./records.ts"; +import { readDeckManifest } from "./deck-manifest.ts"; export interface Manifest { displayName: string; @@ -73,7 +74,11 @@ export function removeIcon(name: string): void { export function ingestManifest(name: string): void { const record = getRecord(name); if (!record || record.workingDirectory === undefined) return; - const manifest = readManifest(record.workingDirectory); + const deck = readDeckManifest(record.workingDirectory); + const manifest = + deck && deck.ok && deck.manifest.displayName && deck.manifest.icon + ? { displayName: deck.manifest.displayName, description: deck.manifest.description, icon: deck.manifest.icon } + : readManifest(record.workingDirectory); // deprecated mattstack.json fallback (identity only) if (!manifest) return; let svg: string; try { From 95a67fe57249695112083f4894e695a5b7e85078 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:06:14 -0500 Subject: [PATCH 09/26] config-init: scaffold mattstack.deck.json Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/config-init.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/cli/config-init.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/cli/config-init.test.ts create mode 100644 src/cli/config-init.ts diff --git a/src/cli/config-init.test.ts b/src/cli/config-init.test.ts new file mode 100644 index 0000000..6171cfe --- /dev/null +++ b/src/cli/config-init.test.ts @@ -0,0 +1,35 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join, basename } from "path"; +import { configInit } from "./config-init.ts"; + +function io() { + const lines: string[] = []; + return { out: (s: string) => lines.push(s), err: (s: string) => lines.push(s), lines }; +} + +test("scaffolds a manifest inferring name and scripts", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ scripts: { serve: "bun run serve", build: "bun run build" } })); + const a = io(); + expect(configInit(dir, a)).toBe(0); + const m = JSON.parse(readFileSync(join(dir, "mattstack.deck.json"), "utf8")); + expect(m.name).toBe(basename(dir)); + expect(m.commands.start).toBe("bun run serve"); + expect(m.commands.build).toBe("bun run build"); +}); + +test("refuses to overwrite an existing manifest", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + writeFileSync(join(dir, "mattstack.deck.json"), "{}"); + const a = io(); + expect(configInit(dir, a)).toBe(1); + expect(readFileSync(join(dir, "mattstack.deck.json"), "utf8")).toBe("{}"); +}); + +test("still scaffolds with no package.json", () => { + const dir = mkdtempSync(join(tmpdir(), "cfginit-")); + expect(configInit(dir, io())).toBe(0); + expect(existsSync(join(dir, "mattstack.deck.json"))).toBe(true); +}); diff --git a/src/cli/config-init.ts b/src/cli/config-init.ts new file mode 100644 index 0000000..7cf3ad9 --- /dev/null +++ b/src/cli/config-init.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; + +interface Io { out(s: string): void; err(s: string): void } + +function readScripts(dir: string): Record { + try { + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + return typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {}; + } catch { + return {}; + } +} + +export function configInit(cwd: string, io: Io): number { + const target = join(cwd, "mattstack.deck.json"); + if (existsSync(target)) { + io.err("mattstack.deck.json already exists ... not overwriting"); + return 1; + } + const scripts = readScripts(cwd); + const start = scripts.serve ? "bun run serve" : scripts.start ? "bun run start" : "bun run serve"; + const commands: Record = { start }; + if (scripts.build) commands.build = "bun run build"; + const manifest = { name: basename(cwd), commands }; + writeFileSync(target, JSON.stringify(manifest, null, 2) + "\n"); + io.out(`wrote ${target}`); + return 0; +} From eb5a9e5ab0ff976753f3ec76b2563253fd68de96 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:15:36 -0500 Subject: [PATCH 10/26] cli: deck register + deck config init --- src/api/server.test.ts | 9 +++++++++ src/api/server.ts | 8 +++++++- src/cli/commands.test.ts | 10 ++++++++++ src/cli/commands.ts | 17 +++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/api/server.test.ts b/src/api/server.test.ts index 44cd247..82a0e1c 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -438,3 +438,12 @@ test("adopt endpoint: renames + flips ownership, and a re-run is an idempotent 2 expect(ghost.status).toBe(404); expect(await ghost.json()).toEqual({ error: "unknown app" }); }); + +test("POST /apps/register creates a record from a manifest dir", async () => { + const dir = mkdtempSync(join(tmpdir(), "reg-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "regtest", port: 4321, commands: { start: "bun run serve" } })); + const res = await post("/api/v1/apps/register", { dir }); + expect(res.status).toBe(200); + const get = await api("/api/v1/apps/regtest"); + expect(get.status).toBe(200); +}); diff --git a/src/api/server.ts b/src/api/server.ts index c96b9c3..99d3e9a 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -305,7 +305,13 @@ export function startApi(deps: ApiDeps) { } // Checked ahead of the generic /apps/:name matcher below so a real app - // named "managed" can never shadow these bulk lifecycle routes. + // named "register" or "managed" can never shadow these routes. + if (pathname === "/api/v1/apps/register" && req.method === "POST") { + const b = await body(req); + const { applyManifest } = await import("./register-manifest.ts"); + const r = await applyManifest(String(b.dir ?? ""), undefined, deps); + return json(r.body, r.status); + } if (pathname === "/api/v1/apps/managed/restart" && req.method === "POST") { const r = await restartManagedApps(deps); return json(r.body, r.status); diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 214f4be..7da506c 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -325,3 +325,13 @@ test("manifest refresh without a sub-verb or name is usage", async () => { const y = io(); expect(await runCommand(["manifest", "refresh"], y)).toBe(2); }); + +test("register from a manifest dir, then config init refuses overwrite", async () => { + const appDir = mkdtempSync(join(tmpdir(), "regcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ name: "regcli", port: 4322, commands: { start: "bun run serve" } })); + const a = io(); + expect(await runCommand(["register", "--dir", appDir], a)).toBe(0); + const s = io(); + expect(await runCommand(["status"], s)).toBe(0); + expect(s.lines.join("\n")).toContain("regcli"); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index bfd8dec..71561d5 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,5 +1,6 @@ // src/cli/commands.ts import { apiJson } from "./client.ts"; +import { configInit } from "./config-init.ts"; import pkg from "../../package.json"; export const VERSION = pkg.version; @@ -17,6 +18,8 @@ usage: deck url [--public] print its local url (or public url with --public) deck add --port N route an app you run yourself deck add --cmd "…" --dir PATH register a supervised app + deck config init scaffold mattstack.deck.json in cwd + deck register [--dir PATH] create/sync an app from its mattstack.deck.json deck remove [--force] unregister (registrar-owned; --force is the escape hatch) deck remove --managed unregister every app deck manages (installer's uninstall step) deck restart kickstart its service @@ -244,6 +247,20 @@ export async function runCommand( : `already adopted — ${host}`); return 0; } + case "config": { + const [sub] = rest; + if (sub !== "init") { io.err("usage: deck config init"); return 2; } + return configInit(process.cwd(), io); + } + case "register": { + const dir = flag(rest, "--dir") ?? process.cwd(); + const { status, body } = await apiJson("/api/v1/apps/register", { + method: "POST", body: JSON.stringify({ dir }), + }); + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(`registered ${body.record.name} on port ${body.record.port}`); + return 0; + } case "manifest": { const [sub, name] = rest; if (sub !== "refresh" || !name) { From 9ac73c057a2bb356d9359990c4d7421c301b20f1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:18:54 -0500 Subject: [PATCH 11/26] cli: deck alt overlay activation --- src/api/server.test.ts | 19 +++++++++++++++++++ src/api/server.ts | 10 ++++++++++ src/cli/commands.test.ts | 11 +++++++++++ src/cli/commands.ts | 12 ++++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/api/server.test.ts b/src/api/server.test.ts index 82a0e1c..07d6af4 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -447,3 +447,22 @@ test("POST /apps/register creates a record from a manifest dir", async () => { const get = await api("/api/v1/apps/regtest"); expect(get.status).toBe(200); }); + +test("POST /apps/:name/alt activates and clears an overlay", async () => { + const dir = mkdtempSync(join(tmpdir(), "alt-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ + name: "altapp", port: 4400, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 4500, commands: { start: "bun run dev" } } }, + })); + await post("/api/v1/apps/register", { dir }); + const on = await post("/api/v1/apps/altapp/alt", { alt: "dev" }); + expect(on.status).toBe(200); + expect((await (await api("/api/v1/apps/altapp")).json()).record.port).toBe(4500); + const off = await post("/api/v1/apps/altapp/alt", { alt: null }); + expect(off.status).toBe(200); + expect((await (await api("/api/v1/apps/altapp")).json()).record.port).toBe(4400); +}); + +test("alt on an unknown app is 404", async () => { + expect((await post("/api/v1/apps/ghost/alt", { alt: "dev" })).status).toBe(404); +}); diff --git a/src/api/server.ts b/src/api/server.ts index 99d3e9a..9bdc0ee 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -383,6 +383,16 @@ export function startApi(deps: ApiDeps) { if (!svc) return json({ error: "unknown app" }, 404); return json({ ok: await restartService(svc.label) }); } + if (sub === "alt" && req.method === "POST") { + const record = getRecord(name); + if (!record) return json({ error: "unknown app" }, 404); + if (!record.workingDirectory) return json({ error: "app has no manifest directory" }, 400); + const b = await body(req); + const alt = b.alt == null ? undefined : String(b.alt); + const { applyManifest } = await import("./register-manifest.ts"); + const r = await applyManifest(record.workingDirectory, alt, deps); + return json(r.body, r.status); + } if (sub === "logs" && req.method === "GET") { const lines = Number(url.searchParams.get("lines") ?? 40); const record = getRecord(name); diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 7da506c..f22d09e 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -335,3 +335,14 @@ test("register from a manifest dir, then config init refuses overwrite", async ( expect(await runCommand(["status"], s)).toBe(0); expect(s.lines.join("\n")).toContain("regcli"); }); + +test("deck alt on/off round-trip", async () => { + const appDir = mkdtempSync(join(tmpdir(), "altcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ + name: "altcli", port: 4600, commands: { start: "bun run serve" }, + altConfigs: { dev: { port: 4700 } }, + })); + expect(await runCommand(["register", "--dir", appDir], io())).toBe(0); + expect(await runCommand(["alt", "altcli", "dev"], io())).toBe(0); + expect(await runCommand(["alt", "altcli", "off"], io())).toBe(0); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 71561d5..a9ec240 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -20,6 +20,7 @@ usage: deck add --cmd "…" --dir PATH register a supervised app deck config init scaffold mattstack.deck.json in cwd deck register [--dir PATH] create/sync an app from its mattstack.deck.json + deck alt activate a declared serve overlay, or return to base deck remove [--force] unregister (registrar-owned; --force is the escape hatch) deck remove --managed unregister every app deck manages (installer's uninstall step) deck restart kickstart its service @@ -261,6 +262,17 @@ export async function runCommand( io.out(`registered ${body.record.name} on port ${body.record.port}`); return 0; } + case "alt": { + const [name, which] = rest; + if (!name || !which) { io.err(USAGE); return 2; } + const alt = which === "off" ? null : which; + const { status, body } = await apiJson(`/api/v1/apps/${name}/alt`, { + method: "POST", body: JSON.stringify({ alt }), + }); + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(which === "off" ? `${name} back on its base config` : `${name} now on alt "${which}"`); + return 0; + } case "manifest": { const [sub, name] = rest; if (sub !== "refresh" || !name) { From 6a8d3943ae792f9d2601b33efbccdead93d86ddd Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:21:05 -0500 Subject: [PATCH 12/26] dev-mode: rt-client mattstack.mode reader, fail closed --- src/api/dev-mode.test.ts | 20 ++++++++++++++++++++ src/api/dev-mode.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/api/dev-mode.test.ts create mode 100644 src/api/dev-mode.ts diff --git a/src/api/dev-mode.test.ts b/src/api/dev-mode.test.ts new file mode 100644 index 0000000..af3f745 --- /dev/null +++ b/src/api/dev-mode.test.ts @@ -0,0 +1,20 @@ +import { test, expect, beforeEach } from "bun:test"; +import { isDevMode, resetDevModeCache } from "./dev-mode.ts"; + +beforeEach(() => resetDevModeCache()); + +test("dev when mattstack.mode is dev", () => { + expect(isDevMode({ read: () => "dev" })).toBe(true); +}); + +test("prod when mattstack.mode is prod", () => { + expect(isDevMode({ read: () => "prod" })).toBe(false); +}); + +test("unset value is production (fail closed)", () => { + expect(isDevMode({ read: () => undefined })).toBe(false); +}); + +test("a throwing read is production (fail closed)", () => { + expect(isDevMode({ read: () => { throw new Error("no daemon"); } })).toBe(false); +}); diff --git a/src/api/dev-mode.ts b/src/api/dev-mode.ts new file mode 100644 index 0000000..dee875a --- /dev/null +++ b/src/api/dev-mode.ts @@ -0,0 +1,29 @@ +import { getSetting } from "@mattstack/rt-client"; + +// rt's machine-flavor setting, written by `rt settings dev-mode`. Read through +// rt-client only, never by touching ~/.mattstack/rt files directly. +const MODE_KEY = "mattstack.mode"; +const DEV_MODE_TTL_MS = 2000; + +function defaultRead(): string | undefined { + return getSetting(MODE_KEY).value; +} + +let cached: { at: number; dev: boolean } | null = null; + +export function resetDevModeCache(): void { + cached = null; +} + +export function isDevMode(deps: { read?: () => string | undefined } = {}): boolean { + const now = Date.now(); + if (cached && now - cached.at < DEV_MODE_TTL_MS) return cached.dev; + let dev = false; + try { + dev = (deps.read ?? defaultRead)() === "dev"; + } catch { + dev = false; // fail closed: a failed read counts as production + } + cached = { at: now, dev }; + return dev; +} From 945ca418ced64f27addb91863d6301792815f264 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:30:59 -0500 Subject: [PATCH 13/26] command-runner: spawn shell command to app log, one in flight --- src/services/command-runner.test.ts | 49 ++++++++++++++++++++++ src/services/command-runner.ts | 63 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/services/command-runner.test.ts create mode 100644 src/services/command-runner.ts diff --git a/src/services/command-runner.test.ts b/src/services/command-runner.test.ts new file mode 100644 index 0000000..1cfb840 --- /dev/null +++ b/src/services/command-runner.test.ts @@ -0,0 +1,49 @@ +import { test, expect, beforeEach } from "bun:test"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { startCommandRun, commandRunStatus, resetRuns } from "./command-runner.ts"; + +beforeEach(() => resetRuns()); + +function fakeSpawn(exit: Promise) { + const calls: Array<{ argv: string[]; cwd: string }> = []; + const spawn = (argv: string[], opts: { cwd: string; stdout: number; stderr: number }) => { + calls.push({ argv, cwd: opts.cwd }); + return { exited: exit }; + }; + return { spawn, calls }; +} + +test("spawns sh -c in the working directory and returns a runId", () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn, calls } = fakeSpawn(new Promise(() => {})); // never resolves = still running + const r = startCommandRun({ name: "chat", cmd: "deploy", shell: "bun run deploy", workingDirectory: "/tmp/app" }, { spawn, logDir }); + expect(r.started).toBe(true); + if (!r.started) throw new Error("unreachable"); + expect(calls[0].argv).toEqual(["sh", "-c", "bun run deploy"]); + expect(calls[0].cwd).toBe("/tmp/app"); + expect(commandRunStatus("chat", r.runId)!.status).toBe("running"); +}); + +test("refuses a second run while one is in flight (busy)", () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn } = fakeSpawn(new Promise(() => {})); + const first = startCommandRun({ name: "chat", cmd: "deploy", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + expect(first.started).toBe(true); + const second = startCommandRun({ name: "chat", cmd: "build", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + expect(second.started).toBe(false); +}); + +test("status flips to exited with the code when the process ends", async () => { + const logDir = mkdtempSync(join(tmpdir(), "runlog-")); + const { spawn } = fakeSpawn(Promise.resolve(0)); + const r = startCommandRun({ name: "chat", cmd: "deploy", shell: "s", workingDirectory: "/tmp" }, { spawn, logDir }); + if (!r.started) throw new Error("unreachable"); + await new Promise((res) => setTimeout(res, 10)); // let the exited handler run + expect(commandRunStatus("chat", r.runId)).toEqual({ status: "exited", exitCode: 0 }); +}); + +test("unknown run is null", () => { + expect(commandRunStatus("chat", "nope")).toBeNull(); +}); diff --git a/src/services/command-runner.ts b/src/services/command-runner.ts new file mode 100644 index 0000000..3dbe9f7 --- /dev/null +++ b/src/services/command-runner.ts @@ -0,0 +1,63 @@ +import { openSync, mkdirSync } from "fs"; +import { join } from "path"; +import { randomBytes } from "crypto"; +import { logsDir } from "../api/state.ts"; + +export type SpawnFn = ( + argv: string[], + opts: { cwd: string; stdout: number; stderr: number }, +) => { exited: Promise }; + +interface Run { + runId: string; + cmd: string; + status: "running" | "exited"; + exitCode?: number; +} + +const runs = new Map(); // keyed by app name: one in-flight run per app + +export function resetRuns(): void { + runs.clear(); +} + +const defaultSpawn: SpawnFn = (argv, opts) => Bun.spawn(argv, opts) as unknown as { exited: Promise }; + +export function startCommandRun( + input: { name: string; cmd: string; shell: string; workingDirectory: string }, + deps: { spawn?: SpawnFn; logDir?: string } = {}, +): { started: true; runId: string } | { started: false; reason: "busy" } { + const active = runs.get(input.name); + if (active && active.status === "running") return { started: false, reason: "busy" }; + + const dir = deps.logDir ?? logsDir(); + mkdirSync(dir, { recursive: true }); + // Append into the app's existing deck log, so `deck logs` shows command output. + const out = openSync(join(dir, `${input.name}.out.log`), "a"); + const errFd = openSync(join(dir, `${input.name}.err.log`), "a"); + + const runId = randomBytes(8).toString("hex"); + const run: Run = { runId, cmd: input.cmd, status: "running" }; + runs.set(input.name, run); + + const proc = (deps.spawn ?? defaultSpawn)(["sh", "-c", input.shell], { + cwd: input.workingDirectory, + stdout: out, + stderr: errFd, + }); + proc.exited.then((code) => { + run.status = "exited"; + run.exitCode = code; + }); + + return { started: true, runId }; +} + +export function commandRunStatus( + name: string, + runId: string, +): { status: "running" | "exited"; exitCode?: number } | null { + const run = runs.get(name); + if (!run || run.runId !== runId) return null; + return run.exitCode === undefined ? { status: run.status } : { status: run.status, exitCode: run.exitCode }; +} From 61dc275397b9dabf105230e6aed5fdfcfe67d9f6 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:36:53 -0500 Subject: [PATCH 14/26] server: dev-gated action-command run + status routes --- src/api/server.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ src/api/server.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/api/server.test.ts b/src/api/server.test.ts index 07d6af4..47d25fc 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -48,6 +48,12 @@ const cannedAccessFetch = (async (url: string | URL | Request, init?: RequestIni return Response.json({ success: true, result: { id: "pol-1" } }); }) as typeof fetch; +// A dev-gated server: devMode: () => true unlocks the action-command routes +// regardless of the real machine's mattstack.mode, which this test environment +// can never set to "dev" (rt-client 0.3.0 does not register that key). +const DEV_PORT = 18923; +let devServer: ReturnType; + beforeAll(() => { manager = new FakeServiceManager(); edge = new FakeEdgeProxy(); @@ -56,6 +62,15 @@ beforeAll(() => { port: PORT, canaryPort: PORT + 1, freshness: () => "unknown", autoHeal: () => null, onRouteWrite: () => {}, tunnel: new FakeTunnelDriver(), + devMode: () => false, + }); + + devServer = startApi({ + manager: new FakeServiceManager(), edge: new FakeEdgeProxy(), + port: DEV_PORT, canaryPort: DEV_PORT + 1, + freshness: () => "unknown", autoHeal: () => null, onRouteWrite: () => {}, + tunnel: new FakeTunnelDriver(), + devMode: () => true, }); domainCfDir = mkdtempSync(join(tmpdir(), "local-cfdir-")); @@ -83,6 +98,7 @@ afterAll(() => { server.stop(true); domainServer.stop(true); cfServer.stop(true); + devServer.stop(true); rmSync(dir, { recursive: true, force: true }); rmSync(domainCfDir, { recursive: true, force: true }); }); @@ -101,6 +117,13 @@ beforeEach(() => { const api = (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${PORT}${path}`, init); const post = (path: string, body: unknown, headers: Record = {}) => api(path, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); +// prodPost is the default (devMode: () => false) server: used where a test +// specifically needs the production gate rather than any of the other servers. +const prodPost = post; + +const devApi = (path: string, init?: RequestInit) => fetch(`http://127.0.0.1:${DEV_PORT}${path}`, init); +const devPost = (path: string, body: unknown, headers: Record = {}) => + devApi(path, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(body) }); test("healthz answers ok (health contract)", async () => { expect(await (await api("/healthz")).text()).toBe("ok"); @@ -466,3 +489,25 @@ test("POST /apps/:name/alt activates and clears an overlay", async () => { test("alt on an unknown app is 404", async () => { expect((await post("/api/v1/apps/ghost/alt", { alt: "dev" })).status).toBe(404); }); + +test("command route runs a declared command in dev", async () => { + const dir = mkdtempSync(join(tmpdir(), "cmd-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "cmdapp", port: 4800, commands: { start: "s", build: "echo built" } })); + await devPost("/api/v1/apps/register", { dir }); + const run = await devPost("/api/v1/apps/cmdapp/commands/build", {}); + expect(run.status).toBe(200); + const body = await run.json(); + expect(body.started).toBe(true); + expect(typeof body.runId).toBe("string"); +}); + +test("unknown command name is 404 in dev", async () => { + const dir = mkdtempSync(join(tmpdir(), "cmd-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "cmdapp2", port: 4801, commands: { start: "s" } })); + await devPost("/api/v1/apps/register", { dir }); + expect((await devPost("/api/v1/apps/cmdapp2/commands/ghost", {})).status).toBe(404); +}); + +test("command route is 404 in production", async () => { + expect((await prodPost("/api/v1/apps/anything/commands/build", {})).status).toBe(404); +}); diff --git a/src/api/server.ts b/src/api/server.ts index 9bdc0ee..a7d2b23 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -24,6 +24,8 @@ import { migrate } from "../registry/migrate.ts"; import { convert } from "../registry/convert.ts"; import { redactedSettings, updatePlatformSettings, getPlatformSettings } from "./platform-settings.ts"; import { logsDir } from "./state.ts"; +import { isDevMode } from "./dev-mode.ts"; +import { startCommandRun, commandRunStatus } from "../services/command-runner.ts"; import { join } from "path"; import { userInfo } from "os"; import type { TunnelDriver } from "../edge/tunnel.ts"; @@ -46,6 +48,8 @@ export interface ApiDeps extends Drivers { accessFetch?: typeof fetch; /** Fake rt-secrets transport for CF Access driver tests; production omits it and reads the real daemon. */ deckSecrets?: RtSecretsDeps; + /** Dev-mode gate for action commands. Defaults to isDevMode in production wiring; tests inject it. */ + devMode?: () => boolean; } export function callerOf(req: Request): string { @@ -340,6 +344,30 @@ export function startApi(deps: ApiDeps) { } } + // Dev-only action-command routes: production must 404 exactly as if the + // route did not exist, so no command metadata leaks off-machine. + { + const cm = pathname.match(/^\/api\/v1\/apps\/([^/]+)\/commands\/([a-z0-9-]+)(?:\/([a-z0-9]+))?$/); + if (cm) { + const dev = (deps.devMode ?? isDevMode)(); + if (!dev) return json({ error: "not found" }, 404); // production: indistinguishable from absent + const [, name, cmd, runId] = cm as unknown as [string, string, string, string | undefined]; + const record = getRecord(name); + if (!record?.commands || !(cmd in record.commands)) return json({ error: "not found" }, 404); + if (runId && req.method === "GET") { + const st = commandRunStatus(name, runId); + return st ? json(st) : json({ error: "unknown run" }, 404); + } + if (!runId && req.method === "POST") { + const started = startCommandRun({ + name, cmd, shell: record.commands[cmd]!, workingDirectory: record.workingDirectory!, + }); + if (!started.started) return json({ error: "busy" }, 409); + return json({ started: true, runId: started.runId }); + } + } + } + const m = pathname.match(/^\/api\/v1\/apps\/([^/]+)(?:\/([a-z-]+))?$/); if (m) { const [, name, sub] = m as unknown as [string, string, string | undefined]; From d44880b4bb230209c9644c5bf1f0cf2a47fd3d78 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:41:09 -0500 Subject: [PATCH 15/26] server: 400 when an action command has no manifest workingDirectory --- src/api/server.test.ts | 7 +++++++ src/api/server.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/api/server.test.ts b/src/api/server.test.ts index 47d25fc..a546efe 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -511,3 +511,10 @@ test("unknown command name is 404 in dev", async () => { test("command route is 404 in production", async () => { expect((await prodPost("/api/v1/apps/anything/commands/build", {})).status).toBe(404); }); + +test("a port-only manifest with an action command but no workingDirectory 400s instead of spawning with cwd undefined", async () => { + const dir = mkdtempSync(join(tmpdir(), "cmd-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "portonly", port: 4890, commands: { build: "echo hi" } })); + await devPost("/api/v1/apps/register", { dir }); + expect((await devPost("/api/v1/apps/portonly/commands/build", {})).status).toBe(400); +}); diff --git a/src/api/server.ts b/src/api/server.ts index a7d2b23..e85bc38 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -359,8 +359,11 @@ export function startApi(deps: ApiDeps) { return st ? json(st) : json({ error: "unknown run" }, 404); } if (!runId && req.method === "POST") { + // A port-only (external) manifest carries commands but no workingDirectory: + // never spawn with cwd undefined, which would run in deck's own directory. + if (!record.workingDirectory) return json({ error: "app has no manifest directory" }, 400); const started = startCommandRun({ - name, cmd, shell: record.commands[cmd]!, workingDirectory: record.workingDirectory!, + name, cmd, shell: record.commands[cmd]!, workingDirectory: record.workingDirectory, }); if (!started.started) return json({ error: "busy" }, 409); return json({ started: true, runId: started.runId }); From d4a456963b21b705c2ce4610d64466c90718f213 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:43:40 -0500 Subject: [PATCH 16/26] cli: deck cmd action-command verb --- src/cli/commands.test.ts | 10 ++++++++++ src/cli/commands.ts | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index f22d09e..590b741 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -33,6 +33,7 @@ beforeAll(() => { cloudflaredDir: dir, port: PORT, canaryPort: PORT + 1, freshness: () => "unknown", autoHeal: () => null, onRouteWrite: () => {}, + devMode: () => true, }); writeApiInfo(PORT); }); @@ -346,3 +347,12 @@ test("deck alt on/off round-trip", async () => { expect(await runCommand(["alt", "altcli", "dev"], io())).toBe(0); expect(await runCommand(["alt", "altcli", "off"], io())).toBe(0); }); + +test("deck cmd runs a declared action command", async () => { + const appDir = mkdtempSync(join(tmpdir(), "cmdcli-")); + writeFileSync(join(appDir, "mattstack.deck.json"), JSON.stringify({ name: "cmdcli", port: 4900, commands: { start: "s", build: "echo hi" } })); + expect(await runCommand(["register", "--dir", appDir], io())).toBe(0); + const a = io(); + expect(await runCommand(["cmd", "cmdcli", "build"], a)).toBe(0); + expect(a.lines.join("\n")).toContain("started"); +}); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index a9ec240..b508ded 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -21,6 +21,7 @@ usage: deck config init scaffold mattstack.deck.json in cwd deck register [--dir PATH] create/sync an app from its mattstack.deck.json deck alt activate a declared serve overlay, or return to base + deck cmd run a declared action command (dev mode only) deck remove [--force] unregister (registrar-owned; --force is the escape hatch) deck remove --managed unregister every app deck manages (installer's uninstall step) deck restart kickstart its service @@ -273,6 +274,16 @@ export async function runCommand( io.out(which === "off" ? `${name} back on its base config` : `${name} now on alt "${which}"`); return 0; } + case "cmd": { + const [app, name] = rest; + if (!app || !name) { io.err(USAGE); return 2; } + const { status, body } = await apiJson(`/api/v1/apps/${app}/commands/${name}`, { method: "POST" }); + if (status === 404) { io.err(`no such command (is deck in dev mode?): ${app} ${name}`); return 1; } + if (status === 409) { io.err(`${app} is already running a command`); return 1; } + if (status !== 200) { io.err(body.error ?? `failed (${status})`); return 1; } + io.out(`started ${app} ${name} (run ${body.runId})`); + return 0; + } case "manifest": { const [sub, name] = rest; if (sub !== "refresh" || !name) { From e0a0990310acb2bf202eb499041433a61e66f98c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:47:33 -0500 Subject: [PATCH 17/26] status: dev-gated command names on the row --- src/api/server.test.ts | 14 ++++++++++++++ src/api/server.ts | 1 + src/api/status.ts | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/src/api/server.test.ts b/src/api/server.test.ts index a546efe..68b33e6 100644 --- a/src/api/server.test.ts +++ b/src/api/server.test.ts @@ -518,3 +518,17 @@ test("a port-only manifest with an action command but no workingDirectory 400s i await devPost("/api/v1/apps/register", { dir }); expect((await devPost("/api/v1/apps/portonly/commands/build", {})).status).toBe(400); }); + +test("status carries command names in dev, omits them in prod", async () => { + const dir = mkdtempSync(join(tmpdir(), "meta-")); + writeFileSync(join(dir, "mattstack.deck.json"), JSON.stringify({ name: "metaapp", port: 4950, commands: { start: "s", deploy: "d" } })); + await devPost("/api/v1/apps/register", { dir }); + // Registration only creates the registry record and an in-memory alias (the + // fake edge driver never persists to routes.json); a status row needs a + // route on disk too, same as the other status-row tests in this file. + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, JSON.stringify([{ hostname: "metaapp.localhost", port: 4950, pid: 0 }])); + const devRow = (await (await devApi("/api/v1/status")).json()).apps.find((a: any) => a.name === "metaapp"); + expect(devRow.commands).toEqual(["deploy"]); + const prodRow = (await (await api("/api/v1/status")).json()).apps.find((a: any) => a.name === "metaapp"); + expect(prodRow.commands).toBeUndefined(); +}); diff --git a/src/api/server.ts b/src/api/server.ts index e85bc38..dbf3be2 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -214,6 +214,7 @@ export function startApi(deps: ApiDeps) { const statusOpts = { requestHost: host, port: deps.port, canaryPort: deps.canaryPort, proxyFreshness: deps.freshness(), autoHeal: deps.autoHeal(), + devMode: (deps.devMode ?? isDevMode)(), }; // ---- static / identity (carried from core/server.ts) ---- diff --git a/src/api/status.ts b/src/api/status.ts index 476b163..367333a 100644 --- a/src/api/status.ts +++ b/src/api/status.ts @@ -27,6 +27,8 @@ export interface BuildStatusOpts { canaryPort: number; proxyFreshness: "fresh" | "stale" | "unknown"; autoHeal: { at: number; ok: boolean | null } | null; + /** Gates `StatusRow.commands`: action-command names leak only to the local dev UI. */ + devMode?: boolean; } export interface StatusService { @@ -92,6 +94,8 @@ export interface StatusRow { */ record: { kind: "service" | "external"; command: string[] | null; workingDirectory: string | null } | null; oauth: OAuth; + /** Action-command names (excludes `start`), dev-mode only. */ + commands?: string[]; } export interface Status { @@ -208,6 +212,7 @@ export async function buildStatus(opts: BuildStatusOpts): Promise { } : null, oauth: getOAuth(a.name), + commands: opts.devMode && record?.commands ? Object.keys(record.commands) : undefined, }; }), ); From 5e6fb697b3552af11564d4415121ec2d9f8dd177 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 28 Aug 2026 22:54:56 -0500 Subject: [PATCH 18/26] board: per-command action buttons on the app row --- core/board/AppsTable.tsx | 19 ++- core/board/logic.ts | 3 + core/board/useBoardState.ts | 7 + core/generated/board.js | 204 +++++++++++------------ test/dom/commands.spec.ts | 22 +++ test/fixture/status-commands.json | 258 ++++++++++++++++++++++++++++++ 6 files changed, 410 insertions(+), 103 deletions(-) create mode 100644 test/dom/commands.spec.ts create mode 100644 test/fixture/status-commands.json diff --git a/core/board/AppsTable.tsx b/core/board/AppsTable.tsx index c8280e7..4992c09 100644 --- a/core/board/AppsTable.tsx +++ b/core/board/AppsTable.tsx @@ -31,7 +31,7 @@ export function AppsTable({ onOpenRow, registerChevron, }: { section: AppsSection; showHead: boolean; data: StatusData; board: BoardState } & DrawerRowProps) { - const { isRestarting, onRestart, onPublish } = board; + const { isRestarting, onRestart, onRunCommand, onPublish } = board; return ( {showHead && ( @@ -45,6 +45,7 @@ export function AppsTable({ public + )} @@ -76,6 +77,9 @@ export function AppsTable({ + + + registerChevron(row.name, el)} /> @@ -291,6 +295,19 @@ function RestartCell({ ); } +function CommandsCell({ row, onRunCommand }: { row: Row; onRunCommand: (row: Row, name: string) => void }) { + if (!row.commands?.length) return null; + return ( + <> + {row.commands.map((name) => ( + + ))} + + ); +} + /** Opens the row's drawer via the row's own onClick (this button is exempted from `isDrawerClick`'s interactive-target check, so the click bubbles rather than needing its own handler). A plain `