| 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import { |
| 4 | CLI_RELEASE_BASE_URL_ENV, |
| 5 | CLI_RELEASE_CHANNELS, |
| 6 | cliReleaseIndexPageUrl, |
| 7 | cliReleaseChannelOf, |
| 8 | newestCliReleaseVersion, |
| 9 | type CliReleaseChannel, |
| 10 | } from "@t3tools/shared/cliRelease"; |
| 11 | import * as Console from "effect/Console"; |
| 12 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Path from "effect/Path"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli"; |
| 20 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; |
| 21 | |
| 22 | import * as BootService from "../cloud/bootService.ts"; |
| 23 | import { |
| 24 | ensurePinnedRuntimeInstalled, |
| 25 | pinnedRuntimeCommand, |
| 26 | PinnedRuntimeInstallError, |
| 27 | pinnedRuntimePaths, |
| 28 | } from "../cloud/pinnedRuntime.ts"; |
| 29 | import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts"; |
| 30 | import * as ProcessRunner from "../processRunner.ts"; |
| 31 | import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 32 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 33 | import { createUpdateProgress } from "./updateProgress.ts"; |
| 34 | import * as CliService from "./service.ts"; |
| 35 | |
| 36 | export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", { |
| 37 | reason: Schema.String, |
| 38 | }) { |
| 39 | override get message(): string { |
| 40 | return this.reason; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | const ReleaseIndex = Schema.Array( |
| 45 | Schema.Struct({ |
| 46 | tag_name: Schema.String, |
| 47 | draft: Schema.optional(Schema.Boolean), |
| 48 | }), |
| 49 | ); |
| 50 | const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex)); |
| 51 | |
| 52 | const RELEASE_INDEX_TIMEOUT = Duration.seconds(30); |
| 53 | // Enough to walk past a long run of nightlies without hammering the API when |
| 54 | // a channel genuinely has nothing published. |
| 55 | const RELEASE_INDEX_MAX_PAGES = 10; |
| 56 | |
| 57 | /** Asks GitHub for the newest published version on a channel, page by page. */ |
| 58 | const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* ( |
| 59 | channel: CliReleaseChannel, |
| 60 | ) { |
| 61 | const httpClient = yield* HttpClient.HttpClient; |
| 62 | for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) { |
| 63 | const body = yield* httpClient |
| 64 | .execute( |
| 65 | HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe( |
| 66 | HttpClientRequest.setHeader("Accept", "application/vnd.github+json"), |
| 67 | ), |
| 68 | ) |
| 69 | .pipe( |
| 70 | Effect.flatMap(HttpClientResponse.filterStatusOk), |
| 71 | Effect.flatMap((response) => response.text), |
| 72 | Effect.mapError(() => new CliUpdateError({ reason: "Could not list t3 releases." })), |
| 73 | Effect.timeoutOrElse({ |
| 74 | duration: RELEASE_INDEX_TIMEOUT, |
| 75 | orElse: () => |
| 76 | Effect.fail(new CliUpdateError({ reason: "Timed out listing t3 releases." })), |
| 77 | }), |
| 78 | ); |
| 79 | const releases = yield* decodeReleaseIndex(body).pipe( |
| 80 | Effect.mapError( |
| 81 | () => new CliUpdateError({ reason: "The t3 release index had an unexpected shape." }), |
| 82 | ), |
| 83 | ); |
| 84 | const version = newestCliReleaseVersion(releases, channel); |
| 85 | if (version !== undefined) return version; |
| 86 | if (releases.length === 0) break; |
| 87 | } |
| 88 | return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` }); |
| 89 | }); |
| 90 | |
| 91 | /** Whether a launcher target lives inside `<baseDir>/runtime/versions`. */ |
| 92 | export function launcherOwnsVersionsDir( |
| 93 | path: Path.Path, |
| 94 | versionsDir: string, |
| 95 | candidate: string, |
| 96 | ): boolean { |
| 97 | const relative = path.relative(versionsDir, path.resolve(candidate)); |
| 98 | return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative); |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * The launcher the install scripts leave behind: a symlink at `<bin>/t3` on |
| 103 | * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3` |
| 104 | * invocation is the new version. Only a launcher that already points into |
| 105 | * this home's `runtime/versions` tree is touched; a plain copy of the |
| 106 | * executable, or a launcher for some other install, is left alone. |
| 107 | */ |
| 108 | export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: { |
| 109 | /** Path the current process was started through, if known. */ |
| 110 | readonly launchedAs: string | undefined; |
| 111 | /** `<baseDir>/runtime/versions` of the home being updated. */ |
| 112 | readonly versionsDir: string; |
| 113 | readonly targetEntryPath: string; |
| 114 | }) { |
| 115 | const fs = yield* FileSystem.FileSystem; |
| 116 | const path = yield* Path.Path; |
| 117 | const platform = yield* HostProcess.Platform; |
| 118 | if (input.launchedAs === undefined) return Option.none<string>(); |
| 119 | const ownsTarget = (candidate: string) => |
| 120 | launcherOwnsVersionsDir(path, input.versionsDir, candidate); |
| 121 | |
| 122 | if (platform === "win32") { |
| 123 | // The shim runs the executable by absolute path, so the executable sees |
| 124 | // itself as argv0; the shim is the `t3.cmd` next to it only when launched |
| 125 | // from an install script's bin directory. Find it by searching the |
| 126 | // directories that would resolve `t3` on this shell's PATH. |
| 127 | const shimPath = yield* findWindowsShim(input.launchedAs); |
| 128 | if (shimPath === undefined) return Option.none<string>(); |
| 129 | const current = yield* fs.readFileString(shimPath).pipe(Effect.option); |
| 130 | const quoted = Option.isSome(current) ? /^"([^"]+)"/m.exec(current.value)?.[1] : undefined; |
| 131 | if (quoted === undefined || !ownsTarget(quoted)) return Option.none<string>(); |
| 132 | yield* fs |
| 133 | .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`) |
| 134 | .pipe( |
| 135 | Effect.mapError( |
| 136 | () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }), |
| 137 | ), |
| 138 | ); |
| 139 | return Option.some(shimPath); |
| 140 | } |
| 141 | |
| 142 | const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option); |
| 143 | if (Option.isNone(linkTarget)) return Option.none<string>(); |
| 144 | const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value); |
| 145 | if (!ownsTarget(resolvedTarget)) return Option.none<string>(); |
| 146 | const tempLink = `${input.launchedAs}.${process.pid}.tmp`; |
| 147 | yield* fs.symlink(input.targetEntryPath, tempLink).pipe( |
| 148 | Effect.andThen(fs.rename(tempLink, input.launchedAs)), |
| 149 | Effect.mapError( |
| 150 | () => |
| 151 | new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }), |
| 152 | ), |
| 153 | ); |
| 154 | return Option.some(input.launchedAs); |
| 155 | }); |
| 156 | |
| 157 | /** |
| 158 | * The path the executable was started through. Node keeps the shell's |
| 159 | * spelling in argv0: a launcher symlink or `./t3` resolves against the |
| 160 | * working directory, while a bare `t3` was found on PATH and has to be |
| 161 | * looked up there again, or the launcher symlink is never seen. |
| 162 | */ |
| 163 | export const resolveLauncherPath = Effect.gen(function* () { |
| 164 | const path = yield* Path.Path; |
| 165 | const fs = yield* FileSystem.FileSystem; |
| 166 | const invokedAs = yield* HostProcess.InvokedAs; |
| 167 | const cwd = yield* HostProcess.WorkingDirectory; |
| 168 | const environment = yield* HostProcess.Environment; |
| 169 | const platform = yield* HostProcess.Platform; |
| 170 | if (invokedAs.includes("/") || invokedAs.includes("\\")) { |
| 171 | return path.resolve(cwd, invokedAs); |
| 172 | } |
| 173 | const delimiter = platform === "win32" ? ";" : ":"; |
| 174 | for (const directory of (environment["PATH"] ?? "").split(delimiter)) { |
| 175 | if (directory.length === 0) continue; |
| 176 | const candidate = path.join(directory, invokedAs); |
| 177 | if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { |
| 178 | return candidate; |
| 179 | } |
| 180 | } |
| 181 | return undefined; |
| 182 | }); |
| 183 | |
| 184 | /** |
| 185 | * On Windows a `.cmd` shim is what PATH resolves, but the executable it runs |
| 186 | * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the |
| 187 | * running executable; that is the launcher the install script wrote. |
| 188 | */ |
| 189 | export const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* ( |
| 190 | executablePath: string, |
| 191 | ) { |
| 192 | const fs = yield* FileSystem.FileSystem; |
| 193 | const path = yield* Path.Path; |
| 194 | const environment = yield* HostProcess.Environment; |
| 195 | const candidates = [ |
| 196 | ...(environment["T3CODE_INSTALL_BIN_DIR"] ? [environment["T3CODE_INSTALL_BIN_DIR"]] : []), |
| 197 | ...(environment["PATH"] ?? environment["Path"] ?? "").split(";"), |
| 198 | ].filter((entry) => entry.trim().length > 0); |
| 199 | for (const directory of candidates) { |
| 200 | const shimPath = path.join(directory, "t3.cmd"); |
| 201 | const contents = yield* fs.readFileString(shimPath).pipe(Effect.option); |
| 202 | if (Option.isNone(contents)) continue; |
| 203 | const target = /^"([^"]+)"/m.exec(contents.value)?.[1]; |
| 204 | if ( |
| 205 | target !== undefined && |
| 206 | path.resolve(target).toLowerCase() === path.resolve(executablePath).toLowerCase() |
| 207 | ) { |
| 208 | return shimPath; |
| 209 | } |
| 210 | } |
| 211 | return undefined; |
| 212 | }); |
| 213 | |
| 214 | const updateFlags = { |
| 215 | ...projectLocationFlags, |
| 216 | channel: Flag.Literals("channel", CLI_RELEASE_CHANNELS).pipe( |
| 217 | Flag.withDescription( |
| 218 | "Release channel to follow. Defaults to the channel this t3 was published on.", |
| 219 | ), |
| 220 | Flag.optional, |
| 221 | ), |
| 222 | allowDowngrade: Flag.Boolean("allow-downgrade").pipe( |
| 223 | Flag.withDescription("Allow moving to an older version than the one running."), |
| 224 | Flag.withDefault(false), |
| 225 | ), |
| 226 | yes: Flag.Boolean("yes").pipe( |
| 227 | Flag.withAlias("y"), |
| 228 | Flag.withDescription( |
| 229 | "Restart the background service without asking. Required to restart it from a script, where there is no prompt.", |
| 230 | ), |
| 231 | Flag.withDefault(false), |
| 232 | ), |
| 233 | }; |
| 234 | |
| 235 | const versionArgument = Argument.String("version").pipe( |
| 236 | Argument.withDescription( |
| 237 | "Exact version to install. Defaults to the newest release on the channel.", |
| 238 | ), |
| 239 | Argument.optional, |
| 240 | ); |
| 241 | |
| 242 | export const updateCommand = Command.make("update", { |
| 243 | ...updateFlags, |
| 244 | version: versionArgument, |
| 245 | }).pipe( |
| 246 | Command.withDescription( |
| 247 | "Download a newer t3 and switch this machine to it, including the background service when one is installed.", |
| 248 | ), |
| 249 | Command.withHandler((flags) => |
| 250 | Effect.gen(function* () { |
| 251 | const logLevel = yield* GlobalFlag.LogLevel; |
| 252 | const config = yield* resolveCliAuthConfig(flags, logLevel); |
| 253 | return yield* runUpdate({ |
| 254 | baseDir: config.baseDir, |
| 255 | logsDir: config.logsDir, |
| 256 | serverRuntimeStatePath: config.serverRuntimeStatePath, |
| 257 | channel: Option.getOrUndefined(flags.channel), |
| 258 | requestedVersion: Option.getOrUndefined(flags.version), |
| 259 | allowDowngrade: flags.allowDowngrade, |
| 260 | assumeYes: flags.yes, |
| 261 | }).pipe( |
| 262 | Effect.provide( |
| 263 | Layer.mergeAll(CliService.layer(config), ProcessRunner.layer, FetchHttpClient.layer), |
| 264 | ), |
| 265 | ); |
| 266 | }), |
| 267 | ), |
| 268 | ); |
| 269 | |
| 270 | /** |
| 271 | * A `t3 serve` or `t3` someone started by hand, as opposed to the one the |
| 272 | * background service supervises. The server records its pid on startup; a |
| 273 | * stale file from a crashed server is ignored by checking the pid is alive. |
| 274 | * |
| 275 | * Servers from before `serviceManaged` was recorded cannot be told apart by |
| 276 | * the file alone, so the launcher-supervised case is also recognised by |
| 277 | * lineage: a service server's parent is the launcher, and on Linux that |
| 278 | * launcher runs inside the unit's cgroup. |
| 279 | */ |
| 280 | const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(function* (input: { |
| 281 | readonly serverRuntimeStatePath: string; |
| 282 | readonly serviceInstalled: boolean; |
| 283 | }) { |
| 284 | const state = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath); |
| 285 | if (Option.isNone(state) || state.value.serviceManaged || !isProcessAlive(state.value.pid)) { |
| 286 | return undefined; |
| 287 | } |
| 288 | if (input.serviceInstalled && (yield* belongsToBootService(state.value.pid))) return undefined; |
| 289 | return state.value; |
| 290 | }); |
| 291 | |
| 292 | const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(function* ( |
| 293 | pid: number, |
| 294 | ) { |
| 295 | const platform = yield* HostProcess.Platform; |
| 296 | const fs = yield* FileSystem.FileSystem; |
| 297 | const runner = yield* ProcessRunner.ProcessRunner; |
| 298 | if (platform === "linux") { |
| 299 | const cgroup = yield* fs.readFileString(`/proc/${pid}/cgroup`).pipe(Effect.option); |
| 300 | return Option.isSome(cgroup) && cgroup.value.includes("/t3code.service"); |
| 301 | } |
| 302 | if (platform === "darwin") { |
| 303 | // The service server's parent is the launcher process. |
| 304 | const parent = yield* runner |
| 305 | .run({ |
| 306 | command: "ps", |
| 307 | args: ["-o", "ppid=", "-p", String(pid)], |
| 308 | timeout: Duration.seconds(5), |
| 309 | }) |
| 310 | .pipe(Effect.option); |
| 311 | const ppid = Option.isSome(parent) && parent.value.code === 0 ? parent.value.stdout.trim() : ""; |
| 312 | if (!/^\d+$/.test(ppid)) return false; |
| 313 | const command = yield* runner |
| 314 | .run({ command: "ps", args: ["-o", "command=", "-p", ppid], timeout: Duration.seconds(5) }) |
| 315 | .pipe( |
| 316 | Effect.map((result) => (result.code === 0 ? result.stdout : "")), |
| 317 | Effect.orElseSucceed(() => ""), |
| 318 | ); |
| 319 | return /__service-launcher/.test(command); |
| 320 | } |
| 321 | return false; |
| 322 | }); |
| 323 | |
| 324 | const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 325 | readonly baseDir: string; |
| 326 | readonly logsDir: string; |
| 327 | readonly serverRuntimeStatePath: string; |
| 328 | readonly channel: CliReleaseChannel | undefined; |
| 329 | readonly requestedVersion: string | undefined; |
| 330 | readonly allowDowngrade: boolean; |
| 331 | readonly assumeYes: boolean; |
| 332 | }) { |
| 333 | const fs = yield* FileSystem.FileSystem; |
| 334 | const path = yield* Path.Path; |
| 335 | const runner = yield* ProcessRunner.ProcessRunner; |
| 336 | const platform = yield* HostProcess.Platform; |
| 337 | const arch = yield* HostProcess.Architecture; |
| 338 | const environment = yield* HostProcess.Environment; |
| 339 | const httpClient = yield* HttpClient.HttpClient; |
| 340 | const service = yield* BootService.BootService; |
| 341 | |
| 342 | const currentVersion = SERVER_VERSION; |
| 343 | const channel = input.channel ?? cliReleaseChannelOf(currentVersion); |
| 344 | if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) { |
| 345 | return yield* new CliUpdateError({ |
| 346 | reason: `'${input.requestedVersion}' is not an exact t3 version.`, |
| 347 | }); |
| 348 | } |
| 349 | const progress = createUpdateProgress(); |
| 350 | progress.status("Checking for updates..."); |
| 351 | const targetVersion = yield* ( |
| 352 | input.requestedVersion === undefined |
| 353 | ? resolveNewestVersion(channel) |
| 354 | : Effect.succeed(input.requestedVersion) |
| 355 | ).pipe(Effect.ensuring(Effect.sync(progress.finish))); |
| 356 | const targetChannel = cliReleaseChannelOf(targetVersion); |
| 357 | |
| 358 | // Preview is a maintainers' dogfooding train: it is cut by hand from |
| 359 | // unmerged branches, receives no fixes, and is never offered to anyone. |
| 360 | // Reaching it from stable or nightly takes an explicit ask and an explicit |
| 361 | // acknowledgement; the flag alone is not enough from a script. |
| 362 | const currentChannel = cliReleaseChannelOf(currentVersion); |
| 363 | if (targetChannel === "preview" && currentChannel !== "preview") { |
| 364 | yield* Console.log( |
| 365 | [ |
| 366 | `t3@${targetVersion} is a preview build.`, |
| 367 | " Preview builds are cut by maintainers from unreleased branches to exercise the release", |
| 368 | " pipeline. They can be broken, receive no fixes, and are never offered as updates; you", |
| 369 | ` will have to switch back to ${currentChannel} yourself with \`t3 update --channel ${currentChannel} --allow-downgrade\`.`, |
| 370 | ].join("\n"), |
| 371 | ); |
| 372 | if (!(process.stdin.isTTY && process.stdout.isTTY)) { |
| 373 | return yield* new CliUpdateError({ |
| 374 | reason: |
| 375 | "Refusing to install a preview build without confirmation. Run this from a terminal to confirm, or pass --channel preview from an interactive shell.", |
| 376 | }); |
| 377 | } |
| 378 | const confirmed = yield* Prompt.run( |
| 379 | Prompt.Confirm({ message: "Install the preview build anyway?", initial: false }), |
| 380 | ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); |
| 381 | if (!confirmed) { |
| 382 | yield* Console.log("Left as is."); |
| 383 | return; |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | // Work out everything that will be touched before touching anything, so the |
| 388 | // user sees one plan and one question rather than a surprise restart. |
| 389 | const status = yield* service.status; |
| 390 | // The unit name is per user, not per T3 home. Only touch the service when it |
| 391 | // serves the home this update targets; otherwise it belongs to another |
| 392 | // install on this machine and restarting it would take that server down. |
| 393 | const servesThisHome = |
| 394 | status.installedBaseDir !== undefined && |
| 395 | path.resolve(status.installedBaseDir) === path.resolve(input.baseDir); |
| 396 | const serviceInstalled = status.supported && status.installed && servesThisHome; |
| 397 | const foreground = yield* findForegroundServer({ |
| 398 | serverRuntimeStatePath: input.serverRuntimeStatePath, |
| 399 | serviceInstalled, |
| 400 | }); |
| 401 | // What this machine runs is the executable behind the launcher and, when a |
| 402 | // service is installed for this home, the version that service runs. Either |
| 403 | // being stale is an update to do, and the newest of the two is what the |
| 404 | // downgrade check protects. |
| 405 | const serviceVersion = serviceInstalled ? status.installedVersion : undefined; |
| 406 | const executableCurrent = targetVersion === currentVersion; |
| 407 | // A service whose recorded version is missing or unreadable is not known |
| 408 | // to be current, so it gets the update rather than being skipped. Nor is |
| 409 | // one on the right version that is stopped, disabled, or still running the |
| 410 | // version before it (an earlier update where the restart was declined): |
| 411 | // `status.current` covers all of that when the target is this executable, |
| 412 | // and the problem list is what can be judged for any other target. |
| 413 | const restartPending = status.problems?.includes("restart-pending") === true; |
| 414 | const serviceCurrent = |
| 415 | !serviceInstalled || |
| 416 | (serviceVersion === targetVersion && |
| 417 | (executableCurrent ? status.current : (status.problems ?? []).length === 0)); |
| 418 | const newestInstalled = |
| 419 | serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0 |
| 420 | ? serviceVersion |
| 421 | : currentVersion; |
| 422 | |
| 423 | if (executableCurrent && serviceCurrent) { |
| 424 | yield* Console.log( |
| 425 | serviceVersion !== undefined |
| 426 | ? `t3 and its background service are already on ${targetVersion} (${targetChannel}).` |
| 427 | : `t3 is already on ${targetVersion} (${targetChannel}).`, |
| 428 | ); |
| 429 | return; |
| 430 | } |
| 431 | if (!input.allowDowngrade && compareExactServiceVersions(targetVersion, newestInstalled) < 0) { |
| 432 | return yield* new CliUpdateError({ |
| 433 | reason: `t3@${targetVersion} is older than the installed ${newestInstalled}. Pass --allow-downgrade to install it anyway.`, |
| 434 | }); |
| 435 | } |
| 436 | |
| 437 | const alreadyOnDisk = yield* fs |
| 438 | .readFileString(pinnedRuntimePaths(path, input.baseDir, targetVersion, platform).sentinelPath) |
| 439 | .pipe( |
| 440 | Effect.map((sentinel) => sentinel.trim() === targetVersion), |
| 441 | Effect.orElseSucceed(() => false), |
| 442 | ); |
| 443 | |
| 444 | progress.heading( |
| 445 | executableCurrent && restartPending |
| 446 | ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` |
| 447 | : executableCurrent |
| 448 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` |
| 449 | : alreadyOnDisk |
| 450 | ? "Switching T3 Code" |
| 451 | : "Updating T3 Code", |
| 452 | executableCurrent |
| 453 | ? "" |
| 454 | : `${currentVersion} → ${targetVersion}${targetChannel === "stable" ? "" : ` (${targetChannel})`}`, |
| 455 | ); |
| 456 | let restartService = false; |
| 457 | if (serviceInstalled && !serviceCurrent) { |
| 458 | yield* Console.log( |
| 459 | " A background service is installed for this T3 home. Restarting it interrupts anything running in it: agent turns, terminals, remote clients.", |
| 460 | ); |
| 461 | if (input.assumeYes) { |
| 462 | restartService = true; |
| 463 | } else if (process.stdin.isTTY && process.stdout.isTTY) { |
| 464 | restartService = yield* Prompt.run( |
| 465 | Prompt.Confirm({ |
| 466 | message: "Restart the background service once the download is verified?", |
| 467 | initial: true, |
| 468 | }), |
| 469 | ).pipe(Effect.catchTags({ QuitError: () => Effect.succeed(false) })); |
| 470 | } else { |
| 471 | yield* Console.log( |
| 472 | " Not a terminal, so the service keeps running its current version. Rerun with --yes to restart it now, or run `t3 service restart` later.", |
| 473 | ); |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | const runtime = yield* ensurePinnedRuntimeInstalled({ |
| 478 | onProgress: progress.report, |
| 479 | baseDir: input.baseDir, |
| 480 | version: targetVersion, |
| 481 | fs, |
| 482 | path, |
| 483 | runner, |
| 484 | httpClient, |
| 485 | platform, |
| 486 | arch, |
| 487 | releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined, |
| 488 | validate: (paths) => |
| 489 | runner |
| 490 | .run({ |
| 491 | command: pinnedRuntimeCommand(paths).command, |
| 492 | args: [...pinnedRuntimeCommand(paths).args, "--version"], |
| 493 | timeout: Duration.seconds(30), |
| 494 | }) |
| 495 | .pipe( |
| 496 | Effect.mapError( |
| 497 | (cause) => |
| 498 | new PinnedRuntimeInstallError({ step: "verifying the downloaded t3", cause }), |
| 499 | ), |
| 500 | Effect.flatMap((result) => |
| 501 | result.code === 0 && /\bv(\S+)\s*$/.exec(result.stdout)?.[1] === targetVersion |
| 502 | ? Effect.void |
| 503 | : Effect.fail( |
| 504 | new PinnedRuntimeInstallError({ |
| 505 | step: "verifying the downloaded t3", |
| 506 | exitCode: Number(result.code), |
| 507 | }), |
| 508 | ), |
| 509 | ), |
| 510 | ), |
| 511 | }).pipe( |
| 512 | Effect.ensuring(Effect.sync(progress.finish)), |
| 513 | Effect.catchIf( |
| 514 | (error): error is PinnedRuntimeInstallError => |
| 515 | error._tag === "PinnedRuntimeInstallError" && |
| 516 | error.step.startsWith("downloading the t3 release checksums") && |
| 517 | String(error.cause).includes("404"), |
| 518 | () => |
| 519 | Effect.fail( |
| 520 | new CliUpdateError({ |
| 521 | reason: `No release archive was published for t3@${targetVersion}.`, |
| 522 | }), |
| 523 | ), |
| 524 | ), |
| 525 | ); |
| 526 | |
| 527 | const launchedAs = (yield* HostProcess.IsExecutable) ? yield* resolveLauncherPath : undefined; |
| 528 | const repointed = yield* repointLauncher({ |
| 529 | launchedAs, |
| 530 | versionsDir: path.dirname(runtime.versionDir), |
| 531 | targetEntryPath: runtime.entryPath, |
| 532 | }); |
| 533 | |
| 534 | // The service switch runs in this process against the target version: the |
| 535 | // downloaded runtime has already proven it runs (the `--version` check |
| 536 | // above), and doing it here rather than through the target's own CLI means |
| 537 | // a downgrade to a version without today's commands still works. The unit |
| 538 | // is rewritten either way so a later `t3 service restart` lands on the new |
| 539 | // version; only the restart itself waits for the user's answer. |
| 540 | let serviceUpdated = false; |
| 541 | if (serviceInstalled && !serviceCurrent) { |
| 542 | yield* Console.log( |
| 543 | restartService |
| 544 | ? "Restarting the background service..." |
| 545 | : "Updating the background service...", |
| 546 | ); |
| 547 | yield* BootService.BootService.pipe( |
| 548 | Effect.flatMap((target) => |
| 549 | target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), |
| 550 | ), |
| 551 | Effect.provide( |
| 552 | BootService.layer({ |
| 553 | baseDir: input.baseDir, |
| 554 | logsDir: input.logsDir, |
| 555 | cliVersion: targetVersion, |
| 556 | }), |
| 557 | ), |
| 558 | Effect.mapError( |
| 559 | (error) => |
| 560 | new CliUpdateError({ |
| 561 | reason: `t3@${targetVersion} is installed but the background service could not be ${restartService ? "updated" : "pointed at it"}: ${error.message}`, |
| 562 | }), |
| 563 | ), |
| 564 | ); |
| 565 | serviceUpdated = restartService; |
| 566 | } |
| 567 | |
| 568 | progress.success(`Installed T3 Code ${targetVersion}`); |
| 569 | if (Option.isSome(repointed)) { |
| 570 | yield* Console.log(" Run t3 to get started.\n"); |
| 571 | } else { |
| 572 | yield* Console.log(` Run ${runtime.entryPath}\n`); |
| 573 | } |
| 574 | if (serviceUpdated) { |
| 575 | yield* Console.log(` Background service restarted on ${targetVersion}`); |
| 576 | } else if (serviceInstalled && serviceCurrent) { |
| 577 | yield* Console.log(` Background service already on ${targetVersion}`); |
| 578 | } else if (serviceInstalled) { |
| 579 | yield* Console.log( |
| 580 | ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service restart\` when you are ready to switch it to ${targetVersion}.`, |
| 581 | ); |
| 582 | } else if (status.installed && !servesThisHome) { |
| 583 | yield* Console.log( |
| 584 | ` The background service serves ${status.installedBaseDir ?? "another T3 home"} and was left unchanged.`, |
| 585 | ); |
| 586 | } |
| 587 | if (foreground !== undefined) { |
| 588 | yield* Console.log( |
| 589 | ` A server started by hand is still running at ${foreground.origin} (pid ${foreground.pid}). Stop it and start it again to pick up ${targetVersion}.`, |
| 590 | ); |
| 591 | } |
| 592 | }); |