History

feat(connect): keep macOS hosts reachable after reboot

Theo Browne committed 3 months ago

Commit ec3c5f61 parent 90f391312 files · +690 −91Browse files

Unified · Split

12 files
  1. apps/server/src/atomicWrite.ts +22 −0
  2. apps/server/src/cli/connect.ts +1 −1
  3. apps/server/src/cli/service.test.ts +3 −3
  4. apps/server/src/cli/service.ts +3 −4
  5. apps/server/src/cloud/bootService.test.ts +142 −2
  6. apps/server/src/cloud/bootService.ts +255 −35
  7. apps/server/src/cloud/selfUpdate.test.ts +133 −16
  8. apps/server/src/cloud/selfUpdate.ts +89 −7
  9. docs/architecture/server-updates.md +21 −16
  10. docs/user/background-service.md +12 −5
  11. packages/contracts/src/environment.ts +2 −2
  12. packages/shared/src/hostProcess.ts +7 −0
apps/server/src/atomicWrite.ts+22−0
@@ -23,3 +23,25 @@ export const writeFileStringAtomically = (input: { · 22 unmodified lines
2323 yield* fs.rename(tempPath, input.filePath);
2424 }),
2525 );
26+
27+export const writeSymbolicLinkAtomically = (input: {
28+ readonly linkPath: string;
29+ readonly targetPath: string;
30+}) =>
31+ Effect.scoped(
32+ Effect.gen(function* () {
33+ const fs = yield* FileSystem.FileSystem;
34+ const path = yield* Path.Path;
35+ const targetDirectory = path.dirname(input.linkPath);
36+
37+ yield* fs.makeDirectory(targetDirectory, { recursive: true });
38+ const tempDirectory = yield* fs.makeTempDirectoryScoped({
39+ directory: targetDirectory,
40+ prefix: `${path.basename(input.linkPath)}.`,
41+ });
42+ const tempPath = path.join(tempDirectory, "link.tmp");
43+
44+ yield* fs.symlink(input.targetPath, tempPath);
45+ yield* fs.rename(tempPath, input.linkPath);
46+ }),
47+ );
apps/server/src/cli/connect.ts+1−1
@@ -695,7 +695,7 @@ export const connectCommand = Command.make("connect", { · 694 unmodified lines
695695 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
696696 if (background) {
697697 yield* Console.log(
698− "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out.",
698+ "\n✓ Background service ready\n\nT3 Code will start automatically and stay reachable in the background.",
699699 );
700700 return;
701701 }
apps/server/src/cli/service.test.ts+3−3
@@ -16,7 +16,7 @@ it("reports the installed service version and host paths", () => { · 15 unmodified lines
1616 [
1717 "T3 Code service",
1818 " Status: installed · t3@0.0.29",
19− " Unit: /home/me/.config/systemd/user/t3code.service",
19+ " Definition: /home/me/.config/systemd/user/t3code.service",
2020 " Logs: /home/me/.t3/userdata/logs/boot-service.log",
2121 ].join("\n"),
2222 );
@@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { · 6 unmodified lines
2929 );
3030 });
3131
32−it("explains service availability without systemd", () => {
32+it("explains service availability on unsupported platforms", () => {
3333 assert.include(
3434 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),
35− "Supported on: Linux with systemd",
35+ "Supported on: macOS with launchd or Linux with systemd",
3636 );
3737 });
apps/server/src/cli/service.ts+3−4
@@ -48,7 +48,7 @@ export function formatServiceStatus( · 47 unmodified lines
4848 cliVersion: string,
4949 ): string {
5050 if (!status.supported) {
51− return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd";
51+ return "T3 Code service\n Status: unavailable on this machine\n Supported on: macOS with launchd or Linux with systemd";
5252 }
5353 if (!status.installed) {
5454 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
@@ -56,7 +56,7 @@ export function formatServiceStatus( · 1 unmodified line
5656 return [
5757 "T3 Code service",
5858 ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,
59− ` Unit: ${status.unitPath}`,
59+ ` Definition: ${status.unitPath}`,
6060 ` Logs: ${status.logPath}`,
6161 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
6262 ].join("\n");
@@ -156,8 +156,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { · 93 unmodified lines
156156 Prompt.confirm({
157157 message: installed
158158 ? "The installed T3 Code service needs an update or repair. Update it now?"
159− : "Run T3 Code in the background whenever this machine boots? " +
160− "It stays reachable through T3 Connect even after you log out.",
159+ : "Start T3 Code automatically and keep this machine reachable in the background?",
161160 initial: true,
162161 }),
163162 );
apps/server/src/cloud/bootService.test.ts+142−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
1212 HostProcessArguments,
1313 HostProcessExecutablePath,
1414 HostProcessPlatform,
15+ HostProcessUserId,
1516 } from "@t3tools/shared/hostProcess";
1617
1718 import { reconcileService } from "../cli/service.ts";
@@ -64,6 +65,7 @@ const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => · 46 unmodified lines
6465 Effect.provide(
6566 Layer.mergeAll(
6667 Layer.succeed(HostProcessPlatform, platform),
68+ Layer.succeed(HostProcessUserId, 501),
6769 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
6870 ),
6971 );
@@ -143,6 +145,34 @@ it("quotes systemd values containing spaces and escapes percent specifiers", () · 73 unmodified lines
143145 assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");
144146 });
145147
148+it("renders a restartable per-user macOS LaunchAgent", () => {
149+ const launchAgent = BootService.renderBootServiceLaunchAgent({
150+ nodePath: "/opt/homebrew/bin/node",
151+ t3EntryPath: "/Users/theo/T3 & Data/runtime/service/current/node_modules/t3/dist/bin.mjs",
152+ baseDir: "/Users/theo/T3 & Data",
153+ logPath: "/Users/theo/T3 & Data/userdata/logs/boot-service.log",
154+ unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.server.plist",
155+ pathEnvironment: "/opt/homebrew/bin:/usr/bin:/bin",
156+ });
157+
158+ assert.include(launchAgent, "<string>com.t3tools.t3code.server</string>");
159+ assert.include(launchAgent, "<key>RunAtLoad</key>\n <true/>");
160+ assert.include(launchAgent, "<key>KeepAlive</key>\n <true/>");
161+ assert.include(launchAgent, "<string>/Users/theo/T3 &amp; Data</string>");
162+ assert.include(
163+ launchAgent,
164+ "<string>/Users/theo/T3 &amp; Data/runtime/service/current/node_modules/t3/dist/bin.mjs</string>",
165+ );
166+ assert.include(
167+ launchAgent,
168+ "<key>T3_BOOT_SERVICE_UNIT</key>\n <string>com.t3tools.t3code.server</string>",
169+ );
170+ assert.include(
171+ launchAgent,
172+ "<key>PATH</key>\n <string>/opt/homebrew/bin:/usr/bin:/bin</string>",
173+ );
174+});
175+
146176 it("flags package-manager cache entry points as ephemeral", () => {
147177 assert.isTrue(
148178 BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
@@ -388,7 +418,7 @@ it.layer(NodeServices.layer)("BootService", (it) => { · 239 unmodified lines
388418 }),
389419 );
390420
391− it.effect("fails on non-Linux platforms without touching the filesystem", () =>
421+ it.effect("installs and starts a pinned macOS LaunchAgent", () =>
392422 Effect.gen(function* () {
393423 const { dirs, fs, path } = yield* makeTestContext();
394424 const commands: Array<RecordedCommand> = [];
@@ -396,12 +426,122 @@ it.layer(NodeServices.layer)("BootService", (it) => { · 1 unmodified line
396426 baseDir: dirs.baseDir,
397427 logsDir: dirs.logsDir,
398428 cliVersion: "0.0.27",
399− host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
429+ host: makeHost(dirs.stableEntry),
400430 }).pipe(
401431 Effect.provide(makeRecordingRunnerLayer(commands)),
402432 provideHostRefs(dirs.home, "darwin"),
403433 );
404434
435+ const plan = yield* service.install;
436+ const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
437+ const activeRuntimeLink = path.join(dirs.baseDir, "runtime", "service", "current");
438+ const launchAgentPath = path.join(
439+ dirs.home,
440+ "Library",
441+ "LaunchAgents",
442+ "com.t3tools.t3code.server.plist",
443+ );
444+
445+ assert.equal(
446+ plan.t3EntryPath,
447+ path.join(activeRuntimeLink, "node_modules", "t3", "dist", "bin.mjs"),
448+ );
449+ assert.equal(yield* fs.readLink(activeRuntimeLink), runtimeDir);
450+ assert.deepEqual(
451+ commands.map((entry) => [entry.command, ...entry.args].join(" ")),
452+ [
453+ `npm install --prefix ${runtimeDir} --no-fund --no-audit t3@0.0.27`,
454+ "/bin/launchctl enable gui/501/com.t3tools.t3code.server",
455+ "/bin/launchctl bootout gui/501/com.t3tools.t3code.server",
456+ `/bin/launchctl bootstrap gui/501 ${launchAgentPath}`,
457+ "/bin/launchctl print gui/501/com.t3tools.t3code.server",
458+ ],
459+ );
460+
461+ const plist = yield* fs.readFileString(launchAgentPath);
462+ assert.include(plist, `<string>${plan.t3EntryPath}</string>`);
463+ assert.notInclude(plist, dirs.stableEntry);
464+
465+ // The recording npm runner does not materialize files, so complete the
466+ // managed runtime before checking the current-state contract.
467+ yield* fs.makeDirectory(path.dirname(path.join(runtimeDir, "node_modules/t3/dist/bin.mjs")), {
468+ recursive: true,
469+ });
470+ yield* fs.writeFileString(
471+ path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"),
472+ "#!/usr/bin/env node\n",
473+ );
474+ const status = yield* service.status;
475+ assert.isTrue(status.supported);
476+ assert.isTrue(status.installed);
477+ assert.isTrue(status.current);
478+
479+ assert.isTrue(yield* service.uninstall);
480+ assert.isFalse(yield* fs.exists(launchAgentPath));
481+ }),
482+ );
483+
484+ it.effect("restores the previous macOS runtime when LaunchAgent activation fails", () =>
485+ Effect.gen(function* () {
486+ const { dirs, fs, path } = yield* makeTestContext();
487+ const initialCommands: Array<RecordedCommand> = [];
488+ const initialService = yield* BootService.make({
489+ baseDir: dirs.baseDir,
490+ logsDir: dirs.logsDir,
491+ cliVersion: "0.0.27",
492+ host: makeHost(dirs.stableEntry),
493+ }).pipe(
494+ Effect.provide(makeRecordingRunnerLayer(initialCommands)),
495+ provideHostRefs(dirs.home, "darwin"),
496+ );
497+ yield* initialService.install;
498+
499+ const activeRuntimeLink = path.join(dirs.baseDir, "runtime", "service", "current");
500+ const previousRuntime = yield* fs.readLink(activeRuntimeLink);
501+ const launchAgentPath = path.join(
502+ dirs.home,
503+ "Library",
504+ "LaunchAgents",
505+ "com.t3tools.t3code.server.plist",
506+ );
507+ const previousLaunchAgent = yield* fs.readFileString(launchAgentPath);
508+ const repairCommands: Array<RecordedCommand> = [];
509+ const repairService = yield* BootService.make({
510+ baseDir: dirs.baseDir,
511+ logsDir: dirs.logsDir,
512+ cliVersion: "0.0.28",
513+ host: makeHost(dirs.stableEntry),
514+ }).pipe(
515+ Effect.provide(
516+ makeRecordingRunnerLayer(repairCommands, {
517+ failWhen: (command, args) => command === "/bin/launchctl" && args.includes("bootstrap"),
518+ }),
519+ ),
520+ provideHostRefs(dirs.home, "darwin"),
521+ );
522+
523+ const error = yield* repairService.install.pipe(Effect.flip);
524+
525+ assert.isTrue(isCommandError(error));
526+ assert.equal(yield* fs.readLink(activeRuntimeLink), previousRuntime);
527+ assert.equal(yield* fs.readFileString(launchAgentPath), previousLaunchAgent);
528+ }),
529+ );
530+
531+ it.effect("fails on unsupported platforms without touching the filesystem", () =>
532+ Effect.gen(function* () {
533+ const { dirs, fs, path } = yield* makeTestContext();
534+ const commands: Array<RecordedCommand> = [];
535+ const service = yield* BootService.make({
536+ baseDir: dirs.baseDir,
537+ logsDir: dirs.logsDir,
538+ cliVersion: "0.0.27",
539+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
540+ }).pipe(
541+ Effect.provide(makeRecordingRunnerLayer(commands)),
542+ provideHostRefs(dirs.home, "win32"),
543+ );
544+
405545 const error = yield* service.install.pipe(Effect.flip);
406546 assert.isTrue(isUnsupportedError(error));
407547 assert.lengthOf(commands, 0);
apps/server/src/cloud/bootService.ts+255−35
@@ -11,16 +11,19 @@ import * as Schema from "effect/Schema"; · 10 unmodified lines
1111
1212 import {
1313 HostProcessArguments,
14+ HostProcessEnvironment,
1415 HostProcessExecutablePath,
1516 HostProcessPlatform,
17+ HostProcessUserId,
1618 } from "@t3tools/shared/hostProcess";
1719
20+import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts";
1821 import * as ProcessRunner from "../processRunner.ts";
1922 import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts";
2023
2124 /**
22− * Installs T3 Code as a per-user boot service. Linux-only for now: systemd
23− * user unit + loginctl enable-linger. The service runs a stable or pinned
25+ * Installs T3 Code as a per-user background service: systemd + linger on
26+ * Linux, or a LaunchAgent on macOS. The service runs a stable or pinned
2427 * runtime — never an ephemeral `npx t3` cache whose eviction could break
2528 * startup.
2629 */
@@ -29,6 +32,8 @@ const BOOT_SERVICE_NAME = "t3code"; · 2 unmodified lines
2932
3033 export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
3134 export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
35+export const BOOT_SERVICE_LAUNCH_AGENT_LABEL = "com.t3tools.t3code.server";
36+export const BOOT_SERVICE_LAUNCH_AGENT_FILE = `${BOOT_SERVICE_LAUNCH_AGENT_LABEL}.plist`;
3237
3338 const EPHEMERAL_CACHE_SEGMENTS = [
3439 "/_npx/", // npx
@@ -76,6 +81,39 @@ export interface BootServicePlan { · 41 unmodified lines
7681 readonly baseDir: string;
7782 readonly logPath: string;
7883 readonly unitPath: string;
84+ /** Captured for launchd, whose default PATH omits common CLI install locations. */
85+ readonly pathEnvironment?: string;
86+}
87+
88+export interface LaunchAgentPaths {
89+ readonly unitPath: string;
90+ readonly activeRuntimeLinkPath: string;
91+ readonly activeEntryPath: string;
92+ readonly serviceTarget: string;
93+}
94+
95+export function launchAgentPaths(
96+ path: Path.Path,
97+ homeDir: string,
98+ baseDir: string,
99+ userId: number,
100+): LaunchAgentPaths {
101+ const activeRuntimeLinkPath = path.join(baseDir, "runtime", "service", "current");
102+ return {
103+ unitPath: path.join(homeDir, "Library", "LaunchAgents", BOOT_SERVICE_LAUNCH_AGENT_FILE),
104+ activeRuntimeLinkPath,
105+ activeEntryPath: path.join(activeRuntimeLinkPath, "node_modules", "t3", "dist", "bin.mjs"),
106+ serviceTarget: `gui/${String(userId)}/${BOOT_SERVICE_LAUNCH_AGENT_LABEL}`,
107+ };
108+}
109+
110+export function escapeLaunchdXml(value: string): string {
111+ return value
112+ .replaceAll("&", "&amp;")
113+ .replaceAll("<", "&lt;")
114+ .replaceAll(">", "&gt;")
115+ .replaceAll('"', "&quot;")
116+ .replaceAll("'", "&apos;");
79117 }
80118
81119 /**
@@ -114,12 +152,61 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { · 32 unmodified lines
114152 ].join("\n");
115153 }
116154
155+/**
156+ * LaunchAgents are loaded for a user's GUI session after login. KeepAlive
157+ * restarts crashes, while the stable entry path lets remote updates switch
158+ * exact runtimes without rewriting or unloading the plist from inside the
159+ * process it owns.
160+ */
161+export function renderBootServiceLaunchAgent(plan: BootServicePlan): string {
162+ const value = escapeLaunchdXml;
163+ return [
164+ '<?xml version="1.0" encoding="UTF-8"?>',
165+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
166+ '<plist version="1.0">',
167+ "<dict>",
168+ " <key>Label</key>",
169+ ` <string>${BOOT_SERVICE_LAUNCH_AGENT_LABEL}</string>`,
170+ " <key>ProgramArguments</key>",
171+ " <array>",
172+ ` <string>${value(plan.nodePath)}</string>`,
173+ ` <string>${value(plan.t3EntryPath)}</string>`,
174+ " <string>serve</string>",
175+ " </array>",
176+ " <key>EnvironmentVariables</key>",
177+ " <dict>",
178+ " <key>T3CODE_HOME</key>",
179+ ` <string>${value(plan.baseDir)}</string>`,
180+ ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`,
181+ ` <string>${BOOT_SERVICE_LAUNCH_AGENT_LABEL}</string>`,
182+ ...(plan.pathEnvironment === undefined
183+ ? []
184+ : [" <key>PATH</key>", ` <string>${value(plan.pathEnvironment)}</string>`]),
185+ " </dict>",
186+ " <key>WorkingDirectory</key>",
187+ ` <string>${value(plan.baseDir)}</string>`,
188+ " <key>RunAtLoad</key>",
189+ " <true/>",
190+ " <key>KeepAlive</key>",
191+ " <true/>",
192+ " <key>ThrottleInterval</key>",
193+ " <integer>5</integer>",
194+ " <key>StandardOutPath</key>",
195+ ` <string>${value(plan.logPath)}</string>`,
196+ " <key>StandardErrorPath</key>",
197+ ` <string>${value(plan.logPath)}</string>`,
198+ "</dict>",
199+ "</plist>",
200+ "",
201+ ].join("\n");
202+}
203+
117204 export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
118205 "BootServiceUnsupportedError",
119206 { platform: Schema.String },
120207 ) {
121208 override get message(): string {
122− return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`;
209+ return `Background setup supports Linux with systemd and macOS with launchd; this machine reports '${this.platform}'.`;
123210 }
124211 }
125212
@@ -134,9 +221,13 @@ export class BootServiceCommandError extends Schema.TaggedErrorClass<BootService · 8 unmodified lines
134221 },
135222 ) {
136223 override get message(): string {
137− return this.exitCode === undefined
138− ? `Background setup failed while ${this.step}.`
139− : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
224+ const detail =
225+ this.exitCode === undefined
226+ ? `Background setup failed while ${this.step}.`
227+ : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
228+ return this.step.includes("LaunchAgent")
229+ ? `${detail} If macOS blocked the background item, allow T3 Code in System Settings > General > Login Items & Extensions.`
230+ : detail;
140231 }
141232 }
142233
@@ -157,7 +248,7 @@ export type BootServiceError = · 14 unmodified lines
157248 export interface BootServiceStatus {
158249 readonly supported: boolean;
159250 readonly installed: boolean;
160− /** False when the installed unit no longer matches what install would write. */
251+ /** False when the installed service no longer matches what install would write. */
161252 readonly current: boolean;
162253 readonly unitPath: string;
163254 readonly logPath: string;
@@ -166,7 +257,7 @@ export interface BootServiceStatus { · 2 unmodified lines
166257 export class BootService extends Context.Service<
167258 BootService,
168259 {
169− /** Installs the pinned runtime + unit, enables linger, starts the service. */
260+ /** Installs the runtime + user service and starts it immediately. */
170261 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
171262 /**
172263 * Stops and removes the unit; leaves the pinned runtime for reuse.
@@ -192,23 +283,37 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 19 unmodified lines
192283 const hostArguments = yield* HostProcessArguments;
193284 const host = input.host ?? {
194285 execPath: hostExecPath,
195− // When running the packed CLI this is dist/bin.mjs; when stable (global
196− // install, repo checkout) the boot service runs this same artifact.
286+ // When running the packed CLI this is dist/bin.mjs. Linux can reuse a
287+ // stable global install or checkout; macOS pins the exact CLI version.
197288 cliEntryPath: hostArguments[1] ?? "",
198289 };
199290 const platform = yield* HostProcessPlatform;
291+ const hostEnvironment = yield* HostProcessEnvironment;
292+ const userId = yield* HostProcessUserId;
200293 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
201294 const fs = yield* FileSystem.FileSystem;
202295 const path = yield* Path.Path;
203296 const runner = yield* ProcessRunner.ProcessRunner;
204297
205− const unitDir = path.join(homeDir, ".config", "systemd", "user");
206− const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
298+ const systemdUnitDir = path.join(homeDir, ".config", "systemd", "user");
299+ const systemdUnitPath = path.join(systemdUnitDir, BOOT_SERVICE_UNIT_FILE);
300+ const launchAgent =
301+ userId === null ? null : launchAgentPaths(path, homeDir, input.baseDir, userId);
302+ const unitDir =
303+ platform === "darwin" && launchAgent !== null
304+ ? path.dirname(launchAgent.unitPath)
305+ : systemdUnitDir;
306+ const unitPath =
307+ platform === "darwin" && launchAgent !== null ? launchAgent.unitPath : systemdUnitPath;
207308 const logPath = path.join(input.logsDir, "boot-service.log");
208309 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
209310
210− const requireSystemdLinux = Effect.gen(function* () {
211− if (platform !== "linux" || homeDir === "") {
311+ const requireSupportedPlatform = Effect.gen(function* () {
312+ if (
313+ homeDir === "" ||
314+ (platform !== "linux" && platform !== "darwin") ||
315+ (platform === "darwin" && userId === null)
316+ ) {
212317 return yield* new BootServiceUnsupportedError({ platform });
213318 }
214319 });
@@ -244,15 +349,24 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 29 unmodified lines
244349 );
245350 });
246351
352+ const runOptionalStep = Effect.fn("cloud.boot_service.run_optional_step")(function* (
353+ command: string,
354+ args: ReadonlyArray<string>,
355+ ) {
356+ return yield* runner.run({ command, args }).pipe(
357+ Effect.map((result) => result.code === 0),
358+ Effect.orElseSucceed(() => false),
359+ );
360+ });
361+
247362 /**
248− * Ensures plannedEntryPath exists before the unit points at it. A stable
249− * install (global bin, repo checkout) is used as-is; an ephemeral cache
250− * entry is replaced by `npm install --prefix`-ing the exact running
251− * version into <baseDir>/runtime/versions/<v>. A real install (not a copy
252− * of bin.mjs) because t3 ships native deps like node-pty.
363+ * Ensures plannedRuntimeEntryPath exists before the service points at it.
364+ * macOS always gets an exact managed runtime so the LaunchAgent can use a
365+ * stable symlink across remote updates. Linux can reuse a stable global or
366+ * checkout entry; ephemeral cache entries are pinned on both platforms.
253367 */
254368 const ensurePinnedRuntime = Effect.gen(function* () {
255− if (!isEphemeralCacheEntry(host.cliEntryPath)) {
369+ if (platform !== "darwin" && !isEphemeralCacheEntry(host.cliEntryPath)) {
256370 return;
257371 }
258372 yield* ensurePinnedRuntimeInstalled({
@@ -286,21 +400,44 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 27 unmodified lines
286400 );
287401 });
288402
289− // Where the unit will point: derivable without touching the network, so
290− // status can compare units purely; install materializes it first.
291− const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)
292− ? runtimePaths.entryPath
293− : host.cliEntryPath;
403+ // Where the service definition will point; install materializes it first.
404+ const plannedRuntimeEntryPath =
405+ platform === "darwin" || isEphemeralCacheEntry(host.cliEntryPath)
406+ ? runtimePaths.entryPath
407+ : host.cliEntryPath;
408+ const plannedEntryPath =
409+ platform === "darwin" && launchAgent !== null
410+ ? launchAgent.activeEntryPath
411+ : plannedRuntimeEntryPath;
294412 const plan: BootServicePlan = {
295413 nodePath: host.execPath,
296414 t3EntryPath: plannedEntryPath,
297415 baseDir: input.baseDir,
298416 logPath,
299417 unitPath,
418+ pathEnvironment: hostEnvironment.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
419+ };
420+
421+ const writeUnit = (contents: string) =>
422+ writeFileStringAtomically({ filePath: unitPath, contents }).pipe(
423+ Effect.provideService(FileSystem.FileSystem, fs),
424+ Effect.provideService(Path.Path, path),
425+ );
426+ const writeActiveRuntimeLink = (targetPath: string) => {
427+ if (launchAgent === null) {
428+ return Effect.void;
429+ }
430+ return writeSymbolicLinkAtomically({
431+ linkPath: launchAgent.activeRuntimeLinkPath,
432+ targetPath,
433+ }).pipe(
434+ Effect.provideService(FileSystem.FileSystem, fs),
435+ Effect.provideService(Path.Path, path),
436+ );
300437 };
301438
302439 const install: BootService["Service"]["install"] = Effect.gen(function* () {
303− yield* requireSystemdLinux;
440+ yield* requireSupportedPlatform;
304441 yield* fs
305442 .makeDirectory(input.logsDir, { recursive: true })
306443 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
@@ -316,15 +453,54 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 9 unmodified lines
316453 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
317454 );
318455
456+ const previousRuntimeLink =
457+ platform === "darwin" && launchAgent !== null
458+ ? yield* fs.exists(launchAgent.activeRuntimeLinkPath).pipe(
459+ Effect.flatMap((exists) =>
460+ exists
461+ ? fs.readLink(launchAgent.activeRuntimeLinkPath).pipe(Effect.map(Option.some))
462+ : Effect.succeed(Option.none<string>()),
463+ ),
464+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
465+ )
466+ : Option.none<string>();
467+
319468 yield* fs.makeDirectory(unitDir, { recursive: true }).pipe(
320− Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))),
469+ Effect.andThen(
470+ platform === "darwin"
471+ ? writeActiveRuntimeLink(runtimePaths.versionDir).pipe(
472+ Effect.andThen(writeUnit(renderBootServiceLaunchAgent(plan))),
473+ )
474+ : writeUnit(renderBootServiceUnit(plan)),
475+ ),
321476 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
477+ Effect.tapError(() => rollbackFailedInstall(previousUnit, previousRuntimeLink)),
322478 );
323479
324− // If any activation step fails, remove the unit again: a leftover file
325− // would make service status report it as installed even though it was
326− // never enabled or lingered.
327480 yield* Effect.gen(function* () {
481+ if (platform === "darwin" && launchAgent !== null && userId !== null) {
482+ const domain = `gui/${String(userId)}`;
483+ // An explicit install repairs a background item the user previously
484+ // disabled in System Settings.
485+ yield* runStep("enabling the LaunchAgent", "/bin/launchctl", [
486+ "enable",
487+ launchAgent.serviceTarget,
488+ ]);
489+ // bootout is expected to fail on first install. On repair it stops
490+ // the old job before bootstrap loads the new definition.
491+ yield* runOptionalStep("/bin/launchctl", ["bootout", launchAgent.serviceTarget]);
492+ yield* runStep("loading the LaunchAgent", "/bin/launchctl", [
493+ "bootstrap",
494+ domain,
495+ unitPath,
496+ ]);
497+ yield* runStep("checking the LaunchAgent", "/bin/launchctl", [
498+ "print",
499+ launchAgent.serviceTarget,
500+ ]);
501+ return;
502+ }
503+
328504 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
329505 yield* runStep("enabling the service", "systemctl", [
330506 "--user",
@@ -344,7 +520,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 13 unmodified lines
344520 // username argument: loginctl defaults to the calling user, which is
345521 // always right, while $USER can be stale (su without -l) or unset.
346522 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
347− }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit)));
523+ }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit, previousRuntimeLink)));
348524
349525 return plan;
350526 }).pipe(Effect.withSpan("cloud.boot_service.install"));
@@ -356,9 +532,26 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 5 unmodified lines
356532 // next lifecycle command misreports the state.
357533 const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
358534 previousUnit: Option.Option<string>,
535+ previousRuntimeLink: Option.Option<string>,
359536 ) {
537+ if (platform === "darwin" && launchAgent !== null && userId !== null) {
538+ yield* runOptionalStep("/bin/launchctl", ["bootout", launchAgent.serviceTarget]);
539+ if (Option.isSome(previousRuntimeLink)) {
540+ yield* writeActiveRuntimeLink(previousRuntimeLink.value).pipe(Effect.ignore);
541+ } else {
542+ yield* fs.remove(launchAgent.activeRuntimeLinkPath).pipe(Effect.ignore);
543+ }
544+ if (Option.isSome(previousUnit)) {
545+ yield* writeUnit(previousUnit.value).pipe(Effect.ignore);
546+ yield* runOptionalStep("/bin/launchctl", ["bootstrap", `gui/${String(userId)}`, unitPath]);
547+ } else {
548+ yield* fs.remove(unitPath).pipe(Effect.ignore);
549+ }
550+ return;
551+ }
552+
360553 if (Option.isSome(previousUnit)) {
361− yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore);
554+ yield* writeUnit(previousUnit.value).pipe(Effect.ignore);
362555 } else {
363556 yield* runStep("cleaning up the service", "systemctl", [
364557 "--user",
@@ -381,13 +574,22 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 16 unmodified lines
381574 });
382575
383576 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
384− yield* requireSystemdLinux;
577+ yield* requireSupportedPlatform;
385578 const exists = yield* fs
386579 .exists(unitPath)
387580 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
388581 if (!exists) {
389582 return false;
390583 }
584+ if (platform === "darwin" && launchAgent !== null) {
585+ // A disabled or already-unloaded agent returns non-zero from bootout;
586+ // the plist is still safe to remove and is the durable source of truth.
587+ yield* runOptionalStep("/bin/launchctl", ["bootout", launchAgent.serviceTarget]);
588+ yield* fs
589+ .remove(unitPath)
590+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
591+ return true;
592+ }
391593 yield* runStep("stopping the service", "systemctl", [
392594 "--user",
393595 "disable",
@@ -402,7 +604,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 8 unmodified lines
402604 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
403605
404606 const status: BootService["Service"]["status"] = Effect.gen(function* () {
405− if (platform !== "linux" || homeDir === "") {
607+ if (
608+ homeDir === "" ||
609+ (platform !== "linux" && platform !== "darwin") ||
610+ (platform === "darwin" && launchAgent === null)
611+ ) {
406612 return { supported: false, installed: false, current: false, unitPath, logPath };
407613 }
408614 const unitExists = yield* fs.exists(unitPath);
@@ -410,11 +616,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 1 unmodified line
410616 return { supported: true, installed: false, current: false, unitPath, logPath };
411617 }
412618 const unit = yield* fs.readFileString(unitPath);
619+ const entryExists = yield* fs.exists(plannedEntryPath);
620+ if (platform === "darwin" && launchAgent !== null) {
621+ const activeRuntimeIsCurrent = yield* fs.readLink(launchAgent.activeRuntimeLinkPath).pipe(
622+ Effect.map((target) => target === runtimePaths.versionDir),
623+ Effect.orElseSucceed(() => false),
624+ );
625+ const loaded = yield* runOptionalStep("/bin/launchctl", ["print", launchAgent.serviceTarget]);
626+ const current =
627+ unit === renderBootServiceLaunchAgent(plan) &&
628+ entryExists &&
629+ activeRuntimeIsCurrent &&
630+ loaded;
631+ return { supported: true, installed: true, current, unitPath, logPath };
632+ }
633+
413634 // A unit is current only if it matches what install would write now (an
414635 // older CLI wrote a different runtime/node path) AND the entry point it
415636 // references still exists (a pinned runtime under ~/.t3 can be deleted to
416637 // reclaim space). Either mismatch makes connect offer a repair.
417− const entryExists = yield* fs.exists(plannedEntryPath);
418638 const current = unit === renderBootServiceUnit(plan) && entryExists;
419639 return { supported: true, installed: true, current, unitPath, logPath };
420640 }).pipe(
apps/server/src/cloud/selfUpdate.test.ts+133−16
@@ -13,13 +13,17 @@ import { · 12 unmodified lines
1313 HostProcessEnvironment,
1414 HostProcessExecutablePath,
1515 HostProcessPlatform,
16+ HostProcessUserId,
1617 } from "@t3tools/shared/hostProcess";
1718
1819 import * as ServerConfig from "../config.ts";
1920 import * as ProcessRunner from "../processRunner.ts";
2021 import {
22+ BOOT_SERVICE_LAUNCH_AGENT_LABEL,
2123 BOOT_SERVICE_UNIT_ENV,
2224 BOOT_SERVICE_UNIT_FILE,
25+ launchAgentPaths,
26+ renderBootServiceLaunchAgent,
2327 renderBootServiceUnit,
2428 } from "./bootService.ts";
2529 import * as SelfUpdate from "./selfUpdate.ts";
@@ -76,6 +80,7 @@ const provideHostRefs = (input: { · 50 unmodified lines
7680 Layer.succeed(HostProcessEnvironment, input.env),
7781 Layer.succeed(HostProcessExecutablePath, NODE_PATH),
7882 Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]),
83+ Layer.succeed(HostProcessUserId, 501),
7984 ),
8085 );
8186
@@ -192,6 +197,41 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { · 110 unmodified lines
192197 }),
193198 );
194199
200+ it.effect("reports boot-service for the T3-managed macOS LaunchAgent", () =>
201+ Effect.gen(function* () {
202+ const { fs, home, path } = yield* makeHome();
203+ const baseDir = path.join(home, ".t3");
204+ const launchAgent = launchAgentPaths(path, home, baseDir, 501);
205+ yield* fs.makeDirectory(path.dirname(launchAgent.unitPath), { recursive: true });
206+ yield* fs.writeFileString(
207+ launchAgent.unitPath,
208+ renderBootServiceLaunchAgent({
209+ nodePath: NODE_PATH,
210+ t3EntryPath: launchAgent.activeEntryPath,
211+ baseDir,
212+ logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
213+ unitPath: launchAgent.unitPath,
214+ }),
215+ );
216+
217+ const method = yield* SelfUpdate.resolveServerSelfUpdateCapability({
218+ desktopManaged: false,
219+ }).pipe(
220+ provideHostRefs({
221+ platform: "darwin",
222+ env: {
223+ HOME: home,
224+ T3CODE_HOME: baseDir,
225+ [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_LAUNCH_AGENT_LABEL,
226+ },
227+ entryPath: launchAgent.activeEntryPath,
228+ }),
229+ );
230+
231+ assert.equal(method, "boot-service");
232+ }),
233+ );
234+
195235 it.effect("reports desktop-managed for desktop-supervised backends", () =>
196236 Effect.gen(function* () {
197237 const { home, path } = yield* makeHome();
@@ -262,11 +302,21 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 64 unmodified lines
262302 const path = yield* Path.Path;
263303 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
264304 const baseDir = path.join(home, ".t3");
305+ const platform = options?.platform ?? "linux";
306+ const launchdBootService = options?.bootService === true && platform === "darwin";
307+ const launchAgent = launchAgentPaths(path, home, baseDir, 501);
265308 const entryPath =
266309 options?.entryPath ??
267− path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");
268− const env: NodeJS.ProcessEnv =
269− options?.bootService === true
310+ (launchdBootService
311+ ? launchAgent.activeEntryPath
312+ : path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs"));
313+ const env: NodeJS.ProcessEnv = launchdBootService
314+ ? {
315+ HOME: home,
316+ T3CODE_HOME: baseDir,
317+ [BOOT_SERVICE_UNIT_ENV]: BOOT_SERVICE_LAUNCH_AGENT_LABEL,
318+ }
319+ : options?.bootService === true
270320 ? {
271321 HOME: home,
272322 INVOCATION_ID: "abc123",
@@ -274,18 +324,37 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line
274324 }
275325 : { HOME: home };
276326 if (options?.bootService === true) {
277− const unitDir = path.join(home, ".config", "systemd", "user");
278− yield* fs.makeDirectory(unitDir, { recursive: true });
279− yield* fs.writeFileString(
280− path.join(unitDir, "t3code.service"),
281− renderBootServiceUnit({
282− nodePath: NODE_PATH,
283− t3EntryPath: entryPath,
284− baseDir,
285− logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
286− unitPath: path.join(unitDir, "t3code.service"),
287− }),
288− );
327+ if (launchdBootService) {
328+ const previousRuntime = path.join(baseDir, "runtime", "versions", "0.0.28");
329+ yield* fs.makeDirectory(path.dirname(launchAgent.unitPath), { recursive: true });
330+ yield* fs.makeDirectory(path.dirname(launchAgent.activeRuntimeLinkPath), {
331+ recursive: true,
332+ });
333+ yield* fs.symlink(previousRuntime, launchAgent.activeRuntimeLinkPath);
334+ yield* fs.writeFileString(
335+ launchAgent.unitPath,
336+ renderBootServiceLaunchAgent({
337+ nodePath: NODE_PATH,
338+ t3EntryPath: entryPath,
339+ baseDir,
340+ logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
341+ unitPath: launchAgent.unitPath,
342+ }),
343+ );
344+ } else {
345+ const unitDir = path.join(home, ".config", "systemd", "user");
346+ yield* fs.makeDirectory(unitDir, { recursive: true });
347+ yield* fs.writeFileString(
348+ path.join(unitDir, "t3code.service"),
349+ renderBootServiceUnit({
350+ nodePath: NODE_PATH,
351+ t3EntryPath: entryPath,
352+ baseDir,
353+ logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),
354+ unitPath: path.join(unitDir, "t3code.service"),
355+ }),
356+ );
357+ }
289358 }
290359
291360 const commands: Array<RecordedCommand> = [];
@@ -333,7 +402,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 41 unmodified lines
333402 configLayer,
334403 ),
335404 ),
336− provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }),
405+ provideHostRefs({ platform, env, entryPath }),
337406 );
338407 return {
339408 fs,
@@ -341,6 +410,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line
341410 home,
342411 baseDir,
343412 entryPath,
413+ launchAgent,
344414 commands,
345415 spawns,
346416 exitCount: () => exited,
@@ -520,6 +590,53 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 173 unmodified lines
520590 }).pipe(Effect.provide(TestClock.layer())),
521591 );
522592
593+ it.effect("switches the macOS LaunchAgent runtime and restarts it", () =>
594+ Effect.gen(function* () {
595+ const context = yield* makeContext({
596+ platform: "darwin",
597+ bootService: true,
598+ });
599+
600+ const result = yield* context.service.update({ targetVersion: "0.0.29" });
601+
602+ assert.deepEqual(result, { targetVersion: "0.0.29", method: "boot-service" });
603+ assert.equal(
604+ yield* context.fs.readLink(context.launchAgent.activeRuntimeLinkPath),
605+ context.path.join(context.baseDir, "runtime", "versions", "0.0.29"),
606+ );
607+ assert.deepEqual(
608+ context.commands.map((entry) => entry.command),
609+ ["npm", NODE_PATH, "/bin/launchctl"],
610+ );
611+ assert.deepEqual(context.commands[2], {
612+ command: "/bin/launchctl",
613+ args: ["kickstart", "-k", "gui/501/com.t3tools.t3code.server"],
614+ });
615+ assert.lengthOf(context.spawns, 0);
616+ assert.equal(context.exitCount(), 0);
617+ }).pipe(Effect.provide(TestClock.layer())),
618+ );
619+
620+ it.effect("restores the previous macOS runtime when launchd rejects the restart", () =>
621+ Effect.gen(function* () {
622+ const context = yield* makeContext({
623+ platform: "darwin",
624+ bootService: true,
625+ failWhen: (command) => command === "/bin/launchctl",
626+ });
627+ const previousRuntime = yield* context.fs.readLink(context.launchAgent.activeRuntimeLinkPath);
628+
629+ const error = yield* context.service.update({ targetVersion: "0.0.29" }).pipe(Effect.flip);
630+
631+ assert.include(error.reason, "Restarting the T3 Code LaunchAgent failed");
632+ assert.equal(
633+ yield* context.fs.readLink(context.launchAgent.activeRuntimeLinkPath),
634+ previousRuntime,
635+ );
636+ assert.lengthOf(context.spawns, 0);
637+ }).pipe(Effect.provide(TestClock.layer())),
638+ );
639+
523640 it.effect("restores the previous unit and permits a retry when systemd restart fails", () =>
524641 Effect.gen(function* () {
525642 let failRestart = true;
apps/server/src/cloud/selfUpdate.ts+89−7
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 HostProcessEnvironment,
1414 HostProcessExecutablePath,
1515 HostProcessPlatform,
16+ HostProcessUserId,
1617 } from "@t3tools/shared/hostProcess";
1718 import * as NodeChildProcess from "node:child_process";
1819 import * as Context from "effect/Context";
@@ -24,11 +25,13 @@ import * as Path from "effect/Path"; · 5 unmodified lines
2425 import * as Ref from "effect/Ref";
2526
2627 import * as ServerConfig from "../config.ts";
27−import { writeFileStringAtomically } from "../atomicWrite.ts";
28+import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts";
2829 import * as ProcessRunner from "../processRunner.ts";
2930 import {
31+ BOOT_SERVICE_LAUNCH_AGENT_LABEL,
3032 BOOT_SERVICE_UNIT_ENV,
3133 BOOT_SERVICE_UNIT_FILE,
34+ launchAgentPaths,
3235 quoteSystemdValue,
3336 renderBootServiceUnit,
3437 } from "./bootService.ts";
@@ -82,11 +85,11 @@ export function isPublishedCliEntry(entryPath: string): boolean { · 47 unmodified lines
8285 * The update path this process can offer, or null when only a manual
8386 * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this
8487 * backend and owns its version; only updating the app updates it.
85− * "boot-service" — this is the systemd-supervised process from
86− * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a
87− * foreground POSIX process running a published artifact: replace it with a
88− * detached child. Windows foreground runs are unsupported for now (no
89− * equivalent of the detach-and-exec handoff below).
88+ * "boot-service" — this is the systemd- or launchd-supervised process from
89+ * bootService.ts: switch the service to the verified runtime and restart it.
90+ * "respawn" — a foreground POSIX process running a published artifact:
91+ * replace it with a detached child. Windows foreground runs are unsupported
92+ * for now (no equivalent of the detach-and-exec handoff below).
9093 */
9194 export const resolveServerSelfUpdateCapability = Effect.fn(
9295 "cloud.server_self_update.resolve_capability",
@@ -101,6 +104,7 @@ export const resolveServerSelfUpdateCapability = Effect.fn( · 8 unmodified lines
101104 const platform = yield* HostProcessPlatform;
102105 const env = yield* HostProcessEnvironment;
103106 const hostArguments = yield* HostProcessArguments;
107+ const userId = yield* HostProcessUserId;
104108 const fs = yield* FileSystem.FileSystem;
105109 const path = yield* Path.Path;
106110
@@ -110,6 +114,26 @@ export const resolveServerSelfUpdateCapability = Effect.fn( · 3 unmodified lines
110114 }
111115
112116 const homeDir = env.HOME ?? "";
117+ if (platform === "darwin" && homeDir !== "" && userId !== null) {
118+ const launchAgent = launchAgentPaths(path, homeDir, env.T3CODE_HOME ?? "", userId);
119+ const unitExists = yield* fs
120+ .exists(launchAgent.unitPath)
121+ .pipe(Effect.orElseSucceed(() => false));
122+ if (
123+ unitExists &&
124+ entryPath === launchAgent.activeEntryPath &&
125+ env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_LAUNCH_AGENT_LABEL
126+ ) {
127+ return "boot-service" as const;
128+ }
129+
130+ // A marked LaunchAgent process with an unexpected definition must not
131+ // detach a second server outside its supervisor.
132+ if (env[BOOT_SERVICE_UNIT_ENV] === BOOT_SERVICE_LAUNCH_AGENT_LABEL) {
133+ return null;
134+ }
135+ }
136+
113137 if (platform === "linux" && homeDir !== "") {
114138 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
115139 const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe(
@@ -159,6 +183,8 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option · 43 unmodified lines
159183 const path = yield* Path.Path;
160184 const runner = yield* ProcessRunner.ProcessRunner;
161185 const env = yield* HostProcessEnvironment;
186+ const platform = yield* HostProcessPlatform;
187+ const userId = yield* HostProcessUserId;
162188 const hostExecPath = yield* HostProcessExecutablePath;
163189 const hostArguments = yield* HostProcessArguments;
164190 const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({
@@ -224,6 +250,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option · 59 unmodified lines
224250 Effect.provideService(FileSystem.FileSystem, fs),
225251 Effect.provideService(Path.Path, path),
226252 );
253+ const writeRuntimeLinkAtomically = (linkPath: string, targetPath: string) =>
254+ writeSymbolicLinkAtomically({ linkPath, targetPath }).pipe(
255+ Effect.provideService(FileSystem.FileSystem, fs),
256+ Effect.provideService(Path.Path, path),
257+ );
227258
228259 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
229260 "cloud.server_self_update.update",
@@ -297,7 +328,58 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option · 67 unmodified lines
297328 );
298329 }
299330
300− if (activeMethod === "boot-service") {
331+ if (activeMethod === "boot-service" && platform === "darwin" && userId !== null) {
332+ const homeDir = env.HOME ?? "";
333+ const launchAgent = launchAgentPaths(path, homeDir, serverConfig.baseDir, userId);
334+ const previousRuntime = yield* fs
335+ .readLink(launchAgent.activeRuntimeLinkPath)
336+ .pipe(
337+ Effect.mapError((cause) =>
338+ failWith("Could not read the active launchd runtime.", cause),
339+ ),
340+ );
341+ yield* writeRuntimeLinkAtomically(
342+ launchAgent.activeRuntimeLinkPath,
343+ runtimePaths.versionDir,
344+ ).pipe(
345+ Effect.mapError((cause) =>
346+ failWith("Could not activate the requested launchd runtime.", cause),
347+ ),
348+ );
349+ yield* Effect.logInfo("Server self-update installed; restarting LaunchAgent.", {
350+ targetVersion,
351+ });
352+
353+ yield* Effect.gen(function* () {
354+ const kickstart = yield* runner
355+ .run({
356+ command: "/bin/launchctl",
357+ args: ["kickstart", "-k", launchAgent.serviceTarget],
358+ })
359+ .pipe(
360+ Effect.mapError((cause) =>
361+ failWith("Could not restart the T3 Code LaunchAgent.", cause),
362+ ),
363+ );
364+ if (kickstart.code !== 0) {
365+ return yield* failWith(
366+ `Restarting the T3 Code LaunchAgent failed (exit code ${String(kickstart.code)}).`,
367+ );
368+ }
369+ }).pipe(
370+ Effect.catch((restartError) =>
371+ writeRuntimeLinkAtomically(launchAgent.activeRuntimeLinkPath, previousRuntime).pipe(
372+ Effect.mapError((rollbackCause) =>
373+ failWith("Could not restore the previous launchd runtime.", {
374+ restartError,
375+ rollbackCause,
376+ }),
377+ ),
378+ Effect.andThen(Effect.fail(restartError)),
379+ ),
380+ ),
381+ );
382+ } else if (activeMethod === "boot-service") {
301383 const homeDir = env.HOME ?? "";
302384 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
303385 const previousUnit = yield* fs
docs/architecture/server-updates.md+21−16
@@ -32,17 +32,17 @@ the mismatch and action disappear. · 31 unmodified lines
3232
3333 The server resolves its capability once at startup and publishes it in the environment descriptor.
3434
35−| Advertised value | Process shape | Client behavior |
36−| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
37−| `boot-service` | Linux server running under the T3-managed systemd user service | Call the update RPC; the service unit is replaced and restarted. |
38−| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. |
39−| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. |
40−| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. |
35+| Advertised value | Process shape | Client behavior |
36+| ----------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
37+| `boot-service` | Server running under the T3-managed systemd user service or macOS LaunchAgent | Call the update RPC; the managed service switches runtime and restarts. |
38+| `respawn` | Published npm CLI running in the foreground on macOS or Linux | Call the update RPC; the process hands off to a detached replacement. |
39+| `desktop-managed` | Backend supervised by the desktop app | Tell the user to update the desktop app on the server machine. |
40+| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. |
4141
4242 Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must
4343 never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an
44−unrecognized systemd unit does not claim the foreground respawn path because its supervisor could
45−bring the old version back.
44+unrecognized systemd unit or marked LaunchAgent does not claim the foreground respawn path because
45+its supervisor could bring the old version back.
4646
4747 ## Update Flow
4848
@@ -56,7 +56,7 @@ flowchart TD · 7 unmodified lines
5656 F --> G[Run version preflight]
5757 G -->|fails| H[Remove failed runtime and keep current server]
5858 G -->|passes| I{Handoff method}
59− I -->|boot-service| J[Rewrite and restart T3 systemd unit]
59+ I -->|boot-service| J[Switch runtime and restart managed service]
6060 I -->|respawn| K[Start delayed replacement and exit current process]
6161 J --> L[Client reconnects]
6262 K --> L
@@ -77,10 +77,11 @@ preflight also removes the candidate runtime so retrying the same version perfor · 14 unmodified lines
7777
7878 ## Host Service Lifecycle
7979
80−The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone
81−`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both
82−reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and
83−activates the latest CLI release without requiring a connected client.
80+The systemd user service or macOS LaunchAgent is a host lifecycle concern, not a T3 Connect
81+resource. The standalone `t3 service install`, `uninstall`, `update`, and `status` commands own it.
82+Install and update both reconcile the unit through `BootService`; running
83+`npx t3@latest service update` therefore pins and activates the latest CLI release without requiring
84+a connected client.
8485
8586 The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation
8687 operation as `t3 service install`. Connect logout only disables cloud access and clears its
@@ -88,9 +89,13 @@ authorization; it does not uninstall the host service. · 1 unmodified line
8889
8990 ## Process Handoff
9091
91−For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified
92−runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous
93−unit before returning an error.
92+For a systemd `boot-service`, the server atomically rewrites the T3-managed user unit to point at
93+the verified runtime, reloads systemd, and restarts the unit. Reload and restart failures restore
94+the previous unit before returning an error.
95+
96+For a macOS `boot-service`, the LaunchAgent always starts a stable entry path. The server atomically
97+switches that path to the verified runtime and asks launchd to restart the job. A rejected restart
98+restores the previous runtime link before returning an error.
9499
95100 For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
96101 arguments. It then acknowledges the request and schedules the current process to exit. The delays
docs/user/background-service.md+12−5
@@ -1,7 +1,14 @@
11 # Running T3 Code in the Background
22
3−On a Linux host, T3 Code can run as a background service for your user. It starts when the machine
4−boots and keeps running after you log out.
3+On macOS or Linux, T3 Code can run as a background service for your user. The simplest setup is:
4+
5+```sh
6+npx t3 connect
7+```
8+
9+After T3 Connect links the machine, accept the default background setup. T3 Code starts immediately
10+and returns automatically after a reboot. On macOS, the per-user LaunchAgent starts after you sign
11+in. On Linux, the systemd user service starts at boot and keeps running after you log out.
512
613 ## Manage the Service
714
@@ -33,10 +40,10 @@ Updating restarts T3 Code briefly. Let active agent work and terminal commands f · 25 unmodified lines
3340
3441 ## Using It with T3 Connect
3542
36−T3 Connect may offer to install the service during setup so the host stays reachable after you log
37−out. This is only an onboarding shortcut: the service and T3 Connect are managed separately.
43+T3 Connect offers to install the service during setup so the host starts automatically and stays
44+reachable in the background. The service and T3 Connect are still managed separately.
3845
3946 Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
4047 want T3 Code to start in the background.
4148
42−The background service currently requires Linux with systemd.
49+The background service requires macOS with launchd or Linux with systemd.
packages/contracts/src/environment.ts+2−2
@@ -21,8 +21,8 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ · 20 unmodified lines
2121 export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
2222
2323 /** How a server can replace itself with another version when asked over RPC:
24− "boot-service" rewrites the systemd user unit and restarts it; "respawn"
25− installs the target version and respawns the foreground process. */
24+ "boot-service" hands off through its systemd unit or macOS LaunchAgent;
25+ "respawn" installs the target version and replaces the foreground process. */
2626 export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);
2727 export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
2828
packages/shared/src/hostProcess.ts+7−0
@@ -51,4 +51,11 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( · 50 unmodified lines
5151 },
5252 );
5353
54+export const HostProcessUserId = Context.Reference<number | null>(
55+ "@t3tools/shared/hostProcess/HostProcessUserId",
56+ {
57+ defaultValue: () => (typeof process.getuid === "function" ? process.getuid() : null),
58+ },
59+);
60+
5461 export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");