From dee221bf8bd727698b5389ac3e9651051f2c9a57 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 01:14:17 -0500 Subject: [PATCH 01/14] deck: read the served-app catalog from the bundle's deps.lock Co-Authored-By: Claude Opus 5.5 --- .prettierignore | 4 + .../__fixtures__/deps-lock-serve.fixture.json | 59 +++++++++++ apps/deck/src/registry/bundle-catalog.test.ts | 98 +++++++++++++++++++ apps/deck/src/registry/bundle-catalog.ts | 89 +++++++++++++++++ apps/deck/src/services/bundle-layout.test.ts | 12 ++- apps/deck/src/services/bundle-layout.ts | 6 ++ 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 apps/deck/src/registry/__fixtures__/deps-lock-serve.fixture.json create mode 100644 apps/deck/src/registry/bundle-catalog.test.ts create mode 100644 apps/deck/src/registry/bundle-catalog.ts diff --git a/.prettierignore b/.prettierignore index f4371da4..a201ff2c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,10 @@ apps/board/skills/*/SKILL.md # buildBoardArtifacts()/buildGatewayCss() output; prettier reformatting # would desync the committed twin from what `bun run build:board` emits. apps/deck/core/generated +# deck's src/registry/bundle-catalog.test.ts pins this fixture's sha256, and +# repo-tools' byte-identical twin pins the same digest; reformatting it would +# break the parity both tests exist to hold. +apps/deck/src/registry/__fixtures__/deps-lock-serve.fixture.json # generated from @radix-ui/colors by packages/tokens/scripts/generate-radix.ts; radix-fresh.test.ts byte-compares it packages/tokens/src/radix.ts # generated by packages/tokens/scripts/generate-ramps.ts; the freshness gate byte-compares it diff --git a/apps/deck/src/registry/__fixtures__/deps-lock-serve.fixture.json b/apps/deck/src/registry/__fixtures__/deps-lock-serve.fixture.json new file mode 100644 index 00000000..a3361e09 --- /dev/null +++ b/apps/deck/src/registry/__fixtures__/deps-lock-serve.fixture.json @@ -0,0 +1,59 @@ +{ + "lock": { + "schema": 1, + "arch": "arm64", + "tools": [ + { "name": "deck", "version": "1.1.0", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/deck", + "url": "https://github.com/m4ttstack/apps/releases/download/deck-v1.1.0/deck-darwin-arm64.tgz", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "archive": "tar.gz", "extract": "deck", "bundlePath": "Contents/Helpers/deck", "exec": ["Contents/Helpers/deck"], + "exposeByDefault": true, "entitlements": "jit", "status": "bundled", "kind": "helper" }, + { "name": "board", "version": "0.1.5", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/board", + "url": "https://github.com/m4ttstack/apps/releases/download/board-v0.1.5/board-darwin-arm64.tgz", + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "archive": "tar.gz", "extract": "board", "bundlePath": "Contents/Helpers/board", "exec": ["Contents/Helpers/board"], + "exposeByDefault": false, "entitlements": "jit", "status": "bundled", "kind": "helper", + "serve": { "port": 11006, "args": [] } }, + { "name": "gitq", "version": "0.2.1", "license": "MIT", "repo": "m4ttstack/gitq", + "url": "https://github.com/m4ttstack/gitq/releases/download/v0.2.1/gitq-darwin-arm64", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "archive": "raw", "extract": "", "bundlePath": "Contents/Helpers/gitq", "exec": ["Contents/Helpers/gitq"], + "exposeByDefault": true, "entitlements": "jit", "status": "bundled", "kind": "helper", + "futureRowField": "tolerated" }, + { "name": "console", "version": "0.1.2", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/console", + "url": "https://github.com/m4ttstack/apps/releases/download/console-v0.1.2/console-darwin-arm64.tgz", + "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "archive": "tar.gz", "extract": "console", "bundlePath": "Contents/Helpers/console", "exec": ["Contents/Helpers/console"], + "exposeByDefault": false, "entitlements": "jit", "status": "bundled", "kind": "helper", + "serve": { "port": 11001, "args": [] } }, + { "name": "chat", "version": "0.1.2", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/chat", + "url": "https://github.com/m4ttstack/apps/releases/download/chat-v0.1.2/chat-darwin-arm64.tgz", + "sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "archive": "tar.gz", "extract": "chat", "bundlePath": "Contents/Helpers/chat", "exec": ["Contents/Helpers/chat"], + "exposeByDefault": false, "entitlements": "jit", "status": "bundled", "kind": "helper", + "serve": { "port": 11002, "args": [] } }, + { "name": "argsdemo", "version": "9.9.9", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/argsdemo", + "url": "https://github.com/m4ttstack/apps/releases/download/argsdemo-v9.9.9/argsdemo-darwin-arm64.tgz", + "sha256": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "archive": "tar.gz", "extract": "argsdemo", "bundlePath": "Contents/Helpers/argsdemo", "exec": ["Contents/Helpers/argsdemo"], + "exposeByDefault": false, "entitlements": "jit", "status": "bundled", "kind": "helper", + "serve": { "port": 11090, "args": ["serve", "--no-open"], "futureServeField": 1 } }, + { "name": "boxscore", "version": "", "license": "MIT", "repo": "m4ttstack/apps", "subdir": "apps/boxscore", + "url": "", "sha256": "", + "archive": "tar.gz", "extract": "boxscore", "bundlePath": "Contents/Helpers/boxscore", "exec": ["Contents/Helpers/boxscore"], + "exposeByDefault": false, "entitlements": "jit", "status": "pending", "kind": "helper", + "serve": { "port": 11005, "args": [] } }, + { "name": "sparkle", "version": "2.10.0", "license": "MIT", + "url": "https://github.com/sparkle-project/Sparkle/releases/download/2.10.0/Sparkle-2.10.0.tar.xz", + "sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "archive": "tar.xz", "extract": "", "bundlePath": "tools/sparkle", "exec": ["tools/sparkle/bin/generate_appcast"], + "exposeByDefault": false, "entitlements": "none", "status": "bundled", "kind": "buildtool" } + ] + }, + "expectedCatalog": [ + { "name": "board", "port": 11006, "args": [] }, + { "name": "console", "port": 11001, "args": [] }, + { "name": "chat", "port": 11002, "args": [] }, + { "name": "argsdemo", "port": 11090, "args": ["serve", "--no-open"] } + ] +} diff --git a/apps/deck/src/registry/bundle-catalog.test.ts b/apps/deck/src/registry/bundle-catalog.test.ts new file mode 100644 index 00000000..f012bf10 --- /dev/null +++ b/apps/deck/src/registry/bundle-catalog.test.ts @@ -0,0 +1,98 @@ +import { createHash } from 'crypto'; +import { mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { expect, test } from 'bun:test'; + +import { parseServeCatalog, readBundleCatalog } from './bundle-catalog.ts'; + +// Parity anchor: byte-identical twin at repo-tools +// scripts/lib/__tests__/fixtures/deps-lock-serve.fixture.json, whose test pins +// the same digest and asserts repo-tools' parser derives expectedCatalog. +// Change both files together and move the digest in both tests. +const FIXTURE_SHA256 = + '95256df5809898010329ed5c0c331c806a4ba8ad9e860768c1b130a604b14230'; + +type LockRow = Record; + +interface Fixture { + lock: { tools: LockRow[] }; + expectedCatalog: Array<{ name: string; port: number; args: string[] }>; +} + +const BYTES = readFileSync( + join(import.meta.dir, '__fixtures__', 'deps-lock-serve.fixture.json') +); +const fixture = JSON.parse(BYTES.toString('utf8')) as Fixture; +const LOCK = JSON.stringify(fixture.lock); +const EXPECTED = Object.fromEntries( + fixture.expectedCatalog.map(({ name, ...v }) => [name, v]) +); + +function lockCopy(): Fixture['lock'] { + return structuredClone(fixture.lock); +} + +function row(lock: Fixture['lock'], name: string): LockRow { + const found = lock.tools.find(t => t.name === name); + if (!found) throw new Error(`fixture has no ${name} row`); + return found; +} + +function resources(lock: string | null): string { + const dir = mkdtempSync(join(tmpdir(), 'resources-')); + if (lock !== null) writeFileSync(join(dir, 'deps.lock'), lock); + return dir; +} + +test('the fixture bytes match the digest its repo-tools twin pins', () => { + expect(createHash('sha256').update(BYTES).digest('hex')).toBe(FIXTURE_SHA256); +}); + +test('the shared fixture parses to the catalog repo-tools derives from it', () => { + expect(Object.fromEntries(parseServeCatalog(LOCK))).toEqual(EXPECTED); +}); + +test('readBundleCatalog reads Resources/deps.lock', () => { + expect(Object.fromEntries(readBundleCatalog(resources(LOCK))!)).toEqual( + EXPECTED + ); +}); + +test('no catalog outside a bundle, without a lock, with a broken lock, or with no served rows', () => { + expect(readBundleCatalog(null)).toBeNull(); + expect(readBundleCatalog(resources(null))).toBeNull(); + expect(readBundleCatalog(resources('{not json'))).toBeNull(); + const unserved = lockCopy(); + for (const t of unserved.tools) delete t.serve; + expect(readBundleCatalog(resources(JSON.stringify(unserved)))).toBeNull(); +}); + +test('pending and buildtool rows never serve, even carrying serve', () => { + const lock = lockCopy(); + row(lock, 'chat').status = 'pending'; + row(lock, 'sparkle').serve = { port: 11098, args: [] }; + const catalog = parseServeCatalog(JSON.stringify(lock)); + expect(catalog.has('chat')).toBe(false); + expect(catalog.has('sparkle')).toBe(false); + expect(catalog.has('boxscore')).toBe(false); + expect(catalog.has('board')).toBe(true); +}); + +test('a malformed serve on a served row is rejected, not guessed', () => { + const bad: unknown[] = [ + { port: '11006', args: [] }, + { port: 11006.5, args: [] }, + { port: 70000, args: [] }, + { port: 11006, args: 'serve' }, + { port: 11006, args: [1] }, + null, + ]; + for (const serve of bad) { + const lock = lockCopy(); + row(lock, 'board').serve = serve; + expect(() => parseServeCatalog(JSON.stringify(lock))).toThrow( + /board serve/ + ); + } +}); diff --git a/apps/deck/src/registry/bundle-catalog.ts b/apps/deck/src/registry/bundle-catalog.ts new file mode 100644 index 00000000..ded0b249 --- /dev/null +++ b/apps/deck/src/registry/bundle-catalog.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { bundleResourcesDir } from '../services/bundle-layout.ts'; + +/** The registrar id every mattstack-shipped row carries. */ +export const MATTSTACK_REGISTRAR = 'rt'; + +export interface CatalogEntry { + port: number; + args: string[]; +} + +export type BundleCatalog = Map; + +/** + * The apps a bundle serves: bundled helper rows of deps.lock that carry + * `serve`. Parity anchor: repo-tools lib/bundle-layout.ts parseDepsLock + * validates the same field, and both repos test their parser against the + * byte-identical deps-lock-serve.fixture.json. + */ +export function parseServeCatalog(text: string): BundleCatalog { + const raw = JSON.parse(text) as { schema?: unknown; tools?: unknown }; + if (raw.schema !== 1) + throw new Error(`deps.lock: unsupported schema ${String(raw.schema)}`); + if (!Array.isArray(raw.tools)) + throw new Error('deps.lock: tools must be an array'); + const catalog: BundleCatalog = new Map(); + for (const row of raw.tools as Array | null>) { + if (!row || row.serve === undefined) continue; + if (row.kind !== 'helper' || row.status !== 'bundled') continue; + const name = row.name; + if (typeof name !== 'string' || !name) + throw new Error('deps.lock: a served row has no name'); + const serve = row.serve as { port?: unknown; args?: unknown } | null; + if (typeof serve !== 'object' || serve === null) + throw new Error(`deps.lock: ${name} serve must be an object`); + const { port, args } = serve; + if ( + typeof port !== 'number' || + !Number.isInteger(port) || + port < 1 || + port > 65535 + ) + throw new Error(`deps.lock: ${name} serve.port must be an integer port`); + if (!Array.isArray(args) || !args.every(a => typeof a === 'string')) + throw new Error( + `deps.lock: ${name} serve.args must be an array of strings` + ); + if (catalog.has(name)) + throw new Error(`deps.lock: duplicate served app ${name}`); + catalog.set(name, { port, args: [...args] }); + } + return catalog; +} + +/** + * The catalog in `/deps.lock`, or null when there is none to + * enforce: outside a bundle, no readable lock, an invalid one, or a lock from + * before any row carried `serve`. Null keeps the pre-catalog serve rules, so + * an older bundle can never read as "serve nothing". + */ +export function readBundleCatalog( + resourcesDir: string | null +): BundleCatalog | null { + if (!resourcesDir) return null; + const path = join(resourcesDir, 'deps.lock'); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return null; + } + try { + const catalog = parseServeCatalog(text); + return catalog.size > 0 ? catalog : null; + } catch (err) { + console.error(`[catalog] ignoring ${path}: ${String(err)}`); + return null; + } +} + +let memo: BundleCatalog | null | undefined; + +/** The running bundle's catalog; a live deck's bundle does not change under it. */ +export function bundleCatalog(): BundleCatalog | null { + if (memo === undefined) memo = readBundleCatalog(bundleResourcesDir()); + return memo; +} diff --git a/apps/deck/src/services/bundle-layout.test.ts b/apps/deck/src/services/bundle-layout.test.ts index 76b56438..2ad818b1 100644 --- a/apps/deck/src/services/bundle-layout.test.ts +++ b/apps/deck/src/services/bundle-layout.test.ts @@ -10,7 +10,11 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, expect, test } from 'bun:test'; -import { bundleHelpersDir, bundleRootFromExec } from './bundle-layout.ts'; +import { + bundleHelpersDir, + bundleResourcesDir, + bundleRootFromExec, +} from './bundle-layout.ts'; // macOS's /tmp is itself a symlink (-> /private/tmp); bundleRootFromExec // realpath-resolves execPath, so expectations must be built on the same @@ -93,6 +97,12 @@ test('bundleHelpersDir returns null outside a bundle', () => { expect(bundleHelpersDir('/no/such/binary')).toBeNull(); }); +test('bundleResourcesDir is Contents/Resources beside Helpers, null outside a bundle', () => { + const { appRoot, exec } = tmpApp(); + expect(bundleResourcesDir(exec)).toBe(join(appRoot, 'Contents', 'Resources')); + expect(bundleResourcesDir(join(TMPDIR, 'no-such-bundle', 'deck'))).toBeNull(); +}); + test('an unargumented call honors a valid DECK_BUNDLE_ROOT', () => { const { appRoot } = tmpApp(); const prev = process.env.DECK_BUNDLE_ROOT; diff --git a/apps/deck/src/services/bundle-layout.ts b/apps/deck/src/services/bundle-layout.ts index 4d10e25e..00c5a0c1 100644 --- a/apps/deck/src/services/bundle-layout.ts +++ b/apps/deck/src/services/bundle-layout.ts @@ -46,3 +46,9 @@ export function bundleHelpersDir(execPath?: string): string | null { const root = bundleRootFromExec(execPath); return root ? join(root, 'Contents', 'Helpers') : null; } + +/** Absolute path to the bundle's Resources directory, or null outside a bundle. */ +export function bundleResourcesDir(execPath?: string): string | null { + const root = bundleRootFromExec(execPath); + return root ? join(root, 'Contents', 'Resources') : null; +} From 91aaba77a529146f869f963b3ce0420e9e8c4c31 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 01:30:01 -0500 Subject: [PATCH 02/14] deck: serve catalog apps with catalog args and never serve a tool helper by name Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/registry/serve-shape.test.ts | 148 +++++++++++++++++++++ apps/deck/src/registry/serve-shape.ts | 64 ++++++++- 2 files changed, 206 insertions(+), 6 deletions(-) diff --git a/apps/deck/src/registry/serve-shape.test.ts b/apps/deck/src/registry/serve-shape.test.ts index 3249ddb1..fb607545 100644 --- a/apps/deck/src/registry/serve-shape.test.ts +++ b/apps/deck/src/registry/serve-shape.test.ts @@ -7,7 +7,9 @@ import { getRecord, putRecord, reloadRegistry } from './records.ts'; import type { AppRecord } from './records.ts'; import { bundleBinaryPath, + bundleShape, dataDir, + notServedHere, readLinkedManifest, serveShape, } from './serve-shape.ts'; @@ -270,3 +272,149 @@ describe('serveShape matrix', () => { ); }); }); + +const CATALOG = new Map([ + ['chat', { port: 11002, args: [] }], + ['board', { port: 11006, args: ['serve', '--quiet'] }], +]); + +function fakeHelpers(...names: string[]): string { + const helpers = join( + mkdtempSync(join(tmpdir(), 'catalog-')), + 'mattstack.app', + 'Contents', + 'Helpers' + ); + mkdirSync(helpers, { recursive: true }); + for (const n of names) writeFileSync(join(helpers, n), ''); + return helpers; +} + +describe('bundle catalog', () => { + test('a catalog app serves its helper with the catalog args from its data dir', () => { + const helpers = fakeHelpers('board'); + const r = rec({ name: 'board', port: 11006 }); + putRecord(r); + expect( + serveShape(r, { + devMode: () => false, + helpersDir: helpers, + catalog: CATALOG, + }) + ).toEqual({ + command: [join(helpers, 'board'), 'serve', '--quiet'], + cwd: dataDir('board'), + }); + }); + + test("catalog args win over a stored command in the other flavor's bundle, silently", () => { + const helpers = fakeHelpers('chat'); + const r = rec({ + command: ['/Applications/mattstack-dev.app/Contents/Helpers/chat'], + workingDirectory: dataDir('chat'), + }); + putRecord(r); + expect( + serveShape(r, { + devMode: () => false, + helpersDir: helpers, + catalog: CATALOG, + }) + ).toEqual({ command: [join(helpers, 'chat')], cwd: dataDir('chat') }); + expect(getRecord('chat')?.issues).toBeUndefined(); + }); + + test('a stored command outside any bundle is still flagged as legacy on a catalog app', () => { + const helpers = fakeHelpers('chat'); + const r = rec({ + command: ['bun', 'src/server/index.ts'], + workingDirectory: '/somewhere', + }); + putRecord(r); + serveShape(r, { + devMode: () => false, + helpersDir: helpers, + catalog: CATALOG, + }); + expect(getRecord('chat')?.issues?.[0]?.message).toContain( + 'legacy stored command' + ); + }); + + test('a helper outside the catalog is never served by name alone', () => { + const helpers = fakeHelpers('gitq'); + const r = rec({ name: 'gitq', port: 11008 }); + putRecord(r); + expect(bundleShape(r, helpers, CATALOG)).toBeNull(); + expect( + serveShape(r, { + devMode: () => true, + helpersDir: helpers, + catalog: CATALOG, + }) + ).toBeNull(); + }); + + test('dev: a linked row outside the catalog serves its source', () => { + const helpers = fakeHelpers('gitq'); + const dir = linkedDir({ + name: 'gitq', + dev: { start: 'bun src/server/server.ts' }, + }); + const r = rec({ + name: 'gitq', + port: 11008, + dev: { workingDirectory: dir }, + }); + putRecord(r); + expect( + serveShape(r, { + devMode: () => true, + helpersDir: helpers, + catalog: CATALOG, + }) + ).toEqual({ command: ['bun', 'src/server/server.ts'], cwd: dir }); + }); + + test('without a catalog the name-derived bundle shape is unchanged', () => { + const helpers = fakeHelpers('gitq'); + expect(bundleShape(rec({ name: 'gitq' }), helpers, null)).toEqual({ + command: [join(helpers, 'gitq')], + cwd: dataDir('gitq'), + }); + }); +}); + +describe('notServedHere', () => { + test('prod with a catalog: an rt row outside it is not served', () => { + expect( + notServedHere(rec({ name: 'gitq' }), { + devMode: () => false, + catalog: CATALOG, + }) + ).toBe(true); + }); + + test('catalog apps, dev mode, user rows, the platform and a missing catalog are all served', () => { + const prod = { devMode: () => false, catalog: CATALOG }; + expect(notServedHere(rec({ name: 'chat' }), prod)).toBe(false); + expect( + notServedHere(rec({ name: 'gitq' }), { + devMode: () => true, + catalog: CATALOG, + }) + ).toBe(false); + expect(notServedHere(rec({ name: 'gitq', managedBy: 'user' }), prod)).toBe( + false + ); + expect(notServedHere(rec({ name: 'deck', managedBy: 'deck' }), prod)).toBe( + false + ); + expect( + notServedHere(rec({ name: 'gitq' }), { + devMode: () => false, + catalog: null, + }) + ).toBe(false); + }); +}); diff --git a/apps/deck/src/registry/serve-shape.ts b/apps/deck/src/registry/serve-shape.ts index 4a3326aa..e4c9ada3 100644 --- a/apps/deck/src/registry/serve-shape.ts +++ b/apps/deck/src/registry/serve-shape.ts @@ -4,6 +4,11 @@ import { join } from 'path'; import { isDevMode } from '../api/dev-mode.ts'; import { bundleHelpersDir } from '../services/bundle-layout.ts'; +import { + bundleCatalog, + MATTSTACK_REGISTRAR, + type BundleCatalog, +} from './bundle-catalog.ts'; import { readDeckManifest, startArgv, @@ -72,6 +77,40 @@ export interface ServeShapeDeps { devMode?: () => boolean; /** Test seam for bundleBinaryPath's helpers dir; default derives from the running bundle. */ helpersDir?: string | null; + /** Test seam for the served-app catalog; default reads the running bundle's deps.lock. */ + catalog?: BundleCatalog | null; +} + +export interface Flavor { + dev: boolean; + helpersDir: string | null; + catalog: BundleCatalog | null; +} + +export function resolveFlavor(deps: ServeShapeDeps = {}): Flavor { + return { + dev: (deps.devMode ?? isDevMode)(), + helpersDir: + deps.helpersDir !== undefined ? deps.helpersDir : bundleHelpersDir(), + catalog: deps.catalog !== undefined ? deps.catalog : bundleCatalog(), + }; +} + +/** Prod serves exactly the bundle's catalog: an rt row outside it keeps its + record and dev link for the other flavor but runs nowhere here. */ +export function notServedHere( + record: AppRecord, + deps: ServeShapeDeps = {} +): boolean { + if (record.managedBy !== MATTSTACK_REGISTRAR) return false; + const { dev, catalog } = resolveFlavor(deps); + return !dev && catalog !== null && !catalog.has(record.name); +} + +/** rt setup stores the absolute helper path of whichever flavor ran it, so any + bundle's Helpers/ counts, not only the running one's. */ +function isBundledHelperPath(argv0: string | undefined, name: string): boolean { + return !!argv0 && argv0.endsWith(`.app/Contents/Helpers/${name}`); } export function sourceShape(record: AppRecord): ResolvedShape | null { @@ -101,11 +140,22 @@ function storedBundleCommand( export function bundleShape( record: AppRecord, - helpersDir?: string | null + helpersDir?: string | null, + catalog?: BundleCatalog | null ): ResolvedShape | null { const dir = helpersDir !== undefined ? helpersDir : bundleHelpersDir(); + const served = catalog !== undefined ? catalog : bundleCatalog(); + const entry = served?.get(record.name); + if (entry) { + const bin = bundleBinaryPath(record.name, dir); + return bin + ? { command: [bin, ...entry.args], cwd: dataDir(record.name) } + : null; + } const stored = storedBundleCommand(record, dir); if (stored) return stored; + // With a catalog, a helper outside it is a tool (the gitq CLI), never an app. + if (served) return null; const bin = bundleBinaryPath(record.name, dir); return bin ? { command: [bin], cwd: dataDir(record.name) } : null; } @@ -123,18 +173,20 @@ export function serveShape( return { command: record.command!, cwd: record.workingDirectory! }; } - const helpersDir = - deps.helpersDir !== undefined ? deps.helpersDir : bundleHelpersDir(); + const { dev, helpersDir, catalog } = resolveFlavor(deps); const source = sourceShape(record); - const bundle = bundleShape(record, helpersDir); + const bundle = bundleShape(record, helpersDir, catalog); const linkBroken = readLinkedManifest(record).state === 'broken'; - const dev = (deps.devMode ?? isDevMode)(); const chosen = dev ? (source ?? bundle) : (bundle ?? source); // A stored command that is not the bundled binary is a pre-manifest row — // it never serves, and staying quiet about it would hide real drift. const legacyIgnored = !!record.command?.length && - storedBundleCommand(record, helpersDir) === null; + storedBundleCommand(record, helpersDir) === null && + !( + catalog?.has(record.name) && + isBundledHelperPath(record.command[0], record.name) + ); if (!chosen) { const legacyHint = legacyIgnored From ba6ded4951d3873f0d1ce75c01c2cb3b69d5c4f4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 01:48:17 -0500 Subject: [PATCH 03/14] deck: create the data dir deck owns and refuse any other missing working directory Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/register.test.ts | 54 +++++++++++++++++++++--- apps/deck/src/api/register.ts | 52 ++++++++++++++++------- apps/deck/src/api/server.test.ts | 9 ++-- apps/deck/src/registry/bootstrap.test.ts | 2 +- 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/apps/deck/src/api/register.test.ts b/apps/deck/src/api/register.test.ts index ec15ba30..aec69b30 100644 --- a/apps/deck/src/api/register.test.ts +++ b/apps/deck/src/api/register.test.ts @@ -43,6 +43,7 @@ const { FakeServiceManager } = await import('../services/fake.ts'); const { FakeEdgeProxy } = await import('../edge/portless.ts'); const { getRecord, putRecord, reloadRegistry, listRecords, deleteRecord } = await import('../registry/records.ts'); +const { dataDir } = await import('../registry/serve-shape.ts'); const { getAppSettings, setPublished, @@ -84,7 +85,7 @@ afterEach(() => { const input = { name: 'myapp', command: ['bun', 'src/server.ts'], - workingDirectory: '/tmp/myapp', + workingDirectory: mkdtempSync(join(tmpdir(), 'myapp-')), }; /** A managed row only serves what the resolver finds: a binary inside the @@ -113,7 +114,7 @@ test('register: allocates from 11000, installs the service, registers the alias, expect(rec.label).toBe('com.mattstack.deck.myapp'); const spec = drivers.manager.installed.get('com.mattstack.deck.myapp')!; expect(spec.environment.PORT).toBe('11000'); - expect(spec.workingDirectory).toBe('/tmp/myapp'); + expect(spec.workingDirectory).toBe(input.workingDirectory); expect(drivers.edge.aliases.get('myapp')).toBe(11000); }); @@ -204,6 +205,39 @@ test('adopt succeeds when the route ALREADY exists — the bootstrap/migrate rea writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); }); +test('register: a user app whose working directory is gone is refused with a launchd issue, never handed to launchd', async () => { + const gone = join(tmpdir(), `gone-${Date.now()}`); + const res = await registerApp( + { ...input, name: 'gone', workingDirectory: gone }, + drivers + ); + expect(res.status).toBe(201); + expect(drivers.manager.installed.has(`${LABEL_PREFIX}gone`)).toBe(false); + const issue = getRecord('gone')!.issues![0]!; + expect(issue.source).toBe('launchd'); + expect(issue.message).toContain(`working directory ${gone} does not exist`); + expect(existsSync(gone)).toBe(false); +}); + +test('register: a managed app served from the bundle gets the data dir deck owns', async () => { + const h = bundleHelpers('fresh'); + const res = await registerApp( + { + ...input, + name: 'fresh', + managedBy: 'rt', + command: h.command('fresh'), + workingDirectory: dataDir('fresh'), + }, + drivers + ); + expect(res.status).toBe(201); + expect(existsSync(dataDir('fresh'))).toBe(true); + expect( + drivers.manager.installed.get(`${LABEL_PREFIX}fresh`)!.workingDirectory + ).toBe(dataDir('fresh')); +}); + test('a portless failure still registers, but lands a loud portless issue', async () => { drivers.edge.failNext = 'myapp'; const res = await registerApp(input, drivers); @@ -241,7 +275,11 @@ test('edit re-ports: reinstalls the service on the new port and re-aliases', asy test('edit re-ports onto a port already held by another record is a 409, and the port stays unchanged', async () => { await registerApp(input, drivers); const otherRes = await registerApp( - { name: 'other', command: ['bun', 'x'], workingDirectory: '/tmp/other' }, + { + name: 'other', + command: ['bun', 'x'], + workingDirectory: mkdtempSync(join(tmpdir(), 'other-')), + }, drivers ); const otherPort = (otherRes.body as any).record.port as number; @@ -839,7 +877,11 @@ test('adopt refuses a rename target that is a different existing app', async () await Bun.write(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); await registerApp({ ...input, name: 'mrs' }, drivers); await registerApp( - { ...input, name: 'board', workingDirectory: '/tmp/other' }, + { + ...input, + name: 'board', + workingDirectory: mkdtempSync(join(tmpdir(), 'other-')), + }, drivers ); const res = await adoptApp('mrs', { as: 'board' }, drivers); @@ -1105,7 +1147,7 @@ test('reresolve: reinstalls only the app whose resolved command differs from its name: 'changed', managedBy: 'rt', command: h.command('changed', 'serve'), - workingDirectory: '/tmp/changed', + workingDirectory: mkdtempSync(join(tmpdir(), 'changed-')), }, reresolveDrivers ); @@ -1189,7 +1231,7 @@ test('reresolve: a flip-then-flip-back is a no-op (restarts nothing, churns no d name: 'app2', managedBy: 'rt', command: h.command('app2', 'serve'), - workingDirectory: '/tmp/app2', + workingDirectory: mkdtempSync(join(tmpdir(), 'app2-')), }, reresolveDrivers ); diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index d20af469..6cdfea45 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -28,6 +28,7 @@ import { clearIssues, deleteRecord, getRecord, + isMattstackOwned, listRecords, putRecord, reloadRegistry, @@ -35,6 +36,7 @@ import { type SyncIssue, } from '../registry/records.ts'; import { + dataDir, serveShape, type ResolvedShape, type ServeShapeDeps, @@ -95,35 +97,55 @@ export function setServeShapeDeps(deps: ServeShapeDeps): void { serveShapeDeps = deps; } +interface BuiltSpec { + spec: ServiceSpec; + createdCwd: boolean; +} + /** * launchd does not search PATH for `ProgramArguments[0]`, so argv0 must be - * absolute in the plist. The caller passes the shape resolved for this render - * (bundled binary or linked source), and this resolves argv0 to an absolute - * path on every render, so an interpreter that moves -- a version manager - * reorganizing, or being swapped for another -- is picked up by the next - * render instead of being frozen at registration. + * absolute in the plist, and it is resolved again on every render so an + * interpreter that moves is picked up by the next render. * - * Throws rather than naming a program that does not exist: launchd declines - * to start such a job without logging anything, so writing it anyway produces - * an app that is silently, inexplicably down. + * Throws rather than naming a program or a working directory that does not + * exist: launchd declines such a job (exit 78 for a missing cwd) without + * logging anything, so writing it anyway produces an app that is silently down. */ -function specFor(record: AppRecord, shape: ResolvedShape): ServiceSpec { +function buildSpec(record: AppRecord, shape: ResolvedShape): BuiltSpec { const env = serviceEnv(record); const path = env.PATH ?? composeServicePath(); const [argv0, ...rest] = shape.command; const program = resolveProgram(argv0!, path); if (!program) throw new Error(`${argv0} not found on the service PATH (${path})`); + const createdCwd = ensureWorkingDirectory(record, shape.cwd); return { - label: record.label!, - programArguments: [program, ...rest], - workingDirectory: shape.cwd, - environment: { ...env, PATH: path }, - stdoutPath: join(logsDir(), `${record.name}.out.log`), - stderrPath: join(logsDir(), `${record.name}.err.log`), + spec: { + label: record.label!, + programArguments: [program, ...rest], + workingDirectory: shape.cwd, + environment: { ...env, PATH: path }, + stdoutPath: join(logsDir(), `${record.name}.out.log`), + stderrPath: join(logsDir(), `${record.name}.err.log`), + }, + createdCwd, }; } +/** Deck owns only a mattstack app's data dir; creating anyone else's missing + dir would hide a deleted checkout behind an empty one. */ +function ensureWorkingDirectory(record: AppRecord, cwd: string): boolean { + if (existsSync(cwd)) return false; + if (!isMattstackOwned(record) || cwd !== dataDir(record.name)) + throw new Error(`working directory ${cwd} does not exist`); + mkdirSync(cwd, { recursive: true }); + return true; +} + +function specFor(record: AppRecord, shape: ResolvedShape): ServiceSpec { + return buildSpec(record, shape).spec; +} + function sameEnvironment( a: Record, b: Record diff --git a/apps/deck/src/api/server.test.ts b/apps/deck/src/api/server.test.ts index 6c99ab66..1720ab81 100644 --- a/apps/deck/src/api/server.test.ts +++ b/apps/deck/src/api/server.test.ts @@ -235,10 +235,11 @@ test('a freshly-registered app with no route yet shows up in the list without le }); test("a public host gets the row's record shape redacted; a local one still pre-fills the edit dialog", async () => { + const secretDir = mkdtempSync(join(tmpdir(), 'secret-dir-')); const created = await post('/api/v1/apps', { name: 'secretful', command: ['bun', 's.ts'], - workingDirectory: '/tmp/secret-dir', + workingDirectory: secretDir, env: { API_KEY: 'shh-do-not-leak' }, }); expect(created.status).toBe(201); @@ -248,7 +249,7 @@ test("a public host gets the row's record shape redacted; a local one still pre- await api('/api/v1/apps', { headers: pubHeaders }) ).text(); // Whole-body assertions: a leak that sinks one level deeper must still fail. - expect(pubRaw).not.toContain('/tmp/secret-dir'); + expect(pubRaw).not.toContain(secretDir); expect(pubRaw).not.toContain('shh-do-not-leak'); const pubRow = JSON.parse(pubRaw).apps.find( (a: any) => a.name === 'secretful' @@ -263,7 +264,7 @@ test("a public host gets the row's record shape redacted; a local one still pre- const oneRaw = await ( await api('/api/v1/apps/secretful', { headers: pubHeaders }) ).text(); - expect(oneRaw).not.toContain('/tmp/secret-dir'); + expect(oneRaw).not.toContain(secretDir); expect(JSON.parse(oneRaw).row.record).toEqual({ kind: 'service', command: null, @@ -276,7 +277,7 @@ test("a public host gets the row's record shape redacted; a local one still pre- expect(localRow.record).toEqual({ kind: 'service', command: ['bun', 's.ts'], - workingDirectory: '/tmp/secret-dir', + workingDirectory: secretDir, }); }); diff --git a/apps/deck/src/registry/bootstrap.test.ts b/apps/deck/src/registry/bootstrap.test.ts index 430afcfe..157f80f3 100644 --- a/apps/deck/src/registry/bootstrap.test.ts +++ b/apps/deck/src/registry/bootstrap.test.ts @@ -218,7 +218,7 @@ test("setup re-renders supervised apps' plists so a moved interpreter self-heals kind: 'service', label: 'com.mattstack.deck.stale', command: ['bun', 'src/server.ts'], - workingDirectory: '/tmp/stale', + workingDirectory: mkdtempSync(join(tmpdir(), 'stale-')), createdAt: new Date().toISOString(), }); From 5a8ab2d5d9189fcb16bd46ae7a378b8b32d0da42 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 02:27:25 -0500 Subject: [PATCH 04/14] deck: the boot sweep stops serving rt rows outside the prod catalog and heals missing data dirs Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/register.test.ts | 205 +++++++++++++++++++++++++++ apps/deck/src/api/register.ts | 105 ++++++++++---- apps/deck/src/boot-reresolve.test.ts | 10 ++ apps/deck/src/boot-reresolve.ts | 3 + 4 files changed, 292 insertions(+), 31 deletions(-) diff --git a/apps/deck/src/api/register.test.ts b/apps/deck/src/api/register.test.ts index aec69b30..0a23632d 100644 --- a/apps/deck/src/api/register.test.ts +++ b/apps/deck/src/api/register.test.ts @@ -10,6 +10,8 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { afterEach, beforeEach, expect, test } from 'bun:test'; +import type { AppRecord } from '../registry/records.ts'; + const dir = mkdtempSync(join(tmpdir(), 'local-flows-')); process.env.LOCAL_REGISTRY_PATH = join(dir, 'registry.json'); process.env.LOCAL_STATE_DIR = dir; @@ -1128,6 +1130,49 @@ class CountingManager extends FakeServiceManager { } } +/** Writes and removes real plist files the way LaunchdManager does, so the + sweep's diff reads what the previous sweep installed. */ +class PlistManager extends CountingManager { + override async install(spec: ServiceSpec): Promise { + await super.install(spec); + mkdirSync(agentsDir(), { recursive: true }); + writeFileSync(join(agentsDir(), `${spec.label}.plist`), renderPlist(spec)); + } + override async uninstall(label: string): Promise { + await super.uninstall(label); + rmSync(join(agentsDir(), `${label}.plist`), { force: true }); + } +} + +const AT = '2026-09-24T00:00:00Z'; + +function checkout(name: string, start: string): string { + const dir = mkdtempSync(join(tmpdir(), `${name}-src-`)); + writeFileSync( + join(dir, 'mattstack.deck.json'), + JSON.stringify({ name, dev: { start } }) + ); + return dir; +} + +function rtRow( + name: string, + port: number, + over: Partial = {} +): void { + putRecord({ + name, + managedBy: 'rt', + port, + kind: 'service', + label: `${LABEL_PREFIX}${name}`, + createdAt: AT, + ...over, + }); +} + +const CHAT_ONLY = new Map([['chat', { port: 11002, args: [] as string[] }]]); + test('reresolve: reinstalls only the app whose resolved command differs from its installed plist', async () => { const counting = new CountingManager(); const reresolveDrivers = { manager: counting, edge: drivers.edge }; @@ -1530,6 +1575,166 @@ test('reresolve: a later successful install clears the launchd issue a previous expect(getRecord('recovered')!.issues ?? []).toEqual([]); }); +test('reresolve: prod does not serve an rt row outside the catalog; its plist goes, its row and dev link stay', async () => { + rmSync(agentsDir(), { recursive: true, force: true }); + try { + const manager = new PlistManager(); + const d = { manager, edge: drivers.edge }; + const h = bundleHelpers('chat'); + const gitqSrc = checkout('gitq', 'bun src/server/server.ts'); + rtRow('gitq', 11008, { dev: { workingDirectory: gitqSrc } }); + setServeShapeDeps({ + devMode: () => true, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + await reresolveManagedApps(d); + const plist = join(agentsDir(), `${LABEL_PREFIX}gitq.plist`); + expect(existsSync(plist)).toBe(true); + + setServeShapeDeps({ + devMode: () => false, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + const body = (await reresolveManagedApps(d)).body as any; + + expect(body).toMatchObject({ ok: true, notServed: ['gitq'], failed: [] }); + expect(existsSync(plist)).toBe(false); + expect(manager.installed.has(`${LABEL_PREFIX}gitq`)).toBe(false); + expect(getRecord('gitq')!.dev).toEqual({ workingDirectory: gitqSrc }); + expect(getRecord('gitq')!.issues).toBeUndefined(); + expect(existsSync(dataDir('gitq'))).toBe(false); + } finally { + rmSync(agentsDir(), { recursive: true, force: true }); + } +}); + +test('reresolve: prod with no catalog serves rt rows as before and marks none not-served', async () => { + const h = bundleHelpers('gitq'); + setServeShapeDeps({ devMode: () => false, helpersDir: h.dir, catalog: null }); + await registerApp( + { + ...input, + name: 'gitq', + managedBy: 'rt', + command: h.command('gitq', 'board'), + }, + drivers + ); + const body = (await reresolveManagedApps(drivers)).body as any; + expect(body.notServed).toEqual([]); + expect( + drivers.manager.installed.get(`${LABEL_PREFIX}gitq`)!.programArguments + ).toEqual([join(h.dir, 'gitq'), 'board']); +}); + +test('reresolve: an unchanged plist whose owned data dir went missing gets the dir back and a kickstart', async () => { + rmSync(agentsDir(), { recursive: true, force: true }); + try { + const manager = new PlistManager(); + const d = { manager, edge: drivers.edge }; + const h = bundleHelpers('chat'); + setServeShapeDeps({ + devMode: () => false, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + rtRow('chat', 11002); + await reresolveManagedApps(d); + rmSync(dataDir('chat'), { recursive: true, force: true }); + manager.kickstarts = []; + + const body = (await reresolveManagedApps(d)).body as any; + + expect(body).toMatchObject({ + ok: true, + restarted: ['chat'], + unchanged: [], + }); + expect(existsSync(dataDir('chat'))).toBe(true); + expect(manager.kickstarts).toEqual([`${LABEL_PREFIX}chat`]); + expect(manager.installCalls).toEqual([`${LABEL_PREFIX}chat`]); + } finally { + rmSync(agentsDir(), { recursive: true, force: true }); + } +}); + +test('reresolve: a managed row whose non-owned cwd is gone is refused with a launchd issue and never installed', async () => { + const counting = new CountingManager(); + const h = bundleHelpers('legacy'); + const gone = join(tmpdir(), `gone-${Date.now()}`); + rtRow('legacy', 11050, { + command: h.command('legacy'), + workingDirectory: gone, + }); + setServeShapeDeps({ devMode: () => false, helpersDir: h.dir, catalog: null }); + + const body = ( + await reresolveManagedApps({ manager: counting, edge: drivers.edge }) + ).body as any; + + expect(body.failed).toEqual([ + { + name: 'legacy', + error: expect.stringContaining( + `working directory ${gone} does not exist` + ), + }, + ]); + expect(getRecord('legacy')!.issues![0]!.source).toBe('launchd'); + expect(counting.installCalls).toEqual([]); +}); + +test('restartManagedApps skips a row prod does not serve, so the verb does not fail on its missing plist', async () => { + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); + const h = bundleHelpers('chat'); + setServeShapeDeps({ + devMode: () => false, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + rtRow('gitq', 11008); + await registerApp( + { ...input, name: 'chat', managedBy: 'rt', command: h.command('chat') }, + drivers + ); + const res = await restartManagedApps(drivers); + expect(res.body).toEqual({ ok: true, restarted: ['chat'], failed: [] }); + expect(drivers.manager.kickstarts).toEqual([`${LABEL_PREFIX}chat`]); +}); + +test('prod: registering or linking a not-served row writes the record but never its plist', async () => { + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); + const h = bundleHelpers('chat', 'gitq'); + setServeShapeDeps({ + devMode: () => false, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + await registerApp( + { + ...input, + name: 'gitq', + managedBy: 'rt', + command: h.command('gitq', 'board'), + }, + drivers + ); + expect(drivers.manager.installed.has(`${LABEL_PREFIX}gitq`)).toBe(false); + const src = checkout('gitq', 'bun src/server/server.ts'); + const res = await editApp( + 'gitq', + { dev: { workingDirectory: src } }, + 'user', + false, + drivers + ); + expect(res.status).toBe(200); + expect(getRecord('gitq')!.dev).toEqual({ workingDirectory: src }); + expect(drivers.manager.installed.has(`${LABEL_PREFIX}gitq`)).toBe(false); +}); + // ─── editApp: never uninstall a shape the patch can't replace ───────────── test('edit: unlinking a slim row with no bundle installed is rejected before any teardown', async () => { diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index 6cdfea45..fc1131fa 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -37,6 +37,7 @@ import { } from '../registry/records.ts'; import { dataDir, + notServedHere, serveShape, type ResolvedShape, type ServeShapeDeps, @@ -286,7 +287,7 @@ export async function registerApp( if (!input.adopt) { mkdirSync(logsDir(), { recursive: true }); - if (isService) { + if (isService && !notServedHere(record, serveShapeDeps)) { const shape = serveShape(record, serveShapeDeps); if (shape) await tryDriver(name, 'launchd', () => @@ -433,6 +434,7 @@ export async function restartManagedApps( const failed: Array<{ name: string; error: string }> = []; for (const record of managed) { if (record.kind !== 'service' || !record.label) continue; + if (notServedHere(record, serveShapeDeps)) continue; try { // kickstart signals failure via its boolean return (label not // installed), not by throwing — same contract the single-app @@ -448,19 +450,20 @@ export async function restartManagedApps( } /** - * Selective restart after the dev/prod flavor may have moved: deck runs this - * at boot, since switching between mattstack-dev.app and mattstack.app starts - * a different deck, so every managed app must re-resolve its shape, but only - * the ones whose resolved command actually moved get torn down and rebuilt. The diff is against the installed plist (ProgramArguments, - * WorkingDirectory, EnvironmentVariables), not any last-resolved value on the - * record, so a flip and a flip-back reads as the same "unchanged" outcome - * both times. + * The flavor sweep. Deck runs it on every bundled start, which is every + * switch between mattstack-dev.app and mattstack.app, and it is the only + * writer that moves managed apps between shapes. An rt row prod does not + * serve loses its plist but keeps its record and dev link for the other + * flavor. Every other managed row is re-resolved and diffed against its + * installed plist (ProgramArguments, WorkingDirectory, EnvironmentVariables), + * so a flip and a flip-back both read as "unchanged". */ export async function reresolveManagedApps( drivers: Drivers ): Promise { const restarted: string[] = []; const unchanged: string[] = []; + const notServed: string[] = []; const failed: Array<{ name: string; error: string }> = []; for (const record of listRecords()) { if ( @@ -471,38 +474,62 @@ export async function reresolveManagedApps( continue; // The platform never restarts itself mid-request; bootstrapSelf owns its shape. if (isPlatformManagedBy(record.managedBy)) continue; + if (notServedHere(record, serveShapeDeps)) { + const issue = await runDriver('launchd', () => + drivers.manager.uninstall(record.label!) + ); + if (issue) { + addIssue(record.name, issue); + failed.push({ name: record.name, error: issue.message }); + continue; + } + clearIssues(record.name, 'launchd'); + clearIssues(record.name, 'dev-link'); + notServed.push(record.name); + continue; + } const shape = serveShape(record, serveShapeDeps); if (!shape) { failed.push({ name: record.name, error: 'no runnable shape' }); continue; } - let spec: ServiceSpec; + let built: BuiltSpec; try { - spec = specFor(record, shape); + built = buildSpec(record, shape); } catch (err) { - failed.push({ name: record.name, error: String(err).slice(0, 300) }); + const message = String(err).slice(0, 300); + addIssue(record.name, { + source: 'launchd', + message, + at: new Date().toISOString(), + }); + failed.push({ name: record.name, error: message }); continue; } - const installed = readInstalledProgramArguments(record.label); - const installedCwd = readInstalledWorkingDirectory(record.label); - const installedEnv = readInstalledEnvironment(record.label); - if ( - installed !== null && - installed.length === spec.programArguments.length && - installed.every((a, i) => a === spec.programArguments[i]) && - installedCwd === spec.workingDirectory && - installedEnv !== null && - sameEnvironment(installedEnv, renderedEnvironment(spec)) - ) { - unchanged.push(record.name); + const { spec, createdCwd } = built; + if (installedMatches(record.label, spec)) { + if (!createdCwd) { + clearIssues(record.name, 'launchd'); + unchanged.push(record.name); + continue; + } + // The installed job has been failing to spawn on the missing dir, and + // launchd's KeepAlive backoff would otherwise decide when it recovers. + const ok = await drivers.manager + .kickstart(record.label) + .catch(() => false); + if (ok) { + clearIssues(record.name, 'launchd'); + restarted.push(record.name); + } else { + failed.push({ name: record.name, error: 'kickstart failed' }); + } continue; } // launchd has no atomic replace, so a failure between the two calls is a - // real possibility, not just a defensive catch: an uninstall that throws - // must not be followed by an install attempt (nothing to replace), and an - // install that throws leaves the app down -- loud enough to survive past - // this response body via a SyncIssue, the same convention editApp and - // registerApp already use for their own install failures. + // real possibility: an uninstall that throws must not be followed by an + // install attempt, and an install that throws leaves the app down, which + // is recorded as a SyncIssue so it outlives this response. const uninstallIssue = await runDriver('launchd', () => drivers.manager.uninstall(record.label!) ); @@ -523,10 +550,23 @@ export async function reresolveManagedApps( } return { status: 200, - body: { ok: failed.length === 0, restarted, unchanged, failed }, + body: { ok: failed.length === 0, restarted, unchanged, notServed, failed }, }; } +function installedMatches(label: string, spec: ServiceSpec): boolean { + const installed = readInstalledProgramArguments(label); + const installedEnv = readInstalledEnvironment(label); + return ( + installed !== null && + installed.length === spec.programArguments.length && + installed.every((a, i) => a === spec.programArguments[i]) && + readInstalledWorkingDirectory(label) === spec.workingDirectory && + installedEnv !== null && + sameEnvironment(installedEnv, renderedEnvironment(spec)) + ); +} + /** * Bulk lifecycle verb behind `deck remove --managed`: the app calls this * during `rt uninstall` (installer spec §12.3) to unregister every non-user @@ -667,7 +707,9 @@ export async function editApp( // runnable one: resolve the prospective shape before any teardown call, not // after, or a patch that resolves to nothing tears down with nothing to fall // back on. - if (next.kind === 'service' && !serveShape(next, serveShapeDeps)) { + const servedHere = + next.kind === 'service' && !notServedHere(next, serveShapeDeps); + if (servedHere && !serveShape(next, serveShapeDeps)) { return { status: 400, body: { @@ -720,7 +762,7 @@ export async function editApp( } if (portChanged) clearOverride(next.name); putRecord(next); - if (next.kind === 'service') { + if (servedHere) { const shape = serveShape(next, serveShapeDeps); if (shape) await tryDriver(next.name, 'launchd', () => @@ -844,6 +886,7 @@ export async function reinstallSupervised( !record.label ) continue; + if (notServedHere(record, serveShapeDeps)) continue; const shape = serveShape(record, serveShapeDeps); if (!shape) { failed.push(record.name); diff --git a/apps/deck/src/boot-reresolve.test.ts b/apps/deck/src/boot-reresolve.test.ts index 591dff25..8ca49bc4 100644 --- a/apps/deck/src/boot-reresolve.test.ts +++ b/apps/deck/src/boot-reresolve.test.ts @@ -64,3 +64,13 @@ test('a throwing sweep is logged, never thrown into boot', async () => { '[reresolve] boot sweep failed: Error: launchctl gone', ]); }); + +test('a bundled deck names the rows it does not serve', async () => { + const lines: string[] = []; + await reresolveOnBoot({ + bundleRoot: '/Applications/mattstack.app', + reresolve: async () => swept({ notServed: ['gitq'] }), + log: line => lines.push(line), + }); + expect(lines).toEqual(['[reresolve] boot: not-served gitq']); +}); diff --git a/apps/deck/src/boot-reresolve.ts b/apps/deck/src/boot-reresolve.ts index 5727e0f4..af443324 100644 --- a/apps/deck/src/boot-reresolve.ts +++ b/apps/deck/src/boot-reresolve.ts @@ -2,6 +2,7 @@ import type { FlowResult } from './api/register.ts'; interface Swept { restarted?: string[]; + notServed?: string[]; failed?: Array<{ name: string; error: string }>; } @@ -27,6 +28,8 @@ export async function reresolveOnBoot(opts: { const parts: string[] = []; if (body.restarted?.length) parts.push(`restarted ${body.restarted.join(', ')}`); + if (body.notServed?.length) + parts.push(`not-served ${body.notServed.join(', ')}`); if (body.failed?.length) parts.push( `failed ${body.failed.map(f => `${f.name} (${f.error})`).join(', ')}` From 3cb608d4cb573b5145be73a77bb6afe7167d3110 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:02:05 -0500 Subject: [PATCH 05/14] deck: the boot sweep creates a row for every catalog app and adopts in prod Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/register.test.ts | 276 ++++++++++++++++++++++++++- apps/deck/src/api/register.ts | 163 +++++++++++++++- apps/deck/src/boot-reresolve.test.ts | 18 ++ apps/deck/src/boot-reresolve.ts | 4 + 4 files changed, 456 insertions(+), 5 deletions(-) diff --git a/apps/deck/src/api/register.test.ts b/apps/deck/src/api/register.test.ts index 0a23632d..9e07bea7 100644 --- a/apps/deck/src/api/register.test.ts +++ b/apps/deck/src/api/register.test.ts @@ -72,6 +72,8 @@ let drivers: { beforeEach(() => { rmSync(process.env.LOCAL_REGISTRY_PATH!, { force: true }); rmSync(process.env.LOCAL_APPS_SETTINGS_PATH!, { force: true }); + // The sweep's catalog rows write .mattstack routes into this shared file. + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); // A fresh HOME per test keeps deck.platform store state (read via register.ts) // from leaking test-to-test, same as server.test.ts. process.env.HOME = mkdtempSync(join(tmpdir(), 'local-flows-home-')); @@ -1687,7 +1689,6 @@ test('reresolve: a managed row whose non-owned cwd is gone is refused with a lau }); test('restartManagedApps skips a row prod does not serve, so the verb does not fail on its missing plist', async () => { - writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); const h = bundleHelpers('chat'); setServeShapeDeps({ devMode: () => false, @@ -1705,7 +1706,6 @@ test('restartManagedApps skips a row prod does not serve, so the verb does not f }); test('prod: registering or linking a not-served row writes the record but never its plist', async () => { - writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); const h = bundleHelpers('chat', 'gitq'); setServeShapeDeps({ devMode: () => false, @@ -1778,3 +1778,275 @@ test('edit: unlinking a slim row with no bundle installed is rejected before any expect(counting.installCalls).toEqual([]); expect(counting.installed.get(label)).toEqual(installedBefore); }); + +// The flavor flip on a lived-in registry. + +const FLIP_CATALOG = new Map([ + ['board', { port: 11006, args: [] as string[] }], + ['chat', { port: 11002, args: [] as string[] }], + ['console', { port: 11001, args: [] as string[] }], + ['boxscore', { port: 11005, args: [] as string[] }], +]); + +function fakeBundle(appName: string): string { + const helpers = join( + mkdtempSync(join(tmpdir(), 'flip-')), + appName, + 'Contents', + 'Helpers' + ); + mkdirSync(helpers, { recursive: true }); + for (const n of ['board', 'chat', 'console', 'boxscore', 'gitq', 'deck']) + writeFileSync(join(helpers, n), ''); + return helpers; +} + +/** Every key an AppRecord could carry in deck 1.0.7; the other flavor's + pinned deck reads the same registry file. */ +const DECK_107_RECORD_KEYS = new Set([ + 'name', + 'managedBy', + 'port', + 'kind', + 'command', + 'workingDirectory', + 'env', + 'label', + 'displayName', + 'description', + 'icon', + 'badge', + 'commands', + 'altConfigs', + 'activeAlt', + 'sourceDirectory', + 'dev', + 'grandfathered', + 'createdAt', + 'issues', + 'remote', +]); + +test('flip: dev -> prod -> dev on a lived-in registry creates missing catalog rows in either flavor, adopts only in prod, and deletes nothing', async () => { + rmSync(agentsDir(), { recursive: true, force: true }); + try { + const manager = new PlistManager(); + const flip = { manager, edge: drivers.edge }; + const prodHelpers = fakeBundle('mattstack.app'); + const devHelpers = fakeBundle('mattstack-dev.app'); + const chatSrc = checkout('chat', 'bun src/server/index.ts'); + const gitqSrc = checkout('gitq', 'bun src/server/server.ts'); + const boxSrc = checkout('boxscore', 'bun src/server/index.ts'); + rtRow('chat', 11002, { dev: { workingDirectory: chatSrc } }); + rtRow('gitq', 11008, { dev: { workingDirectory: gitqSrc } }); + rtRow('console', 11001, { + command: [join(devHelpers, 'console')], + workingDirectory: dataDir('console'), + }); + rtRow('boxscore', 11005, { + managedBy: 'user', + command: ['bun', 'src/server/index.ts'], + workingDirectory: boxSrc, + }); + const spec = (name: string) => + manager.installed.get(`${LABEL_PREFIX}${name}`); + const dev = { + devMode: () => true, + helpersDir: devHelpers, + catalog: FLIP_CATALOG, + }; + const prod = { + devMode: () => false, + helpersDir: prodHelpers, + catalog: FLIP_CATALOG, + }; + + setServeShapeDeps(dev); + const dev0 = (await reresolveManagedApps(flip)).body as any; + expect(dev0).toMatchObject({ + created: ['board'], + adopted: [], + notServed: [], + failed: [], + }); + expect(getRecord('board')!.managedBy).toBe('rt'); + expect(getRecord('board')!.command).toBeUndefined(); + expect(spec('board')!.programArguments).toEqual([ + join(devHelpers, 'board'), + ]); + expect(spec('board')!.workingDirectory).toBe(dataDir('board')); + expect(getRecord('boxscore')!.managedBy).toBe('user'); + expect(spec('chat')!.workingDirectory).toBe(chatSrc); + expect(existsSync(dataDir('chat'))).toBe(false); + + setServeShapeDeps(prod); + const toProd = (await reresolveManagedApps(flip)).body as any; + expect(toProd).toMatchObject({ + ok: true, + created: [], + adopted: ['boxscore'], + notServed: ['gitq'], + failed: [], + }); + expect([...toProd.restarted].sort()).toEqual([ + 'board', + 'boxscore', + 'chat', + 'console', + ]); + for (const name of ['board', 'boxscore', 'chat', 'console']) { + expect(spec(name)!.programArguments).toEqual([join(prodHelpers, name)]); + expect(spec(name)!.workingDirectory).toBe(dataDir(name)); + expect(existsSync(dataDir(name))).toBe(true); + expect(getRecord(name)!.managedBy).toBe('rt'); + expect(getRecord(name)!.issues ?? []).toEqual([]); + } + expect(spec('gitq')).toBeUndefined(); + expect(existsSync(join(agentsDir(), `${LABEL_PREFIX}gitq.plist`))).toBe( + false + ); + expect(getRecord('gitq')!.dev).toEqual({ workingDirectory: gitqSrc }); + expect(getRecord('board')!.port).toBe(11006); + expect(drivers.edge.aliases.get('board')).toBe(11006); + expect(getRecord('boxscore')!.dev).toEqual({ workingDirectory: boxSrc }); + expect(getRecord('boxscore')!.command).toBeUndefined(); + + setServeShapeDeps(dev); + const toDev = (await reresolveManagedApps(flip)).body as any; + expect(toDev).toMatchObject({ + ok: true, + created: [], + adopted: [], + notServed: [], + failed: [], + }); + expect(spec('chat')!.workingDirectory).toBe(chatSrc); + expect(spec('gitq')!.workingDirectory).toBe(gitqSrc); + expect(spec('boxscore')!.workingDirectory).toBe(boxSrc); + expect(spec('console')!.programArguments).toEqual([ + join(devHelpers, 'console'), + ]); + expect(spec('board')!.programArguments).toEqual([ + join(devHelpers, 'board'), + ]); + expect( + listRecords() + .map(r => r.name) + .sort() + ).toEqual(['board', 'boxscore', 'chat', 'console', 'gitq']); + + setServeShapeDeps(prod); + await reresolveManagedApps(flip); + const settled = (await reresolveManagedApps(flip)).body as any; + expect(settled).toMatchObject({ + ok: true, + restarted: [], + created: [], + adopted: [], + notServed: ['gitq'], + }); + expect([...settled.unchanged].sort()).toEqual([ + 'board', + 'boxscore', + 'chat', + 'console', + ]); + + const file = JSON.parse( + readFileSync(process.env.LOCAL_REGISTRY_PATH!, 'utf8') + ); + expect(file.version).toBe(1); + for (const record of Object.values(file.apps) as Array< + Record + >) + for (const key of Object.keys(record)) + expect(DECK_107_RECORD_KEYS.has(key)).toBe(true); + } finally { + rmSync(agentsDir(), { recursive: true, force: true }); + } +}); + +test('prod catalog: a catalog port held by another row, or a route-only row with a catalog name, is reported and never duplicated', async () => { + const helpers = fakeBundle('mattstack.app'); + putRecord({ + name: 'mine', + managedBy: 'user', + port: 11006, + kind: 'external', + createdAt: AT, + }); + putRecord({ + name: 'chat', + managedBy: 'user', + port: 11002, + kind: 'external', + createdAt: AT, + }); + setServeShapeDeps({ + devMode: () => false, + helpersDir: helpers, + catalog: new Map([ + ['board', { port: 11006, args: [] as string[] }], + ['chat', { port: 11002, args: [] as string[] }], + ]), + }); + + const body = (await reresolveManagedApps(drivers)).body as any; + + expect(body.created).toEqual([]); + expect(body.adopted).toEqual([]); + expect(body.failed).toEqual([ + { name: 'board', error: 'catalog port 11006 is held by mine' }, + { + name: 'chat', + error: + 'chat is a route-only app; `deck remove chat` lets mattstack serve it', + }, + ]); + expect(getRecord('board')).toBeUndefined(); + expect(getRecord('chat')!.managedBy).toBe('user'); + expect(getRecord('chat')!.kind).toBe('external'); +}); + +test("catalog: an app the bundle ships no Helpers binary for gets no row, and deck's own row names it until it ships", async () => { + const helpers = mkdtempSync(join(tmpdir(), 'partial-helpers-')); + writeFileSync(join(helpers, 'chat'), ''); + putRecord({ + name: PLATFORM_NAME, + managedBy: 'deck', + port: 11000, + kind: 'service', + label: PLATFORM_LABEL, + createdAt: AT, + }); + setServeShapeDeps({ + devMode: () => true, + helpersDir: helpers, + catalog: new Map([ + ['chat', { port: 11002, args: [] as string[] }], + ['board', { port: 11006, args: [] as string[] }], + ]), + }); + + const body = (await reresolveManagedApps(drivers)).body as any; + + expect(body.created).toEqual(['chat']); + expect(body.failed).toEqual([ + { name: 'board', error: 'this bundle ships no Helpers/board' }, + ]); + expect(getRecord('board')).toBeUndefined(); + expect(getRecord(PLATFORM_NAME)!.issues).toEqual([ + { + source: 'launchd', + message: 'catalog apps missing from this bundle: board', + at: expect.any(String), + }, + ]); + + writeFileSync(join(helpers, 'board'), ''); + const healed = (await reresolveManagedApps(drivers)).body as any; + + expect(healed).toMatchObject({ ok: true, created: ['board'], failed: [] }); + expect(getRecord('board')!.port).toBe(11006); + expect(getRecord(PLATFORM_NAME)!.issues).toBeUndefined(); +}); diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index fc1131fa..337e2747 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -20,6 +20,10 @@ import type { RailwayDriver } from '../edge/railway.ts'; import { disableRemote } from '../edge/remote.ts'; import type { TunnelDriver } from '../edge/tunnel.ts'; import { allocatePort } from '../registry/allocate.ts'; +import { + MATTSTACK_REGISTRAR, + type BundleCatalog, +} from '../registry/bundle-catalog.ts'; import { readDeckManifest } from '../registry/deck-manifest.ts'; import { authorizeStructural } from '../registry/lifecycle.ts'; import { ingestManifest, removeIcon } from '../registry/manifest.ts'; @@ -36,8 +40,10 @@ import { type SyncIssue, } from '../registry/records.ts'; import { + bundleBinaryPath, dataDir, notServedHere, + resolveFlavor, serveShape, type ResolvedShape, type ServeShapeDeps, @@ -449,10 +455,132 @@ export async function restartManagedApps( return { status: 200, body: { ok: failed.length === 0, restarted, failed } }; } +type SweepFailure = { name: string; error: string }; + +interface EnsuredCatalog { + created: string[]; + adopted: string[]; + missing: string[]; + failed: SweepFailure[]; +} + +/** + * Every catalog app gets an rt row in either flavor, so a fresh machine + * serves the catalog whichever app it opens first. Only prod (`adopt`) takes + * over a same-named user row: dev serves the user's registrations as they + * are. A route-only row, a catalog port another row holds, or an app whose + * binary this bundle does not ship is reported, never written. + */ +async function ensureCatalogRows( + catalog: BundleCatalog, + helpersDir: string | null, + drivers: Drivers, + adopt: boolean +): Promise { + const out: EnsuredCatalog = { + created: [], + adopted: [], + missing: [], + failed: [], + }; + for (const [name, entry] of catalog) { + const existing = getRecord(name); + if (!existing) { + if (!bundleBinaryPath(name, helpersDir)) { + out.missing.push(name); + out.failed.push({ + name, + error: `this bundle ships no Helpers/${name}`, + }); + continue; + } + const holder = listRecords().find(r => r.port === entry.port); + if (holder) { + out.failed.push({ + name, + error: `catalog port ${entry.port} is held by ${holder.name}`, + }); + continue; + } + putRecord({ + name, + managedBy: MATTSTACK_REGISTRAR, + port: entry.port, + kind: 'service', + label: `${LABEL_PREFIX}${name}`, + createdAt: new Date().toISOString(), + }); + await tryDriver(name, 'portless', () => + drivers.edge.alias(name, entry.port) + ); + ingestManifest(name); + out.created.push(name); + continue; + } + if (!adopt || existing.managedBy !== 'user') continue; + if (existing.kind !== 'service') { + out.failed.push({ + name, + error: `${name} is a route-only app; \`deck remove ${name}\` lets mattstack serve it`, + }); + continue; + } + putRecord(adoptedCatalogRow(existing)); + ingestManifest(name); + out.adopted.push(name); + } + return out; +} + +/** A registered checkout becomes the dev link, the way migrateManagedDevShape + slims a row; without one the stored command stays and is flagged as legacy. */ +function adoptedCatalogRow(record: AppRecord): AppRecord { + const dir = record.workingDirectory; + const parsed = !record.dev && dir ? readDeckManifest(dir) : null; + const dev = + record.dev ?? + (parsed?.ok && parsed.manifest.name === record.name + ? { workingDirectory: dir! } + : undefined); + if (!dev) return { ...record, managedBy: MATTSTACK_REGISTRAR }; + return { + ...record, + managedBy: MATTSTACK_REGISTRAR, + dev, + command: undefined, + workingDirectory: undefined, + commands: undefined, + }; +} + +const MISSING_HELPERS = 'catalog apps missing from this bundle'; + +/** A catalog app with no binary has no row to carry its issue, so deck's own + row carries it, and only this sweep clears it. */ +function reportMissingHelpers(names: string[]): void { + const platform = listRecords().find(r => isPlatformManagedBy(r.managedBy)); + if (!platform) return; + if (names.length) { + addIssue(platform.name, { + source: 'launchd', + message: `${MISSING_HELPERS}: ${names.join(', ')}`, + at: new Date().toISOString(), + }); + } else if ( + platform.issues?.some( + i => i.source === 'launchd' && i.message.startsWith(MISSING_HELPERS) + ) + ) { + clearIssues(platform.name, 'launchd'); + } +} + /** * The flavor sweep. Deck runs it on every bundled start, which is every * switch between mattstack-dev.app and mattstack.app, and it is the only - * writer that moves managed apps between shapes. An rt row prod does not + * writer that moves managed apps between shapes. It first gives every catalog + * app an rt row in either flavor, adopting same-named user rows only in prod + * (ensureCatalogRows). An rt row prod does not * serve loses its plist but keeps its record and dev link for the other * flavor. Every other managed row is re-resolved and diffed against its * installed plist (ProgramArguments, WorkingDirectory, EnvironmentVariables), @@ -464,7 +592,18 @@ export async function reresolveManagedApps( const restarted: string[] = []; const unchanged: string[] = []; const notServed: string[] = []; - const failed: Array<{ name: string; error: string }> = []; + const failed: SweepFailure[] = []; + const flavor = resolveFlavor(serveShapeDeps); + const ensured = flavor.catalog + ? await ensureCatalogRows( + flavor.catalog, + flavor.helpersDir, + drivers, + !flavor.dev + ) + : { created: [], adopted: [], missing: [], failed: [] }; + failed.push(...ensured.failed); + reportMissingHelpers(ensured.missing); for (const record of listRecords()) { if ( record.managedBy === 'user' || @@ -548,9 +687,27 @@ export async function reresolveManagedApps( clearIssues(record.name, 'launchd'); restarted.push(record.name); } + if (ensured.created.length || ensured.adopted.length) { + try { + reconcileMattstackTld(); + } catch (err) { + failed.push({ + name: 'deck', + error: `mattstack route reconcile failed: ${String(err).slice(0, 200)}`, + }); + } + } return { status: 200, - body: { ok: failed.length === 0, restarted, unchanged, notServed, failed }, + body: { + ok: failed.length === 0, + restarted, + unchanged, + notServed, + created: ensured.created, + adopted: ensured.adopted, + failed, + }, }; } diff --git a/apps/deck/src/boot-reresolve.test.ts b/apps/deck/src/boot-reresolve.test.ts index 8ca49bc4..86332110 100644 --- a/apps/deck/src/boot-reresolve.test.ts +++ b/apps/deck/src/boot-reresolve.test.ts @@ -74,3 +74,21 @@ test('a bundled deck names the rows it does not serve', async () => { }); expect(lines).toEqual(['[reresolve] boot: not-served gitq']); }); + +test('a prod deck names the catalog rows it created and adopted ahead of what it restarted', async () => { + const lines: string[] = []; + await reresolveOnBoot({ + bundleRoot: '/Applications/mattstack.app', + reresolve: async () => + swept({ + created: ['board'], + adopted: ['boxscore'], + restarted: ['board', 'boxscore'], + notServed: ['gitq'], + }), + log: line => lines.push(line), + }); + expect(lines).toEqual([ + '[reresolve] boot: created board; adopted boxscore; restarted board, boxscore; not-served gitq', + ]); +}); diff --git a/apps/deck/src/boot-reresolve.ts b/apps/deck/src/boot-reresolve.ts index af443324..7cfe3e6e 100644 --- a/apps/deck/src/boot-reresolve.ts +++ b/apps/deck/src/boot-reresolve.ts @@ -1,6 +1,8 @@ import type { FlowResult } from './api/register.ts'; interface Swept { + created?: string[]; + adopted?: string[]; restarted?: string[]; notServed?: string[]; failed?: Array<{ name: string; error: string }>; @@ -26,6 +28,8 @@ export async function reresolveOnBoot(opts: { return; } const parts: string[] = []; + if (body.created?.length) parts.push(`created ${body.created.join(', ')}`); + if (body.adopted?.length) parts.push(`adopted ${body.adopted.join(', ')}`); if (body.restarted?.length) parts.push(`restarted ${body.restarted.join(', ')}`); if (body.notServed?.length) From 6adb6caf1dc43764acc7f5f5a09d8d2b3d64f734 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:02:30 -0500 Subject: [PATCH 06/14] deck: /api/apps lists only the apps this flavor serves Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/discovery.test.ts | 37 +++++++++++++++++++++++++++++ apps/deck/src/api/discovery.ts | 9 ++++--- apps/deck/src/api/server.ts | 5 +++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/deck/src/api/discovery.test.ts b/apps/deck/src/api/discovery.test.ts index c330591d..02c1ae14 100644 --- a/apps/deck/src/api/discovery.test.ts +++ b/apps/deck/src/api/discovery.test.ts @@ -376,3 +376,40 @@ test('GET /api/apps/:name/icon carries vary: origin with no Origin header at all expect(res.headers.get('vary')).toBe('origin'); expect(res.headers.get('access-control-allow-origin')).toBeNull(); }); + +test('discovery hides an rt app the prod catalog does not serve, and shows it in dev', async () => { + putRecord({ + name: 'chat', + managedBy: 'rt', + port: 11002, + kind: 'service', + createdAt: '2026-09-24T00:00:00Z', + }); + putRecord({ + name: 'gitq', + managedBy: 'rt', + port: 11008, + kind: 'service', + createdAt: '2026-09-24T00:00:00Z', + }); + writeFileSync( + process.env.LOCAL_APPS_ROUTES_PATH!, + JSON.stringify([ + { hostname: 'chat.localhost', port: 11002 }, + { hostname: 'gitq.localhost', port: 11008 }, + ]) + ); + const catalog = new Map([['chat', { port: 11002, args: [] as string[] }]]); + + const prod = await buildDiscoveryApps(statusOpts, { + devMode: () => false, + catalog, + }); + expect(prod.map(a => a.name)).toEqual(['chat']); + + const dev = await buildDiscoveryApps(statusOpts, { + devMode: () => true, + catalog, + }); + expect(dev.map(a => a.name).sort()).toEqual(['chat', 'gitq']); +}); diff --git a/apps/deck/src/api/discovery.ts b/apps/deck/src/api/discovery.ts index c5114e90..4c3785e7 100644 --- a/apps/deck/src/api/discovery.ts +++ b/apps/deck/src/api/discovery.ts @@ -2,6 +2,7 @@ import { existsSync } from 'fs'; import { iconPathFor } from '../registry/manifest.ts'; import { listRecords } from '../registry/records.ts'; +import { notServedHere, type ServeShapeDeps } from '../registry/serve-shape.ts'; import { isPlatformManagedBy } from '../services/manager.ts'; import { buildStatus, type BuildStatusOpts } from './status.ts'; @@ -17,13 +18,14 @@ export interface DiscoveryApp { } /** - * The launcher's app list: managed products only, deck's own platform row and - * all user apps excluded. `url` is reused verbatim from buildStatus (never + * The launcher's app list: managed products this flavor serves, deck's own + * platform row and all user apps excluded. `url` is reused verbatim from buildStatus (never * recomputed) so it matches deck's routing. No internal record field * (command, workingDirectory, env, port, health) crosses this boundary. */ export async function buildDiscoveryApps( - opts: BuildStatusOpts + opts: BuildStatusOpts, + flavor: ServeShapeDeps = {} ): Promise { const status = await buildStatus(opts); const urlByName = new Map(status.apps.map(row => [row.name, row.url])); @@ -31,6 +33,7 @@ export async function buildDiscoveryApps( for (const record of listRecords()) { if (record.managedBy === 'user' || isPlatformManagedBy(record.managedBy)) continue; + if (notServedHere(record, flavor)) continue; const url = urlByName.get(record.name); if (!url) continue; apps.push({ diff --git a/apps/deck/src/api/server.ts b/apps/deck/src/api/server.ts index 20c7a220..9c041237 100644 --- a/apps/deck/src/api/server.ts +++ b/apps/deck/src/api/server.ts @@ -84,6 +84,7 @@ import { reresolveManagedApps, restartLabelFor, restartManagedApps, + serveShapeDeps, unregisterApp, type Drivers, } from './register.ts'; @@ -367,7 +368,9 @@ export function startApi(deps: ApiDeps) { return new Response(null, { status: 204, headers: cors }); if (pathname === '/api/apps' && req.method === 'GET') { const base = deckBaseFor(host); // https://deck. from the request host - const apps = (await buildDiscoveryApps(statusOpts)).map(a => ({ + const apps = ( + await buildDiscoveryApps(statusOpts, serveShapeDeps) + ).map(a => ({ ...a, icon: a.icon ? `${base}/api/apps/${a.icon}/icon` : null, })); From 9fc033c6947f4029a166c159371dc9ca022807ea Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:03:43 -0500 Subject: [PATCH 07/14] deck: remove --managed removes only that app; the refusal names deck remove --force Co-Authored-By: Claude Opus 5.5 --- apps/deck/README.md | 2 +- apps/deck/core/board/useBoardState.ts | 4 +-- apps/deck/src/api/register.test.ts | 32 ++++++++++++++++++++++- apps/deck/src/api/register.ts | 20 ++++++++++---- apps/deck/src/api/server.ts | 8 +++++- apps/deck/src/cli/commands.test.ts | 33 ++++++++++++++++++++++++ apps/deck/src/cli/commands.ts | 8 ++++-- apps/deck/src/registry/lifecycle.test.ts | 5 +++- apps/deck/src/registry/lifecycle.ts | 2 +- 9 files changed, 100 insertions(+), 14 deletions(-) diff --git a/apps/deck/README.md b/apps/deck/README.md index 7c3dd669..abfe549f 100644 --- a/apps/deck/README.md +++ b/apps/deck/README.md @@ -138,7 +138,7 @@ usage: 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 remove --managed [name] unregister one managed app, or every one (installer's uninstall step) deck restart kickstart its service deck restart --managed kickstart every app deck manages (installer's version-change step) deck logs [--lines N] tail stderr diff --git a/apps/deck/core/board/useBoardState.ts b/apps/deck/core/board/useBoardState.ts index 4471254d..47b0baf2 100644 --- a/apps/deck/core/board/useBoardState.ts +++ b/apps/deck/core/board/useBoardState.ts @@ -563,8 +563,8 @@ export function useBoardState() { const body = await res .json() .catch(() => ({}) as { message?: string; error?: string }); - // Surface the API's message VERBATIM - for managed rows it carries the - // escape hatch ("Managed by mattstack - `rt uninstall `"). + // Surface the API's message VERBATIM: for managed rows it carries the + // escape hatch (`deck remove --force`). notice( 'bad', body.message || body.error || `remove failed (${res.status})`, diff --git a/apps/deck/src/api/register.test.ts b/apps/deck/src/api/register.test.ts index 9e07bea7..4433941a 100644 --- a/apps/deck/src/api/register.test.ts +++ b/apps/deck/src/api/register.test.ts @@ -256,7 +256,7 @@ test('unregister: registrar-owned, 409 with escape hatch, force overrides', asyn const denied = await unregisterApp('myapp', 'user', false, drivers); expect(denied.status).toBe(409); expect((denied.body as any).message).toBe( - 'Managed by mattstack — `rt uninstall myapp`' + 'Managed by mattstack: remove it anyway with `deck remove myapp --force`' ); const forced = await unregisterApp('myapp', 'user', true, drivers); expect(forced.status).toBe(200); @@ -1077,6 +1077,36 @@ test('removeManagedApps: tears down every non-user record, leaves user apps alon expect(drivers.manager.installed.has('com.mattstack.deck.board')).toBe(false); }); +test('removeManagedApps with a name removes only that managed record', async () => { + const h = bundleHelpers('one', 'two'); + for (const n of ['one', 'two']) + await registerApp( + { ...input, name: n, managedBy: 'rt', command: h.command(n) }, + drivers + ); + await registerApp({ ...input, name: 'mine' }, drivers); + + const res = await removeManagedApps(drivers, 'one'); + + expect(res.body).toMatchObject({ ok: true, removed: ['one'], failed: [] }); + expect(getRecord('one')).toBeUndefined(); + expect(getRecord('two')).toBeDefined(); + expect(getRecord('mine')).toBeDefined(); +}); + +test('removeManagedApps with an absent or user-owned name removes nothing and says so', async () => { + await registerApp({ ...input, name: 'mine' }, drivers); + expect(await removeManagedApps(drivers, 'ghost')).toEqual({ + status: 404, + body: { error: 'unknown app' }, + }); + expect(await removeManagedApps(drivers, 'mine')).toEqual({ + status: 409, + body: { error: 'mine is not managed' }, + }); + expect(getRecord('mine')).toBeDefined(); +}); + test('removeManagedApps: a driver failure keeps the record and reports it in failed', async () => { await registerApp({ ...input, name: 'board', managedBy: 'rt' }, drivers); drivers.manager.failNext = 'com.mattstack.deck.board'; diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index 337e2747..420f8475 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -725,14 +725,24 @@ function installedMatches(label: string, spec: ServiceSpec): boolean { } /** - * Bulk lifecycle verb behind `deck remove --managed`: the app calls this - * during `rt uninstall` (installer spec §12.3) to unregister every non-user - * record deck supervises. Same implicit-authority model as restartManagedApps. + * Lifecycle verb behind `deck remove --managed [name]`: with a name it removes + * only that managed record, without one every non-user record deck + * supervises. Same implicit-authority model as restartManagedApps. */ -export async function removeManagedApps(drivers: Drivers): Promise { - const managed = listRecords().filter( +export async function removeManagedApps( + drivers: Drivers, + only?: string +): Promise { + let managed = listRecords().filter( r => r.managedBy !== 'user' && !isPlatformManagedBy(r.managedBy) ); + if (only !== undefined) { + if (!getRecord(only)) + return { status: 404, body: { error: 'unknown app' } }; + managed = managed.filter(r => r.name === only); + if (managed.length === 0) + return { status: 409, body: { error: `${only} is not managed` } }; + } const removed: string[] = []; const failed: string[] = []; for (const record of managed) { diff --git a/apps/deck/src/api/server.ts b/apps/deck/src/api/server.ts index 9c041237..89978177 100644 --- a/apps/deck/src/api/server.ts +++ b/apps/deck/src/api/server.ts @@ -493,6 +493,9 @@ export function startApi(deps: ApiDeps) { pathname === '/api/v1/apps/managed/remove' && req.method === 'POST' ) { + const b = await body(req); + const only = + typeof b.name === 'string' && b.name ? b.name : undefined; const remoteDrivers = listRecords().some( r => r.managedBy !== 'user' && r.remote ) @@ -501,7 +504,10 @@ export function startApi(deps: ApiDeps) { await readDeckSecrets(deps.deckSecrets) ) : {}; - const r = await removeManagedApps({ ...deps, ...remoteDrivers }); + const r = await removeManagedApps( + { ...deps, ...remoteDrivers }, + only + ); return json(r.body, r.status); } diff --git a/apps/deck/src/cli/commands.test.ts b/apps/deck/src/cli/commands.test.ts index 2e01f405..480e992a 100644 --- a/apps/deck/src/cli/commands.test.ts +++ b/apps/deck/src/cli/commands.test.ts @@ -136,6 +136,39 @@ test('restart --managed / remove --managed only touch non-user records', async ( await runCommand(['remove', 't-user-only'], io()); }); +test('remove --managed removes only that app', async () => { + const helpers = mkdtempSync(join(tmpdir(), 'helpers-')); + for (const n of ['t-one', 't-two']) writeFileSync(join(helpers, n), ''); + setServeShapeDeps({ helpersDir: helpers }); + for (const n of ['t-one', 't-two']) + await fetch(`http://127.0.0.1:${PORT}/api/v1/apps`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-local-caller': 'rt' }, + body: JSON.stringify({ + name: n, + command: [join(helpers, n)], + workingDirectory: dir, + }), + }); + + const removed = io(); + expect(await runCommand(['remove', '--managed', 't-one'], removed)).toBe(0); + expect(removed.lines.join('\n')).toContain('removed t-one'); + expect(removed.lines.join('\n')).not.toContain('t-two'); + + const missing = io(); + expect(await runCommand(['remove', '--managed', 't-ghost'], missing)).toBe(1); + expect(missing.lines.join('\n')).toContain('unknown app'); + + const s = io(); + await runCommand(['status'], s); + expect(s.lines.join('\n')).toContain('t-two'); + expect(s.lines.join('\n')).not.toContain('t-one'); + + await runCommand(['remove', '--managed'], io()); + setServeShapeDeps({}); +}); + test('unknown verb exits 2 with usage', async () => { const x = io(); expect(await runCommand(['frobnicate'], x)).toBe(2); diff --git a/apps/deck/src/cli/commands.ts b/apps/deck/src/cli/commands.ts index 864a2617..6c2a3295 100644 --- a/apps/deck/src/cli/commands.ts +++ b/apps/deck/src/cli/commands.ts @@ -23,7 +23,7 @@ usage: 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 remove --managed [name] unregister one managed app, or every one (installer's uninstall step) deck restart kickstart its service deck restart --managed kickstart every app deck manages (installer's version-change step) deck logs [--lines N] tail stderr @@ -153,9 +153,13 @@ export async function runCommand( } case 'remove': { if (rest.includes('--managed')) { + const only = rest.find(a => !a.startsWith('--')); const { status, body } = await apiJson( `/api/v1/apps/managed/remove`, - { method: 'POST' } + { + method: 'POST', + ...(only && { body: JSON.stringify({ name: only }) }), + } ); if (status !== 200) { io.err(body.error ?? `failed (${status})`); diff --git a/apps/deck/src/registry/lifecycle.test.ts b/apps/deck/src/registry/lifecycle.test.ts index e8016e18..66b39e5d 100644 --- a/apps/deck/src/registry/lifecycle.test.ts +++ b/apps/deck/src/registry/lifecycle.test.ts @@ -18,7 +18,10 @@ test("user caller on an rt-managed record gets the spec's exact 409", () => { if (!v.ok) { expect(v.status).toBe(409); expect(v.body.managedBy).toBe('rt'); - expect(v.body.message).toBe('Managed by mattstack — `rt uninstall gitq`'); + expect(v.body.message).toBe( + 'Managed by mattstack: remove it anyway with `deck remove gitq --force`' + ); + expect(v.body.message).not.toContain('uninstall'); expect(v.body.escapeHatch).toBe('?force=true'); } }); diff --git a/apps/deck/src/registry/lifecycle.ts b/apps/deck/src/registry/lifecycle.ts index 2af90fba..e6784865 100644 --- a/apps/deck/src/registry/lifecycle.ts +++ b/apps/deck/src/registry/lifecycle.ts @@ -32,7 +32,7 @@ export function authorizeStructural( ? 'This is Deck itself: `deck uninstall`' : record.managedBy === 'user' ? `Managed by user: remove it from the board or \`deck remove ${record.name}\`` - : `Managed by ${MANAGER_DISPLAY[record.managedBy] ?? record.managedBy} — \`${record.managedBy} uninstall ${record.name}\``; + : `Managed by ${MANAGER_DISPLAY[record.managedBy] ?? record.managedBy}: remove it anyway with \`deck remove ${record.name} --force\``; return { ok: false, status: 409, From 11fb7396009e1d644dc452a1c43dfa11fc9831d8 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:04:04 -0500 Subject: [PATCH 08/14] deck: version 1.1.0; AGENTS.md describes the catalog sweep, not convert.ts Co-Authored-By: Claude Opus 5.5 --- apps/deck/AGENTS.md | 24 +++++++++++++++++------- apps/deck/package.json | 2 +- bun.lock | 2 +- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/deck/AGENTS.md b/apps/deck/AGENTS.md index a93594de..8f1127bc 100644 --- a/apps/deck/AGENTS.md +++ b/apps/deck/AGENTS.md @@ -73,13 +73,23 @@ against a manifest `dev` node, and do not read its lack of one as drift. `deck status` prints this class in its third column, so check there before concluding anything about an app's shape. -One asymmetry worth knowing when an app is down for no visible reason: dev -commands are read live from the manifest, but the SERVE unit is rendered from -the stored `record.command` (`src/registry/convert.ts`) and is only compared -against what launchd has when `register` runs (`src/api/register.ts`). So a -linked app that moves its entry point keeps a stale unit, with a correct -manifest, until something re-registers it. `deck register --dir ` -rewrites the unit from the manifest and is the fix. +Serve units are rendered by `buildSpec` in `src/api/register.ts` from the +shape `serveShape` resolves (`src/registry/serve-shape.ts`); `convert.ts` only +relabels legacy agents. The boot sweep (`reresolveManagedApps`) re-resolves +every managed row on each bundled deck start and diffs the result against the +installed plist, so a linked app that moves its entry point is picked up on +the next deck restart. The sweep reads the bundle's catalog (the `deps.lock` +rows in `Contents/Resources` that carry `serve`, read by +`src/registry/bundle-catalog.ts`) and gives each catalog app an rt row in +either flavor, unless the bundle ships no `Helpers/` for it (then deck's +own row carries the issue). In prod it also adopts a same-named user row, +serves each catalog app as `Contents/Helpers/ ` from +`~/.mattstack/`, which deck creates, and does not serve any other rt row +(plist removed, row and dev link kept, hidden from `/api/apps`). In dev a +catalog row serves its linked source, or the dev bundle's binary when it has +no link. Deck never creates any other missing working directory: a user app or +checkout whose dir is gone gets a launchd issue instead of a plist launchd +would reject. ## .localhost redirects to .mattstack, app-side diff --git a/apps/deck/package.json b/apps/deck/package.json index cae7c01e..bd6aa5b8 100644 --- a/apps/deck/package.json +++ b/apps/deck/package.json @@ -1,6 +1,6 @@ { "name": "deck", - "version": "1.0.7", + "version": "1.1.0", "module": "src/main.ts", "type": "module", "private": true, diff --git a/bun.lock b/bun.lock index 45976113..b1b9fb03 100644 --- a/bun.lock +++ b/bun.lock @@ -226,7 +226,7 @@ }, "apps/deck": { "name": "deck", - "version": "1.0.7", + "version": "1.1.0", "dependencies": { "@mattstack/app-server": "workspace:*", "@mattstack/glance": "catalog:", From 2821c667d57bad49dd36f18fa2716750a80d64d4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:05:24 -0500 Subject: [PATCH 09/14] deck: discovery test status opts carry the required local flag Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/discovery.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/deck/src/api/discovery.test.ts b/apps/deck/src/api/discovery.test.ts index 02c1ae14..b8f7895d 100644 --- a/apps/deck/src/api/discovery.test.ts +++ b/apps/deck/src/api/discovery.test.ts @@ -44,6 +44,7 @@ beforeEach(() => { }); const statusOpts = { + local: false, port: 7940, canaryPort: 7942, proxyFreshness: 'unknown' as const, From 69f25a417b857d0f9f6f1d2867a02a15561c1eb1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:06:03 -0500 Subject: [PATCH 10/14] deck: reflow the sweep's doc comment Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/register.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index 420f8475..34db181b 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -580,11 +580,11 @@ function reportMissingHelpers(names: string[]): void { * switch between mattstack-dev.app and mattstack.app, and it is the only * writer that moves managed apps between shapes. It first gives every catalog * app an rt row in either flavor, adopting same-named user rows only in prod - * (ensureCatalogRows). An rt row prod does not - * serve loses its plist but keeps its record and dev link for the other - * flavor. Every other managed row is re-resolved and diffed against its - * installed plist (ProgramArguments, WorkingDirectory, EnvironmentVariables), - * so a flip and a flip-back both read as "unchanged". + * (ensureCatalogRows). An rt row prod does not serve loses its plist but + * keeps its record and dev link for the other flavor. Every other managed row + * is re-resolved and diffed against its installed plist (ProgramArguments, + * WorkingDirectory, EnvironmentVariables), so a flip and a flip-back both + * read as "unchanged". */ export async function reresolveManagedApps( drivers: Drivers From 3a85855fdb3e910a433fd6791733a2a9a27cb171 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:43:11 -0500 Subject: [PATCH 11/14] deck: a named managed remove carries the name in its path, so an older deck 404s it instead of removing every app Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/server.test.ts | 70 +++++++++++++++++++++++++++++- apps/deck/src/api/server.ts | 22 +++++++--- apps/deck/src/cli/commands.test.ts | 30 +++++++++++++ apps/deck/src/cli/commands.ts | 9 ++-- 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/apps/deck/src/api/server.test.ts b/apps/deck/src/api/server.test.ts index 1720ab81..d49dae4a 100644 --- a/apps/deck/src/api/server.test.ts +++ b/apps/deck/src/api/server.test.ts @@ -30,7 +30,8 @@ const { FakeServiceManager } = await import('../services/fake.ts'); const { FakeEdgeProxy } = await import('../edge/portless.ts'); const { FakeTunnelDriver } = await import('../edge/tunnel.ts'); const { FakeCfDns } = await import('../../test/fixture/remote.ts'); -const { reloadRegistry, getRecord } = await import('../registry/records.ts'); +const { reloadRegistry, getRecord, putRecord } = + await import('../registry/records.ts'); const { reloadPlatformSettings } = await import('./platform-settings.ts'); const PORT = 18917; @@ -364,6 +365,73 @@ test('managed/reresolve answers 200 with the ok/restarted/unchanged/failed body }); }); +test('managed/remove/ removes only that managed row', async () => { + for (const [name, port] of [ + ['mr-one', 12101], + ['mr-two', 12102], + ] as const) + putRecord({ + name, + managedBy: 'rt', + port, + kind: 'external', + createdAt: '2026-09-25T00:00:00Z', + }); + putRecord({ + name: 'mr-mine', + managedBy: 'user', + port: 12103, + kind: 'external', + createdAt: '2026-09-25T00:00:00Z', + }); + + const one = await api('/api/v1/apps/managed/remove/mr-one', { + method: 'POST', + }); + expect(one.status).toBe(200); + expect(await one.json()).toMatchObject({ + ok: true, + removed: ['mr-one'], + failed: [], + }); + expect(getRecord('mr-one')).toBeUndefined(); + expect(getRecord('mr-two')).toBeDefined(); + + const ghost = await api('/api/v1/apps/managed/remove/mr-ghost', { + method: 'POST', + }); + expect(ghost.status).toBe(404); + const mine = await api('/api/v1/apps/managed/remove/mr-mine', { + method: 'POST', + }); + expect(mine.status).toBe(409); + expect(getRecord('mr-two')).toBeDefined(); + expect(getRecord('mr-mine')).toBeDefined(); +}); + +test('managed/remove refuses a name in its body instead of removing every managed row', async () => { + for (const [name, port] of [ + ['mb-one', 12111], + ['mb-two', 12112], + ] as const) + putRecord({ + name, + managedBy: 'rt', + port, + kind: 'external', + createdAt: '2026-09-25T00:00:00Z', + }); + + const res = await post('/api/v1/apps/managed/remove', { name: 'mb-one' }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toContain( + '/api/v1/apps/managed/remove/' + ); + expect(getRecord('mb-one')).toBeDefined(); + expect(getRecord('mb-two')).toBeDefined(); +}); + test('publish flips settings through the versioned path', async () => { writeFileSync( process.env.LOCAL_APPS_ROUTES_PATH!, diff --git a/apps/deck/src/api/server.ts b/apps/deck/src/api/server.ts index 89978177..37ccb06a 100644 --- a/apps/deck/src/api/server.ts +++ b/apps/deck/src/api/server.ts @@ -489,13 +489,21 @@ export function startApi(deps: ApiDeps) { const r = await reresolveManagedApps(deps); return json(r.body, r.status); } - if ( - pathname === '/api/v1/apps/managed/remove' && - req.method === 'POST' - ) { - const b = await body(req); - const only = - typeof b.name === 'string' && b.name ? b.name : undefined; + // A named remove carries its name in the path: deck 1.0.x reads no + // body here and would remove every managed row, but 404s this path. + const managedRemove = pathname.match( + /^\/api\/v1\/apps\/managed\/remove(?:\/([^/]+))?$/ + ); + if (managedRemove && req.method === 'POST') { + const only = managedRemove[1]; + if (only === undefined && (await body(req)).name !== undefined) + return json( + { + error: + 'name the app in the path: /api/v1/apps/managed/remove/', + }, + 400 + ); const remoteDrivers = listRecords().some( r => r.managedBy !== 'user' && r.remote ) diff --git a/apps/deck/src/cli/commands.test.ts b/apps/deck/src/cli/commands.test.ts index 480e992a..37274068 100644 --- a/apps/deck/src/cli/commands.test.ts +++ b/apps/deck/src/cli/commands.test.ts @@ -169,6 +169,36 @@ test('remove --managed removes only that app', async () => { setServeShapeDeps({}); }); +test('remove --managed against a deck that predates named removes fails instead of removing every managed app', async () => { + const bulkCalls: string[] = []; + const oldDeck = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === '/api/v1/apps/managed/remove') { + bulkCalls.push(req.method); + return Response.json({ + ok: true, + removed: ['board', 'chat', 'console'], + failed: [], + }); + } + return Response.json({ error: 'not found' }, { status: 404 }); + }, + }); + writeApiInfo(oldDeck.port!); + try { + const x = io(); + expect(await runCommand(['remove', '--managed', 'chat'], x)).toBe(1); + expect(bulkCalls).toEqual([]); + expect(x.lines.join('\n')).toContain('not found'); + } finally { + writeApiInfo(PORT); + oldDeck.stop(true); + } +}); + test('unknown verb exits 2 with usage', async () => { const x = io(); expect(await runCommand(['frobnicate'], x)).toBe(2); diff --git a/apps/deck/src/cli/commands.ts b/apps/deck/src/cli/commands.ts index 6c2a3295..abe8ea0a 100644 --- a/apps/deck/src/cli/commands.ts +++ b/apps/deck/src/cli/commands.ts @@ -155,11 +155,10 @@ export async function runCommand( if (rest.includes('--managed')) { const only = rest.find(a => !a.startsWith('--')); const { status, body } = await apiJson( - `/api/v1/apps/managed/remove`, - { - method: 'POST', - ...(only && { body: JSON.stringify({ name: only }) }), - } + only + ? `/api/v1/apps/managed/remove/${encodeURIComponent(only)}` + : `/api/v1/apps/managed/remove`, + { method: 'POST' } ); if (status !== 200) { io.err(body.error ?? `failed (${status})`); From 128522bd46d6cf5c7c1a176a553cf3a5f63cd588 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:44:53 -0500 Subject: [PATCH 12/14] deck: /api/apps waits for the boot sweep, and answers 503 while it is still creating catalog rows Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/server.test.ts | 73 ++++++++++++++++++++++++++++ apps/deck/src/api/server.ts | 51 +++++++++++++++++++ apps/deck/src/boot-reresolve.test.ts | 25 +++++++++- apps/deck/src/boot-reresolve.ts | 19 ++++++++ apps/deck/src/main.ts | 7 ++- 5 files changed, 172 insertions(+), 3 deletions(-) diff --git a/apps/deck/src/api/server.test.ts b/apps/deck/src/api/server.test.ts index d49dae4a..02b33d73 100644 --- a/apps/deck/src/api/server.test.ts +++ b/apps/deck/src/api/server.test.ts @@ -432,6 +432,79 @@ test('managed/remove refuses a name in its body instead of removing every manage expect(getRecord('mb-two')).toBeDefined(); }); +describe('/api/apps during the boot sweep', () => { + const BOOT_PORT = 18927; + + function bootingServer(bootSweep: Promise, bootSweepWaitMs: number) { + return startApi({ + manager: new FakeServiceManager(), + edge: new FakeEdgeProxy(), + port: BOOT_PORT, + canaryPort: BOOT_PORT + 1, + freshness: () => 'unknown', + autoHeal: () => null, + onRouteWrite: () => {}, + tunnel: new FakeTunnelDriver(), + bootSweep, + bootSweepWaitMs, + }); + } + + function sweptApp(): void { + putRecord({ + name: 'bs-app', + managedBy: 'rt', + port: 12120, + kind: 'external', + createdAt: '2026-09-25T00:00:00Z', + }); + writeFileSync( + process.env.LOCAL_APPS_ROUTES_PATH!, + JSON.stringify([{ hostname: 'bs-app.localhost', port: 12120, pid: 0 }]) + ); + } + + test('answers 503 with no app list while the sweep runs past the wait, then the list once it settles', async () => { + const sweep = Promise.withResolvers(); + const booting = bootingServer(sweep.promise, 20); + try { + const waiting = await fetch(`http://127.0.0.1:${BOOT_PORT}/api/apps`); + expect(waiting.status).toBe(503); + expect(await waiting.json()).not.toHaveProperty('apps'); + + sweptApp(); + sweep.resolve(); + const ready = await fetch(`http://127.0.0.1:${BOOT_PORT}/api/apps`); + expect(ready.status).toBe(200); + expect((await ready.json()).apps.map((a: any) => a.name)).toEqual([ + 'bs-app', + ]); + } finally { + booting.stop(true); + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); + } + }); + + test('a request that arrives mid-sweep waits for the rows the sweep creates', async () => { + const sweep = Promise.withResolvers(); + const booting = bootingServer(sweep.promise, 10_000); + try { + const pending = fetch(`http://127.0.0.1:${BOOT_PORT}/api/apps`); + await Bun.sleep(20); + sweptApp(); + sweep.resolve(); + const res = await pending; + expect(res.status).toBe(200); + expect((await res.json()).apps.map((a: any) => a.name)).toEqual([ + 'bs-app', + ]); + } finally { + booting.stop(true); + writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); + } + }); +}); + test('publish flips settings through the versioned path', async () => { writeFileSync( process.env.LOCAL_APPS_ROUTES_PATH!, diff --git a/apps/deck/src/api/server.ts b/apps/deck/src/api/server.ts index 37ccb06a..479425c5 100644 --- a/apps/deck/src/api/server.ts +++ b/apps/deck/src/api/server.ts @@ -115,6 +115,38 @@ export interface ApiDeps extends Drivers { readyFetch?: typeof fetch; /** Tests inject an absolute fake path; production resolves cloudflared on the service PATH. */ resolveCloudflared?: () => string | null; + /** + * Settles when the first boot sweep has finished. The sweep creates every + * catalog row, and the launcher treats its first 200 from /api/apps as the + * whole catalog, so /api/apps must not answer 200 before this settles. + */ + bootSweep?: Promise; + /** How long /api/apps waits on `bootSweep` before answering 503. */ + bootSweepWaitMs?: number; +} + +const BOOT_SWEEP_WAIT_MS = 10_000; + +async function settlesWithin( + promise: Promise | undefined, + ms: number +): Promise { + if (!promise) return true; + let timer: ReturnType | undefined; + const timedOut = new Promise(resolve => { + timer = setTimeout(() => resolve(false), ms); + }); + try { + return await Promise.race([ + promise.then( + () => true, + () => true + ), + timedOut, + ]); + } finally { + clearTimeout(timer); + } } /** DNS driver for the edge routes, or null when the deck secrets do not carry a zone id and a DNS-capable token. */ @@ -367,6 +399,25 @@ export function startApi(deps: ApiDeps) { if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors }); if (pathname === '/api/apps' && req.method === 'GET') { + if ( + !(await settlesWithin( + deps.bootSweep, + deps.bootSweepWaitMs ?? BOOT_SWEEP_WAIT_MS + )) + ) { + return new Response( + JSON.stringify({ error: 'deck is still starting its apps' }), + { + status: 503, + headers: { + 'content-type': 'application/json', + 'retry-after': '1', + vary: 'origin', + ...cors, + }, + } + ); + } const base = deckBaseFor(host); // https://deck. from the request host const apps = ( await buildDiscoveryApps(statusOpts, serveShapeDeps) diff --git a/apps/deck/src/boot-reresolve.test.ts b/apps/deck/src/boot-reresolve.test.ts index 86332110..f4a7fb63 100644 --- a/apps/deck/src/boot-reresolve.test.ts +++ b/apps/deck/src/boot-reresolve.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test'; import type { FlowResult } from './api/register.ts'; -import { reresolveOnBoot } from './boot-reresolve.ts'; +import { bootSweepGate, reresolveOnBoot } from './boot-reresolve.ts'; const swept = (body: Record): FlowResult => ({ status: 200, @@ -92,3 +92,26 @@ test('a prod deck names the catalog rows it created and adopted ahead of what it '[reresolve] boot: created board; adopted boxscore; restarted board, boxscore; not-served gitq', ]); }); + +test('the boot gate opens when the sweep settles, whether it succeeded or threw', async () => { + const ok = Promise.withResolvers(); + const okGate = bootSweepGate(ok.promise, 60_000); + ok.resolve(); + expect(await Promise.race([okGate.then(() => 'open'), Bun.sleep(50)])).toBe( + 'open' + ); + + const threw = Promise.withResolvers(); + const threwGate = bootSweepGate(threw.promise, 60_000); + threw.reject(new Error('launchctl failed')); + expect( + await Promise.race([threwGate.then(() => 'open'), Bun.sleep(50)]) + ).toBe('open'); +}); + +test('the boot gate opens at its cap when the sweep never settles', async () => { + const gate = bootSweepGate(new Promise(() => {}), 20); + expect(await Promise.race([gate.then(() => 'open'), Bun.sleep(500)])).toBe( + 'open' + ); +}); diff --git a/apps/deck/src/boot-reresolve.ts b/apps/deck/src/boot-reresolve.ts index 7cfe3e6e..e229408a 100644 --- a/apps/deck/src/boot-reresolve.ts +++ b/apps/deck/src/boot-reresolve.ts @@ -40,3 +40,22 @@ export async function reresolveOnBoot(opts: { ); if (parts.length) opts.log(`[reresolve] boot: ${parts.join('; ')}`); } + +/** + * Settles when the boot sweep does, or at `capMs`: launchd and portless calls + * carry no timeout, and a sweep wedged on one must not keep the app catalog + * unanswered for the rest of the session. + */ +export function bootSweepGate( + sweep: Promise, + capMs: number +): Promise { + return new Promise(resolve => { + const timer = setTimeout(resolve, capMs); + const open = () => { + clearTimeout(timer); + resolve(); + }; + sweep.then(open, open); + }); +} diff --git a/apps/deck/src/main.ts b/apps/deck/src/main.ts index 94e285d3..4aee0b1b 100644 --- a/apps/deck/src/main.ts +++ b/apps/deck/src/main.ts @@ -16,7 +16,7 @@ import { reresolveManagedApps } from './api/register.ts'; import { startApi } from './api/server.ts'; import { claimApiInfo, stateDir } from './api/state.ts'; import { reconcileMattstackTld } from './api/tld-reconcile.ts'; -import { reresolveOnBoot } from './boot-reresolve.ts'; +import { bootSweepGate, reresolveOnBoot } from './boot-reresolve.ts'; import { resolveCfDns, type CfDns } from './edge/cf-dns.ts'; import { PortlessCli } from './edge/portless.ts'; import { CloudflaredCli } from './edge/tunnel.ts'; @@ -48,6 +48,7 @@ const APP_NAME = listRecords().find(r => isPlatformManagedBy(r.managedBy))?.name ?? 'apps'; const CANARY_INTERVAL_MS = 5 * 60_000; +const BOOT_SWEEP_CAP_MS = 60_000; export async function serve(): Promise { const bundleRoot = bundleRootFromExec(); @@ -121,6 +122,7 @@ export async function serve(): Promise { // launchd's retry, naming the holder on the way out, instead of the // uncaught EADDRINUSE crash loop a held port otherwise produces. const drivers = { manager: new LaunchdManager(), edge: new PortlessCli() }; + const bootSweep = Promise.withResolvers(); let apiServer: ReturnType; try { apiServer = startApi({ @@ -132,6 +134,7 @@ export async function serve(): Promise { onRouteWrite: () => setTimeout(runCanaryCheck, 500), tunnel: new CloudflaredCli(), deckOwner: liveDeckOwner(bundleRoot, process.pid), + bootSweep: bootSweepGate(bootSweep.promise, BOOT_SWEEP_CAP_MS), }); } catch (err) { console.error('api failed to start, exiting so launchd retries:', err); @@ -189,7 +192,7 @@ export async function serve(): Promise { bundleRoot, reresolve: () => reresolveManagedApps(drivers), log: console.log, - }); + }).finally(() => bootSweep.resolve()); const reconcileInterval = setInterval(() => { reconcileOnce().catch(err => console.error('reconcile tick failed:', err)); From 549100d5e2c36ad396a5424c346f64ba1c5b877f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:49:34 -0500 Subject: [PATCH 13/14] deck: catalog refusals reach deck's board row on every machine; a catalog port held by a route or service, or a legacy mrs row, stops creation The sweep's refusals live in memory and join deck's own status row, since a helper-run deck has no platform record. editApp refuses a missing working dir before any teardown, reinstallSupervised's not-served skip is pinned, and the sweep tests never rm outside their temp dir. Co-Authored-By: Claude Opus 5.5 --- apps/deck/src/api/register.test.ts | 183 ++++++++++++++++++++--- apps/deck/src/api/register.ts | 116 +++++++------- apps/deck/src/api/server.test.ts | 15 +- apps/deck/src/api/status.test.ts | 63 ++++++++ apps/deck/src/api/status.ts | 18 ++- apps/deck/src/registry/catalog-report.ts | 38 +++++ 6 files changed, 341 insertions(+), 92 deletions(-) create mode 100644 apps/deck/src/registry/catalog-report.ts diff --git a/apps/deck/src/api/register.test.ts b/apps/deck/src/api/register.test.ts index 4433941a..616b3f11 100644 --- a/apps/deck/src/api/register.test.ts +++ b/apps/deck/src/api/register.test.ts @@ -22,6 +22,8 @@ await Bun.write(process.env.LOCAL_APPS_ROUTES_PATH, '[]'); // so this test's port allocations aren't shifted by real launchd agents // already running on the dev machine (see LOCAL_AGENTS_DIR in core/discover.ts). process.env.LOCAL_AGENTS_DIR = join(dir, 'agents-not-present'); +// readServices() would otherwise run the machine's `launchctl list`. +process.env.LOCAL_LAUNCHCTL_PIDS = ''; // Settings live in the same throwaway dir: the rename tests write real // published/password state and must never touch the repo's data/settings.json. process.env.LOCAL_APPS_SETTINGS_PATH = join(dir, 'settings.json'); @@ -39,6 +41,7 @@ const { restartLabelFor, reresolveManagedApps, removeManagedApps, + reinstallSupervised, setServeShapeDeps, } = await import('./register.ts'); const { FakeServiceManager } = await import('../services/fake.ts'); @@ -63,6 +66,15 @@ type ServiceSpec = Parameters< InstanceType['install'] >[0]; const { agentsDir } = await import('../services/launchd.ts'); +const { catalogReport } = await import('../registry/catalog-report.ts'); + +/** agentsDir() falls back to the real ~/Library/LaunchAgents when its env + seam is unset, so a recursive rm checks it first. */ +function wipeAgentsDir(): void { + if (!agentsDir().startsWith(dir)) + throw new Error(`refusing to remove ${agentsDir()} outside ${dir}`); + rmSync(agentsDir(), { recursive: true, force: true }); +} const { renderPlist } = await import('../services/plist.ts'); let drivers: { @@ -1608,7 +1620,7 @@ test('reresolve: a later successful install clears the launchd issue a previous }); test('reresolve: prod does not serve an rt row outside the catalog; its plist goes, its row and dev link stay', async () => { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); try { const manager = new PlistManager(); const d = { manager, edge: drivers.edge }; @@ -1638,7 +1650,7 @@ test('reresolve: prod does not serve an rt row outside the catalog; its plist go expect(getRecord('gitq')!.issues).toBeUndefined(); expect(existsSync(dataDir('gitq'))).toBe(false); } finally { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); } }); @@ -1662,7 +1674,7 @@ test('reresolve: prod with no catalog serves rt rows as before and marks none no }); test('reresolve: an unchanged plist whose owned data dir went missing gets the dir back and a kickstart', async () => { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); try { const manager = new PlistManager(); const d = { manager, edge: drivers.edge }; @@ -1688,7 +1700,7 @@ test('reresolve: an unchanged plist whose owned data dir went missing gets the d expect(manager.kickstarts).toEqual([`${LABEL_PREFIX}chat`]); expect(manager.installCalls).toEqual([`${LABEL_PREFIX}chat`]); } finally { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); } }); @@ -1765,6 +1777,26 @@ test('prod: registering or linking a not-served row writes the record but never expect(drivers.manager.installed.has(`${LABEL_PREFIX}gitq`)).toBe(false); }); +test('reinstallSupervised in prod reinstalls the catalog app and skips an rt row outside the catalog', async () => { + const counting = new CountingManager(); + const h = bundleHelpers('chat', 'gitq'); + setServeShapeDeps({ + devMode: () => false, + helpersDir: h.dir, + catalog: CHAT_ONLY, + }); + rtRow('chat', 11002); + rtRow('gitq', 11008); + + const res = await reinstallSupervised({ + manager: counting, + edge: drivers.edge, + }); + + expect(res).toEqual({ reinstalled: ['chat'], failed: [] }); + expect(counting.installCalls).toEqual([`${LABEL_PREFIX}chat`]); +}); + // ─── editApp: never uninstall a shape the patch can't replace ───────────── test('edit: unlinking a slim row with no bundle installed is rejected before any teardown', async () => { @@ -1809,6 +1841,28 @@ test('edit: unlinking a slim row with no bundle installed is rejected before any expect(counting.installed.get(label)).toEqual(installedBefore); }); +test('edit: a port change on a user row whose directory is gone is refused before any teardown', async () => { + const counting = new CountingManager(); + const d = { manager: counting, edge: drivers.edge }; + const gone = mkdtempSync(join(tmpdir(), 'gone-edit-')); + await registerApp({ ...input, name: 'goneapp', workingDirectory: gone }, d); + const port = getRecord('goneapp')!.port; + rmSync(gone, { recursive: true, force: true }); + counting.installCalls = []; + counting.uninstallCalls = []; + + const res = await editApp('goneapp', { port: 11999 }, 'user', false, d); + + expect(res.status).toBe(400); + expect((res.body as any).error).toBe( + `working directory ${gone} does not exist` + ); + expect(counting.uninstallCalls).toEqual([]); + expect(counting.installCalls).toEqual([]); + expect(counting.installed.has(`${LABEL_PREFIX}goneapp`)).toBe(true); + expect(getRecord('goneapp')!.port).toBe(port); +}); + // The flavor flip on a lived-in registry. const FLIP_CATALOG = new Map([ @@ -1858,7 +1912,7 @@ const DECK_107_RECORD_KEYS = new Set([ ]); test('flip: dev -> prod -> dev on a lived-in registry creates missing catalog rows in either flavor, adopts only in prod, and deletes nothing', async () => { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); try { const manager = new PlistManager(); const flip = { manager, edge: drivers.edge }; @@ -1992,7 +2046,7 @@ test('flip: dev -> prod -> dev on a lived-in registry creates missing catalog ro for (const key of Object.keys(record)) expect(DECK_107_RECORD_KEYS.has(key)).toBe(true); } finally { - rmSync(agentsDir(), { recursive: true, force: true }); + wipeAgentsDir(); } }); @@ -2038,17 +2092,9 @@ test('prod catalog: a catalog port held by another row, or a route-only row with expect(getRecord('chat')!.kind).toBe('external'); }); -test("catalog: an app the bundle ships no Helpers binary for gets no row, and deck's own row names it until it ships", async () => { +test('catalog: an app the bundle ships no Helpers binary for gets no row, and the catalog report names it until it ships, with no platform record', async () => { const helpers = mkdtempSync(join(tmpdir(), 'partial-helpers-')); writeFileSync(join(helpers, 'chat'), ''); - putRecord({ - name: PLATFORM_NAME, - managedBy: 'deck', - port: 11000, - kind: 'service', - label: PLATFORM_LABEL, - createdAt: AT, - }); setServeShapeDeps({ devMode: () => true, helpersDir: helpers, @@ -2065,18 +2111,109 @@ test("catalog: an app the bundle ships no Helpers binary for gets no row, and de { name: 'board', error: 'this bundle ships no Helpers/board' }, ]); expect(getRecord('board')).toBeUndefined(); - expect(getRecord(PLATFORM_NAME)!.issues).toEqual([ - { - source: 'launchd', - message: 'catalog apps missing from this bundle: board', - at: expect.any(String), - }, - ]); + expect(listRecords().some(r => r.managedBy === 'deck')).toBe(false); + expect(catalogReport()).toEqual({ + source: 'launchd', + message: + 'bundled apps deck cannot serve: board (this bundle ships no Helpers/board)', + at: expect.any(String), + }); writeFileSync(join(helpers, 'board'), ''); const healed = (await reresolveManagedApps(drivers)).body as any; expect(healed).toMatchObject({ ok: true, created: ['board'], failed: [] }); expect(getRecord('board')!.port).toBe(11006); - expect(getRecord(PLATFORM_NAME)!.issues).toBeUndefined(); + expect(catalogReport()).toBeNull(); +}); + +test("catalog: the report leaves deck's own record and its launchd issue alone", async () => { + const platformIssue = { + source: 'launchd' as const, + message: 'bootstrap install failed', + at: AT, + }; + putRecord({ + name: PLATFORM_NAME, + managedBy: 'deck', + port: 11000, + kind: 'service', + label: PLATFORM_LABEL, + createdAt: AT, + issues: [platformIssue], + }); + setServeShapeDeps({ + devMode: () => false, + helpersDir: mkdtempSync(join(tmpdir(), 'empty-helpers-')), + catalog: new Map([['board', { port: 11006, args: [] as string[] }]]), + }); + + await reresolveManagedApps(drivers); + + expect(catalogReport()!.message).toContain('board'); + expect(getRecord(PLATFORM_NAME)!.issues).toEqual([platformIssue]); +}); + +test("catalog: a catalog port a portless route holds is reported and never written; the app's own leftover plist is not a holder", async () => { + const helpers = fakeBundle('mattstack.app'); + writeFileSync( + process.env.LOCAL_APPS_ROUTES_PATH!, + JSON.stringify([{ hostname: 'squatter.localhost', port: 11006, pid: 0 }]) + ); + wipeAgentsDir(); + try { + seedPlist(`${LABEL_PREFIX}chat`, [join(helpers, 'chat')], '/tmp', { + PORT: '11002', + }); + setServeShapeDeps({ + devMode: () => false, + helpersDir: helpers, + catalog: new Map([ + ['board', { port: 11006, args: [] as string[] }], + ['chat', { port: 11002, args: [] as string[] }], + ]), + }); + + const body = (await reresolveManagedApps(drivers)).body as any; + + expect(body.created).toEqual(['chat']); + expect(body.failed).toEqual([ + { + name: 'board', + error: 'catalog port 11006 is held by squatter.localhost', + }, + ]); + expect(getRecord('board')).toBeUndefined(); + expect(catalogReport()!.message).toContain( + 'board (catalog port 11006 is held by squatter.localhost)' + ); + } finally { + wipeAgentsDir(); + } +}); + +test('catalog: board is not created while a legacy mrs row waits for its rename, and is once mrs is gone', async () => { + const helpers = fakeBundle('mattstack-dev.app'); + rtRow('mrs', 11040, { managedBy: 'user' }); + setServeShapeDeps({ + devMode: () => true, + helpersDir: helpers, + catalog: new Map([['board', { port: 11006, args: [] as string[] }]]), + }); + + const waiting = (await reresolveManagedApps(drivers)).body as any; + + expect(waiting.created).toEqual([]); + expect(waiting.failed).toEqual([ + { + name: 'board', + error: + 'the legacy mrs row becomes board through `deck adopt mrs --as board`', + }, + ]); + expect(getRecord('board')).toBeUndefined(); + + deleteRecord('mrs'); + const created = (await reresolveManagedApps(drivers)).body as any; + expect(created.created).toEqual(['board']); }); diff --git a/apps/deck/src/api/register.ts b/apps/deck/src/api/register.ts index 34db181b..f5ccd093 100644 --- a/apps/deck/src/api/register.ts +++ b/apps/deck/src/api/register.ts @@ -24,6 +24,7 @@ import { MATTSTACK_REGISTRAR, type BundleCatalog, } from '../registry/bundle-catalog.ts'; +import { setCatalogReport } from '../registry/catalog-report.ts'; import { readDeckManifest } from '../registry/deck-manifest.ts'; import { authorizeStructural } from '../registry/lifecycle.ts'; import { ingestManifest, removeIcon } from '../registry/manifest.ts'; @@ -141,10 +142,16 @@ function buildSpec(record: AppRecord, shape: ResolvedShape): BuiltSpec { /** Deck owns only a mattstack app's data dir; creating anyone else's missing dir would hide a deleted checkout behind an empty one. */ +function missingCwdRefusal(record: AppRecord, cwd: string): string | null { + if (existsSync(cwd)) return null; + if (isMattstackOwned(record) && cwd === dataDir(record.name)) return null; + return `working directory ${cwd} does not exist`; +} + function ensureWorkingDirectory(record: AppRecord, cwd: string): boolean { if (existsSync(cwd)) return false; - if (!isMattstackOwned(record) || cwd !== dataDir(record.name)) - throw new Error(`working directory ${cwd} does not exist`); + const refusal = missingCwdRefusal(record, cwd); + if (refusal) throw new Error(refusal); mkdirSync(cwd, { recursive: true }); return true; } @@ -209,28 +216,40 @@ async function runDriver( } /** - * True when `port` is already held by another record, a portless route, or a - * launchd service. Used only for a manifest-declared SERVICE port - * (`registerApp`'s `input.port`, `editApp`'s `patch.port`): `allocatePort` - * never sees a caller-declared port, so nothing else guards against handing - * out a port a real process is already bound to. + * What already holds `port`: another record, a portless route, or a launchd + * service other than `excludeName`'s own, named for a refusal message. + * `allocatePort` never sees a caller-declared or catalog port, so nothing + * else guards against handing out a port a real process is already bound to. */ +async function portHolder( + port: number, + excludeName: string, + routes: PortlessRoute[] +): Promise { + const record = listRecords().find( + r => r.name !== excludeName && r.port === port + ); + if (record) return record.name; + const tlds = getPlatformSettings().tlds; + const route = routes.find( + r => bareName(r.hostname, tlds) !== excludeName && r.port === port + ); + if (route) return route.hostname; + const ownLabel = `${LABEL_PREFIX}${excludeName}`; + const service = (await readServices()).find( + s => s.label !== ownLabel && s.port === port + ); + return service?.label ?? null; +} + +/** Guards a manifest-declared SERVICE port (`registerApp`'s `input.port`, + `editApp`'s `patch.port`). */ async function portCollides( port: number, excludeName: string, routes: PortlessRoute[] ): Promise { - if (listRecords().some(r => r.name !== excludeName && r.port === port)) - return true; - const tlds = getPlatformSettings().tlds; - if ( - routes.some( - r => bareName(r.hostname, tlds) !== excludeName && r.port === port - ) - ) - return true; - const services = await readServices(); - return services.some(s => s.port === port); + return (await portHolder(port, excludeName, routes)) !== null; } export async function registerApp( @@ -460,16 +479,20 @@ type SweepFailure = { name: string; error: string }; interface EnsuredCatalog { created: string[]; adopted: string[]; - missing: string[]; failed: SweepFailure[]; } +/** rt setup renames these legacy rows with `deck adopt --as `, + which answers "name taken" once a row of the new name exists. */ +const RENAMED_FROM: Record = { board: 'mrs' }; + /** * Every catalog app gets an rt row in either flavor, so a fresh machine * serves the catalog whichever app it opens first. Only prod (`adopt`) takes * over a same-named user row: dev serves the user's registrations as they - * are. A route-only row, a catalog port another row holds, or an app whose - * binary this bundle does not ship is reported, never written. + * are. A route-only row, a catalog port something else holds, a legacy row + * awaiting its rename, or an app whose binary this bundle does not ship is + * reported, never written. */ async function ensureCatalogRows( catalog: BundleCatalog, @@ -477,28 +500,30 @@ async function ensureCatalogRows( drivers: Drivers, adopt: boolean ): Promise { - const out: EnsuredCatalog = { - created: [], - adopted: [], - missing: [], - failed: [], - }; + const out: EnsuredCatalog = { created: [], adopted: [], failed: [] }; for (const [name, entry] of catalog) { const existing = getRecord(name); if (!existing) { if (!bundleBinaryPath(name, helpersDir)) { - out.missing.push(name); out.failed.push({ name, error: `this bundle ships no Helpers/${name}`, }); continue; } - const holder = listRecords().find(r => r.port === entry.port); + const legacy = RENAMED_FROM[name]; + if (legacy && getRecord(legacy)) { + out.failed.push({ + name, + error: `the legacy ${legacy} row becomes ${name} through \`deck adopt ${legacy} --as ${name}\``, + }); + continue; + } + const holder = await portHolder(entry.port, name, readRoutes()); if (holder) { out.failed.push({ name, - error: `catalog port ${entry.port} is held by ${holder.name}`, + error: `catalog port ${entry.port} is held by ${holder}`, }); continue; } @@ -553,28 +578,6 @@ function adoptedCatalogRow(record: AppRecord): AppRecord { }; } -const MISSING_HELPERS = 'catalog apps missing from this bundle'; - -/** A catalog app with no binary has no row to carry its issue, so deck's own - row carries it, and only this sweep clears it. */ -function reportMissingHelpers(names: string[]): void { - const platform = listRecords().find(r => isPlatformManagedBy(r.managedBy)); - if (!platform) return; - if (names.length) { - addIssue(platform.name, { - source: 'launchd', - message: `${MISSING_HELPERS}: ${names.join(', ')}`, - at: new Date().toISOString(), - }); - } else if ( - platform.issues?.some( - i => i.source === 'launchd' && i.message.startsWith(MISSING_HELPERS) - ) - ) { - clearIssues(platform.name, 'launchd'); - } -} - /** * The flavor sweep. Deck runs it on every bundled start, which is every * switch between mattstack-dev.app and mattstack.app, and it is the only @@ -601,9 +604,9 @@ export async function reresolveManagedApps( drivers, !flavor.dev ) - : { created: [], adopted: [], missing: [], failed: [] }; + : { created: [], adopted: [], failed: [] }; failed.push(...ensured.failed); - reportMissingHelpers(ensured.missing); + setCatalogReport(ensured.failed); for (const record of listRecords()) { if ( record.managedBy === 'user' || @@ -876,7 +879,8 @@ export async function editApp( // back on. const servedHere = next.kind === 'service' && !notServedHere(next, serveShapeDeps); - if (servedHere && !serveShape(next, serveShapeDeps)) { + const nextShape = servedHere ? serveShape(next, serveShapeDeps) : null; + if (servedHere && !nextShape) { return { status: 400, body: { @@ -884,6 +888,8 @@ export async function editApp( }, }; } + const cwdRefusal = nextShape && missingCwdRefusal(next, nextShape.cwd); + if (cwdRefusal) return { status: 400, body: { error: cwdRefusal } }; // Teardown-phase failures are collected, not recorded yet: the record they // belong to doesn't exist under its final cache key yet (a rename deletes the diff --git a/apps/deck/src/api/server.test.ts b/apps/deck/src/api/server.test.ts index 02b33d73..e531ad02 100644 --- a/apps/deck/src/api/server.test.ts +++ b/apps/deck/src/api/server.test.ts @@ -425,7 +425,7 @@ test('managed/remove refuses a name in its body instead of removing every manage const res = await post('/api/v1/apps/managed/remove', { name: 'mb-one' }); expect(res.status).toBe(400); - expect((await res.json()).error).toContain( + expect(((await res.json()) as { error: string }).error).toContain( '/api/v1/apps/managed/remove/' ); expect(getRecord('mb-one')).toBeDefined(); @@ -450,6 +450,11 @@ describe('/api/apps during the boot sweep', () => { }); } + async function appNames(res: Response): Promise { + const body = (await res.json()) as { apps: Array<{ name: string }> }; + return body.apps.map(a => a.name); + } + function sweptApp(): void { putRecord({ name: 'bs-app', @@ -476,9 +481,7 @@ describe('/api/apps during the boot sweep', () => { sweep.resolve(); const ready = await fetch(`http://127.0.0.1:${BOOT_PORT}/api/apps`); expect(ready.status).toBe(200); - expect((await ready.json()).apps.map((a: any) => a.name)).toEqual([ - 'bs-app', - ]); + expect(await appNames(ready)).toEqual(['bs-app']); } finally { booting.stop(true); writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); @@ -495,9 +498,7 @@ describe('/api/apps during the boot sweep', () => { sweep.resolve(); const res = await pending; expect(res.status).toBe(200); - expect((await res.json()).apps.map((a: any) => a.name)).toEqual([ - 'bs-app', - ]); + expect(await appNames(res)).toEqual(['bs-app']); } finally { booting.stop(true); writeFileSync(process.env.LOCAL_APPS_ROUTES_PATH!, '[]'); diff --git a/apps/deck/src/api/status.test.ts b/apps/deck/src/api/status.test.ts index ed983b0a..497b420b 100644 --- a/apps/deck/src/api/status.test.ts +++ b/apps/deck/src/api/status.test.ts @@ -20,6 +20,7 @@ process.env.HOME = dir; const { buildStatus } = await import('./status.ts'); const { putRecord, reloadRegistry } = await import('../registry/records.ts'); +const { setCatalogReport } = await import('../registry/catalog-report.ts'); beforeEach(() => { rmSync(process.env.LOCAL_REGISTRY_PATH!, { force: true }); @@ -32,6 +33,7 @@ beforeEach(() => { process.env.LOCAL_APPS_ROUTES_PATH!, JSON.stringify([{ hostname: 'myapp.localhost', port: 19999, pid: 0 }]) ); + setCatalogReport([]); }); const opts = { @@ -88,6 +90,67 @@ test("the platform's own record marks its row self, wherever its port is", async expect(status.apps.find(a => a.name === 'myapp')!.self).toBe(true); }); +test("deck's own row carries the catalog report with no platform record, and no other row does", async () => { + writeFileSync( + process.env.LOCAL_APPS_ROUTES_PATH!, + JSON.stringify([ + { hostname: 'deck.localhost', port: 7940, pid: 0 }, + { hostname: 'myapp.localhost', port: 19999, pid: 0 }, + ]) + ); + setCatalogReport([ + { name: 'board', error: 'this bundle ships no Helpers/board' }, + ]); + + const status = await buildStatus(opts); + + expect(status.apps.find(a => a.name === 'deck')!.issues).toEqual([ + { + source: 'launchd', + message: + 'bundled apps deck cannot serve: board (this bundle ships no Helpers/board)', + at: expect.any(String), + }, + ]); + expect(status.apps.find(a => a.name === 'myapp')!.issues).toEqual([]); +}); + +test("the catalog report joins the platform record's own launchd issue, one issue per source", async () => { + writeFileSync( + process.env.LOCAL_APPS_ROUTES_PATH!, + JSON.stringify([{ hostname: 'deck.localhost', port: 7940, pid: 0 }]) + ); + putRecord({ + name: 'deck', + managedBy: 'deck', + port: 7940, + kind: 'service', + label: 'com.mattstack.deck', + createdAt: '2026-08-10T00:00:00Z', + issues: [ + { + source: 'launchd', + message: 'bootstrap install failed', + at: '2026-08-10T00:00:00Z', + }, + ], + }); + setCatalogReport([ + { name: 'board', error: 'catalog port 11006 is held by mine' }, + ]); + + const status = await buildStatus(opts); + + expect(status.apps.find(a => a.name === 'deck')!.issues).toEqual([ + { + source: 'launchd', + message: + 'bootstrap install failed; bundled apps deck cannot serve: board (catalog port 11006 is held by mine)', + at: '2026-08-10T00:00:00Z', + }, + ]); +}); + test('a pre-rename self-record (managedBy local) still marks its row self', async () => { // Local -> Deck rename: an upgrading machine's self-row may still carry // the pre-rename managedBy id until `deck setup` next runs and migrates diff --git a/apps/deck/src/api/status.ts b/apps/deck/src/api/status.ts index d17de574..3797cbb1 100644 --- a/apps/deck/src/api/status.ts +++ b/apps/deck/src/api/status.ts @@ -19,6 +19,7 @@ import { tunnelRowHealth } from '../edge/edge-health.ts'; import { edgeDrift } from '../edge/edge-reconcile.ts'; import { getOAuth, type OAuth } from '../edge/oauth.ts'; import { allocatePort } from '../registry/allocate.ts'; +import { withCatalogReport } from '../registry/catalog-report.ts'; import { listRecords, type RemoteState, @@ -183,6 +184,12 @@ export async function buildStatus(opts: BuildStatusOpts): Promise { const settings = getAppSettings(a.name); const follows = settings.publicFollowsOverride ?? false; const record = recordsByName.get(a.name); + // Also matches CANARY_PORT: during a freshness check the board's route + // points at its canary listener, and it is still the board's own row. + const self = + a.port === opts.port || + a.port === opts.canaryPort || + (platformRecord !== undefined && a.port === platformRecord.port); // Ownership decides the displayed TLD: a managed record (managedBy set // to anything but "user") is a mattstack product and surfaces as // name.mattstack; user-added apps surface as name.localhost. Through a @@ -205,12 +212,7 @@ export async function buildStatus(opts: BuildStatusOpts): Promise { isTunnel: false, override: settings.override ?? null, publicFollowsOverride: follows, - // Also matches CANARY_PORT: during a freshness check the board's route - // points at its canary listener, and it is still the board's own row. - self: - a.port === opts.port || - a.port === opts.canaryPort || - (platformRecord !== undefined && a.port === platformRecord.port), + self, managedBy: record?.managedBy ?? null, icon: record ? isPlatformManagedBy(record.managedBy) @@ -219,7 +221,9 @@ export async function buildStatus(opts: BuildStatusOpts): Promise { ? `/api/apps/${a.name}/icon` : null : null, - issues: record?.issues ?? [], + issues: self + ? withCatalogReport(record?.issues ?? []) + : (record?.issues ?? []), record: record ? { kind: record.kind, diff --git a/apps/deck/src/registry/catalog-report.ts b/apps/deck/src/registry/catalog-report.ts new file mode 100644 index 00000000..cf25523b --- /dev/null +++ b/apps/deck/src/registry/catalog-report.ts @@ -0,0 +1,38 @@ +import type { SyncIssue } from './records.ts'; + +let report: SyncIssue | null = null; + +/** + * The catalog apps the sweep could not create or adopt have no row of their + * own, so deck's board row carries them. Kept in memory rather than on a + * record: every deck start sweeps again, and a machine whose deck runs as the + * bundle helper has no platform record at all. + */ +export function setCatalogReport( + refusals: Array<{ name: string; error: string }> +): void { + report = refusals.length + ? { + source: 'launchd', + message: `bundled apps deck cannot serve: ${refusals + .map(r => `${r.name} (${r.error})`) + .join('; ')}`, + at: new Date().toISOString(), + } + : null; +} + +export function catalogReport(): SyncIssue | null { + return report; +} + +/** The board keys a row's issues by source, so the report joins an existing + issue of its source instead of sitting beside it. */ +export function withCatalogReport(issues: SyncIssue[]): SyncIssue[] { + if (!report) return issues; + const { source, message } = report; + if (!issues.some(i => i.source === source)) return [...issues, report]; + return issues.map(i => + i.source === source ? { ...i, message: `${i.message}; ${message}` } : i + ); +} From 025fd33bc7e50901a939e8cda14df5b4de0e5284 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 25 Sep 2026 03:49:34 -0500 Subject: [PATCH 14/14] deck AGENTS.md: catalog refusals, restored catalog apps, and /api/apps during the boot sweep Co-Authored-By: Claude Opus 5.5 --- apps/deck/AGENTS.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/deck/AGENTS.md b/apps/deck/AGENTS.md index 8f1127bc..a66bf58f 100644 --- a/apps/deck/AGENTS.md +++ b/apps/deck/AGENTS.md @@ -81,13 +81,19 @@ installed plist, so a linked app that moves its entry point is picked up on the next deck restart. The sweep reads the bundle's catalog (the `deps.lock` rows in `Contents/Resources` that carry `serve`, read by `src/registry/bundle-catalog.ts`) and gives each catalog app an rt row in -either flavor, unless the bundle ships no `Helpers/` for it (then deck's -own row carries the issue). In prod it also adopts a same-named user row, -serves each catalog app as `Contents/Helpers/ ` from -`~/.mattstack/`, which deck creates, and does not serve any other rt row -(plist removed, row and dev link kept, hidden from `/api/apps`). In dev a -catalog row serves its linked source, or the dev bundle's binary when it has -no link. Deck never creates any other missing working directory: a user app or +either flavor. An app it cannot create (no `Helpers/` in the bundle, +its catalog port held by something else, a route-only row of its name, or a +legacy `mrs` row still waiting for `deck adopt mrs --as board`) is reported on +deck's own board row, from memory, since a helper-run deck has no platform +record. A catalog app removed with `deck remove --force` comes back on +the next deck start. Until the first sweep settles, `/api/apps` holds its +answer for up to 10s and then answers 503, so the launcher never takes a +half-built catalog for the whole one. In prod the sweep also adopts a +same-named user row, serves each catalog app as +`Contents/Helpers/ ` from `~/.mattstack/`, which deck +creates, and does not serve any other rt row (plist removed, row and dev link +kept, hidden from `/api/apps`). In dev a catalog row serves its linked source, +or the dev bundle's binary when it has no link. Deck never creates any other missing working directory: a user app or checkout whose dir is gone gets a launchd issue instead of a plist launchd would reject.