1#!/usr/bin/env node
2// Share the installer's runtime cache; pre-0.0.41 services re-enter here over IPC.
3import { spawn, spawnSync } from "node:child_process";
4import crypto from "node:crypto";
5import fs from "node:fs";
6import os from "node:os";
7import path from "node:path";
8
9const { version } = JSON.parse(
10 fs.readFileSync(path.join(import.meta.dirname, "../package.json"), "utf8"),
11);
12
13const SUPPORTED = ["darwin-arm64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64"];
14
15function t3Home() {
16 const raw = (process.env.T3CODE_HOME || "").trim();
17 if (raw === "") return path.join(os.homedir(), ".t3");
18 if (raw === "~") return os.homedir();
19 if (raw.startsWith("~/") || raw.startsWith("~\\")) return path.join(os.homedir(), raw.slice(2));
20 return path.resolve(raw);
21}
22
23const isTransient = (status) => status === 408 || status === 429 || status >= 500;
24
25async function get(url) {
26 let response;
27 for (let attempt = 1; ; attempt++) {
28 try {
29 response = await fetch(url);
30 if (attempt === 4 || !isTransient(response.status)) break;
31 await response.body?.cancel();
32 } catch (error) {
33 if (attempt === 4) throw error;
34 }
35 await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
36 }
37 if (response.status === 404) {
38 throw new Error(
39 `${url} does not exist yet. If ${version} was just released, try again in a few minutes.`,
40 );
41 }
42 if (!response.ok) throw new Error(`GET ${url} returned HTTP ${response.status}`);
43 return response;
44}
45
46async function download(url, file) {
47 const response = await get(url);
48 const total = Number(response.headers.get("content-length")) || 0;
49 const hash = crypto.createHash("sha256");
50 const out = fs.openSync(file, "w");
51 const tty = process.stderr.isTTY;
52 let received = 0;
53 let shown = -1;
54 try {
55 for await (const chunk of response.body) {
56 hash.update(chunk);
57 fs.writeSync(out, chunk);
58 received += chunk.byteLength;
59 // Redraw once per percent (or per MB without a length), not per chunk.
60 const step = total ? Math.floor((received * 100) / total) : Math.floor(received / 1048576);
61 if (tty && step !== shown) {
62 shown = step;
63 const percent = total ? ` ${step}%` : "";
64 process.stderr.write(
65 `\r\x1b[2Kt3: downloading T3 Code ${version}${percent} (${(received / 1048576).toFixed(1)} MB)`,
66 );
67 }
68 }
69 } finally {
70 fs.closeSync(out);
71 if (tty) process.stderr.write("\r\x1b[2K");
72 }
73 return hash.digest("hex");
74}
75
76async function install(dir, key) {
77 const archive = `t3-${version}-${key}.${process.platform === "win32" ? "zip" : "tar.gz"}`;
78 const base = (
79 process.env.T3CODE_RELEASE_BASE_URL || "https://github.com/pingdotgg/t3code/releases/download"
80 ).replace(/\/+$/, "");
81 const sums = await (await get(`${base}/v${version}/SHA256SUMS`)).text();
82 const expected = /^([0-9a-f]{64})\s+\*?(.+)$/im;
83 const line = sums
84 .split(/\r?\n/)
85 .map((l) => expected.exec(l.trim()))
86 .find((m) => m && m[2] === archive);
87 if (!line) throw new Error(`${archive} is not listed in the release's SHA256SUMS`);
88
89 // Stage beside the destination and rename atomically to avoid half-installed versions.
90 fs.mkdirSync(path.dirname(dir), { recursive: true });
91 const staging = fs.mkdtempSync(`${dir}.staging-`);
92 try {
93 const file = path.join(staging, archive);
94 if ((await download(`${base}/v${version}/${archive}`, file)) !== line[1].toLowerCase()) {
95 throw new Error(`checksum mismatch for ${archive}`);
96 }
97 // Windows' own tar.exe reads zip; a Git-for-Windows tar on PATH cannot.
98 const tar =
99 process.platform === "win32" ? `${process.env.SystemRoot}\\System32\\tar.exe` : "tar";
100 const extract = spawnSync(tar, ["-xf", file, "-C", staging, "--strip-components=1"], {
101 stdio: "inherit",
102 });
103 if (extract.status !== 0) throw new Error(`could not extract ${archive}`);
104 fs.rmSync(file);
105 const validate = spawnSync(
106 path.join(staging, process.platform === "win32" ? "t3.exe" : "t3"),
107 ["--version"],
108 {
109 stdio: ["ignore", "ignore", "pipe"],
110 encoding: "utf8",
111 timeout: 30_000,
112 killSignal: "SIGKILL",
113 },
114 );
115 if (validate.status !== 0) {
116 throw executableError(
117 key,
118 validate.error?.message ||
119 validate.stderr?.trim() ||
120 `--version exited with ${validate.signal || validate.status}`,
121 );
122 }
123 fs.writeFileSync(path.join(staging, ".install-complete"), `${version}\n`);
124 publish(staging, dir);
125 } finally {
126 fs.rmSync(staging, { recursive: true, force: true });
127 }
128}
129
130const isComplete = (dir) => {
131 try {
132 return fs.readFileSync(path.join(dir, ".install-complete"), "utf8").trim() === version;
133 } catch {
134 return false;
135 }
136};
137
138/** Preserve concurrently published complete installs; replace only incomplete folders without the completion marker. */
139function publish(staging, dir) {
140 try {
141 fs.renameSync(staging, dir);
142 return;
143 } catch (error) {
144 if (isComplete(dir)) return;
145 if (!fs.existsSync(dir)) throw error;
146 }
147 const lock = `${dir}.lock`;
148 try {
149 fs.mkdirSync(lock);
150 } catch (error) {
151 if (error.code !== "EEXIST") throw error;
152 throw new Error(
153 `another t3 is installing ${version}. If none is running, delete ${lock} and try again.`,
154 { cause: error },
155 );
156 }
157 const stale = `${staging}.stale`;
158 try {
159 // A peer may have published between our marker check and taking the lock.
160 if (isComplete(dir)) return;
161 // Move it aside rather than deleting in place: once the folder is empty, a
162 // peer's first rename can land in it.
163 fs.renameSync(dir, stale);
164 try {
165 fs.renameSync(staging, dir);
166 } catch (error) {
167 if (!isComplete(dir)) throw error;
168 }
169 } finally {
170 fs.rmSync(lock, { recursive: true, force: true });
171 fs.rmSync(stale, { recursive: true, force: true });
172 }
173}
174
175async function main() {
176 const key = `${process.platform}-${process.arch}`;
177 if (!SUPPORTED.includes(key)) {
178 throw new Error(
179 `no T3 Code CLI build is available for ${key} (supported: ${SUPPORTED.join(", ")}).\n` +
180 "The desktop app is at https://github.com/pingdotgg/t3code/releases",
181 );
182 }
183 const dir = path.join(t3Home(), "runtime", "versions", version);
184 if (!isComplete(dir)) await install(dir, key);
185
186 // Service IPC and signals must reach the child; terminal Ctrl-C already does, so do not forward SIGINT twice.
187 const ipc = process.send !== undefined;
188 const child = spawn(
189 path.join(dir, process.platform === "win32" ? "t3.exe" : "t3"),
190 process.argv.slice(2),
191 {
192 stdio: ipc ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
193 // The executable lives in the T3 home, so this is how it knows npm launched it.
194 env: { ...process.env, T3CODE_NPM_LAUNCHER_PATH: import.meta.filename },
195 },
196 );
197 if (ipc) {
198 process.on("message", (message) => child.connected && child.send(message));
199 child.on("message", (message) => process.connected && process.send(message));
200 process.on("disconnect", () => child.connected && child.disconnect());
201 }
202 for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
203 process.on(signal, () => (ipc || signal !== "SIGINT") && child.kill(signal));
204 }
205 child.on("error", (error) => fail(executableError(key, error.message)));
206 child.on("exit", (code, signal) =>
207 process.exit(code ?? 128 + (os.constants.signals[signal] || 1)),
208 );
209}
210
211function executableError(key, detail) {
212 return new Error(
213 `could not run T3 Code ${version} for ${key}: ${detail}` +
214 (process.platform === "linux"
215 ? "\nThe Linux release requires glibc; musl-based systems such as Alpine are not supported. Use a glibc-based Linux environment."
216 : ""),
217 );
218}
219
220function fail(error) {
221 process.stderr.write(`t3: ${error.message}\n`);
222 process.exit(1);
223}
224
225await main().catch(fail);