Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
3e92bd5
docs: manifest-first design spec (mattstack.deck.json)
m4ttheweric Aug 29, 2026
6921dc5
spec: record CLI cleanup (manifest refresh removed, adopt slimmed)
m4ttheweric Aug 29, 2026
3e5dbc8
docs: manifest-first implementation plan (16 tasks, 7 phases)
m4ttheweric Aug 29, 2026
6e4b1f9
plan: fix service port, DOM test idiom, optional devMode (review r1)
m4ttheweric Aug 29, 2026
2e7d28a
deck-manifest: parse + validate mattstack.deck.json
m4ttheweric Aug 29, 2026
b51d10c
deck-manifest: resolveServeShape base + overlay
m4ttheweric Aug 29, 2026
d488831
records: add commands/altConfigs/activeAlt to AppRecord
m4ttheweric Aug 29, 2026
6aa815d
register-manifest: applyManifest shared register/alt flow
m4ttheweric Aug 29, 2026
95a67fe
config-init: scaffold mattstack.deck.json
m4ttheweric Aug 29, 2026
eb5a9e5
cli: deck register + deck config init
m4ttheweric Aug 29, 2026
9ac73c0
cli: deck alt overlay activation
m4ttheweric Aug 29, 2026
6a8d394
dev-mode: rt-client mattstack.mode reader, fail closed
m4ttheweric Aug 29, 2026
945ca41
command-runner: spawn shell command to app log, one in flight
m4ttheweric Aug 29, 2026
61dc275
server: dev-gated action-command run + status routes
m4ttheweric Aug 29, 2026
d44880b
server: 400 when an action command has no manifest workingDirectory
m4ttheweric Aug 29, 2026
d4a4569
cli: deck cmd action-command verb
m4ttheweric Aug 29, 2026
e0a0990
status: dev-gated command names on the row
m4ttheweric Aug 29, 2026
5e6fb69
board: per-command action buttons on the app row
m4ttheweric Aug 29, 2026
d799043
cleanup: remove deck manifest refresh (subsumed by register)
m4ttheweric Aug 29, 2026
b02a34c
test: migrate manifest-refresh coverage onto register/adopt resync path
m4ttheweric Aug 29, 2026
6bbc1e7
adopt: ingest identity through the shared deck-manifest path
m4ttheweric Aug 29, 2026
6b34b9c
deck: adopt its own mattstack.deck.json + deploy script
m4ttheweric Aug 29, 2026
142b6da
deck: fix deploy.ts install path to match deck's plist binary location
m4ttheweric Aug 29, 2026
da4cf03
review: validate command keys, doc force, close runner fds, atomic de…
m4ttheweric Aug 29, 2026
b284f12
review: reject occupied port, validate config-init name, prototype-sa…
m4ttheweric Aug 29, 2026
5c39db5
review: exempt external records from editApp port-collision + cover r…
m4ttheweric Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion core/board/AppsTable.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 (
<Table>
{showHead && (
Expand All@@ -45,6 +45,7 @@ export function AppsTable({
<Table.HeadCell className="col-gap">public</Table.HeadCell>
<Table.HeadCell />
<Table.HeadCell />
<Table.HeadCell />
</Table.Head>
)}
<Table.Body>
Expand DownExpand Up@@ -76,6 +77,9 @@ export function AppsTable({
<Table.Cell>
<RestartCell row={row} data={data} restarting={restarting} onRestart={onRestart} />
</Table.Cell>
<Table.Cell>
<CommandsCell row={row} onRunCommand={onRunCommand} />
</Table.Cell>
<Table.Cell>
<ChevronCell row={row} registerRef={(el) => registerChevron(row.name, el)} />
</Table.Cell>
Expand DownExpand Up@@ -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) => (
<Button key={name} variant="subtle" size="sm" aria-label={`${name} ${row.name}`} onClick={() => onRunCommand(row, name)}>
{name}
</Button>
))}
</>
);
}

/** 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 `<button>`, not the kit
Expand Down
3 changes: 3 additions & 0 deletions core/board/logic.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,9 @@ interface StatusRow {
issues: { source: "portless" | "launchd" | "cloudflare"; message: string; at: string }[];
record: { kind: "service" | "external"; command: string[] | null; workingDirectory: string | null } | null;
oauth: { mode: "off" } | { mode: "emails"; emails: string[] } | { mode: "domains"; domains: string[] };
/** Names of manifest-defined commands the server has gated in for this row;
absent or empty renders no command buttons. */
commands?: string[];
}

export interface StatusData {
Expand Down
7 changes: 7 additions & 0 deletions core/board/useBoardState.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,6 +146,12 @@ export function useBoardState() {
apiPost(`/api/v1/apps/${row.name}/restart`).catch(() => {});
}, []);

// Swallow the rejection: a self-restarting deploy kills the API mid-POST,
// exactly like onRestart; the 5s poll re-syncs once it returns.
const onRunCommand = useCallback((row: Row, name: string) => {
apiPost(`/api/v1/apps/${row.name}/commands/${name}`).catch(() => {});
}, []);

const onPublish = useCallback(
async (row: Row) => {
try {
Expand DownExpand Up@@ -485,6 +491,7 @@ export function useBoardState() {
isRestarting,
refresh,
onRestart,
onRunCommand,
onPublish,
editing,
startEdit,
Expand Down
204 changes: 102 additions & 102 deletions core/generated/board.js

Large diffs are not rendered by default.

1,713 changes: 1,713 additions & 0 deletions docs/superpowers/plans/2026-08-28-deck-manifest-first.md

Large diffs are not rendered by default.

200 changes: 200 additions & 0 deletions docs/superpowers/specs/2026-08-28-deck-manifest-first-design.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
# 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 <app> <name|off>`**: 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 <app> <name>`**: runs an action command (CLI twin of the
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 <name>`**: 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 <name> [--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 <id> --as <name>`
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 <name> --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 <name> <port|off>`**: the flag-based twin of `deck alt`.
For a manifested app the declared overlay (`deck alt <app> 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
`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).
- 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,
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.
10 changes: 10 additions & 0 deletions mattstack.deck.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
{
"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"
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,7 +26,8 @@
"test:e2e": "LOCAL_E2E=1 bun test test/e2e.smoke.test.ts",
"capture": "bun test/capture.ts --out test/.captures",
"capture:baseline": "bun test/capture.ts --out test/baselines",
"capture:compare": "bun test/compare.ts"
"capture:compare": "bun test/compare.ts",
"deploy": "bun run scripts/deploy.ts"
},
"dependencies": {
"@mattstack/rt-client": "^0.3.0",
Expand Down
19 changes: 19 additions & 0 deletions scripts/deploy.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { $ } from "bun";
import { homedir } from "os";
import { join } from "path";

const binDir = join(homedir(), ".mattstack", "deck", "bin");
const target = join(binDir, "deck");
await $`bun run build`;
await $`bun run build:board`;
// The plist's ProgramArguments[0] is this exact path; kickstart re-execs it
// without re-reading anything else, so the binary must land here (not just
// anywhere on PATH) for the restart below to pick up the new build.
await $`mkdir -p ${binDir}`;
// install truncates-in-place, which can ETXTBSY on macOS against the currently-running
// binary; installing to a temp path in the same dir and renaming over it is atomic and
// leaves the running process holding its old inode.
await $`install -m 0755 dist/deck ${target}.new`;
await $`mv -f ${target}.new ${target}`;
// The self-restart drops the API mid-response; the board tolerates it and re-polls.
await $`deck restart deck`;
20 changes: 20 additions & 0 deletions src/api/dev-mode.test.ts
Original file line numberDiff line numberDiff line change
@@ -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);
});
29 changes: 29 additions & 0 deletions src/api/dev-mode.ts
Original file line numberDiff line numberDiff line change
@@ -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<string>(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;
}
Loading