From fd02f9b99dd417b9b1e226a9bc014981465ea20c Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 5 Jun 2026 19:29:05 -0700 Subject: [PATCH 01/10] feat(apps): external app sources + relocate app data out of app folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install bb apps from a git repo (or local path) that version-controls them and updates them via an explicit manual sync — no background auto-update. Inspired by Claude Code / Codex plugin marketplaces. App data moves out of the app folder to {dataDir}/app-data// so app code (author-owned, replaceable by source syncs) and runtime data (user-owned) have independent lifecycles. A one-time idempotent boot migration relocates legacy apps//data dirs; the daemon now watches the app-data root so window.bb.data.onChange keeps receiving live updates. - Sync engine (apps/server/src/services/app-sources/): git fetch + detached checkout per source, manifest discovery, sha-based provenance markers with content-hash divergence detection, atomic whole-dir materialize, local-wins conflict rules, upstream-removal handling that never destroys user data or local edits, coalesced one-in-flight sync. - Routes: GET/POST /api/v1/app-sources, POST /:name/sync {force}, DELETE /:name, POST /api/v1/apps/:id/detach; delete-guard for managed apps; source field on AppSummary; .bb-app-source.json never served. - CLI: bb app source add/list/sync(--force)/detach/remove; source column. - UI: App sources settings section; managed-app badge in the sidebar. - Docs: building-bb-apps skill + bb guide app updated. Security/correctness hardening from review: - git ref/origin can no longer inject options: `--` end-of-options guard in fetch/remote-add plus schema rejection of leading-dash values. - runCoalescedSync re-checks the in-flight map after awaiting so concurrent force syncs can't run two materializes on one source. - shared app-storage staging (nonce + .tmp-/.delete- prefixes) and fs-errors (type guard + directoryExists) replace duplicated helpers. Co-Authored-By: Claude Opus 4.8 --- .../secondary-panel/AppTabContent.test.tsx | 2 + .../secondary-panel/NewTabFileSearch.test.tsx | 1 + .../ThreadSecondaryPanelNewTab.stories.tsx | 4 + .../components/settings/AppSourcesSection.tsx | 383 +++++++++ .../components/sidebar/ProjectList.test.tsx | 1 + .../sidebar/SidebarAppsSection.test.tsx | 2 + .../components/sidebar/SidebarAppsSection.tsx | 10 + .../cache-owners/cache-owner-registry.test.ts | 3 + .../cache-owners/mutation-cache-effects.ts | 15 + .../cache-owners/realtime-cache-registry.ts | 4 + .../hooks/mutations/app-source-mutations.ts | 53 ++ apps/app/src/hooks/queries/query-keys.ts | 6 + apps/app/src/hooks/queries/thread-queries.ts | 13 + .../hooks/useFileSearchSuggestions.test.tsx | 1 + apps/app/src/lib/api.ts | 34 + apps/app/src/views/AppSettingsView.tsx | 3 + .../standalone-app/StandaloneAppView.test.tsx | 1 + apps/cli/src/__tests__/command-output.test.ts | 160 +++- apps/cli/src/commands/app.ts | 244 +++++- apps/host-daemon/src/app-data-files.test.ts | 7 +- apps/host-daemon/src/app-data-files.ts | 7 + apps/host-daemon/src/app.ts | 3 + apps/host-daemon/src/runtime-manager.test.ts | 10 +- apps/host-daemon/src/runtime-manager.ts | 5 +- apps/server/src/routes/apps.ts | 112 ++- apps/server/src/services/app-sources/git.ts | 57 ++ .../src/services/app-sources/provenance.ts | 126 +++ apps/server/src/services/app-sources/store.ts | 102 +++ .../src/services/app-sources/sync-service.ts | 754 ++++++++++++++++++ .../apps/app-data-layout-migration.ts | 72 ++ .../src/services/apps/app-storage-staging.ts | 31 + apps/server/src/services/lib/fs-errors.ts | 24 + .../builtin-skills/building-bb-apps/SKILL.md | 68 +- .../threads/app-scaffold-template-copy.ts | 11 +- .../threads/app-scaffold-template/README.md | 4 +- .../app-scaffold-template/data/state.json | 1 - apps/server/src/start-server.ts | 2 + .../test/public/public-app-sources.test.ts | 203 +++++ apps/server/test/public/public-apps.test.ts | 42 +- .../app-sources/app-source-sync.test.ts | 671 ++++++++++++++++ .../apps/app-data-layout-migration.test.ts | 112 +++ packages/config/src/app-storage-paths.ts | 59 +- .../config/test/app-storage-paths.test.ts | 5 + packages/domain/src/apps.ts | 29 + packages/domain/src/index.ts | 4 +- .../host-watcher/src/host-watcher-types.ts | 3 + .../host-watcher/src/parcel-host-watcher.ts | 208 +++-- .../test/thread-storage-watch.test.ts | 70 +- .../app-runtime-browser-bundle.generated.ts | 4 +- packages/sdk/src/areas/apps.ts | 63 ++ packages/server-contract/src/api-types.ts | 116 +++ packages/server-contract/src/index.ts | 16 + packages/server-contract/src/public-api.ts | 42 + .../src/generated/templates.generated.ts | 2 +- .../templates/src/templates/bb-guide-app.md | 35 +- 55 files changed, 3845 insertions(+), 175 deletions(-) create mode 100644 apps/app/src/components/settings/AppSourcesSection.tsx create mode 100644 apps/app/src/hooks/mutations/app-source-mutations.ts create mode 100644 apps/server/src/services/app-sources/git.ts create mode 100644 apps/server/src/services/app-sources/provenance.ts create mode 100644 apps/server/src/services/app-sources/store.ts create mode 100644 apps/server/src/services/app-sources/sync-service.ts create mode 100644 apps/server/src/services/apps/app-data-layout-migration.ts create mode 100644 apps/server/src/services/apps/app-storage-staging.ts create mode 100644 apps/server/src/services/lib/fs-errors.ts delete mode 100644 apps/server/src/services/threads/app-scaffold-template/data/state.json create mode 100644 apps/server/test/public/public-app-sources.test.ts create mode 100644 apps/server/test/services/app-sources/app-source-sync.test.ts create mode 100644 apps/server/test/services/apps/app-data-layout-migration.test.ts diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx index 0c9ee5686..ff4f5aea5 100644 --- a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx +++ b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx @@ -24,6 +24,7 @@ const HTML_APP: AppDetail = { entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, appsRootPath: "/tmp/bb-data/apps", appRootPath: "/tmp/bb-data/apps/status", appDataPath: "/tmp/bb-data/apps/status/data", @@ -35,6 +36,7 @@ const MARKDOWN_APP: AppDetail = { entry: { path: "docs/index.md", kind: "md" }, capabilities: [], icon: { kind: "builtin", name: "GridView" }, + source: null, appsRootPath: "/tmp/bb-data/apps", appRootPath: "/tmp/bb-data/apps/readme", appDataPath: "/tmp/bb-data/apps/readme/data", diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx index 1870b3ab8..b405fcc9a 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.test.tsx @@ -112,6 +112,7 @@ const APP_SUGGESTION = { entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, applicationId: "status", name: "Review Board", diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx index 358b97d12..7e8a0cf07 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelNewTab.stories.tsx @@ -97,6 +97,7 @@ const APPS_RESPONSE: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, ]; @@ -107,6 +108,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data", "message"], icon: { kind: "builtin", name: "ListTodo" }, + source: null, }, { applicationId: "app_workspace_map", @@ -114,6 +116,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, { applicationId: "app_release_notes", @@ -121,6 +124,7 @@ const APPS_ROW_APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["message"], icon: { kind: "builtin", name: "File" }, + source: null, }, ]; diff --git a/apps/app/src/components/settings/AppSourcesSection.tsx b/apps/app/src/components/settings/AppSourcesSection.tsx new file mode 100644 index 000000000..232fe379d --- /dev/null +++ b/apps/app/src/components/settings/AppSourcesSection.tsx @@ -0,0 +1,383 @@ +import { useId, useState, type FormEvent } from "react"; +import { timeAgo } from "@bb/core-ui"; +import type { AppSourceAppState, AppSourceStatus } from "@bb/server-contract"; +import { Button } from "@/components/ui/button.js"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.js"; +import { Icon } from "@/components/ui/icon.js"; +import { Input } from "@/components/ui/input.js"; +import { Pill } from "@/components/ui/pill.js"; +import { + SettingsRow, + SettingsRowList, + SettingsSection, +} from "@/components/ui/settings-section.js"; +import { useAppSources } from "@/hooks/queries/thread-queries"; +import { + useAddAppSource, + useRemoveAppSource, + useSyncAppSource, +} from "@/hooks/mutations/app-source-mutations"; + +interface AppSourceAppStatePillProps { + state: AppSourceAppState; +} + +function AppSourceAppStatePill({ state }: AppSourceAppStatePillProps) { + const variant = + state.status === "installed" + ? "secondary" + : state.status === "modified" + ? "emphasis" + : "destructive"; + return ( + + + {state.applicationId} · {state.status} + + + ); +} + +interface AddAppSourceDialogProps { + open: boolean; + pending: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (args: { origin: string; name?: string; ref?: string }) => void; +} + +function AddAppSourceDialog({ + open, + pending, + onOpenChange, + onAdd, +}: AddAppSourceDialogProps) { + const originId = useId(); + const nameId = useId(); + const refId = useId(); + const [origin, setOrigin] = useState(""); + const [name, setName] = useState(""); + const [ref, setRef] = useState(""); + const [validationMessage, setValidationMessage] = useState( + null, + ); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + const trimmedOrigin = origin.trim(); + if (!trimmedOrigin) { + setValidationMessage("Enter a git URL or local path."); + return; + } + const trimmedName = name.trim(); + const trimmedRef = ref.trim(); + onAdd({ + origin: trimmedOrigin, + ...(trimmedName ? { name: trimmedName } : {}), + ...(trimmedRef ? { ref: trimmedRef } : {}), + }); + }; + + return ( + + +
+ + Add app source + + Install every app from a git repo and keep them updated with + manual syncs. Apps from a source serve browser code and inject + agent skills — only add repos you trust. + + +
+
+ + { + setOrigin(event.target.value); + setValidationMessage(null); + }} + /> +
+
+ + setName(event.target.value)} + /> +
+
+ + setRef(event.target.value)} + /> +
+ {validationMessage ? ( +

{validationMessage}

+ ) : null} +
+ + + +
+
+
+ ); +} + +interface ConfirmAppSourceActionDialogProps { + target: AppSourceStatus | null; + title: string; + description: string; + confirmLabel: string; + pending: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (source: AppSourceStatus) => void; +} + +function ConfirmAppSourceActionDialog({ + target, + title, + description, + confirmLabel, + pending, + onOpenChange, + onConfirm, +}: ConfirmAppSourceActionDialogProps) { + return ( + + + {target ? ( + <> + + {title} + {description} + + + + + + ) : null} + + + ); +} + +function formatLastSynced(source: AppSourceStatus): string { + if (source.syncing) { + return "Syncing…"; + } + if (source.lastSyncedAt === null) { + return "Never synced"; + } + return `Synced ${timeAgo(Date.parse(source.lastSyncedAt))}`; +} + +export function AppSourcesSection() { + const { data: sources = [], isLoading } = useAppSources(); + const addAppSource = useAddAppSource(); + const syncAppSource = useSyncAppSource(); + const removeAppSource = useRemoveAppSource(); + const [addOpen, setAddOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState( + null, + ); + const [forceSyncTarget, setForceSyncTarget] = + useState(null); + + return ( + setAddOpen(true)}> + Add source + + } + > + {isLoading ? ( +

Loading…

+ ) : ( + + {sources.length === 0 ? ( + + + No app sources. Add a git repo of apps to install them. + + + ) : ( + sources.map((source) => ( + +
+
+ + {source.name} + + {source.ref !== null ? ( + {source.ref} + ) : null} + + {formatLastSynced(source)} + {source.lastCommitSha !== null + ? ` · ${source.lastCommitSha.slice(0, 8)}` + : null} + +
+

+ {source.origin} +

+ {source.lastError !== null ? ( +

+ {source.lastError} +

+ ) : null} + {source.apps.length > 0 ? ( +
+ {source.apps.map((app) => ( + + ))} +
+ ) : null} +
+ + + + + + + syncAppSource.mutate({ + name: source.name, + force: false, + }) + } + > + Sync now + + {source.apps.some((app) => app.status === "modified") ? ( + setForceSyncTarget(source)} + > + Force sync… + + ) : null} + setRemoveTarget(source)} + > + Remove… + + + +
+ )) + )} +
+ )} + + addAppSource.mutate(args, { onSuccess: () => setAddOpen(false) }) + } + /> + { + if (!open) setRemoveTarget(null); + }} + onConfirm={(source) => + removeAppSource.mutate(source.name, { + onSuccess: () => setRemoveTarget(null), + }) + } + /> + { + if (!open) setForceSyncTarget(null); + }} + onConfirm={(source) => + syncAppSource.mutate( + { name: source.name, force: true }, + { onSuccess: () => setForceSyncTarget(null) }, + ) + } + /> +
+ ); +} diff --git a/apps/app/src/components/sidebar/ProjectList.test.tsx b/apps/app/src/components/sidebar/ProjectList.test.tsx index 721bebd32..6f3283d8c 100644 --- a/apps/app/src/components/sidebar/ProjectList.test.tsx +++ b/apps/app/src/components/sidebar/ProjectList.test.tsx @@ -163,6 +163,7 @@ function makeApp({ applicationId, name, icon }: MakeAppArgs): AppSummary { entry: { path: "index.html", kind: "html" }, capabilities: [], icon, + source: null, }; } diff --git a/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx b/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx index 409d23fb8..d09161ea2 100644 --- a/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx +++ b/apps/app/src/components/sidebar/SidebarAppsSection.test.tsx @@ -13,6 +13,7 @@ const APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, { applicationId: "beta", @@ -20,6 +21,7 @@ const APPS: AppSummary[] = [ entry: { path: "index.html", kind: "html" }, capabilities: ["data"], icon: { kind: "builtin", name: "GridView" }, + source: null, }, ]; diff --git a/apps/app/src/components/sidebar/SidebarAppsSection.tsx b/apps/app/src/components/sidebar/SidebarAppsSection.tsx index 4121b987c..68f5e8610 100644 --- a/apps/app/src/components/sidebar/SidebarAppsSection.tsx +++ b/apps/app/src/components/sidebar/SidebarAppsSection.tsx @@ -2,6 +2,7 @@ import { memo, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import type { AppSummary } from "@bb/server-contract"; import { ResolvedAppIcon } from "@/components/secondary-panel/AppIcon"; +import { Icon } from "@/components/ui/icon.js"; import { COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, COARSE_POINTER_GLYPH_BOX_CLASS, @@ -70,6 +71,15 @@ const SidebarAppRow = memo(function SidebarAppRow({ /> {app.name} + {app.source !== null ? ( + // Source-managed apps update on source sync; the glyph marks them as + // externally owned without taking row space from the name. +