mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
refactor(settings): make project imports pluggable
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+17
-140
@@ -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");
|
||||
+8
-18
@@ -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(
|
||||
+120
-139
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user