feat(settings): import existing project setup

This commit is contained in:
Mohamed Boudra
2026-07-17 11:15:10 +02:00
parent a8ebd390fa
commit 8fe11e3bb0
43 changed files with 3464 additions and 48 deletions
+4
View File
@@ -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,
},