1import { SERVER_VERSION } from "../version.ts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import {
4 CLI_RELEASE_BASE_URL_ENV,
5 CLI_RELEASE_CHANNELS,
6 cliReleaseIndexPageUrl,
7 cliReleaseChannelOf,
8 newestCliReleaseVersion,
9 type CliReleaseChannel,
10} from "@t3tools/shared/cliRelease";
11import * as Console from "effect/Console";
12import * as Duration from "effect/Duration";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/cli";
20import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
21
22import * as BootService from "../cloud/bootService.ts";
23import {
24 ensurePinnedRuntimeInstalled,
25 pinnedRuntimeCommand,
26 PinnedRuntimeInstallError,
27 pinnedRuntimePaths,
28} from "../cloud/pinnedRuntime.ts";
29import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts";
30import * as ProcessRunner from "../processRunner.ts";
31import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
32import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
33import { createUpdateProgress } from "./updateProgress.ts";
34import * as CliService from "./service.ts";
35
36export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", {
37 reason: Schema.String,
38}) {
39 override get message(): string {
40 return this.reason;
41 }
42}
43
44const ReleaseIndex = Schema.Array(
45 Schema.Struct({
46 tag_name: Schema.String,
47 draft: Schema.optional(Schema.Boolean),
48 }),
49);
50const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex));
51
52const 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.
55const RELEASE_INDEX_MAX_PAGES = 10;
56
57/** Asks GitHub for the newest published version on a channel, page by page. */
58const 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`. */
92export 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 */
108export 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 */
163export 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 */
189export 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
214const 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
235const 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
242export 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 */
280const 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
292const 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
324const 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});