diff --git a/README.md b/README.md index bf86fd2..eaa9638 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ wkb --version ## Use ```sh -wkb new [--from ] # create sandbox worktree +wkb new [--from ] # create and provision sandbox worktree wkb rm [--force] [--unmanaged] [--delete-branch] # remove worktree wkb list # list workbox worktrees wkb prune # prune stale git worktree metadata @@ -60,12 +60,35 @@ steps = [ { name = "build", run = "bun run build" } ] +[provision] +enabled = true + +[[provision.copy]] +from = ".env" +to = ".env" + +[[provision.copy]] +from = ".env.local" +to = ".env.local" +required = false + +[[provision.steps]] +name = "generate" +run = "bun run generate" + [dev] command = "bun run dev" # Optional (explicit opt-in): open an editor when running `wkb dev`. # open = "code ." ``` +Provision runs automatically after `wkb new` creates a worktree. Copy sources resolve from the +current worktree where `wkb new` is run, copy destinations and steps run inside the new worktree, +and missing copied files are skipped unless `required = true`. + +Bootstrap is separate: `wkb setup` runs bootstrap in the current worktree, and `wkb dev ` +runs bootstrap in the named sandbox before the dev command. + ## Development ```sh diff --git a/src/commands/commands.test.ts b/src/commands/commands.test.ts index a422751..904d83f 100644 --- a/src/commands/commands.test.ts +++ b/src/commands/commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, spyOn } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -65,6 +65,7 @@ const buildConfig = ( baseRef?: string; bootstrapEnabled?: boolean; bootstrapSteps?: ResolvedWorkboxConfig["bootstrap"]["steps"]; + provision?: ResolvedWorkboxConfig["provision"]; dev?: ResolvedWorkboxConfig["dev"]; } ): ResolvedWorkboxConfig => { @@ -78,6 +79,11 @@ const buildConfig = ( enabled: options?.bootstrapEnabled ?? false, steps: options?.bootstrapSteps ?? [], }, + provision: options?.provision ?? { + enabled: false, + copy: [], + steps: [], + }, ...(options?.dev ? { dev: options.dev } : {}), }; }; @@ -150,6 +156,60 @@ describe("new command", () => { expect(result.message).toContain('Created worktree "box1"'); }); }); + + it("provisions configured files after creating a worktree", async () => { + await withRepo(async (repoRoot) => { + await writeFile(join(repoRoot, ".env"), "TOKEN=local\n"); + const context = buildContext( + repoRoot, + buildConfig(repoRoot, { + baseRef: "HEAD", + provision: { + enabled: true, + copy: [{ from: ".env", to: ".env", required: false }], + steps: [], + }, + }), + { json: true } + ); + + const result = await newCommand.run(context, ["box1"]); + const worktreePath = join(repoRoot, ".workbox", "worktrees", "box1"); + + expect(result.exitCode).toBeUndefined(); + expect(result.data).toEqual( + expect.objectContaining({ + worktree: expect.objectContaining({ name: "box1" }), + provision: expect.objectContaining({ status: "ok" }), + }) + ); + expect(await readFile(join(worktreePath, ".env"), "utf8")).toBe("TOKEN=local\n"); + }); + }); + + it("keeps the worktree when provisioning fails", async () => { + await withRepo(async (repoRoot) => { + const context = buildContext( + repoRoot, + buildConfig(repoRoot, { + baseRef: "HEAD", + provision: { + enabled: true, + copy: [{ from: ".env", to: ".env", required: true }], + steps: [], + }, + }), + { json: true } + ); + + const result = await newCommand.run(context, ["box1"]); + const worktreePath = join(repoRoot, ".workbox", "worktrees", "box1"); + + expect(result.exitCode).toBe(1); + expect(result.message).toContain("missing required source"); + expect(await readFile(join(worktreePath, "README.md"), "utf8")).toBe("hello\n"); + }); + }); }); describe("list command", () => { diff --git a/src/commands/new.ts b/src/commands/new.ts index d410f5f..c53e1e6 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -1,4 +1,5 @@ import { createWorktree } from "../core/git"; +import { runProvision } from "../provision/runner"; import { UsageError } from "../ui/errors"; import { parseArgsOrUsage } from "./parse"; import type { CommandDefinition } from "./types"; @@ -46,9 +47,27 @@ export const newCommand: CommandDefinition = { baseRef, }); + let provisionResult: Awaited> | undefined; + if (context.config.provision.enabled) { + provisionResult = await runProvision(context.config.provision, { + sourceRoot: context.worktreeRoot, + targetRoot: worktree.path, + worktreeName: worktree.name, + mode: context.flags.json ? "capture" : "inherit", + }); + + if (provisionResult.exitCode !== 0) { + return { + message: provisionResult.message, + data: { worktree, provision: provisionResult }, + exitCode: provisionResult.exitCode, + }; + } + } + return { message: `Created worktree "${worktree.name}" at ${worktree.path} on branch ${worktree.managedBranch}.`, - data: worktree, + data: provisionResult ? { worktree, provision: provisionResult } : worktree, }; }, }; diff --git a/src/commands/rm.test.ts b/src/commands/rm.test.ts index 6584ec6..9226166 100644 --- a/src/commands/rm.test.ts +++ b/src/commands/rm.test.ts @@ -75,6 +75,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; const context = { @@ -108,6 +113,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; const context = { @@ -139,6 +149,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; const context = { @@ -173,6 +188,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; await createWorktree({ @@ -218,6 +238,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; await createWorktree({ @@ -264,6 +289,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; const created = await createWorktree({ @@ -314,6 +344,11 @@ describe("rm command", () => { enabled: false, steps: [], }, + provision: { + enabled: false, + copy: [], + steps: [], + }, }; const created = await createWorktree({ diff --git a/src/core/config.test.ts b/src/core/config.test.ts index bb57095..893fa68 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -51,6 +51,20 @@ describe("loadConfig", () => { }); }); + it("defaults provision to disabled when it is not configured", async () => { + await withTempDir(async (cwd) => { + await writeFile(join(cwd, "workbox.toml"), minimalConfig); + + const result = await loadTestConfig(cwd); + + expect(result.config.provision).toEqual({ + enabled: false, + copy: [], + steps: [], + }); + }); + }); + it("loads global config from the fallback home path when no project config exists", async () => { await withTempDir(async (cwd) => { const homeDir = join(cwd, "home"); @@ -136,6 +150,47 @@ open = "open http://localhost:4000" }); }); + it("merges project provision config over global provision config", async () => { + await withTempDir(async (cwd) => { + const homeDir = join(cwd, "home"); + await mkdir(join(homeDir, ".workbox"), { recursive: true }); + await writeFile( + join(homeDir, ".workbox", "config.toml"), + `${minimalConfig} +[provision] +enabled = true + +[[provision.copy]] +from = ".env" +to = ".env" +required = true + +[[provision.steps]] +name = "global" +run = "echo global" +` + ); + await writeFile( + join(cwd, "workbox.toml"), + `[provision] +enabled = false + +[[provision.copy]] +from = ".env.local" +to = ".env.local" +` + ); + + const result = await loadTestConfig(cwd, homeDir); + + expect(result.config.provision).toEqual({ + enabled: false, + copy: [{ from: ".env.local", to: ".env.local", required: false }], + steps: [{ name: "global", run: "echo global" }], + }); + }); + }); + it("rejects when merged global and project config is incomplete", async () => { await withTempDir(async (cwd) => { const homeDir = join(cwd, "home"); @@ -205,6 +260,42 @@ directory = ".workbox/worktrees" }); }); + it("rejects duplicate provision step names", async () => { + await withTempDir(async (cwd) => { + await writeFile( + join(cwd, "workbox.toml"), + `${minimalConfig} +[provision] +enabled = true +steps = [ + { name = "copy", run = "echo one" }, + { name = "copy", run = "echo two" } +] +` + ); + + await expect(loadTestConfig(cwd)).rejects.toThrow(/Duplicate provision step name/); + }); + }); + + it("rejects invalid provision copy entries", async () => { + await withTempDir(async (cwd) => { + await writeFile( + join(cwd, "workbox.toml"), + `${minimalConfig} +[provision] +enabled = true + +[[provision.copy]] +from = "" +to = ".env" +` + ); + + await expect(loadTestConfig(cwd)).rejects.toThrow(/provision.copy.0.from/); + }); + }); + it("rejects worktree directory that escapes the repo via symlink", async () => { await withTempDir(async (cwd) => { const outside = await mkdtemp(join(tmpdir(), "workbox-outside-")); diff --git a/src/core/config.ts b/src/core/config.ts index a65e60e..d7398ac 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -28,6 +28,28 @@ const BootstrapObjectSchema = z }) .strict(); +const ProvisionCopySchema = z + .object({ + from: z.string().min(1, "Provision copy source is required."), + to: z.string().min(1, "Provision copy destination is required."), + required: z.boolean().default(false), + }) + .strict(); + +const PartialProvisionCopySchema = ProvisionCopySchema.omit({ required: true }) + .extend({ + required: z.boolean().optional(), + }) + .strict(); + +const ProvisionObjectSchema = z + .object({ + enabled: z.boolean(), + copy: z.array(ProvisionCopySchema).default([]), + steps: z.array(BootstrapStepSchema).default([]), + }) + .strict(); + const validateBootstrapSteps = ( steps: Array>, ctx: z.RefinementCtx @@ -49,6 +71,27 @@ const BootstrapSchema = BootstrapObjectSchema.superRefine((value, ctx) => { validateBootstrapSteps(value.steps, ctx); }); +const validateProvisionSteps = ( + steps: Array>, + ctx: z.RefinementCtx +) => { + const seen = new Set(); + steps.forEach((step, index) => { + if (seen.has(step.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["steps", index, "name"], + message: `Duplicate provision step name "${step.name}".`, + }); + } + seen.add(step.name); + }); +}; + +const ProvisionSchema = ProvisionObjectSchema.superRefine((value, ctx) => { + validateProvisionSteps(value.steps, ctx); +}); + const WorktreesSchema = z .object({ directory: z.string().min(1, "Worktree directory is required."), @@ -61,6 +104,7 @@ const WorkboxConfigSchema = z .object({ worktrees: WorktreesSchema, bootstrap: BootstrapSchema, + provision: ProvisionSchema.default({ enabled: false, copy: [], steps: [] }), dev: z .object({ command: z.string().min(1, "Dev command is required."), @@ -83,6 +127,19 @@ const PartialWorkboxConfigSchema = z } }) .optional(), + provision: z + .object({ + enabled: z.boolean().optional(), + copy: z.array(PartialProvisionCopySchema).optional(), + steps: z.array(BootstrapStepSchema).optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.steps) { + validateProvisionSteps(value.steps, ctx); + } + }) + .optional(), dev: z .object({ command: z.string().min(1, "Dev command is required.").optional(), @@ -151,6 +208,13 @@ const mergeConfig = (configs: PartialWorkboxConfig[]): PartialWorkboxConfig => }; } + if (config.provision) { + merged.provision = { + ...merged.provision, + ...config.provision, + }; + } + if (config.dev) { merged.dev = { ...merged.dev, diff --git a/src/core/path.ts b/src/core/path.ts index 59f9bed..6d90802 100644 --- a/src/core/path.ts +++ b/src/core/path.ts @@ -42,7 +42,12 @@ const checkSegmentsWithinRoot = async ( } } } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return { ok: true }; + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) + return { ok: true }; throw error; } } @@ -85,7 +90,12 @@ export const checkPathWithinRoot = async (input: { }; } } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return { ok: true }; + if ( + error instanceof Error && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) + return { ok: true }; throw error; } diff --git a/src/provision/runner.test.ts b/src/provision/runner.test.ts new file mode 100644 index 0000000..c5d4bb8 --- /dev/null +++ b/src/provision/runner.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { CliError } from "../ui/errors"; +import { runProvision } from "./runner"; + +const withRoots = async ( + fn: (roots: { sourceRoot: string; targetRoot: string }) => Promise +) => { + const root = await mkdtemp(join(tmpdir(), "workbox-provision-")); + const sourceRoot = join(root, "source"); + const targetRoot = join(root, "target"); + try { + await mkdir(sourceRoot, { recursive: true }); + await mkdir(targetRoot, { recursive: true }); + await fn({ sourceRoot, targetRoot }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}; + +describe("provision runner", () => { + it("reports when no provision actions are configured", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + const result = await runProvision( + { copy: [], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result).toEqual({ + status: "ok", + message: "no provision actions configured.", + copies: [], + steps: [], + exitCode: 0, + }); + }); + }); + + it("copies files, skips missing optional files, overwrites destinations, and runs steps", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await writeFile(join(sourceRoot, ".env"), "TOKEN=source\n"); + await mkdir(join(targetRoot, "nested"), { recursive: true }); + await writeFile(join(targetRoot, "nested", ".env"), "TOKEN=old\n"); + + const result = await runProvision( + { + copy: [ + { from: ".env", to: "nested/.env", required: false }, + { from: ".missing", to: ".missing", required: false }, + ], + steps: [ + { + name: "env", + run: 'printf \'%s:%s:%s:%s\' "$WORKBOX_NAME" "$WORKBOX_SOURCE" "$WORKBOX_WORKTREE" "$CUSTOM"', + env: { CUSTOM: "ok" }, + }, + ], + }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("ok"); + expect(result.message).toBe("provision completed."); + expect(result.copies.map((copy) => copy.status)).toEqual(["copied", "skipped"]); + expect(result.copies[0]).toEqual(expect.objectContaining({ required: false })); + expect(result.steps[0]).toEqual( + expect.objectContaining({ + name: "env", + exitCode: 0, + stdout: `box1:${sourceRoot}:${targetRoot}:ok`, + stderr: "", + }) + ); + expect(await readFile(join(targetRoot, "nested", ".env"), "utf8")).toBe("TOKEN=source\n"); + }); + }); + + it("creates destination parent directories", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await writeFile(join(sourceRoot, ".env"), "TOKEN=source\n"); + + const result = await runProvision( + { copy: [{ from: ".env", to: "config/local/.env", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.exitCode).toBe(0); + expect(await readFile(join(targetRoot, "config", "local", ".env"), "utf8")).toBe( + "TOKEN=source\n" + ); + }); + }); + + it("fails missing required files", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + const result = await runProvision( + { copy: [{ from: ".env", to: ".env", required: true }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain("missing required source"); + expect(result.copies[0]).toEqual(expect.objectContaining({ status: "failed" })); + }); + }); + + it("fails source paths that cannot be statted", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await writeFile(join(sourceRoot, "not-a-dir"), "file\n"); + + const result = await runProvision( + { copy: [{ from: "not-a-dir/.env", to: ".env", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain('provision copy "not-a-dir/.env" failed'); + }); + }); + + it("rejects directory sources", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await mkdir(join(sourceRoot, "config"), { recursive: true }); + + const result = await runProvision( + { copy: [{ from: "config", to: "config", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain("source is a directory"); + }); + }); + + it("reports copy failures", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await writeFile(join(sourceRoot, ".env"), "TOKEN=source\n"); + await mkdir(join(targetRoot, ".env"), { recursive: true }); + + const result = await runProvision( + { copy: [{ from: ".env", to: ".env", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("failed"); + expect(result.message).toContain('provision copy ".env" failed'); + }); + }); + + it("fails step errors", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + const result = await runProvision( + { copy: [], steps: [{ name: "fail", run: "exit 7" }] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ); + + expect(result.status).toBe("failed"); + expect(result.message).toBe('provision step "fail" failed (exit 7).'); + expect(result.exitCode).toBe(7); + }); + }); + + it("runs steps in inherited output mode", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + const result = await runProvision( + { copy: [], steps: [{ name: "ok", run: "true" }] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "inherit" } + ); + + expect(result.steps[0]).toEqual( + expect.not.objectContaining({ stdout: expect.any(String), stderr: expect.any(String) }) + ); + }); + }); + + it("rejects paths that escape their roots", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + await expect( + runProvision( + { copy: [{ from: "../.env", to: ".env", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ) + ).rejects.toBeInstanceOf(CliError); + }); + }); + + it("rejects symlinks that escape roots", async () => { + await withRoots(async ({ sourceRoot, targetRoot }) => { + const outside = await mkdtemp(join(tmpdir(), "workbox-provision-outside-")); + try { + await symlink(outside, join(sourceRoot, "outside")); + await expect( + runProvision( + { copy: [{ from: "outside/.env", to: ".env", required: false }], steps: [] }, + { sourceRoot, targetRoot, worktreeName: "box1", mode: "capture" } + ) + ).rejects.toThrow(/escapes repo root via symlink/); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/src/provision/runner.ts b/src/provision/runner.ts new file mode 100644 index 0000000..7e48994 --- /dev/null +++ b/src/provision/runner.ts @@ -0,0 +1,204 @@ +import { copyFile, mkdir, stat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { checkPathWithinRoot } from "../core/path"; +import { runShellCommand } from "../core/process"; +import { CliError } from "../ui/errors"; + +type ProvisionCopy = { + from: string; + to: string; + required: boolean; +}; + +type ProvisionStep = { + name: string; + run: string; + cwd?: string; + env?: Record; +}; + +type ProvisionCopyResult = { + from: string; + to: string; + required: boolean; + source: string; + destination: string; + status: "copied" | "skipped" | "failed"; + reason?: string; +}; + +type ProvisionStepResult = { + name: string; + command: string; + cwd: string; + exitCode: number; + stdout?: string; + stderr?: string; +}; + +type ProvisionResult = { + status: "ok" | "failed"; + message: string; + copies: ProvisionCopyResult[]; + steps: ProvisionStepResult[]; + exitCode: number; +}; + +type OutputMode = "inherit" | "capture"; + +const isMissingPathError = (error: unknown): boolean => + error instanceof Error && "code" in error && error.code === "ENOENT"; + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const resolveWithinRoot = async (rootDir: string, path: string, label: string): Promise => { + const resolved = resolve(rootDir, path); + const within = await checkPathWithinRoot({ + rootDir, + candidatePath: resolved, + label, + }); + if (!within.ok) { + throw new CliError(within.reason, { exitCode: 2 }); + } + return resolved; +}; + +const buildFailedResult = ( + message: string, + copies: ProvisionCopyResult[], + steps: ProvisionStepResult[], + exitCode: number +): ProvisionResult => ({ + status: "failed", + message, + copies, + steps, + exitCode, +}); + +export const runProvision = async ( + provision: { copy: ProvisionCopy[]; steps: ProvisionStep[] }, + options: { + sourceRoot: string; + targetRoot: string; + worktreeName: string; + mode: OutputMode; + } +): Promise => { + const copies: ProvisionCopyResult[] = []; + const steps: ProvisionStepResult[] = []; + + if (provision.copy.length === 0 && provision.steps.length === 0) { + return { + status: "ok", + message: "no provision actions configured.", + copies, + steps, + exitCode: 0, + }; + } + + for (const item of provision.copy) { + const source = await resolveWithinRoot(options.sourceRoot, item.from, "provision copy source"); + const destination = await resolveWithinRoot( + options.targetRoot, + item.to, + "provision copy destination" + ); + + let sourceStat: Awaited>; + try { + sourceStat = await stat(source); + } catch (error) { + if (!isMissingPathError(error)) { + const reason = getErrorMessage(error); + copies.push({ ...item, source, destination, status: "failed", reason }); + return buildFailedResult( + `provision copy "${item.from}" failed: ${reason}`, + copies, + steps, + 1 + ); + } + if (!item.required) { + copies.push({ ...item, source, destination, status: "skipped", reason: "missing source" }); + continue; + } + const reason = "missing required source"; + copies.push({ ...item, source, destination, status: "failed", reason }); + return buildFailedResult( + `provision copy "${item.from}" failed: ${reason}.`, + copies, + steps, + 1 + ); + } + + if (sourceStat.isDirectory()) { + const reason = "source is a directory"; + copies.push({ ...item, source, destination, status: "failed", reason }); + return buildFailedResult( + `provision copy "${item.from}" failed: ${reason}.`, + copies, + steps, + 1 + ); + } + + try { + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + } catch (error) { + const reason = getErrorMessage(error); + copies.push({ ...item, source, destination, status: "failed", reason }); + return buildFailedResult(`provision copy "${item.from}" failed: ${reason}`, copies, steps, 1); + } + + copies.push({ ...item, source, destination, status: "copied" }); + } + + for (const step of provision.steps) { + const cwd = await resolveWithinRoot(options.targetRoot, step.cwd ?? ".", "provision step cwd"); + const result = await runShellCommand({ + command: step.run, + cwd, + mode: options.mode, + env: { + WORKBOX_SOURCE: options.sourceRoot, + WORKBOX_WORKTREE: options.targetRoot, + WORKBOX_NAME: options.worktreeName, + ...step.env, + }, + }); + + steps.push({ + name: step.name, + command: step.run, + cwd, + exitCode: result.exitCode, + ...(options.mode === "capture" + ? { stdout: result.stdout.trim(), stderr: result.stderr.trim() } + : {}), + }); + + if (result.exitCode !== 0) { + return buildFailedResult( + `provision step "${step.name}" failed (exit ${result.exitCode}).`, + copies, + steps, + result.exitCode + ); + } + } + + return { + status: "ok", + message: "provision completed.", + copies, + steps, + exitCode: 0, + }; +};