Open History to see the last commit
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as NetService from "@t3tools/shared/Net";
5import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as TestConsole from "effect/testing/TestConsole";
10import { Command } from "effect/cli";
11import { afterEach, vi } from "vite-plus/test";
12
13import packageJson from "../../package.json" with { type: "json" };
14import * as BootService from "../cloud/bootService.ts";
15import { serviceCommand } from "./service.ts";
16
17afterEach(() => vi.restoreAllMocks());
18
19const status = {
20 supported: true,
21 installed: true,
22 current: true,
23 unitPath: "/home/me/.config/systemd/user/t3code.service",
24 logPath: "/home/me/.t3/userdata/logs/boot-service.log",
25} as const;
26
27const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
28
29function makeTestService(
30 serviceStatus: BootService.BootServiceStatus,
31 restartOutcome: "restarted" | "queued" = "restarted",
32) {
33 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];
34 const restarts: Array<true> = [];
35 const uninstalls: Array<true> = [];
36 const service = BootService.BootService.of({
37 status: Effect.succeed(serviceStatus),
38 withStopped: (operation) => operation,
39 restart: Effect.sync(() => {
40 restarts.push(true);
41 return serviceStatus.installed ? restartOutcome : "skipped";
42 }),
43 install: (options) =>
44 Effect.sync(() => {
45 installOptions.push(options);
46 return {
47 program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"],
48 baseDir: "/test/t3",
49 unitPath: serviceStatus.unitPath,
50 logPath: serviceStatus.logPath,
51 };
52 }),
53 uninstall: Effect.sync(() => {
54 uninstalls.push(true);
55 return serviceStatus.installed;
56 }),
57 });
58 return { service, installOptions, restarts, uninstalls };
59}
60
61it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
62 it.effect.each(
63 (["install", "update"] as const).flatMap((command) => [
64 {
65 command,
66 label: "a downgrade",
67 status: newerServiceStatus,
68 error: {
69 _tag: "BootServiceDowngradeRefusedError",
70 installedVersion: "999.0.0",
71 targetVersion: packageJson.version,
72 },
73 },
74 // This home's newer state must not ask for --allow-downgrade first.
75 {
76 command,
77 label: "another home's service",
78 status: { ...newerServiceStatus, otherBaseDir: "/srv/t3" },
79 error: { _tag: "BootServiceOtherBaseDirError", installedBaseDir: "/srv/t3" },
80 },
81 ]),
82 )(
83 "$command refuses $label before changing the service",
84 ({ command, status: serviceStatus, error: expected }) =>
85 Effect.gen(function* () {
86 const fs = yield* FileSystem.FileSystem;
87 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
88 const { service, installOptions } = makeTestService(serviceStatus);
89 vi.spyOn(BootService, "layer").mockReturnValue(
90 Layer.succeed(BootService.BootService, service),
91 );
92
93 const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
94 command,
95 "--base-dir",
96 baseDir,
97 ]).pipe(
98 Effect.provideService(HostProcess.Environment, {}),
99 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
100 Effect.flip,
101 );
102
103 expect(error).toMatchObject(expected);
104 expect(installOptions).toEqual([]);
105 }),
106 );
107
108 it.effect.each([
109 { label: "a new service", installed: false, env: "", expected: "T3CODE_LAN=false\n" },
110 // Services installed before T3CODE_LAN existed keep listening on the local network.
111 { label: "an older service", installed: true, env: "", expected: "" },
112 {
113 label: "a set host",
114 installed: false,
115 env: "T3CODE_HOST=::\n",
116 expected: "T3CODE_HOST=::\n",
117 },
118 {
119 label: "a quoted multiline value",
120 installed: false,
121 env: 'CUSTOM="first\nT3CODE_LAN=example\nlast"\n',
122 expected: 'CUSTOM="first\nT3CODE_LAN=example\nlast"\nT3CODE_LAN=false\n',
123 },
124 ])("install keeps $label off the local network only when it is new", (input) =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
128 yield* fs.writeFileString(`${baseDir}/service.env`, input.env);
129 const { service } = makeTestService({
130 ...status,
131 installed: input.installed,
132 current: false,
133 });
134 vi.spyOn(BootService, "layer").mockReturnValue(
135 Layer.succeed(BootService.BootService, service),
136 );
137
138 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
139 "install",
140 "--base-dir",
141 baseDir,
142 ]).pipe(
143 Effect.provideService(HostProcess.Environment, {}),
144 Effect.provide(
145 Layer.mergeAll(
146 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
147 Layer.fresh(TestConsole.layer),
148 ),
149 ),
150 );
151 expect(yield* fs.readFileString(`${baseDir}/service.env`)).toBe(input.expected);
152 }),
153 );
154
155 it.effect("protect refuses root before creating any T3 directories", () =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
159 const baseDir = `${parent}/t3`;
160 vi.spyOn(BootService, "layer").mockReturnValue(
161 Layer.succeed(BootService.BootService, makeTestService(status).service),
162 );
163
164 const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
165 "protect",
166 "--base-dir",
167 baseDir,
168 ]).pipe(
169 Effect.andThen(TestConsole.logLines),
170 Effect.map((lines) => lines.join("\n")),
171 Effect.provideService(HostProcess.Environment, {}),
172 Effect.provideService(HostProcess.Platform, "linux"),
173 Effect.provideService(HostProcess.UserId, 0),
174 Effect.provide(
175 Layer.mergeAll(
176 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
177 Layer.fresh(TestConsole.layer),
178 ),
179 ),
180 );
181 expect(output).toContain("without sudo");
182 expect(yield* fs.exists(baseDir)).toBe(false);
183 }),
184 );
185
186 // regression: #13239 a stale nvm-installed CLI reported "already using" its own version
187 it.effect.each(["install", "update"] as const)(
188 "%s names the CLI that found nothing to change",
189 (command) =>
190 Effect.gen(function* () {
191 const fs = yield* FileSystem.FileSystem;
192 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
193 const { service, installOptions } = makeTestService(status);
194 vi.spyOn(BootService, "layer").mockReturnValue(
195 Layer.succeed(BootService.BootService, service),
196 );
197 const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node";
198 const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs";
199
200 const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
201 command,
202 "--base-dir",
203 baseDir,
204 ]).pipe(
205 Effect.andThen(TestConsole.logLines),
206 Effect.map((lines) => lines.join("\n")),
207 Effect.provideService(HostProcess.Environment, {}),
208 Effect.provideService(HostProcess.IsExecutable, false),
209 Effect.provideService(HostProcess.ExecutablePath, node),
210 Effect.provideService(HostProcess.Arguments, [node, script, "service", command]),
211 Effect.provide(
212 Layer.mergeAll(
213 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
214 Layer.fresh(TestConsole.layer),
215 ),
216 ),
217 );
218 expect(output).toContain(`t3@${packageJson.version}`);
219 expect(output).toContain(script);
220 expect(installOptions).toEqual([]);
221 }),
222 );
223
224 it.effect.each([
225 { label: "this home's service", otherHome: false, unreadable: false, removed: [true] },
226 { label: "another home's service", otherHome: true, unreadable: false, removed: [] },
227 { label: "an unreadable service", otherHome: false, unreadable: true, removed: [true] },
228 ])("uninstall removes only $label", (input) =>
229 Effect.gen(function* () {
230 const fs = yield* FileSystem.FileSystem;
231 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
232 const { service, uninstalls } = makeTestService({
233 ...status,
234 installedBaseDir: input.otherHome ? "/home/me/.t3" : baseDir,
235 });
236 vi.spyOn(BootService, "layer").mockReturnValue(
237 Layer.succeed(
238 BootService.BootService,
239 input.unreadable
240 ? {
241 ...service,
242 status: Effect.fail(new BootService.BootServiceInstallError({ cause: "EACCES" })),
243 }
244 : service,
245 ),
246 );
247
248 const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
249 "uninstall",
250 "--base-dir",
251 baseDir,
252 ]).pipe(
253 Effect.andThen(TestConsole.logLines),
254 Effect.map((lines) => lines.join("\n")),
255 Effect.provideService(HostProcess.Environment, {}),
256 Effect.provide(
257 Layer.mergeAll(
258 ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
259 Layer.fresh(TestConsole.layer),
260 ),
261 ),
262 );
263 expect(uninstalls).toEqual(input.removed);
264 expect(output).toContain(input.otherHome ? "--base-dir '/home/me/.t3'" : "Removed");
265 }),
266 );
267});