1export type {
2 ProviderMaintenanceCapabilities,
3 ProviderMaintenanceCommandAction,
4} from "@t3tools/provider-core/server/maintenance";
5import type {
6 ProviderMaintenanceCapabilities,
7 ProviderMaintenanceCommandAction,
8} from "@t3tools/provider-core/server/maintenance";
9import {
10 ProviderDriverKind,
11 type ServerProvider,
12 type ServerProviderVersionAdvisory,
13} from "@t3tools/contracts";
14import { compareSemverVersions } from "@t3tools/shared/semver";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import { causeErrorTag } from "@t3tools/shared/observability";
17import { resolveCommandPath } from "@t3tools/shared/shell";
18import * as Cache from "effect/Cache";
19import * as Config from "effect/Config";
20import * as DateTime from "effect/DateTime";
21import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
23import * as FileSystem from "effect/FileSystem";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";
27import * as Stream from "effect/Stream";
28import * as HttpClient from "effect/http/HttpClient";
29import * as HttpClientRequest from "effect/http/HttpClientRequest";
30import * as ChildProcess from "effect/process/ChildProcess";
31import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
32
33import { collectUint8StreamText } from "./collectStreamText.ts";
34import * as ProviderLatestVersions from "./ProviderLatestVersions.ts";
35
36const LATEST_VERSION_TIMEOUT_MS = 4_000;
37const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
38const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
39const MISE_WRAPPER_MAX_BYTES = 16 * 1_024;
40const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
41
42/**
43 * Ownership is re-derived from the executable this often. Installs do not
44 * move on their own, so this mostly bounds how stale a Homebrew "latest" can
45 * get; the npm registry check keeps its own cache.
46 */
47const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1);
48
49const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
50 Object.fromEntries(
51 Object.entries(input).flatMap(([key, value]) =>
52 Option.match(value, {
53 onNone: () => [],
54 onSome: (resolved) => [[key, resolved]],
55 }),
56 ),
57 );
58
59const CommandLookupEnvConfig = Config.all({
60 PATH: Config.String("PATH").pipe(Config.option),
61 Path: Config.String("Path").pipe(Config.option),
62 path: Config.String("path").pipe(Config.option),
63 PATHEXT: Config.String("PATHEXT").pipe(Config.option),
64}).pipe(Config.map(compactEnv));
65
66const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() => ({})));
67
68/** Where the provider executable was found; every path is absolute. */
69export interface ProviderMaintenanceResolutionContext {
70 readonly binaryPath: string;
71 readonly resolvedCommandPath: string;
72 readonly realCommandPath: string;
73 readonly env: NodeJS.ProcessEnv;
74 /** Host platform; decides how the copyable command quotes the executable. */
75 readonly platform: NodeJS.Platform;
76}
77
78export type ProviderMaintenanceResolverServices =
79 | FileSystem.FileSystem
80 | Path.Path
81 | ChildProcessSpawner.ChildProcessSpawner;
82
83export interface ProviderMaintenanceCapabilitiesResolver {
84 readonly resolve: (
85 context: ProviderMaintenanceResolutionContext | null,
86 ) => Effect.Effect<ProviderMaintenanceCapabilities, never, ProviderMaintenanceResolverServices>;
87}
88
89export interface PackageManagedProviderMaintenanceDefinition {
90 readonly provider: ProviderDriverKind;
91 readonly npmPackageName: string;
92 /**
93 * The provider's own updater (`claude update`, `pi update --self`). It runs
94 * first for paths its installer owns, and otherwise only when no package
95 * manager is proven, because these updaters detect their installer too.
96 */
97 readonly nativeUpdate: {
98 readonly args: ReadonlyArray<string>;
99 /** Paths the provider's own installer owns; omit when it has none. */
100 readonly isCommandPath?: (commandPath: string) => boolean;
101 /** Environment the native updater needs to target this instance's install. */
102 readonly env?: NodeJS.ProcessEnv;
103 } | null;
104}
105
106const NpmLatestVersionResponse = Schema.Struct({
107 version: Schema.optional(Schema.String),
108});
109
110function nonEmptyString(value: unknown): string | null {
111 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
112}
113
114/**
115 * The copyable command must paste into a shell as-is, so an executable path
116 * with spaces or quotes is quoted for the host's default shell.
117 */
118function quoteShellWord(word: string, platform: NodeJS.Platform): string {
119 const safeWord = platform === "win32" ? /^[\w./:\\@=-]+$/ : /^[\w./:@=-]+$/;
120 if (safeWord.test(word)) return word;
121 return platform === "win32"
122 ? `'${word.replace(/['\u2018\u2019]/g, "$&$&")}'`
123 : `'${word.replaceAll("'", "'\\''")}'`;
124}
125
126function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {
127 const quoted = quoteShellWord(executable, platform);
128 // Windows terminals default to PowerShell, where a quoted executable needs &.
129 return platform === "win32" && quoted !== executable ? `& ${quoted}` : quoted;
130}
131
132export function makeProviderMaintenanceCapabilities(input: {
133 readonly provider: ProviderDriverKind;
134 readonly packageName: string | null;
135 readonly updateExecutable: string | null;
136 readonly updateArgs: ReadonlyArray<string>;
137 readonly updateLockKey: string | null;
138 /** Shown to the user instead of `<executable> <args>`; use for a bare tool name like `brew`. */
139 readonly updateCommand?: string;
140 readonly platform?: NodeJS.Platform;
141 readonly env?: NodeJS.ProcessEnv;
142 readonly latestVersion?: string | null;
143}): ProviderMaintenanceCapabilities {
144 const platform = input.platform ?? HostProcess.Platform.defaultValue();
145 const update =
146 input.updateExecutable === null || input.updateLockKey === null
147 ? null
148 : {
149 command:
150 input.updateCommand ??
151 [
152 quoteUpdateExecutable(input.updateExecutable, platform),
153 ...input.updateArgs.map((arg) => quoteShellWord(arg, platform)),
154 ].join(" "),
155 executable: input.updateExecutable,
156 args: input.updateArgs,
157 lockKey: input.updateLockKey,
158 ...(input.env ? { env: input.env } : {}),
159 };
160 return {
161 provider: input.provider,
162 packageName: input.packageName,
163 update,
164 ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}),
165 };
166}
167
168/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */
169export function makeTargetedProviderUpdateAction(
170 capabilities: ProviderMaintenanceCapabilities,
171 version: string,
172): ProviderMaintenanceCommandAction | null {
173 if (!/^\d+\.\d+\.\d+$/.test(version)) return null;
174 const update = capabilities.update;
175 const packageName = capabilities.packageName;
176 if (!update || !packageName) return null;
177 if (
178 !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test(
179 update.lockKey,
180 )
181 )
182 return null;
183 const packageIndex = update.args.findIndex(
184 (arg) => arg === `${packageName}@latest` || arg === packageName,
185 );
186 if (packageIndex < 0) return null;
187 const args = update.args.map((arg, index) =>
188 index === packageIndex ? `${packageName}@${version}` : arg,
189 );
190 const previous = update.args[packageIndex]!;
191 const commandIndex = update.command.lastIndexOf(previous);
192 const command =
193 commandIndex < 0
194 ? update.command
195 : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`;
196 return { ...update, args, command };
197}
198
199export function makeManualOnlyProviderMaintenanceCapabilities(input: {
200 readonly provider: ProviderDriverKind;
201 readonly packageName: string | null;
202}): ProviderMaintenanceCapabilities {
203 return makeProviderMaintenanceCapabilities({
204 provider: input.provider,
205 packageName: input.packageName,
206 updateExecutable: null,
207 updateArgs: [],
208 updateLockKey: null,
209 });
210}
211
212export function normalizeCommandPath(commandPath: string): string {
213 return commandPath.replaceAll("\\", "/").toLowerCase();
214}
215
216function isVitePlusGlobalCommandPath(commandPath: string): boolean {
217 return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");
218}
219
220function isBunGlobalCommandPath(commandPath: string): boolean {
221 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
222}
223
224function isYarnGlobalCommandPath(commandPath: string): boolean {
225 // `~/.config/yarn/global/…` on POSIX, `%LOCALAPPDATA%\Yarn\Data\global\…` on Windows.
226 return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath));
227}
228
229/** Version-manager installs are pinned in its config, so updating them means editing that. */
230function isMiseCommandPath(commandPath: string): boolean {
231 const normalized = normalizeCommandPath(commandPath);
232 return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/");
233}
234
235function isPnpmGlobalCommandPath(commandPath: string): boolean {
236 const normalized = normalizeCommandPath(commandPath);
237 return (
238 normalized.includes("/.local/share/pnpm/") ||
239 normalized.includes("/library/pnpm/") ||
240 normalized.includes("/local/share/pnpm/") ||
241 normalized.includes("/appdata/local/pnpm/") ||
242 normalized.includes("/pnpm/global/")
243 );
244}
245
246/**
247 * The npm global prefix that owns a package, derived from the real path of
248 * its entry point: `<prefix>/lib/node_modules/<pkg>/…`. Windows global
249 * installs have no `lib` segment and are proven by the shim instead (see
250 * `resolveNpmGlobalPrefix`). A project-local `node_modules` is not a global
251 * install and yields null.
252 */
253export function npmGlobalPrefixFromCommandPath(
254 realCommandPath: string,
255 packageName: string,
256): string | null {
257 const slashPath = realCommandPath.replaceAll("\\", "/");
258 const normalized = slashPath.toLowerCase();
259 const packageSegment = `/lib/node_modules/${packageName.toLowerCase()}/`;
260 const packageIndex = normalized.lastIndexOf(packageSegment);
261 if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) {
262 return null;
263 }
264 // Mise's npm backend uses a global-looking layout inside a tool version.
265 // Globals under its Node installation still belong to npm.
266 const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1];
267 if (miseTool && miseTool !== "node") {
268 return null;
269 }
270 return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex);
271}
272
273// `<prefix>/Cellar/<name>/<version>/…` or `<prefix>/Caskroom/<name>/<version>/…`.
274// Homebrew always nests a version directory under the keg.
275const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i;
276
277export interface HomebrewOwnership {
278 readonly kind: "formula" | "cask";
279 readonly name: string;
280 /** The Homebrew prefix the keg sits under; must match `brew --prefix`. */
281 readonly prefix: string;
282}
283
284/**
285 * Homebrew looks like the owner when the real path runs through a versioned
286 * keg or cask. It is only proven once the prefix matches the `brew` that will
287 * run the upgrade (see `resolvePackageManagedProviderMaintenance`).
288 */
289export function homebrewOwnershipFromCommandPath(
290 realCommandPath: string,
291): HomebrewOwnership | null {
292 const match = HOMEBREW_KEG_PATTERN.exec(realCommandPath.replaceAll("\\", "/"));
293 if (!match) {
294 return null;
295 }
296 return {
297 kind: match[2]!.toLowerCase() === "cellar" ? "formula" : "cask",
298 name: match[3]!,
299 prefix: match[1]!,
300 };
301}
302
303const HomebrewInfoResponse = Schema.Struct({
304 formulae: Schema.optional(
305 Schema.Array(
306 Schema.Struct({
307 versions: Schema.optional(Schema.Struct({ stable: Schema.optional(Schema.String) })),
308 }),
309 ),
310 ),
311 casks: Schema.optional(Schema.Array(Schema.Struct({ version: Schema.optional(Schema.String) }))),
312});
313
314const decodeHomebrewInfo = Schema.decodeUnknownOption(Schema.fromJsonString(HomebrewInfoResponse));
315
316/** Cask versions may carry a build suffix after a comma (`1.2.3,456`). */
317export function parseHomebrewLatestVersion(
318 infoJson: string,
319 ownership: HomebrewOwnership,
320): string | null {
321 const decoded = decodeHomebrewInfo(infoJson);
322 if (Option.isNone(decoded)) {
323 return null;
324 }
325 const raw =
326 ownership.kind === "formula"
327 ? decoded.value.formulae?.[0]?.versions?.stable
328 : decoded.value.casks?.[0]?.version?.split(",", 1)[0];
329 return nonEmptyString(raw);
330}
331
332/** Run `brew <args>` and return stdout, or null on failure, timeout, or oversized output. */
333const runHomebrew = Effect.fn("runHomebrew")(function* (
334 brewPath: string,
335 args: ReadonlyArray<string>,
336 env: NodeJS.ProcessEnv,
337) {
338 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
339 const collect = Effect.gen(function* () {
340 const child = yield* spawner.spawn(ChildProcess.make(brewPath, args, { env, extendEnv: true }));
341 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
342 // stderr is drained so a chatty brew cannot block on a full pipe.
343 const [stdout, exitCode] = yield* Effect.all(
344 [
345 collectUint8StreamText({ stream: child.stdout, maxBytes: HOMEBREW_INFO_MAX_BYTES }),
346 child.exitCode,
347 Stream.runDrain(child.stderr),
348 ],
349 { concurrency: "unbounded" },
350 );
351 return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text;
352 });
353 return yield* collect.pipe(
354 Effect.scoped,
355 Effect.timeoutOption(Duration.millis(HOMEBREW_INFO_TIMEOUT_MS)),
356 Effect.map(Option.getOrNull),
357 Effect.catchCause((cause) =>
358 Effect.logWarning("Homebrew probe failed", {
359 subcommand: args[0],
360 errorTag: causeErrorTag(cause),
361 }).pipe(Effect.as(null)),
362 ),
363 );
364});
365
366/**
367 * Derive update capabilities from where the executable actually lives. Every
368 * package-manager branch has evidence that the named tool owns that path, so
369 * T3 Code never runs a package manager against an install it did not create.
370 * An unproven install falls back to the provider's own updater, which detects
371 * its installer itself, and stays manual-only without one.
372 */
373export const resolvePackageManagedProviderMaintenance = Effect.fn(
374 "resolvePackageManagedProviderMaintenance",
375)(function* (
376 definition: PackageManagedProviderMaintenanceDefinition,
377 context: ProviderMaintenanceResolutionContext | null,
378) {
379 const manual = makeManualOnlyProviderMaintenanceCapabilities({
380 provider: definition.provider,
381 packageName: definition.npmPackageName,
382 });
383 if (!context) {
384 return manual;
385 }
386 const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
387 const packageName = definition.npmPackageName;
388
389 const nativeUpdate = definition.nativeUpdate;
390 const native = nativeUpdate
391 ? makeProviderMaintenanceCapabilities({
392 provider: definition.provider,
393 packageName,
394 updateExecutable: context.resolvedCommandPath,
395 updateArgs: nativeUpdate.args,
396 updateLockKey: `${definition.provider}-native`,
397 platform: context.platform,
398 ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}),
399 })
400 : manual;
401 if (nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath)) {
402 return native;
403 }
404 // A `node_modules` path not proven below belongs to another package or a
405 // project, so the provider's own updater could act on the wrong install.
406 const fallback = commandPaths.some((commandPath) =>
407 normalizeCommandPath(commandPath).includes("/node_modules/"),
408 )
409 ? manual
410 : native;
411 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
412 return makeProviderMaintenanceCapabilities({
413 provider: definition.provider,
414 packageName,
415 updateExecutable: "vp",
416 updateArgs: ["i", "-g", packageName],
417 updateLockKey: "vite-plus-global",
418 });
419 }
420 if (commandPaths.some(isBunGlobalCommandPath)) {
421 return makeProviderMaintenanceCapabilities({
422 provider: definition.provider,
423 packageName,
424 updateExecutable: "bun",
425 updateArgs: ["i", "-g", `${packageName}@latest`],
426 updateLockKey: "bun-global",
427 });
428 }
429 if (commandPaths.some(isPnpmGlobalCommandPath)) {
430 return makeProviderMaintenanceCapabilities({
431 provider: definition.provider,
432 packageName,
433 updateExecutable: "pnpm",
434 updateArgs: ["add", "-g", `${packageName}@latest`],
435 updateLockKey: "pnpm-global",
436 });
437 }
438
439 if (commandPaths.some(isYarnGlobalCommandPath)) {
440 return makeProviderMaintenanceCapabilities({
441 provider: definition.provider,
442 packageName,
443 updateExecutable: "yarn",
444 updateArgs: ["global", "add", `${packageName}@latest`],
445 updateLockKey: "yarn-global",
446 });
447 }
448 if (yield* isVoltaPackageInstall(context, packageName)) {
449 return makeProviderMaintenanceCapabilities({
450 provider: definition.provider,
451 packageName,
452 updateExecutable: "volta",
453 updateArgs: ["install", `${packageName}@latest`],
454 updateLockKey: "volta",
455 });
456 }
457
458 // npm proof names the package, so it outranks a keg the path merely passes
459 // through: a Homebrew-installed Node keeps its globals under
460 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.
461 const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);
462 if (npmPrefix) {
463 // npm 12 blocks install scripts by default (empty allow-scripts allowlist)
464 // and still exits 0, so a package whose postinstall finishes the install
465 // (claude copies its native binary over a placeholder stub) is left broken
466 // while the update reports success. Allow this one package's scripts.
467 // Older npm warns about the unknown config and continues.
468 return makeProviderMaintenanceCapabilities({
469 provider: definition.provider,
470 packageName,
471 updateExecutable: "npm",
472 updateArgs: [
473 "install",
474 "-g",
475 "--prefix",
476 npmPrefix,
477 `--allow-scripts=${packageName}`,
478 `${packageName}@latest`,
479 ],
480 updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`,
481 });
482 }
483
484 if (commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context))) {
485 return manual;
486 }
487
488 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
489 if (homebrew) {
490 // Mise shims resolve to the version manager, not the provider.
491 if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") {
492 return manual;
493 }
494 const fileSystem = yield* FileSystem.FileSystem;
495 const path = yield* Path.Path;
496 // The keg's own brew works even when a GUI-launched server has no
497 // Homebrew on PATH; the prefix check below still applies to it.
498 const kegBrewPath = path.join(homebrew.prefix, "bin", "brew");
499 const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false)))
500 ? kegBrewPath
501 : yield* resolveCommandPath("brew", { env: context.env });
502 if (!brewPath) {
503 return fallback;
504 }
505 // A keg-shaped path is only Homebrew's if it sits under the prefix of the
506 // `brew` that would upgrade it; `brew --prefix` is a cheap shell script.
507 const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));
508 const realBrewPrefix = brewPrefix
509 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
510 : null;
511 if (
512 !realBrewPrefix ||
513 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
514 ) {
515 return fallback;
516 }
517 const args =
518 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
519 // Homebrew lags npm by hours on every release, so compare against what
520 // `brew upgrade` can actually deliver.
521 const info = yield* runHomebrew(brewPath, ["info", "--json=v2", homebrew.name], context.env);
522 return makeProviderMaintenanceCapabilities({
523 provider: definition.provider,
524 packageName,
525 updateExecutable: brewPath,
526 updateArgs: args,
527 updateLockKey: "homebrew",
528 updateCommand: ["brew", ...args].join(" "),
529 latestVersion: info ? parseHomebrewLatestVersion(info, homebrew) : null,
530 });
531 }
532
533 return fallback;
534});
535
536/** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */
537const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* (
538 context: ProviderMaintenanceResolutionContext,
539) {
540 const fileSystem = yield* FileSystem.FileSystem;
541 const size = yield* fileSystem.stat(context.realCommandPath).pipe(
542 Effect.map((info) => Number(info.size)),
543 Effect.orElseSucceed(() => Infinity),
544 );
545 if (size > MISE_WRAPPER_MAX_BYTES) {
546 return false;
547 }
548 const script = yield* fileSystem
549 .readFileString(context.realCommandPath)
550 .pipe(Effect.orElseSucceed(() => ""));
551 return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script);
552});
553
554/**
555 * Volta's `bin/<cmd>` is a link to its `volta-shim`, which picks the package
556 * at run time; the package's own image directory proves Volta installed it.
557 */
558const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* (
559 context: ProviderMaintenanceResolutionContext,
560 packageName: string,
561) {
562 const path = yield* Path.Path;
563 if (
564 path.basename(normalizeCommandPath(context.realCommandPath)).replace(/\.exe$/, "") !==
565 "volta-shim"
566 ) {
567 return false;
568 }
569 const voltaHome = path.dirname(path.dirname(context.resolvedCommandPath));
570 const packageDir = path.join(voltaHome, "tools", "image", "packages", ...packageName.split("/"));
571 const fileSystem = yield* FileSystem.FileSystem;
572 return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false));
573});
574
575/**
576 * POSIX npm links `<prefix>/bin/<cmd>` into the package, so the real path is
577 * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof
578 * is the package manifest next to the shim.
579 */
580const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* (
581 context: ProviderMaintenanceResolutionContext,
582 packageName: string,
583) {
584 const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName);
585 if (fromRealPath) {
586 return fromRealPath;
587 }
588 if ((yield* HostProcess.Platform) !== "win32") {
589 return null;
590 }
591 const fileSystem = yield* FileSystem.FileSystem;
592 const path = yield* Path.Path;
593 const shimDir = path.dirname(context.resolvedCommandPath);
594 const manifestPath = path.join(
595 shimDir,
596 "node_modules",
597 ...packageName.split("/"),
598 "package.json",
599 );
600 // npm writes both `<cmd>.cmd` and an extensionless sh script into the
601 // Windows prefix; either one sits directly beside `node_modules`. A POSIX
602 // project checkout has the same shape, which is why this is Windows-only.
603 const hasManifest = yield* fileSystem
604 .exists(manifestPath)
605 .pipe(Effect.orElseSucceed(() => false));
606 return hasManifest ? shimDir : null;
607});
608
609export function makePackageManagedProviderMaintenanceResolver(
610 definition: PackageManagedProviderMaintenanceDefinition,
611): ProviderMaintenanceCapabilitiesResolver {
612 return {
613 resolve: (context) => resolvePackageManagedProviderMaintenance(definition, context),
614 };
615}
616
617function makeManualProviderMaintenanceCapabilities(
618 provider: ProviderDriverKind,
619): ProviderMaintenanceCapabilities {
620 return makeManualOnlyProviderMaintenanceCapabilities({
621 provider,
622 packageName: null,
623 });
624}
625
626/**
627 * Locate the configured provider executable, follow symlinks, and hand the
628 * result to the resolver. A binary that cannot be found yields the resolver's
629 * no-context answer.
630 */
631export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
632 "resolveProviderMaintenanceCapabilitiesEffect",
633)(function* (
634 resolver: ProviderMaintenanceCapabilitiesResolver,
635 options?: {
636 readonly binaryPath?: string | null;
637 readonly env?: NodeJS.ProcessEnv;
638 },
639) {
640 const binaryPath = nonEmptyString(options?.binaryPath);
641 if (!binaryPath) {
642 return yield* resolver.resolve(null);
643 }
644
645 const env = options?.env ?? (yield* readCommandLookupEnv);
646 // resolveCommandPath checks explicit paths for existence too, so a missing
647 // binary always lands in the no-context branch and never gets an update
648 // command it cannot run.
649 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env });
650 if (!resolvedCommandPath) {
651 return yield* resolver.resolve(null);
652 }
653
654 const fileSystem = yield* FileSystem.FileSystem;
655 const realCommandPath = yield* fileSystem
656 .realPath(resolvedCommandPath)
657 .pipe(Effect.orElseSucceed(() => null));
658 if (!realCommandPath) {
659 return yield* resolver.resolve(null);
660 }
661 return yield* resolver.resolve({
662 binaryPath,
663 resolvedCommandPath,
664 realCommandPath,
665 env,
666 platform: yield* HostProcess.Platform,
667 });
668});
669
670/**
671 * Turn a one-shot resolution into the shape drivers expose: a cached read for
672 * advisories and a `fresh` read that update execution uses so it never trusts
673 * ownership derived before the user clicked. An interrupted resolution is not
674 * cached, so the next read resolves again.
675 */
676export const makeCachedProviderMaintenanceResolution = Effect.fn(
677 "makeCachedProviderMaintenanceResolution",
678)(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) {
679 const cache = yield* Cache.make({
680 capacity: 1,
681 timeToLive: MAINTENANCE_CAPABILITIES_CACHE_TTL,
682 lookup: () => resolve,
683 });
684 const cached = Cache.get(cache, undefined);
685 return (options?: { readonly fresh?: boolean }) =>
686 options?.fresh ? Cache.invalidate(cache, undefined).pipe(Effect.andThen(cached)) : cached;
687});
688
689function deriveVersionAdvisory(input: {
690 readonly currentVersion: string | null;
691 readonly latestVersion: string | null;
692 readonly compareVersions?: (current: string, latest: string) => number;
693}): Pick<ServerProviderVersionAdvisory, "status" | "message"> {
694 if (!input.currentVersion) {
695 return { status: "unknown", message: null };
696 }
697 if (!input.latestVersion) {
698 return { status: "unknown", message: null };
699 }
700 if (
701 (input.compareVersions ?? compareSemverVersions)(input.currentVersion, input.latestVersion) < 0
702 ) {
703 return {
704 status: "behind_latest",
705 message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
706 };
707 }
708 return { status: "current", message: null };
709}
710
711export function createProviderVersionAdvisory(input: {
712 readonly driver: ProviderDriverKind;
713 readonly currentVersion: string | null;
714 readonly latestVersion?: string | null;
715 readonly checkedAt?: string | null;
716 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
717}): ServerProviderVersionAdvisory {
718 const capabilities =
719 input.maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(input.driver);
720 const latestVersion = input.latestVersion ?? null;
721 const advisory = deriveVersionAdvisory({
722 currentVersion: input.currentVersion,
723 latestVersion,
724 ...(capabilities.compareVersions ? { compareVersions: capabilities.compareVersions } : {}),
725 });
726
727 return {
728 status: advisory.status,
729 currentVersion: input.currentVersion,
730 latestVersion,
731 updateCommand: capabilities.update?.command ?? null,
732 canUpdate: capabilities.update !== null,
733 canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null,
734 checkedAt: input.checkedAt ?? null,
735 message: advisory.message,
736 };
737}
738
739const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
740 const client = yield* HttpClient.HttpClient;
741 const request = HttpClientRequest.get(
742 `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`,
743 ).pipe(HttpClientRequest.setHeader("accept", "application/json"));
744 const response = yield* client.execute(request).pipe(
745 Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS),
746 Effect.orElseSucceed(() => Option.none()),
747 );
748 if (Option.isNone(response)) {
749 return null;
750 }
751 const httpResponse = response.value;
752 if (httpResponse.status < 200 || httpResponse.status >= 300) {
753 return null;
754 }
755 const payload = yield* httpResponse.json.pipe(
756 Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)),
757 Effect.orElseSucceed(() => null),
758 );
759 return payload ? nonEmptyString(payload.version) : null;
760});
761
762export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (
763 maintenanceCapabilities: ProviderMaintenanceCapabilities,
764) {
765 if (maintenanceCapabilities.latestVersion !== undefined) {
766 return maintenanceCapabilities.latestVersion;
767 }
768 const packageName = maintenanceCapabilities.packageName;
769 if (!packageName) {
770 return null;
771 }
772
773 const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
774 return yield* latestVersions.cached(packageName, fetchNpmLatestVersion(packageName));
775});
776
777export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn(
778 "enrichProviderSnapshotWithVersionAdvisory",
779)(function* (
780 snapshot: ServerProvider,
781 maintenanceCapabilities?: ProviderMaintenanceCapabilities,
782 options?: {
783 readonly enableProviderUpdateChecks: boolean | undefined;
784 },
785) {
786 const capabilities =
787 maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver);
788 const shouldResolveLatestVersion =
789 options?.enableProviderUpdateChecks !== false &&
790 snapshot.enabled &&
791 snapshot.installed &&
792 Boolean(snapshot.version);
793 if (!shouldResolveLatestVersion) {
794 return {
795 ...snapshot,
796 versionAdvisory: createProviderVersionAdvisory({
797 driver: snapshot.driver,
798 currentVersion: snapshot.version,
799 checkedAt: snapshot.checkedAt,
800 maintenanceCapabilities: capabilities,
801 }),
802 };
803 }
804
805 const latestVersion = yield* resolveLatestProviderVersion(capabilities);
806 return {
807 ...snapshot,
808 versionAdvisory: createProviderVersionAdvisory({
809 driver: snapshot.driver,
810 currentVersion: snapshot.version,
811 latestVersion,
812 checkedAt: DateTime.formatIso(yield* DateTime.now),
813 maintenanceCapabilities: capabilities,
814 }),
815 };
816});