Unified · Split
| @@ -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 | |||
| 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 | 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 | } |
| @@ -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 | ||
| 32 | it("explains service availability without systemd", () => { | 32 | it("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 | }); |
| @@ -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: installed | 157 | 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 | ); |
| @@ -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 | ||
| 17 | import { reconcileService } from "../cli/service.ts"; | 18 | import { 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 | ||
| 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 | it("flags package-manager cache entry points as ephemeral", () => { | 176 | it("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); |
| @@ -11,16 +11,19 @@ import * as Schema from "effect/Schema"; · 10 unmodified lines | |||
| 11 | 11 | ||
| 12 | import { | 12 | import { |
| 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 | ||
| 20 | import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts"; | ||
| 18 | import * as ProcessRunner from "../processRunner.ts"; | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 19 | import { ensurePinnedRuntimeInstalled, pinnedRuntimePaths } from "./pinnedRuntime.ts"; | 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 | 25 | * Installs T3 Code as a per-user background service: systemd + linger on |
| 23 | * user unit + loginctl enable-linger. The service runs a stable or pinned | 26 | * Linux, or a LaunchAgent on macOS. The service runs a stable or pinned |
| 24 | * runtime — never an ephemeral `npx t3` cache whose eviction could break | 27 | * 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 | ||
| 30 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; | 33 | export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; |
| 31 | export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; | 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 | const EPHEMERAL_CACHE_SEGMENTS = [ | 38 | const EPHEMERAL_CACHE_SEGMENTS = [ |
| 34 | "/_npx/", // npx | 39 | "/_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 | |||
| 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 | ].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 | */ | ||
| 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 | export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()( | 204 | export 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 === undefined | 224 | 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 | |||
| 157 | export interface BootServiceStatus { | 248 | export 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 | |||
| 166 | export class BootService extends Context.Service< | 257 | export 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 (global | 286 | // 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 stable | 363 | * Ensures plannedRuntimeEntryPath exists before the service points at it. |
| 249 | * install (global bin, repo checkout) is used as-is; an ephemeral cache | 364 | * macOS always gets an exact managed runtime so the LaunchAgent can use a |
| 250 | * entry is replaced by `npm install --prefix`-ing the exact running | 365 | * stable symlink across remote updates. Linux can reuse a stable global or |
| 251 | * version into <baseDir>/runtime/versions/<v>. A real install (not a copy | 366 | * 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, so | 403 | // 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.entryPath | 406 | ? 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* fs | 441 | 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 is | 520 | // 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* fs | 578 | 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 (an | 634 | // 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 it | 635 | // 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 to | 636 | // 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( |
| @@ -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 | ||
| 18 | import * as ServerConfig from "../config.ts"; | 19 | import * as ServerConfig from "../config.ts"; |
| 19 | import * as ProcessRunner from "../processRunner.ts"; | 20 | import * as ProcessRunner from "../processRunner.ts"; |
| 20 | import { | 21 | import { |
| 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"; |
| 25 | import * as SelfUpdate from "./selfUpdate.ts"; | 29 | import * 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 === true | 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 | 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; |
| @@ -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"; |
| 17 | import * as NodeChildProcess from "node:child_process"; | 18 | import * as NodeChildProcess from "node:child_process"; |
| 18 | import * as Context from "effect/Context"; | 19 | import * as Context from "effect/Context"; |
| @@ -24,11 +25,13 @@ import * as Path from "effect/Path"; · 5 unmodified lines | |||
| 24 | import * as Ref from "effect/Ref"; | 25 | import * as Ref from "effect/Ref"; |
| 25 | 26 | ||
| 26 | import * as ServerConfig from "../config.ts"; | 27 | import * as ServerConfig from "../config.ts"; |
| 27 | import { writeFileStringAtomically } from "../atomicWrite.ts"; | 28 | import { writeFileStringAtomically, writeSymbolicLinkAtomically } from "../atomicWrite.ts"; |
| 28 | import * as ProcessRunner from "../processRunner.ts"; | 29 | import * as ProcessRunner from "../processRunner.ts"; |
| 29 | import { | 30 | import { |
| 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 manual | 85 | * The update path this process can offer, or null when only a manual |
| 83 | * relaunch works. "desktop-managed" — the T3 Code desktop app spawned this | 86 | * 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 from | 88 | * "boot-service" — this is the systemd- or launchd-supervised process from |
| 86 | * bootService.ts: rewrite the unit and let systemd swap it. "respawn" — a | 89 | * bootService.ts: switch the service to the verified runtime and restart it. |
| 87 | * foreground POSIX process running a published artifact: replace it with a | 90 | * "respawn" — a foreground POSIX process running a published artifact: |
| 88 | * detached child. Windows foreground runs are unsupported for now (no | 91 | * 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 | */ |
| 91 | export const resolveServerSelfUpdateCapability = Effect.fn( | 94 | export 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* fs | 385 | const previousUnit = yield* fs |
| @@ -32,17 +32,17 @@ the mismatch and action disappear. · 31 unmodified lines | |||
| 32 | 32 | ||
| 33 | The server resolves its capability once at startup and publishes it in the environment descriptor. | 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 | | 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 | ||
| 42 | Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must | 42 | Desktop ownership takes precedence over process-shape detection. A desktop-managed backend must |
| 43 | never spawn a second CLI server beside the app-owned process. Likewise, a process launched by an | 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 | 44 | unrecognized systemd unit or marked LaunchAgent does not claim the foreground respawn path because |
| 45 | bring the old version back. | 45 | its supervisor could bring the old version back. |
| 46 | 46 | ||
| 47 | ## Update Flow | 47 | ## 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 --> L | 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 | ## Host Service Lifecycle | 78 | ## Host Service Lifecycle |
| 79 | 79 | ||
| 80 | The systemd user service is a host lifecycle concern, not a T3 Connect resource. The standalone | 80 | The 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 both | 81 | resource. The standalone `t3 service install`, `uninstall`, `update`, and `status` commands own it. |
| 82 | reconcile the unit through `BootService`; running `npx t3@latest service update` therefore pins and | 82 | Install and update both reconcile the unit through `BootService`; running |
| 83 | activates 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 |
| 84 | a connected client. | ||
| 84 | 85 | ||
| 85 | The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation | 86 | The `t3 connect` onboarding flow may offer service installation, but it calls the same reconciliation |
| 86 | operation as `t3 service install`. Connect logout only disables cloud access and clears its | 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 | ## Process Handoff | 90 | ## Process Handoff |
| 90 | 91 | ||
| 91 | For `boot-service`, the server atomically rewrites the T3-managed user unit to point at the verified | 92 | For a systemd `boot-service`, the server atomically rewrites the T3-managed user unit to point at |
| 92 | runtime, reloads systemd, and restarts the unit. Reload and restart failures restore the previous | 93 | the verified runtime, reloads systemd, and restarts the unit. Reload and restart failures restore |
| 93 | unit before returning an error. | 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 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI | 100 | For `respawn`, the server starts a detached, delayed replacement that replays the original CLI |
| 96 | arguments. It then acknowledges the request and schedules the current process to exit. The delays | 101 | arguments. It then acknowledges the request and schedules the current process to exit. The delays |
| @@ -1,7 +1,14 @@ | |||
| 1 | # Running T3 Code in the Background | 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 | 3 | On macOS or Linux, T3 Code can run as a background service for your user. The simplest setup is: |
| 4 | boots and keeps running after you log out. | 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 | ## Manage the Service | 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 | ## Using It with T3 Connect | 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 | 43 | T3 Connect offers to install the service during setup so the host starts automatically and stays |
| 37 | out. This is only an onboarding shortcut: the service and T3 Connect are managed separately. | 44 | reachable in the background. The service and T3 Connect are still managed separately. |
| 38 | 45 | ||
| 39 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer | 46 | Signing out of T3 Connect does not remove the service. Use `t3 service uninstall` when you no longer |
| 40 | want T3 Code to start in the background. | 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 | export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; | 21 | export 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. */ |
| 26 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); | 26 | export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); |
| 27 | export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; | 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 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); | 61 | export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); |