1// @effect-diagnostics nodeBuiltinImport:off
2// The Windows cleanup shell must outlive this process (it deletes the
3// directory this executable runs from), which Effect's scoped ChildProcess
4// cannot express: it kills the child when the scope closes.
5import * as NodeChildProcess from "node:child_process";
6
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import * as Console from "effect/Console";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Schema from "effect/Schema";
14import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
15
16import * as BootService from "../cloud/bootService.ts";
17import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts";
18import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
19import * as CliService from "./service.ts";
20import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts";
21
22export class CliUninstallError extends Schema.TaggedError<CliUninstallError>()(
23 "CliUninstallError",
24 { reason: Schema.String },
25) {
26 override get message(): string {
27 return this.reason;
28 }
29}
30
31/**
32 * What `t3 uninstall` would remove for one T3 home. Computed before anything
33 * is touched so the user sees the whole plan in one place.
34 */
35export interface UninstallPlan {
36 /** The background service serves this home and will be stopped and removed. */
37 readonly service: boolean;
38 /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */
39 readonly launcher: string | undefined;
40 /** `<home>/runtime`, holding every downloaded version, when it exists. */
41 readonly runtimeDir: string | undefined;
42 /** `<home>/userdata`, which is never removed; shown so the user knows where it is. */
43 readonly userdataDir: string;
44}
45
46/**
47 * Finds the launcher this install left on PATH. Only a launcher that points
48 * into this home's `runtime/versions` is claimed: a plain copy of the
49 * executable, or a launcher for another home, is not ours to delete.
50 */
51export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: {
52 readonly launchedAs: string | undefined;
53 readonly versionsDir: string;
54}) {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const platform = yield* HostProcess.Platform;
58 if (input.launchedAs === undefined) return undefined;
59 if (platform === "win32") {
60 const shimPath = yield* findWindowsShim(input.launchedAs);
61 if (shimPath === undefined) return undefined;
62 const contents = yield* fs.readFileString(shimPath).pipe(Effect.option);
63 const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined;
64 return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target)
65 ? shimPath
66 : undefined;
67 }
68 const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option);
69 if (Option.isNone(linkTarget)) return undefined;
70 const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value);
71 return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined;
72});
73
74const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: {
75 readonly baseDir: string;
76}) {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const service = yield* BootService.BootService;
80 const status = yield* service.status;
81 const servesThisHome =
82 status.installedBaseDir !== undefined &&
83 path.resolve(status.installedBaseDir) === path.resolve(input.baseDir);
84 const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir);
85 const runtimeDir = path.dirname(versionsDir);
86 const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined;
87 const plan: UninstallPlan = {
88 service: status.supported && status.installed && servesThisHome,
89 launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }),
90 runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false)))
91 ? runtimeDir
92 : undefined,
93 userdataDir: path.join(input.baseDir, "userdata"),
94 };
95 return plan;
96});
97
98export const uninstallCommand = Command.make("uninstall", {
99 ...projectLocationFlags,
100 yes: Flag.Boolean("yes").pipe(
101 Flag.withAlias("y"),
102 Flag.withDescription(
103 "Remove everything without asking. Required from a script, where there is no prompt.",
104 ),
105 Flag.withDefault(false),
106 ),
107}).pipe(
108 Command.withDescription(
109 "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.",
110 ),
111 Command.withHandler((flags) =>
112 Effect.gen(function* () {
113 const logLevel = yield* GlobalFlag.LogLevel;
114 const config = yield* resolveCliAuthConfig(flags, logLevel);
115 return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe(
116 Effect.provide(CliService.layer(config)),
117 );
118 }),
119 ),
120);
121
122const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: {
123 readonly baseDir: string;
124 readonly assumeYes: boolean;
125}) {
126 const fs = yield* FileSystem.FileSystem;
127 const platform = yield* HostProcess.Platform;
128 const environment = yield* HostProcess.Environment;
129 const service = yield* BootService.BootService;
130 const plan = yield* planUninstall({ baseDir: input.baseDir });
131
132 if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) {
133 yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`);
134 if (!(yield* HostProcess.IsExecutable)) {
135 yield* Console.log(
136 " 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).",
137 );
138 }
139 return;
140 }
141
142 yield* Console.log("This will remove:");
143 if (plan.service) yield* Console.log(" the background service (stopping it first)");
144 if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`);
145 if (plan.runtimeDir !== undefined) {
146 yield* Console.log(` every downloaded version under ${plan.runtimeDir}`);
147 }
148 yield* Console.log(
149 `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`,
150 );
151
152 if (!input.assumeYes) {
153 if (!(process.stdin.isTTY && process.stdout.isTTY)) {
154 return yield* new CliUninstallError({
155 reason:
156 "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.",
157 });
158 }
159 const confirmed = yield* Prompt.run(
160 Prompt.Confirm({ message: "Remove t3 from this machine?", initial: false }),
161 ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) }));
162 if (!confirmed) {
163 yield* Console.log("Left as is.");
164 return;
165 }
166 }
167
168 if (plan.service) {
169 yield* service.uninstall;
170 yield* Console.log("Removed the background service.");
171 }
172 if (plan.launcher !== undefined) {
173 yield* fs
174 .remove(plan.launcher, { force: true })
175 .pipe(
176 Effect.mapError(
177 () =>
178 new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }),
179 ),
180 );
181 yield* Console.log(`Removed ${plan.launcher}.`);
182 }
183 if (plan.runtimeDir !== undefined) {
184 // This process runs from inside runtimeDir. POSIX unlinks a running
185 // executable fine; Windows refuses, so the tree is removed after this
186 // process exits by a detached shell, and the user is told either way.
187 if (platform === "win32") {
188 const runtimeDir = plan.runtimeDir;
189 const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe";
190 yield* Effect.try({
191 try: () => {
192 const child = NodeChildProcess.spawn(
193 comspec,
194 ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`],
195 { detached: true, stdio: "ignore", windowsHide: true },
196 );
197 child.unref();
198 },
199 catch: () =>
200 new CliUninstallError({
201 reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`,
202 }),
203 });
204 yield* Console.log(`${runtimeDir} will be removed once t3 exits.`);
205 } else {
206 yield* fs
207 .remove(plan.runtimeDir, { recursive: true, force: true })
208 .pipe(
209 Effect.mapError(
210 () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }),
211 ),
212 );
213 yield* Console.log(`Removed ${plan.runtimeDir}.`);
214 }
215 }
216 yield* Console.log("");
217 yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code.");
218});