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
23 yield* fs.rename(tempPath, input.filePath);23 yield* fs.rename(tempPath, input.filePath);
24 }),24 }),
25 );25 );
26
27export 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
695 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);695 const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding);
696 if (background) {696 if (background) {
697 yield* Console.log(697 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.",
699 );699 );
700 return;700 return;
701 }701 }
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
16 [16 [
17 "T3 Code service",17 "T3 Code service",
18 " Status: installed · t3@0.0.29",18 " Status: installed · t3@0.0.29",
19 " Unit: /home/me/.config/systemd/user/t3code.service",19 " Definition: /home/me/.config/systemd/user/t3code.service",
20 " Logs: /home/me/.t3/userdata/logs/boot-service.log",20 " Logs: /home/me/.t3/userdata/logs/boot-service.log",
21 ].join("\n"),21 ].join("\n"),
22 );22 );
@@ -29,9 +29,9 @@ it("gives a direct repair command for a stale service", () => { · 6 unmodified lines
29 );29 );
30});30});
31 31
32it("explains service availability without systemd", () => {32it("explains service availability on unsupported platforms", () => {
33 assert.include(33 assert.include(
34 formatServiceStatus({ ...status, supported: false, installed: false }, "0.0.29"),34 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",
36 );36 );
37});37});
apps/server/src/cli/service.ts+3−4
@@ -48,7 +48,7 @@ export function formatServiceStatus( · 47 unmodified lines
48 cliVersion: string,48 cliVersion: string,
49): string {49): string {
50 if (!status.supported) {50 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";
52 }52 }
53 if (!status.installed) {53 if (!status.installed) {
54 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";54 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
@@ -56,7 +56,7 @@ export function formatServiceStatus( · 1 unmodified line
56 return [56 return [
57 "T3 Code service",57 "T3 Code service",
58 ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,58 ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`,
59 ` Unit: ${status.unitPath}`,59 ` Definition: ${status.unitPath}`,
60 ` Logs: ${status.logPath}`,60 ` Logs: ${status.logPath}`,
61 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),61 ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]),
62 ].join("\n");62 ].join("\n");
@@ -156,8 +156,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { · 93 unmodified lines
156 Prompt.confirm({156 Prompt.confirm({
157 message: installed157 message: installed
158 ? "The installed T3 Code service needs an update or repair. Update it now?"158 ? "The installed T3 Code service needs an update or repair. Update it now?"
159 : "Run T3 Code in the background whenever this machine boots? " +159 : "Start T3 Code automatically and keep this machine reachable in the background?",
160 "It stays reachable through T3 Connect even after you log out.",
161 initial: true,160 initial: true,
162 }),161 }),
163 );162 );
apps/server/src/cloud/bootService.test.ts+142−2
@@ -12,6 +12,7 @@ import { · 11 unmodified lines
12 HostProcessArguments,12 HostProcessArguments,
13 HostProcessExecutablePath,13 HostProcessExecutablePath,
14 HostProcessPlatform,14 HostProcessPlatform,
15 HostProcessUserId,
15} from "@t3tools/shared/hostProcess";16} from "@t3tools/shared/hostProcess";
16 17
17import { reconcileService } from "../cli/service.ts";18import { reconcileService } from "../cli/service.ts";
@@ -64,6 +65,7 @@ const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => · 46 unmodified lines
64 Effect.provide(65 Effect.provide(
65 Layer.mergeAll(66 Layer.mergeAll(
66 Layer.succeed(HostProcessPlatform, platform),67 Layer.succeed(HostProcessPlatform, platform),
68 Layer.succeed(HostProcessUserId, 501),
67 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),69 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
68 ),70 ),
69 );71 );
@@ -143,6 +145,34 @@ it("quotes systemd values containing spaces and escapes percent specifiers", () · 73 unmodified lines
143 assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");145 assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");
144});146});
145 147
148it("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
146it("flags package-manager cache entry points as ephemeral", () => {176it("flags package-manager cache entry points as ephemeral", () => {
147 assert.isTrue(177 assert.isTrue(
148 BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),178 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
388 }),418 }),
389 );419 );
390 420
391 it.effect("fails on non-Linux platforms without touching the filesystem", () =>421 it.effect("installs and starts a pinned macOS LaunchAgent", () =>
392 Effect.gen(function* () {422 Effect.gen(function* () {
393 const { dirs, fs, path } = yield* makeTestContext();423 const { dirs, fs, path } = yield* makeTestContext();
394 const commands: Array<RecordedCommand> = [];424 const commands: Array<RecordedCommand> = [];
@@ -396,12 +426,122 @@ it.layer(NodeServices.layer)("BootService", (it) => { · 1 unmodified line
396 baseDir: dirs.baseDir,426 baseDir: dirs.baseDir,
397 logsDir: dirs.logsDir,427 logsDir: dirs.logsDir,
398 cliVersion: "0.0.27",428 cliVersion: "0.0.27",
399 host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),429 host: makeHost(dirs.stableEntry),
400 }).pipe(430 }).pipe(
401 Effect.provide(makeRecordingRunnerLayer(commands)),431 Effect.provide(makeRecordingRunnerLayer(commands)),
402 provideHostRefs(dirs.home, "darwin"),432 provideHostRefs(dirs.home, "darwin"),
403 );433 );
404 434
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
405 const error = yield* service.install.pipe(Effect.flip);545 const error = yield* service.install.pipe(Effect.flip);
406 assert.isTrue(isUnsupportedError(error));546 assert.isTrue(isUnsupportedError(error));
407 assert.lengthOf(commands, 0);547 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
11 11
12import {12import {
13 HostProcessArguments,13 HostProcessArguments,
14 HostProcessEnvironment,
14 HostProcessExecutablePath,15 HostProcessExecutablePath,
15 HostProcessPlatform,16 HostProcessPlatform,
17 HostProcessUserId,
16} from "@t3tools/shared/hostProcess";18} from "@t3tools/shared/hostProcess";
17 19
20import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts";
18import * as ProcessRunner from "../processRunner.ts";21import * as ProcessRunner from "../processRunner.ts";
19import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts";22import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts";
20 23
21/**24/**
22 * Installs T3 Code as a per-user boot service. Linux-only for now: systemd25 * Installs T3 Code as a per-user background service: systemd + linger on
23 * user unit + loginctl enable-linger. The service runs a stable or pinned26 * Linux, or a LaunchAgent on macOS. The service runs a stable or pinned
24 * runtime — never an ephemeral `npx t3` cache whose eviction could break27 * runtime — never an ephemeral `npx t3` cache whose eviction could break
25 * startup.28 * startup.
26 */29 */
@@ -29,6 +32,8 @@ const BOOT_SERVICE_NAME = "t3code"; · 2 unmodified lines
29 32
30export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;33export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
31export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";34export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT";
35export const BOOT_SERVICE_LAUNCH_AGENT_LABEL = "com.t3tools.t3code.server";
36export const BOOT_SERVICE_LAUNCH_AGENT_FILE = `${BOOT_SERVICE_LAUNCH_AGENT_LABEL}.plist`;
32 37
33const EPHEMERAL_CACHE_SEGMENTS = [38const EPHEMERAL_CACHE_SEGMENTS = [
34 "/_npx/", // npx39 "/_npx/", // npx
@@ -76,6 +81,39 @@ export interface BootServicePlan { · 41 unmodified lines
76 readonly baseDir: string;81 readonly baseDir: string;
77 readonly logPath: string;82 readonly logPath: string;
78 readonly unitPath: string;83 readonly unitPath: string;
84 /** Captured for launchd, whose default PATH omits common CLI install locations. */
85 readonly pathEnvironment?: string;
86}
87
88export interface LaunchAgentPaths {
89 readonly unitPath: string;
90 readonly activeRuntimeLinkPath: string;
91 readonly activeEntryPath: string;
92 readonly serviceTarget: string;
93}
94
95export 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
110export function escapeLaunchdXml(value: string): string {
111 return value
112 .replaceAll("&", "&amp;")
113 .replaceAll("<", "&lt;")
114 .replaceAll(">", "&gt;")
115 .replaceAll('"', "&quot;")
116 .replaceAll("'", "&apos;");
79}117}
80 118
81/**119/**
@@ -114,12 +152,61 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { · 32 unmodified lines
114 ].join("\n");152 ].join("\n");
115}153}
116 154
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 */
161export 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
117export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(204export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
118 "BootServiceUnsupportedError",205 "BootServiceUnsupportedError",
119 { platform: Schema.String },206 { platform: Schema.String },
120) {207) {
121 override get message(): string {208 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}'.`;
123 }210 }
124}211}
125 212
@@ -134,9 +221,13 @@ export class BootServiceCommandError extends Schema.TaggedErrorClass<BootService · 8 unmodified lines
134 },221 },
135) {222) {
136 override get message(): string {223 override get message(): string {
137 return this.exitCode === undefined224 const detail =
138 ? `Background setup failed while ${this.step}.`225 this.exitCode === undefined
139 : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;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;
140 }231 }
141}232}
142 233
@@ -157,7 +248,7 @@ export type BootServiceError = · 14 unmodified lines
157export interface BootServiceStatus {248export interface BootServiceStatus {
158 readonly supported: boolean;249 readonly supported: boolean;
159 readonly installed: boolean;250 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. */
161 readonly current: boolean;252 readonly current: boolean;
162 readonly unitPath: string;253 readonly unitPath: string;
163 readonly logPath: string;254 readonly logPath: string;
@@ -166,7 +257,7 @@ export interface BootServiceStatus { · 2 unmodified lines
166export class BootService extends Context.Service<257export class BootService extends Context.Service<
167 BootService,258 BootService,
168 {259 {
169 /** Installs the pinned runtime + unit, enables linger, starts the service. */260 /** Installs the runtime + user service and starts it immediately. */
170 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;261 readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
171 /**262 /**
172 * Stops and removes the unit; leaves the pinned runtime for reuse.263 * 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
192 const hostArguments = yield* HostProcessArguments;283 const hostArguments = yield* HostProcessArguments;
193 const host = input.host ?? {284 const host = input.host ?? {
194 execPath: hostExecPath,285 execPath: hostExecPath,
195 // When running the packed CLI this is dist/bin.mjs; when stable (global286 // When running the packed CLI this is dist/bin.mjs. Linux can reuse a
196 // install, repo checkout) the boot service runs this same artifact.287 // stable global install or checkout; macOS pins the exact CLI version.
197 cliEntryPath: hostArguments[1] ?? "",288 cliEntryPath: hostArguments[1] ?? "",
198 };289 };
199 const platform = yield* HostProcessPlatform;290 const platform = yield* HostProcessPlatform;
291 const hostEnvironment = yield* HostProcessEnvironment;
292 const userId = yield* HostProcessUserId;
200 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));293 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
201 const fs = yield* FileSystem.FileSystem;294 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;295 const path = yield* Path.Path;
203 const runner = yield* ProcessRunner.ProcessRunner;296 const runner = yield* ProcessRunner.ProcessRunner;
204 297
205 const unitDir = path.join(homeDir, ".config", "systemd", "user");298 const systemdUnitDir = path.join(homeDir, ".config", "systemd", "user");
206 const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);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;
207 const logPath = path.join(input.logsDir, "boot-service.log");308 const logPath = path.join(input.logsDir, "boot-service.log");
208 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);309 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
209 310
210 const requireSystemdLinux = Effect.gen(function* () {311 const requireSupportedPlatform = Effect.gen(function* () {
211 if (platform !== "linux" || homeDir === "") {312 if (
313 homeDir === "" ||
314 (platform !== "linux" && platform !== "darwin") ||
315 (platform === "darwin" && userId === null)
316 ) {
212 return yield* new BootServiceUnsupportedError({ platform });317 return yield* new BootServiceUnsupportedError({ platform });
213 }318 }
214 });319 });
@@ -244,15 +349,24 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 29 unmodified lines
244 );349 );
245 });350 });
246 351
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
247 /**362 /**
248 * Ensures plannedEntryPath exists before the unit points at it. A stable363 * Ensures plannedRuntimeEntryPath exists before the service points at it.
249 * install (global bin, repo checkout) is used as-is; an ephemeral cache364 * macOS always gets an exact managed runtime so the LaunchAgent can use a
250 * entry is replaced by `npm install --prefix`-ing the exact running365 * stable symlink across remote updates. Linux can reuse a stable global or
251 * version into <baseDir>/runtime/versions/<v>. A real install (not a copy366 * checkout entry; ephemeral cache entries are pinned on both platforms.
252 * of bin.mjs) because t3 ships native deps like node-pty.
253 */367 */
254 const ensurePinnedRuntime = Effect.gen(function* () {368 const ensurePinnedRuntime = Effect.gen(function* () {
255 if (!isEphemeralCacheEntry(host.cliEntryPath)) {369 if (platform !== "darwin" && !isEphemeralCacheEntry(host.cliEntryPath)) {
256 return;370 return;
257 }371 }
258 yield* ensurePinnedRuntimeInstalled({372 yield* ensurePinnedRuntimeInstalled({
@@ -286,21 +400,44 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 27 unmodified lines
286 );400 );
287 });401 });
288 402
289 // Where the unit will point: derivable without touching the network, so403 // Where the service definition will point; install materializes it first.
290 // status can compare units purely; install materializes it first.404 const plannedRuntimeEntryPath =
291 const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)405 platform === "darwin" || isEphemeralCacheEntry(host.cliEntryPath)
292 ? runtimePaths.entryPath406 ? runtimePaths.entryPath
293 : host.cliEntryPath;407 : host.cliEntryPath;
408 const plannedEntryPath =
409 platform === "darwin" && launchAgent !== null
410 ? launchAgent.activeEntryPath
411 : plannedRuntimeEntryPath;
294 const plan: BootServicePlan = {412 const plan: BootServicePlan = {
295 nodePath: host.execPath,413 nodePath: host.execPath,
296 t3EntryPath: plannedEntryPath,414 t3EntryPath: plannedEntryPath,
297 baseDir: input.baseDir,415 baseDir: input.baseDir,
298 logPath,416 logPath,
299 unitPath,417 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 );
300 };437 };
301 438
302 const install: BootService["Service"]["install"] = Effect.gen(function* () {439 const install: BootService["Service"]["install"] = Effect.gen(function* () {
303 yield* requireSystemdLinux;440 yield* requireSupportedPlatform;
304 yield* fs441 yield* fs
305 .makeDirectory(input.logsDir, { recursive: true })442 .makeDirectory(input.logsDir, { recursive: true })
306 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));443 .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
316 Effect.mapError((cause) => new BootServiceInstallError({ cause })),453 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
317 );454 );
318 455
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
319 yield* fs.makeDirectory(unitDir, { recursive: true }).pipe(468 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 ),
321 Effect.mapError((cause) => new BootServiceInstallError({ cause })),476 Effect.mapError((cause) => new BootServiceInstallError({ cause })),
477 Effect.tapError(() => rollbackFailedInstall(previousUnit, previousRuntimeLink)),
322 );478 );
323 479
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.
327 yield* Effect.gen(function* () {480 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
328 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);504 yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
329 yield* runStep("enabling the service", "systemctl", [505 yield* runStep("enabling the service", "systemctl", [
330 "--user",506 "--user",
@@ -344,7 +520,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 13 unmodified lines
344 // username argument: loginctl defaults to the calling user, which is520 // username argument: loginctl defaults to the calling user, which is
345 // always right, while $USER can be stale (su without -l) or unset.521 // always right, while $USER can be stale (su without -l) or unset.
346 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);522 yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
347 }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit)));523 }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit, previousRuntimeLink)));
348 524
349 return plan;525 return plan;
350 }).pipe(Effect.withSpan("cloud.boot_service.install"));526 }).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
356 // next lifecycle command misreports the state.532 // next lifecycle command misreports the state.
357 const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (533 const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
358 previousUnit: Option.Option<string>,534 previousUnit: Option.Option<string>,
535 previousRuntimeLink: Option.Option<string>,
359 ) {536 ) {
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
360 if (Option.isSome(previousUnit)) {553 if (Option.isSome(previousUnit)) {
361 yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore);554 yield* writeUnit(previousUnit.value).pipe(Effect.ignore);
362 } else {555 } else {
363 yield* runStep("cleaning up the service", "systemctl", [556 yield* runStep("cleaning up the service", "systemctl", [
364 "--user",557 "--user",
@@ -381,13 +574,22 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 16 unmodified lines
381 });574 });
382 575
383 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {576 const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
384 yield* requireSystemdLinux;577 yield* requireSupportedPlatform;
385 const exists = yield* fs578 const exists = yield* fs
386 .exists(unitPath)579 .exists(unitPath)
387 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));580 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
388 if (!exists) {581 if (!exists) {
389 return false;582 return false;
390 }583 }
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 }
391 yield* runStep("stopping the service", "systemctl", [593 yield* runStep("stopping the service", "systemctl", [
392 "--user",594 "--user",
393 "disable",595 "disable",
@@ -402,7 +604,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 8 unmodified lines
402 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));604 }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
403 605
404 const status: BootService["Service"]["status"] = Effect.gen(function* () {606 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 ) {
406 return { supported: false, installed: false, current: false, unitPath, logPath };612 return { supported: false, installed: false, current: false, unitPath, logPath };
407 }613 }
408 const unitExists = yield* fs.exists(unitPath);614 const unitExists = yield* fs.exists(unitPath);
@@ -410,11 +616,25 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 1 unmodified line
410 return { supported: true, installed: false, current: false, unitPath, logPath };616 return { supported: true, installed: false, current: false, unitPath, logPath };
411 }617 }
412 const unit = yield* fs.readFileString(unitPath);618 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
413 // A unit is current only if it matches what install would write now (an634 // A unit is current only if it matches what install would write now (an
414 // older CLI wrote a different runtime/node path) AND the entry point it635 // older CLI wrote a different runtime/node path) AND the entry point it
415 // references still exists (a pinned runtime under ~/.t3 can be deleted to636 // references still exists (a pinned runtime under ~/.t3 can be deleted to
416 // reclaim space). Either mismatch makes connect offer a repair.637 // reclaim space). Either mismatch makes connect offer a repair.
417 const entryExists = yield* fs.exists(plannedEntryPath);
418 const current = unit === renderBootServiceUnit(plan) && entryExists;638 const current = unit === renderBootServiceUnit(plan) && entryExists;
419 return { supported: true, installed: true, current, unitPath, logPath };639 return { supported: true, installed: true, current, unitPath, logPath };
420 }).pipe(640 }).pipe(
apps/server/src/cloud/selfUpdate.test.ts+133−16
@@ -13,13 +13,17 @@ import { · 12 unmodified lines
13 HostProcessEnvironment,13 HostProcessEnvironment,
14 HostProcessExecutablePath,14 HostProcessExecutablePath,
15 HostProcessPlatform,15 HostProcessPlatform,
16 HostProcessUserId,
16} from "@t3tools/shared/hostProcess";17} from "@t3tools/shared/hostProcess";
17 18
18import * as ServerConfig from "../config.ts";19import * as ServerConfig from "../config.ts";
19import * as ProcessRunner from "../processRunner.ts";20import * as ProcessRunner from "../processRunner.ts";
20import {21import {
22 BOOT_SERVICE_LAUNCH_AGENT_LABEL,
21 BOOT_SERVICE_UNIT_ENV,23 BOOT_SERVICE_UNIT_ENV,
22 BOOT_SERVICE_UNIT_FILE,24 BOOT_SERVICE_UNIT_FILE,
25 launchAgentPaths,
26 renderBootServiceLaunchAgent,
23 renderBootServiceUnit,27 renderBootServiceUnit,
24} from "./bootService.ts";28} from "./bootService.ts";
25import * as SelfUpdate from "./selfUpdate.ts";29import * as SelfUpdate from "./selfUpdate.ts";
@@ -76,6 +80,7 @@ const provideHostRefs = (input: { · 50 unmodified lines
76 Layer.succeed(HostProcessEnvironment, input.env),80 Layer.succeed(HostProcessEnvironment, input.env),
77 Layer.succeed(HostProcessExecutablePath, NODE_PATH),81 Layer.succeed(HostProcessExecutablePath, NODE_PATH),
78 Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]),82 Layer.succeed(HostProcessArguments, [NODE_PATH, input.entryPath, "serve"]),
83 Layer.succeed(HostProcessUserId, 501),
79 ),84 ),
80 );85 );
81 86
@@ -192,6 +197,41 @@ it.layer(NodeServices.layer)("resolveServerSelfUpdateCapability", (it) => { · 110 unmodified lines
192 }),197 }),
193 );198 );
194 199
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
195 it.effect("reports desktop-managed for desktop-supervised backends", () =>235 it.effect("reports desktop-managed for desktop-supervised backends", () =>
196 Effect.gen(function* () {236 Effect.gen(function* () {
197 const { home, path } = yield* makeHome();237 const { home, path } = yield* makeHome();
@@ -262,11 +302,21 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 64 unmodified lines
262 const path = yield* Path.Path;302 const path = yield* Path.Path;
263 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });303 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" });
264 const baseDir = path.join(home, ".t3");304 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);
265 const entryPath =308 const entryPath =
266 options?.entryPath ??309 options?.entryPath ??
267 path.join(home, ".t3/runtime/versions/0.0.28/node_modules/t3/dist/bin.mjs");310 (launchdBootService
268 const env: NodeJS.ProcessEnv =311 ? launchAgent.activeEntryPath
269 options?.bootService === true312 : 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
270 ? {320 ? {
271 HOME: home,321 HOME: home,
272 INVOCATION_ID: "abc123",322 INVOCATION_ID: "abc123",
@@ -274,18 +324,37 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line
274 }324 }
275 : { HOME: home };325 : { HOME: home };
276 if (options?.bootService === true) {326 if (options?.bootService === true) {
277 const unitDir = path.join(home, ".config", "systemd", "user");327 if (launchdBootService) {
278 yield* fs.makeDirectory(unitDir, { recursive: true });328 const previousRuntime = path.join(baseDir, "runtime", "versions", "0.0.28");
279 yield* fs.writeFileString(329 yield* fs.makeDirectory(path.dirname(launchAgent.unitPath), { recursive: true });
280 path.join(unitDir, "t3code.service"),330 yield* fs.makeDirectory(path.dirname(launchAgent.activeRuntimeLinkPath), {
281 renderBootServiceUnit({331 recursive: true,
282 nodePath: NODE_PATH,332 });
283 t3EntryPath: entryPath,333 yield* fs.symlink(previousRuntime, launchAgent.activeRuntimeLinkPath);
284 baseDir,334 yield* fs.writeFileString(
285 logPath: path.join(baseDir, "userdata", "logs", "boot-service.log"),335 launchAgent.unitPath,
286 unitPath: path.join(unitDir, "t3code.service"),336 renderBootServiceLaunchAgent({
287 }),337 nodePath: NODE_PATH,
288 );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 }
289 }358 }
290 359
291 const commands: Array<RecordedCommand> = [];360 const commands: Array<RecordedCommand> = [];
@@ -333,7 +402,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 41 unmodified lines
333 configLayer,402 configLayer,
334 ),403 ),
335 ),404 ),
336 provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }),405 provideHostRefs({ platform, env, entryPath }),
337 );406 );
338 return {407 return {
339 fs,408 fs,
@@ -341,6 +410,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line
341 home,410 home,
342 baseDir,411 baseDir,
343 entryPath,412 entryPath,
413 launchAgent,
344 commands,414 commands,
345 spawns,415 spawns,
346 exitCount: () => exited,416 exitCount: () => exited,
@@ -520,6 +590,53 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 173 unmodified lines
520 }).pipe(Effect.provide(TestClock.layer())),590 }).pipe(Effect.provide(TestClock.layer())),
521 );591 );
522 592
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
523 it.effect("restores the previous unit and permits a retry when systemd restart fails", () =>640 it.effect("restores the previous unit and permits a retry when systemd restart fails", () =>
524 Effect.gen(function* () {641 Effect.gen(function* () {
525 let failRestart = true;642 let failRestart = true;
apps/server/src/cloud/selfUpdate.ts+89−7
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 HostProcessEnvironment,13 HostProcessEnvironment,
14 HostProcessExecutablePath,14 HostProcessExecutablePath,
15 HostProcessPlatform,15 HostProcessPlatform,
16 HostProcessUserId,
16} from "@t3tools/shared/hostProcess";17} from "@t3tools/shared/hostProcess";
17import * as NodeChildProcess from "node:child_process";18import * as NodeChildProcess from "node:child_process";
18import * as Context from "effect/Context";19import * as Context from "effect/Context";
@@ -24,11 +25,13 @@ import * as Path from "effect/Path"; · 5 unmodified lines
24import * as Ref from "effect/Ref";25import * as Ref from "effect/Ref";
25 26
26import * as ServerConfig from "../config.ts";27import * as ServerConfig from "../config.ts";
27import { writeFileStringAtomically } from "../atomicWrite.ts";28import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts";
28import * as ProcessRunner from "../processRunner.ts";29import * as ProcessRunner from "../processRunner.ts";
29import {30import {
31 BOOT_SERVICE_LAUNCH_AGENT_LABEL,
30 BOOT_SERVICE_UNIT_ENV,32 BOOT_SERVICE_UNIT_ENV,
31 BOOT_SERVICE_UNIT_FILE,33 BOOT_SERVICE_UNIT_FILE,
34 launchAgentPaths,
32 quoteSystemdValue,35 quoteSystemdValue,
33 renderBootServiceUnit,36 renderBootServiceUnit,
34} from "./bootService.ts";37} from "./bootService.ts";
@@ -82,11 +85,11 @@ export function isPublishedCliEntry(entryPath: string): boolean { · 47 unmodified lines
82 * The update path this process can offer, or null when only a manual85 * The update path this process can offer, or null when only a manual
83 * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this86 * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this
84 * backend and owns its version; only updating the app updates it.87 * backend and owns its version; only updating the app updates it.
85 * "boot-service" — this is the systemd-supervised process from88 * "boot-service" — this is the systemd- or launchd-supervised process from
86 * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a89 * bootService.ts: switch the service to the verified runtime and restart it.
87 * foreground POSIX process running a published artifact: replace it with a90 * "respawn" — a foreground POSIX process running a published artifact:
88 * detached child. Windows foreground runs are unsupported for now (no91 * replace it with a detached child. Windows foreground runs are unsupported
89 * equivalent of the detach-and-exec handoff below).92 * for now (no equivalent of the detach-and-exec handoff below).
90 */93 */
91export const resolveServerSelfUpdateCapability = Effect.fn(94export const resolveServerSelfUpdateCapability = Effect.fn(
92 "cloud.server_self_update.resolve_capability",95 "cloud.server_self_update.resolve_capability",
@@ -101,6 +104,7 @@ export const resolveServerSelfUpdateCapability = Effect.fn( · 8 unmodified lines
101 const platform = yield* HostProcessPlatform;104 const platform = yield* HostProcessPlatform;
102 const env = yield* HostProcessEnvironment;105 const env = yield* HostProcessEnvironment;
103 const hostArguments = yield* HostProcessArguments;106 const hostArguments = yield* HostProcessArguments;
107 const userId = yield* HostProcessUserId;
104 const fs = yield* FileSystem.FileSystem;108 const fs = yield* FileSystem.FileSystem;
105 const path = yield* Path.Path;109 const path = yield* Path.Path;
106 110
@@ -110,6 +114,26 @@ export const resolveServerSelfUpdateCapability = Effect.fn( · 3 unmodified lines
110 }114 }
111 115
112 const homeDir = env.HOME ?? "";116 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
113 if (platform === "linux" && homeDir !== "") {137 if (platform === "linux" && homeDir !== "") {
114 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);138 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
115 const unitReferencesEntry = yield* fs.readFileString(unitPath).pipe(139 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
159 const path = yield* Path.Path;183 const path = yield* Path.Path;
160 const runner = yield* ProcessRunner.ProcessRunner;184 const runner = yield* ProcessRunner.ProcessRunner;
161 const env = yield* HostProcessEnvironment;185 const env = yield* HostProcessEnvironment;
186 const platform = yield* HostProcessPlatform;
187 const userId = yield* HostProcessUserId;
162 const hostExecPath = yield* HostProcessExecutablePath;188 const hostExecPath = yield* HostProcessExecutablePath;
163 const hostArguments = yield* HostProcessArguments;189 const hostArguments = yield* HostProcessArguments;
164 const capability: ServerSelfUpdateCapability | null = yield* resolveServerSelfUpdateCapability({190 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
224 Effect.provideService(FileSystem.FileSystem, fs),250 Effect.provideService(FileSystem.FileSystem, fs),
225 Effect.provideService(Path.Path, path),251 Effect.provideService(Path.Path, path),
226 );252 );
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 );
227 258
228 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(259 const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
229 "cloud.server_self_update.update",260 "cloud.server_self_update.update",
@@ -297,7 +328,58 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option · 67 unmodified lines
297 );328 );
298 }329 }
299 330
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") {
301 const homeDir = env.HOME ?? "";383 const homeDir = env.HOME ?? "";
302 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);384 const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE);
303 const previousUnit = yield* fs385 const previousUnit = yield* fs
docs/architecture/server-updates.md+21−16
@@ -32,17 +32,17 @@ the mismatch and action disappear. · 31 unmodified lines
32 32
33The server resolves its capability once at startup and publishes it in the environment descriptor.33The server resolves its capability once at startup and publishes it in the environment descriptor.
34 34
35| Advertised value | Process shape | Client behavior |35| Advertised value | Process shape | Client behavior |
36| ----------------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |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. |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. |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. |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. |40| absent | Older server, development checkout, Windows foreground process, or an unrecognized supervisor | Offer the exact manual relaunch command. |
41 41
42Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must42Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must
43never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an43never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an
44unrecognized systemd unit does not claim the foreground respawn path because its supervisor could44unrecognized systemd unit or marked LaunchAgent does not claim the foreground respawn path because
45bring the old version back.45its supervisor could bring the old version back.
46 46
47## Update Flow47## Update Flow
48 48
@@ -56,7 +56,7 @@ flowchart TD · 7 unmodified lines
56 F --> G[Run version preflight]56 F --> G[Run version preflight]
57 G -->|fails| H[Remove failed runtime and keep current server]57 G -->|fails| H[Remove failed runtime and keep current server]
58 G -->|passes| I{Handoff method}58 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]
60 I -->|respawn| K[Start delayed replacement and exit current process]60 I -->|respawn| K[Start delayed replacement and exit current process]
61 J --> L[Client reconnects]61 J --> L[Client reconnects]
62 K --> L62 K --> L
@@ -77,10 +77,11 @@ preflight also removes the candidate runtime so retrying the same version perfor · 14 unmodified lines
77 77
78## Host Service Lifecycle78## Host Service Lifecycle
79 79
80The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone80The systemd user service or macOS LaunchAgent is a host lifecycle concern, not a T3 Connect
81`t3 service install`, `uninstall`, `update`, and `status` commands own it. Install and update both81resource. The standalone `t3 service install`, `uninstall`, `update`, and `status` commands own it.
82reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and82Install and update both reconcile the unit through `BootService`; running
83activates the latest CLI release without requiring a connected client.83`npx t3@latest service update` therefore pins and activates the latest CLI release without requiring
84a connected client.
84 85
85The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation86The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation
86operation as `t3 service install`. Connect logout only disables cloud access and clears its87operation 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
88 89
89## Process Handoff90## Process Handoff
90 91
91For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified92For a systemd `boot-service`, the server atomically rewrites the T3-managed user unit to point at
92runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous93the verified runtime, reloads systemd, and restarts the unit. Reload and restart failures restore
93unit before returning an error.94the previous unit before returning an error.
95
96For a macOS `boot-service`, the LaunchAgent always starts a stable entry path. The server atomically
97switches that path to the verified runtime and asks launchd to restart the job. A rejected restart
98restores the previous runtime link before returning an error.
94 99
95For `respawn`, the server starts a detached, delayed replacement that replays the original CLI100For `respawn`, the server starts a detached, delayed replacement that replays the original CLI
96arguments. It then acknowledges the request and schedules the current process to exit. The delays101arguments. 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 @@
1# Running T3 Code in the Background1# Running T3 Code in the Background
2 2
3On a Linux host, T3 Code can run as a background service for your user. It starts when the machine3On macOS or Linux, T3 Code can run as a background service for your user. The simplest setup is:
4boots and keeps running after you log out.4
5```sh
6npx t3 connect
7```
8
9After T3 Connect links the machine, accept the default background setup. T3 Code starts immediately
10and returns automatically after a reboot. On macOS, the per-user LaunchAgent starts after you sign
11in. On Linux, the systemd user service starts at boot and keeps running after you log out.
5 12
6## Manage the Service13## Manage the Service
7 14
@@ -33,10 +40,10 @@ Updating restarts T3 Code briefly. Let active agent work and terminal commands f · 25 unmodified lines
33 40
34## Using It with T3 Connect41## Using It with T3 Connect
35 42
36T3 Connect may offer to install the service during setup so the host stays reachable after you log43T3 Connect offers to install the service during setup so the host starts automatically and stays
37out. This is only an onboarding shortcut: the service and T3 Connect are managed separately.44reachable in the background. The service and T3 Connect are still managed separately.
38 45
39Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer46Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer
40want T3 Code to start in the background.47want T3 Code to start in the background.
41 48
42The background service currently requires Linux with systemd.49The 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
21export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;21export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type;
22 22
23/** How a server can replace itself with another version when asked over RPC:23/** 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"24 "boot-service" hands off through its systemd unit or macOS LaunchAgent;
25 installs the target version and respawns the foreground process. */25 "respawn" installs the target version and replaces the foreground process. */
26export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);26export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]);
27export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;27export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type;
28 28
packages/shared/src/hostProcess.ts+7−0
@@ -51,4 +51,11 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( · 50 unmodified lines
51 },51 },
52);52);
53 53
54export const HostProcessUserId = Context.Reference<number | null>(
55 "@t3tools/shared/hostProcess/HostProcessUserId",
56 {
57 defaultValue: () => (typeof process.getuid === "function" ? process.getuid() : null),
58 },
59);
60
54export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");61export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32");