authorgravatar for cjimenez@r21digital.comCarlos Jimenez <cjimenez@r21digital.com> 2026-08-24 03:46:50-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 03:46:50-07:00
log3fd50643311d0b0e702628f906f7cb6bbe84e1ba
tree260280c58644e3c01ed0623c0cefa531bd60e6bd
parent04df98db4c90790fb5171426894baa3bca57a935
signature Signed by PGP key B5690EEEBB952194

fix(server): run the CLI on Node versions without import.meta.main (#7141)


4 files changed, 143 insertions(+), 2 deletions(-)

apps/server/src/bin.ts+8-1
......@@ -12,6 +12,7 @@ import { connectCommand } from "./cli/connect.ts";
1212import { pairCommand } from "./cli/pair.ts";
1313import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
1414import { sharedServerCommandFlags } from "./cli/config.ts";
15import { isEntrypoint } from "./entrypoint.ts";
1516import { projectCommand } from "./cli/project.ts";
1617import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1718import { serviceCommand } from "./cli/service.ts";
......@@ -63,7 +64,13 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
6364
6465export const cli = makeCli();
6566
66if (import.meta.main) {
67if (
68 isEntrypoint({
69 moduleUrl: import.meta.url,
70 entryPath: process.argv[1],
71 runtimeMain: import.meta.main,
72 })
73) {
6774 Command.run(cli, { version: packageJson.version }).pipe(
6875 Effect.scoped,
6976 Effect.provide(CliRuntimeLayer),
apps/server/src/entrypoint.test.ts created+89
......@@ -0,0 +1,89 @@
1// @effect-diagnostics nodeBuiltinImport:off - entrypoint detection is a Node filesystem boundary.
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6
7import { describe, expect, it } from "vite-plus/test";
8
9import { isEntrypoint } from "./entrypoint.ts";
10
11const makeTempDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-entrypoint-test-"));
12
13describe("isEntrypoint", () => {
14 it("uses the runtime answer when Node provides one", () => {
15 // Node 22.18+ and 24.2+ populate `import.meta.main`; nothing else is consulted.
16 expect(
17 isEntrypoint({
18 moduleUrl: "file:///somewhere/bin.mjs",
19 entryPath: "/elsewhere/other.mjs",
20 runtimeMain: true,
21 }),
22 ).toBe(true);
23 expect(
24 isEntrypoint({
25 moduleUrl: "file:///somewhere/bin.mjs",
26 entryPath: "/somewhere/bin.mjs",
27 runtimeMain: false,
28 }),
29 ).toBe(false);
30 });
31
32 it("matches the entrypoint path when the runtime has no import.meta.main", () => {
33 // Node 22.16, 22.17 and 23.11 are inside `engines.node` but leave it undefined.
34 const dir = makeTempDir();
35 const entry = NodePath.join(dir, "bin.mjs");
36 NodeFS.writeFileSync(entry, "");
37
38 expect(
39 isEntrypoint({
40 moduleUrl: NodeURL.pathToFileURL(entry).href,
41 entryPath: entry,
42 runtimeMain: undefined,
43 }),
44 ).toBe(true);
45 });
46
47 it("matches through a symlinked entrypoint, as npm and npx install it", () => {
48 const dir = makeTempDir();
49 const real = NodePath.join(dir, "bin.mjs");
50 const link = NodePath.join(dir, "t3");
51 NodeFS.writeFileSync(real, "");
52 NodeFS.symlinkSync(real, link);
53
54 expect(
55 isEntrypoint({
56 moduleUrl: NodeURL.pathToFileURL(real).href,
57 entryPath: link,
58 runtimeMain: undefined,
59 }),
60 ).toBe(true);
61 });
62
63 it("stays false for an imported module that is not the entrypoint", () => {
64 // This is what keeps `bin.test.ts` from launching the CLI on import.
65 const dir = makeTempDir();
66 const entry = NodePath.join(dir, "bin.mjs");
67 const imported = NodePath.join(dir, "cli.mjs");
68 NodeFS.writeFileSync(entry, "");
69 NodeFS.writeFileSync(imported, "");
70
71 expect(
72 isEntrypoint({
73 moduleUrl: NodeURL.pathToFileURL(imported).href,
74 entryPath: entry,
75 runtimeMain: undefined,
76 }),
77 ).toBe(false);
78 });
79
80 it("stays false when there is no entrypoint argument", () => {
81 expect(
82 isEntrypoint({
83 moduleUrl: "file:///somewhere/bin.mjs",
84 entryPath: undefined,
85 runtimeMain: undefined,
86 }),
87 ).toBe(false);
88 });
89});
apps/server/src/entrypoint.ts created+38
......@@ -0,0 +1,38 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// Entrypoint detection runs before any Effect runtime is built, so it stays on
3// Node built-ins.
4import * as NodeFS from "node:fs";
5import * as NodeURL from "node:url";
6
7/**
8 * Whether the module identified by `moduleUrl` is the process entrypoint.
9 *
10 * `import.meta.main` answers this directly, but it only exists on Node 22.18+
11 * and 24.2+. This package's `engines.node` range also accepts 22.16, 22.17 and
12 * 23.11, where it is `undefined`: an `if (import.meta.main)` guard never runs,
13 * so the process loads every module and exits 0 without output. Fall back to
14 * comparing the entrypoint path on those versions.
15 */
16export const isEntrypoint = (input: {
17 readonly moduleUrl: string;
18 readonly entryPath: string | undefined;
19 readonly runtimeMain: boolean | undefined;
20}): boolean => {
21 if (input.runtimeMain !== undefined) {
22 return input.runtimeMain;
23 }
24 if (input.entryPath === undefined || input.entryPath === "") {
25 return false;
26 }
27 if (input.moduleUrl === NodeURL.pathToFileURL(input.entryPath).href) {
28 return true;
29 }
30 // npm and npx install the CLI as a symlink. Without `--preserve-symlinks` the
31 // module URL is the resolved real path while `process.argv[1]` keeps the link
32 // path, so the comparison above misses.
33 try {
34 return input.moduleUrl === NodeURL.pathToFileURL(NodeFS.realpathSync(input.entryPath)).href;
35 } catch {
36 return false;
37 }
38};
apps/server/src/serviceLauncher.ts+8-1
......@@ -27,6 +27,7 @@ import {
2727 SERVICE_STATE_FILE,
2828 SERVICE_STOP_MARKER_FILE,
2929} from "./cloud/serviceProtocol.ts";
30import { isEntrypoint } from "./entrypoint.ts";
3031
3132const HANDOFF_DELAY_MS = 2_000;
3233const PREPARED_TIMEOUT_MS = 120_000;
......@@ -611,7 +612,13 @@ async function main(): Promise<void> {
611612 await new Launcher(baseDir, state).run();
612613}
613614
614if (import.meta.main) {
615if (
616 isEntrypoint({
617 moduleUrl: import.meta.url,
618 entryPath: process.argv[1],
619 runtimeMain: import.meta.main,
620 })
621) {
615622 main().catch((cause: unknown) => {
616623 const error = cause instanceof Error ? cause : new Error(String(cause));
617624 process.stderr.write(`[service-launcher] ${error.message}\n`);