import { HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; const BOOT_SERVICE_NAME = "t3code"; const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ function escapeSystemdSpecifiers(value: string): string { return value.replaceAll("%", "%%"); } function quoteSystemdValue(value: string): string { const escaped = escapeSystemdSpecifiers(value); return /[\s"'\\]/.test(escaped) ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` : escaped; } export interface BootServicePlan { readonly nodePath: string; readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; } /** Pure renderer: service units cannot rely on the user's shell or PATH. */ export function renderBootServiceUnit(plan: BootServicePlan): string { // The user manager has no reliable network-online target; server networking retries itself. return [ "[Unit]", "Description=T3 Code server", "StartLimitIntervalSec=300", "StartLimitBurst=5", "", "[Service]", "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", // Agent tool calls run as children of the server, so they share this cgroup. // With the systemd default of OOMPolicy=stop, the kernel killing one greedy // child stops the whole unit: the server, every live agent, and the user's // connection. Keep running and let Restart=always cover the main process. "OOMPolicy=continue", "Restart=always", "RestartSec=5", `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`, `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`, "", "[Install]", "WantedBy=default.target", "", ].join("\n"); } /** Plist values are emitted as XML text nodes; only these three need escaping. */ function escapeXmlText(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } /** Pure renderer: launch agents cannot rely on the user's shell or PATH. */ export function renderBootServicePlist( plan: BootServicePlan, options: { readonly homeDir: string; readonly environmentPath: string }, ): string { // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever. // ExitTimeOut 90 matches systemd's default TimeoutStopSec. A plain stop // completes within the launcher's 5s child grace, but a stop that queues // behind an in-flight update transition can take much longer; launchd's // system-defined default (5s on current macOS) would SIGKILL the launcher // (and, with it, the process group) mid-handoff. // ProcessType Interactive opts out of background-job resource throttling. // AbandonProcessGroup stays at its default (false): launchd reaps leftover // process-group members only when the launcher itself exits — the analog of // KillMode=mixed's final cgroup kill — and not when the launcher restarts its // child, so agent children survive server updates. return [ ``, ``, ``, ``, ` Label`, ` ${BOOT_SERVICE_LAUNCHD_LABEL}`, ` ProgramArguments`, ` `, ` ${escapeXmlText(plan.nodePath)}`, ` ${escapeXmlText(plan.launcherPath)}`, ` `, ` EnvironmentVariables`, ` `, ` PATH`, ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, ` ${BOOT_SERVICE_UNIT_ENV}`, ` ${BOOT_SERVICE_PLIST_FILE}`, ` `, ` WorkingDirectory`, ` ${escapeXmlText(options.homeDir)}`, ` RunAtLoad`, ` `, ` KeepAlive`, ` `, ` ThrottleInterval`, ` 5`, ` ExitTimeOut`, ` 90`, ` ProcessType`, ` Interactive`, ` StandardOutPath`, ` ${escapeXmlText(plan.logPath)}`, ` StandardErrorPath`, ` ${escapeXmlText(plan.logPath)}`, ``, ``, ``, ].join("\n"); } export interface BootServiceStep { readonly step: string; readonly command: string; readonly args: ReadonlyArray; /** * Non-zero exit is logged and ignored. Reserved for steps whose common * failures (not loaded, already enabled) leave a state a later strict step * either tolerates or fails loudly on. */ readonly optional?: boolean; /** Override the ProcessRunner default (60s) for steps that block longer. */ readonly timeout?: Duration.Input; } /** * Stop commands block until the service manager gives up: 90s by default for * systemd's TimeoutStopSec, and ExitTimeOut=90 in the rendered plist. This * must stay above both, or the runner cancels the stop mid-shutdown and the * next step races a still-loaded service. */ const STOP_STEP_TIMEOUT = Duration.seconds(120); /** * Platform service-manager integration as data: paths, a pure renderer, and * the command steps each flow runs. install/uninstall/status consume this and * never branch on platform. */ export interface BootServiceManager { readonly kind: "systemd" | "launchd"; readonly unitPath: string; readonly render: (plan: BootServicePlan) => string; /** Before rewriting files, when a unit is already installed. */ readonly stop: ReadonlyArray; /** After files are written. The last entry starts the service. */ readonly activate: ReadonlyArray; /** Best-effort recovery after a failed repair of an installed service. */ readonly restart: ReadonlyArray; /** Uninstall, before the unit file is removed. */ readonly deactivate: ReadonlyArray; /** Uninstall, after the unit file is removed. */ readonly finalize: ReadonlyArray; } function systemdManager(input: { readonly path: Path.Path; readonly homeDir: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE, ); return { kind: "systemd", unitPath, render: renderBootServiceUnit, stop: [ { step: "stopping the installed service", command: "systemctl", args: ["--user", "stop", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], activate: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, { step: "enabling the service", command: "systemctl", args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE], }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], restart: [ { step: "restarting the service after a failed update", command: "systemctl", args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], }, ], deactivate: [ { step: "stopping the service", command: "systemctl", args: ["--user", "disable", "--now", BOOT_SERVICE_UNIT_FILE], timeout: STOP_STEP_TIMEOUT, }, ], finalize: [ { step: "reloading systemd user units", command: "systemctl", args: ["--user", "daemon-reload"], }, ], }; } function launchdManager(input: { readonly path: Path.Path; readonly homeDir: string; readonly uid: number; readonly environmentPath: string; }): BootServiceManager { const unitPath = input.path.join( input.homeDir, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE, ); const domainTarget = `gui/${input.uid}`; const serviceTarget = `${domainTarget}/${BOOT_SERVICE_LAUNCHD_LABEL}`; // bootout/enable are optional: they fail on not-loaded states that are fine // to proceed from. The strict `bootstrap` runs last and is also the start: // loading a RunAtLoad/KeepAlive plist starts the job, so a separate // kickstart would kill and restart a server it just booted. A lingering job // that survived bootout, or a gui domain with nobody logged in at the // screen (SSH install), makes bootstrap fail the flow loudly rather than // silently keeping a stale server. return { kind: "launchd", unitPath, render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir, environmentPath: input.environmentPath, }), // Without --wait, bootout returns in milliseconds while the job drains // for up to ExitTimeOut, and a bootstrap during the drain fails EIO. // --wait (present on modern macOS, absent from the man page) blocks until // the job is removed from the domain; STOP_STEP_TIMEOUT outlives it. stop: [ { step: "stopping the installed launch agent", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], activate: [ // A persisted `launchctl disable` override refuses bootstrap; clear it. { step: "enabling the launch agent", command: "launchctl", args: ["enable", serviceTarget], optional: true, }, // Start last. No administrative state write occurs after this succeeds. { step: "starting the service", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], restart: [ { step: "restarting the service after a failed update", command: "launchctl", args: ["bootstrap", domainTarget, unitPath], }, ], // No `launchctl disable` here: a persisted override would sabotage a // later reinstall. Removing the plist is what stops the next login load. // A bootout that fails for a reason other than "not loaded" leaves the // job running until logout; the failure is in the boot-service log. deactivate: [ { step: "stopping the service", command: "launchctl", args: ["bootout", "--wait", serviceTarget], optional: true, timeout: STOP_STEP_TIMEOUT, }, ], finalize: [], }; } /** Undefined means this host cannot run the background service. */ function selectBootServiceManager(input: { readonly platform: NodeJS.Platform; readonly homeDir: string; readonly uid: number | undefined; readonly path: Path.Path; readonly environmentPath: string; }): BootServiceManager | undefined { if (input.homeDir === "") { return undefined; } if (input.platform === "linux") { return systemdManager({ path: input.path, homeDir: input.homeDir }); } if (input.platform === "darwin" && input.uid !== undefined) { return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid, environmentPath: input.environmentPath, }); } return undefined; } export class BootServiceUnsupportedError extends Schema.TaggedError()( "BootServiceUnsupportedError", { platform: Schema.String }, ) { override get message(): string { return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`; } } export class BootServiceCommandError extends Schema.TaggedError()( "BootServiceCommandError", { step: Schema.String, exitCode: Schema.optional(Schema.Number), stdoutLength: Schema.optional(Schema.Number), stderrLength: Schema.optional(Schema.Number), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { return this.exitCode === undefined ? `Background setup failed while ${this.step}.` : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`; } } export class BootServiceInstallError extends Schema.TaggedError()( "BootServiceInstallError", { cause: Schema.Defect() }, ) { override get message(): string { return "Could not set up the T3 Code background service."; } } const BootServiceProblem = Schema.Literals([ "user-manager-unavailable", "linger-unavailable", "linger-disabled", "service-disabled", "service-stopped", ]); type BootServiceProblem = typeof BootServiceProblem.Type; /** These codes and recovery steps are documented in docs/user/background-service.md. */ export function formatBootServiceProblem(problem: BootServiceProblem): string { switch (problem) { case "user-manager-unavailable": 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."; case "linger-unavailable": 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.'; case "linger-disabled": 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.'; case "service-disabled": return "The service is not enabled to start automatically. Run `t3 service update` to repair it."; case "service-stopped": return "The service is not running. Check the service log and `systemctl --user status t3code.service`, then run `t3 service update`."; } } export class BootServicePrerequisiteError extends Schema.TaggedError()( "BootServicePrerequisiteError", { problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) }, ) { override get message(): string { return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`; } } export class BootServiceUpdatePendingError extends Schema.TaggedError()( "BootServiceUpdatePendingError", {}, ) { override get message(): string { return "A remote server update is still pending. Wait for it to finish, then retry."; } } export class BootServiceDowngradeRefusedError extends Schema.TaggedError()( "BootServiceDowngradeRefusedError", { installedVersion: Schema.String, targetVersion: Schema.String, }, ) { override get message(): string { return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`; } } export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError | BootServicePrerequisiteError | BootServiceUpdatePendingError | BootServiceDowngradeRefusedError; export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; readonly current: boolean; readonly installedVersion?: string; readonly problems?: ReadonlyArray; readonly unitPath: string; readonly logPath: string; } export class BootService extends Context.Service< BootService, { readonly install: (options?: { readonly allowDowngrade?: boolean; }) => Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } >()("t3/cloud/bootService") {} export interface BootServiceHost { readonly execPath: string; readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { readonly baseDir: string; readonly logsDir: string; readonly cliVersion: string; readonly host?: BootServiceHost; }) { const hostExecPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const uid = yield* HostProcessUserId; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; const host = input.host ?? { execPath: hostExecPath }; const xmlSafeInstallerDirectories = installerPath.split(":").filter( (directory) => directory.length > 0 && Array.from(directory).every((character) => { const code = character.charCodeAt(0); return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d; }), ); const environmentPath = Array.from( new Set([ ...xmlSafeInstallerDirectories, path.dirname(host.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", ]), ).join(":"); const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path, environmentPath, }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); const launcherSourcePath = host.launcherSourcePath ?? path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { const directory = path.dirname(filePath); yield* fs.makeDirectory(directory, { recursive: true }); const tempPath = yield* fs.makeTempFileScoped({ directory, prefix: ".service-write-" }); yield* fs.writeFileString(tempPath, contents, { mode: 0o600 }); // Opened read-write: Windows refuses to flush a handle without write access. yield* (yield* fs.open(tempPath, { flag: "r+" })).sync; yield* fs.rename(tempPath, filePath); // Windows has no directory fsync (EPERM); NTFS journals the rename. yield* (yield* fs.open(directory, { flag: "r" })).sync.pipe( Effect.catchIf( (error) => (error.reason.cause as NodeJS.ErrnoException | undefined)?.code === "EPERM", () => Effect.void, ), ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const plan: BootServicePlan = { nodePath: host.execPath, launcherPath, baseDir: input.baseDir, logPath, unitPath, }; const requireManager = Effect.suspend(() => detectedManager === undefined ? new BootServiceUnsupportedError({ platform }) : Effect.succeed(detectedManager), ); const logFailure = (error: { readonly message: string }) => DateTime.now.pipe( Effect.flatMap((now) => fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, { flag: "a" }), ), Effect.ignore, ); const runStep = Effect.fn("cloud.boot_service.run_step")(function* ( step: string, command: string, args: ReadonlyArray, options?: { readonly timeout?: Duration.Input }, ) { return yield* runner.run({ command, args, timeout: options?.timeout }).pipe( Effect.mapError((cause) => new BootServiceCommandError({ step, cause })), Effect.filterOrFail( (result) => result.code === 0, (result) => new BootServiceCommandError({ step, exitCode: Number(result.code), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, }), ), Effect.tapError(logFailure), ); }); const runSteps = (steps: ReadonlyArray) => Effect.forEach( steps, (entry) => { const run = runStep( entry.step, entry.command, entry.args, entry.timeout === undefined ? undefined : { timeout: entry.timeout }, ); // runStep's tapError already appends the failure to the log, so an // ignored optional step still leaves a trace. return entry.optional === true ? run.pipe(Effect.ignore) : run.pipe(Effect.asVoid); }, { discard: true }, ); const probe = (command: string, args: ReadonlyArray) => runner.run({ command, args, timeout: Duration.seconds(5) }).pipe(Effect.option); const succeeded = (result: Option.Option) => Option.isSome(result) && result.value.code === 0; const lingerArgs = [ "show-user", ...(uid === undefined ? [] : [String(uid)]), "--property=Linger", "--value", ]; const readSystemdProblems = Effect.fn("cloud.boot_service.read_systemd_problems")(function* ( includeService: boolean, ) { const [manager, linger] = yield* Effect.all( [probe("systemctl", ["--user", "show-environment"]), probe("loginctl", lingerArgs)], { concurrency: "unbounded" }, ); const problems: BootServiceProblem[] = []; if (!succeeded(manager)) problems.push("user-manager-unavailable"); const lingering = succeeded(linger) && Option.isSome(linger) ? linger.value.stdout.trim() : ""; if (lingering !== "yes") { problems.push(lingering === "no" ? "linger-disabled" : "linger-unavailable"); } if (includeService && succeeded(manager)) { const [enabled, active] = yield* Effect.all( [ probe("systemctl", ["--user", "is-enabled", BOOT_SERVICE_UNIT_FILE]), probe("systemctl", ["--user", "is-active", BOOT_SERVICE_UNIT_FILE]), ], { concurrency: "unbounded" }, ); if ( !succeeded(enabled) || (Option.isSome(enabled) && enabled.value.stdout.trim() !== "enabled") ) { problems.push("service-disabled"); } if (!succeeded(active)) problems.push("service-stopped"); } return problems; }); const requireSystemdPrerequisites = Effect.gen(function* () { const problems = yield* readSystemdProblems(false); const unavailable = problems.find((problem) => problem !== "linger-disabled"); if (unavailable) return yield* new BootServicePrerequisiteError({ problem: unavailable }); if (!problems.includes("linger-disabled")) return; yield* runStep("enabling lingering for this user", "loginctl", [ "enable-linger", "--no-ask-password", ...(uid === undefined ? [] : [String(uid)]), ]).pipe( Effect.mapError( (cause) => new BootServicePrerequisiteError({ problem: "linger-disabled", cause }), ), ); const remaining = yield* readSystemdProblems(false); if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); }); const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; }) { const manager = yield* requireManager; yield* fs .makeDirectory(input.logsDir, { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); // A permissions failure must not leave a partial install or stop a working server. if (manager.kind === "systemd") { yield* requireSystemdPrerequisites.pipe(Effect.tapError(logFailure)); } // Prepare every immutable artifact before stopping the installed unit. yield* ensurePinnedRuntimeInstalled({ baseDir: input.baseDir, version: input.cliVersion, fs, path, runner, validate: (runtime) => runner .run({ command: host.execPath, args: [runtime.entryPath, "--version"], timeout: Duration.seconds(30), }) .pipe( Effect.mapError( (cause) => new PinnedRuntimeInstallError({ step: "verifying the pinned t3 runtime", cause, }), ), Effect.flatMap((result) => { const reportedVersion = /\bv(\S+)\s*$/.exec(result.stdout)?.[1]; return result.code === 0 && reportedVersion === input.cliVersion ? Effect.void : Effect.fail( new PinnedRuntimeInstallError({ step: "verifying the pinned t3 runtime", exitCode: Number(result.code), stdoutLength: result.stdout.length, stderrLength: result.stderr.length, }), ); }), ), }).pipe( Effect.mapError((error) => error._tag === "PinnedRuntimeInstallError" ? new BootServiceCommandError({ step: error.step, exitCode: error.exitCode, stdoutLength: error.stdoutLength, stderrLength: error.stderrLength, cause: error, }) : new BootServiceInstallError({ cause: error }), ), ); const launcherSource = yield* fs .readFileString(launcherSourcePath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); if (installed) { yield* runSteps(manager.stop); } yield* Effect.gen(function* () { if (installed) { const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); if (Option.isSome(previousStateText)) { if (serviceStateHasPendingUpdate(previousStateText.value)) { return yield* new BootServiceUpdatePendingError(); } // A remote update can finish after the CLI checks status. Read its // final version after the launcher stops and before changing files. const installedVersion = serviceStateActiveVersion(previousStateText.value); if ( installedVersion !== undefined && options?.allowDowngrade !== true && compareExactServiceVersions(input.cliVersion, installedVersion) < 0 ) { return yield* new BootServiceDowngradeRefusedError({ installedVersion, targetVersion: input.cliVersion, }); } } } yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); yield* writeDurably(launcherPath, launcherSource); yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. `${JSON.stringify( { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: input.cliVersion, } satisfies ServiceState, null, 2, )}\n`, ); yield* writeDurably(unitPath, manager.render(plan)); yield* runSteps(manager.activate); }).pipe( Effect.tapError(() => installed ? runSteps(manager.restart).pipe(Effect.ignore) : Effect.void, ), ); return plan; }); const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; if ( !(yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })))) ) return false; yield* runSteps(manager.deactivate); yield* fs .remove(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); yield* runSteps(manager.finalize); return true; }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); const status: BootService["Service"]["status"] = Effect.gen(function* () { if (detectedManager === undefined) { return { supported: false, installed: false, current: false, unitPath, logPath }; } if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ fs.readFileString(unitPath), fs.exists(launcherPath), fs.exists(runtimePaths.entryPath), fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) : undefined; const normalizeUnit = (contents: string) => detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") : contents; const problems = detectedManager.kind === "systemd" ? yield* readSystemdProblems(true) : []; return { supported: true, installed: true, ...(installedVersion === undefined ? {} : { installedVersion }), problems, current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && runtimeSentinel.value.trim() === input.cliVersion && state?.activeVersion === input.cliVersion && state?.update?.status !== "pending", unitPath, logPath, }; }).pipe( Effect.mapError((cause) => new BootServiceInstallError({ cause })), Effect.withSpan("cloud.boot_service.status"), ); return BootService.of({ install, uninstall, status }); }); export const layer = (input: { readonly baseDir: string; readonly logsDir: string; readonly cliVersion: string; readonly host?: BootServiceHost; }) => Layer.effect(BootService, make(input));