feat: project settings page with paseo.json editor

Adds a Projects settings flow for inspecting and editing per-project
paseo.json (worktree setup/teardown, scripts, terminals) directly from the
app. Reads/writes go through new daemon RPCs with revision-based optimistic
concurrency, and the screen always keeps its header and host picker visible
so a hung host doesn't trap the user.
This commit is contained in:
Mohamed Boudra
2026-04-26 11:37:47 +07:00
parent 18903bcecb
commit 219050a7ab
36 changed files with 6447 additions and 81 deletions
@@ -0,0 +1,186 @@
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
import {
readPaseoConfigForEdit,
statPaseoConfigPath,
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
describe("paseo config file substrate", () => {
let tempDir: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "paseo-config-file-test-")));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns null config and revision when paseo.json is missing", () => {
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({ ok: true, config: null, revision: null });
});
it("returns invalid_project_config for invalid JSON", () => {
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: false,
error: { code: "invalid_project_config" },
});
});
it("preserves raw lifecycle string and array forms with a revision token", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
}),
);
const result = readPaseoConfigForEdit(tempDir);
expect(result).toEqual({
ok: true,
config: {
worktree: {
setup: "npm install",
teardown: ["npm run clean", "npm run reset"],
},
},
revision: statPaseoConfigPath(tempDir),
});
});
it("keeps runtime lifecycle commands normalized for execution", () => {
writeFileSync(
join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: "npm install",
teardown: ["npm run clean", "", 42, "npm run reset"],
},
}),
);
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
});
it("writes pretty JSON with a trailing newline when revision matches", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "npm install" } },
expectedRevision,
});
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm install" } },
revision: statPaseoConfigPath(tempDir),
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
);
});
it("rejects stale writes when the current revision changed before rename", () => {
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
const expectedRevision = statPaseoConfigPath(tempDir);
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
const currentRevision = statPaseoConfigPath(tempDir);
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config: { worktree: { setup: "from editor" } },
expectedRevision,
});
expect(result).toEqual({
ok: false,
error: { code: "stale_project_config", currentRevision },
});
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
JSON.stringify({ worktree: { setup: "new" } }),
);
});
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
const config = {
extraTop: { keep: true },
worktree: {
setup: ["npm install"],
customWorktreeField: "preserve me",
},
scripts: {
dev: {
command: "npm run dev",
type: "service",
customScriptField: 123,
},
},
};
const result = writePaseoConfigForEdit({
repoRoot: tempDir,
config,
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
expect(readPaseoConfigForEdit(tempDir)).toEqual({
ok: true,
config,
revision: statPaseoConfigPath(tempDir),
});
});
it("returns write_failed for filesystem write exceptions", () => {
const fileRoot = join(tempDir, "not-a-directory");
writeFileSync(fileRoot, "file");
const result = writePaseoConfigForEdit({
repoRoot: fileRoot,
config: { worktree: { setup: "npm install" } },
expectedRevision: null,
});
expect(result).toEqual({
ok: false,
error: { code: "write_failed" },
});
});
it("creates paseo.json when the file is still missing and expected revision is null", () => {
mkdirSync(join(tempDir, "nested"));
const result = writePaseoConfigForEdit({
repoRoot: join(tempDir, "nested"),
config: { scripts: { dev: { command: "npm run dev" } } },
expectedRevision: null,
});
expect(result).toEqual({
ok: true,
config: { scripts: { dev: { command: "npm run dev" } } },
revision: statPaseoConfigPath(join(tempDir, "nested")),
});
});
});
@@ -0,0 +1,129 @@
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import {
PaseoConfigRawSchema,
type PaseoConfigRaw,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export {
PaseoConfigRevisionSchema,
ProjectConfigRpcErrorSchema,
type PaseoConfigRevision,
type ProjectConfigRpcError,
} from "./paseo-config-schema.js";
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
export type ReadPaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
| { ok: false; error: ProjectConfigRpcError };
export type WritePaseoConfigForEditResult =
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
| { ok: false; error: ProjectConfigRpcError };
export interface WritePaseoConfigForEditInput {
repoRoot: string;
config: PaseoConfigRaw;
expectedRevision: PaseoConfigRevision | null;
}
export function resolvePaseoConfigPath(repoRoot: string): string {
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
}
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
const stats = statSync(configPath);
return {
mtimeMs: stats.mtimeMs,
size: stats.size,
};
}
export function readPaseoConfigJson(repoRoot: string): unknown | null {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return null;
}
return JSON.parse(readFileSync(configPath, "utf8"));
}
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null, revision: null };
}
return {
ok: true,
config: PaseoConfigRawSchema.parse(json),
revision: statPaseoConfigPath(repoRoot),
};
} catch {
return {
ok: false,
error: { code: "invalid_project_config" },
};
}
}
export function writePaseoConfigForEdit(
input: WritePaseoConfigForEditInput,
): WritePaseoConfigForEditResult {
const parsed = PaseoConfigRawSchema.safeParse(input.config);
if (!parsed.success) {
return { ok: false, error: { code: "invalid_project_config" } };
}
const configPath = resolvePaseoConfigPath(input.repoRoot);
const tempPath = join(
input.repoRoot,
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
);
try {
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
const currentRevision = statPaseoConfigPath(input.repoRoot);
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
removeTempPaseoConfig(tempPath);
return {
ok: false,
error: { code: "stale_project_config", currentRevision },
};
}
renameSync(tempPath, configPath);
const revision = statPaseoConfigPath(input.repoRoot);
if (!revision) {
return { ok: false, error: { code: "write_failed" } };
}
return { ok: true, config: parsed.data, revision };
} catch {
removeTempPaseoConfig(tempPath);
return { ok: false, error: { code: "write_failed" } };
}
}
function paseoConfigRevisionsEqual(
left: PaseoConfigRevision | null,
right: PaseoConfigRevision | null,
): boolean {
if (left === null || right === null) {
return left === right;
}
return left.mtimeMs === right.mtimeMs && left.size === right.size;
}
function removeTempPaseoConfig(tempPath: string): void {
try {
rmSync(tempPath, { force: true });
} catch {
// Best-effort cleanup only; callers need the original write outcome.
}
}
@@ -0,0 +1,75 @@
import { z } from "zod";
export 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;
});
}
export const PaseoLifecycleCommandRawSchema = z.union([z.string(), z.array(z.string())]);
export const PaseoScriptEntryRawSchema = z
.object({
type: z.unknown().optional(),
command: z.unknown().optional(),
port: z.unknown().optional(),
})
.passthrough();
export const PaseoWorktreeConfigRawSchema = z
.object({
setup: PaseoLifecycleCommandRawSchema.optional(),
teardown: PaseoLifecycleCommandRawSchema.optional(),
terminals: z.unknown().optional(),
})
.passthrough();
export const PaseoConfigRawSchema = z
.object({
worktree: PaseoWorktreeConfigRawSchema.optional(),
scripts: z.record(z.string(), PaseoScriptEntryRawSchema).optional(),
})
.passthrough();
export const WorktreeConfigSchema = PaseoWorktreeConfigRawSchema.extend({
setup: z.unknown().transform(normalizeLifecycleCommands),
teardown: z.unknown().transform(normalizeLifecycleCommands),
})
.passthrough()
.catch({ setup: [], teardown: [] });
export const ScriptEntrySchema = PaseoScriptEntryRawSchema.catch({});
export const PaseoConfigSchema = PaseoConfigRawSchema.extend({
worktree: WorktreeConfigSchema.optional(),
scripts: z.record(z.string(), ScriptEntrySchema).optional().catch({}),
})
.passthrough()
.catch({});
export const PaseoConfigRevisionSchema = z.object({
mtimeMs: z.number(),
size: z.number(),
});
export const ProjectConfigRpcErrorSchema = z.discriminatedUnion("code", [
z.object({ code: z.literal("project_not_found") }),
z.object({ code: z.literal("invalid_project_config") }),
z.object({
code: z.literal("stale_project_config"),
currentRevision: PaseoConfigRevisionSchema.nullable(),
}),
z.object({ code: z.literal("write_failed") }),
]);
export type PaseoScriptEntryRaw = z.infer<typeof PaseoScriptEntryRawSchema>;
export type PaseoConfigRaw = z.infer<typeof PaseoConfigRawSchema>;
export type PaseoConfig = z.infer<typeof PaseoConfigSchema>;
export type PaseoConfigRevision = z.infer<typeof PaseoConfigRevisionSchema>;
export type ProjectConfigRpcError = z.infer<typeof ProjectConfigRpcErrorSchema>;
+18 -51
View File
@@ -1,14 +1,24 @@
import { execFile, spawn } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { rm, stat } from "fs/promises";
import { join, basename, dirname, resolve, sep } from "path";
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 { readPaseoConfigJson } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
PaseoScriptEntryRawSchema,
PaseoWorktreeConfigRawSchema,
PaseoConfigSchema,
type PaseoConfig,
type PaseoConfigRaw,
} from "./paseo-config-schema.js";
import { PaseoConfigSchema, type PaseoConfig } from "./paseo-config-schema.js";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
@@ -84,49 +94,6 @@ 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<typeof PaseoConfigSchema>;
export interface PlainScriptConfig {
type?: undefined;
command: string;
@@ -229,13 +196,13 @@ export class UnknownBranchError extends Error {
}
}
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
const paseoConfigPath = join(repoRoot, "paseo.json");
if (!existsSync(paseoConfigPath)) {
return null;
}
export function readPaseoConfig(repoRoot: string): PaseoConfig | null {
try {
return PaseoConfigSchema.parse(JSON.parse(readFileSync(paseoConfigPath, "utf8")));
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return null;
}
return PaseoConfigSchema.parse(json);
} catch {
throw new Error(`Failed to parse paseo.json`);
}