diff --git a/README.md b/README.md index 5c7473a5e4..0792800f9a 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,19 @@ checkout path. The checkout instance id is the sanitized path to the checkout, relative to your home directory, plus a short hash suffix. Separate worktrees can run alongside each other and the packaged `npx bb-app@latest` instance. +To test the production bundle and serving path without switching to production +data or ports, use: + +```bash +pnpm start:worktree +``` + +This builds the same optimized frontend and runtime artifacts as `pnpm start`, +then serves the app from the BB server on the checkout-specific dev server port. +It keeps the normal checkout-specific dev data directory and host-daemon port. +There is no Vite dev server or hot reload in this mode; rerun the command after +source changes. As with `pnpm dev`, worktree starts do not send telemetry. + To run that same source dev server with the Electron desktop shell: ```bash diff --git a/docs/configuration.md b/docs/configuration.md index 436d33c0f8..2ec3ad75ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -926,9 +926,9 @@ enrolled to other servers. Atomic reservations under ## Source Development -For source development only, `pnpm dev` and `pnpm start` load the repo-root -dotenv cascade. Add a repo-root `.env` only when you need to override the -defaults described above. +For source development only, `pnpm dev`, `pnpm start:worktree`, and `pnpm start` +load the repo-root dotenv cascade. Add a repo-root `.env` only when you need to +override the defaults described above. The standard [dotenv-cli](https://github.com/entropitor/dotenv-cli) cascade applies to source development. `pnpm dev` loads `.env`, `.env.local`, @@ -940,6 +940,14 @@ Vite app bind to loopback by default; an explicit `BB_DEV_APP_HOST` still overrides the Vite listener. Remote HTTP dev via `BB_DEV_APP_HOST` also requires `BB_SERVER_BIND_HOST=0.0.0.0` for realtime updates; the Tailscale Serve HTTPS path avoids this because WebSocket traffic goes through the Vite proxy. +`pnpm start:worktree` loads the same development dotenv cascade and uses the +same checkout-specific data directory, server port, and host-daemon port. It +builds production artifacts and serves the frontend bundle from the main +server, so there is no separate Vite listener or hot reload. Telemetry remains +disabled for this source-development command. Its worktree data directory, +ports, inherited skills, listener host, absent Vite port, and telemetry policy +take precedence over conflicting values saved in that instance's `config.json` +or `env.json`. `pnpm start` loads `.env`, `.env.local`, `.env.production`, and `.env.production.local`. diff --git a/docs/debugging-and-qa.md b/docs/debugging-and-qa.md index dd49de41f5..902f8fd84f 100644 --- a/docs/debugging-and-qa.md +++ b/docs/debugging-and-qa.md @@ -1,6 +1,7 @@ # Debugging And QA - `pnpm dev` prints the active frontend URL, server API URL, host daemon port, data dir, and logs dir. Do not assume fixed dev ports. +- `pnpm start:worktree` builds production artifacts and serves the optimized app bundle from the checkout-specific dev server URL, while keeping the same dev data directory and deterministic server/host-daemon ports. It has no Vite dev server or hot reload. - The packaged app defaults to server/frontend `:38886`, host daemon `:38887`, data dir `~/.bb/`, and logs under `~/.bb/logs/`. - Entity IDs in URLs (`proj_*`, `thr_*`) are primary keys. Query them directly against the active data dir: `sqlite3 /bb.db "SELECT * FROM threads WHERE id = 'thr_xxx';"`. - API routes are under `/api/v1/`, for example `GET /api/v1/threads/:id`. diff --git a/docs/platform-support.md b/docs/platform-support.md index 5b6433b25d..4117cf9f76 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -81,7 +81,7 @@ Not available on the phone (use the web app or desktop for these): - `npx bb-app` - `npx --package bb-app bb ...` -- source checkout package startup with `pnpm start` +- source checkout package startup with `pnpm start` or `pnpm start:worktree` - source checkout validation with `pnpm install`, `pnpm build`, `pnpm exec turbo run typecheck`, and `pnpm exec turbo run test` - app + server + host-daemon startup on supported persistent-host OSes diff --git a/package.json b/package.json index bb9a9e6f07..e8ff774863 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "cli:prepare": "pnpm exec turbo run build --filter=@bb/scripts --filter=@bb/cli --output-logs=none --log-prefix=none --summarize=false", "ensure-native-modules": "node scripts/ensure-native-modules.mjs", "dev": "node scripts/ensure-native-modules.mjs && cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts", + "start:worktree": "cross-env NODE_ENV=development dotenv -c development -- node --conditions=source --import tsx packages/scripts/src/commands/run-dev.ts --worktree", "dev:remote": "cross-env BB_DEV_APP_HOST=0.0.0.0 BB_SERVER_BIND_HOST=0.0.0.0 pnpm run dev", "cloud:dev": "node --conditions=source --import tsx scripts/bb-cloud-dev.mjs", "dev:desktop": "scripts/bb-dev-app current --desktop", diff --git a/packages/bb-app/src/index.ts b/packages/bb-app/src/index.ts index c5c7fa5fc9..616295762a 100644 --- a/packages/bb-app/src/index.ts +++ b/packages/bb-app/src/index.ts @@ -9,6 +9,7 @@ export { resolveBbAppStartContext, resolveDataDir, resolvePort, + resolveWorktreeRuntimePolicy, runBbApp, runBbCli, runBbHostDaemon, @@ -37,5 +38,8 @@ export type { ResolveBbAppStartContextArgs, ResolveDataDirArgs, ResolvePortArgs, + ResolveWorktreeRuntimePolicyArgs, + RunBbAppOptions, StartCommand, + WorktreeRuntimePolicy, } from "./launcher.js"; diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index 68853bbeb6..2ad1d7f970 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -55,11 +55,13 @@ import { } from "@bb/config/inference-model"; import { validateLogLevel } from "@bb/config/log-level"; import { validateOptionalUrl } from "@bb/config/public-url"; -import { parseServerBindHost } from "@bb/config/server"; +import { parseServerBindHost, type ServerBindHost } from "@bb/config/server"; import { BB_PROD_HOST_DAEMON_PORT, BB_LOOPBACK_HOST, BB_PROD_SERVER_PORT, + parseDataDirEnvValue, + parsePortValue, resolveConfiguredDataDir, resolveDataDirDatabasePath, resolvePortFromEnv, @@ -211,6 +213,26 @@ export interface ResolveBbAppRuntimeContextArgs { homeDir: string; options: LauncherCliOptions; serverUrlMode: "local" | "managed"; + worktreePolicy?: WorktreeRuntimePolicy; +} + +export interface WorktreeRuntimePolicy { + dataDir: string; + devAppPort: null; + hostDaemonPort: number; + inheritedSkillsRoots: string; + serverBindHost: ServerBindHost; + serverPort: number; + telemetry: false; +} + +export interface ResolveWorktreeRuntimePolicyArgs { + env: NodeJS.ProcessEnv; + homeDir: string; +} + +export interface RunBbAppOptions { + worktreePolicy: WorktreeRuntimePolicy | null; } export interface BbAppStartContext { @@ -564,6 +586,7 @@ interface ResolveBbAppRuntimeStateArgs { homeDir: string; options: LauncherCliOptions; serverUrlMode: "local" | "managed"; + worktreePolicy?: WorktreeRuntimePolicy; } interface RunConfigCommandArgs { @@ -809,6 +832,72 @@ export function resolvePort(args: ResolvePortArgs): number { return resolvePortFromEnv(args); } +function requireWorktreePolicyEnvValue( + env: NodeJS.ProcessEnv, + name: string, +): string { + const value = env[name]; + if (value === undefined) { + throw new Error(`${name} is required for worktree startup`); + } + return value; +} + +export function resolveWorktreeRuntimePolicy( + args: ResolveWorktreeRuntimePolicyArgs, +): WorktreeRuntimePolicy { + const rawDataDir = requireWorktreePolicyEnvValue(args.env, "BB_DATA_DIR"); + const rawHostDaemonPort = requireWorktreePolicyEnvValue( + args.env, + "BB_HOST_DAEMON_PORT", + ); + const inheritedSkillsRoots = requireWorktreePolicyEnvValue( + args.env, + "BB_INHERITED_SKILLS_ROOTS", + ); + const rawServerPort = requireWorktreePolicyEnvValue( + args.env, + "BB_SERVER_PORT", + ); + return { + dataDir: parseDataDirEnvValue({ + homeDir: args.homeDir, + rawDataDir, + }), + devAppPort: null, + hostDaemonPort: parsePortValue({ + name: "BB_HOST_DAEMON_PORT", + rawPort: rawHostDaemonPort, + }), + inheritedSkillsRoots, + serverBindHost: parseServerBindHost( + args.env.BB_SERVER_BIND_HOST ?? BB_LOOPBACK_HOST, + ), + serverPort: parsePortValue({ + name: "BB_SERVER_PORT", + rawPort: rawServerPort, + }), + telemetry: false, + }; +} + +function applyWorktreeRuntimePolicy( + env: NodeJS.ProcessEnv, + policy: WorktreeRuntimePolicy, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { + ...env, + BB_DATA_DIR: policy.dataDir, + BB_HOST_DAEMON_PORT: String(policy.hostDaemonPort), + BB_INHERITED_SKILLS_ROOTS: policy.inheritedSkillsRoots, + BB_SERVER_BIND_HOST: policy.serverBindHost, + BB_SERVER_PORT: String(policy.serverPort), + BB_TELEMETRY: String(policy.telemetry), + }; + delete nextEnv.BB_DEV_APP_PORT; + return nextEnv; +} + function createEnvFromOptions( args: CreateEnvFromOptionsArgs, ): NodeJS.ProcessEnv { @@ -1311,21 +1400,28 @@ export async function resolveBbAppRuntimeState( }); const config = await readManagedConfig({ dataDir: initialContext.dataDir }); const envFile = await readManagedEnvFile({ dataDir: initialContext.dataDir }); - const managedEnv = applyManagedConfigEnv({ + const persistedEnv = applyManagedConfigEnv({ config, envFile, env: initialEnv, }); + const applyRuntimePolicy = (env: NodeJS.ProcessEnv): NodeJS.ProcessEnv => + args.worktreePolicy === undefined + ? env + : applyWorktreeRuntimePolicy(env, args.worktreePolicy); + const managedEnv = applyRuntimePolicy(persistedEnv); if (args.serverUrlMode === "local") { const localEnv = { ...managedEnv }; const localServerEnv = stripThreadContextEnv( - createServerBaseEnv({ - config, - envFile, - env: initialEnv, - serverBindHostOverride: args.options.serverBindHost, - }), + applyRuntimePolicy( + createServerBaseEnv({ + config, + envFile, + env: initialEnv, + serverBindHostOverride: args.options.serverBindHost, + }), + ), ); delete localEnv.BB_SERVER_URL; delete localServerEnv.BB_SERVER_URL; @@ -1359,12 +1455,14 @@ export async function resolveBbAppRuntimeState( }), env: finalEnv, serverEnv: stripThreadContextEnv( - createServerBaseEnv({ - config, - envFile, - env: initialEnv, - serverBindHostOverride: args.options.serverBindHost, - }), + applyRuntimePolicy( + createServerBaseEnv({ + config, + envFile, + env: initialEnv, + serverBindHostOverride: args.options.serverBindHost, + }), + ), ), }; } @@ -3186,6 +3284,7 @@ async function runStopCommand(args: { dataDir: string }): Promise { export async function runBbApp( cliArgs: string[] = process.argv.slice(2), + options: RunBbAppOptions = { worktreePolicy: null }, ): Promise { const parsedArgs = parseLauncherArgs(cliArgs); @@ -3217,6 +3316,9 @@ export async function runBbApp( command.kind === "host-daemon" ? "managed" : "local", + ...(options.worktreePolicy === null + ? {} + : { worktreePolicy: options.worktreePolicy }), }); if (command.kind === "start") { diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 21f0008b38..0e661251f2 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -27,6 +27,7 @@ import { resolvePort, resolveBbAppStartContext, resolveBbAppCommand, + resolveWorktreeRuntimePolicy, runBbApp, } from "../src/index.js"; import { @@ -992,6 +993,64 @@ describe("bb-app launcher", () => { expect(runtime.serverEnv.OPENAI_API_KEY).toBe("stored-openai-key"); }); + it("applies the worktree policy after conflicting saved environment values", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "bb-app-worktree-policy-")); + const storedDataDir = join(dataDir, "stored-data"); + writeFileSync( + join(dataDir, "env.json"), + JSON.stringify({ + env: { + BB_DATA_DIR: storedDataDir, + BB_DEV_APP_PORT: "4173", + BB_HOST_DAEMON_PORT: "48887", + BB_INHERITED_SKILLS_ROOTS: "/stored/skills", + BB_SERVER_BIND_HOST: "0.0.0.0", + BB_SERVER_PORT: "48886", + BB_TELEMETRY: "true", + OPENAI_API_KEY: "stored-openai-key", + }, + }), + "utf8", + ); + const worktreeEnv: NodeJS.ProcessEnv = { + BB_DATA_DIR: dataDir, + BB_HOST_DAEMON_PORT: "47887", + BB_INHERITED_SKILLS_ROOTS: "/worktree/skills", + BB_SERVER_PORT: "47886", + }; + + const runtime = await resolveBbAppRuntimeState({ + entrypointUrl: pathToFileURL("/repo/packages/bb-app/dist/bb-app.js").href, + env: worktreeEnv, + homeDir: "/home/tester", + options: { help: false }, + serverUrlMode: "local", + worktreePolicy: resolveWorktreeRuntimePolicy({ + env: worktreeEnv, + homeDir: "/home/tester", + }), + }); + + expect(runtime.context).toMatchObject({ + daemonPort: 47887, + dataDir, + serverPort: 47886, + serverUrl: "http://127.0.0.1:47886", + }); + for (const env of [runtime.env, runtime.serverEnv]) { + expect(env).toMatchObject({ + BB_DATA_DIR: dataDir, + BB_HOST_DAEMON_PORT: "47887", + BB_INHERITED_SKILLS_ROOTS: "/worktree/skills", + BB_SERVER_BIND_HOST: "127.0.0.1", + BB_SERVER_PORT: "47886", + BB_TELEMETRY: "false", + OPENAI_API_KEY: "stored-openai-key", + }); + expect(env.BB_DEV_APP_PORT).toBeUndefined(); + } + }); + it("uses launcher flags over managed config and ambient server URL", async () => { const dataDir = mkdtempSync(join(tmpdir(), "bb-app-flag-config-")); writeFileSync( diff --git a/packages/scripts/src/commands/run-dev.ts b/packages/scripts/src/commands/run-dev.ts index 9b08f94ad4..cf0db787ad 100644 --- a/packages/scripts/src/commands/run-dev.ts +++ b/packages/scripts/src/commands/run-dev.ts @@ -15,18 +15,20 @@ interface PortAvailabilityCheck { port: number; } -interface DevTurboCommand { +interface DevCommand { args: string[]; command: string; } +export type DevLaunchMode = "vite" | "worktree"; + const LOOPBACK_HOST = "127.0.0.1"; const commandDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(commandDir, "..", ".."); const repoRoot = resolve(packageRoot, "..", ".."); -export function createDevTurboCommand(): DevTurboCommand { +export function createDevTurboCommand(): DevCommand { return { args: [ "exec", @@ -46,13 +48,62 @@ export function createDevTurboCommand(): DevTurboCommand { }; } -function formatConfig(config: DevInstanceConfig): string { +export function createStartWorktreeCommand(): DevCommand { + return { + args: [ + "--conditions=source", + "--import", + "tsx", + resolve(repoRoot, "scripts", "start-bb.mjs"), + "--worktree-runtime-policy", + ], + command: process.execPath, + }; +} + +export function resolveDevLaunchMode(args: string[]): DevLaunchMode { + if (args.length === 0) { + return "vite"; + } + if (args.length === 1 && args[0] === "--worktree") { + return "worktree"; + } + throw new Error( + `[dev] Unknown arguments: ${args.join(" ")}. Expected no arguments or --worktree.`, + ); +} + +export function toDevLaunchProcessEnv(args: { + baseEnv: NodeJS.ProcessEnv; + config: DevInstanceConfig; + mode: DevLaunchMode; +}): NodeJS.ProcessEnv { + const env = toDevProcessEnv({ + baseEnv: args.baseEnv, + config: args.config, + }); + if (args.mode === "vite") { + return env; + } + + delete env.BB_DEV_APP_PORT; + env.BB_TELEMETRY = "false"; + env.NODE_ENV = "production"; + return env; +} + +function formatConfig(config: DevInstanceConfig, mode: DevLaunchMode): string { + const prefix = mode === "worktree" ? "[start:worktree]" : "[dev]"; + const appUrl = + mode === "worktree" + ? config.serverUrl + : `http://localhost:${config.ports.appPort}`; return [ - `[dev] Instance ${config.instanceId}`, - `[dev] Data dir ${config.dataDir}`, - `[dev] App http://localhost:${config.ports.appPort}`, - `[dev] Server ${config.serverUrl}`, - `[dev] Host daemon http://127.0.0.1:${config.ports.hostDaemonPort}`, + `${prefix} Instance ${config.instanceId}`, + `${prefix} Data dir ${config.dataDir}`, + `${prefix} App ${appUrl}`, + `${prefix} Server ${config.serverUrl}`, + `${prefix} Host daemon http://127.0.0.1:${config.ports.hostDaemonPort}`, ].join("\n"); } @@ -80,12 +131,17 @@ function checkPortAvailable(check: PortAvailabilityCheck): Promise { }); } -async function assertPortsAvailable(config: DevInstanceConfig): Promise { +async function assertPortsAvailable( + config: DevInstanceConfig, + mode: DevLaunchMode, +): Promise { const checks: PortAvailabilityCheck[] = [ - { label: "app", port: config.ports.appPort }, { label: "server", port: config.ports.serverPort }, { label: "host-daemon", port: config.ports.hostDaemonPort }, ]; + if (mode === "vite") { + checks.unshift({ label: "app", port: config.ports.appPort }); + } await Promise.all(checks.map(checkPortAvailable)); } @@ -95,6 +151,7 @@ async function resolveExistingRepoRoot(): Promise { } export async function main(): Promise { + const mode = resolveDevLaunchMode(process.argv.slice(2)); const resolvedRepoRoot = await resolveExistingRepoRoot(); const config = resolveCurrentDevInstanceConfig(resolvedRepoRoot); const migration = await migrateLegacyDevData({ @@ -106,17 +163,21 @@ export async function main(): Promise { "[dev] Legacy ~/.bb-dev data was found, but an old dev server or host-daemon is still running. Stop the old dev process and rerun pnpm dev to migrate it.", ); } - await assertPortsAvailable(config); - process.stdout.write(`${formatConfig(config)}\n`); + await assertPortsAvailable(config, mode); + process.stdout.write(`${formatConfig(config, mode)}\n`); - const turboCommand = createDevTurboCommand(); + const command = + mode === "worktree" + ? createStartWorktreeCommand() + : createDevTurboCommand(); process.exitCode = await runScriptProcess({ - args: turboCommand.args, - command: turboCommand.command, + args: command.args, + command: command.command, cwd: config.repoRoot, - env: toDevProcessEnv({ + env: toDevLaunchProcessEnv({ baseEnv: process.env, config, + mode, }), stdio: "inherit", }); diff --git a/packages/scripts/test/run-dev.test.ts b/packages/scripts/test/run-dev.test.ts index 5af4dbc75a..c2376be1c1 100644 --- a/packages/scripts/test/run-dev.test.ts +++ b/packages/scripts/test/run-dev.test.ts @@ -7,7 +7,12 @@ import { resolveInheritedDevSkillsRootPaths, toDevProcessEnv, } from "@bb/config/runtime"; -import { createDevTurboCommand } from "../src/commands/run-dev.js"; +import { + createDevTurboCommand, + createStartWorktreeCommand, + resolveDevLaunchMode, + toDevLaunchProcessEnv, +} from "../src/commands/run-dev.js"; import { migrateLegacyDevData } from "../src/lib/legacy-dev-data-migration.js"; import { expectedDevDataDir, @@ -226,6 +231,57 @@ describe("run-dev", () => { }); }); + it("runs the production-style source launcher for worktree start", () => { + const command = createStartWorktreeCommand(); + + expect(command.command).toBe(process.execPath); + expect(command.args).toEqual([ + "--conditions=source", + "--import", + "tsx", + path.resolve(import.meta.dirname, "../../..", "scripts/start-bb.mjs"), + "--worktree-runtime-policy", + ]); + }); + + it("accepts only the supported dev launch mode", () => { + expect(resolveDevLaunchMode([])).toBe("vite"); + expect(resolveDevLaunchMode(["--worktree"])).toBe("worktree"); + expect(() => resolveDevLaunchMode(["--watch"])).toThrow( + "Expected no arguments or --worktree", + ); + }); + + it("uses production serving with checkout-specific dev selectors", () => { + const config = resolveDevInstanceConfig({ + homeDir: "/Users/tester", + repoRoot: "/Users/tester/src/bb", + }); + + const env = toDevLaunchProcessEnv({ + baseEnv: { + BB_DATA_DIR: "/Users/tester/.bb", + BB_DEV_APP_PORT: "5173", + BB_TELEMETRY: "true", + NODE_ENV: "development", + OPENAI_API_KEY: "test-key", + }, + config, + mode: "worktree", + }); + + expect(env).toMatchObject({ + BB_DATA_DIR: config.dataDir, + BB_HOST_DAEMON_PORT: String(config.ports.hostDaemonPort), + BB_SERVER_PORT: String(config.ports.serverPort), + BB_SERVER_URL: config.serverUrl, + BB_TELEMETRY: "false", + NODE_ENV: "production", + OPENAI_API_KEY: "test-key", + }); + expect(env.BB_DEV_APP_PORT).toBeUndefined(); + }); + it("migrates legacy flat dev data into the checkout instance", async () => { const homeDir = await makeTempDir("bb-dev-home-"); const legacyDataDir = path.join(homeDir, ".bb-dev"); diff --git a/packages/scripts/test/start-bb.test.mjs b/packages/scripts/test/start-bb.test.mjs new file mode 100644 index 0000000000..b9aea48344 --- /dev/null +++ b/packages/scripts/test/start-bb.test.mjs @@ -0,0 +1,145 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseStartBbArgs } from "../../../scripts/start-bb.mjs"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(testDir, "..", "..", ".."); +const startBbUrl = pathToFileURL( + resolve(repoRoot, "scripts/start-bb.mjs"), +).href; +const spawnedPids = []; + +function isAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitFor(check, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + while (!check()) { + if (Date.now() > deadline) { + throw new Error("Timed out waiting for process state"); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 25)); + } +} + +async function readFirstLine(stream) { + let buffered = ""; + for await (const chunk of stream) { + buffered += String(chunk); + const newlineIndex = buffered.indexOf("\n"); + if (newlineIndex !== -1) { + return buffered.slice(0, newlineIndex).trim(); + } + } + throw new Error("Process stdout ended before a line was written"); +} + +async function waitForExit(child, timeoutMs) { + let timeout; + try { + return await Promise.race([ + once(child, "exit"), + new Promise((_, rejectPromise) => { + timeout = setTimeout( + () => rejectPromise(new Error("start-bb fixture did not stop")), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +afterEach(async () => { + for (const pid of spawnedPids.splice(0)) { + if (isAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + } +}); + +describe("start-bb", () => { + it("keeps the worktree policy marker out of bb-app arguments", () => { + expect( + parseStartBbArgs(["--worktree-runtime-policy", "--server-port", "4000"]), + ).toEqual({ + cliArgs: ["--server-port", "4000"], + useWorktreeRuntimePolicy: true, + }); + expect(parseStartBbArgs(["--server-port", "4000"])).toEqual({ + cliArgs: ["--server-port", "4000"], + useWorktreeRuntimePolicy: false, + }); + }); + + const posixIt = process.platform === "win32" ? it.skip : it; + posixIt( + "stops the build leader and grandchild after direct SIGTERM", + async () => { + const fixtureSource = [ + `import { runBuildProcess } from ${JSON.stringify(startBbUrl)};`, + "const result = await runBuildProcess({", + ' command: "sh",', + " args: [", + ' "-c",', + ' "sleep 300 & grandchild=$!; echo \\\"$$ $grandchild\\\"; wait \\\"$grandchild\\\"",', + " ],", + ` cwd: ${JSON.stringify(repoRoot)},`, + " env: process.env,", + "});", + "process.exitCode = result.code ?? (result.signal === null ? 1 : 0);", + ].join("\n"); + const parent = spawn( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + "--input-type=module", + "--eval", + fixtureSource, + ], + { + cwd: repoRoot, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + if (parent.pid === undefined) { + throw new Error("start-bb fixture did not receive a pid"); + } + spawnedPids.push(parent.pid); + const stderrChunks = []; + parent.stderr.on("data", (chunk) => stderrChunks.push(String(chunk))); + const [leaderPid, grandchildPid] = (await readFirstLine(parent.stdout)) + .split(" ") + .map(Number); + spawnedPids.push(leaderPid, grandchildPid); + expect(isAlive(leaderPid)).toBe(true); + expect(isAlive(grandchildPid)).toBe(true); + + parent.kill("SIGTERM"); + const [code, signal] = await waitForExit(parent, 10_000); + if (code !== 0 || signal !== null) { + throw new Error( + `Expected clean fixture exit, got code=${String(code)} signal=${String(signal)} stderr=${stderrChunks.join("")}`, + ); + } + await waitFor( + () => !isAlive(leaderPid) && !isAlive(grandchildPid), + 5_000, + ); + }, + 20_000, + ); +}); diff --git a/scripts/start-bb.mjs b/scripts/start-bb.mjs index 89086b3110..4c6e0f2be0 100644 --- a/scripts/start-bb.mjs +++ b/scripts/start-bb.mjs @@ -1,12 +1,20 @@ import { spawn } from "node:child_process"; import { createRequire } from "node:module"; +import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { + stopProcessGroupLeaderFirst, + supportsProcessGroups, +} from "../packages/process-utils/src/index.ts"; import { ensureNativeModules } from "./ensure-native-modules.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const requireFromRoot = createRequire(resolve(repoRoot, "package.json")); +const WORKTREE_RUNTIME_POLICY_ARG = "--worktree-runtime-policy"; +const BUILD_TERMINATION_TIMEOUT_MS = 5_000; +const BUILD_KILL_GRACE_MS = 1_000; function waitForProcess(child) { return new Promise((resolvePromise, rejectPromise) => { @@ -17,11 +25,40 @@ function waitForProcess(child) { }); } +export async function runBuildProcess(request) { + const child = spawn(request.command, request.args, { + cwd: request.cwd, + detached: supportsProcessGroups(), + env: request.env, + stdio: "inherit", + }); + let stopPromise; + const stopChild = () => { + stopPromise ??= stopProcessGroupLeaderFirst({ + child, + timeoutMs: BUILD_TERMINATION_TIMEOUT_MS, + killGraceMs: BUILD_KILL_GRACE_MS, + }); + }; + const handleSigint = () => stopChild(); + const handleSigterm = () => stopChild(); + process.on("SIGINT", handleSigint); + process.on("SIGTERM", handleSigterm); + + try { + const result = await waitForProcess(child); + await stopPromise; + return result; + } finally { + process.off("SIGINT", handleSigint); + process.off("SIGTERM", handleSigterm); + } +} + async function buildRuntimeArtifacts() { const turboEntrypoint = requireFromRoot.resolve("turbo/bin/turbo"); - const child = spawn( - process.execPath, - [ + const result = await runBuildProcess({ + args: [ turboEntrypoint, "run", "build", @@ -35,13 +72,10 @@ async function buildRuntimeArtifacts() { "--summarize=false", "--no-update-notifier", ], - { - cwd: repoRoot, - env: process.env, - stdio: "inherit", - }, - ); - const result = await waitForProcess(child); + command: process.execPath, + cwd: repoRoot, + env: process.env, + }); if (result.code === 0) { return; } @@ -52,21 +86,17 @@ async function buildRuntimeArtifacts() { } async function buildBundledPlugins() { - const child = spawn( - process.execPath, - [ + const result = await runBuildProcess({ + args: [ "--conditions=source", "--import", "tsx", resolve(repoRoot, "apps/server/scripts/copy-builtin-plugins.ts"), ], - { - cwd: repoRoot, - env: process.env, - stdio: "inherit", - }, - ); - const result = await waitForProcess(child); + command: process.execPath, + cwd: repoRoot, + env: process.env, + }); if (result.code === 0) { return; } @@ -78,9 +108,37 @@ async function buildBundledPlugins() { ); } -await buildRuntimeArtifacts(); -await buildBundledPlugins(); -ensureNativeModules({ repoRoot }); +export function parseStartBbArgs(args) { + if (args[0] !== WORKTREE_RUNTIME_POLICY_ARG) { + return { cliArgs: args, useWorktreeRuntimePolicy: false }; + } + return { + cliArgs: args.slice(1), + useWorktreeRuntimePolicy: true, + }; +} + +export async function main(args = process.argv.slice(2)) { + const parsedArgs = parseStartBbArgs(args); + await buildRuntimeArtifacts(); + await buildBundledPlugins(); + ensureNativeModules({ repoRoot }); -const { runBbApp } = await import("../packages/bb-app/src/index.ts"); -await runBbApp(); + const { resolveWorktreeRuntimePolicy, runBbApp } = + await import("../packages/bb-app/src/index.ts"); + await runBbApp(parsedArgs.cliArgs, { + worktreePolicy: parsedArgs.useWorktreeRuntimePolicy + ? resolveWorktreeRuntimePolicy({ + env: process.env, + homeDir: homedir(), + }) + : null, + }); +} + +const isMainModule = + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMainModule) { + await main(); +}