diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index d037a6687738..1ec78ff2e173 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { uninstallCommand } from "./cli/uninstall.ts"; import { updateCommand } from "./cli/update.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => projectCommand, serviceCommand, updateCommand, + uninstallCommand, serviceLauncherCommand, claudeHistoryCommand, servicePreflightCommand, diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts new file mode 100644 index 000000000000..02860f529440 --- /dev/null +++ b/apps/server/src/cli/uninstall.test.ts @@ -0,0 +1,37 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { findOwnedLauncher } from "./uninstall.ts"; + +it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { + it.effect("claims only a launcher that points into this home's runtime tree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); + const versionsDir = path.join(root, "runtime/versions"); + const exe = path.join(versionsDir, "1.0.0/t3"); + const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3"); + const copy = path.join(root, "copy/t3"); + for (const file of [exe, otherExe, copy]) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, ""); + } + const ours = path.join(root, "bin/t3"); + const theirs = path.join(root, "other/bin/t3"); + yield* fs.makeDirectory(path.dirname(ours), { recursive: true }); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.symlink(exe, ours); + yield* fs.symlink(otherExe, theirs); + + assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir })); + assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir })); + }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")), + ); +}); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts new file mode 100644 index 000000000000..655f02b8c815 --- /dev/null +++ b/apps/server/src/cli/uninstall.ts @@ -0,0 +1,222 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The Windows cleanup shell must outlive this process (it deletes the +// directory this executable runs from), which Effect's scoped ChildProcess +// cannot express: it kills the child when the scope closes. +import * as NodeChildProcess from "node:child_process"; + +import { + HostProcessEnvironment, + HostProcessIsExecutable, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; + +import * as BootService from "../cloud/bootService.ts"; +import { pinnedRuntimeVersionsDir } from "../cloud/pinnedRuntime.ts"; +import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { bootServiceLayer } from "./service.ts"; +import { findWindowsShim, launcherOwnsVersionsDir, resolveLauncherPath } from "./update.ts"; + +export class CliUninstallError extends Schema.TaggedError()( + "CliUninstallError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * What `t3 uninstall` would remove for one T3 home. Computed before anything + * is touched so the user sees the whole plan in one place. + */ +export interface UninstallPlan { + /** The background service serves this home and will be stopped and removed. */ + readonly service: boolean; + /** The `t3` launcher (symlink or `.cmd` shim) that points into this home's runtime tree. */ + readonly launcher: string | undefined; + /** `/runtime`, holding every downloaded version, when it exists. */ + readonly runtimeDir: string | undefined; + /** `/userdata`, which is never removed; shown so the user knows where it is. */ + readonly userdataDir: string; +} + +/** + * Finds the launcher this install left on PATH. Only a launcher that points + * into this home's `runtime/versions` is claimed: a plain copy of the + * executable, or a launcher for another home, is not ours to delete. + */ +export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(function* (input: { + readonly launchedAs: string | undefined; + readonly versionsDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + if (input.launchedAs === undefined) return undefined; + if (platform === "win32") { + const shimPath = yield* findWindowsShim(input.launchedAs); + if (shimPath === undefined) return undefined; + const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); + const target = Option.isSome(contents) ? /^"([^"]+)"/m.exec(contents.value)?.[1] : undefined; + return target !== undefined && launcherOwnsVersionsDir(path, input.versionsDir, target) + ? shimPath + : undefined; + } + const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); + if (Option.isNone(linkTarget)) return undefined; + const resolved = path.resolve(path.dirname(input.launchedAs), linkTarget.value); + return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; +}); + +const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { + readonly baseDir: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const service = yield* BootService.BootService; + const status = yield* service.status; + const servesThisHome = + status.installedBaseDir !== undefined && + path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); + const versionsDir = pinnedRuntimeVersionsDir(path, input.baseDir); + const runtimeDir = path.dirname(versionsDir); + const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined; + const plan: UninstallPlan = { + service: status.supported && status.installed && servesThisHome, + launcher: yield* findOwnedLauncher({ launchedAs, versionsDir }), + runtimeDir: (yield* fs.exists(runtimeDir).pipe(Effect.orElseSucceed(() => false))) + ? runtimeDir + : undefined, + userdataDir: path.join(input.baseDir, "userdata"), + }; + return plan; +}); + +export const uninstallCommand = Command.make("uninstall", { + ...projectLocationFlags, + yes: Flag.boolean("yes").pipe( + Flag.withAlias("y"), + Flag.withDescription( + "Remove everything without asking. Required from a script, where there is no prompt.", + ), + Flag.withDefault(false), + ), +}).pipe( + Command.withDescription( + "Remove t3 from this machine: the background service, the launcher, and every downloaded version. Your projects and threads are kept.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + return yield* runUninstall({ baseDir: config.baseDir, assumeYes: flags.yes }).pipe( + Effect.provide(bootServiceLayer(config)), + ); + }), + ), +); + +const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { + readonly baseDir: string; + readonly assumeYes: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const environment = yield* HostProcessEnvironment; + const service = yield* BootService.BootService; + const plan = yield* planUninstall({ baseDir: input.baseDir }); + + if (!plan.service && plan.launcher === undefined && plan.runtimeDir === undefined) { + yield* Console.log(`Nothing to remove: t3 is not installed for ${input.baseDir}.`); + if (!(yield* HostProcessIsExecutable)) { + yield* Console.log( + " 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).", + ); + } + return; + } + + yield* Console.log("This will remove:"); + if (plan.service) yield* Console.log(" the background service (stopping it first)"); + if (plan.launcher !== undefined) yield* Console.log(` the launcher at ${plan.launcher}`); + if (plan.runtimeDir !== undefined) { + yield* Console.log(` every downloaded version under ${plan.runtimeDir}`); + } + yield* Console.log( + `Your projects, threads, and settings under ${plan.userdataDir} are kept. Delete that directory yourself if you want them gone too.`, + ); + + if (!input.assumeYes) { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return yield* new CliUninstallError({ + reason: + "Not a terminal, so nothing was removed. Rerun with --yes to confirm from a script.", + }); + } + const confirmed = yield* Prompt.run( + Prompt.confirm({ message: "Remove t3 from this machine?", initial: false }), + ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false))); + if (!confirmed) { + yield* Console.log("Left as is."); + return; + } + } + + if (plan.service) { + yield* service.uninstall; + yield* Console.log("Removed the background service."); + } + if (plan.launcher !== undefined) { + yield* fs + .remove(plan.launcher, { force: true }) + .pipe( + Effect.mapError( + () => + new CliUninstallError({ reason: `Could not remove the launcher at ${plan.launcher}.` }), + ), + ); + yield* Console.log(`Removed ${plan.launcher}.`); + } + if (plan.runtimeDir !== undefined) { + // This process runs from inside runtimeDir. POSIX unlinks a running + // executable fine; Windows refuses, so the tree is removed after this + // process exits by a detached shell, and the user is told either way. + if (platform === "win32") { + const runtimeDir = plan.runtimeDir; + const comspec = environment["ComSpec"] ?? environment["COMSPEC"] ?? "cmd.exe"; + yield* Effect.try({ + try: () => { + const child = NodeChildProcess.spawn( + comspec, + ["/d", "/c", `ping -n 3 127.0.0.1 >nul & rmdir /s /q "${runtimeDir}"`], + { detached: true, stdio: "ignore", windowsHide: true }, + ); + child.unref(); + }, + catch: () => + new CliUninstallError({ + reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`, + }), + }); + yield* Console.log(`${runtimeDir} will be removed once t3 exits.`); + } else { + yield* fs + .remove(plan.runtimeDir, { recursive: true, force: true }) + .pipe( + Effect.mapError( + () => new CliUninstallError({ reason: `Could not remove ${plan.runtimeDir}.` }), + ), + ); + yield* Console.log(`Removed ${plan.runtimeDir}.`); + } + } + yield* Console.log(""); + yield* Console.log("t3 is uninstalled. Thanks for trying T3 Code."); +}); diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 62a66b985022..9edbc30d96b0 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -99,6 +99,16 @@ const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); }); +/** Whether a launcher target lives inside `/runtime/versions`. */ +export function launcherOwnsVersionsDir( + path: Path.Path, + versionsDir: string, + candidate: string, +): boolean { + const relative = path.relative(versionsDir, path.resolve(candidate)); + return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); +} + /** * The launcher the install scripts leave behind: a symlink at `/t3` on * 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 const path = yield* Path.Path; const platform = yield* HostProcessPlatform; if (input.launchedAs === undefined) return Option.none(); - const ownsTarget = (candidate: string) => { - const relative = path.relative(input.versionsDir, path.resolve(candidate)); - return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); - }; + const ownsTarget = (candidate: string) => + launcherOwnsVersionsDir(path, input.versionsDir, candidate); if (platform === "win32") { // The shim runs the executable by absolute path, so the executable sees @@ -189,7 +197,7 @@ export const resolveLauncherPath = Effect.gen(function* () { * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the * running executable; that is the launcher the install script wrote. */ -const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( +export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( executablePath: string, ) { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 686d5cec9d2a..680d80e46cd1 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -51,13 +51,17 @@ export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { return { command: paths.entryPath, args: [] }; } +export function pinnedRuntimeVersionsDir(path: Path.Path, baseDir: string): string { + return path.join(baseDir, PINNED_RUNTIME_DIR, "versions"); +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, version: string, platform: NodeJS.Platform, ): PinnedRuntimePaths { - const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + const versionDir = path.join(pinnedRuntimeVersionsDir(path, baseDir), version); return { versionDir, entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 86b022cf1245..7eca6329f325 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -59,6 +59,12 @@ yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or `--allow-downgrade` to move backwards. +`t3 uninstall` reverses the install script: it shows what it found (the +background service, the `t3` launcher, every downloaded version under +`~/.t3/runtime`), asks once, and removes them. Your projects, threads, and +settings under `~/.t3/userdata` are kept; delete that directory yourself if +you want them gone too. Pass `--yes` from a script. + ## Platform support Linux needs systemd user services. Setup enables lingering so T3 Code starts at