fix(cli): resolve dynamic imports to file:// URLs so the DB fallback works on Windows (#11238)

Validated on the combined 12-PR batch board: cli-combo-command + windows-esm-import-paths suites pass, typecheck:core clean. pathToFileURL on the four dynamic-import call sites unbreaks the CLI offline DB fallback on Windows. Thank you @pacocartones!
This commit is contained in:
Paco Cartones
2026-08-23 19:24:50 +02:00
committed by GitHub
parent a55ee5dc05
commit e32b9264e8
5 changed files with 88 additions and 8 deletions

View File

@@ -1,7 +1,7 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { platform, totalmem } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
@@ -414,7 +414,7 @@ async function runWithSupervisor(
if (detectMitmCrash(crashLog)) {
try {
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const { updateSettings } = await import(`${PROJECT_ROOT}/src/lib/db/settings.ts`);
const { updateSettings } = await import(pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href);
updateSettings({ mitmEnabled: false });
} catch {}
return "disable-mitm-and-retry";

View File

@@ -1,4 +1,4 @@
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve } from "node:path";
import { createPrompt, printHeading, printInfo, printSuccess } from "../io.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
@@ -16,7 +16,7 @@ import { t } from "../i18n.mjs";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
async function getListCliTools() {
const { listCliTools } = await import(`${PROJECT_ROOT}/src/shared/constants/cliTools.ts`);
const { listCliTools } = await import(pathToFileURL(resolve(PROJECT_ROOT, "src/shared/constants/cliTools.ts")).href);
return listCliTools;
}

View File

@@ -1,9 +1,14 @@
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, resolve } from "node:path";
import { apiFetch, isServerUp } from "./api.mjs";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
// Dynamic `import()` resolves its specifier as a URL, not as a filesystem path.
// On Windows an absolute path starts with a drive letter, which the ESM loader
// reads as the unsupported URL scheme `e:` and rejects. Pass a file:// URL.
const projectFileUrl = (relPath) => pathToFileURL(resolve(PROJECT_ROOT, relPath)).href;
export class ServerOfflineError extends Error {
constructor(message = "Server is offline and operation requires HTTP runtime") {
super(message);
@@ -22,8 +27,8 @@ function makeHttpContext(opts) {
async function importDbModules() {
const [combos, recovery] = await Promise.all([
import(`${PROJECT_ROOT}/src/lib/db/combos.ts`),
import(`${PROJECT_ROOT}/src/lib/db/recovery.ts`),
import(projectFileUrl("src/lib/db/combos.ts")),
import(projectFileUrl("src/lib/db/recovery.ts")),
]);
return { combos, recovery };
}

View File

@@ -27,7 +27,14 @@ async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
} finally {
console.log = originalLog;
globalThis.fetch = ORIGINAL_FETCH;
fs.rmSync(dataDir, { recursive: true, force: true });
// On Windows the SQLite file may still be held open by the db module when
// the test ends, and rmSync then throws EPERM, failing a test whose
// assertions all passed. Retry, then give up quietly.
try {
fs.rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
} catch {
// best effort: the OS reclaims its own temp dir
}
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;

View File

@@ -0,0 +1,68 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
// Regression guard for the Windows-only ESM loader failure:
//
// Error: Only URLs with a scheme in: file, data, and node are supported by
// the default ESM loader. On Windows, absolute paths must be valid file://
// URLs. Received protocol 'e:'
//
// `import()` resolves its specifier as a URL. A POSIX absolute path like
// /home/x/src/lib/db/combos.ts happens to also be a valid relative URL, so
// interpolating it works by accident. A Windows absolute path is
// E:\checkout\src\lib\db\combos.ts, whose leading drive letter the loader
// parses as the URL scheme `e:` and rejects. Every such call site must go
// through pathToFileURL().
//
// This broke `omniroute combo list/create/delete/switch` on Windows whenever
// the CLI fell back to direct DB access with the server offline.
const CLI_DIR = path.join(PROJECT_ROOT, "bin", "cli");
function collectMjsFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...collectMjsFiles(full));
else if (entry.name.endsWith(".mjs")) out.push(full);
}
return out;
}
test("bin/cli never passes an interpolated absolute path to dynamic import()", () => {
// Matches import(`${ANY_ROOT_CONST}/...`) — a raw filesystem path, not a URL.
const badImport = /\bimport\(\s*`\$\{[A-Za-z_$][\w$]*\}\//;
const offenders: string[] = [];
for (const file of collectMjsFiles(CLI_DIR)) {
const source = fs.readFileSync(file, "utf8");
source.split(/\r?\n/).forEach((line, i) => {
if (badImport.test(line)) {
offenders.push(`${path.relative(PROJECT_ROOT, file)}:${i + 1}: ${line.trim()}`);
}
});
}
assert.deepEqual(
offenders,
[],
"dynamic import() of an interpolated absolute path fails on Windows; " +
`wrap the path in pathToFileURL(...).href instead:\n${offenders.join("\n")}`,
);
});
test("runtime.mjs resolves db modules to a file:// URL", async () => {
const source = fs.readFileSync(path.join(CLI_DIR, "runtime.mjs"), "utf8");
assert.match(source, /pathToFileURL/, "runtime.mjs must build file:// URLs for dynamic imports");
// The real proof: the db fallback modules actually load on this platform.
const runtime = await import(pathToFileURL(path.join(CLI_DIR, "runtime.mjs")).href);
const ctx = await runtime.withDb(async (c: { kind: string; db: unknown }) => c);
assert.equal(ctx.kind, "db");
assert.ok(ctx.db);
});