diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 004b4815ac..456e2e6f77 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -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"; diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index 4ded5032d4..5415cdd70b 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -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; } diff --git a/bin/cli/runtime.mjs b/bin/cli/runtime.mjs index 6811896759..987fa82799 100644 --- a/bin/cli/runtime.mjs +++ b/bin/cli/runtime.mjs @@ -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 }; } diff --git a/tests/unit/cli-combo-command.test.ts b/tests/unit/cli-combo-command.test.ts index dc38e3349e..1bf00e341d 100644 --- a/tests/unit/cli-combo-command.test.ts +++ b/tests/unit/cli-combo-command.test.ts @@ -27,7 +27,14 @@ async function withComboEnv(fn: (dataDir: string) => Promise) { } 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; diff --git a/tests/unit/cli/windows-esm-import-paths.test.ts b/tests/unit/cli/windows-esm-import-paths.test.ts new file mode 100644 index 0000000000..e85385cc9d --- /dev/null +++ b/tests/unit/cli/windows-esm-import-paths.test.ts @@ -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); +});