1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as DateTime from "effect/DateTime";
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 Option from "effect/Option";
10import * as Path from "effect/Path";
11import { HttpClient } from "effect/http";
12import * as Schema from "effect/Schema";
13
14import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";
15
16import * as ProcessRunner from "../processRunner.ts";
17import {
18 ensurePinnedRuntimeInstalled,
19 pinnedRuntimeCommand,
20 pinnedRuntimePaths,
21 PinnedRuntimeInstallError,
22} from "./pinnedRuntime.ts";
23import {
24 SERVICE_LAUNCHER_PROTOCOL,
25 SERVICE_RESTART_PENDING_FILE,
26 SERVICE_STATE_FILE,
27 compareExactServiceVersions,
28 parseServiceState,
29 serviceStateActiveVersion,
30 serviceStateHasPendingUpdate,
31 type ServiceState,
32} from "./serviceProtocol.ts";
33
34const BOOT_SERVICE_NAME = "t3code";
35const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
36// `.service` suffix keeps the label distinct from the desktop app's bundle id
37// (com.t3tools.t3code), so launchd and TCC records never collide.
38const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service";
39const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;
40const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
41/** File in the logs dir that receives the service's stdout and stderr. `t3 triage` points agents at it. */
42export const BOOT_SERVICE_LOG_FILE = "boot-service.log";
43
44/** systemd expands `%` specifiers, including in unquoted append-log paths. */
45function escapeSystemdSpecifiers(value: string): string {
46 return value.replaceAll("%", "%%");
47}
48
49function quoteSystemdValue(value: string): string {
50 const escaped = escapeSystemdSpecifiers(value);
51 return /[\s"'\\]/.test(escaped)
52 ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
53 : escaped;
54}
55
56/**
57 * Reads `T3CODE_HOME` back out of a rendered unit or plist. Only values this
58 * file writes are expected, so a quoted systemd value is unquoted and
59 * unescaped the same way `quoteSystemdValue` produced it.
60 */
61export function bootServiceBaseDirOf(contents: string): string | undefined {
62 const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1];
63 if (systemd !== undefined) {
64 const raw = systemd.trim();
65 const unquoted =
66 raw.startsWith('"') && raw.endsWith('"')
67 ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
68 : raw;
69 return unquoted.replaceAll("%%", "%");
70 }
71 const plist = /<key>T3CODE_HOME<\/key>\s*<string>([^<]*)<\/string>/.exec(contents)?.[1];
72 if (plist !== undefined) {
73 return plist.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
74 }
75 return undefined;
76}
77
78export interface BootServicePlan {
79 /**
80 * What the service manager executes. npm-distributed runtimes run the
81 * standalone launcher script with the installing Node; archive-distributed
82 * runtimes run their own executable, which hosts the launcher as a hidden
83 * subcommand so the machine never needs Node.
84 */
85 readonly program: ReadonlyArray<string>;
86 readonly baseDir: string;
87 readonly logPath: string;
88 readonly unitPath: string;
89}
90
91/** Pure renderer: service units cannot rely on the user's shell or PATH. */
92export function renderBootServiceUnit(plan: BootServicePlan): string {
93 // The user manager has no reliable network-online target; server networking retries itself.
94 return [
95 "[Unit]",
96 "Description=T3 Code server",
97 "StartLimitIntervalSec=300",
98 "StartLimitBurst=5",
99 "",
100 "[Service]",
101 "Type=simple",
102 "WorkingDirectory=%h",
103 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
104 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
105 `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`,
106 // Let the launcher mark an explicit stop before it signals the server.
107 // systemd still SIGKILLs the whole cgroup if graceful shutdown times out.
108 "KillMode=mixed",
109 // Agents and terminals run in their own scopes in app-t3code-agents.slice
110 // (see process/agentScope.ts). Short helper commands still share this
111 // cgroup, and with the systemd default of OOMPolicy=stop, the kernel
112 // killing one of them stops the whole unit. Keep running and let
113 // Restart=always cover the main process.
114 "OOMPolicy=continue",
115 "Restart=always",
116 "RestartSec=5",
117 // The agents slice is a sibling in app.slice, so these weights keep the
118 // server responsive while agents saturate CPU or disk.
119 "CPUWeight=1000",
120 "IOWeight=1000",
121 // systemd-oomd only honors this when the cgroup it watches is owned by the
122 // same user. Distros watch user-1000.slice, which root owns, so there it is
123 // ignored. The agent scopes are what keep oomd off the server.
124 "ManagedOOMPreference=avoid",
125 `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
126 `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`,
127 "",
128 "[Install]",
129 "WantedBy=default.target",
130 "",
131 ].join("\n");
132}
133
134/** Plist values are emitted as XML text nodes; only these three need escaping. */
135function escapeXmlText(value: string): string {
136 return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
137}
138
139/** Pure renderer: launch agents cannot rely on the user's shell or PATH. */
140export function renderBootServicePlist(
141 plan: BootServicePlan,
142 options: { readonly homeDir: string; readonly environmentPath: string },
143): string {
144 // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd
145 // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever.
146 // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop
147 // completes within the launcher's 5s child grace, but a stop that queues
148 // behind an in-flight update transition can take much longer; launchd's
149 // system-defined default (5s on current macOS) would SIGKILL the launcher
150 // (and, with it, the process group) mid-handoff.
151 // ProcessType Interactive opts out of background-job resource throttling.
152 // AbandonProcessGroup stays at its default (false): launchd reaps leftover
153 // process-group members only when the launcher itself exits — the analog of
154 // KillMode=mixed's final cgroup kill — and not when the launcher restarts its
155 // child, so agent children survive server updates.
156 return [
157 `<?xml version="1.0" encoding="UTF-8"?>`,
158 `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
159 `<plist version="1.0">`,
160 `<dict>`,
161 ` <key>Label</key>`,
162 ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`,
163 ` <key>ProgramArguments</key>`,
164 ` <array>`,
165 ...plan.program.map((argument) => ` <string>${escapeXmlText(argument)}</string>`),
166 ` </array>`,
167 ` <key>EnvironmentVariables</key>`,
168 ` <dict>`,
169 ` <key>PATH</key>`,
170 ` <string>${escapeXmlText(options.environmentPath)}</string>`,
171 ` <key>T3CODE_HOME</key>`,
172 ` <string>${escapeXmlText(plan.baseDir)}</string>`,
173 ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`,
174 ` <string>${BOOT_SERVICE_PLIST_FILE}</string>`,
175 ` </dict>`,
176 ` <key>WorkingDirectory</key>`,
177 ` <string>${escapeXmlText(options.homeDir)}</string>`,
178 ` <key>RunAtLoad</key>`,
179 ` <true/>`,
180 ` <key>KeepAlive</key>`,
181 ` <true/>`,
182 ` <key>ThrottleInterval</key>`,
183 ` <integer>5</integer>`,
184 ` <key>ExitTimeOut</key>`,
185 ` <integer>90</integer>`,
186 ` <key>ProcessType</key>`,
187 ` <string>Interactive</string>`,
188 ` <key>StandardOutPath</key>`,
189 ` <string>${escapeXmlText(plan.logPath)}</string>`,
190 ` <key>StandardErrorPath</key>`,
191 ` <string>${escapeXmlText(plan.logPath)}</string>`,
192 `</dict>`,
193 `</plist>`,
194 ``,
195 ].join("\n");
196}
197
198export interface BootServiceStep {
199 readonly step: string;
200 readonly command: string;
201 readonly args: ReadonlyArray<string>;
202 /**
203 * Non-zero exit is logged and ignored. Reserved for steps whose common
204 * failures (not loaded, already enabled) leave a state a later strict step
205 * either tolerates or fails loudly on.
206 */
207 readonly optional?: boolean;
208 /** Override the ProcessRunner default (60s) for steps that block longer. */
209 readonly timeout?: Duration.Input;
210}
211
212/**
213 * Stop commands block until the service manager gives up: 90s by default for
214 * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This
215 * must stay above both, or the runner cancels the stop mid-shutdown and the
216 * next step races a still-loaded service.
217 */
218const STOP_STEP_TIMEOUT = Duration.seconds(120);
219
220/**
221 * Platform service-manager integration as data: paths, a pure renderer, and
222 * the command steps each flow runs. install/uninstall/status consume this and
223 * never branch on platform.
224 */
225export interface BootServiceManager {
226 readonly kind: "systemd" | "launchd";
227 readonly unitPath: string;
228 readonly render: (plan: BootServicePlan) => string;
229 /** Before rewriting files, when a unit is already installed. */
230 readonly stop: ReadonlyArray<BootServiceStep>;
231 /** After files are written. The last entry starts the service. */
232 readonly activate: ReadonlyArray<BootServiceStep>;
233 /** Best-effort recovery after a failed repair of an installed service. */
234 readonly restart: ReadonlyArray<BootServiceStep>;
235 /** Uninstall, before the unit file is removed. */
236 readonly deactivate: ReadonlyArray<BootServiceStep>;
237 /** Uninstall, after the unit file is removed. */
238 readonly finalize: ReadonlyArray<BootServiceStep>;
239}
240
241function systemdManager(input: {
242 readonly path: Path.Path;
243 readonly homeDir: string;
244}): BootServiceManager {
245 const unitPath = input.path.join(
246 input.homeDir,
247 ".config",
248 "systemd",
249 "user",
250 BOOT_SERVICE_UNIT_FILE,
251 );
252 return {
253 kind: "systemd",
254 unitPath,
255 render: renderBootServiceUnit,
256 stop: [
257 {
258 step: "stopping the installed service",
259 command: "systemctl",
260 args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE],
261 timeout: STOP_STEP_TIMEOUT,
262 },
263 ],
264 activate: [
265 {
266 step: "reloading systemd user units",
267 command: "systemctl",
268 args: ["--user", "daemon-reload"],
269 },
270 {
271 step: "enabling the service",
272 command: "systemctl",
273 args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE],
274 },
275 // Start last. No administrative state write occurs after this succeeds.
276 {
277 step: "starting the service",
278 command: "systemctl",
279 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
280 },
281 ],
282 restart: [
283 {
284 step: "restarting the service after a failed update",
285 command: "systemctl",
286 args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE],
287 },
288 ],
289 deactivate: [
290 {
291 step: "stopping the service",
292 command: "systemctl",
293 args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE],
294 timeout: STOP_STEP_TIMEOUT,
295 },
296 ],
297 finalize: [
298 {
299 step: "reloading systemd user units",
300 command: "systemctl",
301 args: ["--user", "daemon-reload"],
302 },
303 ],
304 };
305}
306
307function launchdManager(input: {
308 readonly path: Path.Path;
309 readonly homeDir: string;
310 readonly uid: number;
311 readonly environmentPath: string;
312}): BootServiceManager {
313 const unitPath = input.path.join(
314 input.homeDir,
315 "Library",
316 "LaunchAgents",
317 BOOT_SERVICE_PLIST_FILE,
318 );
319 const domainTarget = `gui/${input.uid}`;
320 const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`;
321 // bootout/enable are optional: they fail on not-loaded states that are fine
322 // to proceed from. The strict `bootstrap` runs last and is also the start:
323 // loading a RunAtLoad/KeepAlive plist starts the job, so a separate
324 // kickstart would kill and restart a server it just booted. A lingering job
325 // that survived bootout, or a gui domain with nobody logged in at the
326 // screen (SSH install), makes bootstrap fail the flow loudly rather than
327 // silently keeping a stale server.
328 return {
329 kind: "launchd",
330 unitPath,
331 render: (plan) =>
332 renderBootServicePlist(plan, {
333 homeDir: input.homeDir,
334 environmentPath: input.environmentPath,
335 }),
336 // Without --wait, bootout returns in milliseconds while the job drains
337 // for up to ExitTimeOut, and a bootstrap during the drain fails EIO.
338 // --wait (present on modern macOS, absent from the man page) blocks until
339 // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it.
340 stop: [
341 {
342 step: "stopping the installed launch agent",
343 command: "launchctl",
344 args: ["bootout", "--wait", serviceTarget],
345 optional: true,
346 timeout: STOP_STEP_TIMEOUT,
347 },
348 ],
349 activate: [
350 // A persisted `launchctl disable` override refuses bootstrap; clear it.
351 {
352 step: "enabling the launch agent",
353 command: "launchctl",
354 args: ["enable", serviceTarget],
355 optional: true,
356 },
357 // Start last. No administrative state write occurs after this succeeds.
358 {
359 step: "starting the service",
360 command: "launchctl",
361 args: ["bootstrap", domainTarget, unitPath],
362 },
363 ],
364 restart: [
365 {
366 step: "restarting the service after a failed update",
367 command: "launchctl",
368 args: ["bootstrap", domainTarget, unitPath],
369 },
370 ],
371 // No `launchctl disable` here: a persisted override would sabotage a
372 // later reinstall. Removing the plist is what stops the next login load.
373 // A bootout that fails for a reason other than "not loaded" leaves the
374 // job running until logout; the failure is in the boot-service log.
375 deactivate: [
376 {
377 step: "stopping the service",
378 command: "launchctl",
379 args: ["bootout", "--wait", serviceTarget],
380 optional: true,
381 timeout: STOP_STEP_TIMEOUT,
382 },
383 ],
384 finalize: [],
385 };
386}
387
388/** Undefined means this host cannot run the background service. */
389function selectBootServiceManager(input: {
390 readonly platform: NodeJS.Platform;
391 readonly homeDir: string;
392 readonly uid: number | undefined;
393 readonly path: Path.Path;
394 readonly environmentPath: string;
395}): BootServiceManager | undefined {
396 if (input.homeDir === "") {
397 return undefined;
398 }
399 if (input.platform === "linux") {
400 return systemdManager({ path: input.path, homeDir: input.homeDir });
401 }
402 if (input.platform === "darwin" && input.uid !== undefined) {
403 return launchdManager({
404 path: input.path,
405 homeDir: input.homeDir,
406 uid: input.uid,
407 environmentPath: input.environmentPath,
408 });
409 }
410 return undefined;
411}
412
413export class BootServiceUnsupportedError extends Schema.TaggedError<BootServiceUnsupportedError>()(
414 "BootServiceUnsupportedError",
415 { platform: Schema.String },
416) {
417 override get message(): string {
418 return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`;
419 }
420}
421
422export class BootServiceCommandError extends Schema.TaggedError<BootServiceCommandError>()(
423 "BootServiceCommandError",
424 {
425 step: Schema.String,
426 exitCode: Schema.optional(Schema.Number),
427 stdoutLength: Schema.optional(Schema.Number),
428 stderrLength: Schema.optional(Schema.Number),
429 cause: Schema.optional(Schema.Defect()),
430 },
431) {
432 override get message(): string {
433 return this.exitCode === undefined
434 ? `Background setup failed while ${this.step}.`
435 : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
436 }
437}
438
439export class BootServiceInstallError extends Schema.TaggedError<BootServiceInstallError>()(
440 "BootServiceInstallError",
441 { cause: Schema.Defect() },
442) {
443 override get message(): string {
444 return "Could not set up the T3 Code background service.";
445 }
446}
447
448const BootServiceProblem = Schema.Literals([
449 "user-manager-unavailable",
450 "linger-unavailable",
451 "linger-disabled",
452 "service-disabled",
453 "service-stopped",
454 "restart-pending",
455]);
456type BootServiceProblem = typeof BootServiceProblem.Type;
457
458/** These codes and recovery steps are documented in docs/user/background-service.md. */
459export function formatBootServiceProblem(problem: BootServiceProblem): string {
460 switch (problem) {
461 case "user-manager-unavailable":
462 return "Cannot reach the systemd user manager. Run `systemctl --user status` in a login session for the service user. Install your distribution's systemd user-session support if it is missing; do not run T3 with sudo.";
463 case "linger-unavailable":
464 return 'Cannot check whether this user can run services after logout. Run `loginctl show-user "$(id -un)" --property=Linger` and check that systemd-logind is available.';
465 case "linger-disabled":
466 return 'Lingering is disabled. T3 Code will stop when your last login session ends and will not start at boot. Run `sudo loginctl enable-linger "$(id -un)"` on this machine, then retry the service command as your normal user.';
467 case "service-disabled":
468 return "The service is not enabled to start automatically. Run `t3 service install` to repair it.";
469 case "service-stopped":
470 return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service install`.";
471 case "restart-pending":
472 return "A newer version is installed but the service is still running the previous one. Run `t3 service restart` to switch.";
473 }
474}
475
476export class BootServicePrerequisiteError extends Schema.TaggedError<BootServicePrerequisiteError>()(
477 "BootServicePrerequisiteError",
478 { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) },
479) {
480 override get message(): string {
481 return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`;
482 }
483}
484
485export class BootServiceUpdatePendingError extends Schema.TaggedError<BootServiceUpdatePendingError>()(
486 "BootServiceUpdatePendingError",
487 {},
488) {
489 override get message(): string {
490 return "A remote server update is still pending. Wait for it to finish, then retry.";
491 }
492}
493
494export class BootServiceDowngradeRefusedError extends Schema.TaggedError<BootServiceDowngradeRefusedError>()(
495 "BootServiceDowngradeRefusedError",
496 {
497 installedVersion: Schema.String,
498 targetVersion: Schema.String,
499 },
500) {
501 override get message(): string {
502 return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`;
503 }
504}
505
506export type BootServiceError =
507 | BootServiceUnsupportedError
508 | BootServiceCommandError
509 | BootServiceInstallError
510 | BootServicePrerequisiteError
511 | BootServiceUpdatePendingError
512 | BootServiceDowngradeRefusedError;
513
514export interface BootServiceStatus {
515 readonly supported: boolean;
516 readonly installed: boolean;
517 readonly current: boolean;
518 readonly installedVersion?: string;
519 /**
520 * The T3 home the installed unit serves. The unit name is fixed per user,
521 * so a caller working against another base dir must not treat this service
522 * as its own; `t3 update --base-dir` learned that by restarting the live
523 * server of the machine it ran on.
524 */
525 readonly installedBaseDir?: string;
526 readonly problems?: ReadonlyArray<BootServiceProblem>;
527 readonly unitPath: string;
528 readonly logPath: string;
529}
530
531export class BootService extends Context.Service<
532 BootService,
533 {
534 readonly install: (options?: {
535 readonly allowDowngrade?: boolean;
536 /**
537 * Write the unit for this version but leave the service on whatever it
538 * is running now. `t3 update` uses this when the user declines the
539 * restart, so a later `t3 service restart` lands on the new version.
540 */
541 readonly start?: boolean;
542 }) => Effect.Effect<BootServicePlan, BootServiceError>;
543 /**
544 * Stop and start the installed service on the version its unit names.
545 * Only when the unit serves this base dir: the unit name is per user, so
546 * another home's service is left alone. Resolves false when nothing was
547 * restarted.
548 */
549 readonly restart: Effect.Effect<boolean, BootServiceError>;
550 readonly uninstall: Effect.Effect<boolean, BootServiceError>;
551 readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
552 }
553>()("t3/cloud/bootService") {}
554
555export interface BootServiceHost {
556 readonly execPath: string;
557}
558
559export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
560 readonly baseDir: string;
561 readonly logsDir: string;
562 readonly cliVersion: string;
563 readonly host?: BootServiceHost;
564}) {
565 const hostExecPath = yield* HostProcess.ExecutablePath;
566 const platform = yield* HostProcess.Platform;
567 const arch = yield* HostProcess.Architecture;
568 const uid = yield* HostProcess.UserId;
569 const httpClient = yield* HttpClient.HttpClient;
570 const releaseBaseUrl = Option.getOrUndefined(
571 yield* Config.String(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
572 );
573 const homeDir = yield* Config.String("HOME").pipe(Config.withDefault(""));
574 const installerPath = yield* Config.String("PATH").pipe(Config.withDefault(""));
575 const fs = yield* FileSystem.FileSystem;
576 const path = yield* Path.Path;
577 const runner = yield* ProcessRunner.ProcessRunner;
578 const host = input.host ?? { execPath: hostExecPath };
579 const xmlSafeInstallerDirectories = installerPath.split(":").filter(
580 (directory) =>
581 directory.length > 0 &&
582 Array.from(directory).every((character) => {
583 const code = character.charCodeAt(0);
584 return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
585 }),
586 );
587 const environmentPath = Array.from(
588 new Set([
589 ...xmlSafeInstallerDirectories,
590 path.dirname(host.execPath),
591 "/opt/homebrew/bin",
592 "/usr/local/bin",
593 "/usr/bin",
594 "/bin",
595 "/usr/sbin",
596 "/sbin",
597 ]),
598 ).join(":");
599
600 const detectedManager = selectBootServiceManager({
601 platform,
602 homeDir,
603 uid,
604 path,
605 environmentPath,
606 });
607 const unitPath = detectedManager?.unitPath ?? "";
608 const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE);
609 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
610 const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
611 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);
612 const writeDurably = (filePath: string, contents: string) =>
613 Effect.scoped(
614 Effect.gen(function* () {
615 const directory = path.dirname(filePath);
616 yield* fs.makeDirectory(directory, { recursive: true });
617 const tempPath = yield* fs.makeTempFileScoped({ directory, prefix: ".service-write-" });
618 yield* fs.writeFileString(tempPath, contents, { mode: 0o600 });
619 // Opened read-write: Windows refuses to flush a handle without write access.
620 yield* (yield* fs.open(tempPath, { flag: "r+" })).sync;
621 yield* fs.rename(tempPath, filePath);
622 // Windows has no directory fsync (EPERM); NTFS journals the rename.
623 yield* (yield* fs.open(directory, { flag: "r" })).sync.pipe(
624 Effect.catchIf(
625 (error) => (error.reason.cause as NodeJS.ErrnoException | undefined)?.code === "EPERM",
626 () => Effect.void,
627 ),
628 );
629 }),
630 ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
631 // The executable hosts the launcher as a hidden subcommand of itself, so
632 // the unit runs the pinned runtime directly.
633 const plan: BootServicePlan = {
634 program: [runtimePaths.entryPath, "__service-launcher"],
635 baseDir: input.baseDir,
636 logPath,
637 unitPath,
638 };
639
640 const requireManager = Effect.suspend(() =>
641 detectedManager === undefined
642 ? new BootServiceUnsupportedError({ platform })
643 : Effect.succeed(detectedManager),
644 );
645
646 const logFailure = (error: { readonly message: string }) =>
647 DateTime.now.pipe(
648 Effect.flatMap((now) =>
649 fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" }),
650 ),
651 Effect.ignore,
652 );
653
654 const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
655 step: string,
656 command: string,
657 args: ReadonlyArray<string>,
658 options?: { readonly timeout?: Duration.Input },
659 ) {
660 return yield* runner.run({ command, args, timeout: options?.timeout }).pipe(
661 Effect.mapError((cause) => new BootServiceCommandError({ step, cause })),
662 Effect.filterOrFail(
663 (result) => result.code === 0,
664 (result) =>
665 new BootServiceCommandError({
666 step,
667 exitCode: Number(result.code),
668 stdoutLength: result.stdout.length,
669 stderrLength: result.stderr.length,
670 }),
671 ),
672 Effect.tapError(logFailure),
673 );
674 });
675
676 const runSteps = (steps: ReadonlyArray<BootServiceStep>) =>
677 Effect.forEach(
678 steps,
679 (entry) => {
680 const run = runStep(
681 entry.step,
682 entry.command,
683 entry.args,
684 entry.timeout === undefined ? undefined : { timeout: entry.timeout },
685 );
686 // runStep's tapError already appends the failure to the log, so an
687 // ignored optional step still leaves a trace.
688 return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid);
689 },
690 { discard: true },
691 );
692
693 const probe = (command: string, args: ReadonlyArray<string>) =>
694 runner.run({ command, args, timeout: Duration.seconds(5) }).pipe(Effect.option);
695 const succeeded = (result: Option.Option<ProcessRunner.ProcessRunOutput>) =>
696 Option.isSome(result) && result.value.code === 0;
697 const lingerArgs = [
698 "show-user",
699 ...(uid === undefined ? [] : [String(uid)]),
700 "--property=Linger",
701 "--value",
702 ];
703 const readSystemdProblems = Effect.fn("cloud.boot_service.read_systemd_problems")(function* (
704 includeService: boolean,
705 ) {
706 const [manager, linger] = yield* Effect.all(
707 [probe("systemctl", ["--user", "show-environment"]), probe("loginctl", lingerArgs)],
708 { concurrency: "unbounded" },
709 );
710 const problems: BootServiceProblem[] = [];
711 if (!succeeded(manager)) problems.push("user-manager-unavailable");
712 const lingering = succeeded(linger) && Option.isSome(linger) ? linger.value.stdout.trim() : "";
713 if (lingering !== "yes") {
714 problems.push(lingering === "no" ? "linger-disabled" : "linger-unavailable");
715 }
716 if (includeService && succeeded(manager)) {
717 const [enabled, active] = yield* Effect.all(
718 [
719 probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]),
720 probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]),
721 ],
722 { concurrency: "unbounded" },
723 );
724 if (
725 !succeeded(enabled) ||
726 (Option.isSome(enabled) && enabled.value.stdout.trim() !== "enabled")
727 ) {
728 problems.push("service-disabled");
729 }
730 if (!succeeded(active)) problems.push("service-stopped");
731 }
732 return problems;
733 });
734
735 const requireSystemdPrerequisites = Effect.gen(function* () {
736 const problems = yield* readSystemdProblems(false);
737 const unavailable = problems.find((problem) => problem !== "linger-disabled");
738 if (unavailable) return yield* new BootServicePrerequisiteError({ problem: unavailable });
739 if (!problems.includes("linger-disabled")) return;
740 yield* runStep("enabling lingering for this user", "loginctl", [
741 "enable-linger",
742 "--no-ask-password",
743 ...(uid === undefined ? [] : [String(uid)]),
744 ]).pipe(
745 Effect.mapError(
746 (cause) => new BootServicePrerequisiteError({ problem: "linger-disabled", cause }),
747 ),
748 );
749 const remaining = yield* readSystemdProblems(false);
750 if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] });
751 });
752
753 const install = Effect.fn("cloud.boot_service.install")(function* (options?: {
754 readonly allowDowngrade?: boolean;
755 readonly start?: boolean;
756 }) {
757 const manager = yield* requireManager;
758 yield* fs
759 .makeDirectory(input.logsDir, { recursive: true })
760 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
761
762 // A permissions failure must not leave a partial install or stop a working server.
763 if (manager.kind === "systemd") {
764 yield* requireSystemdPrerequisites.pipe(Effect.tapError(logFailure));
765 }
766
767 // Prepare every immutable artifact before stopping the installed unit.
768 yield* ensurePinnedRuntimeInstalled({
769 baseDir: input.baseDir,
770 version: input.cliVersion,
771 fs,
772 path,
773 runner,
774 httpClient,
775 platform,
776 arch,
777 releaseBaseUrl,
778 validate: (runtime) =>
779 runner
780 .run({
781 command: pinnedRuntimeCommand(runtime).command,
782 args: [...pinnedRuntimeCommand(runtime).args, "--version"],
783 timeout: Duration.seconds(30),
784 })
785 .pipe(
786 Effect.mapError(
787 (cause) =>
788 new PinnedRuntimeInstallError({
789 step: "verifying the pinned t3 runtime",
790 cause,
791 }),
792 ),
793 Effect.flatMap((result) => {
794 const reportedVersion = /\bv(\S+)\s*$/.exec(result.stdout)?.[1];
795 return result.code === 0 && reportedVersion === input.cliVersion
796 ? Effect.void
797 : Effect.fail(
798 new PinnedRuntimeInstallError({
799 step: "verifying the pinned t3 runtime",
800 exitCode: Number(result.code),
801 stdoutLength: result.stdout.length,
802 stderrLength: result.stderr.length,
803 }),
804 );
805 }),
806 ),
807 }).pipe(
808 Effect.mapError((error) =>
809 error._tag === "PinnedRuntimeInstallError"
810 ? new BootServiceCommandError({
811 step: error.step,
812 exitCode: error.exitCode,
813 stdoutLength: error.stdoutLength,
814 stderrLength: error.stderrLength,
815 cause: error,
816 })
817 : new BootServiceInstallError({ cause: error }),
818 ),
819 );
820 const installed = yield* fs
821 .exists(unitPath)
822 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
823 // With start=false the service keeps running while its files change. The
824 // launcher reads the state file once at startup and the unit only matters
825 // on the next start, so that is safe as long as the launcher is not in
826 // the middle of a remote update, which is the one time it writes the
827 // state file itself. That case is refused below, before anything is
828 // written, from the same read the downgrade check uses; the stop that
829 // normally serialises against the launcher is skipped on purpose.
830 const start = options?.start !== false;
831 if (installed && start) {
832 yield* runSteps(manager.stop);
833 }
834
835 yield* Effect.gen(function* () {
836 if (installed) {
837 const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option);
838 if (Option.isSome(previousStateText)) {
839 if (serviceStateHasPendingUpdate(previousStateText.value)) {
840 return yield* new BootServiceUpdatePendingError();
841 }
842 // A remote update can finish after the CLI checks status. Read its
843 // final version after the launcher stops and before changing files.
844 const installedVersion = serviceStateActiveVersion(previousStateText.value);
845 if (
846 installedVersion !== undefined &&
847 options?.allowDowngrade !== true &&
848 compareExactServiceVersions(input.cliVersion, installedVersion) < 0
849 ) {
850 return yield* new BootServiceDowngradeRefusedError({
851 installedVersion,
852 targetVersion: input.cliVersion,
853 });
854 }
855 }
856 }
857 yield* fs
858 .makeDirectory(path.dirname(unitPath), { recursive: true })
859 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
860 if (!start && installed) {
861 // Written first: once the files below name the new version, the
862 // running service is behind them, and a failure between the two
863 // writes must not leave it looking current. The launcher removes the
864 // marker when it starts, `restart` and a started install do too.
865 yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 });
866 }
867 yield* writeDurably(
868 statePath,
869 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.
870 `${JSON.stringify(
871 {
872 protocol: SERVICE_LAUNCHER_PROTOCOL,
873 activeVersion: input.cliVersion,
874 } satisfies ServiceState,
875 null,
876 2,
877 )}\n`,
878 );
879 if (!start && installed) {
880 // The launcher only writes this file while a remote update is in
881 // flight. One that began after the check above lands either before
882 // this write (then the launcher's copy in memory is what it keeps
883 // acting on, and its next write puts its own outcome back) or after
884 // it, which this read catches: the file no longer says what was just
885 // written, so stop here before repointing the unit.
886 const written = yield* fs.readFileString(statePath);
887 if (serviceStateActiveVersion(written) !== input.cliVersion) {
888 return yield* new BootServiceUpdatePendingError();
889 }
890 }
891 yield* writeDurably(unitPath, manager.render(plan));
892
893 if (start) {
894 yield* runSteps(manager.activate);
895 yield* fs.remove(restartPendingPath, { force: true });
896 }
897 }).pipe(
898 Effect.mapError((cause) =>
899 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
900 ),
901 Effect.tapError(() =>
902 installed && start ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void,
903 ),
904 );
905 return plan;
906 });
907
908 const restart: BootService["Service"]["restart"] = Effect.gen(function* () {
909 const manager = yield* requireManager;
910 const unit = yield* fs.readFileString(unitPath).pipe(Effect.option);
911 if (Option.isNone(unit)) return false;
912 const installedBaseDir = bootServiceBaseDirOf(unit.value);
913 if (
914 installedBaseDir === undefined ||
915 path.resolve(installedBaseDir) !== path.resolve(input.baseDir)
916 ) {
917 return false;
918 }
919 yield* runSteps(manager.stop);
920 yield* runSteps(manager.activate).pipe(
921 // Same recovery as a failed repair: a service that was running should
922 // not be left stopped because daemon-reload or enable failed.
923 Effect.tapError(() => runSteps(manager.restart).pipe(Effect.ignore)),
924 );
925 yield* fs.remove(restartPendingPath, { force: true });
926 return true;
927 }).pipe(
928 Effect.mapError((cause) =>
929 cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause,
930 ),
931 Effect.withSpan("cloud.boot_service.restart"),
932 );
933
934 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
935 const manager = yield* requireManager;
936 if (
937 !(yield* fs
938 .exists(unitPath)
939 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))))
940 )
941 return false;
942 yield* runSteps(manager.deactivate);
943 yield* fs
944 .remove(unitPath)
945 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
946 yield* runSteps(manager.finalize);
947 return true;
948 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
949
950 const status: BootService["Service"]["status"] = Effect.gen(function* () {
951 if (detectedManager === undefined) {
952 return { supported: false, installed: false, current: false, unitPath, logPath };
953 }
954 if (!(yield* fs.exists(unitPath))) {
955 return { supported: true, installed: false, current: false, unitPath, logPath };
956 }
957 const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([
958 fs.readFileString(unitPath),
959 fs.exists(runtimePaths.entryPath),
960 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
961 fs.readFileString(statePath).pipe(Effect.option),
962 ]);
963 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
964 const installedVersion = Option.isSome(stateText)
965 ? serviceStateActiveVersion(stateText.value)
966 : undefined;
967 const installedBaseDir = bootServiceBaseDirOf(unit);
968 const normalizeUnit = (contents: string) =>
969 detectedManager.kind === "launchd"
970 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
971 : contents;
972 const problems: BootServiceProblem[] =
973 detectedManager.kind === "systemd" ? [...(yield* readSystemdProblems(true))] : [];
974 if (yield* fs.exists(restartPendingPath)) problems.push("restart-pending");
975 return {
976 supported: true,
977 installed: true,
978 ...(installedVersion === undefined ? {} : { installedVersion }),
979 ...(installedBaseDir === undefined ? {} : { installedBaseDir }),
980 problems,
981 current:
982 problems.length === 0 &&
983 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
984 runtimeEntryExists &&
985 Option.isSome(runtimeSentinel) &&
986 runtimeSentinel.value.trim() === input.cliVersion &&
987 state?.activeVersion === input.cliVersion &&
988 state?.update?.status !== "pending",
989 unitPath,
990 logPath,
991 };
992 }).pipe(
993 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
994 Effect.withSpan("cloud.boot_service.status"),
995 );
996
997 return BootService.of({ install, restart, uninstall, status });
998});
999
1000export const layer = (input: {
1001 readonly baseDir: string;
1002 readonly logsDir: string;
1003 readonly cliVersion: string;
1004 readonly host?: BootServiceHost;
1005}) => Layer.effect(BootService, make(input));