Open History to see the last commit
1/* oxlint-disable t3code/no-global-process-runtime -- Standalone setup must run before workspace dependencies are installed. */
2// @effect-diagnostics nodeBuiltinImport:off - standalone bootstrap also runs before dependencies are installed.
3// @effect-diagnostics globalConsole:off globalFetch:off globalDate:off - interactive setup and endpoint verification.
4import * as NodeCrypto from "node:crypto";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import * as NodeURL from "node:url";
9import * as NodeReadlinePromises from "node:readline/promises";
10import { patchNativeCli } from "../src/nativeHook.js";
11
12const home = NodeOS.homedir();
13const root = NodePath.resolve(import.meta.dirname, "../../..");
14const state = NodePath.join(home, ".config/t3-task-cache");
15const nonInteractive = process.argv.includes("--non-interactive");
16const installed = NodePath.join(home, ".local/share/t3-task-cache");
17const hookConfig = NodePath.join(home, ".config/vite-plus/run-hook.json");
18const legacySourceLine = '. "$HOME/.config/t3-task-cache/env"';
19const sourceLine =
20 '[ ! -r "$HOME/.config/t3-task-cache/env" ] || . "$HOME/.config/t3-task-cache/env"';
21const profiles = [".profile", ".bashrc", ".bash_profile", ".bash_login", ".zshenv", ".zshrc"];
22if (process.argv.includes("--uninstall")) {
23 for (const file of profiles) {
24 const path = NodePath.join(home, file);
25 try {
26 const original = await NodeFSP.readFile(path, "utf8");
27 const next = original
28 .split("\n")
29 .filter((line) => line !== sourceLine && line !== legacySourceLine)
30 .join("\n");
31 if (next !== original) {
32 if (!next.trim() && (file === ".bash_profile" || file === ".bash_login"))
33 await NodeFSP.unlink(path);
34 else await NodeFSP.writeFile(path, next);
35 }
36 } catch (error) {
37 if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
38 }
39 }
40 try {
41 const config = JSON.parse(await NodeFSP.readFile(hookConfig, "utf8"));
42 if (config.module === NodePath.join(installed, "runHook.ts")) await NodeFSP.unlink(hookConfig);
43 } catch (error) {
44 if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
45 }
46 await NodeFSP.rm(NodePath.join(state, "credentials.json"), { force: true });
47 await NodeFSP.rm(NodePath.join(state, "env"), { force: true });
48 await NodeFSP.rm(NodePath.join(home, ".cache/t3-task-cache/telemetry"), {
49 recursive: true,
50 force: true,
51 });
52 // Existing shells still import this path until restarted; leave a harmless loader for them.
53 await NodeFSP.mkdir(installed, { recursive: true });
54 await NodeFSP.writeFile(
55 NodePath.join(installed, "preload.mjs"),
56 "// Task cache uninstalled. Safe for shells opened before uninstall.\n",
57 );
58 for (const file of await NodeFSP.readdir(installed)) {
59 if (file !== "preload.mjs")
60 await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true });
61 }
62 console.log(
63 "Task cache uninstalled. Restart existing shells and agents to clear their inherited preload.",
64 );
65 process.exit(0);
66}
67
68const { endpoint: defaultEndpoint } = JSON.parse(
69 await NodeFSP.readFile(NodePath.join(root, ".task-cache.json"), "utf8"),
70);
71let credentials: Record<string, { name: string; token: string }> = {};
72try {
73 credentials = JSON.parse(
74 await NodeFSP.readFile(NodePath.join(state, "credentials.json"), "utf8"),
75 );
76} catch (error) {
77 if (
78 !(error instanceof SyntaxError) &&
79 !(error instanceof Error && "code" in error && error.code === "ENOENT")
80 )
81 throw error;
82}
83const prompt = NodeReadlinePromises.createInterface({
84 input: process.stdin,
85 output: process.stdout,
86});
87let endpoint = process.env.T3_CACHE_SETUP_URL || defaultEndpoint;
88let name = process.env.T3_CACHE_SETUP_NAME || credentials[endpoint]?.name;
89let token = process.env.T3_CACHE_SETUP_TOKEN || credentials[endpoint]?.token;
90const initialEndpoint = endpoint;
91const initialName = name;
92try {
93 if (!nonInteractive) {
94 endpoint = (await prompt.question(`Cache URL [${endpoint}]: `)).trim() || endpoint;
95 name = (await prompt.question(`Developer name${name ? ` [${name}]` : ""}: `)).trim() || name;
96 prompt.close();
97 if (process.stdin.isTTY || !token || endpoint !== initialEndpoint || name !== initialName) {
98 const savedToken = endpoint === initialEndpoint && name === initialName ? token : undefined;
99 if (!process.stdin.isTTY)
100 throw new Error("Use T3_CACHE_SETUP_TOKEN for non-interactive setup.");
101 process.stdout.write(
102 `Credential token (hidden)${savedToken ? " [Enter keeps saved token]" : ""}: `,
103 );
104 process.stdin.setRawMode(true);
105 process.stdin.resume();
106 token =
107 (await new Promise<string>((resolve, reject) => {
108 let value = "";
109 const data = (chunk: Buffer) => {
110 for (const character of chunk.toString()) {
111 if (character === "\u0003") {
112 cleanup();
113 reject(new Error("Cancelled"));
114 return;
115 }
116 if (character === "\r" || character === "\n") {
117 cleanup();
118 resolve(value);
119 return;
120 }
121 if (character === "\u007f") value = value.slice(0, -1);
122 else value += character;
123 }
124 };
125 const cleanup = () => {
126 process.stdin.off("data", data);
127 process.stdin.setRawMode(false);
128 process.stdin.pause();
129 process.stdout.write("\n");
130 };
131 process.stdin.on("data", data);
132 })) || savedToken;
133 }
134 }
135} finally {
136 prompt.close();
137}
138if (!name || !token || !/^[a-z0-9-]{1,64}$/.test(name) || token.length < 32)
139 throw new Error("A valid developer name and credential token are required.");
140const url = new URL(endpoint);
141if (
142 (url.protocol !== "https:" &&
143 !(
144 url.protocol === "http:" &&
145 (url.hostname === "localhost" ||
146 /^127(?:\.\d{1,3}){3}$/.test(url.hostname) ||
147 url.hostname === "[::1]")
148 )) ||
149 url.username ||
150 url.password ||
151 url.search ||
152 url.hash
153)
154 throw new Error(
155 "Use an HTTPS cache endpoint without embedded credentials, query, or fragment (HTTP is only allowed on loopback).",
156 );
157endpoint = url.href.replace(/\/$/, "");
158const response = await fetch(`${endpoint}/telemetry`, {
159 method: "POST",
160 headers: {
161 Authorization: `Basic ${Buffer.from(`${name}:${token}`).toString("base64")}`,
162 "Content-Type": "application/json",
163 },
164 body: JSON.stringify({
165 id: NodeCrypto.randomBytes(32).toString("hex"),
166 recordedAt: Date.now(),
167 host: NodeOS.hostname(),
168 platform: NodeOS.platform(),
169 arch: NodeOS.arch(),
170 ci: false,
171 runId: "setup",
172 tasks: [],
173 }),
174 signal: AbortSignal.timeout(10_000),
175});
176await response.body?.cancel();
177if (!response.ok)
178 throw new Error(`Credential verification failed (${response.status}); nothing was installed.`);
179
180const candidates = [NodePath.join(root, "node_modules/vite-plus/dist/bin.js")];
181for (const directory of [
182 process.env.VITE_PLUS_HOME,
183 NodePath.join(home, ".vite-plus/current"),
184 NodePath.join(home, ".local/share/vite-plus/current"),
185]) {
186 if (directory) candidates.push(NodePath.join(directory, "node_modules/vite-plus/dist/bin.js"));
187}
188for (let index = 0; index < process.argv.length; index++) {
189 if (process.argv[index] === "--entrypoint") {
190 const entrypoint = process.argv[++index];
191 if (!entrypoint) throw new Error("Missing --entrypoint path");
192 candidates.push(entrypoint);
193 }
194}
195const targets = new Map<string, { original: string; patched: string }>();
196for (const candidate of candidates) {
197 try {
198 const path = await NodeFSP.realpath(candidate);
199 const original = await NodeFSP.readFile(path, "utf8");
200 const backup = await NodeFSP.readFile(`${path}.before-task-cache`, "utf8").catch(
201 (error: NodeJS.ErrnoException) => {
202 if (error.code === "ENOENT") return undefined;
203 throw error;
204 },
205 );
206 if (backup && (original === backup || original === patchNativeCli(backup)))
207 targets.set(path, { original: backup, patched: original });
208 } catch (error) {
209 if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
210 }
211}
212
213await NodeFSP.mkdir(installed, { recursive: true });
214await NodeFSP.writeFile(NodePath.join(installed, "package.json"), '{"type":"module"}\n');
215for (const file of [
216 "runHook.ts",
217 "remoteCache.ts",
218 "flush.ts",
219 "telemetry.ts",
220 "nativeHook.js",
221 "nativeRunHook.js",
222 "preload.mjs",
223]) {
224 const destination = NodePath.join(installed, file);
225 const temporary = `${destination}.${NodeCrypto.randomUUID()}.tmp`;
226 try {
227 await NodeFSP.copyFile(NodePath.join(import.meta.dirname, "../src", file), temporary);
228 await NodeFSP.rename(temporary, destination);
229 } finally {
230 await NodeFSP.rm(temporary, { force: true });
231 }
232}
233await NodeFSP.mkdir(NodePath.dirname(hookConfig), { recursive: true });
234await NodeFSP.writeFile(
235 hookConfig,
236 JSON.stringify({
237 rootMarker: ".task-cache.json",
238 module: NodePath.join(installed, "runHook.ts"),
239 }),
240);
241for (const [path, { original }] of targets) {
242 await NodeFSP.writeFile(path, original);
243 await NodeFSP.rm(`${path}.before-task-cache`, { force: true });
244 await NodeFSP.rm(NodePath.join(NodePath.dirname(path), "run-hook.js"), { force: true });
245}
246for (const file of [
247 "nativeHook.ts",
248 "launcher-telemetry.ts",
249 "launcherTelemetry.ts",
250 "vp.ts",
251 "bin",
252])
253 await NodeFSP.rm(NodePath.join(installed, file), { recursive: true, force: true });
254await NodeFSP.mkdir(state, { recursive: true, mode: 0o700 });
255credentials[endpoint] = { name, token };
256const temporaryCredentials = NodePath.join(state, `.credentials-${NodeCrypto.randomUUID()}.tmp`);
257try {
258 await NodeFSP.writeFile(temporaryCredentials, JSON.stringify(credentials), { mode: 0o600 });
259 await NodeFSP.rename(temporaryCredentials, NodePath.join(state, "credentials.json"));
260} finally {
261 await NodeFSP.rm(temporaryCredentials, { force: true });
262}
263
264const environmentFile = NodePath.join(state, "env");
265const preload = NodeURL.pathToFileURL(NodePath.join(installed, "preload.mjs")).href;
266await NodeFSP.writeFile(
267 environmentFile,
268 [
269 `if [ -r "${NodePath.join(installed, "preload.mjs")}" ]; then`,
270 `case " \${NODE_OPTIONS-} " in`,
271 ` *" --import=${preload} "*) ;;`,
272 ` *) export NODE_OPTIONS="\${NODE_OPTIONS:+$NODE_OPTIONS }--import=${preload}" ;;`,
273 `esac`,
274 `fi`,
275 "",
276 ].join("\n"),
277 { mode: 0o600 },
278);
279for (const file of profiles) {
280 const path = NodePath.join(home, file);
281 let original = "";
282 try {
283 original = await NodeFSP.readFile(path, "utf8");
284 } catch (error) {
285 if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
286 }
287 if (file === ".bash_profile" || file === ".bash_login") {
288 if (!original) continue;
289 // Earlier installers created a profile that masks the user’s existing login profile.
290 if (file === ".bash_profile" && [sourceLine, legacySourceLine].includes(original.trim())) {
291 await NodeFSP.unlink(path);
292 continue;
293 }
294 }
295 if (original.split("\n").includes(legacySourceLine)) {
296 original = original
297 .split("\n")
298 .filter((line) => line !== legacySourceLine)
299 .join("\n");
300 await NodeFSP.writeFile(path, original);
301 }
302 if (!original.split("\n").includes(sourceLine))
303 await NodeFSP.appendFile(path, `\n${sourceLine}\n`);
304}
305console.log(
306 "Verified credentials and installed the machine-wide Vite+ hook. Open a fresh shell to enable it in all worktrees. Executables and PATH are unchanged.",
307);