From 6d6418f8520b499cc4dd1e04f3940661a4a2a812 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 04:46:21 +0000 Subject: [PATCH 1/3] fix(cli): use pathToFileURL for portable main-module guard Addresses review feedback on #46. Gate program.parse() on the module being the invoked script via Node's pathToFileURL helper so Windows paths and unicode segments are handled correctly. Test coverage for mex log/timeline option parsing already landed via #47; this PR carries the remaining portable main-module guard change. --- src/cli.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index b5c083d3..03b3a07b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { Command, InvalidArgumentError } from "commander"; +import { pathToFileURL } from "node:url"; import { findConfig } from "./config.js"; import { reportConsole, reportQuiet, reportJSON, reportVerbose } from "./reporter.js"; import { VERSION } from "./version.js"; @@ -300,7 +301,12 @@ program console.log(); }); -program.parse(); +// Skip auto-parse when imported (e.g. by tests). The bin entry is built by +// tsup as ./dist/cli.js with a shebang banner; only run program.parse() when +// this module is the script being invoked. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + program.parse(); +} function buildCompletion(shell: string): string { const commands = [ From 18864543834c141f690492a022f81468a59374ce Mon Sep 17 00:00:00 2001 From: advancedresearcharray <223350115+advancedresearcharray@users.noreply.github.com> Date: Sat, 6 Jun 2026 05:05:20 +0000 Subject: [PATCH 2/3] docs(patterns): note main-module guard in CLI test pattern Update cli-option-parsing-tests.md to reflect that src/cli.ts only auto-parses when run as the invoked script, matching the pathToFileURL guard landed in this PR. --- patterns/cli-option-parsing-tests.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/patterns/cli-option-parsing-tests.md b/patterns/cli-option-parsing-tests.md index 34928bf9..f1e54b0f 100644 --- a/patterns/cli-option-parsing-tests.md +++ b/patterns/cli-option-parsing-tests.md @@ -15,7 +15,7 @@ last_updated: 2026-05-21 # CLI Option Parsing Tests ## Context -`src/cli.ts` calls `program.parse()` at import time. Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, control `process.argv` during a dynamic import and suppress console output. +`src/cli.ts` auto-parses only when invoked as the main script (`import.meta.url === pathToFileURL(process.argv[1]).href`). Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, set `process.argv[1]` to a non-matching path during the dynamic import and suppress console output so the guard stays false. ## Steps 1. Export narrow parser helpers from `src/cli.ts` when direct unit coverage is needed. From 5b062cd89d4127972fbbb02e97c7ce7ff5a0a0ad Mon Sep 17 00:00:00 2001 From: root Date: Sat, 6 Jun 2026 19:04:52 +0000 Subject: [PATCH 3/3] fix(cli): resolve symlinked argv[1] in main-module guard realpathSync(process.argv[1]) so npm global/npx/node_modules/.bin symlinks match import.meta.url. Add a symlinked-bin integration test and guard against missing argv paths in test imports. --- patterns/cli-option-parsing-tests.md | 2 +- src/cli.ts | 14 ++++++++-- test/cli.test.ts | 41 +++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/patterns/cli-option-parsing-tests.md b/patterns/cli-option-parsing-tests.md index f1e54b0f..2acd012a 100644 --- a/patterns/cli-option-parsing-tests.md +++ b/patterns/cli-option-parsing-tests.md @@ -15,7 +15,7 @@ last_updated: 2026-05-21 # CLI Option Parsing Tests ## Context -`src/cli.ts` auto-parses only when invoked as the main script (`import.meta.url === pathToFileURL(process.argv[1]).href`). Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, set `process.argv[1]` to a non-matching path during the dynamic import and suppress console output so the guard stays false. +`src/cli.ts` auto-parses only when invoked as the main script (`import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href`). Do not import its `program` object in tests. If parser helpers must be imported from `src/cli.ts`, set `process.argv[1]` to a non-matching path during the dynamic import and suppress console output so the guard stays false. ## Steps 1. Export narrow parser helpers from `src/cli.ts` when direct unit coverage is needed. diff --git a/src/cli.ts b/src/cli.ts index 03b3a07b..38eb06e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { Command, InvalidArgumentError } from "commander"; +import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { findConfig } from "./config.js"; import { reportConsole, reportQuiet, reportJSON, reportVerbose } from "./reporter.js"; @@ -303,8 +304,17 @@ program // Skip auto-parse when imported (e.g. by tests). The bin entry is built by // tsup as ./dist/cli.js with a shebang banner; only run program.parse() when -// this module is the script being invoked. -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +// this module is the script being invoked. Resolve argv[1] so symlinked bins +// (npm global, npx, node_modules/.bin) match import.meta.url. +let isMainModule = false; +if (process.argv[1]) { + try { + isMainModule = import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; + } catch { + // argv[1] is missing or not on disk (e.g. test fixtures) — not the main entry. + } +} +if (isMainModule) { program.parse(); } diff --git a/test/cli.test.ts b/test/cli.test.ts index 59a22410..f240f2bd 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; import { Command, InvalidArgumentError } from "commander"; -import { readFileSync } from "node:fs"; +import { execSync, spawnSync } from "node:child_process"; +import { readFileSync, symlinkSync, mkdtempSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { runLog, runTimeline } from "../src/events.js"; import type { MexConfig } from "../src/types.js"; @@ -208,6 +210,43 @@ describe("mex timeline parsing", () => { }); }); +describe("built CLI main-module guard", () => { + const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + const cliPath = join(repoRoot, "dist", "cli.js"); + const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { version: string }; + + beforeAll(() => { + execSync("npm run build", { cwd: repoRoot, stdio: "pipe" }); + }); + + it("parses argv when invoked through a symlinked bin (npm/npx layout)", () => { + const binDir = mkdtempSync(join(tmpdir(), "mex-bin-")); + const symlinkedCli = join(binDir, "mex"); + try { + symlinkSync(cliPath, symlinkedCli); + const result = spawnSync(process.execPath, [symlinkedCli, "--version"], { + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }); + expect(result.status).toBe(0); + expect((result.stdout ?? "").trim()).toBe(pkg.version); + } finally { + rmSync(binDir, { recursive: true, force: true }); + } + }); + + it("does not auto-parse when dist/cli.js is imported as a module", () => { + const result = spawnSync( + process.execPath, + ["-e", "import('./dist/cli.js').then(() => console.log('imported'))"], + { cwd: repoRoot, encoding: "utf8" }, + ); + expect(result.status).toBe(0); + expect(result.stdout).toContain("imported"); + expect(result.stdout).not.toContain(pkg.version); + }); +}); + describe("mex --version", () => { it("reports the version from package.json (guards against hard-coded drift)", async () => { // cli.js is imported (and parsed with a safe argv) in beforeAll; this