| 1 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 2 | import * as Config from "effect/Config"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as DateTime from "effect/DateTime"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import { HttpClient } from "effect/http"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | |
| 14 | import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; |
| 15 | |
| 16 | import * as ProcessRunner from "../processRunner.ts"; |
| 17 | import { |
| 18 | ensurePinnedRuntimeInstalled, |
| 19 | pinnedRuntimeCommand, |
| 20 | pinnedRuntimePaths, |
| 21 | PinnedRuntimeInstallError, |
| 22 | } from "./pinnedRuntime.ts"; |
| 23 | import { |
| 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 | |
| 34 | const BOOT_SERVICE_NAME = "t3code"; |
| 35 | const 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. |
| 38 | const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; |
| 39 | const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; |
| 40 | const 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. */ |
| 42 | export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; |
| 43 | |
| 44 | /** systemd expands `%` specifiers, including in unquoted append-log paths. */ |
| 45 | function escapeSystemdSpecifiers(value: string): string { |
| 46 | return value.replaceAll("%", "%%"); |
| 47 | } |
| 48 | |
| 49 | function 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 | */ |
| 61 | export 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 | |
| 78 | export 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. */ |
| 92 | export 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. */ |
| 135 | function 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. */ |
| 140 | export 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 | |
| 198 | export 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 | */ |
| 218 | const 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 | */ |
| 225 | export 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 | |
| 241 | function 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 | |
| 307 | function 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. */ |
| 389 | function 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 | |
| 413 | export 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 | |
| 422 | export 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 | |
| 439 | export 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 | |
| 448 | const BootServiceProblem = Schema.Literals([ |
| 449 | "user-manager-unavailable", |
| 450 | "linger-unavailable", |
| 451 | "linger-disabled", |
| 452 | "service-disabled", |
| 453 | "service-stopped", |
| 454 | "restart-pending", |
| 455 | ]); |
| 456 | type BootServiceProblem = typeof BootServiceProblem.Type; |
| 457 | |
| 458 | /** These codes and recovery steps are documented in docs/user/background-service.md. */ |
| 459 | export 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 | |
| 476 | export 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 | |
| 485 | export 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 | |
| 494 | export 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 | |
| 506 | export type BootServiceError = |
| 507 | | BootServiceUnsupportedError |
| 508 | | BootServiceCommandError |
| 509 | | BootServiceInstallError |
| 510 | | BootServicePrerequisiteError |
| 511 | | BootServiceUpdatePendingError |
| 512 | | BootServiceDowngradeRefusedError; |
| 513 | |
| 514 | export 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 | |
| 531 | export 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 | |
| 555 | export interface BootServiceHost { |
| 556 | readonly execPath: string; |
| 557 | } |
| 558 | |
| 559 | export 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 | |
| 1000 | export 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)); |