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.
This commit is contained in:
@@ -62,6 +62,18 @@ Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens
|
|||||||
|
|
||||||
## paseo.json service scripts
|
## 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:
|
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||||
|
|
||||||
| Variable | Value |
|
| Variable | Value |
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
deriveWorktreeProjectHash,
|
deriveWorktreeProjectHash,
|
||||||
deletePaseoWorktree,
|
deletePaseoWorktree,
|
||||||
getScriptConfigs,
|
getScriptConfigs,
|
||||||
|
getWorktreeSetupCommands,
|
||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
|
getWorktreeTeardownCommands,
|
||||||
isServiceScript,
|
isServiceScript,
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
listPaseoWorktrees,
|
listPaseoWorktrees,
|
||||||
@@ -442,6 +444,79 @@ describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
|||||||
expect(portValue).toBeGreaterThan(0);
|
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 () => {
|
it("does not run setup commands when runSetup=false", async () => {
|
||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
worktree: {
|
worktree: {
|
||||||
@@ -858,6 +933,34 @@ describe("paseo worktree manager", () => {
|
|||||||
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
|
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 () => {
|
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
|
||||||
const paseoConfig = {
|
const paseoConfig = {
|
||||||
worktree: {
|
worktree: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import net from "node:net";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import * as pty from "node-pty";
|
import * as pty from "node-pty";
|
||||||
import stripAnsi from "strip-ansi";
|
import stripAnsi from "strip-ansi";
|
||||||
|
import { z } from "zod";
|
||||||
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
import { buildStringCommandShellInvocation } from "./string-command-shell.js";
|
||||||
import {
|
import {
|
||||||
normalizeBaseRefName,
|
normalizeBaseRefName,
|
||||||
@@ -20,22 +21,6 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
|||||||
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
||||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.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 execFileAsync = promisify(execFile);
|
||||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -99,6 +84,49 @@ export interface WorktreeTerminalConfig {
|
|||||||
command: string;
|
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<typeof PaseoConfigSchema>;
|
||||||
|
|
||||||
export interface PlainScriptConfig {
|
export interface PlainScriptConfig {
|
||||||
type?: undefined;
|
type?: undefined;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -207,7 +235,7 @@ function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(paseoConfigPath, "utf8"));
|
return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8")));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Failed to parse paseo.json`);
|
throw new Error(`Failed to parse paseo.json`);
|
||||||
}
|
}
|
||||||
@@ -215,20 +243,12 @@ function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
|||||||
|
|
||||||
export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||||
const config = readPaseoConfig(repoRoot);
|
const config = readPaseoConfig(repoRoot);
|
||||||
const setupCommands = config?.worktree?.setup;
|
return config?.worktree?.setup ?? [];
|
||||||
if (!setupCommands || setupCommands.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||||
const config = readPaseoConfig(repoRoot);
|
const config = readPaseoConfig(repoRoot);
|
||||||
const teardownCommands = config?.worktree?.teardown;
|
return config?.worktree?.teardown ?? [];
|
||||||
if (!teardownCommands || teardownCommands.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ function Worktrees() {
|
|||||||
<Code>
|
<Code>
|
||||||
<pre className="text-white/80">{`{
|
<pre className="text-white/80">{`{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": ["npm ci"],
|
"setup": "npm ci",
|
||||||
"teardown": ["rm -rf .cache"]
|
"teardown": "rm -rf .cache"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": { "command": "npm test" },
|
"test": { "command": "npm test" },
|
||||||
@@ -88,17 +88,15 @@ function Worktrees() {
|
|||||||
<Code>
|
<Code>
|
||||||
<pre className="text-white/80">{`{
|
<pre className="text-white/80">{`{
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"setup": [
|
"setup": "npm ci\\ncp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" .env\\nnpm run db:migrate",
|
||||||
"npm ci",
|
"teardown": "npm run db:drop || true"
|
||||||
"cp \\"$PASEO_SOURCE_CHECKOUT_PATH/.env\\" .env",
|
|
||||||
"npm run db:migrate"
|
|
||||||
],
|
|
||||||
"teardown": [
|
|
||||||
"npm run db:drop || true"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}`}</pre>
|
}`}</pre>
|
||||||
</Code>
|
</Code>
|
||||||
|
<p className="text-white/60 leading-relaxed">
|
||||||
|
Both fields accept a multiline shell script or an array of commands; commands run
|
||||||
|
sequentially either way.
|
||||||
|
</p>
|
||||||
<p className="text-white/60 leading-relaxed">
|
<p className="text-white/60 leading-relaxed">
|
||||||
Commands run with the worktree as <code className="font-mono">cwd</code>. Use{" "}
|
Commands run with the worktree as <code className="font-mono">cwd</code>. Use{" "}
|
||||||
<code className="font-mono">$PASEO_SOURCE_CHECKOUT_PATH</code> to reach files in the
|
<code className="font-mono">$PASEO_SOURCE_CHECKOUT_PATH</code> to reach files in the
|
||||||
|
|||||||
Reference in New Issue
Block a user