| 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Console from "effect/Console"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Terminal from "effect/Terminal"; |
| 7 | import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; |
| 8 | import { FetchHttpClient } from "effect/http"; |
| 9 | |
| 10 | import * as BootService from "../cloud/bootService.ts"; |
| 11 | import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; |
| 12 | import type * as ServerConfig from "../config.ts"; |
| 13 | import * as ProcessRunner from "../processRunner.ts"; |
| 14 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 15 | |
| 16 | export const layer = (config: ServerConfig.ServerConfig["Service"]) => |
| 17 | BootService.layer({ |
| 18 | baseDir: config.baseDir, |
| 19 | logsDir: config.logsDir, |
| 20 | cliVersion: SERVER_VERSION, |
| 21 | }).pipe( |
| 22 | Layer.provide(ProcessRunner.layer), |
| 23 | // Archive-distributed versions download the release archive here. |
| 24 | Layer.provide(FetchHttpClient.layer), |
| 25 | ); |
| 26 | |
| 27 | export type ServiceReconcileResult = |
| 28 | | { |
| 29 | readonly changed: false; |
| 30 | readonly status: BootService.BootServiceStatus; |
| 31 | } |
| 32 | | { |
| 33 | readonly changed: true; |
| 34 | readonly previouslyInstalled: boolean; |
| 35 | readonly plan: BootService.BootServicePlan; |
| 36 | }; |
| 37 | |
| 38 | /** Install, update, or repair the service using the CLI version running this command. */ |
| 39 | export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { |
| 40 | readonly allowDowngrade?: boolean; |
| 41 | readonly start?: boolean; |
| 42 | }) { |
| 43 | const service = yield* BootService.BootService; |
| 44 | const status = yield* service.status; |
| 45 | if (status.installed && status.current) { |
| 46 | return { changed: false, status } satisfies ServiceReconcileResult; |
| 47 | } |
| 48 | if ( |
| 49 | status.installedVersion !== undefined && |
| 50 | options?.allowDowngrade !== true && |
| 51 | compareExactServiceVersions(SERVER_VERSION, status.installedVersion) < 0 |
| 52 | ) { |
| 53 | return yield* new BootService.BootServiceDowngradeRefusedError({ |
| 54 | installedVersion: status.installedVersion, |
| 55 | targetVersion: SERVER_VERSION, |
| 56 | }); |
| 57 | } |
| 58 | const plan = yield* service.install(options); |
| 59 | return { |
| 60 | changed: true, |
| 61 | previouslyInstalled: status.installed, |
| 62 | plan, |
| 63 | } satisfies ServiceReconcileResult; |
| 64 | }); |
| 65 | |
| 66 | export function formatServiceStatus( |
| 67 | status: BootService.BootServiceStatus, |
| 68 | cliVersion: string, |
| 69 | ): string { |
| 70 | if (!status.supported) { |
| 71 | return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd"; |
| 72 | } |
| 73 | if (!status.installed) { |
| 74 | return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`."; |
| 75 | } |
| 76 | const installedVersion = status.installedVersion ?? cliVersion; |
| 77 | const problems = (status.problems ?? []).map( |
| 78 | (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`, |
| 79 | ); |
| 80 | if ( |
| 81 | !status.current && |
| 82 | status.installedVersion !== undefined && |
| 83 | compareExactServiceVersions(status.installedVersion, cliVersion) > 0 |
| 84 | ) { |
| 85 | return [ |
| 86 | "T3 Code service", |
| 87 | ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, |
| 88 | ` Unit: ${status.unitPath}`, |
| 89 | ` Logs: ${status.logPath}`, |
| 90 | ...problems, |
| 91 | ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`, |
| 92 | ].join("\n"); |
| 93 | } |
| 94 | return [ |
| 95 | "T3 Code service", |
| 96 | ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`, |
| 97 | ` Unit: ${status.unitPath}`, |
| 98 | ` Logs: ${status.logPath}`, |
| 99 | ...problems, |
| 100 | ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]), |
| 101 | ].join("\n"); |
| 102 | } |
| 103 | |
| 104 | const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>( |
| 105 | flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] }, |
| 106 | run: Effect.Effect<A, E, BootService.BootService>, |
| 107 | ) { |
| 108 | const logLevel = yield* GlobalFlag.LogLevel; |
| 109 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 110 | return yield* run.pipe(Effect.provide(layer(config))); |
| 111 | }); |
| 112 | |
| 113 | const serviceReconcileFlags = { |
| 114 | ...projectLocationFlags, |
| 115 | allowDowngrade: Flag.Boolean("allow-downgrade").pipe( |
| 116 | Flag.withDescription("Allow replacing a newer installed service with this older CLI version."), |
| 117 | Flag.withDefault(false), |
| 118 | ), |
| 119 | }; |
| 120 | |
| 121 | const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe( |
| 122 | Command.withDescription("Install T3 Code as a background service for this user."), |
| 123 | Command.withHandler((flags) => |
| 124 | runServiceCommand( |
| 125 | flags, |
| 126 | Effect.gen(function* () { |
| 127 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 128 | if (!result.changed) { |
| 129 | yield* Console.log(`T3 Code service is already installed with t3@${SERVER_VERSION}.`); |
| 130 | return; |
| 131 | } |
| 132 | yield* Console.log( |
| 133 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, |
| 134 | ); |
| 135 | }), |
| 136 | ), |
| 137 | ), |
| 138 | ); |
| 139 | |
| 140 | // Kept one release for muscle memory and old docs. It did what `t3 service |
| 141 | // install` does; the way to move to a newer release is `t3 update`. |
| 142 | const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( |
| 143 | Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."), |
| 144 | Command.unlisted, |
| 145 | Command.withHandler((flags) => |
| 146 | runServiceCommand( |
| 147 | flags, |
| 148 | Effect.gen(function* () { |
| 149 | yield* Console.log( |
| 150 | "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.", |
| 151 | ); |
| 152 | const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); |
| 153 | if (!result.changed) { |
| 154 | yield* Console.log(`T3 Code service is already using t3@${SERVER_VERSION}.`); |
| 155 | return; |
| 156 | } |
| 157 | yield* Console.log( |
| 158 | `${result.previouslyInstalled ? "Updated" : "Installed"} T3 Code service with t3@${SERVER_VERSION}.\nLogs: ${result.plan.logPath}`, |
| 159 | ); |
| 160 | }), |
| 161 | ), |
| 162 | ), |
| 163 | ); |
| 164 | |
| 165 | const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe( |
| 166 | Command.withDescription( |
| 167 | "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.", |
| 168 | ), |
| 169 | Command.withHandler((flags) => |
| 170 | runServiceCommand( |
| 171 | flags, |
| 172 | Effect.gen(function* () { |
| 173 | const service = yield* BootService.BootService; |
| 174 | const status = yield* service.status; |
| 175 | const restarted = yield* service.restart; |
| 176 | yield* Console.log( |
| 177 | restarted |
| 178 | ? `Restarted the T3 Code service${status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`}.` |
| 179 | : "T3 Code service is not installed.", |
| 180 | ); |
| 181 | }), |
| 182 | ), |
| 183 | ), |
| 184 | ); |
| 185 | |
| 186 | const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe( |
| 187 | Command.withDescription("Stop and remove the T3 Code background service."), |
| 188 | Command.withHandler((flags) => |
| 189 | runServiceCommand( |
| 190 | flags, |
| 191 | Effect.gen(function* () { |
| 192 | const service = yield* BootService.BootService; |
| 193 | const removed = yield* service.uninstall; |
| 194 | yield* Console.log( |
| 195 | removed ? "Removed the T3 Code service." : "T3 Code service is not installed.", |
| 196 | ); |
| 197 | }), |
| 198 | ), |
| 199 | ), |
| 200 | ); |
| 201 | |
| 202 | const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( |
| 203 | Command.withDescription("Show whether the T3 Code background service is installed."), |
| 204 | Command.withHandler((flags) => |
| 205 | runServiceCommand( |
| 206 | flags, |
| 207 | Effect.gen(function* () { |
| 208 | const service = yield* BootService.BootService; |
| 209 | yield* Console.log(formatServiceStatus(yield* service.status, SERVER_VERSION)); |
| 210 | }), |
| 211 | ), |
| 212 | ), |
| 213 | ); |
| 214 | |
| 215 | export const offerServiceDuringOnboarding = Effect.gen(function* () { |
| 216 | const service = yield* BootService.BootService; |
| 217 | const status = yield* service.status; |
| 218 | const { supported, installed, current } = status; |
| 219 | if (!supported) { |
| 220 | return false; |
| 221 | } |
| 222 | if (installed && current) { |
| 223 | yield* Console.log("T3 Code is already set up to run in the background on this machine."); |
| 224 | return true; |
| 225 | } |
| 226 | for (const problem of status.problems ?? []) { |
| 227 | yield* Console.warn(`[${problem}] ${BootService.formatBootServiceProblem(problem)}`); |
| 228 | } |
| 229 | if ( |
| 230 | installed && |
| 231 | status.installedVersion !== undefined && |
| 232 | compareExactServiceVersions(status.installedVersion, SERVER_VERSION) > 0 |
| 233 | ) { |
| 234 | yield* Console.log( |
| 235 | `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, |
| 236 | ); |
| 237 | // This CLI cannot verify the newer service. Keep the manual fallback available. |
| 238 | return false; |
| 239 | } |
| 240 | // A LaunchAgent starts at login and dies at logout; there is no |
| 241 | // enable-linger equivalent on macOS. Do not promise more than that. |
| 242 | const platform = yield* HostProcess.Platform; |
| 243 | const wanted = yield* Prompt.run( |
| 244 | Prompt.Confirm({ |
| 245 | message: installed |
| 246 | ? "The installed T3 Code service needs an update or repair. Update it now?" |
| 247 | : platform === "darwin" |
| 248 | ? "Run T3 Code in the background whenever you log in to this Mac? " + |
| 249 | "It stays reachable through T3 Connect while you are logged in." |
| 250 | : "Run T3 Code in the background whenever this machine boots? " + |
| 251 | "It stays reachable through T3 Connect even after you log out.", |
| 252 | initial: true, |
| 253 | }), |
| 254 | ); |
| 255 | if (!wanted) { |
| 256 | return false; |
| 257 | } |
| 258 | const result = yield* reconcileService(); |
| 259 | if (result.changed) { |
| 260 | yield* Console.log( |
| 261 | `Background service ${result.previouslyInstalled ? "updated" : "installed"}. Logs: ${result.plan.logPath}`, |
| 262 | ); |
| 263 | } |
| 264 | return true; |
| 265 | }); |
| 266 | |
| 267 | export const recoverServiceOnboardingOffer = <R>( |
| 268 | offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>, |
| 269 | ) => |
| 270 | offer.pipe( |
| 271 | Effect.catchTags({ |
| 272 | QuitError: () => Effect.succeed(false), |
| 273 | BootServiceUnsupportedError: (error) => |
| 274 | Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)), |
| 275 | BootServiceCommandError: (error) => |
| 276 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 277 | BootServiceInstallError: (error) => |
| 278 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 279 | BootServicePrerequisiteError: (error) => |
| 280 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 281 | BootServiceUpdatePendingError: (error) => |
| 282 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 283 | BootServiceDowngradeRefusedError: (error) => |
| 284 | Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), |
| 285 | }), |
| 286 | ); |
| 287 | |
| 288 | export const serviceCommand = Command.make("service").pipe( |
| 289 | Command.withDescription("Manage the T3 Code background service."), |
| 290 | Command.withSubcommands([ |
| 291 | serviceInstallCommand, |
| 292 | serviceRestartCommand, |
| 293 | serviceUninstallCommand, |
| 294 | serviceStatusCommand, |
| 295 | serviceUpdateCommand, |
| 296 | ]), |
| 297 | ); |