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);
+ });
+});