1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, 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 Terminal from "effect/Terminal";
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 {
16 formatServiceStatus,
17 offerServiceDuringOnboarding,
18 reconcileService,
19 recoverServiceOnboardingOffer,
20 serviceCommand,
21} from "./service.ts";
22
23afterEach(() => vi.restoreAllMocks());
24
25const status = {
26 supported: true,
27 installed: true,
28 current: true,
29 unitPath: "/home/me/.config/systemd/user/t3code.service",
30 logPath: "/home/me/.t3/userdata/logs/boot-service.log",
31} as const;
32
33it("reports the installed service version and host paths", () => {
34 assert.equal(
35 formatServiceStatus(status, "0.0.29"),
36 [
37 "T3 Code service",
38 " Status: installed · t3@0.0.29",
39 " Unit: /home/me/.config/systemd/user/t3code.service",
40 " Logs: /home/me/.t3/userdata/logs/boot-service.log",
41 ].join("\n"),
42 );
43});
44
45it("gives a direct repair command for a stale service", () => {
46 assert.include(
47 formatServiceStatus({ ...status, current: false }, "0.0.29"),
48 "Next: Run `t3 service install` to repair it.",
49 );
50});
51
52it("explains an incomplete nightly installation and keeps repair on its installed version", () => {
53 const output = formatServiceStatus(
54 {
55 ...status,
56 current: false,
57 installedVersion: "0.0.32-nightly.1",
58 problems: ["linger-disabled", "service-stopped"],
59 },
60 "0.0.32-nightly.1",
61 );
62
63 expect(output).toContain("[linger-disabled]");
64 expect(output).toContain("last login session ends");
65 expect(output).toContain('sudo loginctl enable-linger "$(id -un)"');
66 expect(output).toContain("[service-stopped]");
67 expect(output).toContain("Run `t3 service install` to repair it.");
68 expect(output).not.toContain("npx");
69});
70
71it("points an older service at a repair, never at npx", () => {
72 const output = formatServiceStatus(
73 { ...status, current: false, installedVersion: "0.0.28" },
74 "0.0.29",
75 );
76 expect(output).toContain("Run `t3 service install` to repair it.");
77 expect(output).not.toContain("npx");
78});
79
80it("explains where the service is supported", () => {
81 assert.include(
82 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
83 "Supported on: Linux with systemd, macOS with launchd",
84 );
85});
86
87it("reports a newer installed service and tells the CLI to catch up to it", () => {
88 const output = formatServiceStatus(
89 { ...status, current: false, installedVersion: "0.0.32-nightly.1" },
90 "0.0.31",
91 );
92
93 assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)");
94 assert.include(output, "Run `t3 update 0.0.32-nightly.1` to match it");
95 assert.notInclude(output, "npx");
96});
97
98const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" };
99
100function makeTestService(serviceStatus: BootService.BootServiceStatus) {
101 const installOptions: Array<Parameters<BootService.BootService["Service"]["install"]>[0]> = [];
102 const restarts: Array<true> = [];
103 const service = BootService.BootService.of({
104 status: Effect.succeed(serviceStatus),
105 restart: Effect.sync(() => {
106 restarts.push(true);
107 return serviceStatus.installed;
108 }),
109 install: (options) =>
110 Effect.sync(() => {
111 installOptions.push(options);
112 return {
113 program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"],
114 baseDir: "/test/t3",
115 unitPath: serviceStatus.unitPath,
116 logPath: serviceStatus.logPath,
117 };
118 }),
119 uninstall: Effect.succeed(false),
120 });
121 return { service, installOptions, restarts };
122}
123
124it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => {
125 it.effect("restart restarts the installed service", () =>
126 Effect.gen(function* () {
127 const fs = yield* FileSystem.FileSystem;
128 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
129 const { service, installOptions, restarts } = makeTestService(status);
130 vi.spyOn(BootService, "layer").mockReturnValue(
131 Layer.succeed(BootService.BootService, service),
132 );
133
134 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
135 "restart",
136 "--base-dir",
137 baseDir,
138 ]).pipe(
139 Effect.provideService(HostProcess.Environment, {}),
140 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
141 );
142
143 expect(restarts).toEqual([true]);
144 expect(installOptions).toEqual([]);
145 }),
146 );
147
148 it.effect.each(["install", "update"] as const)(
149 "%s refuses a downgrade before changing the service",
150 (command) =>
151 Effect.gen(function* () {
152 const fs = yield* FileSystem.FileSystem;
153 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
154 const { service, installOptions } = makeTestService(newerServiceStatus);
155 vi.spyOn(BootService, "layer").mockReturnValue(
156 Layer.succeed(BootService.BootService, service),
157 );
158
159 const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
160 command,
161 "--base-dir",
162 baseDir,
163 ]).pipe(
164 Effect.provideService(HostProcess.Environment, {}),
165 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
166 Effect.flip,
167 );
168
169 expect(error).toMatchObject({
170 _tag: "BootServiceDowngradeRefusedError",
171 installedVersion: "999.0.0",
172 targetVersion: packageJson.version,
173 });
174 expect(installOptions).toEqual([]);
175 }),
176 );
177
178 it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) =>
179 Effect.gen(function* () {
180 const fs = yield* FileSystem.FileSystem;
181 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
182 const { service, installOptions } = makeTestService(newerServiceStatus);
183 vi.spyOn(BootService, "layer").mockReturnValue(
184 Layer.succeed(BootService.BootService, service),
185 );
186
187 yield* Command.runWith(serviceCommand, { version: packageJson.version })([
188 command,
189 "--base-dir",
190 baseDir,
191 "--allow-downgrade",
192 ]).pipe(
193 Effect.provideService(HostProcess.Environment, {}),
194 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
195 );
196
197 expect(installOptions).toEqual([{ allowDowngrade: true }]);
198 }),
199 );
200});
201
202it.effect.each([
203 { name: "a new service", state: { ...status, installed: false, current: false } },
204 { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } },
205 {
206 name: "the same version",
207 state: { ...status, current: false, installedVersion: packageJson.version },
208 },
209 {
210 name: "an incomplete install of the same version",
211 state: {
212 ...status,
213 current: false,
214 installedVersion: packageJson.version,
215 problems: ["linger-disabled"] as const,
216 },
217 },
218 { name: "an unknown version", state: { ...status, current: false } },
219])("installs or repairs $name without an override", ({ state }) =>
220 Effect.gen(function* () {
221 const { service, installOptions } = makeTestService(state);
222
223 const result = yield* reconcileService().pipe(
224 Effect.provideService(BootService.BootService, service),
225 );
226
227 expect(result.changed).toBe(true);
228 expect(installOptions).toEqual([undefined]);
229 }),
230);
231
232it.effect("leaves a newer service unchanged during onboarding without prompting", () =>
233 Effect.gen(function* () {
234 const { service, installOptions } = makeTestService(newerServiceStatus);
235 const terminal = Terminal.make({
236 columns: Effect.succeed(80),
237 rows: Effect.succeed(24),
238 readInput: Effect.die("Onboarding must not prompt to replace a newer service."),
239 readLine: Effect.die("Onboarding must not prompt to replace a newer service."),
240 display: () => Effect.die("Onboarding must not prompt to replace a newer service."),
241 });
242
243 const ready = yield* offerServiceDuringOnboarding.pipe(
244 Effect.provideService(BootService.BootService, service),
245 Effect.provideService(Terminal.Terminal, terminal),
246 Effect.provide(NodeServices.layer),
247 );
248
249 expect(ready).toBe(false);
250 expect(installOptions).toEqual([]);
251 }),
252);
253
254it.effect("keeps onboarding successful when a newer version appears before install", () =>
255 Effect.gen(function* () {
256 const ready = yield* recoverServiceOnboardingOffer(
257 Effect.fail(
258 new BootService.BootServiceDowngradeRefusedError({
259 installedVersion: "999.0.0",
260 targetVersion: packageJson.version,
261 }),
262 ),
263 );
264
265 expect(ready).toBe(false);
266 }),
267);
268
269it.effect("keeps the manual-server fallback when background prerequisites fail", () =>
270 Effect.gen(function* () {
271 const ready = yield* recoverServiceOnboardingOffer(
272 Effect.fail(new BootService.BootServicePrerequisiteError({ problem: "linger-disabled" })),
273 );
274 expect(ready).toBe(false);
275 }),
276);