Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/cli/commands/serve.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -381,7 +381,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";
Expand Down
4 changes: 2 additions & 2 deletions bin/cli/commands/setup.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}

Expand Down
11 changes: 8 additions & 3 deletions bin/cli/runtime.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 };
}
Expand Down
9 changes: 8 additions & 1 deletion tests/unit/cli-combo-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/cli/windows-esm-import-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading