fix(migrate): preserve source command semantics

This commit is contained in:
Mohamed Boudra
2026-07-19 01:03:00 +02:00
parent c54ddfd07f
commit e61c06706c
4 changed files with 129 additions and 20 deletions
@@ -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");
@@ -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]";
}
@@ -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(
@@ -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<string, unknown> = { ...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<string>();
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);