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")),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user