1// @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer against a local GitHub fixture.
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeCrypto from "node:crypto";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeHttp from "node:http";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import { describe, expect, it } from "vite-plus/test";
10
11const nightlies = Array.from({ length: 100 }, (_, index) => ({
12 tag_name: `v1.2.4-nightly.20261020.${200 - index}`,
13}));
14
15describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer home", () => {
16 it("installs a relative home from a directory ending in a newline", async () => {
17 const temporaryRoot = await NodeFSP.realpath(
18 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-home-")),
19 );
20 const root = NodePath.join(temporaryRoot, "cwd\n");
21 const homeName = "-custom home\\home\n";
22 const home = NodePath.join(root, homeName);
23 const version = "1.2.3";
24 const target = NodePath.join(home, "runtime/versions", version);
25 const launcher = NodePath.join(root, "bin/t3");
26 try {
27 await NodeFSP.mkdir(target, { recursive: true });
28 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`);
29 await NodeFSP.writeFile(NodePath.join(target, "t3"), "#!/bin/sh\necho 't3 v1.2.3'\n", {
30 mode: 0o755,
31 });
32 const { status, stdout, stderr } = NodeChildProcess.spawnSync(
33 "sh",
34 [NodePath.resolve(import.meta.dirname, "install.sh")],
35 {
36 cwd: root,
37 env: {
38 ...process.env,
39 HOME: root,
40 T3CODE_HOME: homeName,
41 T3CODE_VERSION: version,
42 T3CODE_INSTALL_BIN_DIR: NodePath.dirname(launcher),
43 T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:1",
44 },
45 encoding: "utf8",
46 },
47 );
48 expect(status, stdout + stderr).toBe(0);
49 expect(stderr).toContain(`Installed T3 Code ${version}`);
50 expect(await NodeFSP.readlink(launcher)).toBe(NodePath.join(target, "t3"));
51 expect(
52 NodeChildProcess.execFileSync(launcher, ["--version"], {
53 cwd: NodeOS.tmpdir(),
54 encoding: "utf8",
55 }).trim(),
56 ).toBe("t3 v1.2.3");
57 } finally {
58 await NodeFSP.rm(temporaryRoot, { recursive: true, force: true });
59 }
60 });
61});
62
63// regression: #22490
64describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer channel lookup", () => {
65 it.each([
66 // regression: #32897
67 ["stable", "1.2.3", 1],
68 ["preview", "1.2.4-preview.20261019.50", 0],
69 ])("finds %s %s behind nightlies and %i HTTP 504s", async (channel, version, failures) => {
70 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-index-"));
71 const target = NodePath.join(root, "home/runtime/versions", version);
72 const server = NodeHttp.createServer((request, response) => {
73 if (failures-- > 0) return response.writeHead(504).end();
74 const url = new URL(request.url ?? "/", "http://fixture");
75 const body =
76 url.pathname === "/pingdotgg/t3code/releases/latest" &&
77 request.headers.accept === "application/json"
78 ? { tag_name: "v1.2.3" }
79 : url.pathname === "/repos/pingdotgg/t3code/releases"
80 ? { "1": nightlies, "2": [{ tag_name: `v${version}` }, { tag_name: "v1.2.3" }] }[
81 url.searchParams.get("page") ?? ""
82 ]
83 : undefined;
84 if (body === undefined) response.statusCode = 404;
85 // The API pretty-prints and the installer reads it line by line.
86 response.end(JSON.stringify(body ?? [], null, 2));
87 });
88 try {
89 // An existing completed install isolates release resolution from archive downloads.
90 await NodeFSP.mkdir(target, { recursive: true });
91 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`);
92 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
93 const address = server.address();
94 if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
95 const origin = `http://127.0.0.1:${address.port}`;
96 const source = await NodeFSP.readFile(
97 NodePath.resolve(import.meta.dirname, "install.sh"),
98 "utf8",
99 );
100 const installer = NodePath.join(root, "install.sh");
101 await NodeFSP.writeFile(installer, source.replace(/https:\/\/(api\.)?github\.com/g, origin));
102 const { code, output } = await new Promise<{ code: number | null; output: string }>(
103 (resolve, reject) => {
104 const child = NodeChildProcess.spawn("sh", [installer], {
105 env: {
106 ...process.env,
107 T3CODE_VERSION: "",
108 T3CODE_CHANNEL: channel,
109 T3CODE_HOME: NodePath.join(root, "home"),
110 T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"),
111 // A wrong version must fail here instead of downloading a real release.
112 T3CODE_RELEASE_BASE_URL: origin,
113 },
114 stdio: ["ignore", "pipe", "pipe"],
115 });
116 let output = "";
117 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
118 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
119 child.on("error", reject);
120 child.on("close", (code) => resolve({ code, output }));
121 },
122 );
123 expect(code, output).toBe(0);
124 expect(output).toContain(`Installed T3 Code ${version}`);
125 } finally {
126 server.closeAllConnections();
127 await new Promise<void>((resolve) => server.close(() => resolve()));
128 await NodeFSP.rm(root, { recursive: true, force: true });
129 }
130 });
131});
132
133/** Runs an installer for a version that is already unpacked, so only the t3 command step runs. */
134async function installOver(input: {
135 readonly script: "install.sh" | "install.ps1";
136 readonly home: string;
137 readonly binDir: string;
138 readonly env?: NodeJS.ProcessEnv;
139 readonly cwd?: string;
140}) {
141 const target = NodePath.join(input.home, "runtime", "versions", "1.2.3");
142 await NodeFSP.mkdir(target, { recursive: true });
143 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), "1.2.3\n");
144 const installer = NodePath.resolve(import.meta.dirname, input.script);
145 const [command, args] =
146 input.script === "install.sh"
147 ? ["sh", [installer]]
148 : ["powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installer]];
149 const result = NodeChildProcess.spawnSync(command, args, {
150 encoding: "utf8",
151 ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
152 env: {
153 ...process.env,
154 T3CODE_CHANNEL: "",
155 T3CODE_VERSION: "1.2.3",
156 T3CODE_HOME: input.home,
157 T3CODE_INSTALL_BIN_DIR: input.binDir,
158 T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:9",
159 ...input.env,
160 },
161 });
162 return { code: result.status, output: `${result.stdout}${result.stderr}`, target };
163}
164
165describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 command", () => {
166 it("replaces only this home's own links and prints a PATH command", async () => {
167 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-link-"));
168 try {
169 const home = NodePath.join(root, "home");
170 const binDir = NodePath.join(root, "bin");
171 const link = NodePath.join(binDir, "t3");
172 await NodeFSP.mkdir(binDir, { recursive: true });
173
174 await NodeFSP.writeFile(link, "#!/bin/sh\necho mine\n");
175 const foreign = await installOver({ script: "install.sh", home, binDir });
176 expect(foreign.code).not.toBe(0);
177 expect(foreign.output).toContain(`another t3 is already at ${link}`);
178 expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n");
179 // Not even from inside the versions folder, where `.` would resolve into it.
180 const fromVersions = await installOver({
181 script: "install.sh",
182 home,
183 binDir,
184 cwd: foreign.target,
185 });
186 expect(fromVersions.code).not.toBe(0);
187 expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n");
188
189 // A link that only looks like it points into versions (join() would normalize the ..).
190 await NodeFSP.rm(link);
191 await NodeFSP.mkdir(NodePath.join(home, "other"), { recursive: true });
192 await NodeFSP.writeFile(NodePath.join(home, "other/t3"), "#!/bin/sh\n");
193 await NodeFSP.symlink(`${home}/runtime/versions/../../other/t3`, link);
194 const climbing = await installOver({ script: "install.sh", home, binDir });
195 expect(climbing.code).not.toBe(0);
196 expect(await NodeFSP.readlink(link)).toBe(`${home}/runtime/versions/../../other/t3`);
197
198 // Only `<version>/t3` counts, not another program inside a version.
199 await NodeFSP.rm(link);
200 await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/helper"), link);
201 const sibling = await installOver({ script: "install.sh", home, binDir });
202 expect(sibling.code).not.toBe(0);
203 expect(await NodeFSP.readlink(link)).toBe(
204 NodePath.join(home, "runtime/versions/1.0.0/helper"),
205 );
206
207 // An upgrade over an older version's link, written through a symlinked T3 home.
208 await NodeFSP.rm(link);
209 await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link);
210 await NodeFSP.symlink(home, NodePath.join(root, "alias"));
211 const upgrade = await installOver({
212 script: "install.sh",
213 home: NodePath.join(root, "alias"),
214 binDir,
215 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
216 });
217 expect(upgrade.code, upgrade.output).toBe(0);
218 expect(await NodeFSP.readlink(link)).toBe(
219 NodePath.join(root, "alias/runtime/versions/1.2.3/t3"),
220 );
221 expect(upgrade.output).toContain(
222 `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`,
223 );
224
225 // Containers and CI often have no SHELL; `set -u` must not stop the installer there.
226 const noShell = await installOver({
227 script: "install.sh",
228 home,
229 binDir,
230 env: { HOME: root, SHELL: undefined, PATH: "/usr/bin:/bin" },
231 });
232 expect(noShell.code, noShell.output).toBe(0);
233 expect(noShell.output).toContain(`Add ${binDir} to your PATH`);
234
235 // Its folder on PATH with a trailing slash is still this t3, not another one.
236 const slashed = await installOver({
237 script: "install.sh",
238 home,
239 binDir,
240 env: { HOME: root, PATH: `${binDir}/:/usr/bin:/bin` },
241 });
242 expect(slashed.code, slashed.output).toBe(0);
243 expect(slashed.output).toContain("Run t3 to use T3 Code here");
244
245 // A relative folder is made absolute, so the PATH command never follows the working directory.
246 const relative = await installOver({
247 script: "install.sh",
248 home,
249 binDir: "bin",
250 cwd: root,
251 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
252 });
253 expect(relative.code, relative.output).toBe(0);
254 expect(relative.output).toContain(`'export PATH="$PATH:$HOME/bin"'`);
255
256 await NodeFSP.rm(link);
257 await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link);
258 const overDesktop = await installOver({
259 script: "install.sh",
260 home: `${home}//`,
261 binDir: `${binDir}/`,
262 });
263 expect(overDesktop.code, overDesktop.output).toBe(0);
264 expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/1.2.3/t3"));
265 } finally {
266 await NodeFSP.rm(root, { recursive: true, force: true });
267 }
268 });
269});
270
271describe.skipIf(HostProcess.Platform.defaultValue() !== "win32")("install.ps1 t3 command", () => {
272 it("replaces only its own t3.cmd, also in non-ASCII and bracketed paths", async () => {
273 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-ps1-"));
274 try {
275 // Windows PowerShell 5.1 reads files as ANSI by default and expands wildcards in paths.
276 const home = NodePath.join(root, "José", ".t3");
277 const binDir = NodePath.join(root, "bin[t3]");
278 const shim = NodePath.join(binDir, "t3.cmd");
279 await NodeFSP.mkdir(binDir, { recursive: true });
280
281 const desktopShim = '@echo off\r\n"C:\\Program Files\\T3 Code\\T3 Code.exe" "x" %*\r\n';
282 await NodeFSP.writeFile(shim, desktopShim);
283 const foreign = await installOver({ script: "install.ps1", home, binDir });
284 expect(foreign.code).not.toBe(0);
285 expect(foreign.output).toContain("another t3 is already at");
286 expect(await NodeFSP.readFile(shim, "utf8")).toBe(desktopShim);
287
288 // A hand-edited wrapper may set its own environment; replacing it would drop that.
289 const edited = `@echo off\r\nset "T3CODE_HOME=D:\\other"\r\n"${NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe")}" %*`;
290 await NodeFSP.writeFile(shim, edited, "utf8");
291 const wrapper = await installOver({ script: "install.ps1", home, binDir });
292 expect(wrapper.code).not.toBe(0);
293 expect(await NodeFSP.readFile(shim, "utf8")).toBe(edited);
294
295 const climbing = `@echo off\r\n"${NodePath.join(home, "runtime", "versions")}\\..\\other\\t3.exe" %*`;
296 await NodeFSP.writeFile(shim, climbing, "utf8");
297 const outside = await installOver({ script: "install.ps1", home, binDir });
298 expect(outside.code).not.toBe(0);
299 expect(await NodeFSP.readFile(shim, "utf8")).toBe(climbing);
300
301 const older = NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe");
302 await NodeFSP.writeFile(shim, `@echo off\r\n"${older}" %*`, "utf8");
303 const upgrade = await installOver({ script: "install.ps1", home, binDir });
304 expect(upgrade.code, upgrade.output).toBe(0);
305 expect(await NodeFSP.readFile(shim, "utf8")).toContain(
306 NodePath.join(upgrade.target, "t3.exe"),
307 );
308
309 const again = await installOver({ script: "install.ps1", home, binDir });
310 expect(again.code, again.output).toBe(0);
311
312 // Another t3 first on PATH still runs, so the installer names this one in full.
313 const otherBin = NodePath.join(root, "other");
314 await NodeFSP.mkdir(otherBin);
315 await NodeFSP.writeFile(NodePath.join(otherBin, "t3.cmd"), "@echo off\r\n");
316 // Windows spells it Path; a second PATH key would be ambiguous.
317 const pathKey =
318 Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
319 const shadowed = await installOver({
320 script: "install.ps1",
321 home,
322 binDir,
323 env: { [pathKey]: `${otherBin};${process.env[pathKey] ?? ""}` },
324 });
325 expect(shadowed.code, shadowed.output).toBe(0);
326 expect(shadowed.output).toContain(
327 `Another t3 at ${NodePath.join(otherBin, "t3.cmd")} runs first`,
328 );
329
330 // Its own folder on PATH with a trailing separator is already on PATH.
331 const slashed = await installOver({
332 script: "install.ps1",
333 home,
334 binDir,
335 env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` },
336 });
337 expect(slashed.code, slashed.output).toBe(0);
338 expect(slashed.output).toContain("to use T3 Code here");
339 // The same when the install folder itself is given with a trailing separator.
340 const bothSlashed = await installOver({
341 script: "install.ps1",
342 home,
343 binDir: `${binDir}\\`,
344 env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` },
345 });
346 expect(bothSlashed.code, bothSlashed.output).toBe(0);
347 expect(bothSlashed.output).toContain("to use T3 Code here");
348 } finally {
349 await NodeFSP.rm(root, { recursive: true, force: true });
350 }
351 });
352});
353
354describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer checksum", () => {
355 it.each(
356 ["home\\data", "home\ndata"].flatMap((homeName) =>
357 [true, false].map((validChecksum) => ({ homeName, validChecksum })),
358 ),
359 )(
360 "verifies home $homeName with valid checksum $validChecksum",
361 async ({ homeName, validChecksum }) => {
362 const availableTools = ["sha256sum", "shasum"].filter(
363 (tool) =>
364 NodeChildProcess.spawnSync("sh", ["-c", 'command -v "$1"', "sh", tool]).status === 0,
365 );
366 expect(availableTools.length).toBeGreaterThan(0);
367 for (const checksumTool of availableTools) {
368 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-checksum-"));
369 const home = NodePath.join(root, homeName);
370 const version = "1.2.3";
371 const stem = `t3-${version}-linux-${HostProcess.Architecture.defaultValue()}`;
372 const archiveName = `${stem}.tar.gz`;
373 const server = NodeHttp.createServer();
374 try {
375 const tools = NodePath.join(root, "tools");
376 await NodeFSP.mkdir(tools);
377 for (const command of [
378 "sh",
379 "uname",
380 "tar",
381 "gzip",
382 "cut",
383 "curl",
384 "grep",
385 "mkdir",
386 "mktemp",
387 "rm",
388 "mv",
389 "ln",
390 checksumTool,
391 ]) {
392 const executable = NodeChildProcess.execFileSync(
393 "sh",
394 ["-c", 'command -v "$1"', "sh", command],
395 { encoding: "utf8" },
396 ).trim();
397 await NodeFSP.symlink(executable, NodePath.join(tools, command));
398 }
399 await NodeFSP.mkdir(NodePath.join(root, stem));
400 await NodeFSP.writeFile(
401 NodePath.join(root, stem, "t3"),
402 "#!/bin/sh\necho 't3 v1.2.3'\n",
403 { mode: 0o755 },
404 );
405 const archivePath = NodePath.join(root, archiveName);
406 NodeChildProcess.execFileSync("tar", ["-czf", archivePath, "-C", root, stem]);
407 const archive = await NodeFSP.readFile(archivePath);
408 const digest = NodeCrypto.createHash("sha256").update(archive).digest("hex");
409 server.on("request", (request, response) => {
410 response.end(
411 request.url?.endsWith("/SHA256SUMS")
412 ? `${validChecksum ? digest : "0".repeat(64)} ${archiveName}\n`
413 : archive,
414 );
415 });
416 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
417 const address = server.address();
418 if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
419 const { code, output } = await new Promise<{ code: number | null; output: string }>(
420 (resolve, reject) => {
421 const child = NodeChildProcess.spawn(
422 "sh",
423 [NodePath.resolve(import.meta.dirname, "install.sh")],
424 {
425 env: {
426 ...process.env,
427 PATH: tools,
428 T3CODE_VERSION: version,
429 T3CODE_HOME: home,
430 T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"),
431 T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`,
432 },
433 stdio: ["ignore", "pipe", "pipe"],
434 },
435 );
436 let output = "";
437 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
438 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
439 child.on("error", reject);
440 child.on("close", (code) => resolve({ code, output }));
441 },
442 );
443 const versions = NodePath.join(home, "runtime/versions");
444 if (validChecksum) {
445 expect(code, `${checksumTool}: ${output}`).toBe(0);
446 expect(
447 await NodeFSP.readFile(NodePath.join(versions, version, ".install-complete"), "utf8"),
448 ).toBe("1.2.3\n");
449 expect(
450 NodeChildProcess.execFileSync(NodePath.join(root, "bin/t3"), ["--version"], {
451 encoding: "utf8",
452 }).trim(),
453 ).toBe("t3 v1.2.3");
454 } else {
455 expect(code).not.toBe(0);
456 expect(output).toContain("checksum mismatch");
457 expect(output).not.toContain("Installed T3 Code");
458 expect(await NodeFSP.readdir(versions)).toEqual([]);
459 }
460 } finally {
461 server.closeAllConnections();
462 await new Promise<void>((resolve) => server.close(() => resolve()));
463 await NodeFSP.rm(root, { recursive: true, force: true });
464 }
465 }
466 },
467 );
468});