From 6d0e054515214a1013c6cd33e5cb71e5f6e7a782 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Tue, 3 Nov 2026 16:42:08 +0000 Subject: [PATCH 1/2] =?UTF-8?q?team-setup:=20data=20profiles=20=E2=80=94?= =?UTF-8?q?=20RPCs=20and=20Advanced=20switch=20section=20(BB-224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/team-setup/README.md | 43 +++ plugins/team-setup/app.test.ts | 2 +- plugins/team-setup/app.tsx | 265 ++++++++++++++++++ plugins/team-setup/components/ui/badge.tsx | 15 + plugins/team-setup/components/ui/dialog.tsx | 35 +++ .../team-setup/components/ui/disclosure.tsx | 38 +++ plugins/team-setup/components/ui/input.tsx | 19 ++ plugins/team-setup/profile-switch.test.ts | 95 +++++++ plugins/team-setup/profile-switch.ts | 20 ++ plugins/team-setup/profiles-contract.ts | 35 +++ plugins/team-setup/profiles-name.ts | 18 ++ plugins/team-setup/profiles.ts | 181 ++++++++++++ plugins/team-setup/server-update.ts | 56 ++-- plugins/team-setup/server.test.ts | 94 +++++++ plugins/team-setup/server.ts | 17 ++ 15 files changed, 913 insertions(+), 20 deletions(-) create mode 100644 plugins/team-setup/components/ui/badge.tsx create mode 100644 plugins/team-setup/components/ui/dialog.tsx create mode 100644 plugins/team-setup/components/ui/disclosure.tsx create mode 100644 plugins/team-setup/components/ui/input.tsx create mode 100644 plugins/team-setup/profile-switch.test.ts create mode 100644 plugins/team-setup/profile-switch.ts create mode 100644 plugins/team-setup/profiles-contract.ts create mode 100644 plugins/team-setup/profiles-name.ts create mode 100644 plugins/team-setup/profiles.ts diff --git a/plugins/team-setup/README.md b/plugins/team-setup/README.md index d22f4df8..3277aeca 100644 --- a/plugins/team-setup/README.md +++ b/plugins/team-setup/README.md @@ -98,6 +98,49 @@ the page when the server is back. A bb that is not behind bb-gate gets a on bb's own host, so the request carries the tailnet assertion the gateway adds, and the plugin's server side knows nothing about it. +## Data profiles + +A bb-gate server's one data volume can hold several data sets, switchable +from the "Advanced" disclosure at the foot of this plugin's settings page. +It is for testing or filming onboarding: create a `demo` profile, switch to +it for an empty server, and switch back to `default` for the real one. Most +people never need it. + +The bb-gate entrypoint owns the layout. The volume root is the `default` +profile and every other profile is a directory under `profiles//`; a +one-line `active-profile` file at the root names the active one. The +entrypoint exports `BB_GATE_PROFILES_DIR` and `BB_GATE_ACTIVE_PROFILE_FILE` +into this plugin's server process. The plugin registers four RPCs only when +both are set: + +| RPC | What it does | +|---|---| +| `profiles.list` | `{ active, profiles: [{ name, booted, bytes }] }`; `booted` is "has a `bb.db`", sizes are computed on request and `profiles/` is left out of `default`'s. | +| `profiles.create { name }` | Validates the name, refuses an existing one, creates the empty directory. | +| `profiles.delete { name }` | Refuses `default` and the active profile; removes the directory, never following a symlink. | +| `profiles.activate { name }` | Refuses an unknown name, writes `active-profile`, returns `{ booted }`. | + +A profile name is a lowercase DNS label of 1 to 32 characters, checked before +it becomes a path, and the resolved path is refused if it escapes +`profiles/`. + +Switching calls `profiles.activate` and then `POST`s the router's +`/_bb-gate/restart`, with `?first_visit=1` for a profile that has never +booted so the first browser visit lands on Team setup, then follows the +restart through `/_bb-gate/update` exactly as the Server update section does. +The router rolls only the caller's own Deployment. + +What does not follow a profile: machines are enrolled per profile, so a +devbox stays attached to the profile that made it and gets 401 from any +other until switched back; Linear, Slack, hooks leases and every steward +thread live in the profile's database, so a fresh profile has no subscribers +until it connects. Disk is one shared 20Gi volume with no cap on the number +of profiles. + +A server not behind bb-gate, or on an image older than this layout, has no +`BB_GATE_PROFILES_DIR`, so the RPCs are absent and the Advanced section +renders nothing. + ## Development ```sh diff --git a/plugins/team-setup/app.test.ts b/plugins/team-setup/app.test.ts index 07ebff1f..24dc7126 100644 --- a/plugins/team-setup/app.test.ts +++ b/plugins/team-setup/app.test.ts @@ -32,7 +32,7 @@ describe("registration", () => { it("registers the checklist homepage section and the settings sections", () => { const { homepageSections, settingsSections } = capture(); expect(homepageSections.map((section) => section.id)).toEqual(["checklist"]); - expect(settingsSections.map((section) => section.id)).toEqual(["server-update", "checklist"]); + expect(settingsSections.map((section) => section.id)).toEqual(["server-update", "checklist", "advanced"]); }); // BB-174's Get started nav panel and redirect overlay were removed in diff --git a/plugins/team-setup/app.tsx b/plugins/team-setup/app.tsx index f9559fa4..fd9dabcf 100644 --- a/plugins/team-setup/app.tsx +++ b/plugins/team-setup/app.tsx @@ -7,10 +7,17 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { definePluginApp, useBbNavigate, useRealtime, useRpc } from "@get-bb/plugin-sdk/app"; import type { rpcContract } from "./server"; +import type { profilesRpcContract } from "./profiles-contract.js"; +import { validProfileName, type ProfileRow } from "./profiles-name.js"; +import { switchProfile } from "./profile-switch.js"; import { browserDeps, createUpdateStore, type UpdateStore } from "./server-update"; import { CreateProjectEntry } from "./create-project-entry.js"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Disclosure } from "@/components/ui/disclosure"; import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; type Status = Awaited>["call"]>> extends infer R ? Extract @@ -778,6 +785,258 @@ function ServerUpdateSection() { ); } +// ---- Data profiles (Advanced) --------------------------------------------- + +interface ProfilesList { + active: string; + profiles: ProfileRow[]; +} + +type ProfilesRpc = ReturnType>; + +function sortProfiles(profiles: ProfileRow[], active: string): ProfileRow[] { + return [...profiles].sort((a, b) => (a.name === active ? -1 : b.name === active ? 1 : a.name.localeCompare(b.name))); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`; +} + +function ProfilesPanel({ rpc, list, setList }: { rpc: ProfilesRpc; list: ProfilesList; setList: (list: ProfilesList) => void }) { + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [switchTarget, setSwitchTarget] = useState(null); + const [deleting, setDeleting] = useState(null); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(null); + + const trimmed = name.trim(); + const nameValid = validProfileName(trimmed); + const nameTaken = list.profiles.some((profile) => profile.name === trimmed); + const canCreate = nameValid && !nameTaken && !creating; + + const create = async () => { + if (!canCreate) return; + setCreating(true); + setCreateError(null); + try { + const row = await rpc.call("profiles.create", { name: trimmed }); + setList({ active: list.active, profiles: sortProfiles([...list.profiles, row], list.active) }); + setName(""); + } catch (error) { + setCreateError(message(error)); + } finally { + setCreating(false); + } + }; + + const confirmSwitch = async (target: ProfileRow) => { + setBusy(true); + setActionError(null); + try { + await switchProfile({ ...browserDeps(), activate: (value) => rpc.call("profiles.activate", { name: value }) }, target.name); + } catch (error) { + setActionError(message(error)); + setBusy(false); + setSwitchTarget(null); + } + }; + + const confirmDelete = async (target: ProfileRow) => { + setBusy(true); + setActionError(null); + try { + await rpc.call("profiles.delete", { name: target.name }); + setList({ active: list.active, profiles: list.profiles.filter((profile) => profile.name !== target.name) }); + setDeleting(null); + } catch (error) { + setActionError(message(error)); + setDeleting(null); + } finally { + setBusy(false); + } + }; + + return ( +
+
    + {list.profiles.map((profile) => ( +
  • + + {profile.name} + {profile.name === list.active ? Active : !profile.booted ? Never started : null} + + + {formatBytes(profile.bytes)} + + {profile.name !== list.active ? ( + + ) : null} + {profile.name !== list.active && profile.name !== "default" ? ( + + ) : null} + + +
  • + ))} +
+ +
{ + event.preventDefault(); + void create(); + }} + > +
+ { + setName(event.target.value); + setCreateError(null); + }} + /> + +
+ {trimmed !== "" && !nameValid ? ( +

Use a lowercase DNS label: letters, digits and hyphens, 1–32 characters.

+ ) : nameTaken ? ( +

“{trimmed}” already exists.

+ ) : null} + {createError !== null ?

{createError}

: null} +
+ + {actionError !== null ?

{actionError}

: null} + + { + if (!busy) setSwitchTarget(null); + }} + footer={ + <> + + + + } + > + {switchTarget !== null ? ( + <> +

The server restarts in under a minute and any running turn is interrupted.

+

Machines and connections stay with the profile that made them.

+ {!switchTarget.booted ?

This profile has never started, so it opens on Team setup.

: null} + + ) : null} +
+ + { + if (!busy) setDeleting(null); + }} + footer={ + <> + + + + } + > + {deleting !== null ? ( +

+ {deleting.name} is {formatBytes(deleting.bytes)}. Deleting it cannot be undone. +

+ ) : null} +
+
+ ); +} + +/** The settings section: switch this server between data sets. It renders + * nothing on a server whose entrypoint does not export the profile layout + * (not behind bb-gate, or an older image), which it learns from the first + * profiles.list. */ +function AdvancedSection() { + const rpc = useRpc(); + const [open, setOpen] = useState(false); + const [list, setList] = useState(null); + const [absent, setAbsent] = useState(false); + + useEffect(() => { + let cancelled = false; + rpc.call("profiles.list").then( + (result) => { + if (!cancelled) setList(result); + }, + () => { + // No profile layout: not behind bb-gate, or an older image. + if (!cancelled) setAbsent(true); + }, + ); + return () => { + cancelled = true; + }; + }, [rpc]); + + if (absent) return null; + return ( + + {list === null ? ( +

Loading…

+ ) : ( + + )} +
+ ); +} + const CHANGED_CHANNEL = "changed"; export default definePluginApp((app) => { @@ -803,4 +1062,10 @@ export default definePluginApp((app) => { description: "Everything a new server needs before agents can work here.", component: () => , }); + app.slots.settingsSection({ + id: "advanced", + title: "Advanced", + description: "Switch this server between data sets; for testing onboarding.", + component: AdvancedSection, + }); }); diff --git a/plugins/team-setup/components/ui/badge.tsx b/plugins/team-setup/components/ui/badge.tsx new file mode 100644 index 00000000..630a04cf --- /dev/null +++ b/plugins/team-setup/components/ui/badge.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +export function Badge({ className, ...props }: React.HTMLAttributes) { + return ( + + ); +} diff --git a/plugins/team-setup/components/ui/dialog.tsx b/plugins/team-setup/components/ui/dialog.tsx new file mode 100644 index 00000000..f6763343 --- /dev/null +++ b/plugins/team-setup/components/ui/dialog.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; + +/** A small confirm dialog. A native would need a ref and showModal; + * this is the two pieces the Advanced section needs, with the usual dialog + * semantics. */ +export function Dialog({ + open, + title, + onClose, + children, + footer, +}: { + open: boolean; + title: string; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; +}) { + if (!open) return null; + return ( +
+
event.stopPropagation()} + > +

{title}

+
{children}
+ {footer !== undefined ?
{footer}
: null} +
+
+ ); +} diff --git a/plugins/team-setup/components/ui/disclosure.tsx b/plugins/team-setup/components/ui/disclosure.tsx new file mode 100644 index 00000000..36f1f958 --- /dev/null +++ b/plugins/team-setup/components/ui/disclosure.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { Icon } from "./icon"; + +/** A chevron disclosure: collapsed by default, with its open state held by + * the caller so it can fetch on first open and keep the state for the + * session. */ +export function Disclosure({ + title, + description, + open, + onOpenChange, + children, +}: { + title: string; + description?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; +}) { + return ( +
+ + {open ?
{children}
: null} +
+ ); +} diff --git a/plugins/team-setup/components/ui/input.tsx b/plugins/team-setup/components/ui/input.tsx new file mode 100644 index 00000000..17b2175c --- /dev/null +++ b/plugins/team-setup/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { cn } from "../../lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef(({ className, ...props }, ref) => ( + +)); +Input.displayName = "Input"; + +export { Input }; diff --git a/plugins/team-setup/profile-switch.test.ts b/plugins/team-setup/profile-switch.test.ts new file mode 100644 index 00000000..c8aa8d3d --- /dev/null +++ b/plugins/team-setup/profile-switch.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { switchProfile } from "./profile-switch.js"; +import { RESTART_PATH, RESTART_POLL_MS, UPDATE_PATH, type UpdateStoreDeps } from "./server-update.js"; + +// A router and a clock the test drives. Timers fire only when `advance` is +// called, so each step is explicit. +function harness(answers: Array<{ status: number; body?: unknown }>) { + const calls: Array<{ method: string; url: string }> = []; + const timers: Array<{ at: number; fn: () => void }> = []; + let now = 0; + let reloaded = 0; + const deps: UpdateStoreDeps = { + fetch: async (input, init) => { + calls.push({ method: init?.method ?? "GET", url: input }); + const a = answers.shift() ?? { status: 500 }; + return new Response(a.body === undefined ? null : JSON.stringify(a.body), { + status: a.status, + headers: { "Content-Type": "application/json" }, + }); + }, + setTimeout: (fn, ms) => { + const t = { at: now + ms, fn }; + timers.push(t); + return t; + }, + clearTimeout: (h) => { + const i = timers.indexOf(h as { at: number; fn: () => void }); + if (i >= 0) timers.splice(i, 1); + }, + now: () => now, + reload: () => reloaded++, + visible: () => true, + }; + const flush = () => new Promise((r) => setTimeout(r, 0)); + const advance = async (ms: number) => { + now += ms; + for (;;) { + const due = timers.filter((t) => t.at <= now).sort((a, b) => a.at - b.at)[0]; + if (!due) break; + timers.splice(timers.indexOf(due), 1); + due.fn(); + await flush(); + } + }; + return { deps, calls, advance, flush, reloaded: () => reloaded }; +} + +describe("profile switch", () => { + it("activates, restarts with first_visit for a never-started profile, and follows", async () => { + const h = harness([ + { status: 200, body: { restarted: true } }, + { status: 200, body: { pending: false, ready: true } }, + { status: 200, body: { pending: false, ready: false } }, + ]); + const activated: string[] = []; + const running = switchProfile( + { + ...h.deps, + activate: async (name) => { + activated.push(name); + return { booted: false }; + }, + }, + "demo", + ); + await h.flush(); + expect(activated).toEqual(["demo"]); + expect(h.calls[0]).toEqual({ method: "POST", url: `${RESTART_PATH}?first_visit=1` }); + await h.advance(RESTART_POLL_MS); + await h.advance(RESTART_POLL_MS); + await running; + expect(h.calls[1]).toEqual({ method: "GET", url: UPDATE_PATH }); + expect(h.reloaded()).toBe(1); + }); + + it("does not set first_visit for a profile that has booted", async () => { + const h = harness([ + { status: 200, body: { restarted: true } }, + { status: 200, body: { pending: false, ready: false } }, + ]); + const running = switchProfile({ ...h.deps, activate: async () => ({ booted: true }) }, "default"); + await h.flush(); + expect(h.calls[0]).toEqual({ method: "POST", url: RESTART_PATH }); + await h.advance(RESTART_POLL_MS); + await running; + expect(h.reloaded()).toBe(1); + }); + + it("reports a refused restart and does not follow", async () => { + const h = harness([{ status: 403 }]); + await expect(switchProfile({ ...h.deps, activate: async () => ({ booted: false }) }, "demo")).rejects.toThrow(/403/u); + expect(h.calls).toHaveLength(1); + expect(h.reloaded()).toBe(0); + }); +}); diff --git a/plugins/team-setup/profile-switch.ts b/plugins/team-setup/profile-switch.ts new file mode 100644 index 00000000..9d755701 --- /dev/null +++ b/plugins/team-setup/profile-switch.ts @@ -0,0 +1,20 @@ +// The data-profile switch sequence: activate the profile through the +// plugin's own RPC, then ask the router to roll this server onto it, then +// hand over to the update store's restart follow-through so the page reloads +// onto the gate's starting page. Kept out of app.tsx so it can be tested +// without a DOM, alongside server-update.ts. +import { followRestart, gateStatus, RESTART_PATH, type UpdateStoreDeps } from "./server-update.js"; + +export interface SwitchDeps extends UpdateStoreDeps { + activate: (name: string) => Promise<{ booted: boolean }>; +} + +export async function switchProfile(deps: SwitchDeps, name: string): Promise { + const { booted } = await deps.activate(name); + // A never-started profile opens on Team setup once, as a brand-new server + // does; a profile that has booted keeps its own state and never redirects. + const url = booted ? RESTART_PATH : `${RESTART_PATH}?first_visit=1`; + const r = await deps.fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + if (!r.ok) throw new Error(`bb-gate answered ${r.status}`); + await followRestart(deps, () => gateStatus(deps.fetch)); +} diff --git a/plugins/team-setup/profiles-contract.ts b/plugins/team-setup/profiles-contract.ts new file mode 100644 index 00000000..fb23c791 --- /dev/null +++ b/plugins/team-setup/profiles-contract.ts @@ -0,0 +1,35 @@ +// The data-profiles RPC contract, shared by team-setup's server entry (which +// implements it) and its app entry (which calls it). +// +// The methods are registered only on a server whose entrypoint set +// BB_GATE_PROFILES_DIR and BB_GATE_ACTIVE_PROFILE_FILE. A server that is not +// behind bb-gate, or is on an image older than the profile layout, has no +// such method and the Advanced section renders nothing. +import { z } from "zod"; + +export const profileRowSchema = z.object({ + name: z.string(), + booted: z.boolean(), + bytes: z.number().int().min(0), +}); + +// Kept a plain object rather than defineRpcContract so the app entry can +// import it as a type without pulling the server SDK into the browser bundle. +export const profilesRpcContract = { + "profiles.list": { + input: z.null(), + output: z.object({ active: z.string(), profiles: z.array(profileRowSchema) }), + }, + "profiles.create": { + input: z.object({ name: z.string() }).strict(), + output: profileRowSchema, + }, + "profiles.delete": { + input: z.object({ name: z.string() }).strict(), + output: z.object({ ok: z.boolean() }), + }, + "profiles.activate": { + input: z.object({ name: z.string() }).strict(), + output: z.object({ booted: z.boolean() }), + }, +} as const; diff --git a/plugins/team-setup/profiles-name.ts b/plugins/team-setup/profiles-name.ts new file mode 100644 index 00000000..486759da --- /dev/null +++ b/plugins/team-setup/profiles-name.ts @@ -0,0 +1,18 @@ +// The data-profile name rule, kept free of zod so the app entry can import it +// at runtime without pulling the contract (or zod) into the browser bundle. +export const DEFAULT_PROFILE = "default"; +export const PROFILE_NAME_MAX = 32; +// A lowercase DNS label: starts and ends alphanumeric, hyphens inside. The +// name becomes a path segment, so anything else is refused. +export const PROFILE_NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +export function validProfileName(name: string): boolean { + return name.length >= 1 && name.length <= PROFILE_NAME_MAX && PROFILE_NAME_RE.test(name); +} + +export interface ProfileRow { + name: string; + // "has a bb.db": the profile has booted at least once. + booted: boolean; + bytes: number; +} diff --git a/plugins/team-setup/profiles.ts b/plugins/team-setup/profiles.ts new file mode 100644 index 00000000..632d3bc8 --- /dev/null +++ b/plugins/team-setup/profiles.ts @@ -0,0 +1,181 @@ +// The server side of data profiles: the filesystem layout the bb-gate +// entrypoint sets up. The volume root is the `default` profile's data dir +// and every other profile lives under profiles/; active-profile at the +// root names the active one. Everything here treats a profile name as a path +// segment only after the DNS-label check, resolves the path inside the +// profiles directory, and never follows a symlink out of the tree. +import { lstat, mkdir, readdir, readFile, realpath, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { DEFAULT_PROFILE, PROFILE_NAME_MAX, type ProfileRow, validProfileName } from "./profiles-name.js"; + +export interface ProfilesLayout { + profilesDir: string; + activeProfileFile: string; +} + +// profilesLayout returns the two paths the entrypoint exports, or null on a +// server that does not have the layout (not behind bb-gate, or an older +// image). The RPCs are registered only when it is non-null. +export function profilesLayout(env: Record): ProfilesLayout | null { + const profilesDir = env.BB_GATE_PROFILES_DIR?.trim(); + const activeProfileFile = env.BB_GATE_ACTIVE_PROFILE_FILE?.trim(); + if (!profilesDir || !activeProfileFile) return null; + return { profilesDir, activeProfileFile }; +} + +function assertName(name: string): void { + if (!validProfileName(name)) { + throw new Error(`"${name}" is not a lowercase DNS label of 1 to ${PROFILE_NAME_MAX} characters`); + } +} + +// profileDir is the data dir for name: the volume root for `default`, and +// profilesDir/name otherwise. assertName guarantees the name cannot contain a +// slash or `..`, so it stays one segment below profilesDir. +export function profileDir(layout: ProfilesLayout, name: string): string { + assertName(name); + if (name === DEFAULT_PROFILE) return resolve(layout.profilesDir, ".."); + return join(resolve(layout.profilesDir), name); +} + +function inside(root: string, target: string): boolean { + return target === root || target.startsWith(root + sep); +} + +// resolvesInside reports whether dir resolves to a path inside profilesDir. +// A symlink pointing elsewhere is not inside. +async function resolvesInside(layout: ProfilesLayout, dir: string): Promise { + const root = await realpath(layout.profilesDir).catch(() => null); + if (root === null) return false; + const target = await realpath(dir).catch(() => null); + if (target === null) return false; + return inside(root, target); +} + +async function exists(path: string): Promise { + try { + await lstat(path); + return true; + } catch { + return false; + } +} + +// readActiveProfile is the active name, falling back to `default` for a +// missing, unreadable or invalid file, or a profile whose directory is gone +// or resolves outside the tree. +export async function readActiveProfile(layout: ProfilesLayout): Promise { + let raw: string; + try { + raw = (await readFile(layout.activeProfileFile, "utf8")).trim(); + } catch { + return DEFAULT_PROFILE; + } + if (!validProfileName(raw) || raw === DEFAULT_PROFILE) return DEFAULT_PROFILE; + if (!(await resolvesInside(layout, profileDir(layout, raw)))) return DEFAULT_PROFILE; + return raw; +} + +// dirSize sums regular files under dir, skipping symlinks (never following +// them) and any entry named in skip. +async function dirSize(dir: string, skip: ReadonlySet = new Set()): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return 0; + } + let total = 0; + for (const entry of entries) { + if (skip.has(entry.name) || entry.isSymbolicLink()) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + total += await dirSize(path); + } else if (entry.isFile()) { + try { + total += (await stat(path)).size; + } catch { + // Vanished between readdir and stat. + } + } + } + return total; +} + +// listProfiles reports the active profile and every profile with its booted +// state and size. `default` is the volume root, and its size leaves +// profiles/ out so it does not include the other profiles. +export async function listProfiles(layout: ProfilesLayout): Promise<{ active: string; profiles: ProfileRow[] }> { + const active = await readActiveProfile(layout); + const names = new Set([DEFAULT_PROFILE, active]); + try { + for (const entry of await readdir(layout.profilesDir, { withFileTypes: true })) { + if (entry.name === DEFAULT_PROFILE || !validProfileName(entry.name)) continue; + if (!(await resolvesInside(layout, join(resolve(layout.profilesDir), entry.name)))) continue; + names.add(entry.name); + } + } catch { + // No profiles directory yet: default alone. + } + const profiles = await Promise.all( + [...names].map(async (name): Promise => { + const dir = profileDir(layout, name); + return { + name, + booted: await exists(join(dir, "bb.db")), + bytes: name === DEFAULT_PROFILE ? await dirSize(dir, new Set(["profiles"])) : await dirSize(dir), + }; + }), + ); + // Active first, then by name, so the list is stable across a switch. + profiles.sort((a, b) => (a.name === active ? -1 : b.name === active ? 1 : a.name.localeCompare(b.name))); + return { active, profiles }; +} + +export async function createProfile(layout: ProfilesLayout, name: string): Promise { + assertName(name); + if (name === DEFAULT_PROFILE) throw new Error(`the "${DEFAULT_PROFILE}" profile already exists`); + const dir = profileDir(layout, name); + if (await exists(dir)) throw new Error(`a profile named "${name}" already exists`); + await mkdir(resolve(layout.profilesDir), { recursive: true }); + try { + await mkdir(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`a profile named "${name}" already exists`); + throw error; + } + return { name, booted: false, bytes: 0 }; +} + +export async function deleteProfile(layout: ProfilesLayout, name: string): Promise<{ ok: true }> { + assertName(name); + if (name === DEFAULT_PROFILE) throw new Error(`the "${DEFAULT_PROFILE}" profile cannot be deleted`); + if (name === (await readActiveProfile(layout))) throw new Error(`"${name}" is the active profile`); + const dir = profileDir(layout, name); + let info; + try { + info = await lstat(dir); + } catch { + throw new Error(`no profile named "${name}"`); + } + if (info.isSymbolicLink()) { + // Remove the link itself, never what it points at. + await unlink(dir); + } else if (info.isDirectory()) { + await rm(dir, { recursive: true, force: true }); + } else { + await unlink(dir); + } + return { ok: true }; +} + +export async function activateProfile(layout: ProfilesLayout, name: string): Promise<{ booted: boolean }> { + assertName(name); + const dir = profileDir(layout, name); + if (name !== DEFAULT_PROFILE && !(await resolvesInside(layout, dir))) { + throw new Error(`no profile named "${name}"`); + } + const booted = await exists(join(dir, "bb.db")); + await writeFile(layout.activeProfileFile, `${name}\n`); + return { booted }; +} diff --git a/plugins/team-setup/server-update.ts b/plugins/team-setup/server-update.ts index ce046c43..a2f40269 100644 --- a/plugins/team-setup/server-update.ts +++ b/plugins/team-setup/server-update.ts @@ -12,6 +12,9 @@ // per open thread and the home page section all read it. export const UPDATE_PATH = "/_bb-gate/update"; +// The data-profile switch rolls the server through the router's restart +// endpoint, which acts on the caller's own Deployment. +export const RESTART_PATH = "/_bb-gate/restart"; export const POLL_MS = 60_000; export const RESTART_POLL_MS = 500; // A restart the Deployment has not reported within this long is not one the @@ -59,6 +62,38 @@ function parseStatus(v: unknown): GateStatus | null { return { pending, ready }; } +// gateStatus asks the router for the caller's own server. A 404 or a +// non-JSON body means this bb is not behind bb-gate (or is on an older +// router), so the caller hides its gate-driven UI. +export const gateStatus = async (fetchFn: UpdateStoreDeps["fetch"], path = UPDATE_PATH): Promise => { + const r = await fetchFn(path, { headers: { Accept: "application/json" } }); + if (r.status === 404) return "unavailable"; + if (!r.ok) throw new Error(`bb-gate answered ${r.status}`); + const s = parseStatus(await r.json().catch(() => null)); + if (s === null) return "unavailable"; + return s; +}; + +// followRestart polls until the Deployment reports the old pod gone, then +// reloads onto the router's starting page, which reloads itself until the +// new pod answers. It is shared by the update apply and the profile switch. +export async function followRestart( + deps: UpdateStoreDeps, + get: () => Promise, +): Promise { + const started = deps.now(); + while (deps.now() - started < RESTART_TIMEOUT_MS) { + await new Promise((resolve) => deps.setTimeout(resolve, RESTART_POLL_MS)); + try { + const s = await get(); + if (s === "unavailable" || !s.ready) break; + } catch { + // The router itself is briefly away, or the gateway is; keep waiting. + } + } + deps.reload(); +} + export function createUpdateStore(deps: UpdateStoreDeps): UpdateStore { let state: UpdateState = { status: "unknown" }; const listeners = new Set<() => void>(); @@ -70,14 +105,7 @@ export function createUpdateStore(deps: UpdateStoreDeps): UpdateStore { for (const l of listeners) l(); }; - const get = async (): Promise => { - const r = await deps.fetch(UPDATE_PATH, { headers: { Accept: "application/json" } }); - if (r.status === 404) return "unavailable"; - if (!r.ok) throw new Error(`bb-gate answered ${r.status}`); - const s = parseStatus(await r.json().catch(() => null)); - if (s === null) return "unavailable"; - return s; - }; + const get = (): Promise => gateStatus(deps.fetch); const refresh = async () => { if (state.status === "restarting") return; @@ -118,17 +146,7 @@ export function createUpdateStore(deps: UpdateStoreDeps): UpdateStore { // from landing on the old pod in its last moments and loading the full // app only to lose it again. const follow = async () => { - const started = deps.now(); - while (deps.now() - started < RESTART_TIMEOUT_MS) { - await new Promise((resolve) => deps.setTimeout(resolve, RESTART_POLL_MS)); - try { - const s = await get(); - if (s === "unavailable" || !s.ready) break; - } catch { - // The router itself is briefly away, or the gateway is; keep waiting. - } - } - deps.reload(); + await followRestart(deps, get); }; const apply = async () => { diff --git a/plugins/team-setup/server.test.ts b/plugins/team-setup/server.test.ts index 2e12b280..311a761f 100644 --- a/plugins/team-setup/server.test.ts +++ b/plugins/team-setup/server.test.ts @@ -1,4 +1,7 @@ import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import { mkdir, mkdtemp, readdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { GhProcess, GhResult, GhRunner } from "./gh.js"; import { ACCOUNT_POOL_ID, CHANGED, createTeamSetupPlugin, DEVBOX_ID, LINEAR_ID, LOGIN_TTL_MS, SEEDED_NOTE, SLACK_ID } from "./server.js"; @@ -520,3 +523,94 @@ describe("machine environment seeding", () => { }); + +describe("data profiles", () => { + async function layout() { + const root = await mkdtemp(join(tmpdir(), "team-setup-profiles-")); + const profilesDir = join(root, "profiles"); + await mkdir(profilesDir, { recursive: true }); + return { root, profilesDir, activeProfileFile: join(root, "active-profile") }; + } + const envFor = (l: Awaited>) => ({ + BB_GATE_PROFILES_DIR: l.profilesDir, + BB_GATE_ACTIVE_PROFILE_FILE: l.activeProfileFile, + }); + + it("registers the profiles RPCs only when both env vars are set", async () => { + const off = await setup(); + expect(off.harness.registrations.rpcMethods).not.toContain("profiles.list"); + await expect(off.harness.callRpc("profiles.list", null)).rejects.toThrow(/no rpc method/u); + + const l = await layout(); + const on = await setup({ env: envFor(l) }); + expect(on.harness.registrations.rpcMethods).toEqual( + expect.arrayContaining(["profiles.list", "profiles.create", "profiles.delete", "profiles.activate"]), + ); + }); + + it("lists default and every profile with booted and size", async () => { + const l = await layout(); + await mkdir(join(l.profilesDir, "demo")); + await writeFile(join(l.profilesDir, "demo", "bb.db"), "x"); + await writeFile(join(l.profilesDir, "demo", "big"), "x".repeat(4096)); + const { harness } = await setup({ env: envFor(l) }); + const list = (await harness.callRpc("profiles.list", null)) as { + active: string; + profiles: Array<{ name: string; booted: boolean; bytes: number }>; + }; + expect(list.active).toBe("default"); + expect(list.profiles.map((p) => p.name)).toEqual(["default", "demo"]); + const demo = list.profiles.find((p) => p.name === "demo"); + const def = list.profiles.find((p) => p.name === "default"); + expect(demo?.booted).toBe(true); + expect(demo?.bytes).toBeGreaterThanOrEqual(4096); + // profiles/ is left out of default's size, so the other profile's bytes + // do not count towards it. + expect(def?.bytes).toBeLessThan(demo?.bytes ?? 0); + }); + + it("creates a profile and refuses a bad or existing name", async () => { + const l = await layout(); + const { harness } = await setup({ env: envFor(l) }); + expect(await harness.callRpc("profiles.create", { name: "demo" })).toEqual({ name: "demo", booted: false, bytes: 0 }); + await expect(harness.callRpc("profiles.create", { name: "demo" })).rejects.toThrow(/already exists/u); + for (const name of ["..", "a/b", "x".repeat(33), "Demo", "-demo", "default"]) { + await expect(harness.callRpc("profiles.create", { name })).rejects.toThrow(); + } + // Nothing escaped the profiles directory. + expect((await readdir(l.root)).sort()).toEqual(["profiles"]); + }); + + it("delete refuses default and the active profile, and does not follow a symlink", async () => { + const l = await layout(); + const { harness } = await setup({ env: envFor(l) }); + await harness.callRpc("profiles.create", { name: "demo" }); + await expect(harness.callRpc("profiles.delete", { name: "default" })).rejects.toThrow(/cannot be deleted/u); + await harness.callRpc("profiles.activate", { name: "demo" }); + await expect(harness.callRpc("profiles.delete", { name: "demo" })).rejects.toThrow(/active/u); + + // A symlinked profile dir is removed as a link; its target is untouched. + const outside = await mkdtemp(join(tmpdir(), "team-setup-outside-")); + await writeFile(join(outside, "keep"), "keep"); + await symlink(outside, join(l.profilesDir, "link"), "dir"); + expect(await harness.callRpc("profiles.delete", { name: "link" })).toEqual({ ok: true }); + expect(await readdir(outside)).toEqual(["keep"]); + + await harness.callRpc("profiles.activate", { name: "default" }); + expect(await harness.callRpc("profiles.delete", { name: "demo" })).toEqual({ ok: true }); + await expect(readdir(join(l.profilesDir, "demo"))).rejects.toThrow(); + }); + + it("activate refuses an unknown name, writes the file, and reports booted", async () => { + const l = await layout(); + const { harness } = await setup({ env: envFor(l) }); + await expect(harness.callRpc("profiles.activate", { name: "nope" })).rejects.toThrow(/no profile/u); + await expect(harness.callRpc("profiles.activate", { name: ".." })).rejects.toThrow(/DNS label/u); + await harness.callRpc("profiles.create", { name: "demo" }); + expect(await harness.callRpc("profiles.activate", { name: "demo" })).toEqual({ booted: false }); + expect((await readFile(l.activeProfileFile, "utf8")).trim()).toBe("demo"); + await writeFile(join(l.profilesDir, "demo", "bb.db"), "x"); + expect(await harness.callRpc("profiles.activate", { name: "demo" })).toEqual({ booted: true }); + expect(await harness.callRpc("profiles.activate", { name: "default" })).toEqual({ booted: false }); + }); +}); diff --git a/plugins/team-setup/server.ts b/plugins/team-setup/server.ts index 3320195f..d6d8c48c 100644 --- a/plugins/team-setup/server.ts +++ b/plugins/team-setup/server.ts @@ -29,6 +29,8 @@ import { type GhRunner, } from "./gh.js"; import { figmaHostContract, type FigmaHostState } from "./figma-contract.js"; +import { profilesRpcContract } from "./profiles-contract.js"; +import { activateProfile, createProfile, deleteProfile, listProfiles, profilesLayout } from "./profiles.js"; export const ACCOUNT_POOL_ID = "account-pool"; export const DEVBOX_ID = "devbox-provider"; @@ -681,6 +683,21 @@ export function createTeamSetupPlugin(deps: TeamSetupDeps): (bb: BbPluginApi) => return { url }; }, }); + + // ---- data profiles ---------------------------------------------------- + + // Registered only when the bb-gate entrypoint set both paths. A server + // that is not behind bb-gate, or is on an image older than the profile + // layout, has no such method and the Advanced section renders nothing. + const profiles = profilesLayout(deps.env); + if (profiles !== null) { + bb.rpc.register(profilesRpcContract, { + "profiles.list": () => listProfiles(profiles), + "profiles.create": ({ name }) => createProfile(profiles, name), + "profiles.delete": ({ name }) => deleteProfile(profiles, name), + "profiles.activate": ({ name }) => activateProfile(profiles, name), + }); + } }; } From c94249f3133cf79b816a32434705c23450a5f71e Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Tue, 3 Nov 2026 16:52:06 +0000 Subject: [PATCH 2/2] team-setup: guard the running profile on a failed switch; no host heading for Advanced (BB-224) --- plugins/team-setup/README.md | 8 +++- plugins/team-setup/app.test.ts | 6 +++ plugins/team-setup/app.tsx | 8 ++-- plugins/team-setup/profile-switch.test.ts | 45 +++++++++++++++++++++-- plugins/team-setup/profile-switch.ts | 31 ++++++++++++++-- plugins/team-setup/profiles.ts | 26 ++++++++----- plugins/team-setup/server.test.ts | 14 +++++++ plugins/team-setup/server.ts | 10 +++-- 8 files changed, 125 insertions(+), 23 deletions(-) diff --git a/plugins/team-setup/README.md b/plugins/team-setup/README.md index 3277aeca..b394ba7d 100644 --- a/plugins/team-setup/README.md +++ b/plugins/team-setup/README.md @@ -117,12 +117,16 @@ both are set: |---|---| | `profiles.list` | `{ active, profiles: [{ name, booted, bytes }] }`; `booted` is "has a `bb.db`", sizes are computed on request and `profiles/` is left out of `default`'s. | | `profiles.create { name }` | Validates the name, refuses an existing one, creates the empty directory. | -| `profiles.delete { name }` | Refuses `default` and the active profile; removes the directory, never following a symlink. | +| `profiles.delete { name }` | Refuses `default`, the active profile and the profile the server is actually running; removes the directory, never following a symlink. | | `profiles.activate { name }` | Refuses an unknown name, writes `active-profile`, returns `{ booted }`. | A profile name is a lowercase DNS label of 1 to 32 characters, checked before it becomes a path, and the resolved path is refused if it escapes -`profiles/`. +`profiles/`. The server records the profile it booted on; `list` reports that +as active and `delete` refuses it as well as `active-profile`, so a switch +whose restart failed cannot leave the live data dir deletable. `switchProfile` +also puts `active-profile` back when the restart is refused or unreachable, +so a later unrelated restart does not land the developer on the new data set. Switching calls `profiles.activate` and then `POST`s the router's `/_bb-gate/restart`, with `?first_visit=1` for a profile that has never diff --git a/plugins/team-setup/app.test.ts b/plugins/team-setup/app.test.ts index 24dc7126..4d784a49 100644 --- a/plugins/team-setup/app.test.ts +++ b/plugins/team-setup/app.test.ts @@ -33,6 +33,12 @@ describe("registration", () => { const { homepageSections, settingsSections } = capture(); expect(homepageSections.map((section) => section.id)).toEqual(["checklist"]); expect(settingsSections.map((section) => section.id)).toEqual(["server-update", "checklist", "advanced"]); + // The Advanced section draws its own heading and description in the + // disclosure, and renders nothing off-gate; the host must not draw a + // heading for it. + const advanced = settingsSections.find((section) => section.id === "advanced"); + expect(advanced?.title).toBeUndefined(); + expect(advanced?.description).toBeUndefined(); }); // BB-174's Get started nav panel and redirect overlay were removed in diff --git a/plugins/team-setup/app.tsx b/plugins/team-setup/app.tsx index fd9dabcf..3d961ed0 100644 --- a/plugins/team-setup/app.tsx +++ b/plugins/team-setup/app.tsx @@ -843,7 +843,11 @@ function ProfilesPanel({ rpc, list, setList }: { rpc: ProfilesRpc; list: Profile setBusy(true); setActionError(null); try { - await switchProfile({ ...browserDeps(), activate: (value) => rpc.call("profiles.activate", { name: value }) }, target.name); + await switchProfile( + { ...browserDeps(), activate: (value) => rpc.call("profiles.activate", { name: value }) }, + target.name, + list.active, + ); } catch (error) { setActionError(message(error)); setBusy(false); @@ -1064,8 +1068,6 @@ export default definePluginApp((app) => { }); app.slots.settingsSection({ id: "advanced", - title: "Advanced", - description: "Switch this server between data sets; for testing onboarding.", component: AdvancedSection, }); }); diff --git a/plugins/team-setup/profile-switch.test.ts b/plugins/team-setup/profile-switch.test.ts index c8aa8d3d..62f1beb6 100644 --- a/plugins/team-setup/profile-switch.test.ts +++ b/plugins/team-setup/profile-switch.test.ts @@ -62,6 +62,7 @@ describe("profile switch", () => { }, }, "demo", + "default", ); await h.flush(); expect(activated).toEqual(["demo"]); @@ -78,7 +79,7 @@ describe("profile switch", () => { { status: 200, body: { restarted: true } }, { status: 200, body: { pending: false, ready: false } }, ]); - const running = switchProfile({ ...h.deps, activate: async () => ({ booted: true }) }, "default"); + const running = switchProfile({ ...h.deps, activate: async () => ({ booted: true }) }, "default", "demo"); await h.flush(); expect(h.calls[0]).toEqual({ method: "POST", url: RESTART_PATH }); await h.advance(RESTART_POLL_MS); @@ -86,10 +87,48 @@ describe("profile switch", () => { expect(h.reloaded()).toBe(1); }); - it("reports a refused restart and does not follow", async () => { + it("puts the previous profile back when the restart is refused", async () => { const h = harness([{ status: 403 }]); - await expect(switchProfile({ ...h.deps, activate: async () => ({ booted: false }) }, "demo")).rejects.toThrow(/403/u); + const activated: string[] = []; + await expect( + switchProfile( + { + ...h.deps, + activate: async (name) => { + activated.push(name); + return { booted: false }; + }, + }, + "demo", + "default", + ), + ).rejects.toThrow(/403/u); + // The target, then the previous profile restored before the throw. + expect(activated).toEqual(["demo", "default"]); expect(h.calls).toHaveLength(1); expect(h.reloaded()).toBe(0); }); + + it("puts the previous profile back when the restart is unreachable", async () => { + const h = harness([]); + h.deps.fetch = async () => { + throw new Error("network down"); + }; + const activated: string[] = []; + await expect( + switchProfile( + { + ...h.deps, + activate: async (name) => { + activated.push(name); + return { booted: true }; + }, + }, + "demo", + "default", + ), + ).rejects.toThrow(/network down/u); + expect(activated).toEqual(["demo", "default"]); + expect(h.reloaded()).toBe(0); + }); }); diff --git a/plugins/team-setup/profile-switch.ts b/plugins/team-setup/profile-switch.ts index 9d755701..a97df650 100644 --- a/plugins/team-setup/profile-switch.ts +++ b/plugins/team-setup/profile-switch.ts @@ -9,12 +9,37 @@ export interface SwitchDeps extends UpdateStoreDeps { activate: (name: string) => Promise<{ booted: boolean }>; } -export async function switchProfile(deps: SwitchDeps, name: string): Promise { +// switchProfile activates name, restarts the server onto it and follows the +// restart. previous is the profile the server is running now: if the restart +// is refused or unreachable, the file is put back so a later, unrelated +// restart does not silently land the developer on the new data set. +export async function switchProfile(deps: SwitchDeps, name: string, previous: string): Promise { const { booted } = await deps.activate(name); // A never-started profile opens on Team setup once, as a brand-new server // does; a profile that has booted keeps its own state and never redirects. const url = booted ? RESTART_PATH : `${RESTART_PATH}?first_visit=1`; - const r = await deps.fetch(url, { method: "POST", headers: { Accept: "application/json" } }); - if (!r.ok) throw new Error(`bb-gate answered ${r.status}`); + let response: Response; + try { + response = await deps.fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + } catch (error) { + await restoreActive(deps, previous); + throw error; + } + if (!response.ok) { + await restoreActive(deps, previous); + throw new Error(`bb-gate answered ${response.status}`); + } await followRestart(deps, () => gateStatus(deps.fetch)); } + +// restoreActive is best effort: the restart may have failed before the +// activate call was observed, or the RPC may be unreachable; the next boot +// still falls back to default rather than stranding the server. +async function restoreActive(deps: SwitchDeps, previous: string): Promise { + try { + await deps.activate(previous); + } catch { + // Leave the file as it is; the entrypoint falls back to default if it is + // unreadable or names a missing profile. + } +} diff --git a/plugins/team-setup/profiles.ts b/plugins/team-setup/profiles.ts index 632d3bc8..9008a13c 100644 --- a/plugins/team-setup/profiles.ts +++ b/plugins/team-setup/profiles.ts @@ -102,12 +102,15 @@ async function dirSize(dir: string, skip: ReadonlySet = new Set()): Prom return total; } -// listProfiles reports the active profile and every profile with its booted -// state and size. `default` is the volume root, and its size leaves -// profiles/ out so it does not include the other profiles. -export async function listProfiles(layout: ProfilesLayout): Promise<{ active: string; profiles: ProfileRow[] }> { - const active = await readActiveProfile(layout); - const names = new Set([DEFAULT_PROFILE, active]); +// listProfiles reports the running profile and every profile with its +// booted state and size. `running` is the profile the server actually booted +// on (the entrypoint resolved it at start), which can differ from +// active-profile for a moment after a failed switch; reporting it as active +// keeps the UI from offering Delete on the live data dir. `default` is the +// volume root, and its size leaves profiles/ out so it does not include the +// other profiles. +export async function listProfiles(layout: ProfilesLayout, running: string): Promise<{ active: string; profiles: ProfileRow[] }> { + const names = new Set([DEFAULT_PROFILE, running]); try { for (const entry of await readdir(layout.profilesDir, { withFileTypes: true })) { if (entry.name === DEFAULT_PROFILE || !validProfileName(entry.name)) continue; @@ -128,8 +131,8 @@ export async function listProfiles(layout: ProfilesLayout): Promise<{ active: st }), ); // Active first, then by name, so the list is stable across a switch. - profiles.sort((a, b) => (a.name === active ? -1 : b.name === active ? 1 : a.name.localeCompare(b.name))); - return { active, profiles }; + profiles.sort((a, b) => (a.name === running ? -1 : b.name === running ? 1 : a.name.localeCompare(b.name))); + return { active: running, profiles }; } export async function createProfile(layout: ProfilesLayout, name: string): Promise { @@ -147,9 +150,14 @@ export async function createProfile(layout: ProfilesLayout, name: string): Promi return { name, booted: false, bytes: 0 }; } -export async function deleteProfile(layout: ProfilesLayout, name: string): Promise<{ ok: true }> { +// deleteProfile refuses default, the running profile and the one +// active-profile names. The last two differ only for a moment after a failed +// switch, and both must be protected: one is the live data dir, the other is +// what the next boot will use. +export async function deleteProfile(layout: ProfilesLayout, name: string, running: string): Promise<{ ok: true }> { assertName(name); if (name === DEFAULT_PROFILE) throw new Error(`the "${DEFAULT_PROFILE}" profile cannot be deleted`); + if (name === running) throw new Error(`"${name}" is the active profile`); if (name === (await readActiveProfile(layout))) throw new Error(`"${name}" is the active profile`); const dir = profileDir(layout, name); let info; diff --git a/plugins/team-setup/server.test.ts b/plugins/team-setup/server.test.ts index 311a761f..dfc39325 100644 --- a/plugins/team-setup/server.test.ts +++ b/plugins/team-setup/server.test.ts @@ -613,4 +613,18 @@ describe("data profiles", () => { expect(await harness.callRpc("profiles.activate", { name: "demo" })).toEqual({ booted: true }); expect(await harness.callRpc("profiles.activate", { name: "default" })).toEqual({ booted: false }); }); + + it("protects the running profile after a failed switch rewrote active-profile", async () => { + const l = await layout(); + await mkdir(join(l.profilesDir, "demo")); + await writeFile(l.activeProfileFile, "demo\n"); + // The server booted on demo, so that is the running profile. + const { harness } = await setup({ env: envFor(l) }); + // A switch to default whose restart failed left the file pointing at + // default while the server still runs demo. + await writeFile(l.activeProfileFile, "default\n"); + const list = (await harness.callRpc("profiles.list", null)) as { active: string }; + expect(list.active).toBe("demo"); + await expect(harness.callRpc("profiles.delete", { name: "demo" })).rejects.toThrow(/active/u); + }); }); diff --git a/plugins/team-setup/server.ts b/plugins/team-setup/server.ts index d6d8c48c..40dbc1aa 100644 --- a/plugins/team-setup/server.ts +++ b/plugins/team-setup/server.ts @@ -30,7 +30,7 @@ import { } from "./gh.js"; import { figmaHostContract, type FigmaHostState } from "./figma-contract.js"; import { profilesRpcContract } from "./profiles-contract.js"; -import { activateProfile, createProfile, deleteProfile, listProfiles, profilesLayout } from "./profiles.js"; +import { activateProfile, createProfile, deleteProfile, listProfiles, profilesLayout, readActiveProfile } from "./profiles.js"; export const ACCOUNT_POOL_ID = "account-pool"; export const DEVBOX_ID = "devbox-provider"; @@ -691,10 +691,14 @@ export function createTeamSetupPlugin(deps: TeamSetupDeps): (bb: BbPluginApi) => // layout, has no such method and the Advanced section renders nothing. const profiles = profilesLayout(deps.env); if (profiles !== null) { + // The profile the entrypoint resolved at start. active-profile can be + // rewritten by a switch whose restart then fails, so the running one is + // what list reports as active and delete refuses. + const running = await readActiveProfile(profiles); bb.rpc.register(profilesRpcContract, { - "profiles.list": () => listProfiles(profiles), + "profiles.list": () => listProfiles(profiles, running), "profiles.create": ({ name }) => createProfile(profiles, name), - "profiles.delete": ({ name }) => deleteProfile(profiles, name), + "profiles.delete": ({ name }) => deleteProfile(profiles, name, running), "profiles.activate": ({ name }) => activateProfile(profiles, name), }); }