1import * as HostProcess from "@t3tools/shared/HostProcess";
2import { pathSetupCommand } from "@t3tools/shared/shell";
3import * as Console from "effect/Console";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import { Command, Prompt } from "effect/cli";
11import * as ChildProcess from "effect/process/ChildProcess";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13
14import packageJson from "../../package.json" with { type: "json" };
15import {
16 generatedWindowsShimTarget,
17 isVersionExecutable,
18 recordPinnedRuntimeLauncher,
19} from "../cloud/pinnedRuntime.ts";
20import { detectCliRunner, resolveCliCommand, resolveEntryPath } from "./invocation.ts";
21
22export class CliInstallError extends Schema.TaggedError<CliInstallError>()("CliInstallError", {
23 reason: Schema.String,
24 cause: Schema.optional(Schema.Defect()),
25}) {
26 override get message(): string {
27 return this.reason;
28 }
29}
30
31/** `<T3 home>/runtime/versions/<version>/t3`: the layout the install scripts and npm launcher share. */
32const standaloneRuntime = Effect.gen(function* () {
33 const path = yield* Path.Path;
34 if (!(yield* HostProcess.IsExecutable)) return Option.none();
35 const executable = yield* HostProcess.ExecutablePath;
36 const versionsDir = path.dirname(path.dirname(executable));
37 const runtimeDir = path.dirname(versionsDir);
38 if (path.basename(versionsDir) !== "versions" || path.basename(runtimeDir) !== "runtime") {
39 return Option.none();
40 }
41 return Option.some({ executable, versionsDir, runtimeDir });
42});
43
44/** Links this runtime as the `t3` command the way the install scripts do, replacing only our own link. */
45export const installCli = Effect.fn("cli.install.install")(function* () {
46 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;
48 const platform = yield* HostProcess.Platform;
49 const environment = yield* HostProcess.Environment;
50 const home = yield* HostProcess.HomeDirectory;
51 const runtime = yield* standaloneRuntime;
52 const windows = platform === "win32";
53 if (Option.isNone(runtime)) {
54 const script = windows
55 ? "irm https://t3.codes/install.ps1 | iex"
56 : "curl -fsSL https://t3.codes/install.sh | sh";
57 return yield* new CliInstallError({
58 reason: `Only the standalone t3 can install itself. Use \`${script}\`, or Settings in the desktop app.`,
59 });
60 }
61 const { executable, versionsDir, runtimeDir } = runtime.value;
62 // cmd.exe expands `%` and reads the shim in the OEM code page, so such a path would not run.
63 if (windows && /[%\u0080-\uffff]/.test(executable)) {
64 return yield* new CliInstallError({
65 reason: `A t3.cmd cannot run ${executable}, because the path has % or non-ASCII characters. Keep using npx t3.`,
66 });
67 }
68 // Absolute, so the PATH hint never adds a folder that changes with the working directory.
69 const binDir = path.resolve(
70 environment.T3CODE_INSTALL_BIN_DIR?.trim() || path.join(home, ".local", "bin"),
71 );
72 const name = windows ? "t3.cmd" : "t3";
73 const link = path.join(binDir, name);
74 const failed = (reason: string) => (cause: unknown) => new CliInstallError({ reason, cause });
75
76 // Resolve fully so a link through a symlinked T3 home still counts as ours; a dangling one
77 // (a pruned version) falls back to its own text.
78 const realVersionsDir = yield* fs
79 .realPath(versionsDir)
80 .pipe(Effect.orElseSucceed(() => versionsDir));
81 const current = windows
82 ? yield* fs.readFileString(link).pipe(Effect.map(generatedWindowsShimTarget), Effect.option)
83 : yield* fs.readLink(link).pipe(
84 Effect.flatMap((target) =>
85 fs.realPath(link).pipe(Effect.orElseSucceed(() => path.resolve(binDir, target))),
86 ),
87 Effect.option,
88 );
89 // A dangling symlink is occupied too: writing the shim would follow it.
90 const occupied =
91 Option.isSome(current) ||
92 (yield* fs.exists(link)) ||
93 Option.isSome(yield* fs.readLink(link).pipe(Effect.option));
94 if (
95 occupied &&
96 !(
97 Option.isSome(current) &&
98 current.value !== undefined &&
99 (isVersionExecutable(path, versionsDir, current.value) ||
100 isVersionExecutable(path, realVersionsDir, current.value) ||
101 // This home's desktop launcher, which install.sh also replaces.
102 current.value === path.join(path.dirname(runtimeDir), "bin", name))
103 )
104 ) {
105 return yield* new CliInstallError({
106 reason: `Another t3 is already at ${link}. Remove it, then run this again.`,
107 });
108 }
109
110 yield* fs
111 .makeDirectory(binDir, { recursive: true })
112 .pipe(Effect.mapError(failed(`Could not create ${binDir}.`)));
113 // An empty spot is created exclusively, so a file that appears after the check above is kept.
114 if (windows) {
115 yield* fs
116 .writeFileString(link, `@echo off\r\n"${executable}" %*`, occupied ? {} : { flag: "wx" })
117 .pipe(Effect.mapError(failed(`Could not write ${link}.`)));
118 } else if (!occupied) {
119 yield* fs.symlink(executable, link).pipe(Effect.mapError(failed(`Could not link ${link}.`)));
120 } else {
121 // Swap a temporary link into place so a running `t3` never sees the command missing.
122 const staging = `${link}.${process.pid}.tmp`;
123 // Clean up only a staging link this call made, never a file that was already there.
124 yield* fs
125 .symlink(executable, staging)
126 .pipe(
127 Effect.andThen(
128 fs
129 .rename(staging, link)
130 .pipe(Effect.tapError(() => fs.remove(staging, { force: true }).pipe(Effect.ignore))),
131 ),
132 Effect.mapError(failed(`Could not link ${link}.`)),
133 );
134 }
135
136 // Pruning keeps what recorded launchers point at; a background service cannot see binDir otherwise.
137 const recorded = path.join(
138 yield* fs.realPath(binDir).pipe(Effect.orElseSucceed(() => binDir)),
139 name,
140 );
141 yield* recordPinnedRuntimeLauncher({
142 baseDir: path.dirname(runtimeDir),
143 launcher: recorded,
144 }).pipe(
145 Effect.catch(() =>
146 Console.error(
147 `Warning: could not record ${recorded} in ${path.join(runtimeDir, "launchers")}. Cleanup may remove the version it runs.`,
148 ),
149 ),
150 );
151
152 // resolve() drops trailing separators, so `~/.local/bin/` on PATH still matches.
153 const normalize = (value: string) =>
154 windows ? path.resolve(value).toLowerCase() : path.resolve(value);
155 const same = (entry: string) => entry !== "" && normalize(entry) === normalize(binDir);
156 const entries = (environment.PATH ?? environment.Path ?? "").split(windows ? ";" : ":");
157 const onPath = entries.some(same);
158 // A t3 earlier on PATH runs instead, and an added folder goes last. Runner caches (npx's own
159 // .bin) are not installs.
160 const firstOther = Effect.gen(function* () {
161 for (const entry of entries) {
162 if (same(entry)) return undefined;
163 if (!path.isAbsolute(entry) || detectCliRunner(`${entry}/`) !== null) continue;
164 for (const name of windows ? ["t3.cmd", "t3.exe"] : ["t3"]) {
165 const candidate = path.join(entry, name);
166 if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate;
167 }
168 }
169 return undefined;
170 });
171 const shadowedBy = yield* firstOther;
172 return {
173 link,
174 onPath: onPath && shadowedBy === undefined,
175 shadowedBy,
176 pathCommand:
177 onPath || shadowedBy !== undefined
178 ? undefined
179 : windows
180 ? // PowerShell; it covers this window only, since changing the saved PATH would flatten
181 // its %VAR% entries. A `;` would split the folder.
182 binDir.includes(";")
183 ? undefined
184 : `$env:Path += ';${binDir.replaceAll("'", "''")}'`
185 : pathSetupCommand({
186 directory: binDir,
187 home,
188 shell: environment.SHELL,
189 platform,
190 configHome: environment.XDG_CONFIG_HOME,
191 zdotdir: environment.ZDOTDIR,
192 }),
193 binDir,
194 };
195});
196
197const reportInstalled = Effect.fn("cli.install.report")(function* (
198 installed: Effect.Success<ReturnType<typeof installCli>>,
199) {
200 yield* Console.log(`Installed t3 ${packageJson.version} at ${installed.link}.`);
201 if (installed.shadowedBy !== undefined) {
202 return yield* Console.log(
203 `Another t3 at ${installed.shadowedBy} runs first, so run ${installed.link} to use this one.`,
204 );
205 }
206 if (installed.onPath) return yield* Console.log("Run `t3` to get started.");
207 if (installed.pathCommand === undefined) {
208 return yield* Console.log(`Add ${installed.binDir} to your PATH, then run \`t3\`.`);
209 }
210 if ((yield* HostProcess.Platform) === "win32") {
211 yield* Console.log(
212 `${installed.binDir} is not on your PATH yet. In this PowerShell window, run:`,
213 );
214 yield* Console.log(` ${installed.pathCommand}`);
215 return yield* Console.log("Then run `t3`. For new windows, add the folder to your PATH.");
216 }
217 yield* Console.log(`${installed.binDir} is not on your PATH yet. Add it with:`);
218 yield* Console.log(` ${installed.pathCommand}`);
219 yield* Console.log("Then open a new terminal and run `t3`.");
220});
221
222export const installCommand = Command.make("install").pipe(
223 Command.withDescription("Install this version as the `t3` command."),
224 Command.withHandler(() => installCli().pipe(Effect.flatMap(reportInstalled))),
225);
226
227/** Saying no lasts until the next minor release: `0.2` covers `0.2.5` and `0.2.6-nightly.…`. */
228const minorRelease = (version: string) => version.split(".").slice(0, 2).join(".");
229
230const SKIPPED_ARGUMENTS = new Set([
231 "help",
232 "--help",
233 "-h",
234 "--version",
235 "-v",
236 "--completions",
237 // Scripts and machine-readable output; a prompt would stall them or corrupt their output.
238 "--json",
239 "--yes",
240 "-y",
241]);
242
243/** The subcommand, past global flags that may come first (two take a value). */
244const topLevelCommand = (args: ReadonlyArray<string>) => {
245 for (let index = 0; index < args.length; index++) {
246 const arg = args[index]!;
247 if (arg === "--log-level" || arg === "--completions") index++;
248 else if (!arg.startsWith("-")) return arg;
249 }
250 return undefined;
251};
252
253/** Agents can run commands in a terminal that nobody answers; no answer counts as no. */
254const PROMPT_TIMEOUT = Duration.seconds(30);
255
256/** `/proc/<pid>/stat` fields after the parenthesized command name: state ppid pgrp session tty_nr tpgid. */
257export const isForegroundProcStat = (stat: string) => {
258 const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
259 return fields[2] !== undefined && fields[2] !== "" && fields[2] === fields[5];
260};
261
262/** Output of `ps -o tpgid=,pgid=`; tpgid is -1 without a controlling terminal. */
263export const isForegroundPs = (output: string) => {
264 const [tpgid, pgid] = output.trim().split(/\s+/);
265 return tpgid !== undefined && tpgid !== "" && tpgid === pgid;
266};
267
268/**
269 * A background job (`npx t3 serve &`) that reads the terminal is stopped by the shell, so only
270 * the terminal's foreground process group may prompt. Unknown counts as background.
271 */
272export const inTerminalForeground = Effect.gen(function* () {
273 const fs = yield* FileSystem.FileSystem;
274 const platform = yield* HostProcess.Platform;
275 if (platform === "win32") return true;
276 if (platform === "linux") {
277 return isForegroundProcStat(
278 yield* fs.readFileString("/proc/self/stat").pipe(Effect.orElseSucceed(() => "")),
279 );
280 }
281 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
282 return isForegroundPs(
283 yield* spawner
284 .string(
285 // Absolute: under npx, PATH starts with a project's node_modules/.bin.
286 ChildProcess.make("/bin/ps", ["-o", "tpgid=,pgid=", "-p", String(process.pid)], {
287 stdin: "ignore",
288 stderr: "ignore",
289 }),
290 )
291 .pipe(Effect.orElseSucceed(() => "")),
292 );
293});
294
295/**
296 * Before a command run through npx, pnpm dlx, or bunx at a terminal, asks to install the `t3`
297 * command. Yes is the default. No, no answer, or a failed install is saved in the T3 home until
298 * the next minor release. Returns false when the user quits the prompt, so the command does not run.
299 */
300export const offerCliInstall = Effect.fn("cli.install.offer")(function* <E, R, E2, R2>(input: {
301 readonly args: ReadonlyArray<string>;
302 readonly interactive: boolean;
303 readonly foreground: Effect.Effect<boolean, E2, R2>;
304 /** Undefined when the user quits the prompt. */
305 readonly confirm: Effect.Effect<boolean | void, E, R>;
306}) {
307 const fs = yield* FileSystem.FileSystem;
308 const path = yield* Path.Path;
309 const platform = yield* HostProcess.Platform;
310 const environment = yield* HostProcess.Environment;
311 if (!input.interactive || environment.CI || environment.SUDO_USER) return true;
312 if (environment.TERM === "dumb") return true;
313 // `npx --yes` asks for no prompts; npm passes it on to the command as npm_config_yes.
314 if (environment.npm_config_yes === "true") return true;
315 // `--json=true` and `-y=true` are the same flags.
316 if (input.args.some((arg) => SKIPPED_ARGUMENTS.has(arg.split("=")[0]!))) return true;
317 const command = topLevelCommand(input.args);
318 if (command === "install" || command === "uninstall") return true;
319 if (detectCliRunner(yield* resolveEntryPath) === null) return true;
320 const runtime = yield* standaloneRuntime;
321 if (Option.isNone(runtime)) return true;
322 const { runtimeDir } = runtime.value;
323
324 // Any t3 already installed means no prompt: ours (even when off PATH) or another on PATH.
325 // Runners put their own cache's node_modules/.bin on PATH, so skip those.
326 const windows = platform === "win32";
327 const names = windows ? ["t3.cmd", "t3.exe"] : ["t3"];
328 const launchers = yield* fs
329 .readFileString(path.join(runtimeDir, "launchers"))
330 .pipe(Effect.orElseSucceed(() => ""));
331 const candidates = [
332 ...launchers.split(/\r?\n/).filter(Boolean),
333 path.join(path.dirname(runtimeDir), "bin", windows ? "t3.cmd" : "t3"),
334 ...(environment.PATH ?? environment.Path ?? "")
335 .split(windows ? ";" : ":")
336 .filter(
337 (directory) => path.isAbsolute(directory) && detectCliRunner(`${directory}/`) === null,
338 )
339 .flatMap((directory) => names.map((name) => path.join(directory, name))),
340 ];
341 for (const candidate of candidates) {
342 if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return true;
343 }
344
345 const declinedFile = path.join(runtimeDir, "install-prompt-declined");
346 const declined = yield* fs.readFileString(declinedFile).pipe(Effect.orElseSucceed(() => ""));
347 const minor = minorRelease(packageJson.version);
348 if (declined.trim() === minor) return true;
349 if (!(yield* input.foreground)) return true;
350
351 const installHint = `Run \`${yield* resolveCliCommand("install")}\` any time.`;
352 const saveDecline = fs.writeFileString(declinedFile, `${minor}\n`).pipe(Effect.ignore);
353 const answer = yield* input.confirm.pipe(Effect.timeoutOption(PROMPT_TIMEOUT));
354 if (Option.isNone(answer)) {
355 yield* saveDecline;
356 yield* Console.log(`\nNo answer, so t3 was not installed. ${installHint}\n`);
357 return true;
358 }
359 if (answer.value === undefined) return false;
360 if (!answer.value) {
361 yield* saveDecline;
362 yield* Console.log(`Skipped. ${installHint}\n`);
363 return true;
364 }
365 yield* installCli().pipe(
366 Effect.flatMap(reportInstalled),
367 Effect.catch((error) =>
368 Effect.andThen(saveDecline, Console.error(`Could not install t3: ${error.message}`)),
369 ),
370 );
371 yield* Console.log("");
372 return true;
373});
374
375export const confirmCliInstall = Prompt.run(
376 Prompt.Confirm({
377 message: "Install the t3 command? Then you can run `t3` directly.",
378 initial: true,
379 }),
380).pipe(Effect.catchTags({ QuitError: () => Effect.void }));