From 4dd3b0006b6b7f2d9ee315f8bd361dd7b7863f39 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 25 Apr 2026 13:58:49 +0700 Subject: [PATCH] Accept string form for worktree setup/teardown Lifecycle config now takes either a multiline shell script string or an array of commands, making it friendlier to edit in a textarea. Schema parsing moved to Zod so coercion stays in one place. --- docs/DEVELOPMENT.md | 12 ++ packages/server/src/utils/worktree.test.ts | 103 ++++++++++++++++++ packages/server/src/utils/worktree.ts | 74 ++++++++----- .../website/src/routes/docs/worktrees.tsx | 18 ++- 4 files changed, 170 insertions(+), 37 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1e965751b..8194162d4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -62,6 +62,18 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens ## paseo.json service scripts +`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array +of commands. Both run sequentially. + +```json +{ + "worktree": { + "setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate", + "teardown": "npm run db:drop || true" + } +} +``` + Every `scripts` entry with `"type": "service"` receives these environment variables: | Variable | Value | diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 0a9ab967a..ac0146c5e 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -5,7 +5,9 @@ import { deriveWorktreeProjectHash, deletePaseoWorktree, getScriptConfigs, + getWorktreeSetupCommands, getWorktreeTerminalSpecs, + getWorktreeTeardownCommands, isServiceScript, isPaseoOwnedWorktreeCwd, listPaseoWorktrees, @@ -442,6 +444,79 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => { expect(portValue).toBeGreaterThan(0); }); + it("runs string setup scripts from paseo.json as a single shell command", async () => { + const paseoConfig = { + worktree: { + setup: 'greeting="hello from string setup"\necho "$greeting" > setup.log', + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string setup"', { + cwd: repoDir, + }); + + const result = await createLegacyWorktreeForTest({ + branchName: "main", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "string-setup-test", + paseoHome, + }); + + expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([ + 'greeting="hello from string setup"\necho "$greeting" > setup.log', + ]); + expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe( + "hello from string setup", + ); + }); + + it("treats blank lifecycle strings as empty", () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: " \n\t ", + teardown: " \n ", + }, + }), + ); + + expect(getWorktreeSetupCommands(repoDir)).toEqual([]); + expect(getWorktreeTeardownCommands(repoDir)).toEqual([]); + }); + + it("filters non-string and blank entries from lifecycle arrays", () => { + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + 'echo "first" > setup-array.log', + null, + " ", + 'echo "second" >> setup-array.log', + ], + teardown: [ + 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + null, + "", + 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + ], + }, + }), + ); + + expect(getWorktreeSetupCommands(repoDir)).toEqual([ + 'echo "first" > setup-array.log', + 'echo "second" >> setup-array.log', + ]); + expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + 'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + 'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"', + ]); + }); + it("does not run setup commands when runSetup=false", async () => { const paseoConfig = { worktree: { @@ -858,6 +933,34 @@ describe("paseo worktree manager", () => { expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`); }); + it("runs string teardown scripts from paseo.json as a single shell command", async () => { + const paseoConfig = { + worktree: { + teardown: + 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + }, + }; + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig)); + execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add string teardown"', { + cwd: repoDir, + }); + + const created = await createLegacyWorktreeForTest({ + branchName: "teardown-string-branch", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "teardown-string-test", + paseoHome, + }); + + await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }); + + expect(getWorktreeTeardownCommands(repoDir)).toEqual([ + 'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"', + ]); + expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string"); + }); + it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => { const paseoConfig = { worktree: { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1340ddcab..54c1aa7c5 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -7,6 +7,7 @@ import net from "node:net"; import { createHash } from "node:crypto"; import * as pty from "node-pty"; import stripAnsi from "strip-ansi"; +import { z } from "zod"; import { buildStringCommandShellInvocation } from "./string-command-shell.js"; import { normalizeBaseRefName, @@ -20,22 +21,6 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; -interface PaseoConfig { - worktree?: { - setup?: string[]; - teardown?: string[]; - terminals?: WorktreeTerminalConfig[]; - }; - scripts?: Record< - string, - { - type?: unknown; - command?: unknown; - port?: unknown; - } - >; -} - const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -99,6 +84,49 @@ export interface WorktreeTerminalConfig { command: string; } +function normalizeLifecycleCommands(commands: unknown): string[] { + if (typeof commands === "string") { + return commands.trim().length > 0 ? [commands] : []; + } + if (!Array.isArray(commands)) { + return []; + } + return commands.filter((command): command is string => { + return typeof command === "string" && command.trim().length > 0; + }); +} + +const LifecycleCommandsSchema = z.unknown().transform(normalizeLifecycleCommands); + +const WorktreeConfigSchema = z + .object({ + setup: LifecycleCommandsSchema, + teardown: LifecycleCommandsSchema, + terminals: z.unknown().optional(), + }) + .passthrough() + .catch({ setup: [], teardown: [] }); + +const ScriptEntrySchema = z + .object({ + type: z.unknown(), + command: z.unknown(), + port: z.unknown(), + }) + .partial() + .passthrough() + .catch({}); + +const PaseoConfigSchema = z + .object({ + worktree: WorktreeConfigSchema.optional(), + scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}), + }) + .passthrough() + .catch({}); + +type PaseoConfig = z.infer; + export interface PlainScriptConfig { type?: undefined; command: string; @@ -207,7 +235,7 @@ function readPaseoConfig(repoRoot: string): PaseoConfig | null { return null; } try { - return JSON.parse(readFileSync(paseoConfigPath, "utf8")); + return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8"))); } catch { throw new Error(`Failed to parse paseo.json`); } @@ -215,20 +243,12 @@ function readPaseoConfig(repoRoot: string): PaseoConfig | null { export function getWorktreeSetupCommands(repoRoot: string): string[] { const config = readPaseoConfig(repoRoot); - const setupCommands = config?.worktree?.setup; - if (!setupCommands || setupCommands.length === 0) { - return []; - } - return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); + return config?.worktree?.setup ?? []; } export function getWorktreeTeardownCommands(repoRoot: string): string[] { const config = readPaseoConfig(repoRoot); - const teardownCommands = config?.worktree?.teardown; - if (!teardownCommands || teardownCommands.length === 0) { - return []; - } - return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0); + return config?.worktree?.teardown ?? []; } export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] { diff --git a/packages/website/src/routes/docs/worktrees.tsx b/packages/website/src/routes/docs/worktrees.tsx index 8ef42bd44..f45dc0449 100644 --- a/packages/website/src/routes/docs/worktrees.tsx +++ b/packages/website/src/routes/docs/worktrees.tsx @@ -64,8 +64,8 @@ function Worktrees() {
{`{
   "worktree": {
-    "setup":    ["npm ci"],
-    "teardown": ["rm -rf .cache"]
+    "setup":    "npm ci",
+    "teardown": "rm -rf .cache"
   },
   "scripts": {
     "test": { "command": "npm test" },
@@ -88,17 +88,15 @@ function Worktrees() {
         
           
{`{
   "worktree": {
-    "setup": [
-      "npm ci",
-      "cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" .env",
-      "npm run db:migrate"
-    ],
-    "teardown": [
-      "npm run db:drop || true"
-    ]
+    "setup": "npm ci\\ncp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" .env\\nnpm run db:migrate",
+    "teardown": "npm run db:drop || true"
   }
 }`}
+

+ Both fields accept a multiline shell script or an array of commands; commands run + sequentially either way. +

Commands run with the worktree as cwd. Use{" "} $PASEO_SOURCE_CHECKOUT_PATH to reach files in the