1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Config from "effect/Config";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as TestConsole from "effect/testing/TestConsole";
10import { Command } from "effect/cli";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import * as NetService from "@t3tools/shared/Net";
13import { afterEach, vi } from "vite-plus/test";
14
15import packageJson from "../../package.json" with { type: "json" };
16import * as BootService from "../cloud/bootService.ts";
17import {
18 findOwnedLauncher,
19 findRecordedLaunchers,
20 spawnWindowsRemoval,
21 uninstallCommand,
22} from "./uninstall.ts";
23
24afterEach(() => vi.restoreAllMocks());
25
26it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("t3 uninstall command", (it) => {
27 it.effect("names another home's service it leaves installed", () =>
28 Effect.gen(function* () {
29 const fs = yield* FileSystem.FileSystem;
30 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-cli-test-" });
31 const uninstalls: Array<true> = [];
32 vi.spyOn(BootService, "layer").mockReturnValue(
33 Layer.succeed(
34 BootService.BootService,
35 BootService.BootService.of({
36 status: Effect.succeed({
37 supported: true,
38 installed: true,
39 current: true,
40 installedBaseDir: "/home/me/.t3",
41 unitPath: "/home/me/.config/systemd/user/t3code.service",
42 logPath: "/home/me/.t3/userdata/logs/boot-service.log",
43 }),
44 withStopped: (operation) => operation,
45 restart: Effect.die("unexpected restart"),
46 install: () => Effect.die("unexpected install"),
47 uninstall: Effect.sync(() => {
48 uninstalls.push(true);
49 return true;
50 }),
51 }),
52 ),
53 );
54
55 const output = yield* Command.runWith(uninstallCommand, { version: packageJson.version })([
56 "--base-dir",
57 baseDir,
58 "--yes",
59 ]).pipe(
60 Effect.andThen(TestConsole.logLines),
61 Effect.map((lines) => lines.join("\n")),
62 Effect.provideService(HostProcess.Environment, {}),
63 Effect.provideService(HostProcess.IsExecutable, false),
64 Effect.provide(
65 Layer.mergeAll(
66 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
67 Layer.fresh(TestConsole.layer),
68 ),
69 ),
70 );
71 assert.deepStrictEqual(uninstalls, []);
72 assert.include(output, "--base-dir '/home/me/.t3'");
73 }),
74 );
75});
76
77it.layer(NodeServices.layer)("t3 uninstall launcher", (it) => {
78 it.effect("claims only a launcher that points into this home's runtime tree", () =>
79 Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const path = yield* Path.Path;
82 // macOS spells the temp folder /var but resolves it to /private/var.
83 const root = yield* fs
84 .makeTempDirectoryScoped({ prefix: "t3-uninstall-" })
85 .pipe(Effect.flatMap(fs.realPath));
86 const versionsDir = path.join(root, "runtime/versions");
87 const exe = path.join(versionsDir, "1.0.0/t3");
88 const otherExe = path.join(root, "other/runtime/versions/1.0.0/t3");
89 const copy = path.join(root, "copy/t3");
90 for (const file of [exe, otherExe, copy]) {
91 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
92 yield* fs.writeFileString(file, "");
93 }
94 const ours = path.join(root, "bin/t3");
95 const theirs = path.join(root, "other/bin/t3");
96 yield* fs.makeDirectory(path.dirname(ours), { recursive: true });
97 yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
98 yield* fs.symlink(exe, ours);
99 yield* fs.symlink(otherExe, theirs);
100
101 assert.equal(yield* findOwnedLauncher({ launchedAs: ours, versionsDir }), ours);
102 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: theirs, versionsDir }));
103 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: copy, versionsDir }));
104 assert.isUndefined(yield* findOwnedLauncher({ launchedAs: undefined, versionsDir }));
105
106 // On Windows, only the shim exactly as written is ours; a user's wrapper stays.
107 const shimDir = path.join(root, "win-bin");
108 const shim = path.join(shimDir, "t3.cmd");
109 yield* fs.makeDirectory(shimDir);
110 const windowsOwner = (contents: string) =>
111 fs
112 .writeFileString(shim, contents)
113 .pipe(
114 Effect.andThen(findOwnedLauncher({ launchedAs: exe, versionsDir })),
115 Effect.provideService(HostProcess.Platform, "win32"),
116 Effect.provideService(HostProcess.Environment, { PATH: shimDir }),
117 );
118 assert.equal(yield* windowsOwner(`@echo off\r\n"${exe}" %*`), shim);
119 assert.isUndefined(yield* windowsOwner(`@echo off\r\nset FOO=1\r\n"${exe}" %*`));
120
121 // npx can run another version than the installed one, so uninstall also reads the
122 // recorded launchers: through a symlinked home, and dangling after a prune.
123 const throughAlias = path.join(root, "bin/t3-alias");
124 const pruned = path.join(root, "bin/t3-pruned");
125 yield* fs.symlink(root, path.join(root, "alias"));
126 yield* fs.symlink(path.join(root, "alias/runtime/versions/1.0.0/t3"), throughAlias);
127 yield* fs.symlink(path.join(versionsDir, "0.9.0/t3"), pruned);
128 // Another program inside a version is not a launcher.
129 const sibling = path.join(root, "bin/t3-sibling");
130 yield* fs.symlink(path.join(versionsDir, "1.0.0/node"), sibling);
131 yield* fs.writeFileString(
132 path.join(root, "runtime/launchers"),
133 [ours, theirs, copy, throughAlias, pruned, sibling, path.join(root, "missing")].join("\n") +
134 "\n",
135 );
136 for (const dir of [versionsDir, path.join(root, "alias/runtime/versions")]) {
137 assert.deepStrictEqual(yield* findRecordedLaunchers(dir), [ours, throughAlias, pruned]);
138 }
139
140 // On Windows only an unedited t3.cmd is ours; a hand-edited wrapper belongs to the user.
141 const cmd = path.join(root, "bin/t3.cmd");
142 const edited = path.join(root, "bin/edited.cmd");
143 yield* fs.writeFileString(cmd, `@echo off\r\n"${exe}" %*`);
144 yield* fs.writeFileString(edited, `@echo off\r\nset "T3CODE_HOME=D:\\other"\r\n"${exe}" %*`);
145 yield* fs.writeFileString(path.join(root, "runtime/launchers"), `${cmd}\r\n${edited}\r\n`);
146 assert.deepStrictEqual(
147 yield* findRecordedLaunchers(versionsDir).pipe(
148 Effect.provideService(HostProcess.Platform, "win32"),
149 ),
150 [cmd],
151 );
152 }).pipe(Effect.provideService(HostProcess.Platform, "linux")),
153 );
154});
155
156it.live.runIf(HostProcess.Platform.defaultValue() === "win32")(
157 "t3 uninstall has cmd.exe remove the runtime directory, preserving spaces and literal environment-variable syntax",
158 () =>
159 Effect.gen(function* () {
160 const fs = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-uninstall-" });
163 const runtimeDir = path.join(root, "t3 home %COMSPEC% !COMSPEC! & data", "runtime");
164 yield* fs.makeDirectory(path.join(runtimeDir, "versions", "1.0.0"), { recursive: true });
165 yield* fs.writeFileString(path.join(runtimeDir, "versions", "1.0.0", "t3.exe"), "");
166
167 const child = spawnWindowsRemoval(
168 yield* Config.String("ComSpec").pipe(Config.withDefault("cmd.exe")),
169 runtimeDir,
170 );
171 yield* Effect.callback<void, Error>((resume) => {
172 child.once("error", (error) => resume(Effect.fail(error)));
173 child.once("exit", (code) =>
174 resume(code === 0 ? Effect.void : Effect.die(`cmd.exe exited with ${code}`)),
175 );
176 });
177
178 assert.isFalse(yield* fs.exists(runtimeDir));
179 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
180);