From 246a0ac6118b6a4b866346b0941aa438af4d8ae0 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 27 May 2026 16:19:32 -0700 Subject: [PATCH 01/13] apps system phase 1: storage + daemon serving/watching + server routes + status app + bb app CLI Per-thread Apps system foundation (no frontend yet): apps// layout (manifest.json + served assets/ + file-based data/ + logo.*), daemon-owned asset serving + data/ watcher, server routes (entry/assets/icon/data CRUD/ message + WS relay on thread::app::data), injected window.bb bridge (capability-gated, advisory for v1), default seeded `status` app, and bb app new|list|open|rm. STATUS remains in place; teardown is Phase 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/components/ui/icon.tsx | 2 + apps/cli/src/__tests__/command-output.test.ts | 129 ++ apps/cli/src/commands/app.ts | 283 ++++ apps/cli/src/index.ts | 2 + .../src/app-data-change-reporter.test.ts | 212 +++ .../src/app-data-change-reporter.ts | 384 ++++++ apps/host-daemon/src/app-data-files.ts | 280 ++++ apps/host-daemon/src/app.ts | 15 + apps/host-daemon/src/command-dispatch.ts | 5 + .../src/command-handlers/file-list.test.ts | 21 + .../src/command-handlers/file-list.ts | 1 + .../src/command-handlers/file-read.ts | 3 + .../src/command-handlers/file-write.ts | 73 + .../src/command-handlers/host-files.test.ts | 45 + .../src/command-handlers/host-files.ts | 11 + apps/host-daemon/src/command-router.ts | 10 +- apps/host-daemon/src/runtime-manager.ts | 23 + apps/host-daemon/src/server-client.ts | 44 + apps/server/src/internal/app-data-changes.ts | 110 ++ .../src/internal/command-result-owners.ts | 1 + apps/server/src/routes/relative-route-path.ts | 92 ++ apps/server/src/routes/threads/apps.ts | 1205 +++++++++++++++++ apps/server/src/routes/threads/data.ts | 126 +- apps/server/src/routes/threads/index.ts | 2 + apps/server/src/server.ts | 2 + .../src/services/threads/app-client-script.ts | 445 ++++++ .../apps/status/assets/index.html | 273 ++++ .../apps/status/data/state.json | 7 + .../apps/status/manifest.json | 9 + .../threads/manager-storage-templates.ts | 75 +- apps/server/src/ws/hub.ts | 9 + apps/server/test/app/hub.test.ts | 46 + .../internal/internal-app-data-change.test.ts | 173 +++ .../test/public/public-thread-apps.test.ts | 851 ++++++++++++ .../threads/app-client-script.test.ts | 204 +++ .../threads/manager-storage-templates.test.ts | 47 +- packages/domain/src/apps.ts | 52 + packages/domain/src/index.ts | 3 + packages/host-daemon-contract/src/commands.ts | 21 +- packages/host-daemon-contract/src/index.ts | 9 + packages/host-daemon-contract/src/session.ts | 83 ++ .../test/contract.test.ts | 87 ++ .../host-watcher/src/host-watcher-types.ts | 16 +- .../host-watcher/src/parcel-host-watcher.ts | 55 +- .../test/thread-storage-watch.test.ts | 44 + packages/server-contract/src/api-types.ts | 299 +++- packages/server-contract/src/index.ts | 48 + packages/server-contract/src/public-api.ts | 41 + .../server-contract/test/contract.test.ts | 75 + 49 files changed, 5935 insertions(+), 118 deletions(-) create mode 100644 apps/cli/src/commands/app.ts create mode 100644 apps/host-daemon/src/app-data-change-reporter.test.ts create mode 100644 apps/host-daemon/src/app-data-change-reporter.ts create mode 100644 apps/host-daemon/src/app-data-files.ts create mode 100644 apps/server/src/internal/app-data-changes.ts create mode 100644 apps/server/src/routes/relative-route-path.ts create mode 100644 apps/server/src/routes/threads/apps.ts create mode 100644 apps/server/src/services/threads/app-client-script.ts create mode 100644 apps/server/src/services/threads/default-template/apps/status/assets/index.html create mode 100644 apps/server/src/services/threads/default-template/apps/status/data/state.json create mode 100644 apps/server/src/services/threads/default-template/apps/status/manifest.json create mode 100644 apps/server/test/internal/internal-app-data-change.test.ts create mode 100644 apps/server/test/public/public-thread-apps.test.ts create mode 100644 apps/server/test/services/threads/app-client-script.test.ts create mode 100644 packages/domain/src/apps.ts diff --git a/apps/app/src/components/ui/icon.tsx b/apps/app/src/components/ui/icon.tsx index 1a8ee25ae..d0d07da61 100644 --- a/apps/app/src/components/ui/icon.tsx +++ b/apps/app/src/components/ui/icon.tsx @@ -40,6 +40,7 @@ import { FolderRemoveIcon, GitBranchIcon, GitMergeIcon, + GridViewIcon, InformationCircleIcon, LaptopIcon, LayoutTwoColumnIcon, @@ -103,6 +104,7 @@ const ICON_MAP = { FolderPlus: FolderAddIcon, GitBranch: GitBranchIcon, GitMerge: GitMergeIcon, + GridView: GridViewIcon, Info: InformationCircleIcon, Laptop: LaptopIcon, ListTodo: CheckListIcon, diff --git a/apps/cli/src/__tests__/command-output.test.ts b/apps/cli/src/__tests__/command-output.test.ts index 2ccc34be0..b64008599 100644 --- a/apps/cli/src/__tests__/command-output.test.ts +++ b/apps/cli/src/__tests__/command-output.test.ts @@ -44,6 +44,7 @@ vi.mock("../daemon.js", () => ({ import { createClient, unwrap } from "../client.js"; import { fetchLocalHostId } from "../daemon.js"; +import { registerAppCommands } from "../commands/app.js"; import { registerEnvironmentCommands } from "../commands/environment.js"; import { registerGuideCommand } from "../commands/guide.js"; import { registerHostCommands } from "../commands/host.js"; @@ -1176,6 +1177,134 @@ describe("CLI command output contracts", () => { ]); }); + it("bb app list renders resolved app summaries", async () => { + vi.stubEnv("BB_THREAD_ID", "thr_current"); + const apps = [ + { + id: "status", + name: "Status", + entry: { path: "index.html", kind: "html" }, + capabilities: ["data", "message"], + icon: { kind: "builtin", name: "ListTodo" }, + }, + { + id: "demo", + name: "Demo", + entry: { path: "readme.md", kind: "md" }, + capabilities: [], + icon: { + kind: "logo", + url: "/api/v1/threads/thr_current/apps/demo/icon", + }, + }, + ]; + const get = vi.fn(async () => apps); + createClientMock.mockReturnValue( + asServerClient({ + api: { + v1: { + threads: { + ":id": { + apps: { + $get: get, + }, + }, + }, + }, + }, + }), + ); + + await runCommand(["app", "list"], (program) => + registerAppCommands(program, () => "http://server"), + ); + + expect(get).toHaveBeenCalledWith({ param: { id: "thr_current" } }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "ID Name Entry Capabilities Icon\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\nstatus Status html:index.html data,message ListTodo\n------------------------ ------------------------ ------------------------ ------------------------ ------------------\ndemo Demo md:readme.md - logo", + ]); + }); + + it("bb app new targets the current thread and posts the selected template", async () => { + vi.stubEnv("BB_THREAD_ID", "thr_current"); + const created = { + id: "demo", + name: "Demo", + entry: { path: "index.html", kind: "html" }, + capabilities: ["data", "message"], + icon: { kind: "builtin", name: "ListTodo" }, + }; + const post = vi.fn(async () => created); + createClientMock.mockReturnValue( + asServerClient({ + api: { + v1: { + threads: { + ":id": { + apps: { + $post: post, + }, + }, + }, + }, + }, + }), + ); + + await runCommand( + ["app", "new", "demo", "--template", "status"], + (program) => registerAppCommands(program, () => "http://server"), + ); + + expect(post).toHaveBeenCalledWith({ + param: { id: "thr_current" }, + json: { id: "demo", name: "demo", template: "status" }, + }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "App created: demo", + " Name: Demo", + " Entry: html:index.html", + " Capabilities: data,message", + " Icon: ListTodo", + ]); + }); + + it("bb app new derives a valid id from a display name", async () => { + vi.stubEnv("BB_THREAD_ID", "thr_current"); + const created = { + id: "my-app", + name: "My App", + entry: { path: "index.html", kind: "html" }, + capabilities: ["data"], + icon: { kind: "builtin", name: "GridView" }, + }; + const post = vi.fn(async () => created); + createClientMock.mockReturnValue( + asServerClient({ + api: { + v1: { + threads: { + ":id": { + apps: { + $post: post, + }, + }, + }, + }, + }, + }), + ); + + await runCommand(["app", "new", "My App"], (program) => + registerAppCommands(program, () => "http://server"), + ); + + expect(post).toHaveBeenCalledWith({ + param: { id: "thr_current" }, + json: { id: "my-app", name: "My App", template: "blank" }, + }); + }); + it("bb manager status includes managed child threads", async () => { const managerThread: Thread = makeThread({ id: "thread-manager-1", diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts new file mode 100644 index 000000000..28552c8e8 --- /dev/null +++ b/apps/cli/src/commands/app.ts @@ -0,0 +1,283 @@ +import { Command } from "commander"; +import { appIdSchema, type AppId } from "@bb/domain"; +import type { + AppDetail, + AppIcon, + AppSummary, + AppTemplate, + CreateThreadAppRequest, +} from "@bb/server-contract"; +import { appTemplateSchema } from "@bb/server-contract"; +import { action } from "../action.js"; +import { createClient, unwrap } from "../client.js"; +import { renderBorderlessTable } from "../table.js"; +import { + confirmDestructiveAction, + outputJson, + printContextLabel, + requireThreadIdWithLabelOrSelf, +} from "./helpers.js"; + +type ResolveServerUrl = () => string; + +interface AppThreadCommandOptions { + json?: boolean; + self?: boolean; +} + +interface AppNewCommandOptions extends AppThreadCommandOptions { + id?: string; + template?: string; +} + +interface AppRemoveCommandOptions extends AppThreadCommandOptions { + yes?: boolean; +} + +interface ResolveAppCommandThreadArgs { + options: AppThreadCommandOptions; + threadId: string | undefined; +} + +interface AppOpenPayload { + app: AppDetail; + threadId: string; + url: string; +} + +interface ResolveNewAppIdArgs { + id: string | undefined; + name: string; +} + +function parseAppTemplate(value: string | undefined): AppTemplate { + const parsed = appTemplateSchema.safeParse(value ?? "blank"); + if (!parsed.success) { + throw new Error("Invalid app template. Expected 'blank' or 'status'."); + } + return parsed.data; +} + +function resolveAppCommandThread(args: ResolveAppCommandThreadArgs): string { + const resolved = requireThreadIdWithLabelOrSelf( + args.threadId, + args.options, + ); + printContextLabel(resolved, "Thread", "BB_THREAD_ID", args.options); + return resolved.id; +} + +function slugifyAppName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-|-$/gu, ""); +} + +function resolveNewAppId(args: ResolveNewAppIdArgs): AppId { + const candidate = args.id ?? slugifyAppName(args.name); + const parsed = appIdSchema.safeParse(candidate); + if (parsed.success) { + return parsed.data; + } + if (args.id !== undefined) { + throw new Error( + "Invalid app id. Use letters, numbers, underscores, or hyphens.", + ); + } + throw new Error( + `Could not derive a valid app id from "${args.name}". Pass --id with letters, numbers, underscores, or hyphens.`, + ); +} + +function appUrl(baseUrl: string, threadId: string, appId: string): string { + return `${baseUrl.replace(/\/$/u, "")}/api/v1/threads/${encodeURIComponent( + threadId, + )}/apps/${encodeURIComponent(appId)}/`; +} + +function formatIcon(icon: AppIcon): string { + return icon.kind === "builtin" ? icon.name : "logo"; +} + +function printAppsTable(apps: AppSummary[]): void { + if (apps.length === 0) { + console.log("No apps"); + return; + } + console.log( + renderBorderlessTable( + { + head: ["ID", "Name", "Entry", "Capabilities", "Icon"], + colWidths: [24, 24, 24, 24, 18], + trimTrailingWhitespace: true, + }, + apps.map((app) => [ + app.id, + app.name, + `${app.entry.kind}:${app.entry.path}`, + app.capabilities.join(",") || "-", + formatIcon(app.icon), + ]), + ), + ); +} + +function printAppDetail(app: AppDetail): void { + console.log(`App created: ${app.id}`); + console.log(` Name: ${app.name}`); + console.log(` Entry: ${app.entry.kind}:${app.entry.path}`); + console.log(` Capabilities: ${app.capabilities.join(",") || "-"}`); + console.log(` Icon: ${formatIcon(app.icon)}`); +} + +export function registerAppCommands( + program: Command, + getUrl: ResolveServerUrl, +): void { + const app = program.command("app").description("Manage thread apps"); + + app + .command("new [threadId]") + .description("Create a new app in a thread") + .option("--id ", "App id. Defaults to a slug derived from name.") + .option("--template