refactor(settings): make project imports pluggable

This commit is contained in:
Mohamed Boudra
2026-07-17 14:42:02 +02:00
parent f7a6548b68
commit f030ddefb1
53 changed files with 2128 additions and 1546 deletions
@@ -1,127 +0,0 @@
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;
}
@@ -1,5 +1,5 @@
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./model.js";
import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./service.js";
interface MergeProjectConfigImportInput {
repoRoot: string;
@@ -31,28 +31,21 @@ export function mergeProjectConfigImport(
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;
for (const key of ["setup", "teardown"] as const) {
if (!Object.hasOwn(patchWorktree, key)) continue;
if (hasLifecycle(base.worktree?.[key])) {
setOutcome(items, `worktree.${key}`, `Paseo already has ${key} commands.`);
continue;
}
merged.worktree = { ...merged.worktree, [key]: patchWorktree[key] };
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.`);
setOutcome(items, key, `Paseo already has a "${scriptId}" script.`);
continue;
}
merged.scripts = { ...merged.scripts, [scriptId]: script };
@@ -61,7 +54,7 @@ export function mergeProjectConfigImport(
return {
repoRoot: input.repoRoot,
source: input.candidate.source,
source: input.source,
status: importedCount > 0 ? "available" : "nothing_to_import",
sourceRevision: input.candidate.sourceRevision,
paseoRevision: input.paseoRevision,
@@ -81,22 +74,17 @@ function hasLifecycle(value: unknown): boolean {
return false;
}
function setOutcome(
items: ProjectConfigImportPreview["items"],
key: string,
outcome: "collision",
detail: string,
): void {
function setOutcome(items: ProjectConfigImportPreview["items"], key: string, detail: string): void {
const item = items.find((entry) => entry.key === key);
if (!item) {
items.push({
key,
label: key,
outcome,
outcome: "collision",
detail,
});
return;
}
item.outcome = outcome;
item.outcome = "collision";
item.detail = detail;
}
@@ -1,60 +0,0 @@
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,80 @@
import { describe, expect, test } from "vitest";
import {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
projectConfigImportRegistry,
type ProjectConfigImportAdapter,
type ProjectConfigImportSourceSet,
} from "./registry.js";
interface FakeSource {
kind: "fake-source";
profile: string;
}
const fakeSourceSet = {
parse: (source) =>
source.kind === "fake-source" && typeof source.profile === "string"
? { kind: "fake-source", profile: source.profile }
: null,
kinds: () => ["fake-source"],
} satisfies ProjectConfigImportSourceSet<FakeSource>;
const fakeAdapter = {
source: { kind: "fake-source", profile: "alpha" },
inspect: () => null,
} satisfies ProjectConfigImportAdapter<FakeSource>;
describe("project config import adapter registry", () => {
test("enumerates every advertised source from registered adapters", () => {
const productionSource = productionProjectConfigImportSourceSet.parse({
kind: productionProjectConfigImportSourceSet.kinds()[0],
})!;
expect(projectConfigImportRegistry.sources()).toEqual([productionSource]);
expect(projectConfigImportRegistry.get(productionSource.kind)).toBeTruthy();
expect(() => projectConfigImportRegistry.assertProtocolCoverage()).not.toThrow();
});
test("rejects duplicate adapters before coordinators run", () => {
expect(() =>
createProjectConfigImportRegistry([fakeAdapter, fakeAdapter], fakeSourceSet),
).toThrow("Duplicate project config import adapter: fake-source");
});
test("rejects adapter kinds outside the protocol source union", () => {
expect(() =>
createProjectConfigImportRegistry(
[
{
source: { kind: "not-in-protocol" },
inspect: () => null,
},
],
productionProjectConfigImportSourceSet,
),
).toThrow("Unknown project config import adapter: not-in-protocol");
});
test("accepts explicit test source sets without protocol casts", () => {
const registry = createProjectConfigImportRegistry([fakeAdapter], fakeSourceSet);
expect(registry.sources()).toEqual([{ kind: "fake-source", profile: "alpha" }]);
expect(registry.get("fake-source")).toBe(fakeAdapter);
expect(() => registry.assertProtocolCoverage()).not.toThrow();
});
test("validates full adapter source descriptors", () => {
expect(() =>
createProjectConfigImportRegistry(
[
{
source: { kind: "fake-source" },
inspect: () => null,
},
],
fakeSourceSet,
),
).toThrow("Unknown project config import adapter: fake-source");
});
});
@@ -0,0 +1,77 @@
import { ProjectConfigImportSourceSchema } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportSource } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportCandidate } from "./service.js";
import { conductorProjectConfigImporter } from "./sources/conductor/importer.js";
const PROJECT_CONFIG_IMPORT_ADAPTERS = [conductorProjectConfigImporter];
export interface ProjectConfigImportAdapter<
TSource extends { kind: string } = ProjectConfigImportSource,
> {
readonly source: { kind: string; [key: string]: unknown };
inspect(input: { repoRoot: string; source: TSource }): ProjectConfigImportCandidate | null;
}
export interface ProjectConfigImportSourceSet<TSource extends { kind: string }> {
parse(source: { kind: string; [key: string]: unknown }): TSource | null;
kinds(): readonly TSource["kind"][];
}
export interface ProjectConfigImportRegistry<
TSource extends { kind: string } = ProjectConfigImportSource,
> {
get(kind: TSource["kind"]): ProjectConfigImportAdapter<TSource> | null;
sources(): TSource[];
assertProtocolCoverage(): void;
}
export function createProjectConfigImportRegistry<TSource extends { kind: string }>(
adapters: readonly ProjectConfigImportAdapter<TSource>[],
sourceSet: ProjectConfigImportSourceSet<TSource>,
) {
const byKind = new Map<TSource["kind"], ProjectConfigImportAdapter<TSource>>();
for (const adapter of adapters) {
const source = sourceSet.parse(adapter.source);
if (!source) {
throw new Error(`Unknown project config import adapter: ${adapter.source.kind}`);
}
const kind = source.kind;
if (byKind.has(kind)) {
throw new Error(`Duplicate project config import adapter: ${adapter.source.kind}`);
}
byKind.set(kind, adapter);
}
return {
get(kind: TSource["kind"]): ProjectConfigImportAdapter<TSource> | null {
return byKind.get(kind) ?? null;
},
sources(): TSource[] {
return Array.from(byKind.values()).map((adapter) => sourceSet.parse(adapter.source)!);
},
assertProtocolCoverage(): void {
const missing = sourceSet.kinds().filter((kind) => !byKind.has(kind));
if (missing.length > 0) {
throw new Error(`Missing project config import adapters: ${missing.join(", ")}`);
}
},
} satisfies ProjectConfigImportRegistry<TSource>;
}
export const productionProjectConfigImportSourceSet = {
parse(source: { kind: string; [key: string]: unknown }): ProjectConfigImportSource | null {
const parsed = ProjectConfigImportSourceSchema.safeParse(source);
return parsed.success ? parsed.data : null;
},
kinds: readProtocolSourceKinds,
} satisfies ProjectConfigImportSourceSet<ProjectConfigImportSource>;
export const projectConfigImportRegistry = createProjectConfigImportRegistry(
PROJECT_CONFIG_IMPORT_ADAPTERS,
productionProjectConfigImportSourceSet,
);
function readProtocolSourceKinds(): ProjectConfigImportSource["kind"][] {
const options = ProjectConfigImportSourceSchema.options;
return options.map((option) => option.shape.kind.value);
}
@@ -0,0 +1,156 @@
import { 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 {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
type ProjectConfigImportAdapter,
} from "./registry.js";
import { createProjectConfigImportService, type ProjectConfigImportCandidate } from "./service.js";
const tempDirs: string[] = [];
const PROTOCOL_SOURCE = productionProjectConfigImportSourceSet.parse({
kind: productionProjectConfigImportSourceSet.kinds()[0],
})!;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "project-config-import-service-test-"));
tempDirs.push(repo);
return repo;
}
function createFakeService(candidate: () => ProjectConfigImportCandidate | null) {
const adapter = {
source: PROTOCOL_SOURCE,
inspect: () => candidateFor(candidate()),
} satisfies ProjectConfigImportAdapter<typeof PROTOCOL_SOURCE>;
return createProjectConfigImportService(
createProjectConfigImportRegistry([adapter], productionProjectConfigImportSourceSet),
);
}
function candidateFor(
candidate: ProjectConfigImportCandidate | null,
): ProjectConfigImportCandidate | null {
if (!candidate) {
return null;
}
return {
...candidate,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
};
}
function baseCandidate(sourceRevision = "source-1"): ProjectConfigImportCandidate {
return {
sourceRevision,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
patch: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } },
};
}
describe("project config import service", () => {
test("previews missing, available, and collision-only imports through an injected adapter", () => {
const repo = makeRepo();
expect(
createFakeService(() => null).inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {},
paseoRevision: null,
}),
).toMatchObject({ status: "not_found", preview: null, sourceRevision: null });
const service = createFakeService(() => baseCandidate());
expect(
service.inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {},
paseoRevision: null,
}),
).toMatchObject({
status: "available",
preview: { worktree: { setup: "npm ci" }, scripts: { dev: { command: "npm run dev" } } },
});
expect(
service.inspect({
repoRoot: repo,
source: PROTOCOL_SOURCE,
paseoConfig: {
worktree: { setup: "pnpm install" },
scripts: { dev: { command: "pnpm dev" } },
},
paseoRevision: null,
}),
).toMatchObject({
status: "nothing_to_import",
preview: null,
items: [
expect.objectContaining({ key: "worktree.setup", outcome: "collision" }),
expect.objectContaining({ key: "scripts.dev", outcome: "collision" }),
],
});
});
test("apply recomputes from disk and writes formatted paseo.json", () => {
const repo = makeRepo();
let revision = "source-1";
const service = createFakeService(() => baseCandidate(revision));
const result = service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
});
expect(result).toMatchObject({
ok: true,
repoRoot: repo,
config: { worktree: { setup: "npm ci" } },
});
expect(readFileSync(join(repo, "paseo.json"), "utf8")).toContain('"setup": "npm ci"');
revision = "source-2";
expect(
service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: null,
}),
).toEqual({
ok: false,
repoRoot: repo,
error: { code: "stale_source_config", source: PROTOCOL_SOURCE },
});
});
test("apply rejects stale paseo.json revision after recomputing the source", () => {
const repo = makeRepo();
writeFileSync(join(repo, "paseo.json"), '{"custom":true}\n');
const service = createFakeService(() => baseCandidate());
expect(
service.apply({
repoRoot: repo,
source: PROTOCOL_SOURCE,
expectedSourceRevision: "source-1",
expectedPaseoRevision: { mtimeMs: 1, size: 1 },
}),
).toMatchObject({
ok: false,
repoRoot: repo,
error: { code: "stale_project_config" },
});
});
});
@@ -0,0 +1,173 @@
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
} from "../../../../utils/paseo-config-file.js";
import { mergeProjectConfigImport } from "./merge.js";
import type { ProjectConfigImportRegistry } from "./registry.js";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
ProjectConfigImportInput,
ProjectConfigImportItem,
ProjectConfigImportPreview,
ProjectConfigImportSource,
ProjectConfigRpcError,
} from "@getpaseo/protocol/messages";
export type {
ProjectConfigImportInput,
ProjectConfigImportItem,
ProjectConfigImportPreview,
ProjectConfigImportSource,
};
export interface ProjectConfigImportCandidate {
sourceRevision: string;
inputs: ProjectConfigImportInput[];
items: ProjectConfigImportItem[];
patch: PaseoConfigRaw;
}
interface InspectProjectConfigImportInput {
repoRoot: string;
source: ProjectConfigImportSource;
paseoConfig: PaseoConfigRaw;
paseoRevision: PaseoConfigRevision | null;
}
interface ApplyProjectConfigImportInput {
repoRoot: string;
source: ProjectConfigImportSource;
expectedSourceRevision: string;
expectedPaseoRevision: PaseoConfigRevision | null;
}
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;
}
}
export interface ProjectConfigImportService {
inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview;
apply(input: ApplyProjectConfigImportInput): ProjectConfigImportApplyResult;
}
export function createProjectConfigImportService(
registry: ProjectConfigImportRegistry,
): ProjectConfigImportService {
function inspect(input: InspectProjectConfigImportInput): ProjectConfigImportPreview {
const adapter = registry.get(input.source.kind);
const candidate: ProjectConfigImportCandidate | null = adapter
? adapter.inspect({ repoRoot: input.repoRoot, source: input.source })
: null;
return mergeProjectConfigImport({
repoRoot: input.repoRoot,
source: input.source,
candidate,
paseoConfig: input.paseoConfig,
paseoRevision: input.paseoRevision,
});
}
function apply(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 = inspect({
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,
};
}
return { inspect, apply };
}
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;
}
@@ -1,13 +1,25 @@
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, 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";
import {
createProjectConfigImportRegistry,
productionProjectConfigImportSourceSet,
} from "../../registry.js";
import {
createProjectConfigImportService,
InvalidProjectConfigImportSourceError,
} from "../../service.js";
import { conductorProjectConfigImporter } from "./importer.js";
const tempDirs: string[] = [];
const CONDUCTOR_SOURCE = { kind: "conductor" } as const;
const service = createProjectConfigImportService(
createProjectConfigImportRegistry(
[conductorProjectConfigImporter],
productionProjectConfigImportSourceSet,
),
);
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
@@ -32,7 +44,7 @@ function writeLocalToml(repo: string, contents: string): void {
}
function inspect(repo: string, paseoConfig = {}) {
return inspectProjectConfigImport({
return service.inspect({
repoRoot: repo,
source: CONDUCTOR_SOURCE,
paseoConfig,
@@ -51,16 +63,6 @@ function captureInvalidSourceError(repo: string): InvalidProjectConfigImportSour
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();
@@ -125,7 +127,6 @@ enabled = true
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");
@@ -209,34 +210,6 @@ command = "local dev"
});
});
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(
@@ -265,102 +238,6 @@ setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH"
);
});
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");
@@ -3,13 +3,14 @@ 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 type { ProjectConfigImportAdapter } from "../../registry.js";
import {
InvalidProjectConfigImportSourceError,
type ProjectConfigImportCandidate,
type ProjectConfigImportInput,
type ProjectConfigImportItem,
type ProjectConfigImportSource,
} from "../model.js";
} from "../../service.js";
interface SourceFile {
role: string;
@@ -40,14 +41,16 @@ interface ConductorRunScript {
cwd?: string;
};
available_in?: string;
hide?: boolean;
icon?: unknown;
default?: unknown;
}
type RewriteContext = "lifecycle" | "run";
export function inspectConductorImport(input: {
export const conductorProjectConfigImporter = {
source: { kind: "conductor" },
inspect: inspectConductorImport,
} satisfies ProjectConfigImportAdapter<{ kind: "conductor" }>;
function inspectConductorImport(input: {
repoRoot: string;
source: ProjectConfigImportSource;
}): ProjectConfigImportCandidate | null {
@@ -82,7 +85,6 @@ export function inspectConductorImport(input: {
reportUnsupported(input.repoRoot, settings, items);
return {
source: input.source,
sourceRevision: hashSourceFiles(sourceFiles),
inputs,
items,
@@ -217,9 +219,6 @@ function normalizeRunScript(entry: Record<string, unknown>, command: string): Co
...(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 } : {}),
};
}
@@ -269,15 +268,6 @@ function mapRunScript(
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(
@@ -1,21 +1,25 @@
import {
mkdirSync,
mkdtempSync,
realpathSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import pino from "pino";
import {
ProjectConfigImportSourceSchema,
type ProjectConfigImportSource,
} from "@getpaseo/protocol/messages";
import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js";
import type { PersistedProjectRecord } from "../../workspace-registry.js";
import type { SessionOutboundMessage } from "../../messages.js";
import {
InvalidProjectConfigImportSourceError,
type ProjectConfigImportService,
} from "./import/service.js";
const tempDirs: string[] = [];
const PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE: ProjectConfigImportSource =
ProjectConfigImportSourceSchema.options[0].parse({
kind: ProjectConfigImportSourceSchema.options[0].shape.kind.value,
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
@@ -41,34 +45,21 @@ function projectRecord(rootPath: string, archivedAt: string | null = null): Pers
};
}
function makeSubsystem(records: PersistedProjectRecord[]) {
function makeSubsystem(
records: PersistedProjectRecord[],
importService?: ProjectConfigImportService,
) {
const emitted: SessionOutboundMessage[] = [];
const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) };
const subsystem = new ProjectConfigSession({
host,
projectRegistry: { list: async () => records },
...(importService ? { importService } : {}),
logger: pino({ level: "silent" }),
});
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();
@@ -253,24 +244,35 @@ describe("ProjectConfigSession", () => {
]);
});
test("import preview rejects archived and unknown roots with project_not_found", async () => {
test("import preview rejects archived and unknown roots without touching the import service", async () => {
const archivedRoot = makeRoot();
const unknownRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([
projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"),
]);
const serviceCalls: string[] = [];
const { subsystem, emitted } = makeSubsystem(
[projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z")],
{
inspect: () => {
serviceCalls.push("inspect");
throw new Error("unexpected import inspect");
},
apply: () => {
serviceCalls.push("apply");
throw new Error("unexpected import apply");
},
},
);
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-archived-1",
repoRoot: archivedRoot,
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-unknown-1",
repoRoot: unknownRoot,
source: { kind: "conductor" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted).toEqual([
@@ -293,62 +295,108 @@ describe("ProjectConfigSession", () => {
},
},
]);
expect(serviceCalls).toEqual([]);
});
test("import preview reports malformed Conductor config with a safe relative path", async () => {
test("import preview emits a fake service preview", 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",
},
},
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: (input) => ({
repoRoot: input.repoRoot,
source: input.source,
status: "available",
sourceRevision: "source-revision-1",
paseoRevision: input.paseoRevision,
inputs: [{ role: "shared", relativePath: "source/config.json" }],
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
preview: { worktree: { setup: "npm ci" } },
}),
apply: () => {
throw new Error("unexpected import apply");
},
]);
});
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" },
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted[0]).toMatchObject({
type: "project.config.get_import.response",
payload: {
requestId: "import-preview-1",
repoRoot,
ok: true,
sourceRevision: "source-revision-1",
inputs: [{ role: "shared", relativePath: "source/config.json" }],
},
});
});
test("import preview reports invalid source errors from the injected service", async () => {
const repoRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: () => {
throw new InvalidProjectConfigImportSourceError(
PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
"source/config.json",
);
},
apply: () => {
throw new Error("unexpected import apply");
},
});
await subsystem.handleGetProjectConfigImportRequest({
type: "project.config.get_import.request",
requestId: "import-invalid-1",
repoRoot,
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
});
expect(emitted[0]).toMatchObject({
type: "project.config.get_import.response",
payload: {
requestId: "import-invalid-1",
repoRoot,
ok: false,
error: {
code: "invalid_source_config",
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
relativePath: "source/config.json",
},
},
});
});
test("apply import returns the injected service result", async () => {
const repoRoot = makeRoot();
const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)], {
inspect: () => {
throw new Error("unexpected import inspect");
},
apply: (input) => ({
ok: true,
repoRoot: input.repoRoot,
source: input.source,
config: { worktree: { setup: "npm ci" } },
revision: { mtimeMs: 2, size: 42 },
items: [{ key: "worktree.setup", label: "Worktree setup", outcome: "import" }],
}),
});
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,
source: PROTOCOL_PROJECT_CONFIG_IMPORT_SOURCE,
expectedSourceRevision: "source-revision-1",
expectedPaseoRevision: null,
});
expect(emitted[1]).toMatchObject({
expect(emitted[0]).toMatchObject({
type: "project.config.apply_import.response",
payload: {
requestId: "import-apply-1",
@@ -357,72 +405,5 @@ describe("ProjectConfigSession", () => {
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,8 +8,12 @@ import {
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../../../utils/paseo-config-file.js";
import { applyProjectConfigImport, inspectProjectConfigImport } from "./import/index.js";
import { InvalidProjectConfigImportSourceError } from "./import/model.js";
import { projectConfigImportRegistry } from "./import/registry.js";
import {
createProjectConfigImportService,
InvalidProjectConfigImportSourceError,
type ProjectConfigImportService,
} from "./import/service.js";
export interface ProjectConfigSessionHost {
emit(msg: SessionOutboundMessage): void;
@@ -18,6 +22,7 @@ export interface ProjectConfigSessionHost {
export interface ProjectConfigSessionOptions {
host: ProjectConfigSessionHost;
projectRegistry: Pick<ProjectRegistry, "list">;
importService?: ProjectConfigImportService;
logger: pino.Logger;
}
@@ -31,11 +36,14 @@ export interface ProjectConfigSessionOptions {
export class ProjectConfigSession {
private readonly host: ProjectConfigSessionHost;
private readonly projectRegistry: Pick<ProjectRegistry, "list">;
private readonly importService: ProjectConfigImportService;
private readonly logger: pino.Logger;
constructor(options: ProjectConfigSessionOptions) {
this.host = options.host;
this.projectRegistry = options.projectRegistry;
this.importService =
options.importService ?? createProjectConfigImportService(projectConfigImportRegistry);
this.logger = options.logger;
}
@@ -136,7 +144,7 @@ export class ProjectConfigSession {
}
try {
const preview = inspectProjectConfigImport({
const preview = this.importService.inspect({
repoRoot,
source: msg.source,
paseoConfig: config.config ?? {},
@@ -176,7 +184,7 @@ export class ProjectConfigSession {
return;
}
const result = applyProjectConfigImport({
const result = this.importService.apply({
repoRoot,
source: msg.source,
expectedSourceRevision: msg.expectedSourceRevision,
@@ -66,6 +66,7 @@ import {
type WebSocketRuntimeDiagnosticSnapshot,
} from "./websocket/runtime-metrics.js";
import { ProviderUsageService } from "../services/quota-fetcher/service.js";
import { projectConfigImportRegistry } from "./session/project-config/import/registry.js";
import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js";
import {
CLIENT_SHUTDOWN_RPC_REASON,
@@ -1279,8 +1280,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.110, drop the gate when floor >= v0.1.110.
projectConfigImportConductor: true,
// COMPAT(projectConfigImportSources): added in v0.1.110, remove the gate after 2027-01-17.
projectConfigImportSources: projectConfigImportRegistry.sources(),
// COMPAT(forgeProviders): added in v0.1.106, drop the gate when daemon floor >= v0.1.106.
forgeProviders: true,
},