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
36 changes: 35 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -369,6 +370,39 @@ function promiseBoundary<A>(run: () => Promise<A>): Effect.Effect<A, VrefError>
});
}

/**
* 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/<pkg>` 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) => {
Expand All @@ -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());
}

Expand Down
54 changes: 52 additions & 2 deletions test/vref.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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/<pkg>, 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);
});
});