From 56434c577ff88b44ca7b08fdbff6e5813b36a85a Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 28 Jul 2026 12:19:31 +0300 Subject: [PATCH 1/3] fix(cli): run when reached through a symlinked entry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI guarded its entry point by comparing `import.meta.url` against `pathToFileURL(process.argv[1])`. Node canonicalises the former through symlinks but leaves the latter exactly as the caller wrote it, so the comparison fails whenever the CLI is reached through a symlink — the norm under pnpm, which links `node_modules/` into `node_modules/.pnpm/…`. Whether it worked depended on which path the generated bin shim happened to use. `putio-roku` got a shim pointing at the real `.pnpm` path and works; a fresh `pnpm add -D @putdotio/vref` in `putio-ios` got one pointing at the symlink, where `vref describe`, `vref build`, and `vref validate` all exited 0 having done nothing. Same pnpm, same Node, same package checksum. Silent success is the dangerous part: a CI step running `vref build --check` would pass while validating nothing at all. Canonicalise both sides before comparing, via an exported `isDirectInvocation` so the behaviour is testable without spawning a process. A missing `argv[1]` falls through to the plain comparison rather than throwing during startup. Closes #21 --- src/cli.ts | 30 +++++++++++++++++++++++++++++- test/vref.test.ts | 40 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 974d303..9ecc461 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,33 @@ 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; + } + + let resolvedEntry = entryPath; + try { + resolvedEntry = realpathSync(entryPath); + } catch { + // A non-existent argv[1] cannot be this module; fall through to the plain + // comparison rather than throwing during startup. + } + + return moduleUrl === pathToFileURL(resolvedEntry).href; +} + 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 +412,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..8efbdcb 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,38 @@ 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("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); + }); +}); From 3379ea21786a2aada4722b19e6aee305b406f70e Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 28 Jul 2026 12:25:01 +0300 Subject: [PATCH 2/3] fix(cli): keep detecting direct invocation under --preserve-symlinks-main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalising the entry path unconditionally broke a supported mode: with `node --preserve-symlinks-main`, Node deliberately keeps `import.meta.url` on the symlink, so resolving only `argv[1]` made the two disagree and skipped `main` silently — the same class of failure this change set out to remove. Compare the raw paths first, then fall back to the canonical comparison, so both the pnpm symlinked-shim case and the preserve-symlinks case run. Refs #21 --- src/cli.ts | 16 ++++++++++------ test/vref.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9ecc461..a752343 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -386,15 +386,19 @@ export function isDirectInvocation(moduleUrl: string, entryPath: string | undefi return false; } - let resolvedEntry = entryPath; + // 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 { - resolvedEntry = realpathSync(entryPath); + return moduleUrl === pathToFileURL(realpathSync(entryPath)).href; } catch { - // A non-existent argv[1] cannot be this module; fall through to the plain - // comparison rather than throwing during startup. + // A non-existent argv[1] cannot be this module. + return false; } - - return moduleUrl === pathToFileURL(resolvedEntry).href; } export function main(argv: string[], cwd: string): void { diff --git a/test/vref.test.ts b/test/vref.test.ts index 8efbdcb..6fb2216 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -579,6 +579,20 @@ describe("cli entry detection", () => { 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"); From 2e4b2e68968b65cadf2e85abd4a0ee355c5801b8 Mon Sep 17 00:00:00 2001 From: Altay Date: Tue, 28 Jul 2026 12:27:56 +0300 Subject: [PATCH 3/3] docs(cli): describe the realpath failure branch accurately The comment named only a non-existent argv[1], but the catch also covers unreadable paths and symlink loops. Returning false in every case is deliberate: an entry path that cannot be resolved is not proven to be this module, so fail closed instead of throwing during startup. Addresses a review comment on #22. --- src/cli.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index a752343..31c6999 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -396,7 +396,9 @@ export function isDirectInvocation(moduleUrl: string, entryPath: string | undefi try { return moduleUrl === pathToFileURL(realpathSync(entryPath)).href; } catch { - // A non-existent argv[1] cannot be this module. + // 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; } }