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:
@@ -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>;
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user