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.TaggedErrorClass()(
"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.TaggedErrorClass()(
"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.TaggedErrorClass()(
"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.TaggedErrorClass()(
"BootServicePrerequisiteError",
{ problem: BootServiceProblem, cause: Schema.optional(Schema.Defect()) },
) {
override get message(): string {
return `[${this.problem}] ${formatBootServiceProblem(this.problem)}`;
}
}
export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()(
"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.TaggedErrorClass()(
"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));