Skip to content
This repository was archived by the owner on Sep 27, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions apps/deck/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,29 @@ 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 <path>`
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. An app it cannot create (no `Helpers/<name>` 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 <name> --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/<name> <serve.args>` from `~/.mattstack/<name>`, 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

Expand Down
2 changes: 1 addition & 1 deletion apps/deck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ usage:
deck alt <app> <name|off> activate a declared serve overlay, or return to base
deck cmd <app> <name> run a declared action command (dev mode only)
deck remove <name> [--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 <name> kickstart its service
deck restart --managed kickstart every app deck manages (installer's version-change step)
deck logs <name> [--lines N] tail stderr
Expand Down
4 changes: 2 additions & 2 deletions apps/deck/core/board/useBoardState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <app>`").
// Surface the API's message VERBATIM: for managed rows it carries the
// escape hatch (`deck remove <name> --force`).
notice(
'bad',
body.message || body.error || `remove failed (${res.status})`,
Expand Down
2 changes: 1 addition & 1 deletion apps/deck/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "deck",
"version": "1.0.7",
"version": "1.1.0",
"module": "src/main.ts",
"type": "module",
"private": true,
Expand Down
38 changes: 38 additions & 0 deletions apps/deck/src/api/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ beforeEach(() => {
});

const statusOpts = {
local: false,
port: 7940,
canaryPort: 7942,
proxyFreshness: 'unknown' as const,
Expand Down Expand Up @@ -376,3 +377,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']);
});
9 changes: 6 additions & 3 deletions apps/deck/src/api/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,20 +18,22 @@ 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<DiscoveryApp[]> {
const status = await buildStatus(opts);
const urlByName = new Map(status.apps.map(row => [row.name, row.url]));
const apps: DiscoveryApp[] = [];
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({
Expand Down
Loading
Loading