From 9505e4392579ac09af0f24a671332ec6bae50f43 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 16:42:07 +0200 Subject: [PATCH] fix(settings): report remaining import gaps --- packages/protocol/src/messages.ts | 4 + .../session/project-config/import/merge.ts | 33 +++++++ .../import/sources/conductor/importer.test.ts | 85 +++++++++++++++++++ .../import/sources/conductor/importer.ts | 53 +++++++++--- 4 files changed, 163 insertions(+), 12 deletions(-) diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f519d9863a..5d0c9d85aa 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3664,6 +3664,8 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ export const GetProjectConfigImportResponseMessageSchema = z.object({ type: z.literal("project.config.get_import.response"), + // zod-aot 0.2.0 miscompiles boolean discriminators as string options + // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. payload: z.union([ ProjectConfigImportPreviewSchema.extend({ requestId: z.string(), @@ -3680,6 +3682,8 @@ export const GetProjectConfigImportResponseMessageSchema = z.object({ export const ApplyProjectConfigImportResponseMessageSchema = z.object({ type: z.literal("project.config.apply_import.response"), + // zod-aot 0.2.0 miscompiles boolean discriminators as string options + // (`"true"`/`"false"`), so keep this sequential until upstream fixes it. payload: z.union([ z.object({ requestId: z.string(), diff --git a/packages/server/src/server/session/project-config/import/merge.ts b/packages/server/src/server/session/project-config/import/merge.ts index d147b3d85a..34b6715d12 100644 --- a/packages/server/src/server/session/project-config/import/merge.ts +++ b/packages/server/src/server/session/project-config/import/merge.ts @@ -1,4 +1,5 @@ import type { PaseoConfigRaw } from "@getpaseo/protocol/messages"; +import { normalizeServiceEnvName } from "../../../workspace-service-env.js"; import type { ProjectConfigImportCandidate, ProjectConfigImportPreview } from "./service.js"; interface MergeProjectConfigImportInput { @@ -42,12 +43,26 @@ export function mergeProjectConfigImport( } const patchScripts = input.candidate.patch.scripts ?? {}; + const serviceScriptByEnvName = collectServiceScriptsByEnvName(base.scripts ?? {}); for (const [scriptId, script] of Object.entries(patchScripts)) { const key = `scripts.${scriptId}`; if (base.scripts && Object.hasOwn(base.scripts, scriptId)) { setOutcome(items, key, `Paseo already has a "${scriptId}" script.`); continue; } + if (isServiceScript(script)) { + const envName = normalizeServiceEnvName(scriptId); + const existingScriptId = serviceScriptByEnvName.get(envName); + if (existingScriptId) { + setOutcome( + items, + key, + `Service environment name collides with "${existingScriptId}" (${envName}).`, + ); + continue; + } + serviceScriptByEnvName.set(envName, scriptId); + } merged.scripts = { ...merged.scripts, [scriptId]: script }; importedCount += 1; } @@ -64,6 +79,24 @@ export function mergeProjectConfigImport( }; } +function collectServiceScriptsByEnvName( + scripts: NonNullable, +): Map { + const result = new Map(); + for (const [scriptId, script] of Object.entries(scripts)) { + if (isServiceScript(script)) { + result.set(normalizeServiceEnvName(scriptId), scriptId); + } + } + return result; +} + +function isServiceScript(script: unknown): boolean { + return Boolean( + script && typeof script === "object" && "type" in script && script.type === "service", + ); +} + function hasLifecycle(value: unknown): boolean { if (typeof value === "string") { return value.trim().length > 0; diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts index baff6f613f..c76ff7de4d 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts @@ -476,6 +476,91 @@ hide = true ); }); + test("reports nested environment variables, prompts, and Git settings", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[environment_variables.local] +LOCAL_TOKEN = "local-secret" + +[environment_variables.cloud] +CLOUD_TOKEN = "cloud-secret" + +[prompts] +system = "custom prompt" + +[git] +default_branch = "develop" +`, + ); + + expect(inspect(repo).items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "environment_variables", + outcome: "unsupported", + detail: "Environment variable values are not imported. Found: CLOUD_TOKEN, LOCAL_TOKEN.", + }), + expect.objectContaining({ key: "prompts", outcome: "unsupported" }), + expect.objectContaining({ key: "git", outcome: "unsupported" }), + ]), + ); + }); + + test("reports default and icon fields on imported run scripts", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts.run.dev] +command = "npm run dev" +default = true +icon = "play" +`, + ); + + const preview = inspect(repo); + + expect(preview.preview).toMatchObject({ scripts: { dev: { command: "npm run dev" } } }); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "scripts.dev.default", outcome: "unsupported" }), + expect.objectContaining({ key: "scripts.dev.icon", outcome: "unsupported" }), + ]), + ); + }); + + test("does not import services with colliding normalized environment names", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts.run.app-server] +command = "npm run app -- --port $CONDUCTOR_PORT" + +[scripts.run."app.server"] +command = "npm run other -- --port $CONDUCTOR_PORT" +`, + ); + + const preview = inspect(repo); + + expect(preview.preview?.scripts).toMatchObject({ + "app-server": { type: "service", port: "$PASEO_PORT" }, + }); + expect(preview.preview?.scripts).not.toHaveProperty("app.server"); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "scripts.app.server", + outcome: "collision", + detail: 'Service environment name collides with "app-server" (APP_SERVER).', + }), + ]), + ); + }); + test("malformed TOML identifies the safe relative source path", () => { const repo = makeRepo(); writeSharedToml(repo, "[scripts\nsetup = nope"); diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts index 23c8d308a1..82f6624960 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts @@ -30,6 +30,8 @@ interface ConductorSettings { file_include_globs?: unknown; environment_variables?: unknown; environment_variables_forward?: unknown; + prompts?: unknown; + git?: unknown; spotlight_testing?: unknown; [key: string]: unknown; } @@ -37,7 +39,9 @@ interface ConductorSettings { interface ConductorRunScript { command: string; args?: string[]; + default?: boolean; hide?: boolean; + icon?: string; options?: { cwd?: string; }; @@ -240,7 +244,9 @@ function normalizeRunScript(entry: Record, command: string): Co return { command, ...(args ? { args } : {}), + ...(typeof entry.default === "boolean" ? { default: entry.default } : {}), ...(typeof entry.hide === "boolean" ? { hide: entry.hide } : {}), + ...(typeof entry.icon === "string" ? { icon: entry.icon } : {}), ...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}), ...(availableIn ? { available_in: availableIn } : {}), }; @@ -272,6 +278,13 @@ function mapRunScript( return; } + if (script.default !== undefined) { + unsupported(items, `scripts.${scriptId}.default`, "Default script selection is not imported."); + } + if (script.icon !== undefined) { + unsupported(items, `scripts.${scriptId}.icon`, "Script icons are not imported."); + } + let command = appendArgs(script.command, script.args ?? []); if (script.options?.cwd) { const cwdPrefix = safeCwdPrefix(script.options.cwd); @@ -349,27 +362,43 @@ function reportUnsupported( "Paseo spotlight is a separate workflow, not project config.", ); } + if (settings.prompts !== undefined) { + unsupported(items, "prompts", "Custom agent prompts are not imported."); + } + if (settings.git !== undefined) { + unsupported(items, "git", "Conductor Git settings are not imported."); + } } function collectEnvironmentVariableNames(settings: ConductorSettings): string[] { const names = new Set(); for (const key of ["environment_variables", "environment_variables_forward"] as const) { - const value = settings[key]; - if (isRecord(value)) { - for (const name of Object.keys(value).sort()) { - names.add(name); - } - } else if (Array.isArray(value)) { - for (const name of value) { - if (typeof name === "string") { - names.add(name); - } - } - } + collectEnvironmentVariableNamesFromValue(settings[key], names); } return Array.from(names).sort(); } +function collectEnvironmentVariableNamesFromValue(value: unknown, names: Set): void { + if (Array.isArray(value)) { + for (const name of value) { + if (typeof name === "string") { + names.add(name); + } + } + return; + } + if (!isRecord(value)) { + return; + } + for (const [name, nestedValue] of Object.entries(value)) { + if (isRecord(nestedValue)) { + collectEnvironmentVariableNamesFromValue(nestedValue, names); + } else { + names.add(name); + } + } +} + function unsupported(items: ProjectConfigImportItem[], key: string, detail: string): void { items.push({ key, label: key, outcome: "unsupported", detail }); }