mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(migrate): preserve consistent source state
This commit is contained in:
@@ -70,6 +70,9 @@ export function MigrationSheet({
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MigrationState>({ status: "confirm" });
|
||||
const header = useMemo<SheetHeader>(() => ({ title: source.sheetTitle }), [source.sheetTitle]);
|
||||
const close = useCallback(() => {
|
||||
if (state.status !== "running") onClose();
|
||||
}, [onClose, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) setState({ status: "confirm" });
|
||||
@@ -120,17 +123,17 @@ export function MigrationSheet({
|
||||
return <Button disabled>{t("desktop.integrations.migration.actions.importing")}</Button>;
|
||||
}
|
||||
return (
|
||||
<Button onPress={onClose} testID="migration-done">
|
||||
<Button onPress={close} testID="migration-done">
|
||||
{t("desktop.integrations.migration.actions.done")}
|
||||
</Button>
|
||||
);
|
||||
}, [onClose, start, state.status, t]);
|
||||
}, [close, start, state.status, t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onClose={close}
|
||||
footer={footer}
|
||||
desktopMaxWidth={640}
|
||||
testID="migration-sheet"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cpSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -66,6 +66,16 @@ test("rejects an unsupported schema version before guessing at its contents", as
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a database with pending WAL changes", async () => {
|
||||
const databasePath = temporaryDatabase();
|
||||
cpSync(fixturePath, databasePath);
|
||||
writeFileSync(`${databasePath}-wal`, "pending");
|
||||
|
||||
await expect(readConductorCatalog(databasePath)).rejects.toThrow(
|
||||
"Quit Conductor before migrating",
|
||||
);
|
||||
});
|
||||
|
||||
test("reports malformed database JSON and script columns per project", async () => {
|
||||
const databasePath = temporaryDatabase();
|
||||
cpSync(fixturePath, databasePath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import initSqlJs, { type Database } from "sql.js";
|
||||
import type { MigrationNotice } from "../../types.js";
|
||||
@@ -36,6 +36,12 @@ export interface ConductorCatalog {
|
||||
export class UnsupportedConductorDatabaseError extends Error {}
|
||||
|
||||
export async function readConductorCatalog(databasePath: string): Promise<ConductorCatalog> {
|
||||
const walPath = `${databasePath}-wal`;
|
||||
if (existsSync(walPath) && statSync(walPath).size > 0) {
|
||||
throw new UnsupportedConductorDatabaseError(
|
||||
"Conductor has pending database changes. Quit Conductor before migrating.",
|
||||
);
|
||||
}
|
||||
const SQL = await sqlite;
|
||||
const database = new SQL.Database(readFileSync(databasePath));
|
||||
try {
|
||||
|
||||
@@ -132,28 +132,29 @@ test("reports a malformed top-level scripts value", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("does not rewrite literal or escaped Conductor variables", () => {
|
||||
test("preserves literal or escaped Conductor names and rewrites active variables", () => {
|
||||
const inspected = inspectConductorProjectConfig(emptyRepo("literal-variables"), {
|
||||
scripts: {
|
||||
setup: "printf '%s\\n' '$CONDUCTOR_WORKSPACE_PATH'",
|
||||
run: {
|
||||
escaped: { command: "printf \\$CONDUCTOR_PORT" },
|
||||
escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' },
|
||||
bare: { command: "printf CONDUCTOR_PORT" },
|
||||
active: { command: "printf \"'$CONDUCTOR_PORT'\"" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(inspected.config).toEqual({
|
||||
scripts: { active: { command: "printf \"'$PASEO_PORT'\"", type: "service" } },
|
||||
worktree: { setup: "printf '%s\\n' '$CONDUCTOR_WORKSPACE_PATH'" },
|
||||
scripts: {
|
||||
active: { command: "printf \"'$PASEO_PORT'\"", type: "service" },
|
||||
bare: { command: "printf CONDUCTOR_PORT" },
|
||||
escaped: { command: "printf \\$CONDUCTOR_PORT" },
|
||||
escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' },
|
||||
},
|
||||
});
|
||||
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.",
|
||||
"scripts.escapedDouble: Literal or escaped Conductor variables: CONDUCTOR_PORT. Command was not imported.",
|
||||
]),
|
||||
);
|
||||
expect(inspected.notices).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips cwd scripts on Windows instead of emitting POSIX shell syntax", () => {
|
||||
|
||||
@@ -354,16 +354,6 @@ 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"],
|
||||
@@ -578,9 +568,13 @@ function malformedSetting(key: string, detail: string): MigrationNotice {
|
||||
}
|
||||
|
||||
function replaceShellVariable(command: string, from: string, to: string): string {
|
||||
const mask = activeShellMask(command);
|
||||
const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g");
|
||||
return replaceArithmeticVariable(
|
||||
command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`)),
|
||||
command.replace(pattern, (match, offset: number) => {
|
||||
if (mask[offset] !== "$") return match;
|
||||
return match.startsWith("${") ? `\${${to}` : `$${to}`;
|
||||
}),
|
||||
from,
|
||||
to,
|
||||
);
|
||||
@@ -588,63 +582,69 @@ function replaceShellVariable(command: string, from: string, to: string): string
|
||||
|
||||
function collectConductorVariables(command: string): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const match of command.matchAll(/(?:\$\{|\$|[^A-Za-z0-9_])(CONDUCTOR_[A-Za-z0-9_]+)/g)) {
|
||||
if (match[1]) names.add(match[1]);
|
||||
const mask = activeShellMask(command);
|
||||
const direct =
|
||||
/\$\{(CONDUCTOR_[A-Za-z0-9_]+)(?=[}:#%+\-=?])|\$(CONDUCTOR_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/g;
|
||||
for (const match of command.matchAll(direct)) {
|
||||
if (mask[match.index] !== "$") continue;
|
||||
const name = match[1] ?? match[2];
|
||||
if (name) names.add(name);
|
||||
}
|
||||
forEachActiveArithmetic(command, (body) => {
|
||||
for (const match of body.matchAll(/(?:^|[^A-Za-z0-9_])(CONDUCTOR_[A-Za-z0-9_]+)/g)) {
|
||||
if (match[1]) names.add(match[1]);
|
||||
}
|
||||
});
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
function collectLiteralConductorVariables(command: string): string[] {
|
||||
const names = new Set<string>();
|
||||
function activeShellMask(command: string): string {
|
||||
const mask = [...command];
|
||||
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);
|
||||
mask[index] = " ";
|
||||
if (character === "'") quote = null;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
if (command[index + 1] === "$") {
|
||||
const name = readConductorVariable(command, index + 1);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
mask[index] = " ";
|
||||
if (index + 1 < mask.length) mask[index + 1] = " ";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (character === '"') quote = null;
|
||||
if (character === '"') {
|
||||
mask[index] = " ";
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'") quote = "single";
|
||||
if (character === '"') quote = "double";
|
||||
if (character === "'") {
|
||||
mask[index] = " ";
|
||||
quote = "single";
|
||||
} else if (character === '"') {
|
||||
mask[index] = " ";
|
||||
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
|
||||
);
|
||||
return mask.join("");
|
||||
}
|
||||
|
||||
function containsShellVariable(command: string, name: string): boolean {
|
||||
const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`);
|
||||
return pattern.test(command) || containsArithmeticVariable(command, name);
|
||||
const mask = activeShellMask(command);
|
||||
const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`, "g");
|
||||
for (const match of command.matchAll(pattern)) {
|
||||
if (mask[match.index] === "$") return true;
|
||||
}
|
||||
return containsArithmeticVariable(command, name);
|
||||
}
|
||||
|
||||
function replaceArithmeticVariable(command: string, from: string, to: string): string {
|
||||
return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string) => {
|
||||
const mask = activeShellMask(command);
|
||||
return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string, offset: number) => {
|
||||
if (mask.slice(offset, offset + 3) !== "$((") return expression;
|
||||
const identifier = new RegExp(`(^|[^A-Za-z0-9_])${from}(?![A-Za-z0-9_])`, "g");
|
||||
const rewritten = body.replace(identifier, (_match, prefix: string) => `${prefix}${to}`);
|
||||
return rewritten === body ? expression : `$((` + rewritten + `))`;
|
||||
@@ -653,19 +653,28 @@ function replaceArithmeticVariable(command: string, from: string, to: string): s
|
||||
|
||||
function containsArithmeticVariable(command: string, name: string): boolean {
|
||||
const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`);
|
||||
for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) {
|
||||
if (identifier.test(match[1])) return true;
|
||||
}
|
||||
return false;
|
||||
let found = false;
|
||||
forEachActiveArithmetic(command, (body) => {
|
||||
if (identifier.test(body)) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function containsArithmeticVariableOperation(command: string, name: string): boolean {
|
||||
const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`);
|
||||
let found = false;
|
||||
forEachActiveArithmetic(command, (value) => {
|
||||
const body = value.trim();
|
||||
if (identifier.test(body) && body !== name) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function forEachActiveArithmetic(command: string, visit: (body: string) => void): void {
|
||||
const mask = activeShellMask(command);
|
||||
for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) {
|
||||
const body = match[1].trim();
|
||||
if (identifier.test(body) && body !== name) return true;
|
||||
if (mask.slice(match.index, match.index + 3) === "$((") visit(match[1]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function appendArgs(command: string, args: string[]): string {
|
||||
|
||||
Reference in New Issue
Block a user