Skip to content
Source
Sign inSign up

maintenanceResolver.ts

1,334 lines49.9 KB

1// @effect-diagnostics nodeBuiltinImport:off -- Synchronous hashing keeps env values out of cache keys.
2import * as NodeCrypto from "node:crypto";
3export type {
4 ProviderMaintenanceCapabilities,
5 ProviderMaintenanceCommandAction,
6} from "@t3tools/provider-core/server/maintenance";
7import type {
8 ProviderMaintenanceCapabilities,
9 ProviderMaintenanceCommandAction,
10} from "@t3tools/provider-core/server/maintenance";
11import {
12 ProviderDriverKind,
13 type ServerProvider,
14 type ServerProviderVersionAdvisory,
15} from "@t3tools/contracts";
16import { compareSemverVersions, satisfiesSemverRange } from "@t3tools/shared/semver";
17import * as HostProcess from "@t3tools/shared/HostProcess";
18import { causeErrorTag } from "@t3tools/shared/observability";
19import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
20import * as Cache from "effect/Cache";
21import * as Config from "effect/Config";
22import * as DateTime from "effect/DateTime";
23import * as Duration from "effect/Duration";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Option from "effect/Option";
27import * as Path from "effect/Path";
28import * as Schema from "effect/Schema";
29import * as Stream from "effect/Stream";
30import * as HttpClient from "effect/http/HttpClient";
31import * as HttpClientRequest from "effect/http/HttpClientRequest";
32import * as HttpClientResponse from "effect/http/HttpClientResponse";
33import * as ChildProcess from "effect/process/ChildProcess";
34import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
35​
36import { collectUint8StreamText } from "./collectStreamText.ts";
37import * as ProviderLatestVersions from "./ProviderLatestVersions.ts";
38​
39const LATEST_VERSION_TIMEOUT_MS = 4_000;
40const PROBE_TIMEOUT_MS = 10_000;
41const PROBE_MAX_BYTES = 256 * 1_024;
42const MISE_WRAPPER_MAX_BYTES = 16 * 1_024;
43const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
44​
45const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1);
46​
47const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
48 Object.fromEntries(
49 Object.entries(input).flatMap(([key, value]) =>
50 Option.match(value, {
51 onNone: () => [],
52 onSome: (resolved) => [[key, resolved]],
53 }),
54 ),
55 );
56​
57const CommandLookupEnvConfig = Config.all({
58 PATH: Config.String("PATH").pipe(Config.option),
59 Path: Config.String("Path").pipe(Config.option),
60 path: Config.String("path").pipe(Config.option),
61 PATHEXT: Config.String("PATHEXT").pipe(Config.option),
62}).pipe(Config.map(compactEnv));
63​
64const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
65​
66export interface ProviderMaintenanceResolutionContext {
67 readonly binaryPath: string;
68 readonly resolvedCommandPath: string;
69 readonly realCommandPath: string;
70 readonly env: NodeJS.ProcessEnv;
71 readonly platform: NodeJS.Platform;
73​
74export type ProviderMaintenanceResolverServices =
75 | FileSystem.FileSystem
76 | Path.Path
77 | ChildProcessSpawner.ChildProcessSpawner;
78​
79export interface ProviderMaintenanceCapabilitiesResolver {
80 readonly resolve: (
81 context: ProviderMaintenanceResolutionContext | null,
82 ) => Effect.Effect<ProviderMaintenanceCapabilities, never, ProviderMaintenanceResolverServices>;
84​
85export interface PackageManagedProviderMaintenanceDefinition {
86 readonly provider: ProviderDriverKind;
87 readonly npmPackageName: string;
88 readonly nativeUpdate: {
89 readonly args: ReadonlyArray<string>;
90 readonly isCommandPath?: (commandPath: string) => boolean;
91 /** The updater cannot detect installs outside `isCommandPath`, so leave those manual-only. */
92 readonly requiresCommandPath?: boolean;
93 readonly env?:
94 | NodeJS.ProcessEnv
95 | ((context: ProviderMaintenanceResolutionContext) => NodeJS.ProcessEnv);
96 } | null;
97 /** The mise registry tool that installs this provider; unset keeps mise installs manual-only. */
98 readonly mise?: MiseProviderTool;
100​
101export interface MiseProviderTool {
102 readonly tool: string;
103 /** False still claims the tool's installs, but keeps them manual-only. */
104 readonly upgrade?: boolean;
106​
107const NpmLatestVersionResponse = Schema.Struct({
108 version: Schema.optional(Schema.String),
109});
110​
111function nonEmptyString(value: unknown): string | null {
112 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
114​
115function quoteShellWord(word: string, platform: NodeJS.Platform): string {
116 const safeWord = platform === "win32" ? /^[\w./:\\@=-]+$/ : /^[\w./:@=-]+$/;
117 if (safeWord.test(word)) return word;
118 return platform === "win32"
119 ? `'${word.replace(/['\u2018\u2019]/g, "$&$&")}'`
120 : `'${word.replaceAll("'", "'\\''")}'`;
122​
123function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {
124 const quoted = quoteShellWord(executable, platform);
125 // Windows terminals default to PowerShell, where a quoted executable needs &.
126 return platform === "win32" && quoted !== executable ? `& ${quoted}` : quoted;
128​
129export function makeProviderMaintenanceCapabilities(input: {
130 readonly provider: ProviderDriverKind;
131 readonly packageName: string | null;
132 readonly updateExecutable: string | null;
133 readonly updateArgs: ReadonlyArray<string>;
134 readonly updateLockKey: string | null;
135 readonly updateCommand?: string;
136 readonly platform?: NodeJS.Platform;
137 readonly env?: NodeJS.ProcessEnv;
138 readonly latestVersion?: string | null;
139 readonly lookupLatestVersion?: Effect.Effect<string | null>;
140}): ProviderMaintenanceCapabilities {
141 const platform = input.platform ?? HostProcess.Platform.defaultValue();
142 const update =
143 input.updateExecutable === null || input.updateLockKey === null
144 ? null
145 : {
146 command:
147 input.updateCommand ??
148 [
149 quoteUpdateExecutable(input.updateExecutable, platform),
150 ...input.updateArgs.map((arg) => quoteShellWord(arg, platform)),
151 ].join(" "),
152 executable: input.updateExecutable,
153 args: input.updateArgs,
154 lockKey: input.updateLockKey,
155 ...(input.env ? { env: input.env } : {}),
156 };
157 return {
158 provider: input.provider,
159 packageName: input.packageName,
160 update,
161 ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}),
162 ...(input.lookupLatestVersion ? { lookupLatestVersion: input.lookupLatestVersion } : {}),
163 };
165​
166export function makeTargetedProviderUpdateAction(
167 capabilities: ProviderMaintenanceCapabilities,
168 version: string,
169): ProviderMaintenanceCommandAction | null {
170 if (!/^\d+\.\d+\.\d+$/.test(version)) return null;
171 const update = capabilities.update;
172 const packageName = capabilities.packageName;
173 if (!update || !packageName) return null;
174 if (
175 !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test(
176 update.lockKey,
177 )
178 )
179 return null;
180 const packageIndex = update.args.findIndex(
181 (arg) => arg === `${packageName}@latest` || arg === packageName,
182 );
183 if (packageIndex < 0) return null;
184 const args = update.args.map((arg, index) =>
185 index === packageIndex ? `${packageName}@${version}` : arg,
186 );
187 const previous = update.args[packageIndex]!;
188 const commandIndex = update.command.lastIndexOf(previous);
189 const command =
190 commandIndex < 0
191 ? update.command
192 : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`;
193 return { ...update, args, command };
195​
196// pnpm 11 resolves `@latest` to the newest release older than `minimumReleaseAge`
197// and exits 0, so a release advertised from the npm dist-tag would never install.
198// An exact spec installs it and pnpm records it in `minimumReleaseAgeExclude`.
199export function resolveLatestProviderUpdateAction(
200 capabilities: ProviderMaintenanceCapabilities,
201 latestVersion: string | null,
202): ProviderMaintenanceCommandAction | null {
203 const update = capabilities.update;
204 if (update?.lockKey !== "pnpm-global" || latestVersion === null) return update;
205 return makeTargetedProviderUpdateAction(capabilities, latestVersion) ?? update;
207​
208export function makeManualOnlyProviderMaintenanceCapabilities(input: {
209 readonly provider: ProviderDriverKind;
210 readonly packageName: string | null;
211 readonly installPath?: string;
212 readonly lookupLatestVersion?: Effect.Effect<string | null>;
213 readonly manualCommand?: string;
214}): ProviderMaintenanceCapabilities {
215 return {
216 ...makeProviderMaintenanceCapabilities({
217 provider: input.provider,
218 packageName: input.packageName,
219 updateExecutable: null,
220 updateArgs: [],
221 updateLockKey: null,
222 ...(input.lookupLatestVersion
223 ? { latestVersion: null, lookupLatestVersion: input.lookupLatestVersion }
224 : {}),
225 }),
226 ...(input.installPath ? { installPath: input.installPath } : {}),
227 ...(input.manualCommand ? { manualCommand: input.manualCommand } : {}),
228 };
230​
231export function normalizeCommandPath(commandPath: string): string {
232 return commandPath.replaceAll("\\", "/").toLowerCase();
234​
235/**
236 * Vite+ shims live in `<VP_HOME>/bin`: `~/.vite-plus` by default,
237 * `~/.local/share/vite-plus` with the XDG layout, and `%LOCALAPPDATA%\vite-plus`
238 * on Windows.
239 */
240function isVitePlusGlobalCommandPath(commandPath: string): boolean {
241 return /\/(?:\.vite-plus|\.local\/share\/vite-plus|appdata\/local\/vite-plus)\/bin\//.test(
242 normalizeCommandPath(commandPath),
243 );
245​
246function isBunGlobalCommandPath(commandPath: string): boolean {
247 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
249​
250function isYarnGlobalCommandPath(commandPath: string): boolean {
251 return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath));
253​
254/**
255 * Paths mise owns. npm globals under mise's own Node belong to npm, which proves their prefix.
256 * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0].
257 */
258function isMiseCommandPath(commandPath: string): boolean {
259 const normalized = normalizeCommandPath(commandPath);
260 return (
261 (normalized.includes("/mise/installs/") && !normalized.includes("/mise/installs/node/")) ||
262 normalized.includes("/mise/shims/") ||
263 normalized.includes("/mise/command-wrappers/")
264 );
266​
267/** A mise shim or command wrapper is a link to mise itself, wherever mise keeps its data. */
268function isMiseExecutablePath(commandPath: string): boolean {
269 return /(?:^|\/)mise(?:\.exe)?$/.test(normalizeCommandPath(commandPath));
271​
272/** The Nix store is read-only, so no updater can replace a CLI that lives there. */
273export function isNixStoreCommandPath(commandPath: string): boolean {
274 return normalizeCommandPath(commandPath).startsWith("/nix/store/");
276​
277const winGetClaudeCommandPath =
278 /^[a-z]:\/(?:users\/[^/]+\/appdata\/local\/microsoft|program files(?: \(x86\))?)\/winget\/(?:links|packages\/anthropic\.claudecode_[^/]+)\/claude\.exe$/;
279​
280function isWinGetClaudeCommandPath(commandPath: string): boolean {
281 return winGetClaudeCommandPath.test(normalizeCommandPath(commandPath));
283​
284const CHOCOLATEY_CLAUDE_PACKAGE_ID = "claude-code";
285​
286// Chocolatey's executable shim is not a symlink, so realpath cannot prove its owner.
287const chocolateyClaudeCommandPath = /\/chocolatey\/(?:bin|lib\/claude-code\/tools)\/claude\.exe$/;
288​
289function isChocolateyClaudeCommandPath(commandPath: string, env: NodeJS.ProcessEnv): boolean {
290 const normalized = normalizeCommandPath(commandPath);
291 if (chocolateyClaudeCommandPath.test(normalized)) return true;
292 const root = nonEmptyString(env.ChocolateyInstall);
293 if (!root) return false;
294 const prefix = normalizeCommandPath(root.replace(/[\\/]+$/, ""));
295 return (
296 normalized === `${prefix}/bin/claude.exe` ||
297 normalized === `${prefix}/lib/claude-code/tools/claude.exe`
298 );
300​
301/** `choco search --exact --limit-output` prints `<id>|<version>` for a match and nothing otherwise. */
302export function parseChocolateyLatestVersion(output: string, packageId: string): string | null {
303 for (const line of output.split(/\r?\n/)) {
304 const [id, version] = line.trim().split("|", 2);
305 if (id?.toLowerCase() === packageId.toLowerCase()) return nonEmptyString(version);
306 }
307 return null;
309​
310function isPnpmGlobalCommandPath(commandPath: string): boolean {
311 const normalized = normalizeCommandPath(commandPath);
312 return (
313 normalized.includes("/.local/share/pnpm/") ||
314 normalized.includes("/library/pnpm/") ||
315 normalized.includes("/local/share/pnpm/") ||
316 normalized.includes("/appdata/local/pnpm/") ||
317 normalized.includes("/pnpm/global/")
318 );
320​
321export function npmGlobalPrefixFromCommandPath(
322 realCommandPath: string,
323 packageName: string,
324): string | null {
325 const slashPath = realCommandPath.replaceAll("\\", "/");
326 const normalized = slashPath.toLowerCase();
327 const packageSegment = `/lib/node_modules/${packageName.toLowerCase()}/`;
328 const packageIndex = normalized.lastIndexOf(packageSegment);
329 if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) {
330 return null;
331 }
332 // Mise tool layouts resemble npm globals; only globals under its Node install belong to npm.
333 const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1];
334 if (miseTool && miseTool !== "node") {
335 return null;
336 }
337 return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex);
339​
340const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i;
341​
342export interface HomebrewOwnership {
343 readonly kind: "formula" | "cask";
344 readonly name: string;
345 readonly prefix: string;
347​
348export function homebrewOwnershipFromCommandPath(
349 realCommandPath: string,
350): HomebrewOwnership | null {
351 const match = HOMEBREW_KEG_PATTERN.exec(realCommandPath.replaceAll("\\", "/"));
352 if (!match) {
353 return null;
354 }
355 return {
356 kind: match[2]!.toLowerCase() === "cellar" ? "formula" : "cask",
357 name: match[3]!,
358 prefix: match[1]!,
359 };
361​
362const HomebrewInfoResponse = Schema.Struct({
363 formulae: Schema.optional(
364 Schema.Array(
365 Schema.Struct({
366 versions: Schema.optional(Schema.Struct({ stable: Schema.optional(Schema.String) })),
367 }),
368 ),
369 ),
370 casks: Schema.optional(Schema.Array(Schema.Struct({ version: Schema.optional(Schema.String) }))),
371});
372​
373const decodeHomebrewInfo = Schema.decodeUnknownOption(Schema.fromJsonString(HomebrewInfoResponse));
374​
375export function parseHomebrewLatestVersion(
376 infoJson: string,
377 ownership: HomebrewOwnership,
378): string | null {
379 const decoded = decodeHomebrewInfo(infoJson);
380 if (Option.isNone(decoded)) {
381 return null;
382 }
383 const raw =
384 ownership.kind === "formula"
385 ? decoded.value.formulae?.[0]?.versions?.stable
386 : decoded.value.casks?.[0]?.version?.split(",", 1)[0];
387 const version = nonEmptyString(raw);
388 return version === "latest" ? null : version;
390​
391const runProbe = Effect.fn("runProbe")(function* (
392 command: string,
393 args: ReadonlyArray<string>,
394 env: NodeJS.ProcessEnv,
395 okExitCodes: ReadonlyArray<number> = [0],
396) {
397 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
398 const collect = Effect.gen(function* () {
399 // Windows package managers are `.cmd` shims that need the shell.
400 const resolved = yield* resolveSpawnCommand(command, args, { env, extendEnv: true });
401 const child = yield* spawner.spawn(
402 ChildProcess.make(resolved.command, resolved.args, {
403 env,
404 extendEnv: true,
405 shell: resolved.shell,
406 }),
407 );
408 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
409 // stderr is drained so a chatty tool cannot block on a full pipe.
410 const [stdout, exitCode] = yield* Effect.all(
411 [
412 collectUint8StreamText({ stream: child.stdout, maxBytes: PROBE_MAX_BYTES }),
413 child.exitCode,
414 Stream.runDrain(child.stderr),
415 ],
416 { concurrency: "unbounded" },
417 );
418 return !okExitCodes.includes(Number(exitCode)) || stdout.truncated ? null : stdout.text;
419 });
420 return yield* collect.pipe(
421 Effect.scoped,
422 Effect.timeoutOption(Duration.millis(PROBE_TIMEOUT_MS)),
423 Effect.map(Option.getOrNull),
424 Effect.catchCause((cause) =>
425 Effect.logWarning("Maintenance probe failed", {
426 command,
427 subcommand: args[0],
428 errorTag: causeErrorTag(cause),
429 }).pipe(Effect.as(null)),
430 ),
431 );
432});
433​
434const decodePnpmOutdated = Schema.decodeUnknownOption(
435 Schema.fromJsonString(
436 Schema.Record(Schema.String, Schema.Struct({ latest: Schema.optional(Schema.String) })),
437 ),
438);
439const decodePnpmList = Schema.decodeUnknownOption(
440 Schema.fromJsonString(
441 Schema.Array(
442 Schema.Struct({
443 dependencies: Schema.optional(
444 Schema.Record(Schema.String, Schema.Struct({ version: Schema.optional(Schema.String) })),
445 ),
446 }),
447 ),
448 ),
449);
450​
451// Unlike npm's latest tag, pnpm outdated respects minimumReleaseAge and its excludes.
452const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion")(function* (
453 packageName: string,
454 env: NodeJS.ProcessEnv,
455) {
456 // Match the updater's pnpm and environment.
457 const pnpmPath = yield* resolveCommandPath("pnpm", {
458 env: { ...(yield* HostProcess.Environment), ...env },
459 });
460 if (!pnpmPath) return null;
461 // Silent reporting keeps project package-manager warnings out of the JSON.
462 const outdated = yield* runProbe(
463 pnpmPath,
464 ["outdated", "-g", "--reporter=silent", "--format", "json", packageName],
465 env,
466 // pnpm outdated exits 1 when updates are available.
467 [0, 1],
468 );
469 const outdatedEntries = outdated
470 ? Option.getOrUndefined(decodePnpmOutdated(outdated))
471 : undefined;
472 if (!outdatedEntries) return null;
473 const latest = nonEmptyString(outdatedEntries[packageName]?.latest);
474 if (latest) return latest;
475 // An empty result means the installed version is still the eligible release.
476 const listed = yield* runProbe(
477 pnpmPath,
478 ["ls", "-g", "--reporter=silent", "--depth", "0", "--json", packageName],
479 env,
480 );
481 const listedEntries = listed ? Option.getOrUndefined(decodePnpmList(listed)) : undefined;
482 return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version);
483});
484​
485// `bun outdated` has no JSON output; its `Latest` column is what `@latest` installs
486// under minimumReleaseAge (marked `*`). `Update` is only the global package.json range.
487function parseBunOutdatedLatestVersion(output: string, packageName: string): string | null {
488 for (const line of output.split("\n")) {
489 const cells = line.split("|").map((cell) => cell.trim());
490 if (cells.length === 6 && cells[1] === packageName) {
491 return nonEmptyString(cells[4]?.replace(/ \*$/, ""));
492 }
493 }
494 return null;
496​
497// bun rejects an exact pin to a release inside minimumReleaseAge, so the update stays `@latest`.
498const resolveBunGlobalLatestVersion = Effect.fn("resolveBunGlobalLatestVersion")(function* (
499 packageName: string,
500 env: NodeJS.ProcessEnv,
501) {
502 // Match the updater's bun and environment.
503 const bunPath = yield* resolveCommandPath("bun", {
504 env: { ...(yield* HostProcess.Environment), ...env },
505 });
506 if (!bunPath) return null;
507 // FORCE_COLOR=1 draws the table with box characters and splits versions with ANSI codes.
508 const outdated = yield* runProbe(bunPath, ["outdated", "-g", packageName], {
509 ...env,
510 FORCE_COLOR: "0",
511 });
512 // A missing row reads the same whether bun is current or its registry failed, so npm decides.
513 return outdated ? parseBunOutdatedLatestVersion(outdated, packageName) : null;
514});
515​
516const MiseInstall = Schema.Struct({
517 install_path: Schema.String,
518 requested_version: Schema.optional(Schema.NullOr(Schema.String)),
519 active: Schema.optional(Schema.Boolean),
520});
521const decodeMiseToolInstalls = Schema.decodeUnknownOption(
522 Schema.fromJsonString(Schema.Array(MiseInstall)),
523);
524const decodeMiseInstalls = Schema.decodeUnknownOption(
525 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Array(MiseInstall))),
526);
527​
528/** The installs `mise ls --json --installed <tool>` lists, refusing any other tool's. */
529function miseToolInstalls(
530 listing: string,
531 miseTool: string,
532): ReadonlyArray<typeof MiseInstall.Type> {
533 const installs = decodeMiseToolInstalls(listing);
534 if (Option.isSome(installs)) return installs.value;
535 const byTool = Option.getOrUndefined(decodeMiseInstalls(listing));
536 return byTool && Object.keys(byTool).every((tool) => tool === miseTool)
537 ? (byTool[miseTool] ?? [])
538 : [];
540​
541/**
542 * Null when mise does not own the executable. Upgrades need an active `latest` install of the
543 * driver's fixed mise tool, launched through a shim or the `latest` link `mise upgrade` moves;
544 * a fixed version's path would keep running the pruned binary, so it stays manual-only.
545 */
546export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMaintenance")(
547 function* (
548 owner: { readonly provider: ProviderDriverKind; readonly packageName: string | null },
549 mise: MiseProviderTool | undefined,
550 context: ProviderMaintenanceResolutionContext,
551 ) {
552 const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
553 const isShim = isMiseExecutablePath(context.realCommandPath);
554 const looksMise =
555 isShim || commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context));
556 const manual = (installPath: string) =>
557 makeManualOnlyProviderMaintenanceCapabilities({ ...owner, installPath });
558 const notOwned = (installPath: string) => (looksMise ? manual(installPath) : null);
559 const commandName = normalizeCommandPath(context.resolvedCommandPath)
560 .split("/")
561 .at(-1)
562 ?.replace(/\.(?:exe|cmd|bat|ps1)$/, "");
563 if (!mise) return notOwned(context.realCommandPath);
564 const miseTool = mise.tool;
565 // A shim runs whatever mise resolves for its own name, which must be the mapped tool's.
566 if (isShim && commandName !== miseTool) return manual(context.realCommandPath);
567​
568 const fileSystem = yield* FileSystem.FileSystem;
569 const realPath = (target: string) =>
570 fileSystem.realPath(target).pipe(Effect.orElseSucceed(() => target));
571 const misePath =
572 (yield* resolveCommandPath("mise", { env: context.env })) ??
573 (isShim ? context.realCommandPath : null);
574 if (!misePath) return notOwned(context.realCommandPath);
575 // Asked even off mise's usual paths, since `MISE_DATA_DIR` can put installs anywhere.
576 const which = nonEmptyString(yield* runProbe(misePath, ["which", miseTool], context.env));
577 const resolvedPath = which ? yield* realPath(which) : null;
578 // Through a shim T3 Code runs whatever mise resolves; otherwise the configured binary.
579 const binaryPath = isShim ? resolvedPath : context.realCommandPath;
580 if (!binaryPath) return manual(context.realCommandPath);
581 const binary = normalizeCommandPath(binaryPath);
582 const runsBinary = resolvedPath !== null && normalizeCommandPath(resolvedPath) === binary;
583 const listing = yield* runProbe(
584 misePath,
585 ["ls", "--json", "--installed", miseTool],
586 context.env,
587 );
588 if (!listing) return runsBinary ? manual(binaryPath) : notOwned(binaryPath);
589 const isUnder = (path: string, dir: string) =>
590 path.startsWith(`${normalizeCommandPath(dir).replace(/\/+$/, "")}/`);
591 // An inactive or shadowed install is still mise's, even when `mise which` resolves another.
592 const owning = yield* Effect.filter(miseToolInstalls(listing, miseTool), (install) =>
593 realPath(install.install_path).pipe(
594 Effect.map((installPath) => isUnder(binary, installPath)),
595 ),
596 );
597 // `mise which` also resolves binaries from other tools, such as npm globals under mise's Node.
598 if (owning.length === 0) return notOwned(binaryPath);
599 if (!runsBinary) return manual(binaryPath);
600 const install = owning.find(
601 (candidate) => candidate.active === true && candidate.requested_version === "latest",
602 );
603 if (!install || mise.upgrade === false) return manual(binaryPath);
604 if (!isShim) {
605 // Keep the path's case: `realPath` needs it on case-sensitive filesystems.
606 const toolDir = install.install_path
607 .replaceAll("\\", "/")
608 .replace(/\/+$/, "")
609 .replace(/\/[^/]+$/, "");
610 const latestDirs = [toolDir, yield* realPath(toolDir)].map((dir) => `${dir}/latest`);
611 const resolved = normalizeCommandPath(context.resolvedCommandPath);
612 if (!latestDirs.some((dir) => isUnder(resolved, dir))) return manual(binaryPath);
613 }
614 const args = ["upgrade", miseTool];
615 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
616 // Mise reads its settings from this environment, so each one gets its own cached answer.
617 const envHash = NodeCrypto.createHash("sha256");
618 for (const [name, value] of Object.entries(context.env).toSorted(([a], [b]) =>
619 a < b ? -1 : 1,
620 )) {
621 envHash.update(`${name}\0${value ?? ""}\0`);
622 }
623 const envKey = envHash.digest("hex");
624 const capabilities = makeProviderMaintenanceCapabilities({
625 ...owner,
626 updateExecutable: misePath,
627 updateArgs: args,
628 updateLockKey: "mise",
629 updateCommand: ["mise", ...args].join(" "),
630 platform: context.platform,
631 // Mise read its config from this environment, so the upgrade runs in it too.
632 env: context.env,
633 // Settings such as `minimum_release_age` can hold mise behind the registry,
634 // so only mise can say what it installs. It may go to the network, so ask lazily.
635 latestVersion: null,
636 lookupLatestVersion: runProbe(misePath, ["latest", miseTool], context.env).pipe(
637 Effect.map((latest) => {
638 const version = nonEmptyString(latest);
639 return version && /^\S+$/.test(version) ? version : null;
640 }),
641 Effect.provideContext(services),
642 ),
643 });
644 return {
645 ...capabilities,
646 latestVersionCacheKey: ["mise", misePath, miseTool, envKey].join("\0"),
647 };
648 },
649);
650​
651const decodeVitePlusConfig = Schema.decodeUnknownOption(
652 Schema.fromJsonString(Schema.Struct({ shimMode: Schema.optional(Schema.Unknown) })),
653);
654​
655/** With `shimMode: "system_first"` (`vp env off`), a Vite+ shim runs the next same-named command on PATH. */
656const followVitePlusSystemFirstShim = Effect.fn("followVitePlusSystemFirstShim")(function* (
657 context: ProviderMaintenanceResolutionContext,
658) {
659 if (!isVitePlusGlobalCommandPath(context.resolvedCommandPath)) return context;
660 const fileSystem = yield* FileSystem.FileSystem;
661 const path = yield* Path.Path;
662 const shimDir = path.dirname(context.resolvedCommandPath);
663 const dataDir = path.dirname(shimDir);
664 // Without VP_HOME, the XDG layout keeps shims in `~/.local/share/vite-plus` and config in `$XDG_CONFIG_HOME/vite-plus`.
665 const home = context.env.VP_HOME
666 ? undefined
667 : /^(.*)\/\.local\/share\/vite-plus$/.exec(dataDir.replaceAll("\\", "/"))?.[1];
668 const configPaths = [
669 ...(home !== undefined
670 ? [path.join(context.env.XDG_CONFIG_HOME || path.join(home, ".config"), "vite-plus")]
671 : []),
672 dataDir,
673 ].map((dir) => path.join(dir, "config.json"));
674 const config = yield* Effect.firstSuccessOf(
675 configPaths.map((configPath) => fileSystem.readFileString(configPath)),
676 ).pipe(Effect.orElseSucceed(() => ""));
677 if (Option.getOrUndefined(decodeVitePlusConfig(config))?.shimMode !== "system_first") {
678 return context;
679 }
680 const delimiter = context.platform === "win32" ? ";" : ":";
681 const dirKey = (dir: string) => normalizeCommandPath(dir).replace(/\/+$/, "");
682 const shimDirKey = dirKey(shimDir);
683 const env = Object.fromEntries(
684 Object.entries(context.env).map(([name, value]) => [
685 name,
686 name.toLowerCase() === "path" && value
687 ? value
688 .split(delimiter)
689 .filter((entry) => dirKey(entry) !== shimDirKey)
690 .join(delimiter)
691 : value,
692 ]),
693 );
694 const command = path.basename(context.resolvedCommandPath).replace(/\.(?:exe|cmd|bat|ps1)$/i, "");
695 const resolvedCommandPath = yield* resolveCommandPath(command, { env });
696 if (!resolvedCommandPath) return context;
697 const realCommandPath = yield* fileSystem
698 .realPath(resolvedCommandPath)
699 .pipe(Effect.orElseSucceed(() => resolvedCommandPath));
700 return { ...context, resolvedCommandPath, realCommandPath };
701});
702​
703export const resolvePackageManagedProviderMaintenance = Effect.fn(
704 "resolvePackageManagedProviderMaintenance",
705)(function* (
706 definition: PackageManagedProviderMaintenanceDefinition,
707 configuredContext: ProviderMaintenanceResolutionContext | null,
708) {
709 if (!configuredContext) {
710 return makeManualOnlyProviderMaintenanceCapabilities({
711 provider: definition.provider,
712 packageName: definition.npmPackageName,
713 });
714 }
715 const context = yield* followVitePlusSystemFirstShim(configuredContext);
716 const manual = makeManualOnlyProviderMaintenanceCapabilities({
717 provider: definition.provider,
718 packageName: definition.npmPackageName,
719 installPath: context.realCommandPath,
720 });
721 const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
722 const packageName = definition.npmPackageName;
723 // WinGet-managed claude update exits 0 without updating; T3 does not run winget upgrade.
724 if (
725 definition.provider === "claudeAgent" &&
726 context.platform === "win32" &&
727 commandPaths.some(isWinGetClaudeCommandPath)
728 ) {
729 return manual;
730 }
731 // Chocolatey's package directory is writable only by administrators, so an
732 // unelevated server cannot run `choco upgrade`; `claude update` through the
733 // shim installs a native copy elsewhere and leaves the shim's version alone.
734 if (
735 definition.provider === "claudeAgent" &&
736 context.platform === "win32" &&
737 commandPaths.some((commandPath) => isChocolateyClaudeCommandPath(commandPath, context.env))
738 ) {
739 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
740 return makeManualOnlyProviderMaintenanceCapabilities({
741 provider: definition.provider,
742 packageName,
743 installPath: context.realCommandPath,
744 manualCommand: "choco upgrade claude-code",
745 lookupLatestVersion: runProbe(
746 "choco",
747 ["search", CHOCOLATEY_CLAUDE_PACKAGE_ID, "--exact", "--limit-output"],
748 context.env,
749 ).pipe(
750 Effect.map((output) =>
751 output ? parseChocolateyLatestVersion(output, CHOCOLATEY_CLAUDE_PACKAGE_ID) : null,
752 ),
753 Effect.provideContext(services),
754 ),
755 });
756 }
757​
758 const mise = yield* resolveMiseProviderMaintenance(
759 { provider: definition.provider, packageName },
760 definition.mise,
761 context,
762 );
763 if (mise) return mise;
764 // After mise: a shim of a Nix-installed mise resolves into the store, yet its installs are writable.
765 if (commandPaths.some(isNixStoreCommandPath)) {
766 return manual;
767 }
768​
769 const nativeUpdate = definition.nativeUpdate;
770 const nativeEnv = nativeUpdate?.env;
771 const native = nativeUpdate
772 ? makeProviderMaintenanceCapabilities({
773 provider: definition.provider,
774 packageName,
775 updateExecutable: context.resolvedCommandPath,
776 updateArgs: nativeUpdate.args,
777 updateLockKey: `${definition.provider}-native`,
778 platform: context.platform,
779 ...(nativeEnv
780 ? { env: typeof nativeEnv === "function" ? nativeEnv(context) : nativeEnv }
781 : {}),
782 })
783 : manual;
784 // A native-looking launcher can still delegate to an npm-owned install.
785 if (
786 nativeUpdate?.isCommandPath &&
787 commandPaths.some(nativeUpdate.isCommandPath) &&
788 !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName)
789 ) {
790 return native;
791 }
792 // A `node_modules` path not proven below belongs to another package or a
793 // project, so the provider's own updater could act on the wrong install.
794 const fallback =
795 nativeUpdate?.requiresCommandPath ||
796 commandPaths.some((commandPath) => normalizeCommandPath(commandPath).includes("/node_modules/"))
797 ? manual
798 : native;
799 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
800 return makeProviderMaintenanceCapabilities({
801 provider: definition.provider,
802 packageName,
803 updateExecutable: "vp",
804 updateArgs: ["i", "-g", packageName],
805 updateLockKey: "vite-plus-global",
806 env: context.env,
807 });
808 }
809 if (commandPaths.some(isBunGlobalCommandPath)) {
810 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
811 return makeProviderMaintenanceCapabilities({
812 provider: definition.provider,
813 packageName,
814 updateExecutable: "bun",
815 updateArgs: ["i", "-g", `${packageName}@latest`],
816 updateLockKey: "bun-global",
817 env: context.env,
818 lookupLatestVersion: resolveBunGlobalLatestVersion(packageName, context.env).pipe(
819 Effect.provideContext(services),
820 ),
821 });
822 }
823 if (commandPaths.some(isPnpmGlobalCommandPath)) {
824 // pnpm 10 skips unapproved scripts; the approval flag requires 10.4+.
825 const pnpmVersion = yield* runProbe("pnpm", ["--version"], context.env);
826 const allowBuild =
827 pnpmVersion !== null && satisfiesSemverRange(pnpmVersion, ">=10.4.0")
828 ? [`--allow-build=${packageName}`]
829 : [];
830 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
831 return makeProviderMaintenanceCapabilities({
832 provider: definition.provider,
833 packageName,
834 updateExecutable: "pnpm",
835 updateArgs: ["add", "-g", ...allowBuild, `${packageName}@latest`],
836 updateLockKey: "pnpm-global",
837 env: context.env,
838 lookupLatestVersion: resolvePnpmGlobalLatestVersion(packageName, context.env).pipe(
839 Effect.provideContext(services),
840 ),
841 });
842 }
843​
844 if (commandPaths.some(isYarnGlobalCommandPath)) {
845 return makeProviderMaintenanceCapabilities({
846 provider: definition.provider,
847 packageName,
848 updateExecutable: "yarn",
849 updateArgs: ["global", "add", `${packageName}@latest`],
850 updateLockKey: "yarn-global",
851 env: context.env,
852 });
853 }
854 if (yield* isVoltaPackageInstall(context, packageName)) {
855 return makeProviderMaintenanceCapabilities({
856 provider: definition.provider,
857 packageName,
858 updateExecutable: "volta",
859 updateArgs: ["install", `${packageName}@latest`],
860 updateLockKey: "volta",
861 env: context.env,
862 });
863 }
864​
865 // npm proof outranks a containing Homebrew keg; Homebrew Node globals still belong to npm.
866 const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);
867 if (npmPrefix) {
868 // npm 12 requires script approval to install native binaries; older npm only warns about the flag.
869 return makeProviderMaintenanceCapabilities({
870 provider: definition.provider,
871 packageName,
872 updateExecutable: "npm",
873 updateArgs: [
874 "install",
875 "-g",
876 "--prefix",
877 npmPrefix,
878 `--allow-scripts=${packageName}`,
879 `${packageName}@latest`,
880 ],
881 updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`,
882 env: context.env,
883 });
884 }
885​
886 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
887 if (homebrew) {
888 return (
889 (yield* resolveHomebrewProviderMaintenance(
890 { provider: definition.provider, packageName },
891 homebrew,
892 context,
893 )) ?? fallback
894 );
895 }
896​
897 return fallback;
898});
899​
900/**
901 * `brew upgrade` capabilities once the keg's prefix is confirmed by a `brew`
902 * that reports it as its own. Version checks defer `brew info` until needed.
903 * Null when no `brew` is found or it owns another prefix.
904 */
905export const resolveHomebrewProviderMaintenance = Effect.fn("resolveHomebrewProviderMaintenance")(
906 function* (
907 input: { readonly provider: ProviderDriverKind; readonly packageName: string | null },
908 homebrew: HomebrewOwnership,
909 context: ProviderMaintenanceResolutionContext,
910 ) {
911 const fileSystem = yield* FileSystem.FileSystem;
912 const path = yield* Path.Path;
913 // GUI servers may lack Homebrew on PATH; use the keg brew with the same prefix check.
914 const kegBrewPath = path.join(homebrew.prefix, "bin", "brew");
915 const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false)))
916 ? kegBrewPath
917 : yield* resolveCommandPath("brew", { env: context.env });
918 if (!brewPath) {
919 return null;
920 }
921 const brewPrefix = nonEmptyString(yield* runProbe(brewPath, ["--prefix"], context.env));
922 const realBrewPrefix = brewPrefix
923 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
924 : null;
925 if (
926 !realBrewPrefix ||
927 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
928 ) {
929 return null;
930 }
931 const args =
932 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
933 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
934 return makeProviderMaintenanceCapabilities({
935 provider: input.provider,
936 packageName: input.packageName,
937 updateExecutable: brewPath,
938 updateArgs: args,
939 updateLockKey: "homebrew",
940 updateCommand: ["brew", ...args].join(" "),
941 // Brew's release can lag npm; only its own channel can say what upgrade installs.
942 latestVersion: null,
943 lookupLatestVersion: runProbe(
944 brewPath,
945 ["info", "--json=v2", homebrew.name],
946 context.env,
947 ).pipe(
948 Effect.map((info) => (info ? parseHomebrewLatestVersion(info, homebrew) : null)),
949 Effect.provideContext(services),
950 ),
951 });
952 },
953);
954​
955const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* (
956 context: ProviderMaintenanceResolutionContext,
957) {
958 const fileSystem = yield* FileSystem.FileSystem;
959 const size = yield* fileSystem.stat(context.realCommandPath).pipe(
960 Effect.map((info) => Number(info.size)),
961 Effect.orElseSucceed(() => Infinity),
962 );
963 if (size > MISE_WRAPPER_MAX_BYTES) {
964 return false;
965 }
966 const script = yield* fileSystem
967 .readFileString(context.realCommandPath)
968 .pipe(Effect.orElseSucceed(() => ""));
969 return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script);
970});
971​
972// Volta bin links only identify its shim; the package image proves ownership.
973const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* (
974 context: ProviderMaintenanceResolutionContext,
975 packageName: string,
976) {
977 const path = yield* Path.Path;
978 if (
979 path.basename(normalizeCommandPath(context.realCommandPath)).replace(/\.exe$/, "") !==
980 "volta-shim"
981 ) {
982 return false;
983 }
984 const voltaHome = path.dirname(path.dirname(context.resolvedCommandPath));
985 const packageDir = path.join(voltaHome, "tools", "image", "packages", ...packageName.split("/"));
986 const fileSystem = yield* FileSystem.FileSystem;
987 return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false));
988});
989​
990const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* (
991 context: ProviderMaintenanceResolutionContext,
992 packageName: string,
993) {
994 const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName);
995 if (fromRealPath) {
996 return fromRealPath;
997 }
998 if ((yield* HostProcess.Platform) !== "win32") {
999 return null;
1001 const fileSystem = yield* FileSystem.FileSystem;
1002 const path = yield* Path.Path;
1003 const shimDir = path.dirname(context.resolvedCommandPath);
1004 const manifestPath = path.join(
1005 shimDir,
1006 "node_modules",
1007 ...packageName.split("/"),
1008 "package.json",
1009 );
1010 // Windows npm shims sit beside node_modules; POSIX project checkouts can mimic this layout.
1011 const hasManifest = yield* fileSystem
1012 .exists(manifestPath)
1013 .pipe(Effect.orElseSucceed(() => false));
1014 return hasManifest ? shimDir : null;
1015});
1016​
1017export function makePackageManagedProviderMaintenanceResolver(
1018 definition: PackageManagedProviderMaintenanceDefinition,
1019): ProviderMaintenanceCapabilitiesResolver {
1020 return {
1021 resolve: (context) => resolvePackageManagedProviderMaintenance(definition, context),
1022 };
1024​
1025function makeManualProviderMaintenanceCapabilities(
1026 provider: ProviderDriverKind,
1027): ProviderMaintenanceCapabilities {
1028 return makeManualOnlyProviderMaintenanceCapabilities({
1029 provider,
1030 packageName: null,
1031 });
1033​
1034const decodeNpmViewVersion = Schema.decodeUnknownOption(
1035 Schema.fromJsonString(Schema.Union([Schema.String, Schema.Tuple([Schema.String])])),
1037​
1038function environmentCacheKey(env: NodeJS.ProcessEnv): string {
1039 return NodeCrypto.createHash("sha256")
1040 .update(
1041 JSON.stringify(Object.entries(env).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
1043 .digest("hex");
1045​
1046const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup")(function* (
1047 capabilities: ProviderMaintenanceCapabilities,
1048) {
1049 const packageName = capabilities.packageName;
1050 if (!packageName || capabilities.latestVersion !== undefined) {
1051 return capabilities;
1053 const env = { ...(yield* HostProcess.Environment), ...capabilities.update?.env };
1054 const npmPath = yield* resolveCommandPath("npm", { env });
1055 if (!npmPath) return capabilities;
1056 const services = yield* Effect.context<ProviderMaintenanceResolverServices>();
1057 const update = capabilities.update;
1058 const prefixIndex = update?.lockKey.startsWith("npm-global:")
1059 ? update.args.indexOf("--prefix")
1060 : -1;
1061 const prefix = prefixIndex >= 0 ? update?.args[prefixIndex + 1] : undefined;
1062 const lookup = runProbe(
1063 npmPath,
1065 "view",
1066 "--global",
1067 ...(prefix ? ["--prefix", prefix] : []),
1068 "--json",
1069 `${packageName}@latest`,
1070 "version",
1071 ],
1072 env,
1073 ).pipe(
1074 Effect.map((stdout) => {
1075 const value = stdout ? Option.getOrNull(decodeNpmViewVersion(stdout.trim())) : null;
1076 return nonEmptyString(typeof value === "string" ? value : value?.[0]);
1077 }),
1078 Effect.tap((version) =>
1079 version === null
1080 ? Effect.logWarning("Configured npm version lookup failed", { packageName })
1081 : Effect.void,
1082 ),
1083 Effect.provideContext(services),
1084 );
1085 return {
1086 ...capabilities,
1087 // npm owns registry, scoped auth and proxy configuration; failure must not retry public npm.
1088 latestVersion: null,
1089 latestVersionCacheKey: [
1090 "configured-npm",
1091 npmPath,
1092 packageName,
1093 prefix ?? "",
1094 environmentCacheKey(env),
1095 installerLatestVersionKey(capabilities) ?? "",
1096 ].join("\0"),
1097 lookupLatestVersion: capabilities.lookupLatestVersion
1098 ? capabilities.lookupLatestVersion.pipe(
1099 Effect.filterOrElse(
1100 (version) => version !== null,
1101 () => lookup,
1102 ),
1104 : lookup,
1105 };
1106});
1107​
1108export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
1109 "resolveProviderMaintenanceCapabilitiesEffect",
1110)(function* (
1111 resolver: ProviderMaintenanceCapabilitiesResolver,
1112 options?: {
1113 readonly binaryPath?: string | null;
1114 readonly env?: NodeJS.ProcessEnv;
1115 },
1116) {
1117 const binaryPath = nonEmptyString(options?.binaryPath);
1118 if (!binaryPath) {
1119 return yield* resolver.resolve(null);
1121​
1122 const env = options?.env ?? (yield* readCommandLookupEnv);
1123 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env });
1124 if (!resolvedCommandPath) {
1125 return yield* resolver.resolve(null);
1127​
1128 const fileSystem = yield* FileSystem.FileSystem;
1129 const realCommandPath = yield* fileSystem
1130 .realPath(resolvedCommandPath)
1131 .pipe(Effect.orElseSucceed(() => null));
1132 if (!realCommandPath) {
1133 return yield* resolver.resolve(null);
1135 return yield* resolver.resolve({
1136 binaryPath,
1137 resolvedCommandPath,
1138 realCommandPath,
1139 env,
1140 platform: yield* HostProcess.Platform,
1141 });
1142}, Effect.flatMap(withConfiguredNpmVersionLookup));
1143​
1144export const makeCachedProviderMaintenanceResolution = Effect.fn(
1145 "makeCachedProviderMaintenanceResolution",
1146)(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) {
1147 const cache = yield* Cache.make({
1148 capacity: 1,
1149 timeToLive: MAINTENANCE_CAPABILITIES_CACHE_TTL,
1150 lookup: () => resolve,
1151 });
1152 const cached = Cache.get(cache, undefined);
1153 return (options?: { readonly fresh?: boolean }) =>
1154 options?.fresh ? Cache.invalidate(cache, undefined).pipe(Effect.andThen(cached)) : cached;
1155});
1156​
1157function manualUpdateMessage(latestVersion: string, installPath: string | undefined): string {
1158 return installPath
1159 ? `Version ${latestVersion} is out. T3 Code runs ${installPath}; update it with the app or tool that installed it.`
1160 : `Version ${latestVersion} is out. Update it with the app or tool that installed it.`;
1162​
1163function deriveVersionAdvisory(input: {
1164 readonly currentVersion: string | null;
1165 readonly latestVersion: string | null;
1166 readonly capabilities: ProviderMaintenanceCapabilities;
1167}): Pick<ServerProviderVersionAdvisory, "status" | "message"> {
1168 if (!input.currentVersion) {
1169 return { status: "unknown", message: null };
1171 if (!input.latestVersion) {
1172 return { status: "unknown", message: null };
1174 if (
1175 (input.capabilities.compareVersions ?? compareSemverVersions)(
1176 input.currentVersion,
1177 input.latestVersion,
1178 ) < 0
1179 ) {
1180 return {
1181 status: "behind_latest",
1182 message:
1183 input.capabilities.update === null
1184 ? manualUpdateMessage(input.latestVersion, input.capabilities.installPath)
1185 : PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
1186 };
1188 return { status: "current", message: null };
1190​
1191export function createProviderVersionAdvisory(input: {
1192 readonly driver: ProviderDriverKind;
1193 readonly currentVersion: string | null;
1194 readonly latestVersion?: string | null;
1195 readonly checkedAt?: string | null;
1196 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
1197}): ServerProviderVersionAdvisory {
1198 const capabilities =
1199 input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver);
1200 const latestVersion = input.latestVersion ?? null;
1201 const advisory = deriveVersionAdvisory({
1202 currentVersion: input.currentVersion,
1203 latestVersion,
1204 capabilities,
1205 });
1206​
1207 return {
1208 status: advisory.status,
1209 currentVersion: input.currentVersion,
1210 latestVersion,
1211 updateCommand:
1212 resolveLatestProviderUpdateAction(capabilities, latestVersion)?.command ??
1213 capabilities.manualCommand ??
1214 null,
1215 canUpdate: capabilities.update !== null,
1216 canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null,
1217 checkedAt: input.checkedAt ?? null,
1218 message: advisory.message,
1219 };
1221​
1222const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
1223 const client = yield* HttpClient.HttpClient;
1224 const request = HttpClientRequest.get(
1225 `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
1226 ).pipe(HttpClientRequest.setHeader("accept", "application/json"));
1227 // The deadline covers the body too: headers can arrive before the JSON stalls.
1228 const payload = yield* client.execute(request).pipe(
1229 Effect.flatMap(HttpClientResponse.filterStatusOk),
1230 Effect.flatMap(HttpClientResponse.schemaBodyJson(NpmLatestVersionResponse)),
1231 Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS),
1232 Effect.orElseSucceed(() => Option.none()),
1233 );
1234 return Option.isSome(payload) ? nonEmptyString(payload.value.version) : null;
1235});
1236​
1237/**
1238 * The installer's own answer is cached apart from the npm registry entry. A
1239 * manual-only install has no command to key on, so its executable stands in.
1240 */
1241function installerLatestVersionKey(capabilities: ProviderMaintenanceCapabilities) {
1242 if (!capabilities.lookupLatestVersion) return null;
1243 if (capabilities.latestVersionCacheKey) return capabilities.latestVersionCacheKey;
1244 const update = capabilities.update;
1245 if (update) return [update.lockKey, update.executable, ...update.args].join("\0");
1246 return capabilities.installPath
1247 ? ["manual", capabilities.installPath].join("\0")
1248 : capabilities.packageName && `npm-registry\0${capabilities.packageName}`;
1250​
1251export function latestProviderVersionCacheKeys(
1252 capabilities: ProviderMaintenanceCapabilities,
1253): ReadonlyArray<string> {
1254 return [capabilities.packageName, installerLatestVersionKey(capabilities)].filter(
1255 (key) => key !== null,
1256 );
1258​
1259export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (
1260 maintenanceCapabilities: ProviderMaintenanceCapabilities,
1261) {
1262 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
1263 const installerKey = installerLatestVersionKey(maintenanceCapabilities);
1264 if (installerKey && maintenanceCapabilities.lookupLatestVersion) {
1265 const installable = yield* latestVersions.cached(
1266 installerKey,
1267 maintenanceCapabilities.lookupLatestVersion,
1268 );
1269 if (installable !== null) return installable;
1271 if (maintenanceCapabilities.latestVersion !== undefined) {
1272 return maintenanceCapabilities.latestVersion;
1274 const packageName = maintenanceCapabilities.packageName;
1275 if (!packageName) {
1276 return null;
1278​
1279 return yield* latestVersions.cached(packageName, fetchNpmLatestVersion(packageName));
1280});
1281​
1282export const invalidateLatestProviderVersion = Effect.fn("invalidateLatestProviderVersion")(
1283 function* (maintenanceCapabilities: ProviderMaintenanceCapabilities) {
1284 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
1285 yield* Effect.forEach(
1286 latestProviderVersionCacheKeys(maintenanceCapabilities),
1287 latestVersions.invalidate,
1288 { discard: true },
1289 );
1290 },
1292​
1293export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn(
1294 "enrichProviderSnapshotWithVersionAdvisory",
1295)(function* (
1296 snapshot: ServerProvider,
1297 maintenanceCapabilities?: ProviderMaintenanceCapabilities,
1298 options?: {
1299 readonly enableProviderUpdateChecks: boolean | undefined;
1300 },
1301) {
1302 const capabilities =
1303 maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver);
1304 const currentVersion =
1305 snapshot.installedVersion !== undefined ? snapshot.installedVersion : snapshot.version;
1306 const shouldResolveLatestVersion =
1307 options?.enableProviderUpdateChecks !== false &&
1308 snapshot.enabled &&
1309 snapshot.installed &&
1310 Boolean(currentVersion);
1311 if (!shouldResolveLatestVersion) {
1312 return {
1313 ...snapshot,
1314 versionAdvisory: createProviderVersionAdvisory({
1315 driver: snapshot.driver,
1316 currentVersion,
1317 checkedAt: snapshot.checkedAt,
1318 maintenanceCapabilities: capabilities,
1319 }),
1320 };
1322​
1323 const latestVersion = yield* resolveLatestProviderVersion(capabilities);
1324 return {
1325 ...snapshot,
1326 versionAdvisory: createProviderVersionAdvisory({
1327 driver: snapshot.driver,
1328 currentVersion,
1329 latestVersion,
1330 checkedAt: DateTime.formatIso(yield* DateTime.now),
1331 maintenanceCapabilities: capabilities,
1332 }),
1333 };
1334});