authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:24-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:24-07:00
logb70015b6db8ffafd902174f78cb260cc8082fc6e
treeee511f6607dd0dbcb384ec7ad6f8d9d666578d50
parent91cd91c0872bd52be3b45bea70a9539d1ce6adaa
signature Signed by PGP key B5690EEEBB952194

feat(cli): add t3 uninstall for self-contained installs (#11659)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 285 insertions(+), 6 deletions(-)

apps/server/src/bin.ts+2
......@@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts";
1717import { projectCommand } from "./cli/project.ts";
1818import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1919import { serviceCommand } from "./cli/service.ts";
20import { uninstallCommand } from "./cli/uninstall.ts";
2021import { updateCommand } from "./cli/update.ts";
2122import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
2223import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
......@@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
6465 projectCommand,
6566 serviceCommand,
6667 updateCommand,
68 uninstallCommand,
6769 serviceLauncherCommand,
6870 claudeHistoryCommand,
6971 servicePreflightCommand,
apps/server/src/cli/uninstall.test.ts created+37
......@@ -0,0 +1,37 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7
8import { findOwnedLauncher } from "./uninstall.ts";
9
10it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => {
11 it.effect("claims only a launcher that points into this home's runtime tree", () =>
12 Effect.gen(function* () {
13 const fs = yield* FileSystem.FileSystem;
14 const path = yield* Path.Path;
15 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" });
16 const versionsDir = path.join(root, "runtime/versions");
17 const exe = path.join(versionsDir, "1.0.0/t3");
18 const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3");
19 const copy = path.join(root, "copy/t3");
20 for (const file of [exe, otherExe, copy]) {
21 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
22 yield* fs.writeFileString(file, "");
23 }
24 const ours = path.join(root, "bin/t3");
25 const theirs = path.join(root, "other/bin/t3");
26 yield* fs.makeDirectory(path.dirname(ours), { recursive: true });
27 yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
28 yield* fs.symlink(exe, ours);
29 yield* fs.symlink(otherExe, theirs);
30
31 assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours);
32 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir }));
33 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir }));
34 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir }));
35 }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
36 );
37});
apps/server/src/cli/uninstall.ts created+222
......@@ -0,0 +1,222 @@
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 {
8 HostProcessEnvironment,
9 HostProcessIsExecutable,
10 HostProcessPlatform,
11} from "@t3tools/shared/hostProcess";
12import * as Console from "effect/Console";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
19
20import * as BootService from "../cloud/bootService.ts";
21import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts";
22import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
23import { bootServiceLayer } from "./service.ts";
24import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts";
25
26export class CliUninstallError extends Schema.TaggedError<CliUninstallError>()(
27 "CliUninstallError",
28 { reason: Schema.String },
29) {
30 override get message(): string {
31 return this.reason;
32 }
33}
34
35/**
36 * What `t3 uninstall` would remove for one T3 home. Computed before anything
37 * is touched so the user sees the whole plan in one place.
38 */
39export interface UninstallPlan {
40 /** The background service serves this home and will be stopped and removed. */
41 readonly service: boolean;
42 /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */
43 readonly launcher: string | undefined;
44 /** `<home>/runtime`, holding every downloaded version, when it exists. */
45 readonly runtimeDir: string | undefined;
46 /** `<home>/userdata`, which is never removed; shown so the user knows where it is. */
47 readonly userdataDir: string;
48}
49
50/**
51 * Finds the launcher this install left on PATH. Only a launcher that points
52 * into this home's `runtime/versions` is claimed: a plain copy of the
53 * executable, or a launcher for another home, is not ours to delete.
54 */
55export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: {
56 readonly launchedAs: string | undefined;
57 readonly versionsDir: string;
58}) {
59 const fs = yield* FileSystem.FileSystem;
60 const path = yield* Path.Path;
61 const platform = yield* HostProcessPlatform;
62 if (input.launchedAs === undefined) return undefined;
63 if (platform === "win32") {
64 const shimPath = yield* findWindowsShim(input.launchedAs);
65 if (shimPath === undefined) return undefined;
66 const contents = yield* fs.readFileString(shimPath).pipe(Effect.option);
67 const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined;
68 return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target)
69 ? shimPath
70 : undefined;
71 }
72 const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option);
73 if (Option.isNone(linkTarget)) return undefined;
74 const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value);
75 return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined;
76});
77
78const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: {
79 readonly baseDir: string;
80}) {
81 const fs = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const service = yield* BootService.BootService;
84 const status = yield* service.status;
85 const servesThisHome =
86 status.installedBaseDir !== undefined &&
87 path.resolve(status.installedBaseDir) === path.resolve(input.baseDir);
88 const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir);
89 const runtimeDir = path.dirname(versionsDir);
90 const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined;
91 const plan: UninstallPlan = {
92 service: status.supported && status.installed && servesThisHome,
93 launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }),
94 runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false)))
95 ? runtimeDir
96 : undefined,
97 userdataDir: path.join(input.baseDir, "userdata"),
98 };
99 return plan;
100});
101
102export const uninstallCommand = Command.make("uninstall", {
103 ...projectLocationFlags,
104 yes: Flag.boolean("yes").pipe(
105 Flag.withAlias("y"),
106 Flag.withDescription(
107 "Remove everything without asking. Required from a script, where there is no prompt.",
108 ),
109 Flag.withDefault(false),
110 ),
111}).pipe(
112 Command.withDescription(
113 "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.",
114 ),
115 Command.withHandler((flags) =>
116 Effect.gen(function* () {
117 const logLevel = yield* GlobalFlag.LogLevel;
118 const config = yield* resolveCliAuthConfig(flags, logLevel);
119 return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe(
120 Effect.provide(bootServiceLayer(config)),
121 );
122 }),
123 ),
124);
125
126const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: {
127 readonly baseDir: string;
128 readonly assumeYes: boolean;
129}) {
130 const fs = yield* FileSystem.FileSystem;
131 const platform = yield* HostProcessPlatform;
132 const environment = yield* HostProcessEnvironment;
133 const service = yield* BootService.BootService;
134 const plan = yield* planUninstall({ baseDir: input.baseDir });
135
136 if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) {
137 yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`);
138 if (!(yield* HostProcessIsExecutable)) {
139 yield* Console.log(
140 " 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).",
141 );
142 }
143 return;
144 }
145
146 yield* Console.log("This will remove:");
147 if (plan.service) yield* Console.log(" the background service (stopping it first)");
148 if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`);
149 if (plan.runtimeDir !== undefined) {
150 yield* Console.log(` every downloaded version under ${plan.runtimeDir}`);
151 }
152 yield* Console.log(
153 `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`,
154 );
155
156 if (!input.assumeYes) {
157 if (!(process.stdin.isTTY && process.stdout.isTTY)) {
158 return yield* new CliUninstallError({
159 reason:
160 "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.",
161 });
162 }
163 const confirmed = yield* Prompt.run(
164 Prompt.confirm({ message: "Remove t3 from this machine?", initial: false }),
165 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
166 if (!confirmed) {
167 yield* Console.log("Left as is.");
168 return;
169 }
170 }
171
172 if (plan.service) {
173 yield* service.uninstall;
174 yield* Console.log("Removed the background service.");
175 }
176 if (plan.launcher !== undefined) {
177 yield* fs
178 .remove(plan.launcher, { force: true })
179 .pipe(
180 Effect.mapError(
181 () =>
182 new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }),
183 ),
184 );
185 yield* Console.log(`Removed ${plan.launcher}.`);
186 }
187 if (plan.runtimeDir !== undefined) {
188 // This process runs from inside runtimeDir. POSIX unlinks a running
189 // executable fine; Windows refuses, so the tree is removed after this
190 // process exits by a detached shell, and the user is told either way.
191 if (platform === "win32") {
192 const runtimeDir = plan.runtimeDir;
193 const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe";
194 yield* Effect.try({
195 try: () => {
196 const child = NodeChildProcess.spawn(
197 comspec,
198 ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`],
199 { detached: true, stdio: "ignore", windowsHide: true },
200 );
201 child.unref();
202 },
203 catch: () =>
204 new CliUninstallError({
205 reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`,
206 }),
207 });
208 yield* Console.log(`${runtimeDir} will be removed once t3 exits.`);
209 } else {
210 yield* fs
211 .remove(plan.runtimeDir, { recursive: true, force: true })
212 .pipe(
213 Effect.mapError(
214 () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }),
215 ),
216 );
217 yield* Console.log(`Removed ${plan.runtimeDir}.`);
218 }
219 }
220 yield* Console.log("");
221 yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code.");
222});
apps/server/src/cli/update.ts+13-5
......@@ -99,6 +99,16 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
9999 return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` });
100100});
101101
102/** Whether a launcher target lives inside `<baseDir>/runtime/versions`. */
103export function launcherOwnsVersionsDir(
104 path: Path.Path,
105 versionsDir: string,
106 candidate: string,
107): boolean {
108 const relative = path.relative(versionsDir, path.resolve(candidate));
109 return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
110}
111
102112/**
103113 * The launcher the install scripts leave behind: a symlink at `<bin>/t3` on
104114 * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3`
......@@ -117,10 +127,8 @@ export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function
117127 const path = yield* Path.Path;
118128 const platform = yield* HostProcessPlatform;
119129 if (input.launchedAs === undefined) return Option.none<string>();
120 const ownsTarget = (candidate: string) => {
121 const relative = path.relative(input.versionsDir, path.resolve(candidate));
122 return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
123 };
130 const ownsTarget = (candidate: string) =>
131 launcherOwnsVersionsDir(path, input.versionsDir, candidate);
124132
125133 if (platform === "win32") {
126134 // The shim runs the executable by absolute path, so the executable sees
......@@ -189,7 +197,7 @@ export const resolveLauncherPath = Effect.gen(function* () {
189197 * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the
190198 * running executable; that is the launcher the install script wrote.
191199 */
192const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* (
200export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* (
193201 executablePath: string,
194202) {
195203 const fs = yield* FileSystem.FileSystem;
apps/server/src/cloud/pinnedRuntime.ts+5-1
......@@ -51,13 +51,17 @@ export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): {
5151 return { command: paths.entryPath, args: [] };
5252}
5353
54export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string {
55 return path.join(baseDir, PINNED_RUNTIME_DIR, "versions");
56}
57
5458export function pinnedRuntimePaths(
5559 path: Path.Path,
5660 baseDir: string,
5761 version: string,
5862 platform: NodeJS.Platform,
5963): PinnedRuntimePaths {
60 const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version);
64 const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version);
6165 return {
6266 versionDir,
6367 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
docs/user/background-service.md+6
......@@ -59,6 +59,12 @@ yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to
5959pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or
6060`--allow-downgrade` to move backwards.
6161
62`t3 uninstall` reverses the install script: it shows what it found (the
63background service, the `t3` launcher, every downloaded version under
64`~/.t3/runtime`), asks once, and removes them. Your projects, threads, and
65settings under `~/.t3/userdata` are kept; delete that directory yourself if
66you want them gone too. Pass `--yes` from a script.
67
6268## Platform support
6369
6470Linux needs systemd user services. Setup enables lingering so T3 Code starts at