mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
fix(migrate): preserve source command semantics
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user