Unified · Split
| @@ -23,3 +23,25 @@ export const writeFileStringAtomically = (input: { · 22 unmodified lines | ||
| 23 | 23 | yield* fs.rename(tempPath, input.filePath); |
| 24 | 24 | }), |
| 25 | 25 | ); |
| 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 | + ); | |
| @@ -695,7 +695,7 @@ export const connectCommand = Command.make("connect", { · 694 unmodified lines | ||
| 695 | 695 | const background = yield* recoverServiceOnboardingOffer(offerServiceDuringOnboarding); |
| 696 | 696 | if (background) { |
| 697 | 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 | 700 | return; |
| 701 | 701 | } |
| @@ -16,7 +16,7 @@ it("reports the installed service version and host paths", () => { · 15 unmodified lines | ||
| 16 | 16 | [ |
| 17 | 17 | "T3 Code service", |
| 18 | 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 | 20 | " Logs: /home/me/.t3/userdata/logs/boot-service.log", |
| 21 | 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 | |
| 32 | −it("explains service availability without systemd", () => { | |
| 32 | +it("explains service availability on unsupported platforms", () => { | |
| 33 | 33 | assert.include( |
| 34 | 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 | }); |
| @@ -48,7 +48,7 @@ export function formatServiceStatus( · 47 unmodified lines | ||
| 48 | 48 | cliVersion: string, |
| 49 | 49 | ): string { |
| 50 | 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 | 53 | if (!status.installed) { |
| 54 | 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 | 56 | return [ |
| 57 | 57 | "T3 Code service", |
| 58 | 58 | ` Status: ${status.current ? `installed · t3@${cliVersion}` : "needs an update or repair"}`, |
| 59 | − ` Unit: ${status.unitPath}`, | |
| 59 | + ` Definition: ${status.unitPath}`, | |
| 60 | 60 | ` Logs: ${status.logPath}`, |
| 61 | 61 | ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), |
| 62 | 62 | ].join("\n"); |
| @@ -156,8 +156,7 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { · 93 unmodified lines | ||
| 156 | 156 | Prompt.confirm({ |
| 157 | 157 | message: installed |
| 158 | 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? " + | |
| 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?", | |
| 161 | 160 | initial: true, |
| 162 | 161 | }), |
| 163 | 162 | ); |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | HostProcessArguments, |
| 13 | 13 | HostProcessExecutablePath, |
| 14 | 14 | HostProcessPlatform, |
| 15 | + HostProcessUserId, | |
| 15 | 16 | } from "@t3tools/shared/hostProcess"; |
| 16 | 17 | |
| 17 | 18 | import { reconcileService } from "../cli/service.ts"; |
| @@ -64,6 +65,7 @@ const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") => · 46 unmodified lines | ||
| 64 | 65 | Effect.provide( |
| 65 | 66 | Layer.mergeAll( |
| 66 | 67 | Layer.succeed(HostProcessPlatform, platform), |
| 68 | + Layer.succeed(HostProcessUserId, 501), | |
| 67 | 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 | 145 | assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log"); |
| 144 | 146 | }); |
| 145 | 147 | |
| 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 & Data</string>"); | |
| 162 | + assert.include( | |
| 163 | + launchAgent, | |
| 164 | + "<string>/Users/theo/T3 & 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 | + | |
| 146 | 176 | it("flags package-manager cache entry points as ephemeral", () => { |
| 147 | 177 | assert.isTrue( |
| 148 | 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 | 422 | Effect.gen(function* () { |
| 393 | 423 | const { dirs, fs, path } = yield* makeTestContext(); |
| 394 | 424 | const commands: Array<RecordedCommand> = []; |
| @@ -396,12 +426,122 @@ it.layer(NodeServices.layer)("BootService", (it) => { · 1 unmodified line | ||
| 396 | 426 | baseDir: dirs.baseDir, |
| 397 | 427 | logsDir: dirs.logsDir, |
| 398 | 428 | cliVersion: "0.0.27", |
| 399 | − host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"), | |
| 429 | + host: makeHost(dirs.stableEntry), | |
| 400 | 430 | }).pipe( |
| 401 | 431 | Effect.provide(makeRecordingRunnerLayer(commands)), |
| 402 | 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 | 545 | const error = yield* service.install.pipe(Effect.flip); |
| 406 | 546 | assert.isTrue(isUnsupportedError(error)); |
| 407 | 547 | assert.lengthOf(commands, 0); |
| @@ -11,16 +11,19 @@ import * as Schema from "effect/Schema"; · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | import { |
| 13 | 13 | HostProcessArguments, |
| 14 | + HostProcessEnvironment, | |
| 14 | 15 | HostProcessExecutablePath, |
| 15 | 16 | HostProcessPlatform, |
| 17 | + HostProcessUserId, | |
| 16 | 18 | } from "@t3tools/shared/hostProcess"; |
| 17 | 19 | |
| 20 | +import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts"; | |
| 18 | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 19 | 22 | import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; |
| 20 | 23 | |
| 21 | 24 | /** |
| 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 | |
| 24 | 27 | * runtime — never an ephemeral `npx t3` cache whose eviction could break |
| 25 | 28 | * startup. |
| 26 | 29 | */ |
| @@ -29,6 +32,8 @@ const BOOT_SERVICE_NAME = "t3code"; · 2 unmodified lines | ||
| 29 | 32 | |
| 30 | 33 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; |
| 31 | 34 | 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`; | |
| 32 | 37 | |
| 33 | 38 | const EPHEMERAL_CACHE_SEGMENTS = [ |
| 34 | 39 | "/_npx/", // npx |
| @@ -76,6 +81,39 @@ export interface BootServicePlan { · 41 unmodified lines | ||
| 76 | 81 | readonly baseDir: string; |
| 77 | 82 | readonly logPath: string; |
| 78 | 83 | 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("&", "&") | |
| 113 | + .replaceAll("<", "<") | |
| 114 | + .replaceAll(">", ">") | |
| 115 | + .replaceAll('"', """) | |
| 116 | + .replaceAll("'", "'"); | |
| 79 | 117 | } |
| 80 | 118 | |
| 81 | 119 | /** |
| @@ -114,12 +152,61 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { · 32 unmodified lines | ||
| 114 | 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 | + */ | |
| 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 | + | |
| 117 | 204 | export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( |
| 118 | 205 | "BootServiceUnsupportedError", |
| 119 | 206 | { platform: Schema.String }, |
| 120 | 207 | ) { |
| 121 | 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 | 223 | 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; | |
| 140 | 231 | } |
| 141 | 232 | } |
| 142 | 233 | |
| @@ -157,7 +248,7 @@ export type BootServiceError = · 14 unmodified lines | ||
| 157 | 248 | export interface BootServiceStatus { |
| 158 | 249 | readonly supported: boolean; |
| 159 | 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 | 252 | readonly current: boolean; |
| 162 | 253 | readonly unitPath: string; |
| 163 | 254 | readonly logPath: string; |
| @@ -166,7 +257,7 @@ export interface BootServiceStatus { · 2 unmodified lines | ||
| 166 | 257 | export class BootService extends Context.Service< |
| 167 | 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 | 261 | readonly install: Effect.Effect<BootServicePlan, BootServiceError>; |
| 171 | 262 | /** |
| 172 | 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 | 283 | const hostArguments = yield* HostProcessArguments; |
| 193 | 284 | const host = input.host ?? { |
| 194 | 285 | 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. | |
| 197 | 288 | cliEntryPath: hostArguments[1] ?? "", |
| 198 | 289 | }; |
| 199 | 290 | const platform = yield* HostProcessPlatform; |
| 291 | + const hostEnvironment = yield* HostProcessEnvironment; | |
| 292 | + const userId = yield* HostProcessUserId; | |
| 200 | 293 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); |
| 201 | 294 | const fs = yield* FileSystem.FileSystem; |
| 202 | 295 | const path = yield* Path.Path; |
| 203 | 296 | const runner = yield* ProcessRunner.ProcessRunner; |
| 204 | 297 | |
| 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; | |
| 207 | 308 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 208 | 309 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); |
| 209 | 310 | |
| 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 | + ) { | |
| 212 | 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 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. | |
| 253 | 367 | */ |
| 254 | 368 | const ensurePinnedRuntime = Effect.gen(function* () { |
| 255 | − if (!isEphemeralCacheEntry(host.cliEntryPath)) { | |
| 369 | + if (platform !== "darwin" && !isEphemeralCacheEntry(host.cliEntryPath)) { | |
| 256 | 370 | return; |
| 257 | 371 | } |
| 258 | 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, 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; | |
| 294 | 412 | const plan: BootServicePlan = { |
| 295 | 413 | nodePath: host.execPath, |
| 296 | 414 | t3EntryPath: plannedEntryPath, |
| 297 | 415 | baseDir: input.baseDir, |
| 298 | 416 | logPath, |
| 299 | 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 | 439 | const install: BootService["Service"]["install"] = Effect.gen(function* () { |
| 303 | − yield* requireSystemdLinux; | |
| 440 | + yield* requireSupportedPlatform; | |
| 304 | 441 | yield* fs |
| 305 | 442 | .makeDirectory(input.logsDir, { recursive: true }) |
| 306 | 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 | 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 | 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 | 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 | 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 | 504 | yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]); |
| 329 | 505 | yield* runStep("enabling the service", "systemctl", [ |
| 330 | 506 | "--user", |
| @@ -344,7 +520,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 13 unmodified lines | ||
| 344 | 520 | // username argument: loginctl defaults to the calling user, which is |
| 345 | 521 | // always right, while $USER can be stale (su without -l) or unset. |
| 346 | 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 | 525 | return plan; |
| 350 | 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 | 532 | // next lifecycle command misreports the state. |
| 357 | 533 | const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* ( |
| 358 | 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 | 553 | if (Option.isSome(previousUnit)) { |
| 361 | − yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore); | |
| 554 | + yield* writeUnit(previousUnit.value).pipe(Effect.ignore); | |
| 362 | 555 | } else { |
| 363 | 556 | yield* runStep("cleaning up the service", "systemctl", [ |
| 364 | 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 | 576 | const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { |
| 384 | − yield* requireSystemdLinux; | |
| 577 | + yield* requireSupportedPlatform; | |
| 385 | 578 | const exists = yield* fs |
| 386 | 579 | .exists(unitPath) |
| 387 | 580 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 388 | 581 | if (!exists) { |
| 389 | 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 | 593 | yield* runStep("stopping the service", "systemctl", [ |
| 392 | 594 | "--user", |
| 393 | 595 | "disable", |
| @@ -402,7 +604,11 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { · 8 unmodified lines | ||
| 402 | 604 | }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); |
| 403 | 605 | |
| 404 | 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 | 612 | return { supported: false, installed: false, current: false, unitPath, logPath }; |
| 407 | 613 | } |
| 408 | 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 | 616 | return { supported: true, installed: false, current: false, unitPath, logPath }; |
| 411 | 617 | } |
| 412 | 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 | 634 | // A unit is current only if it matches what install would write now (an |
| 414 | 635 | // older CLI wrote a different runtime/node path) AND the entry point it |
| 415 | 636 | // references still exists (a pinned runtime under ~/.t3 can be deleted to |
| 416 | 637 | // reclaim space). Either mismatch makes connect offer a repair. |
| 417 | − const entryExists = yield* fs.exists(plannedEntryPath); | |
| 418 | 638 | const current = unit === renderBootServiceUnit(plan) && entryExists; |
| 419 | 639 | return { supported: true, installed: true, current, unitPath, logPath }; |
| 420 | 640 | }).pipe( |
| @@ -13,13 +13,17 @@ import { · 12 unmodified lines | ||
| 13 | 13 | HostProcessEnvironment, |
| 14 | 14 | HostProcessExecutablePath, |
| 15 | 15 | HostProcessPlatform, |
| 16 | + HostProcessUserId, | |
| 16 | 17 | } from "@t3tools/shared/hostProcess"; |
| 17 | 18 | |
| 18 | 19 | import * as ServerConfig from "../config.ts"; |
| 19 | 20 | import * as ProcessRunner from "../processRunner.ts"; |
| 20 | 21 | import { |
| 22 | + BOOT_SERVICE_LAUNCH_AGENT_LABEL, | |
| 21 | 23 | BOOT_SERVICE_UNIT_ENV, |
| 22 | 24 | BOOT_SERVICE_UNIT_FILE, |
| 25 | + launchAgentPaths, | |
| 26 | + renderBootServiceLaunchAgent, | |
| 23 | 27 | renderBootServiceUnit, |
| 24 | 28 | } from "./bootService.ts"; |
| 25 | 29 | import * as SelfUpdate from "./selfUpdate.ts"; |
| @@ -76,6 +80,7 @@ const provideHostRefs = (input: { · 50 unmodified lines | ||
| 76 | 80 | Layer.succeed(HostProcessEnvironment, input.env), |
| 77 | 81 | Layer.succeed(HostProcessExecutablePath, NODE_PATH), |
| 78 | 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 | 235 | it.effect("reports desktop-managed for desktop-supervised backends", () => |
| 196 | 236 | Effect.gen(function* () { |
| 197 | 237 | const { home, path } = yield* makeHome(); |
| @@ -262,11 +302,21 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 64 unmodified lines | ||
| 262 | 302 | const path = yield* Path.Path; |
| 263 | 303 | const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-self-update-test-" }); |
| 264 | 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 | 308 | const entryPath = |
| 266 | 309 | 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 | |
| 270 | 320 | ? { |
| 271 | 321 | HOME: home, |
| 272 | 322 | INVOCATION_ID: "abc123", |
| @@ -274,18 +324,37 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line | ||
| 274 | 324 | } |
| 275 | 325 | : { HOME: home }; |
| 276 | 326 | 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 | + } | |
| 289 | 358 | } |
| 290 | 359 | |
| 291 | 360 | const commands: Array<RecordedCommand> = []; |
| @@ -333,7 +402,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 41 unmodified lines | ||
| 333 | 402 | configLayer, |
| 334 | 403 | ), |
| 335 | 404 | ), |
| 336 | − provideHostRefs({ platform: options?.platform ?? "linux", env, entryPath }), | |
| 405 | + provideHostRefs({ platform, env, entryPath }), | |
| 337 | 406 | ); |
| 338 | 407 | return { |
| 339 | 408 | fs, |
| @@ -341,6 +410,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 1 unmodified line | ||
| 341 | 410 | home, |
| 342 | 411 | baseDir, |
| 343 | 412 | entryPath, |
| 413 | + launchAgent, | |
| 344 | 414 | commands, |
| 345 | 415 | spawns, |
| 346 | 416 | exitCount: () => exited, |
| @@ -520,6 +590,53 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { · 173 unmodified lines | ||
| 520 | 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 | 640 | it.effect("restores the previous unit and permits a retry when systemd restart fails", () => |
| 524 | 641 | Effect.gen(function* () { |
| 525 | 642 | let failRestart = true; |
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | HostProcessEnvironment, |
| 14 | 14 | HostProcessExecutablePath, |
| 15 | 15 | HostProcessPlatform, |
| 16 | + HostProcessUserId, | |
| 16 | 17 | } from "@t3tools/shared/hostProcess"; |
| 17 | 18 | import * as NodeChildProcess from "node:child_process"; |
| 18 | 19 | import * as Context from "effect/Context"; |
| @@ -24,11 +25,13 @@ import * as Path from "effect/Path"; · 5 unmodified lines | ||
| 24 | 25 | import * as Ref from "effect/Ref"; |
| 25 | 26 | |
| 26 | 27 | import * as ServerConfig from "../config.ts"; |
| 27 | −import { writeFileStringAtomically } from "../atomicWrite.ts"; | |
| 28 | +import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts"; | |
| 28 | 29 | import * as ProcessRunner from "../processRunner.ts"; |
| 29 | 30 | import { |
| 31 | + BOOT_SERVICE_LAUNCH_AGENT_LABEL, | |
| 30 | 32 | BOOT_SERVICE_UNIT_ENV, |
| 31 | 33 | BOOT_SERVICE_UNIT_FILE, |
| 34 | + launchAgentPaths, | |
| 32 | 35 | quoteSystemdValue, |
| 33 | 36 | renderBootServiceUnit, |
| 34 | 37 | } from "./bootService.ts"; |
| @@ -82,11 +85,11 @@ export function isPublishedCliEntry(entryPath: string): boolean { · 47 unmodified lines | ||
| 82 | 85 | * The update path this process can offer, or null when only a manual |
| 83 | 86 | * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this |
| 84 | 87 | * 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). | |
| 90 | 93 | */ |
| 91 | 94 | export const resolveServerSelfUpdateCapability = Effect.fn( |
| 92 | 95 | "cloud.server_self_update.resolve_capability", |
| @@ -101,6 +104,7 @@ export const resolveServerSelfUpdateCapability = Effect.fn( · 8 unmodified lines | ||
| 101 | 104 | const platform = yield* HostProcessPlatform; |
| 102 | 105 | const env = yield* HostProcessEnvironment; |
| 103 | 106 | const hostArguments = yield* HostProcessArguments; |
| 107 | + const userId = yield* HostProcessUserId; | |
| 104 | 108 | const fs = yield* FileSystem.FileSystem; |
| 105 | 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 | 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 | 137 | if (platform === "linux" && homeDir !== "") { |
| 114 | 138 | const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); |
| 115 | 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 | 183 | const path = yield* Path.Path; |
| 160 | 184 | const runner = yield* ProcessRunner.ProcessRunner; |
| 161 | 185 | const env = yield* HostProcessEnvironment; |
| 186 | + const platform = yield* HostProcessPlatform; | |
| 187 | + const userId = yield* HostProcessUserId; | |
| 162 | 188 | const hostExecPath = yield* HostProcessExecutablePath; |
| 163 | 189 | const hostArguments = yield* HostProcessArguments; |
| 164 | 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 | 250 | Effect.provideService(FileSystem.FileSystem, fs), |
| 225 | 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 | 259 | const update: ServerSelfUpdate["Service"]["update"] = Effect.fn( |
| 229 | 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 | 383 | const homeDir = env.HOME ?? ""; |
| 302 | 384 | const unitPath = path.join(homeDir, ".config", "systemd", "user", BOOT_SERVICE_UNIT_FILE); |
| 303 | 385 | const previousUnit = yield* fs |
| @@ -32,17 +32,17 @@ the mismatch and action disappear. · 31 unmodified lines | ||
| 32 | 32 | |
| 33 | 33 | The server resolves its capability once at startup and publishes it in the environment descriptor. |
| 34 | 34 | |
| 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. | | |
| 41 | 41 | |
| 42 | 42 | Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must |
| 43 | 43 | 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. | |
| 46 | 46 | |
| 47 | 47 | ## Update Flow |
| 48 | 48 | |
| @@ -56,7 +56,7 @@ flowchart TD · 7 unmodified lines | ||
| 56 | 56 | F --> G[Run version preflight] |
| 57 | 57 | G -->|fails| H[Remove failed runtime and keep current server] |
| 58 | 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 | 60 | I -->|respawn| K[Start delayed replacement and exit current process] |
| 61 | 61 | J --> L[Client reconnects] |
| 62 | 62 | K --> L |
| @@ -77,10 +77,11 @@ preflight also removes the candidate runtime so retrying the same version perfor · 14 unmodified lines | ||
| 77 | 77 | |
| 78 | 78 | ## Host Service Lifecycle |
| 79 | 79 | |
| 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. | |
| 84 | 85 | |
| 85 | 86 | The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation |
| 86 | 87 | 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 | ||
| 88 | 89 | |
| 89 | 90 | ## Process Handoff |
| 90 | 91 | |
| 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. | |
| 94 | 99 | |
| 95 | 100 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI |
| 96 | 101 | arguments. It then acknowledges the request and schedules the current process to exit. The delays |
| @@ -1,7 +1,14 @@ | ||
| 1 | 1 | # Running T3 Code in the Background |
| 2 | 2 | |
| 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. | |
| 5 | 12 | |
| 6 | 13 | ## 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 | 41 | ## Using It with T3 Connect |
| 35 | 42 | |
| 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. | |
| 38 | 45 | |
| 39 | 46 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer |
| 40 | 47 | want T3 Code to start in the background. |
| 41 | 48 | |
| 42 | −The background service currently requires Linux with systemd. | |
| 49 | +The background service requires macOS with launchd or Linux with systemd. | |
| @@ -21,8 +21,8 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ · 20 unmodified lines | ||
| 21 | 21 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; |
| 22 | 22 | |
| 23 | 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" | |
| 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. */ | |
| 26 | 26 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); |
| 27 | 27 | export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; |
| 28 | 28 | |
| @@ -51,4 +51,11 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>( · 50 unmodified lines | ||
| 51 | 51 | }, |
| 52 | 52 | ); |
| 53 | 53 | |
| 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 | + | |
| 54 | 61 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |