mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
feat(settings): import existing project setup
This commit is contained in:
@@ -1611,6 +1611,10 @@ export class Session {
|
||||
return this.projectConfigSession.handleReadProjectConfigRequest(msg);
|
||||
case "write_project_config_request":
|
||||
return this.projectConfigSession.handleWriteProjectConfigRequest(msg);
|
||||
case "project.config.get_import.request":
|
||||
return this.projectConfigSession.handleGetProjectConfigImportRequest(msg);
|
||||
case "project.config.apply_import.request":
|
||||
return this.projectConfigSession.handleApplyProjectConfigImportRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { readPaseoConfigForEdit } from "../../../../utils/paseo-config-file.js";
|
||||
import { applyProjectConfigImport, inspectProjectConfigImport } from "./index.js";
|
||||
import { InvalidProjectConfigImportSourceError } from "./model.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const CONDUCTOR_SOURCE = { kind: "conductor" } as const;
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeRepo(): string {
|
||||
const repo = mkdtempSync(join(tmpdir(), "conductor-import-test-"));
|
||||
tempDirs.push(repo);
|
||||
return repo;
|
||||
}
|
||||
|
||||
function writeSharedToml(repo: string, contents: string): void {
|
||||
mkdirSync(join(repo, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repo, ".conductor", "settings.toml"), contents);
|
||||
}
|
||||
|
||||
function writeLocalToml(repo: string, contents: string): void {
|
||||
mkdirSync(join(repo, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repo, ".conductor", "settings.local.toml"), contents);
|
||||
}
|
||||
|
||||
function inspect(repo: string, paseoConfig = {}) {
|
||||
return inspectProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
paseoConfig,
|
||||
paseoRevision: null,
|
||||
});
|
||||
}
|
||||
|
||||
function captureInvalidSourceError(repo: string): InvalidProjectConfigImportSourceError {
|
||||
try {
|
||||
inspect(repo);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProjectConfigImportSourceError) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
throw new Error("Expected invalid source config error");
|
||||
}
|
||||
|
||||
function readValidPaseoConfig(
|
||||
repo: string,
|
||||
): Extract<ReturnType<typeof readPaseoConfigForEdit>, { ok: true }> {
|
||||
const result = readPaseoConfigForEdit(repo);
|
||||
if (!result.ok) {
|
||||
throw new Error("Expected valid paseo config");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("Conductor project config import", () => {
|
||||
test("maps shared TOML setup, archive, run service, cwd, args, rewrites, and unsupported settings", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
repo,
|
||||
`
|
||||
file_include_globs = ["config/*.local"]
|
||||
|
||||
[scripts]
|
||||
setup = "echo $CONDUCTOR_WORKSPACE_PATH && echo \${CONDUCTOR_ROOT_PATH}"
|
||||
archive = "cleanup $CONDUCTOR_PORT"
|
||||
run_mode = "nonconcurrent"
|
||||
auto_run_after_setup = true
|
||||
|
||||
[scripts.run.dev]
|
||||
command = "npm run dev -- --port $CONDUCTOR_PORT"
|
||||
args = ["--host", "0.0.0.0"]
|
||||
hide = true
|
||||
|
||||
[scripts.run.dev.options]
|
||||
cwd = "apps/web"
|
||||
|
||||
[environment_variables]
|
||||
SECRET_TOKEN = "do-not-return"
|
||||
|
||||
[spotlight_testing]
|
||||
enabled = true
|
||||
`,
|
||||
);
|
||||
|
||||
const preview = inspect(repo);
|
||||
|
||||
expect(preview.status).toBe("available");
|
||||
expect(preview.inputs).toEqual([{ role: "shared", relativePath: ".conductor/settings.toml" }]);
|
||||
expect(preview.preview).toMatchObject({
|
||||
worktree: {
|
||||
setup: "echo $PASEO_WORKTREE_PATH && echo ${PASEO_SOURCE_CHECKOUT_PATH}",
|
||||
teardown: "cleanup $PASEO_WORKTREE_PORT",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
type: "service",
|
||||
port: "$PASEO_PORT",
|
||||
command: "cd -- 'apps/web' && npm run dev -- --port $PASEO_PORT '--host' '0.0.0.0'",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(preview.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "worktree.setup", outcome: "import" }),
|
||||
expect.objectContaining({ key: "worktree.teardown", outcome: "import" }),
|
||||
expect.objectContaining({ key: "scripts.dev", outcome: "import" }),
|
||||
expect.objectContaining({ key: "variables.CONDUCTOR_WORKSPACE_PATH", outcome: "rewrite" }),
|
||||
expect.objectContaining({ key: "variables.CONDUCTOR_ROOT_PATH", outcome: "rewrite" }),
|
||||
expect.objectContaining({ key: "variables.CONDUCTOR_PORT", outcome: "rewrite" }),
|
||||
expect.objectContaining({ key: "scripts.run_mode", outcome: "unsupported" }),
|
||||
expect.objectContaining({ key: "scripts.auto_run_after_setup", outcome: "unsupported" }),
|
||||
expect.objectContaining({ key: "file_include_globs", outcome: "unsupported" }),
|
||||
expect.objectContaining({
|
||||
key: "environment_variables",
|
||||
outcome: "unsupported",
|
||||
detail: "Environment variable values are not imported. Found: SECRET_TOKEN.",
|
||||
}),
|
||||
expect.objectContaining({ key: "spotlight_testing", outcome: "unsupported" }),
|
||||
expect.objectContaining({ key: "scripts.dev.hide", outcome: "unsupported" }),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(preview.items)).not.toContain("do-not-return");
|
||||
});
|
||||
|
||||
test("local TOML overrides shared scripts and legacy JSON is ignored when shared TOML exists", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
repo,
|
||||
`
|
||||
[scripts]
|
||||
setup = "shared setup"
|
||||
[scripts.run.dev]
|
||||
command = "shared dev"
|
||||
`,
|
||||
);
|
||||
writeLocalToml(
|
||||
repo,
|
||||
`
|
||||
[scripts]
|
||||
setup = "local setup"
|
||||
[scripts.run.dev]
|
||||
command = "local dev"
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, "conductor.json"),
|
||||
JSON.stringify({ scripts: { setup: "legacy setup", run: "legacy run" } }),
|
||||
);
|
||||
|
||||
const preview = inspect(repo);
|
||||
|
||||
expect(preview.inputs).toEqual([
|
||||
{ role: "shared", relativePath: ".conductor/settings.toml" },
|
||||
{ role: "local", relativePath: ".conductor/settings.local.toml" },
|
||||
]);
|
||||
expect(preview.preview).toMatchObject({
|
||||
worktree: { setup: "local setup" },
|
||||
scripts: { dev: { command: "local dev" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("imports legacy conductor.json when shared TOML is absent", () => {
|
||||
const repo = makeRepo();
|
||||
writeFileSync(
|
||||
join(repo, "conductor.json"),
|
||||
JSON.stringify({ scripts: { setup: "legacy setup", run: "npm test" } }),
|
||||
);
|
||||
|
||||
const preview = inspect(repo);
|
||||
|
||||
expect(preview.inputs).toEqual([{ role: "legacy", relativePath: "conductor.json" }]);
|
||||
expect(preview.preview).toMatchObject({
|
||||
worktree: { setup: "legacy setup" },
|
||||
scripts: { run: { command: "npm test" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports missing and empty repository-local Conductor configs", () => {
|
||||
const missingRepo = makeRepo();
|
||||
const emptyRepo = makeRepo();
|
||||
writeSharedToml(emptyRepo, "");
|
||||
|
||||
expect(inspect(missingRepo)).toEqual({
|
||||
repoRoot: missingRepo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
status: "not_found",
|
||||
sourceRevision: null,
|
||||
paseoRevision: null,
|
||||
inputs: [],
|
||||
items: [],
|
||||
preview: null,
|
||||
});
|
||||
expect(inspect(emptyRepo)).toMatchObject({
|
||||
repoRoot: emptyRepo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
status: "nothing_to_import",
|
||||
inputs: [{ role: "shared", relativePath: ".conductor/settings.toml" }],
|
||||
items: [],
|
||||
preview: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves existing Paseo values as collisions and reports nothing to import on retry", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
repo,
|
||||
`
|
||||
[scripts]
|
||||
setup = "npm ci"
|
||||
[scripts.run.dev]
|
||||
command = "npm run dev"
|
||||
`,
|
||||
);
|
||||
|
||||
const preview = inspect(repo, {
|
||||
custom: { keep: true },
|
||||
worktree: { setup: "pnpm install", extra: "field" },
|
||||
scripts: { dev: { command: "pnpm dev", color: "blue" } },
|
||||
});
|
||||
|
||||
expect(preview.status).toBe("nothing_to_import");
|
||||
expect(preview.preview).toBeNull();
|
||||
expect(preview.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: "worktree.setup", outcome: "collision" }),
|
||||
expect.objectContaining({ key: "scripts.dev", outcome: "collision" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rewrite variable substrings and warns for unsupported Conductor variables", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(
|
||||
repo,
|
||||
`
|
||||
[scripts]
|
||||
setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH"
|
||||
`,
|
||||
);
|
||||
|
||||
const preview = inspect(repo);
|
||||
|
||||
expect(preview.preview).toMatchObject({
|
||||
worktree: { setup: "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH" },
|
||||
});
|
||||
expect(preview.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "variables.CONDUCTOR_DEFAULT_BRANCH",
|
||||
outcome: "unsupported",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(preview.items).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ key: "variables.CONDUCTOR_PORT" })]),
|
||||
);
|
||||
});
|
||||
|
||||
test("source digest changes after a byte change and apply rejects stale source", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
const preview = inspect(repo);
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "pnpm install"\n');
|
||||
|
||||
const result = applyProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
repoRoot: repo,
|
||||
error: { code: "stale_source_config", source: CONDUCTOR_SOURCE },
|
||||
});
|
||||
});
|
||||
|
||||
test("apply rejects a stale paseo.json revision", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
writeFileSync(join(repo, "paseo.json"), '{"custom":true}\n');
|
||||
const paseoRevision = readValidPaseoConfig(repo);
|
||||
const preview = inspectProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
paseoConfig: paseoRevision.config ?? {},
|
||||
paseoRevision: paseoRevision.revision,
|
||||
});
|
||||
writeFileSync(join(repo, "paseo.json"), '{"custom":false}\n');
|
||||
|
||||
expect(
|
||||
applyProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: paseoRevision.revision,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
repoRoot: repo,
|
||||
error: { code: "stale_project_config" },
|
||||
});
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")("apply reports write failure visibly", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
const preview = inspect(repo);
|
||||
chmodSync(repo, 0o555);
|
||||
try {
|
||||
expect(
|
||||
applyProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: null,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
repoRoot: repo,
|
||||
error: { code: "write_failed" },
|
||||
});
|
||||
} finally {
|
||||
chmodSync(repo, 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
test("apply writes formatted paseo.json after recomputing from disk", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
const preview = inspect(repo);
|
||||
|
||||
const result = applyProjectConfigImport({
|
||||
repoRoot: repo,
|
||||
source: CONDUCTOR_SOURCE,
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: null,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
repoRoot: repo,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
});
|
||||
expect(readFileSync(join(repo, "paseo.json"), "utf8")).toBe(
|
||||
'{\n "worktree": {\n "setup": "npm ci"\n }\n}\n',
|
||||
);
|
||||
expect(readPaseoConfigForEdit(repo)).toMatchObject({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed TOML identifies the safe relative source path", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, "[scripts\nsetup = nope");
|
||||
|
||||
expect(() => inspect(repo)).toThrow(InvalidProjectConfigImportSourceError);
|
||||
expect(captureInvalidSourceError(repo)).toMatchObject({
|
||||
source: CONDUCTOR_SOURCE,
|
||||
relativePath: ".conductor/settings.toml",
|
||||
});
|
||||
});
|
||||
|
||||
test("malformed local TOML identifies the local override path", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
writeLocalToml(repo, "[scripts\nsetup = nope");
|
||||
|
||||
expect(captureInvalidSourceError(repo)).toMatchObject({
|
||||
source: CONDUCTOR_SOURCE,
|
||||
relativePath: ".conductor/settings.local.toml",
|
||||
});
|
||||
});
|
||||
|
||||
test(".worktreeinclude is reported and not converted to shell commands", () => {
|
||||
const repo = makeRepo();
|
||||
writeSharedToml(repo, '[scripts]\nsetup = "npm ci"\n');
|
||||
writeFileSync(join(repo, ".worktreeinclude"), "config/*.local\n");
|
||||
|
||||
expect(inspect(repo).items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: ".worktreeinclude",
|
||||
outcome: "unsupported",
|
||||
detail: "Worktree include patterns are not converted to shell copy commands.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
writePaseoConfigForEdit,
|
||||
} from "../../../../utils/paseo-config-file.js";
|
||||
import { inspectConductorImport } from "./sources/conductor.js";
|
||||
import { mergeProjectConfigImport } from "./merge.js";
|
||||
import {
|
||||
InvalidProjectConfigImportSourceError,
|
||||
type ApplyProjectConfigImportInput,
|
||||
type InspectProjectConfigImportInput,
|
||||
type ProjectConfigImportApplyResult,
|
||||
type ProjectConfigImportCandidate,
|
||||
type ProjectConfigImportPreview,
|
||||
type ProjectConfigImportSource,
|
||||
} from "./model.js";
|
||||
|
||||
export type {
|
||||
ProjectConfigImportApplyResult,
|
||||
ProjectConfigImportPreview,
|
||||
ProjectConfigImportSource,
|
||||
};
|
||||
|
||||
export function inspectProjectConfigImport(
|
||||
input: InspectProjectConfigImportInput,
|
||||
): ProjectConfigImportPreview {
|
||||
let candidate: ProjectConfigImportCandidate | null;
|
||||
switch (input.source.kind) {
|
||||
case "conductor":
|
||||
candidate = inspectConductorImport({
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.source,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return mergeProjectConfigImport({
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.source,
|
||||
candidate,
|
||||
paseoConfig: input.paseoConfig,
|
||||
paseoRevision: input.paseoRevision,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyProjectConfigImport(
|
||||
input: ApplyProjectConfigImportInput,
|
||||
): ProjectConfigImportApplyResult {
|
||||
const currentConfig = readPaseoConfigForEdit(input.repoRoot);
|
||||
if (!currentConfig.ok) {
|
||||
return { ok: false, repoRoot: input.repoRoot, error: currentConfig.error };
|
||||
}
|
||||
|
||||
let preview: ProjectConfigImportPreview;
|
||||
try {
|
||||
preview = inspectProjectConfigImport({
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.source,
|
||||
paseoConfig: currentConfig.config ?? {},
|
||||
paseoRevision: currentConfig.revision,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProjectConfigImportSourceError) {
|
||||
return {
|
||||
ok: false,
|
||||
repoRoot: input.repoRoot,
|
||||
error: {
|
||||
code: "invalid_source_config",
|
||||
source: error.source,
|
||||
relativePath: error.relativePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (preview.status === "not_found" || !preview.sourceRevision) {
|
||||
return {
|
||||
ok: false,
|
||||
repoRoot: input.repoRoot,
|
||||
error: { code: "source_config_not_found", source: input.source },
|
||||
};
|
||||
}
|
||||
if (preview.sourceRevision !== input.expectedSourceRevision) {
|
||||
return {
|
||||
ok: false,
|
||||
repoRoot: input.repoRoot,
|
||||
error: { code: "stale_source_config", source: input.source },
|
||||
};
|
||||
}
|
||||
if (!paseoConfigRevisionsEqual(currentConfig.revision, input.expectedPaseoRevision)) {
|
||||
return {
|
||||
ok: false,
|
||||
repoRoot: input.repoRoot,
|
||||
error: { code: "stale_project_config", currentRevision: currentConfig.revision },
|
||||
};
|
||||
}
|
||||
if (preview.status === "nothing_to_import" || !preview.preview) {
|
||||
return { ok: false, repoRoot: input.repoRoot, error: { code: "nothing_to_import" } };
|
||||
}
|
||||
|
||||
const written = writePaseoConfigForEdit({
|
||||
repoRoot: input.repoRoot,
|
||||
config: preview.preview,
|
||||
expectedRevision: input.expectedPaseoRevision,
|
||||
});
|
||||
if (!written.ok) {
|
||||
return { ok: false, repoRoot: input.repoRoot, error: written.error };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.source,
|
||||
config: written.config,
|
||||
revision: written.revision,
|
||||
items: preview.items,
|
||||
};
|
||||
}
|
||||
|
||||
function paseoConfigRevisionsEqual(
|
||||
left: ProjectConfigImportPreview["paseoRevision"],
|
||||
right: ProjectConfigImportPreview["paseoRevision"],
|
||||
): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
}
|
||||
return left.mtimeMs === right.mtimeMs && left.size === right.size;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
|
||||
import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./model.js";
|
||||
|
||||
interface MergeProjectConfigImportInput {
|
||||
repoRoot: string;
|
||||
source: ProjectConfigImportPreview["source"];
|
||||
candidate: ProjectConfigImportCandidate | null;
|
||||
paseoConfig: PaseoConfigRaw;
|
||||
paseoRevision: ProjectConfigImportPreview["paseoRevision"];
|
||||
}
|
||||
|
||||
export function mergeProjectConfigImport(
|
||||
input: MergeProjectConfigImportInput,
|
||||
): ProjectConfigImportPreview {
|
||||
if (!input.candidate) {
|
||||
return {
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.source,
|
||||
status: "not_found",
|
||||
sourceRevision: null,
|
||||
paseoRevision: input.paseoRevision,
|
||||
inputs: [],
|
||||
items: [],
|
||||
preview: null,
|
||||
};
|
||||
}
|
||||
|
||||
const base = input.paseoConfig;
|
||||
const merged: PaseoConfigRaw = { ...base };
|
||||
const items = input.candidate.items.map((item) => ({ ...item }));
|
||||
let importedCount = 0;
|
||||
|
||||
const patchWorktree = input.candidate.patch.worktree ?? {};
|
||||
if (Object.hasOwn(patchWorktree, "setup")) {
|
||||
if (hasLifecycle(base.worktree?.setup)) {
|
||||
setOutcome(items, "worktree.setup", "collision", "Paseo already has setup commands.");
|
||||
} else {
|
||||
merged.worktree = { ...merged.worktree, setup: patchWorktree.setup };
|
||||
importedCount += 1;
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(patchWorktree, "teardown")) {
|
||||
if (hasLifecycle(base.worktree?.teardown)) {
|
||||
setOutcome(items, "worktree.teardown", "collision", "Paseo already has teardown commands.");
|
||||
} else {
|
||||
merged.worktree = { ...merged.worktree, teardown: patchWorktree.teardown };
|
||||
importedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const patchScripts = input.candidate.patch.scripts ?? {};
|
||||
for (const [scriptId, script] of Object.entries(patchScripts)) {
|
||||
const key = `scripts.${scriptId}`;
|
||||
if (base.scripts && Object.hasOwn(base.scripts, scriptId)) {
|
||||
setOutcome(items, key, "collision", `Paseo already has a "${scriptId}" script.`);
|
||||
continue;
|
||||
}
|
||||
merged.scripts = { ...merged.scripts, [scriptId]: script };
|
||||
importedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: input.repoRoot,
|
||||
source: input.candidate.source,
|
||||
status: importedCount > 0 ? "available" : "nothing_to_import",
|
||||
sourceRevision: input.candidate.sourceRevision,
|
||||
paseoRevision: input.paseoRevision,
|
||||
inputs: input.candidate.inputs,
|
||||
items,
|
||||
preview: importedCount > 0 ? merged : null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasLifecycle(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((entry) => typeof entry === "string" && entry.trim().length > 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setOutcome(
|
||||
items: ProjectConfigImportPreview["items"],
|
||||
key: string,
|
||||
outcome: "collision",
|
||||
detail: string,
|
||||
): void {
|
||||
const item = items.find((entry) => entry.key === key);
|
||||
if (!item) {
|
||||
items.push({
|
||||
key,
|
||||
label: key,
|
||||
outcome,
|
||||
detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
item.outcome = outcome;
|
||||
item.detail = detail;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
PaseoConfigRaw,
|
||||
PaseoConfigRevision,
|
||||
ProjectConfigImportInput,
|
||||
ProjectConfigImportItem,
|
||||
ProjectConfigImportPreview,
|
||||
ProjectConfigImportSource,
|
||||
ProjectConfigRpcError,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export type {
|
||||
ProjectConfigImportInput,
|
||||
ProjectConfigImportItem,
|
||||
ProjectConfigImportPreview,
|
||||
ProjectConfigImportSource,
|
||||
};
|
||||
|
||||
export interface ProjectConfigImportCandidate {
|
||||
source: ProjectConfigImportSource;
|
||||
sourceRevision: string;
|
||||
inputs: ProjectConfigImportInput[];
|
||||
items: ProjectConfigImportItem[];
|
||||
patch: PaseoConfigRaw;
|
||||
}
|
||||
|
||||
export interface InspectProjectConfigImportInput {
|
||||
repoRoot: string;
|
||||
source: ProjectConfigImportSource;
|
||||
paseoConfig: PaseoConfigRaw;
|
||||
paseoRevision: PaseoConfigRevision | null;
|
||||
}
|
||||
|
||||
export interface ApplyProjectConfigImportInput {
|
||||
repoRoot: string;
|
||||
source: ProjectConfigImportSource;
|
||||
expectedSourceRevision: string;
|
||||
expectedPaseoRevision: PaseoConfigRevision | null;
|
||||
}
|
||||
|
||||
export type ProjectConfigImportApplyResult =
|
||||
| {
|
||||
ok: true;
|
||||
repoRoot: string;
|
||||
source: ProjectConfigImportSource;
|
||||
config: PaseoConfigRaw;
|
||||
revision: PaseoConfigRevision;
|
||||
items: ProjectConfigImportItem[];
|
||||
}
|
||||
| { ok: false; repoRoot: string; error: ProjectConfigRpcError };
|
||||
|
||||
export class InvalidProjectConfigImportSourceError extends Error {
|
||||
readonly source: ProjectConfigImportSource;
|
||||
readonly relativePath: string;
|
||||
|
||||
constructor(source: ProjectConfigImportSource, relativePath: string) {
|
||||
super(`Invalid ${source.kind} config at ${relativePath}`);
|
||||
this.source = source;
|
||||
this.relativePath = relativePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
InvalidProjectConfigImportSourceError,
|
||||
type ProjectConfigImportCandidate,
|
||||
type ProjectConfigImportInput,
|
||||
type ProjectConfigImportItem,
|
||||
type ProjectConfigImportSource,
|
||||
} from "../model.js";
|
||||
|
||||
interface SourceFile {
|
||||
role: string;
|
||||
relativePath: string;
|
||||
path: string;
|
||||
bytes: string;
|
||||
}
|
||||
|
||||
interface ConductorSettings {
|
||||
scripts?: {
|
||||
setup?: unknown;
|
||||
archive?: unknown;
|
||||
run?: unknown;
|
||||
run_mode?: unknown;
|
||||
auto_run_after_setup?: unknown;
|
||||
};
|
||||
file_include_globs?: unknown;
|
||||
environment_variables?: unknown;
|
||||
environment_variables_forward?: unknown;
|
||||
spotlight_testing?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConductorRunScript {
|
||||
command: string;
|
||||
args?: string[];
|
||||
options?: {
|
||||
cwd?: string;
|
||||
};
|
||||
available_in?: string;
|
||||
hide?: boolean;
|
||||
icon?: unknown;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
type RewriteContext = "lifecycle" | "run";
|
||||
|
||||
export function inspectConductorImport(input: {
|
||||
repoRoot: string;
|
||||
source: ProjectConfigImportSource;
|
||||
}): ProjectConfigImportCandidate | null {
|
||||
const sourceFiles = discoverConductorSources(input.repoRoot);
|
||||
if (sourceFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = loadConductorSettings(input.source, sourceFiles);
|
||||
const inputs = sourceFiles.map<ProjectConfigImportInput>((file) => ({
|
||||
role: file.role,
|
||||
relativePath: file.relativePath,
|
||||
}));
|
||||
const patch: PaseoConfigRaw = {};
|
||||
const items: ProjectConfigImportItem[] = [];
|
||||
|
||||
mapLifecycle(settings.scripts?.setup, {
|
||||
key: "worktree.setup",
|
||||
label: "Worktree setup",
|
||||
target: "setup",
|
||||
patch,
|
||||
items,
|
||||
});
|
||||
mapLifecycle(settings.scripts?.archive, {
|
||||
key: "worktree.teardown",
|
||||
label: "Worktree teardown",
|
||||
target: "teardown",
|
||||
patch,
|
||||
items,
|
||||
});
|
||||
mapRunScripts(settings.scripts?.run, patch, items);
|
||||
reportUnsupported(input.repoRoot, settings, items);
|
||||
|
||||
return {
|
||||
source: input.source,
|
||||
sourceRevision: hashSourceFiles(sourceFiles),
|
||||
inputs,
|
||||
items,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
function discoverConductorSources(repoRoot: string): SourceFile[] {
|
||||
const localPath = join(repoRoot, ".conductor", "settings.local.toml");
|
||||
const sharedPath = join(repoRoot, ".conductor", "settings.toml");
|
||||
const legacyPath = join(repoRoot, "conductor.json");
|
||||
const files: SourceFile[] = [];
|
||||
|
||||
if (existsSync(sharedPath)) {
|
||||
files.push(readSourceFile(repoRoot, sharedPath, "shared"));
|
||||
} else if (existsSync(legacyPath)) {
|
||||
files.push(readSourceFile(repoRoot, legacyPath, "legacy"));
|
||||
}
|
||||
if (existsSync(localPath)) {
|
||||
files.push(readSourceFile(repoRoot, localPath, "local"));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function readSourceFile(repoRoot: string, path: string, role: string): SourceFile {
|
||||
return {
|
||||
role,
|
||||
relativePath: relative(repoRoot, path),
|
||||
path,
|
||||
bytes: readFileSync(path, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function loadConductorSettings(
|
||||
source: ProjectConfigImportSource,
|
||||
sourceFiles: SourceFile[],
|
||||
): ConductorSettings {
|
||||
let merged: ConductorSettings = {};
|
||||
for (const file of sourceFiles) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed =
|
||||
file.relativePath === "conductor.json" ? JSON.parse(file.bytes) : parseToml(file.bytes);
|
||||
} catch {
|
||||
throw new InvalidProjectConfigImportSourceError(source, file.relativePath);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new InvalidProjectConfigImportSourceError(source, file.relativePath);
|
||||
}
|
||||
merged = mergeSettings(merged, parsed as ConductorSettings);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeSettings(base: ConductorSettings, override: ConductorSettings): ConductorSettings {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
scripts: {
|
||||
...(isRecord(base.scripts) ? base.scripts : {}),
|
||||
...(isRecord(override.scripts) ? override.scripts : {}),
|
||||
run: mergeRunScripts(base.scripts?.run, override.scripts?.run),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRunScripts(base: unknown, override: unknown): unknown {
|
||||
if (typeof override === "string") {
|
||||
return override;
|
||||
}
|
||||
if (!isRecord(base) || !isRecord(override)) {
|
||||
return override ?? base;
|
||||
}
|
||||
return { ...base, ...override };
|
||||
}
|
||||
|
||||
function mapLifecycle(
|
||||
value: unknown,
|
||||
input: {
|
||||
key: string;
|
||||
label: string;
|
||||
target: "setup" | "teardown";
|
||||
patch: PaseoConfigRaw;
|
||||
items: ProjectConfigImportItem[];
|
||||
},
|
||||
): void {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const rewritten = rewriteVariables(value, "lifecycle", input.items);
|
||||
input.patch.worktree = { ...input.patch.worktree, [input.target]: rewritten.command };
|
||||
input.items.push({
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
outcome: "import",
|
||||
detail: rewritten.command,
|
||||
});
|
||||
}
|
||||
|
||||
function mapRunScripts(
|
||||
runConfig: unknown,
|
||||
patch: PaseoConfigRaw,
|
||||
items: ProjectConfigImportItem[],
|
||||
): void {
|
||||
if (typeof runConfig === "string") {
|
||||
mapRunScript("run", { command: runConfig }, patch, items);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(runConfig)) {
|
||||
return;
|
||||
}
|
||||
for (const scriptId of Object.keys(runConfig).sort()) {
|
||||
const entry = runConfig[scriptId];
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const command = entry.command;
|
||||
if (typeof command !== "string" || command.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
mapRunScript(scriptId, normalizeRunScript(entry, command), patch, items);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRunScript(entry: Record<string, unknown>, command: string): ConductorRunScript {
|
||||
const args = Array.isArray(entry.args)
|
||||
? entry.args.filter((arg): arg is string => typeof arg === "string")
|
||||
: undefined;
|
||||
const options = isRecord(entry.options) ? entry.options : undefined;
|
||||
return {
|
||||
command,
|
||||
...(args ? { args } : {}),
|
||||
...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}),
|
||||
...(typeof entry.available_in === "string" ? { available_in: entry.available_in } : {}),
|
||||
...(typeof entry.hide === "boolean" ? { hide: entry.hide } : {}),
|
||||
...(Object.hasOwn(entry, "icon") ? { icon: entry.icon } : {}),
|
||||
...(Object.hasOwn(entry, "default") ? { default: entry.default } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRunScript(
|
||||
scriptId: string,
|
||||
script: ConductorRunScript,
|
||||
patch: PaseoConfigRaw,
|
||||
items: ProjectConfigImportItem[],
|
||||
): void {
|
||||
if (script.available_in === "cloud") {
|
||||
items.push({
|
||||
key: `scripts.${scriptId}`,
|
||||
label: `Script ${scriptId}`,
|
||||
outcome: "unsupported",
|
||||
detail: "Cloud-only scripts are not imported.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let command = appendArgs(script.command, script.args ?? []);
|
||||
if (script.options?.cwd) {
|
||||
const cwdPrefix = safeCwdPrefix(script.options.cwd);
|
||||
if (!cwdPrefix) {
|
||||
items.push({
|
||||
key: `scripts.${scriptId}.cwd`,
|
||||
label: `Script ${scriptId} working directory`,
|
||||
outcome: "unsupported",
|
||||
detail: "Absolute or escaping cwd values are not imported.",
|
||||
});
|
||||
} else {
|
||||
command = `${cwdPrefix}${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
const isService = containsShellVariable(command, "CONDUCTOR_PORT");
|
||||
const rewritten = rewriteVariables(command, isService ? "run" : "lifecycle", items);
|
||||
const entry: PaseoScriptEntryRaw = { command: rewritten.command };
|
||||
if (isService) {
|
||||
entry.type = "service";
|
||||
entry.port = "$PASEO_PORT";
|
||||
}
|
||||
|
||||
patch.scripts = { ...patch.scripts, [scriptId]: entry };
|
||||
items.push({
|
||||
key: `scripts.${scriptId}`,
|
||||
label: `Script ${scriptId}`,
|
||||
outcome: "import",
|
||||
detail: rewritten.command,
|
||||
});
|
||||
|
||||
if (script.hide === true) {
|
||||
items.push({
|
||||
key: `scripts.${scriptId}.hide`,
|
||||
label: `Script ${scriptId} hidden flag`,
|
||||
outcome: "unsupported",
|
||||
detail: "Paseo does not hide imported scripts.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reportUnsupported(
|
||||
repoRoot: string,
|
||||
settings: ConductorSettings,
|
||||
items: ProjectConfigImportItem[],
|
||||
): void {
|
||||
const scripts = settings.scripts;
|
||||
if (scripts?.run_mode !== undefined) {
|
||||
unsupported(items, "scripts.run_mode", "Paseo has no project-wide run mode.");
|
||||
}
|
||||
if (scripts?.auto_run_after_setup !== undefined) {
|
||||
unsupported(
|
||||
items,
|
||||
"scripts.auto_run_after_setup",
|
||||
"Paseo does not auto-run scripts after setup.",
|
||||
);
|
||||
}
|
||||
if (settings.file_include_globs !== undefined) {
|
||||
unsupported(
|
||||
items,
|
||||
"file_include_globs",
|
||||
"File include globs are not converted to shell copy commands.",
|
||||
);
|
||||
}
|
||||
if (existsSync(join(repoRoot, ".worktreeinclude"))) {
|
||||
unsupported(
|
||||
items,
|
||||
".worktreeinclude",
|
||||
"Worktree include patterns are not converted to shell copy commands.",
|
||||
);
|
||||
}
|
||||
const environmentNames = collectEnvironmentVariableNames(settings);
|
||||
if (environmentNames.length > 0) {
|
||||
unsupported(
|
||||
items,
|
||||
"environment_variables",
|
||||
`Environment variable values are not imported. Found: ${environmentNames.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
if (settings.spotlight_testing !== undefined) {
|
||||
unsupported(
|
||||
items,
|
||||
"spotlight_testing",
|
||||
"Paseo spotlight is a separate workflow, not project config.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function collectEnvironmentVariableNames(settings: ConductorSettings): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const key of ["environment_variables", "environment_variables_forward"] as const) {
|
||||
const value = settings[key];
|
||||
if (isRecord(value)) {
|
||||
for (const name of Object.keys(value).sort()) {
|
||||
names.add(name);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const name of value) {
|
||||
if (typeof name === "string") {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
}
|
||||
|
||||
function unsupported(items: ProjectConfigImportItem[], key: string, detail: string): void {
|
||||
items.push({ key, label: key, outcome: "unsupported", detail });
|
||||
}
|
||||
|
||||
function rewriteVariables(
|
||||
command: string,
|
||||
context: RewriteContext,
|
||||
items: ProjectConfigImportItem[],
|
||||
): { command: string } {
|
||||
const replacements = new Map<string, string>([
|
||||
["CONDUCTOR_WORKSPACE_PATH", "PASEO_WORKTREE_PATH"],
|
||||
["CONDUCTOR_ROOT_PATH", "PASEO_SOURCE_CHECKOUT_PATH"],
|
||||
["CONDUCTOR_PORT", context === "run" ? "PASEO_PORT" : "PASEO_WORKTREE_PORT"],
|
||||
]);
|
||||
const unsupportedVariables = new Set([
|
||||
"CONDUCTOR_DEFAULT_BRANCH",
|
||||
"CONDUCTOR_WORKSPACE_NAME",
|
||||
"CONDUCTOR_IS_LOCAL",
|
||||
]);
|
||||
let rewritten = command;
|
||||
for (const [from, to] of replacements) {
|
||||
const next = replaceShellVariable(rewritten, from, to);
|
||||
if (next !== rewritten) {
|
||||
items.push({
|
||||
key: `variables.${from}`,
|
||||
label: from,
|
||||
outcome: "rewrite",
|
||||
detail: `${from} -> ${to}`,
|
||||
});
|
||||
rewritten = next;
|
||||
}
|
||||
}
|
||||
for (const name of unsupportedVariables) {
|
||||
if (containsShellVariable(rewritten, name)) {
|
||||
items.push({
|
||||
key: `variables.${name}`,
|
||||
label: name,
|
||||
outcome: "unsupported",
|
||||
detail: `${name} has no equivalent Paseo variable.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { command: rewritten };
|
||||
}
|
||||
|
||||
function replaceShellVariable(command: string, from: string, to: string): string {
|
||||
const pattern = new RegExp(`\\$(?:\\{${from}\\}|${from}(?![A-Za-z0-9_]))`, "g");
|
||||
return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}}` : `$${to}`));
|
||||
}
|
||||
|
||||
function containsShellVariable(command: string, name: string): boolean {
|
||||
const pattern = new RegExp(`\\$(?:\\{${name}\\}|${name}(?![A-Za-z0-9_]))`);
|
||||
return pattern.test(command);
|
||||
}
|
||||
|
||||
function appendArgs(command: string, args: string[]): string {
|
||||
if (args.length === 0) {
|
||||
return command;
|
||||
}
|
||||
return `${command} ${args.map(shellQuote).join(" ")}`;
|
||||
}
|
||||
|
||||
function safeCwdPrefix(cwd: string): string | null {
|
||||
if (
|
||||
/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) ||
|
||||
cwd === ".." ||
|
||||
cwd.startsWith("../") ||
|
||||
cwd.startsWith("..\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (cwd.includes("/../") || cwd.includes("\\..\\")) {
|
||||
return null;
|
||||
}
|
||||
return `cd -- ${shellQuote(cwd)} && `;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function hashSourceFiles(sourceFiles: SourceFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const file of [...sourceFiles].sort((left, right) =>
|
||||
left.relativePath.localeCompare(right.relativePath),
|
||||
)) {
|
||||
hash.update(file.relativePath);
|
||||
hash.update("\0");
|
||||
hash.update(file.bytes);
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
@@ -44,6 +52,23 @@ function makeSubsystem(records: PersistedProjectRecord[]) {
|
||||
return { subsystem, emitted };
|
||||
}
|
||||
|
||||
function expectImportPreview(
|
||||
emitted: SessionOutboundMessage[],
|
||||
index: number,
|
||||
): Extract<SessionOutboundMessage, { type: "project.config.get_import.response" }>["payload"] & {
|
||||
ok: true;
|
||||
} {
|
||||
const message = emitted[index];
|
||||
expect(message).toMatchObject({
|
||||
type: "project.config.get_import.response",
|
||||
payload: { ok: true },
|
||||
});
|
||||
if (message.type !== "project.config.get_import.response" || !message.payload.ok) {
|
||||
throw new Error("Expected import preview response");
|
||||
}
|
||||
return message.payload;
|
||||
}
|
||||
|
||||
describe("ProjectConfigSession", () => {
|
||||
test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
@@ -227,4 +252,177 @@ describe("ProjectConfigSession", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("import preview rejects archived and unknown roots with project_not_found", async () => {
|
||||
const archivedRoot = makeRoot();
|
||||
const unknownRoot = makeRoot();
|
||||
const { subsystem, emitted } = makeSubsystem([
|
||||
projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"),
|
||||
]);
|
||||
|
||||
await subsystem.handleGetProjectConfigImportRequest({
|
||||
type: "project.config.get_import.request",
|
||||
requestId: "import-archived-1",
|
||||
repoRoot: archivedRoot,
|
||||
source: { kind: "conductor" },
|
||||
});
|
||||
await subsystem.handleGetProjectConfigImportRequest({
|
||||
type: "project.config.get_import.request",
|
||||
requestId: "import-unknown-1",
|
||||
repoRoot: unknownRoot,
|
||||
source: { kind: "conductor" },
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "project.config.get_import.response",
|
||||
payload: {
|
||||
requestId: "import-archived-1",
|
||||
repoRoot: archivedRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "project.config.get_import.response",
|
||||
payload: {
|
||||
requestId: "import-unknown-1",
|
||||
repoRoot: unknownRoot,
|
||||
ok: false,
|
||||
error: { code: "project_not_found" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("import preview reports malformed Conductor config with a safe relative path", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
mkdirSync(join(repoRoot, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repoRoot, ".conductor", "settings.toml"), "[scripts\nsetup = nope");
|
||||
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
|
||||
|
||||
await subsystem.handleGetProjectConfigImportRequest({
|
||||
type: "project.config.get_import.request",
|
||||
requestId: "import-invalid-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "project.config.get_import.response",
|
||||
payload: {
|
||||
requestId: "import-invalid-1",
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "invalid_source_config",
|
||||
source: { kind: "conductor" },
|
||||
relativePath: ".conductor/settings.toml",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply import recomputes from disk and writes formatted paseo.json", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
mkdirSync(join(repoRoot, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n');
|
||||
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
|
||||
|
||||
await subsystem.handleGetProjectConfigImportRequest({
|
||||
type: "project.config.get_import.request",
|
||||
requestId: "import-preview-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
});
|
||||
const preview = expectImportPreview(emitted, 0);
|
||||
|
||||
await subsystem.handleApplyProjectConfigImportRequest({
|
||||
type: "project.config.apply_import.request",
|
||||
requestId: "import-apply-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: preview.paseoRevision,
|
||||
});
|
||||
|
||||
expect(emitted[1]).toMatchObject({
|
||||
type: "project.config.apply_import.response",
|
||||
payload: {
|
||||
requestId: "import-apply-1",
|
||||
repoRoot,
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
},
|
||||
});
|
||||
expect(readFileSync(join(repoRoot, "paseo.json"), "utf8")).toBe(
|
||||
'{\n "worktree": {\n "setup": "npm ci"\n }\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
test("apply import rejects stale source digest", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
mkdirSync(join(repoRoot, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n');
|
||||
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
|
||||
|
||||
await subsystem.handleApplyProjectConfigImportRequest({
|
||||
type: "project.config.apply_import.request",
|
||||
requestId: "import-stale-source-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
expectedSourceRevision: "old",
|
||||
expectedPaseoRevision: null,
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "project.config.apply_import.response",
|
||||
payload: {
|
||||
requestId: "import-stale-source-1",
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error: { code: "stale_source_config", source: { kind: "conductor" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply import rejects stale paseo.json revision", async () => {
|
||||
const repoRoot = makeRoot();
|
||||
mkdirSync(join(repoRoot, ".conductor"), { recursive: true });
|
||||
writeFileSync(join(repoRoot, ".conductor", "settings.toml"), '[scripts]\nsetup = "npm ci"\n');
|
||||
writeFileSync(join(repoRoot, "paseo.json"), '{"custom":true}\n');
|
||||
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]);
|
||||
|
||||
await subsystem.handleGetProjectConfigImportRequest({
|
||||
type: "project.config.get_import.request",
|
||||
requestId: "import-preview-stale-project-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
});
|
||||
const preview = expectImportPreview(emitted, 0);
|
||||
writeFileSync(join(repoRoot, "paseo.json"), '{"custom":false}\n');
|
||||
|
||||
await subsystem.handleApplyProjectConfigImportRequest({
|
||||
type: "project.config.apply_import.request",
|
||||
requestId: "import-stale-project-1",
|
||||
repoRoot,
|
||||
source: { kind: "conductor" },
|
||||
expectedSourceRevision: preview.sourceRevision ?? "",
|
||||
expectedPaseoRevision: preview.paseoRevision,
|
||||
});
|
||||
|
||||
expect(emitted[1]).toMatchObject({
|
||||
type: "project.config.apply_import.response",
|
||||
payload: {
|
||||
requestId: "import-stale-project-1",
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error: { code: "stale_project_config" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
writePaseoConfigForEdit,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../../../utils/paseo-config-file.js";
|
||||
import { applyProjectConfigImport, inspectProjectConfigImport } from "./import/index.js";
|
||||
import { InvalidProjectConfigImportSourceError } from "./import/model.js";
|
||||
|
||||
export interface ProjectConfigSessionHost {
|
||||
emit(msg: SessionOutboundMessage): void;
|
||||
@@ -118,6 +120,87 @@ export class ProjectConfigSession {
|
||||
});
|
||||
}
|
||||
|
||||
async handleGetProjectConfigImportRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "project.config.get_import.request" }>,
|
||||
): Promise<void> {
|
||||
const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot);
|
||||
if (!repoRoot) {
|
||||
this.emitProjectConfigImportGetFailure(msg, { code: "project_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const config = readPaseoConfigForEdit(repoRoot);
|
||||
if (!config.ok) {
|
||||
this.emitProjectConfigImportGetFailure(msg, config.error, repoRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = inspectProjectConfigImport({
|
||||
repoRoot,
|
||||
source: msg.source,
|
||||
paseoConfig: config.config ?? {},
|
||||
paseoRevision: config.revision,
|
||||
});
|
||||
this.host.emit({
|
||||
type: "project.config.get_import.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
ok: true,
|
||||
...preview,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProjectConfigImportSourceError) {
|
||||
this.emitProjectConfigImportGetFailure(
|
||||
msg,
|
||||
{
|
||||
code: "invalid_source_config",
|
||||
source: error.source,
|
||||
relativePath: error.relativePath,
|
||||
},
|
||||
repoRoot,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handleApplyProjectConfigImportRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "project.config.apply_import.request" }>,
|
||||
): Promise<void> {
|
||||
const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot);
|
||||
if (!repoRoot) {
|
||||
this.emitProjectConfigImportApplyFailure(msg, { code: "project_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = applyProjectConfigImport({
|
||||
repoRoot,
|
||||
source: msg.source,
|
||||
expectedSourceRevision: msg.expectedSourceRevision,
|
||||
expectedPaseoRevision: msg.expectedPaseoRevision,
|
||||
});
|
||||
if (!result.ok) {
|
||||
this.emitProjectConfigImportApplyFailure(msg, result.error, repoRoot);
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.emit({
|
||||
type: "project.config.apply_import.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
source: result.source,
|
||||
ok: true,
|
||||
config: result.config,
|
||||
revision: result.revision,
|
||||
items: result.items,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitProjectConfigReadFailure(
|
||||
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
|
||||
error: ProjectConfigRpcError,
|
||||
@@ -150,6 +233,38 @@ export class ProjectConfigSession {
|
||||
});
|
||||
}
|
||||
|
||||
private emitProjectConfigImportGetFailure(
|
||||
msg: Extract<SessionInboundMessage, { type: "project.config.get_import.request" }>,
|
||||
error: ProjectConfigRpcError,
|
||||
repoRoot = msg.repoRoot,
|
||||
): void {
|
||||
this.host.emit({
|
||||
type: "project.config.get_import.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitProjectConfigImportApplyFailure(
|
||||
msg: Extract<SessionInboundMessage, { type: "project.config.apply_import.request" }>,
|
||||
error: ProjectConfigRpcError,
|
||||
repoRoot = msg.repoRoot,
|
||||
): void {
|
||||
this.host.emit({
|
||||
type: "project.config.apply_import.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
repoRoot,
|
||||
ok: false,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
|
||||
const requestedRoot = canonicalizeConfigRoot(repoRoot);
|
||||
const projects = await this.projectRegistry.list();
|
||||
|
||||
@@ -1279,6 +1279,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerRemoval: true,
|
||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||
importSessionWorkspaceTarget: true,
|
||||
// COMPAT(projectConfigImportConductor): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
projectConfigImportConductor: true,
|
||||
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
|
||||
forgeProviders: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user