From 727a5dabeff8959a2552b955368d9c265cd396e8 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:35:00 +1100 Subject: [PATCH] fix(server): t3 service uninstall leaves another T3 home's service alone Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/service.test.ts | 52 +++++++++++++++++++++++++++-- apps/server/src/cli/service.ts | 16 +++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 4358165958a1b..7ab52e82a3995 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -32,6 +32,7 @@ function makeTestService( ) { const installOptions: Array[0]> = []; const restarts: Array = []; + const uninstalls: Array = []; const service = BootService.BootService.of({ status: Effect.succeed(serviceStatus), withStopped: (operation) => operation, @@ -49,9 +50,12 @@ function makeTestService( logPath: serviceStatus.logPath, }; }), - uninstall: Effect.succeed(false), + uninstall: Effect.sync(() => { + uninstalls.push(true); + return serviceStatus.installed; + }), }); - return { service, installOptions, restarts }; + return { service, installOptions, restarts, uninstalls }; } it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { @@ -194,4 +198,48 @@ it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands expect(installOptions).toEqual([]); }), ); + + it.effect.each([ + { label: "this home's service", otherHome: false, unreadable: false, removed: [true] }, + { label: "another home's service", otherHome: true, unreadable: false, removed: [] }, + { label: "an unreadable service", otherHome: false, unreadable: true, removed: [true] }, + ])("uninstall removes only $label", (input) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, uninstalls } = makeTestService({ + ...status, + installedBaseDir: input.otherHome ? "/home/me/.t3" : baseDir, + }); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed( + BootService.BootService, + input.unreadable + ? { + ...service, + status: Effect.fail(new BootService.BootServiceInstallError({ cause: "EACCES" })), + } + : service, + ), + ); + + const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + "uninstall", + "--base-dir", + baseDir, + ]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.map((lines) => lines.join("\n")), + Effect.provideService(HostProcess.Environment, {}), + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + Layer.fresh(TestConsole.layer), + ), + ), + ); + expect(uninstalls).toEqual(input.removed); + expect(output).toContain(input.otherHome ? "another T3 home" : "Removed"); + }), + ); }); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index b78ee31c9fa4d..4ba17aa17c6de 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -278,6 +278,22 @@ const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags). flags, Effect.gen(function* () { const service = yield* BootService.BootService; + const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig.ServerConfig; + // An unreadable unit stays removable; only a unit known to serve another home is kept. + const installedBaseDir = yield* service.status.pipe( + Effect.map((status) => status.installedBaseDir), + Effect.orElseSucceed(() => undefined), + ); + if ( + installedBaseDir !== undefined && + path.resolve(installedBaseDir) !== path.resolve(baseDir) + ) { + yield* Console.log( + "The installed service runs another T3 home, so it was not removed. Pass its --base-dir.", + ); + return; + } const removed = yield* service.uninstall; yield* Console.log( removed ? "Removed the T3 Code service." : "T3 Code service is not installed.",