diff --git a/apps/server/src/cli/uninstall.test.ts b/apps/server/src/cli/uninstall.test.ts index 1e81e8b8c7a9a..d2ad673408d40 100644 --- a/apps/server/src/cli/uninstall.test.ts +++ b/apps/server/src/cli/uninstall.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { findOwnedLauncher } from "./uninstall.ts"; +import { findOwnedLauncher, spawnWindowsRemoval } 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", () => @@ -35,3 +35,26 @@ it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => { }).pipe(Effect.scoped, Effect.provideService(HostProcess.Platform, "linux")), ); }); + +it.live.runIf(HostProcess.Platform.defaultValue() === "win32")( + "t3 uninstall has cmd.exe remove the runtime directory, preserving spaces and literal environment-variable syntax", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" }); + const runtimeDir = path.join(root, "t3 home %COMSPEC% !COMSPEC! & data", "runtime"); + yield* fs.makeDirectory(path.join(runtimeDir, "versions", "1.0.0"), { recursive: true }); + yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3.exe"), ""); + + const child = spawnWindowsRemoval(process.env.ComSpec ?? "cmd.exe", runtimeDir); + yield* Effect.callback((resume) => { + child.once("error", (error) => resume(Effect.fail(error))); + child.once("exit", (code) => + resume(code === 0 ? Effect.void : Effect.die(`cmd.exe exited with ${code}`)), + ); + }); + + assert.isFalse(yield* fs.exists(runtimeDir)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/uninstall.ts b/apps/server/src/cli/uninstall.ts index 830c0cc543271..33aa63fe4798e 100644 --- a/apps/server/src/cli/uninstall.ts +++ b/apps/server/src/cli/uninstall.ts @@ -61,6 +61,27 @@ export const findOwnedLauncher = Effect.fn("cli.uninstall.find_launcher")(functi return launcherOwnsVersionsDir(path, input.versionsDir, resolved) ? input.launchedAs : undefined; }); +// cmd.exe needs verbatim quoting and a literal path, with delayed expansion disabled. +export function spawnWindowsRemoval(comspec: string, dir: string) { + return NodeChildProcess.spawn( + comspec, + [ + "/d", + "/v:off", + "/s", + "/c", + '"ping -n 3 127.0.0.1 >nul & rmdir /s /q "%T3_UNINSTALL_RUNTIME%""', + ], + { + env: { ...process.env, T3_UNINSTALL_RUNTIME: dir }, + windowsVerbatimArguments: true, + detached: true, + stdio: "ignore", + windowsHide: true, + }, + ); +} + const planUninstall = Effect.fn("cli.uninstall.plan")(function* (input: { readonly baseDir: string; }) { @@ -176,14 +197,7 @@ const runUninstall = Effect.fn("cli.uninstall.run")(function* (input: { 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(); - }, + try: () => spawnWindowsRemoval(comspec, runtimeDir).unref(), catch: () => new CliUninstallError({ reason: `Could not schedule removal of ${runtimeDir}. Delete it yourself once this window is closed.`,