diff --git a/src/cli.ts b/src/cli.ts index 974d303..31c6999 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import process from "node:process"; import { pathToFileURL } from "node:url"; import { Effect } from "effect"; @@ -369,6 +370,39 @@ function promiseBoundary(run: () => Promise): Effect.Effect }); } +/** + * Whether this module is the process entry point. + * + * Node canonicalises `import.meta.url` through symlinks but leaves + * `process.argv[1]` exactly as the caller wrote it, so comparing them directly + * fails whenever the CLI is reached through a symlink — which is the norm under + * pnpm, where `node_modules/` links into `node_modules/.pnpm/…`. Depending + * on which path the generated bin shim used, the CLI would exit 0 having done + * no work at all, so a `vref build --check` step could pass while validating + * nothing. Canonicalise both sides. + */ +export function isDirectInvocation(moduleUrl: string, entryPath: string | undefined): boolean { + if (entryPath === undefined) { + return false; + } + + // Raw comparison first: under `node --preserve-symlinks-main` Node + // deliberately keeps `import.meta.url` on the symlink, so canonicalising only + // the entry path would make the two disagree and silently skip `main`. + if (moduleUrl === pathToFileURL(entryPath).href) { + return true; + } + + try { + return moduleUrl === pathToFileURL(realpathSync(entryPath)).href; + } catch { + // Any resolution failure — missing, unreadable, a symlink loop — leaves the + // entry path unproven, and an unproven entry path is not this module. Fail + // closed rather than throwing during startup. + return false; + } +} + export function main(argv: string[], cwd: string): void { const isInteractiveTerminal = process.stdout.isTTY === true; void Effect.runPromise(runCli(argv, cwd, { isInteractiveTerminal })).catch((error: unknown) => { @@ -384,7 +418,7 @@ export function main(argv: string[], cwd: string): void { }); } -if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isDirectInvocation(import.meta.url, process.argv[1])) { main(process.argv.slice(2), process.cwd()); } diff --git a/test/vref.test.ts b/test/vref.test.ts index 9a58382..6fb2216 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -1,10 +1,11 @@ -import { mkdir, mkdtemp, readFile, symlink, unlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, symlink, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { Effect } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { buildGallery, validateGallery } from "../src/build.js"; -import { runCli } from "../src/cli.js"; +import { isDirectInvocation, runCli } from "../src/cli.js"; import { describeCli } from "../src/describe.js"; import { resolveServableFile } from "../src/serve.js"; import type { VrefManifest } from "../src/types.js"; @@ -560,3 +561,52 @@ function makeManifest(file: string, tags: string[]): VrefManifest { ], }; } + +describe("cli entry detection", () => { + it("treats a symlinked entry path as a direct invocation", async () => { + const root = await mkdtemp(join(tmpdir(), "vref-entry-")); + const real = join(root, "cli.mjs"); + const link = join(root, "linked-cli.mjs"); + await writeFile(real, ""); + await symlink(real, link); + + const moduleUrl = pathToFileURL(await realpath(real)).href; + + // How pnpm's bin shim reaches the CLI: through node_modules/, a + // symlink into node_modules/.pnpm. Comparing raw paths would miss this and + // the CLI would silently do nothing. + expect(isDirectInvocation(moduleUrl, link)).toBe(true); + expect(isDirectInvocation(moduleUrl, real)).toBe(true); + }); + + it("still detects direct invocation when node keeps the main symlink", async () => { + const root = await mkdtemp(join(tmpdir(), "vref-entry-")); + const real = join(root, "cli.mjs"); + const link = join(root, "linked-cli.mjs"); + await writeFile(real, ""); + await symlink(real, link); + + // `node --preserve-symlinks-main` leaves import.meta.url on the symlink, so + // canonicalising only the entry path would make the two disagree. + const moduleUrl = pathToFileURL(link).href; + + expect(isDirectInvocation(moduleUrl, link)).toBe(true); + }); + + it("does not treat an unrelated entry path as a direct invocation", async () => { + const root = await mkdtemp(join(tmpdir(), "vref-entry-")); + const real = join(root, "cli.mjs"); + const other = join(root, "other.mjs"); + await writeFile(real, ""); + await writeFile(other, ""); + + const moduleUrl = pathToFileURL(await realpath(real)).href; + + expect(isDirectInvocation(moduleUrl, other)).toBe(false); + expect(isDirectInvocation(moduleUrl, undefined)).toBe(false); + }); + + it("does not throw when the entry path does not exist", () => { + expect(isDirectInvocation("file:///nowhere/cli.mjs", "/nonexistent/cli.mjs")).toBe(false); + }); +});