1// @effect-diagnostics nodeBuiltinImport:off
2// Windows cleanup must outlive this executable to delete its directory; scoped ChildProcess kills children on exit.
3import * as NodeChildProcess from "node:child_process";
4
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";
12import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
13
14import * as BootService from "../cloud/bootService.ts";
15import {
16 generatedWindowsShimTarget,
17 isVersionExecutable,
18 pinnedRuntimeVersionsDir,
19 resolveLauncherPath,
20} from "../cloud/pinnedRuntime.ts";
21import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
22import * as CliService from "./service.ts";
23import { findWindowsShim } from "./update.ts";
24
25export class CliUninstallError extends Schema.TaggedError<CliUninstallError>()(
26 "CliUninstallError",
27 { reason: Schema.String },
28) {
29 override get message(): string {
30 return this.reason;
31 }
32}
33
34export interface UninstallPlan {
35 readonly service: boolean;
36 readonly otherHomeService: string | undefined;
37 readonly versionsDir: string;
38 readonly launchers: ReadonlyArray<string>;
39 readonly runtimeDir: string | undefined;
40 readonly userdataDir: string;
41}
42
43/** Delete only launchers targeting this home's runtime/versions. */
44export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: {
45 readonly launchedAs: string | undefined;
46 readonly versionsDir: string;
47}) {
48 const fs = yield* FileSystem.FileSystem;
49 const path = yield* Path.Path;
50 const platform = yield* HostProcess.Platform;
51 if (input.launchedAs === undefined) return undefined;
52 if (platform === "win32") {
53 const shimPath = yield* findWindowsShim(input.launchedAs);
54 if (shimPath === undefined) return undefined;
55 const contents = yield* fs.readFileString(shimPath).pipe(Effect.option);
56 const target = Option.isSome(contents) ? generatedWindowsShimTarget(contents.value) : undefined;
57 return target !== undefined && isVersionExecutable(path, input.versionsDir, target)
58 ? shimPath
59 : undefined;
60 }
61 const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option);
62 if (Option.isNone(linkTarget)) return undefined;
63 const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value);
64 return isVersionExecutable(path, input.versionsDir, resolved) ? input.launchedAs : undefined;
65});
66
67/** Whether `launcher` is a link or unedited `t3.cmd` into this home's runtime/versions. */
68const ownsLauncher = Effect.fn("cli.uninstall.owns_launcher")(function* (
69 launcher: string,
70 versionsDir: string,
71) {
72 const fs = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const platform = yield* HostProcess.Platform;
75 const realVersionsDir = yield* fs
76 .realPath(versionsDir)
77 .pipe(Effect.orElseSucceed(() => versionsDir));
78 // The link's own text keeps a pruned (dangling) target; the real path sees through a
79 // symlinked T3 home.
80 const targets =
81 platform === "win32"
82 ? [
83 yield* fs.readFileString(launcher).pipe(
84 Effect.map(generatedWindowsShimTarget),
85 Effect.orElseSucceed(() => undefined),
86 ),
87 ]
88 : [
89 yield* fs.readLink(launcher).pipe(
90 Effect.map((link) => path.resolve(path.dirname(launcher), link)),
91 Effect.orElseSucceed(() => undefined),
92 ),
93 yield* fs.realPath(launcher).pipe(Effect.orElseSucceed(() => undefined)),
94 ];
95 return targets.some(
96 (target) =>
97 target !== undefined &&
98 (isVersionExecutable(path, versionsDir, target) ||
99 isVersionExecutable(path, realVersionsDir, target)),
100 );
101});
102
103/**
104 * Launchers recorded in `runtime/launchers` by the install scripts and `t3 install` that still
105 * point into this home's runtime/versions. npx can run a different version than the installed
106 * one, so the launcher this process started from does not always name them.
107 */
108export const findRecordedLaunchers = Effect.fn("cli.uninstall.find_recorded_launchers")(function* (
109 versionsDir: string,
110) {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const recorded = yield* fs
114 .readFileString(path.join(path.dirname(versionsDir), "launchers"))
115 .pipe(Effect.orElseSucceed(() => ""));
116 const owned: Array<string> = [];
117 for (const launcher of recorded.split(/\r?\n/).filter(Boolean)) {
118 if (yield* ownsLauncher(launcher, versionsDir)) owned.push(launcher);
119 }
120 return owned;
121});
122
123// cmd.exe needs verbatim quoting and a literal path, with delayed expansion disabled.
124export function spawnWindowsRemoval(comspec: string, dir: string) {
125 return NodeChildProcess.spawn(
126 comspec,
127 [
128 "/d",
129 "/v:off",
130 "/s",
131 "/c",
132 '"ping -n 3 127.0.0.1 >nul & rmdir /s /q "%T3_UNINSTALL_RUNTIME%""',
133 ],
134 {
135 env: { ...process.env, T3_UNINSTALL_RUNTIME: dir },
136 windowsVerbatimArguments: true,
137 detached: true,
138 stdio: "ignore",
139 windowsHide: true,
140 },
141 );
142}
143
144const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: {
145 readonly baseDir: string;
146}) {
147 const fs = yield* FileSystem.FileSystem;
148 const path = yield* Path.Path;
149 const service = yield* BootService.BootService;
150 const status = yield* service.status;
151 const installedBaseDir =
152 status.supported && status.installed ? status.installedBaseDir : undefined;
153 const servesThisHome =
154 installedBaseDir !== undefined &&
155 path.resolve(installedBaseDir) === path.resolve(input.baseDir);
156 const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir);
157 const runtimeDir = path.dirname(versionsDir);
158 const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined;
159 const launchedFrom = yield* findOwnedLauncher({ launchedAs, versionsDir });
160 const plan: UninstallPlan = {
161 service: servesThisHome,
162 otherHomeService: servesThisHome ? undefined : installedBaseDir,
163 versionsDir,
164 launchers: [
165 ...new Set([
166 ...(launchedFrom === undefined ? [] : [launchedFrom]),
167 ...(yield* findRecordedLaunchers(versionsDir)),
168 ]),
169 ],
170 runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false)))
171 ? runtimeDir
172 : undefined,
173 userdataDir: path.join(input.baseDir, "userdata"),
174 };
175 return plan;
176});
177
178export const uninstallCommand = Command.make("uninstall", {
179 ...projectLocationFlags,
180 yes: Flag.Boolean("yes").pipe(
181 Flag.withAlias("y"),
182 Flag.withDescription(
183 "Remove everything without asking. Required from a script, where there is no prompt.",
184 ),
185 Flag.withDefault(false),
186 ),
187}).pipe(
188 Command.withDescription(
189 "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.",
190 ),
191 Command.withHandler((flags) =>
192 Effect.gen(function* () {
193 const logLevel = yield* GlobalFlag.LogLevel;
194 const config = yield* resolveCliAuthConfig(flags, logLevel);
195 return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe(
196 Effect.provide(CliService.layer(config)),
197 );
198 }),
199 ),
200);
201
202const logOtherHomeService = (plan: UninstallPlan) =>
203 plan.otherHomeService === undefined
204 ? Effect.void
205 : Console.log(
206 `The T3 Code service serves ${plan.otherHomeService}, so it was not removed. Pass \`${BootService.formatBootServiceBaseDirOption(plan.otherHomeService)}\` to remove it.`,
207 );
208
209const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: {
210 readonly baseDir: string;
211 readonly assumeYes: boolean;
212}) {
213 const fs = yield* FileSystem.FileSystem;
214 const platform = yield* HostProcess.Platform;
215 const environment = yield* HostProcess.Environment;
216 const service = yield* BootService.BootService;
217 const plan = yield* planUninstall({ baseDir: input.baseDir });
218
219 if (!plan.service && plan.launchers.length === 0 && plan.runtimeDir === undefined) {
220 yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`);
221 if (!(yield* HostProcess.IsExecutable)) {
222 yield* Console.log(
223 " This t3 runs from a Node script, so it was installed by npm or built from source. Remove it the same way (`npm uninstall -g t3`, or delete the checkout).",
224 );
225 }
226 yield* logOtherHomeService(plan);
227 return;
228 }
229
230 yield* Console.log("This will remove:");
231 if (plan.service) yield* Console.log(" the background service (stopping it first)");
232 for (const launcher of plan.launchers) yield* Console.log(` the launcher at ${launcher}`);
233 if (plan.runtimeDir !== undefined) {
234 yield* Console.log(` every downloaded version under ${plan.runtimeDir}`);
235 }
236 yield* Console.log(
237 `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`,
238 );
239
240 if (!input.assumeYes) {
241 if (!(process.stdin.isTTY && process.stdout.isTTY)) {
242 return yield* new CliUninstallError({
243 reason:
244 "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.",
245 });
246 }
247 const confirmed = yield* Prompt.run(
248 Prompt.Confirm({ message: "Remove t3 from this machine?", initial: false }),
249 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
250 if (!confirmed) {
251 yield* Console.log("Left as is.");
252 return;
253 }
254 }
255
256 if (plan.service) {
257 yield* service.uninstall;
258 yield* Console.log("Removed the background service.");
259 }
260 for (const launcher of plan.launchers) {
261 // The prompt can wait a long time; never delete a file that replaced the launcher meanwhile.
262 if (!(yield* ownsLauncher(launcher, plan.versionsDir))) {
263 yield* Console.log(`Left ${launcher}: it no longer runs this t3.`);
264 continue;
265 }
266 yield* fs
267 .remove(launcher, { force: true })
268 .pipe(
269 Effect.mapError(
270 () => new CliUninstallError({ reason: `Could not remove the launcher at ${launcher}.` }),
271 ),
272 );
273 yield* Console.log(`Removed ${launcher}.`);
274 }
275 if (plan.runtimeDir !== undefined) {
276 // Windows refuses to unlink this running executable; a detached shell removes it after exit.
277 if (platform === "win32") {
278 const runtimeDir = plan.runtimeDir;
279 const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe";
280 yield* Effect.try({
281 try: () => spawnWindowsRemoval(comspec, runtimeDir).unref(),
282 catch: () =>
283 new CliUninstallError({
284 reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`,
285 }),
286 });
287 yield* Console.log(`${runtimeDir} will be removed once t3 exits.`);
288 } else {
289 yield* fs
290 .remove(plan.runtimeDir, { recursive: true, force: true })
291 .pipe(
292 Effect.mapError(
293 () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }),
294 ),
295 );
296 yield* Console.log(`Removed ${plan.runtimeDir}.`);
297 }
298 }
299 yield* Console.log("");
300 yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code.");
301 yield* logOtherHomeService(plan);
302});