1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7
8import {
9 formatCliCommand,
10 resolveRootCliCommand,
11 resolveServerInstallation,
12} from "./invocation.ts";
13
14it("formats package runner commands from their cache entry paths", () => {
15 for (const [entryPath, expected] of [
16 ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"],
17 [
18 "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs",
19 "npx t3 serve",
20 ],
21 ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"],
22 [
23 "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs",
24 "pnpm dlx t3 serve",
25 ],
26 [
27 "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs",
28 "pnpm dlx t3 serve",
29 ],
30 ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"],
31 ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"],
32 [
33 "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs",
34 "bunx t3 serve",
35 ],
36 ] as const) {
37 assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected);
38 }
39});
40
41it("treats stable installs as direct invocations", () => {
42 for (const entryPath of [
43 "/usr/local/lib/node_modules/t3/dist/bin.mjs",
44 "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs",
45 "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs",
46 "",
47 ]) {
48 assert.equal(
49 formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }),
50 "t3 serve",
51 );
52 }
53});
54
55it("re-suggests the prerelease channel only for prerelease builds", () => {
56 for (const [version, expected] of [
57 ["0.0.31-nightly.20260729", "npx t3@nightly serve"],
58 ["0.0.31-preview.20260729.1", "npx t3@preview serve"],
59 ["0.0.31-foo-preview.20260729.1", "npx t3 serve"],
60 ["0.0.31", "npx t3 serve"],
61 ] as const) {
62 assert.equal(
63 formatCliCommand({
64 subcommand: "serve",
65 entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs",
66 version,
67 }),
68 expected,
69 );
70 }
71});
72
73it("formats serve suggestions to match the launching command", () => {
74 assert.equal(
75 formatCliCommand({
76 subcommand: "serve",
77 entryPath: "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs",
78 version: "0.0.31-nightly.20260729",
79 }),
80 "npx t3@nightly serve",
81 );
82 assert.equal(
83 formatCliCommand({
84 subcommand: "serve",
85 entryPath: "/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs",
86 version: "0.0.31",
87 }),
88 "bunx t3 serve",
89 );
90 assert.equal(
91 formatCliCommand({
92 subcommand: "serve",
93 entryPath: "/usr/local/lib/node_modules/t3/dist/bin.mjs",
94 version: "0.0.31-nightly.20260729",
95 }),
96 "t3 serve",
97 );
98});
99
100it.layer(NodeServices.layer)("root CLI commands", (it) => {
101 /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */
102 const rootCommand = (input: {
103 readonly node: string;
104 readonly entry: string;
105 readonly path?: string;
106 readonly env?: Record<string, string>;
107 readonly executable?: boolean;
108 }) =>
109 resolveRootCliCommand("browser setup").pipe(
110 Effect.provideService(HostProcess.ExecutablePath, input.node),
111 Effect.provideService(HostProcess.Arguments, [input.node, input.entry]),
112 Effect.provideService(HostProcess.IsExecutable, input.executable ?? false),
113 Effect.provideService(HostProcess.Platform, "linux"),
114 Effect.provideService(HostProcess.Environment, { PATH: input.path ?? "", ...input.env }),
115 );
116
117 /** A directory holding an executable `t3`, to stand in for one on PATH. */
118 const pathWithT3 = Effect.gen(function* () {
119 const fs = yield* FileSystem.FileSystem;
120 const path = yield* Path.Path;
121 const bin = yield* fs.makeTempDirectoryScoped();
122 yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 });
123 return bin;
124 });
125
126 it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>
127 Effect.gen(function* () {
128 const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs";
129 // sudo's secure_path already has a system Node.
130 expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe(
131 "sudo npx t3 browser setup",
132 );
133 // nvm, fnm, and tarball installs are dropped by sudo's PATH reset.
134 expect(
135 yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }),
136 ).toBe('sudo env "PATH=$PATH" npx t3 browser setup');
137 expect(
138 yield* rootCommand({
139 node: "/home/theo/.local/node/bin/node",
140 entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
141 path: yield* pathWithT3,
142 }),
143 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
144 }).pipe(Effect.scoped),
145 );
146
147 it.effect("names this install's launcher when t3 is not on PATH", () =>
148 Effect.gen(function* () {
149 const fs = yield* FileSystem.FileSystem;
150 const path = yield* Path.Path;
151 const home = yield* fs.makeTempDirectoryScoped();
152 const shim = path.join(home, ".local bin", "t3");
153 yield* fs.makeDirectory(path.dirname(shim), { recursive: true });
154 yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 });
155 const desktop = {
156 node: "/tmp/.mount_T3abc/t3code",
157 entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs",
158 env: { T3CODE_CLI_PATH: shim },
159 };
160 // The desktop app's shim, quoted for the shell and run as root as is.
161 expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`);
162 // A `t3` the person put on PATH still wins.
163 expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe(
164 'sudo env "PATH=$PATH" t3 browser setup',
165 );
166 // A standalone binary names itself.
167 expect(
168 yield* rootCommand({
169 node: "/opt/t3/t3",
170 entry: "/opt/t3/t3",
171 executable: true,
172 }),
173 ).toBe("sudo /opt/t3/t3 browser setup");
174 // A stale shim path, then no launcher at all, fall back to plain `t3`.
175 expect(
176 yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }),
177 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
178 }).pipe(Effect.scoped),
179 );
180});
181
182it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
183 it.effect("recognizes runner caches for both script and executable packages", () =>
184 Effect.gen(function* () {
185 const fs = yield* FileSystem.FileSystem;
186 const path = yield* Path.Path;
187 const root = yield* fs.makeTempDirectoryScoped();
188 for (const [relative, kind] of [
189 ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"],
190 ["npm/_npx/hash/node_modules/@t3code/t3-linux-x64/t3", "npx"],
191 ["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"],
192 [".bun/install/cache/t3/dist/bin.mjs", "bunx"],
193 ] as const) {
194 const entry = path.join(root, relative);
195 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
196 yield* fs.writeFileString(entry, "");
197 const installation = yield* resolveServerInstallation.pipe(
198 Effect.provideService(HostProcess.Arguments, ["node", entry]),
199 Effect.provideService(HostProcess.ExecutablePath, entry),
200 Effect.provideService(HostProcess.IsExecutable, entry.endsWith("/t3")),
201 );
202 expect(installation).toEqual({ kind });
203 }
204 }),
205 );
206
207 it.effect("requires the npm prefix's bin to point to the running package", () =>
208 Effect.gen(function* () {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const root = yield* fs.makeTempDirectoryScoped();
212 const prefix = path.join(root, "bunx-tools");
213 const packageRoot = path.join(prefix, "lib/node_modules/t3");
214 const entry = path.join(packageRoot, "dist/bin.mjs");
215 const globalBin = path.join(prefix, "bin/t3");
216 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
217 yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true });
218 yield* fs.writeFileString(entry, "");
219 yield* fs.writeFileString(
220 path.join(packageRoot, "package.json"),
221 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
222 );
223 const resolve = resolveServerInstallation.pipe(
224 Effect.provideService(HostProcess.Arguments, ["node", entry]),
225 Effect.provideService(HostProcess.IsExecutable, false),
226 Effect.provideService(HostProcess.Platform, "linux"),
227 );
228 expect(yield* resolve).toBeNull();
229 yield* fs.symlink(entry, globalBin);
230 expect(yield* resolve).toEqual({ kind: "npm-global", prefix });
231 yield* fs.remove(globalBin);
232 yield* fs.writeFileString(globalBin, "an unrelated t3 command");
233 expect(yield* resolve).toBeNull();
234 expect(yield* resolve.pipe(Effect.provideService(HostProcess.Platform, "win32"))).toBeNull();
235 }),
236 );
237
238 it.effect("proves the native executable belongs to the npm launcher", () =>
239 Effect.gen(function* () {
240 const fs = yield* FileSystem.FileSystem;
241 const path = yield* Path.Path;
242 const root = yield* fs.makeTempDirectoryScoped();
243 const prefix = path.join(root, "bunx-tools");
244 const packageRoot = path.join(prefix, "lib/node_modules/t3");
245 const launcher = path.join(packageRoot, "bin/t3.js");
246 const entry = path.join(packageRoot, "node_modules/@t3code/t3-linux-x64/t3");
247 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
248 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
249 yield* fs.makeDirectory(path.join(prefix, "bin"));
250 yield* fs.writeFileString(launcher, "");
251 yield* fs.writeFileString(entry, "");
252 yield* fs.writeFileString(
253 path.join(packageRoot, "package.json"),
254 '{"name":"t3","version":"0.0.45","bin":{"t3":"./bin/t3.js"},"optionalDependencies":{"@t3code/t3-linux-x64":"0.0.45"}}',
255 );
256 yield* fs.symlink(launcher, path.join(prefix, "bin/t3"));
257 const resolve = resolveServerInstallation.pipe(
258 Effect.provideService(HostProcess.ExecutablePath, entry),
259 Effect.provideService(HostProcess.IsExecutable, true),
260 Effect.provideService(HostProcess.Platform, "linux"),
261 );
262 for (const [version, expected] of [
263 ["0.0.44", null],
264 ["0.0.45", { kind: "npm-global", prefix }],
265 ]) {
266 yield* fs.writeFileString(
267 path.join(path.dirname(entry), "package.json"),
268 `{"name":"@t3code/t3-linux-x64","version":"${version}"}`,
269 );
270 expect(yield* resolve).toEqual(expected);
271 }
272 }),
273 );
274
275 it.effect("leaves local, standalone, missing and unreadable installs unknown", () =>
276 Effect.gen(function* () {
277 const fs = yield* FileSystem.FileSystem;
278 const path = yield* Path.Path;
279 const root = yield* fs.makeTempDirectoryScoped();
280 for (const relative of [
281 "project/node_modules/t3/dist/bin.mjs",
282 "project/apps/server/dist/bin.mjs",
283 ".t3/runtime/0.0.45/t3",
284 "missing/dist/bin.mjs",
285 ]) {
286 const entry = path.join(root, relative);
287 if (!relative.startsWith("missing")) {
288 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
289 yield* fs.writeFileString(entry, "");
290 }
291 expect(
292 yield* resolveServerInstallation.pipe(
293 Effect.provideService(HostProcess.Arguments, ["node", entry]),
294 Effect.provideService(HostProcess.IsExecutable, false),
295 ),
296 ).toBeNull();
297 }
298 }),
299 );
300});