diff --git a/packages/desktop/src/integrations/migrations/process.test.ts b/packages/desktop/src/integrations/migrations/process.test.ts index ff2b0795a1..3162ce07bd 100644 --- a/packages/desktop/src/integrations/migrations/process.test.ts +++ b/packages/desktop/src/integrations/migrations/process.test.ts @@ -121,6 +121,15 @@ test.each([ }); }); +test.each(["[::1]:6767", "::1:6767"])("accepts local IPv6 listen address %s", async (listen) => { + const migrations = createMigrations({ ...eligibleTarget, listen }, fakeChild().process); + + await expect(migrations.availability("source-fixture")).resolves.toEqual({ + available: true, + reason: null, + }); +}); + test("reports a bundled package version mismatch before spawn", async () => { const child = fakeChild(); const migrations = createMigrations(eligibleTarget, child.process, undefined, "0.1.109"); diff --git a/packages/desktop/src/integrations/migrations/process.ts b/packages/desktop/src/integrations/migrations/process.ts index d9ce4c72a3..9f8c15c645 100644 --- a/packages/desktop/src/integrations/migrations/process.ts +++ b/packages/desktop/src/integrations/migrations/process.ts @@ -177,7 +177,7 @@ function isLocalListen(listen: string | null): boolean { return true; } const endpoint = listen.replace(/^tcp:\/\//, "").toLowerCase(); - if (endpoint.startsWith("[::1]:")) return true; + if (endpoint.startsWith("[::1]:") || endpoint.startsWith("::1:")) return true; const host = endpoint.split(":")[0]; return host === "127.0.0.1" || host === "localhost" || host === "[::1]"; } diff --git a/packages/migrate/src/sources/conductor/project-config.test.ts b/packages/migrate/src/sources/conductor/project-config.test.ts index ca134ad9d2..8101f33475 100644 --- a/packages/migrate/src/sources/conductor/project-config.test.ts +++ b/packages/migrate/src/sources/conductor/project-config.test.ts @@ -118,6 +118,42 @@ run = 42 }); }); +test("reports a malformed top-level scripts value", () => { + const repo = emptyRepo("malformed-top-level-scripts"); + writeSettings(repo, 'scripts = "npm run dev"\n'); + + const inspected = inspectConductorProjectConfig(repo); + + expect(inspected.config).toBeNull(); + expect(inspected.notices).toContainEqual({ + code: "conductor-setting-malformed", + level: "warning", + message: "scripts: Expected a scripts table.", + }); +}); + +test("does not rewrite literal or escaped Conductor variables", () => { + const inspected = inspectConductorProjectConfig(emptyRepo("literal-variables"), { + scripts: { + setup: "printf '%s\\n' '$CONDUCTOR_WORKSPACE_PATH'", + run: { + escaped: { command: "printf \\$CONDUCTOR_PORT" }, + active: { command: "printf \"'$CONDUCTOR_PORT'\"" }, + }, + }, + }); + + expect(inspected.config).toEqual({ + scripts: { active: { command: "printf \"'$PASEO_PORT'\"", type: "service" } }, + }); + expect(inspected.notices.map((notice) => notice.message)).toEqual( + expect.arrayContaining([ + "worktree.setup: Literal or escaped Conductor variables: CONDUCTOR_WORKSPACE_PATH. Command was not imported.", + "scripts.escaped: Literal or escaped Conductor variables: CONDUCTOR_PORT. Command was not imported.", + ]), + ); +}); + test("skips cwd scripts on Windows instead of emitting POSIX shell syntax", () => { const repo = emptyRepo("windows-cwd"); writeSettings( diff --git a/packages/migrate/src/sources/conductor/project-config.ts b/packages/migrate/src/sources/conductor/project-config.ts index a3e4634090..ac51b7b418 100644 --- a/packages/migrate/src/sources/conductor/project-config.ts +++ b/packages/migrate/src/sources/conductor/project-config.ts @@ -16,14 +16,7 @@ interface SourceFile { } export interface ConductorSettings { - scripts?: { - setup?: unknown; - archive?: unknown; - run?: unknown; - run_mode?: unknown; - auto_run_after_setup?: unknown; - [key: string]: unknown; - }; + scripts?: unknown; file_include_globs?: unknown; environment_variables?: unknown; environment_variables_forward?: unknown; @@ -59,10 +52,15 @@ export function inspectConductorProjectConfig( ); const config: PaseoConfigRaw = {}; const notices: MigrationNotice[] = []; + const scripts = isRecord(settings.scripts) ? settings.scripts : null; - mapLifecycle(settings.scripts?.setup, "worktree.setup", "setup", config, notices); - mapLifecycle(settings.scripts?.archive, "worktree.teardown", "teardown", config, notices); - mapRunScripts(settings.scripts?.run, config, notices, platform); + if (settings.scripts !== undefined && !scripts) { + notices.push(malformedSetting("scripts", "Expected a scripts table.")); + } + + mapLifecycle(scripts?.setup, "worktree.setup", "setup", config, notices); + mapLifecycle(scripts?.archive, "worktree.teardown", "teardown", config, notices); + mapRunScripts(scripts?.run, config, notices, platform); mapMetadataPrompts(settings.prompts, config, notices); reportUnsupported(repoRoot, settings, notices); @@ -107,11 +105,7 @@ function mergeSettings(base: ConductorSettings, override: ConductorSettings): Co return { ...base, ...override, - scripts: { - ...(isRecord(base.scripts) ? base.scripts : {}), - ...(isRecord(override.scripts) ? override.scripts : {}), - run: mergeRunScripts(base.scripts?.run, override.scripts?.run), - }, + scripts: mergeScripts(base.scripts, override.scripts), environment_variables: mergeNested(base.environment_variables, override.environment_variables), environment_variables_forward: mergeNested( base.environment_variables_forward, @@ -121,6 +115,18 @@ function mergeSettings(base: ConductorSettings, override: ConductorSettings): Co }; } +function mergeScripts(base: unknown, override: unknown): unknown { + if (override !== undefined && !isRecord(override)) return override; + if (override === undefined && !isRecord(base)) return base; + const baseScripts = isRecord(base) ? base : {}; + const overrideScripts = isRecord(override) ? override : {}; + return { + ...baseScripts, + ...overrideScripts, + run: mergeRunScripts(baseScripts.run, overrideScripts.run), + }; +} + function mergeNested(base: unknown, override: unknown): unknown { if (!isRecord(base) || !isRecord(override)) return override ?? base; const merged: Record = { ...base }; @@ -329,11 +335,14 @@ function mapRunScript( ); return; } - serviceNames.set(environmentName, scriptId); } const rewritten = rewriteExactCommand(command, service ? "run" : "lifecycle", key, notices); if (!rewritten) return; + if (service) { + const environmentName = scriptId.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + serviceNames.set(environmentName, scriptId); + } const entry: PaseoScriptEntryRaw = { command: rewritten }; if (service) entry.type = "service"; config.scripts = { ...config.scripts, [scriptId]: entry }; @@ -345,6 +354,16 @@ function rewriteExactCommand( key: string, notices: MigrationNotice[], ): string | null { + const literalVariables = collectLiteralConductorVariables(command); + if (literalVariables.length > 0) { + notices.push( + unsupportedSetting( + key, + `Literal or escaped Conductor variables: ${literalVariables.join(", ")}. Command was not imported.`, + ), + ); + return null; + } let rewritten = command; for (const [from, to] of [ ["CONDUCTOR_WORKSPACE_PATH", "PASEO_WORKTREE_PATH"], @@ -371,12 +390,13 @@ function reportUnsupported( settings: ConductorSettings, notices: MigrationNotice[], ): void { + const scripts = isRecord(settings.scripts) ? settings.scripts : {}; const unsupportedValues: Array<[string, unknown, string]> = [ - ["scripts.run_mode", settings.scripts?.run_mode, "Paseo has no project-wide run mode."], + ["scripts.run_mode", scripts.run_mode, "Paseo has no project-wide run mode."], ["runScriptMode", settings.runScriptMode, "Paseo has no project-wide run mode."], [ "scripts.auto_run_after_setup", - settings.scripts?.auto_run_after_setup, + scripts.auto_run_after_setup, "Paseo does not auto-run scripts after setup.", ], [ @@ -574,6 +594,50 @@ function collectConductorVariables(command: string): string[] { return [...names].sort(); } +function collectLiteralConductorVariables(command: string): string[] { + const names = new Set(); + let quote: "single" | "double" | null = null; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]; + if (quote === "single") { + if (character === "'") { + quote = null; + continue; + } + if (!command.startsWith("CONDUCTOR_", index)) continue; + const previous = command[index - 1]; + if (previous && /[A-Za-z0-9_]/.test(previous)) continue; + const name = command.slice(index).match(/^CONDUCTOR_[A-Za-z0-9_]+/)?.[0]; + if (name) names.add(name); + continue; + } + if (character === "\\") { + if (command[index + 1] === "$") { + const name = readConductorVariable(command, index + 1); + if (name) names.add(name); + } + index += 1; + continue; + } + if (quote === "double") { + if (character === '"') quote = null; + continue; + } + if (character === "'") quote = "single"; + if (character === '"') quote = "double"; + } + return [...names].sort(); +} + +function readConductorVariable(command: string, dollarIndex: number): string | null { + const suffix = command.slice(dollarIndex + 1); + return ( + suffix.match(/^\{(CONDUCTOR_[A-Za-z0-9_]+)/)?.[1] ?? + suffix.match(/^(CONDUCTOR_[A-Za-z0-9_]+)/)?.[1] ?? + null + ); +} + function containsShellVariable(command: string, name: string): boolean { const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`); return pattern.test(command) || containsArithmeticVariable(command, name);