1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import { HttpClient } from "effect/http";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import * as ProcessRunner from "../processRunner.ts";
14import * as BootService from "./bootService.ts";
15import { pinnedRuntimePaths } from "./pinnedRuntime.ts";
16import {
17 parseServiceState,
18 SERVICE_LAUNCHER_PROTOCOL,
19 SERVICE_RESTART_PENDING_FILE,
20 serviceStateHasPendingUpdate,
21} from "./serviceProtocol.ts";
22
23const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3";
24const linuxPlan = {
25 program: [linuxRuntime, "__service-launcher"],
26 baseDir: "/home/theo/.t3",
27 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
28 unitPath: "/home/theo/.config/systemd/user/t3code.service",
29};
30
31it("runs the pinned runtime's own executable as the systemd launcher", () => {
32 const unit = BootService.renderBootServiceUnit(linuxPlan);
33
34 expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`);
35 expect(unit).toContain("KillMode=mixed");
36 expect(unit).not.toContain("node");
37});
38
39it("reads the served T3 home back out of a rendered unit or plist", () => {
40 const plan = (baseDir: string) => ({
41 program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"],
42 baseDir,
43 logPath: `${baseDir}/userdata/logs/boot-service.log`,
44 unitPath: "/home/theo/.config/systemd/user/t3code.service",
45 });
46
47 expect(
48 BootService.bootServiceBaseDirOf(BootService.renderBootServiceUnit(plan("/home/theo/.t3"))),
49 ).toBe("/home/theo/.t3");
50 // Spaces and specifiers are quoted and escaped on the way in.
51 expect(
52 BootService.bootServiceBaseDirOf(
53 BootService.renderBootServiceUnit(plan("/home/theo/T3 Data/100%")),
54 ),
55 ).toBe("/home/theo/T3 Data/100%");
56 expect(
57 BootService.bootServiceBaseDirOf(
58 BootService.renderBootServicePlist(plan("/Users/theo/a&b"), {
59 homeDir: "/Users/theo",
60 environmentPath: "/usr/bin",
61 }),
62 ),
63 ).toBe("/Users/theo/a&b");
64 expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined();
65});
66
67it("survives the kernel OOM-killing a greedy agent child", () => {
68 const unit = BootService.renderBootServiceUnit(linuxPlan);
69
70 expect(unit).toContain("OOMPolicy=continue");
71});
72
73const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3";
74const macPlan = {
75 program: [macRuntime, "__service-launcher"],
76 baseDir: "/Users/theo/.t3",
77 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
78 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
79};
80const macInstallerPath =
81 "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin";
82const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath };
83
84it("runs the pinned runtime's own executable as the launch agent", () => {
85 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
86
87 expect(plist).toContain(
88 ` <array>\n <string>${macRuntime}</string>\n <string>__service-launcher</string>\n </array>`,
89 );
90 expect(plist).not.toContain("node</string>");
91});
92
93it("preserves the installer's provider search path in the launch agent", () => {
94 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
95
96 expect(plist).toContain(` <key>PATH</key>\n <string>${macInstallerPath}</string>`);
97});
98
99it("restarts the launch agent on the systemd cadence", () => {
100 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
101
102 expect(plist).toContain("<key>RunAtLoad</key>\n <true/>");
103 expect(plist).toContain("<key>KeepAlive</key>\n <true/>");
104 expect(plist).toContain("<key>ThrottleInterval</key>\n <integer>5</integer>");
105 expect(plist).toContain("<key>ExitTimeOut</key>\n <integer>90</integer>");
106});
107
108it("appends both stdio streams to the boot service log", () => {
109 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
110
111 expect(plist).toContain(
112 "<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
113 );
114 expect(plist).toContain(
115 "<key>StandardErrorPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
116 );
117});
118
119it("escapes XML in host paths", () => {
120 const plist = BootService.renderBootServicePlist(
121 { ...macPlan, baseDir: "/Users/theo/T3 & <Co>" },
122 { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & <Scripts>:/usr/bin" },
123 );
124
125 expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>");
126 expect(plist).toContain("<string>/Users/theo/Tools &amp; &lt;Scripts&gt;:/usr/bin</string>");
127});
128
129const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
130 platform: NodeJS.Platform = "linux",
131 installerPath = macInstallerPath,
132) {
133 const fs = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;
135 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
136 const baseDir = path.join(home, ".t3");
137 const statePath = path.join(baseDir, "runtime", "service-state.json");
138 // A complete pinned runtime is already present, so install only validates
139 // it and never downloads a release archive.
140 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform);
141 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
142 yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n");
143 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
144
145 const commands: string[] = [];
146 const timeouts = new Map<string, unknown>();
147 const control: {
148 failCommand: string | undefined;
149 stateAfterStop?: string;
150 linger: string;
151 enabled: boolean;
152 active: boolean;
153 } = {
154 failCommand: undefined,
155 linger: "yes",
156 enabled: true,
157 active: true,
158 };
159 const runner = ProcessRunner.ProcessRunner.of({
160 run: Effect.fn("test.run_boot_service_command")(function* (
161 input: ProcessRunner.ProcessRunInput,
162 ) {
163 const command = `${input.command} ${input.args.join(" ")}`;
164 commands.push(command);
165 timeouts.set(command, input.timeout);
166 const failed = command === control.failCommand;
167 if (!failed && command === "loginctl enable-linger --no-ask-password 501")
168 control.linger = "yes";
169 if (!failed && command === "systemctl --user enable t3code.service") control.enabled = true;
170 if (!failed && command === "systemctl --user restart t3code.service") control.active = true;
171 if (
172 control.stateAfterStop !== undefined &&
173 (command === "systemctl --user stop t3code.service" ||
174 command.startsWith("launchctl bootout --wait "))
175 ) {
176 yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie);
177 }
178 return {
179 stdout:
180 input.args[0] === "--version"
181 ? // The runtime under test reports the version of the directory it
182 // was launched from, like the real executable.
183 `t3 v${/versions\/([^/]+)\//.exec(input.command)?.[1] ?? "1.2.3"}\n`
184 : input.command === "loginctl" && input.args[0] === "show-user"
185 ? `${control.linger}\n`
186 : input.args[1] === "is-enabled"
187 ? control.enabled
188 ? "enabled\n"
189 : "disabled\n"
190 : "",
191 stderr: "",
192 code: ChildProcessSpawner.ExitCode(
193 failed || (input.args[1] === "is-active" && !control.active) ? 1 : 0,
194 ),
195 timedOut: false,
196 stdoutTruncated: false,
197 stderrTruncated: false,
198 stdoutInvalidUtf8: false,
199 stderrInvalidUtf8: false,
200 };
201 }),
202 });
203 const makeService = (
204 environmentPath: string | undefined = installerPath,
205 cliVersion = "1.2.3",
206 serviceBaseDir = baseDir,
207 ) =>
208 Effect.gen(function* () {
209 // Every version the tests install is present and verified on disk, so
210 // install never downloads.
211 const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform);
212 yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true });
213 yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n");
214 yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`);
215 return yield* BootService.make({
216 baseDir: serviceBaseDir,
217 logsDir: path.join(serviceBaseDir, "userdata", "logs"),
218 cliVersion,
219 host: { execPath: "/usr/bin/t3" },
220 });
221 }).pipe(
222 Effect.provideService(ProcessRunner.ProcessRunner, runner),
223 Effect.provide(
224 Layer.mergeAll(
225 Layer.succeed(HostProcess.Platform, platform),
226 Layer.succeed(HostProcess.UserId, 501),
227 Layer.succeed(HostProcess.ExecutablePath, "/usr/bin/t3"),
228 Layer.succeed(
229 HttpClient.HttpClient,
230 HttpClient.make(() => Effect.die("no release download expected")),
231 ),
232 ConfigProvider.layer(
233 ConfigProvider.fromEnv({
234 env: {
235 HOME: home,
236 ...(environmentPath === undefined || environmentPath === ""
237 ? {}
238 : { PATH: environmentPath }),
239 },
240 }),
241 ),
242 ),
243 ),
244 );
245 const service = yield* makeService();
246 return { service, makeService, fs, statePath, commands, timeouts, control, runtime };
247});
248
249it.layer(NodeServices.layer)("boot service install", (it) => {
250 it.effect(
251 "fails before installing files or validating a runtime when lingering needs an administrator",
252 () =>
253 Effect.gen(function* () {
254 const { service, fs, statePath, commands, control, runtime } = yield* makeHarness();
255 const before = yield* service.status;
256 control.linger = "no";
257 control.failCommand = "loginctl enable-linger --no-ask-password 501";
258 yield* fs.remove(runtime.sentinelPath);
259
260 const error = yield* service.install().pipe(Effect.flip);
261
262 expect(error).toMatchObject({
263 _tag: "BootServicePrerequisiteError",
264 problem: "linger-disabled",
265 });
266 expect(error.message).toContain('sudo loginctl enable-linger "$(id -un)"');
267 expect(error.message).toContain("last login session ends");
268 expect(yield* fs.exists(before.unitPath)).toBe(false);
269 expect(yield* fs.exists(statePath)).toBe(false);
270 expect(commands.some((command) => command.includes("--version"))).toBe(false);
271 expect(
272 commands.some(
273 (command) => command.includes("daemon-reload") || command.includes("restart"),
274 ),
275 ).toBe(false);
276 expect(yield* fs.readFileString(before.logPath)).toContain("[linger-disabled]");
277 }),
278 );
279
280 it.effect(
281 "detects a partial install and preserves the running service when repair lacks permission",
282 () =>
283 Effect.gen(function* () {
284 const { service, fs, statePath, commands, control } = yield* makeHarness();
285 const plan = yield* service.install();
286 const before = yield* fs.readFileString(statePath);
287 const unit = yield* fs.readFileString(plan.unitPath);
288 control.linger = "no";
289 control.failCommand = "loginctl enable-linger --no-ask-password 501";
290
291 expect(yield* service.status).toMatchObject({
292 current: false,
293 problems: ["linger-disabled"],
294 });
295 commands.length = 0;
296 expect((yield* service.install().pipe(Effect.flip))._tag).toBe(
297 "BootServicePrerequisiteError",
298 );
299 expect(yield* fs.readFileString(statePath)).toBe(before);
300 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
301 expect(commands).not.toContain("systemctl --user stop t3code.service");
302 }),
303 );
304
305 it.effect("enables lingering before installing and repairs stopped or disabled services", () =>
306 Effect.gen(function* () {
307 const { service, commands, control } = yield* makeHarness();
308 control.linger = "no";
309 yield* service.install();
310 expect(control.linger).toBe("yes");
311 expect(commands.indexOf("loginctl enable-linger --no-ask-password 501")).toBeLessThan(
312 commands.indexOf("systemctl --user daemon-reload"),
313 );
314
315 control.enabled = false;
316 control.active = false;
317 expect(yield* service.status).toMatchObject({
318 current: false,
319 problems: ["service-disabled", "service-stopped"],
320 });
321 yield* service.install();
322 expect((yield* service.status).current).toBe(true);
323 }),
324 );
325
326 it.effect.each([
327 { command: "systemctl --user show-environment", problem: "user-manager-unavailable" },
328 { command: "loginctl show-user 501 --property=Linger --value", problem: "linger-unavailable" },
329 ])("reports failed prerequisite probes without installing: $command", ({ command, problem }) =>
330 Effect.gen(function* () {
331 const { service, fs, statePath, control } = yield* makeHarness();
332 control.failCommand = command;
333 expect(yield* service.install().pipe(Effect.flip)).toMatchObject({
334 _tag: "BootServicePrerequisiteError",
335 problem,
336 });
337 expect(yield* fs.exists(statePath)).toBe(false);
338 }),
339 );
340
341 it.effect("installs, reports current state, and uninstalls", () =>
342 Effect.gen(function* () {
343 const { service, fs, statePath, timeouts, runtime } = yield* makeHarness();
344 const plan = yield* service.install();
345
346 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
347 protocol: SERVICE_LAUNCHER_PROTOCOL,
348 activeVersion: "1.2.3",
349 });
350 expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]);
351 expect(yield* fs.readFileString(plan.unitPath)).toContain(
352 `ExecStart=${runtime.entryPath} __service-launcher`,
353 );
354 expect(yield* service.status).toMatchObject({
355 current: true,
356 installedVersion: "1.2.3",
357 });
358 const pendingState = JSON.stringify({
359 protocol: SERVICE_LAUNCHER_PROTOCOL,
360 activeVersion: "1.2.3",
361 update: {
362 id: "u",
363 fromVersion: "1.2.3",
364 targetVersion: "1.2.4",
365 dbPath: "/tmp/state.sqlite",
366 status: "pending",
367 },
368 });
369 yield* fs.writeFileString(statePath, pendingState);
370 expect((yield* service.status).current).toBe(false);
371 expect(yield* service.uninstall).toBe(true);
372 expect((yield* service.status).installed).toBe(false);
373 // The stop can block up to systemd's 90s TimeoutStopSec; the runner's
374 // 60s default would cancel it mid-shutdown.
375 expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
376 Duration.seconds(120),
377 );
378 }),
379 );
380
381 it.effect.each(["linux", "darwin"] as const)(
382 "reports the installed version across launcher protocols on %s",
383 (platform) =>
384 Effect.gen(function* () {
385 const { service, fs, statePath } = yield* makeHarness(platform);
386 yield* service.install();
387
388 for (const protocol of [SERVICE_LAUNCHER_PROTOCOL - 1, SERVICE_LAUNCHER_PROTOCOL + 1]) {
389 yield* fs.writeFileString(
390 statePath,
391 `{"protocol":${protocol},"activeVersion":"1.2.4-nightly.1","update":{"status":"unknown"}}`,
392 );
393 expect(yield* service.status).toMatchObject({
394 current: false,
395 installedVersion: "1.2.4-nightly.1",
396 });
397 }
398 }),
399 );
400
401 it.effect("reports an unknown version for invalid service state", () =>
402 Effect.gen(function* () {
403 const { service, fs, statePath } = yield* makeHarness();
404 yield* service.install();
405
406 for (const stateText of [
407 "{",
408 '{"activeVersion":"latest"}',
409 '{"activeVersion":"1.2"}',
410 '{"activeVersion":123}',
411 ]) {
412 yield* fs.writeFileString(statePath, stateText);
413 const status = yield* service.status;
414 expect(status.current).toBe(false);
415 expect(status.installedVersion).toBeUndefined();
416 }
417 }),
418 );
419
420 it.effect.each(["linux", "darwin"] as const)(
421 "preserves a newer version that finishes updating during stop on %s",
422 (platform) =>
423 Effect.gen(function* () {
424 const { service, fs, statePath, commands, control } = yield* makeHarness(platform);
425 const plan = yield* service.install();
426 const unit = yield* fs.readFileString(plan.unitPath);
427 control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`;
428 commands.length = 0;
429
430 const error = yield* service.install().pipe(Effect.flip);
431
432 expect(error).toMatchObject({
433 _tag: "BootServiceDowngradeRefusedError",
434 installedVersion: "1.2.4",
435 targetVersion: "1.2.3",
436 });
437 expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop);
438 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
439 expect(
440 commands.filter(
441 (command) =>
442 command.startsWith(platform === "linux" ? "systemctl " : "launchctl ") &&
443 !command.includes("show-environment"),
444 ),
445 ).toEqual(
446 platform === "linux"
447 ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"]
448 : [
449 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
450 `launchctl bootstrap gui/501 ${plan.unitPath}`,
451 ],
452 );
453 }),
454 );
455
456 it.effect("allows an explicit downgrade", () =>
457 Effect.gen(function* () {
458 const { service, fs, statePath } = yield* makeHarness();
459 yield* service.install();
460 yield* fs.writeFileString(
461 statePath,
462 `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`,
463 );
464
465 yield* service.install({ allowDowngrade: true });
466
467 expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.3");
468 expect((yield* service.status).current).toBe(true);
469 }),
470 );
471
472 it.effect("repairs versions with equal SemVer precedence without an override", () =>
473 Effect.gen(function* () {
474 const { service, fs, statePath } = yield* makeHarness();
475 yield* service.install();
476 yield* fs.writeFileString(
477 statePath,
478 `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.3+previous-build"}`,
479 );
480
481 yield* service.install();
482
483 expect((yield* service.status).current).toBe(true);
484 }),
485 );
486
487 it.effect("install with start=false rewrites the files and marks a restart pending", () =>
488 Effect.gen(function* () {
489 const { service, fs, statePath, commands, makeService } = yield* makeHarness();
490 yield* service.install();
491 commands.length = 0;
492
493 const newer = yield* makeService(undefined, "1.2.4");
494 const plan = yield* newer.install({ start: false });
495
496 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
497 protocol: SERVICE_LAUNCHER_PROTOCOL,
498 activeVersion: "1.2.4",
499 });
500 expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
501 expect(
502 commands.filter(
503 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
504 ),
505 ).toEqual([]);
506 // The files say 1.2.4 but the process is still 1.2.3: not current, and
507 // the reason is named so `t3 service status` can point at restart.
508 const status = yield* newer.status;
509 expect(status.current).toBe(false);
510 expect(status.problems).toContain("restart-pending");
511
512 commands.length = 0;
513 expect(yield* newer.restart).toBe(true);
514 expect((yield* newer.status).problems).not.toContain("restart-pending");
515 expect((yield* newer.status).current).toBe(true);
516 }),
517 );
518
519 it.effect("install with start=false keeps the marker when a later write fails", () =>
520 Effect.gen(function* () {
521 const { service, fs, statePath, makeService } = yield* makeHarness();
522 const path = yield* Path.Path;
523 yield* service.install();
524 const newer = yield* makeService(undefined, "1.2.4");
525 // A non-empty directory in the unit's place: it still counts as an
526 // installed unit, and the rename that writes the new unit fails.
527 const unitPath = (yield* service.status).unitPath;
528 yield* fs.remove(unitPath);
529 yield* fs.makeDirectory(unitPath);
530 yield* fs.writeFileString(path.join(unitPath, "occupied"), "");
531
532 const error = yield* newer.install({ start: false }).pipe(Effect.flip);
533 expect(error._tag).toBe("BootServiceInstallError");
534 expect(
535 yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
536 ).toBe(true);
537 }),
538 );
539
540 it.effect("install with start=false refuses while a remote update is pending", () =>
541 Effect.gen(function* () {
542 const { service, fs, statePath } = yield* makeHarness();
543 yield* service.install();
544 const pendingState = JSON.stringify({
545 protocol: SERVICE_LAUNCHER_PROTOCOL,
546 activeVersion: "1.2.3",
547 update: {
548 id: "u",
549 fromVersion: "1.2.3",
550 targetVersion: "1.2.4",
551 dbPath: "/tmp/state.sqlite",
552 status: "pending",
553 },
554 });
555 yield* fs.writeFileString(statePath, pendingState);
556
557 const error = yield* service.install({ start: false }).pipe(Effect.flip);
558 expect(error._tag).toBe("BootServiceUpdatePendingError");
559 expect(yield* fs.readFileString(statePath)).toBe(pendingState);
560 }),
561 );
562
563 it.effect("restart stops and starts an installed service, and is a no-op otherwise", () =>
564 Effect.gen(function* () {
565 const { service, commands } = yield* makeHarness();
566 expect(yield* service.restart).toBe(false);
567 yield* service.install();
568 commands.length = 0;
569
570 expect(yield* service.restart).toBe(true);
571 expect(
572 commands.filter(
573 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
574 ),
575 ).toEqual([
576 "systemctl --user stop t3code.service",
577 "systemctl --user daemon-reload",
578 "systemctl --user enable t3code.service",
579 "systemctl --user restart t3code.service",
580 ]);
581 }),
582 );
583
584 it.effect("restart leaves a service that serves another T3 home alone", () =>
585 Effect.gen(function* () {
586 const { service, fs, commands, makeService } = yield* makeHarness();
587 yield* service.install();
588 commands.length = 0;
589 const path = yield* Path.Path;
590 const otherHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-other-home-" });
591
592 const other = yield* makeService(undefined, "1.2.3", path.join(otherHome, ".t3"));
593 expect(yield* other.restart).toBe(false);
594 expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([]);
595 }),
596 );
597
598 it.effect("restart brings the service back when activation fails", () =>
599 Effect.gen(function* () {
600 const { service, commands, control } = yield* makeHarness();
601 yield* service.install();
602 commands.length = 0;
603 control.failCommand = "systemctl --user daemon-reload";
604
605 const error = yield* service.restart.pipe(Effect.flip);
606 expect(error._tag).toBe("BootServiceCommandError");
607 expect(
608 commands.filter(
609 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
610 ),
611 ).toEqual([
612 "systemctl --user stop t3code.service",
613 "systemctl --user daemon-reload",
614 "systemctl --user restart t3code.service",
615 ]);
616 }),
617 );
618
619 it.effect("restarts an installed service when repair fails", () =>
620 Effect.gen(function* () {
621 const { service, commands, control } = yield* makeHarness();
622 yield* service.install();
623 commands.length = 0;
624 control.failCommand = "systemctl --user daemon-reload";
625
626 const error = yield* service.install().pipe(Effect.flip);
627 expect(error._tag).toBe("BootServiceCommandError");
628 expect(
629 commands.filter(
630 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
631 ),
632 ).toEqual([
633 "systemctl --user stop t3code.service",
634 "systemctl --user daemon-reload",
635 "systemctl --user restart t3code.service",
636 ]);
637 }),
638 );
639
640 it.effect("restarts without overwriting a pending remote update", () =>
641 Effect.gen(function* () {
642 const { service, fs, statePath, commands } = yield* makeHarness();
643 yield* service.install();
644 const pendingState = JSON.stringify({
645 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
646 activeVersion: "1.2.3",
647 update: {
648 id: "remote-update",
649 fromVersion: "1.2.3",
650 targetVersion: "1.2.4",
651 status: "pending",
652 },
653 });
654 yield* fs.writeFileString(statePath, pendingState);
655 for (const allowDowngrade of [false, true]) {
656 commands.length = 0;
657
658 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
659 "BootServiceUpdatePendingError",
660 );
661 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
662 expect(
663 commands.filter(
664 (command) => command.startsWith("systemctl ") && !command.includes("show-environment"),
665 ),
666 ).toEqual([
667 "systemctl --user stop t3code.service",
668 "systemctl --user restart t3code.service",
669 ]);
670 }
671 }),
672 );
673
674 it.effect("fails closed on Windows", () =>
675 Effect.gen(function* () {
676 const { service } = yield* makeHarness("win32");
677 expect((yield* service.status).supported).toBe(false);
678 expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError");
679 }),
680 );
681
682 it.effect("installs, reports current state, and uninstalls on macOS", () =>
683 Effect.gen(function* () {
684 const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin");
685 const path = yield* Path.Path;
686 const plan = yield* service.install();
687
688 expect(
689 plan.unitPath.endsWith(
690 path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"),
691 ),
692 ).toBe(true);
693 expect(yield* fs.readFileString(plan.unitPath)).toContain(
694 ` <key>PATH</key>\n <string>${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin</string>`,
695 );
696 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
697 protocol: SERVICE_LAUNCHER_PROTOCOL,
698 activeVersion: "1.2.3",
699 });
700 expect(yield* fs.readFileString(plan.unitPath)).toContain(
701 ` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`,
702 );
703 expect(yield* service.status).toMatchObject({
704 current: true,
705 installedVersion: "1.2.3",
706 });
707 expect(yield* service.uninstall).toBe(true);
708 expect((yield* service.status).installed).toBe(false);
709 expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
710 // A bootout can block up to the plist's 90s ExitTimeOut; the runner's
711 // 60s default would cancel it and let bootstrap race a loaded job.
712 expect(timeouts.get("launchctl bootout --wait gui/501/com.t3tools.t3code.service")).toEqual(
713 Duration.seconds(120),
714 );
715 }),
716 );
717
718 it.effect("restarts the launch agent when repair fails", () =>
719 Effect.gen(function* () {
720 const { service, commands, control } = yield* makeHarness("darwin");
721 yield* service.install();
722 const plistPath = (yield* service.status).unitPath;
723 commands.length = 0;
724 control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`;
725
726 const error = yield* service.install().pipe(Effect.flip);
727 expect(error._tag).toBe("BootServiceCommandError");
728 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
729 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
730 "launchctl enable gui/501/com.t3tools.t3code.service",
731 `launchctl bootstrap gui/501 ${plistPath}`,
732 `launchctl bootstrap gui/501 ${plistPath}`,
733 ]);
734 }),
735 );
736
737 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
738 Effect.gen(function* () {
739 const { service, fs } = yield* makeHarness("darwin", "");
740 const plan = yield* service.install();
741
742 expect(yield* fs.readFileString(plan.unitPath)).toContain(
743 " <key>PATH</key>\n <string>/usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",
744 );
745 expect((yield* service.status).current).toBe(true);
746 }),
747 );
748
749 it.effect("adds missing provider directories to a minimal installer PATH", () =>
750 Effect.gen(function* () {
751 const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin");
752 const plan = yield* service.install();
753
754 expect(yield* fs.readFileString(plan.unitPath)).toContain(
755 " <key>PATH</key>\n <string>/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin</string>",
756 );
757 expect((yield* service.status).current).toBe(true);
758 }),
759 );
760
761 it.effect("keeps an installed launch agent current when the process PATH changes", () =>
762 Effect.gen(function* () {
763 const { service, makeService } = yield* makeHarness("darwin");
764 yield* service.install();
765
766 const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin");
767 expect((yield* restartedService.status).current).toBe(true);
768 }),
769 );
770
771 it.effect("drops PATH directories that cannot be represented in a launch agent plist", () =>
772 Effect.gen(function* () {
773 const { service, fs } = yield* makeHarness(
774 "darwin",
775 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
776 );
777 const plan = yield* service.install();
778 const plist = yield* fs.readFileString(plan.unitPath);
779
780 expect(plist).toContain(
781 " <key>PATH</key>\n <string>/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",
782 );
783 expect(plist).not.toContain("\u0001");
784 expect((yield* service.status).current).toBe(true);
785 }),
786 );
787
788 it.effect("ignores a bootout for an agent that is not loaded", () =>
789 Effect.gen(function* () {
790 const { service, control } = yield* makeHarness("darwin");
791 yield* service.install();
792 control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service";
793
794 yield* service.install();
795 expect((yield* service.status).current).toBe(true);
796 }),
797 );
798
799 it.effect("restarts without overwriting a pending remote update on macOS", () =>
800 Effect.gen(function* () {
801 const { service, fs, statePath, commands } = yield* makeHarness("darwin");
802 yield* service.install();
803 const plistPath = (yield* service.status).unitPath;
804 const pendingState = JSON.stringify({
805 protocol: SERVICE_LAUNCHER_PROTOCOL - 1,
806 activeVersion: "1.2.3",
807 update: {
808 id: "remote-update",
809 fromVersion: "1.2.3",
810 targetVersion: "1.2.4",
811 status: "pending",
812 },
813 });
814 yield* fs.writeFileString(statePath, pendingState);
815 for (const allowDowngrade of [false, true]) {
816 commands.length = 0;
817
818 expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe(
819 "BootServiceUpdatePendingError",
820 );
821 expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true);
822 expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([
823 "launchctl bootout --wait gui/501/com.t3tools.t3code.service",
824 `launchctl bootstrap gui/501 ${plistPath}`,
825 ]);
826 }
827 }),
828 );
829});