| ... | ... | @@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; |
| 26 | 26 | const LATEST_VERSION_TIMEOUT_MS = 4_000; |
| 27 | 27 | const HOMEBREW_INFO_TIMEOUT_MS = 10_000; |
| 28 | 28 | const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; |
| 29 | const MISE_WRAPPER_MAX_BYTES = 16 * 1_024; |
| 29 | 30 | const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; |
| 30 | 31 | |
| 31 | 32 | /** |
| ... | ... | @@ -105,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver { |
| 105 | 106 | export interface PackageManagedProviderMaintenanceDefinition { |
| 106 | 107 | readonly provider: ProviderDriverKind; |
| 107 | 108 | readonly npmPackageName: string; |
| 109 | /** |
| 110 | * The provider's own updater (`claude update`, `pi update --self`). It runs |
| 111 | * first for paths its installer owns, and otherwise only when no package |
| 112 | * manager is proven, because these updaters detect their installer too. |
| 113 | */ |
| 108 | 114 | readonly nativeUpdate: { |
| 109 | 115 | readonly args: ReadonlyArray<string>; |
| 110 | | readonly isCommandPath: (commandPath: string) => boolean; |
| 116 | /** Paths the provider's own installer owns; omit when it has none. */ |
| 117 | readonly isCommandPath?: (commandPath: string) => boolean; |
| 111 | 118 | /** Environment the native updater needs to target this instance's install. */ |
| 112 | 119 | readonly env?: NodeJS.ProcessEnv; |
| 113 | 120 | } | null; |
| ... | ... | @@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction( |
| 195 | 202 | const update = capabilities.update; |
| 196 | 203 | const packageName = capabilities.packageName; |
| 197 | 204 | if (!update || !packageName) return null; |
| 198 | | if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) |
| 205 | if ( |
| 206 | !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test( |
| 207 | update.lockKey, |
| 208 | ) |
| 209 | ) |
| 199 | 210 | return null; |
| 200 | 211 | const packageIndex = update.args.findIndex( |
| 201 | 212 | (arg) => arg === `${packageName}@latest` || arg === packageName, |
| ... | ... | @@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean { |
| 238 | 249 | return normalizeCommandPath(commandPath).includes("/.bun/bin/"); |
| 239 | 250 | } |
| 240 | 251 | |
| 252 | function isYarnGlobalCommandPath(commandPath: string): boolean { |
| 253 | // `~/.config/yarn/global/…` on POSIX, `%LOCALAPPDATA%\Yarn\Data\global\…` on Windows. |
| 254 | return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath)); |
| 255 | } |
| 256 | |
| 257 | /** Version-manager installs are pinned in its config, so updating them means editing that. */ |
| 258 | function isMiseCommandPath(commandPath: string): boolean { |
| 259 | const normalized = normalizeCommandPath(commandPath); |
| 260 | return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/"); |
| 261 | } |
| 262 | |
| 241 | 263 | function isPnpmGlobalCommandPath(commandPath: string): boolean { |
| 242 | 264 | const normalized = normalizeCommandPath(commandPath); |
| 243 | 265 | return ( |
| ... | ... | @@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* ( |
| 371 | 393 | |
| 372 | 394 | /** |
| 373 | 395 | * Derive update capabilities from where the executable actually lives. Every |
| 374 | | * branch that yields a one-click command has evidence that the named tool |
| 375 | | * owns that path; anything unproven stays manual-only so T3 Code never runs |
| 376 | | * a package manager against an install it did not create. |
| 396 | * package-manager branch has evidence that the named tool owns that path, so |
| 397 | * T3 Code never runs a package manager against an install it did not create. |
| 398 | * An unproven install falls back to the provider's own updater, which detects |
| 399 | * its installer itself, and stays manual-only without one. |
| 377 | 400 | */ |
| 378 | 401 | export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 379 | 402 | "resolvePackageManagedProviderMaintenance", |
| ... | ... | @@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 392 | 415 | const packageName = definition.npmPackageName; |
| 393 | 416 | |
| 394 | 417 | const nativeUpdate = definition.nativeUpdate; |
| 395 | | if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) { |
| 396 | | return makeProviderMaintenanceCapabilities({ |
| 397 | | provider: definition.provider, |
| 398 | | packageName, |
| 399 | | updateExecutable: context.resolvedCommandPath, |
| 400 | | updateArgs: nativeUpdate.args, |
| 401 | | updateLockKey: `${definition.provider}-native`, |
| 402 | | platform: context.platform, |
| 403 | | ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), |
| 404 | | }); |
| 418 | const native = nativeUpdate |
| 419 | ? makeProviderMaintenanceCapabilities({ |
| 420 | provider: definition.provider, |
| 421 | packageName, |
| 422 | updateExecutable: context.resolvedCommandPath, |
| 423 | updateArgs: nativeUpdate.args, |
| 424 | updateLockKey: `${definition.provider}-native`, |
| 425 | platform: context.platform, |
| 426 | ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), |
| 427 | }) |
| 428 | : manual; |
| 429 | if (nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath)) { |
| 430 | return native; |
| 405 | 431 | } |
| 432 | // A `node_modules` path not proven below belongs to another package or a |
| 433 | // project, so the provider's own updater could act on the wrong install. |
| 434 | const fallback = commandPaths.some((commandPath) => |
| 435 | normalizeCommandPath(commandPath).includes("/node_modules/"), |
| 436 | ) |
| 437 | ? manual |
| 438 | : native; |
| 406 | 439 | if (commandPaths.some(isVitePlusGlobalCommandPath)) { |
| 407 | 440 | return makeProviderMaintenanceCapabilities({ |
| 408 | 441 | provider: definition.provider, |
| ... | ... | @@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 431 | 464 | }); |
| 432 | 465 | } |
| 433 | 466 | |
| 467 | if (commandPaths.some(isYarnGlobalCommandPath)) { |
| 468 | return makeProviderMaintenanceCapabilities({ |
| 469 | provider: definition.provider, |
| 470 | packageName, |
| 471 | updateExecutable: "yarn", |
| 472 | updateArgs: ["global", "add", `${packageName}@latest`], |
| 473 | updateLockKey: "yarn-global", |
| 474 | }); |
| 475 | } |
| 476 | if (yield* isVoltaPackageInstall(context, packageName)) { |
| 477 | return makeProviderMaintenanceCapabilities({ |
| 478 | provider: definition.provider, |
| 479 | packageName, |
| 480 | updateExecutable: "volta", |
| 481 | updateArgs: ["install", `${packageName}@latest`], |
| 482 | updateLockKey: "volta", |
| 483 | }); |
| 484 | } |
| 485 | |
| 434 | 486 | // npm proof names the package, so it outranks a keg the path merely passes |
| 435 | 487 | // through: a Homebrew-installed Node keeps its globals under |
| 436 | 488 | // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's. |
| ... | ... | @@ -457,21 +509,31 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 457 | 509 | }); |
| 458 | 510 | } |
| 459 | 511 | |
| 512 | if (commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context))) { |
| 513 | return manual; |
| 514 | } |
| 515 | |
| 460 | 516 | const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); |
| 461 | 517 | if (homebrew) { |
| 462 | 518 | // Mise shims resolve to the version manager, not the provider. |
| 463 | 519 | if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { |
| 464 | 520 | return manual; |
| 465 | 521 | } |
| 466 | | const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( |
| 467 | | Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), |
| 468 | | ); |
| 522 | const fileSystem = yield* FileSystem.FileSystem; |
| 523 | const path = yield* Path.Path; |
| 524 | // The keg's own brew works even when a GUI-launched server has no |
| 525 | // Homebrew on PATH; the prefix check below still applies to it. |
| 526 | const kegBrewPath = path.join(homebrew.prefix, "bin", "brew"); |
| 527 | const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false))) |
| 528 | ? kegBrewPath |
| 529 | : yield* resolveCommandPath("brew", { env: context.env }).pipe( |
| 530 | Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), |
| 531 | ); |
| 469 | 532 | if (!brewPath) { |
| 470 | | return manual; |
| 533 | return fallback; |
| 471 | 534 | } |
| 472 | 535 | // A keg-shaped path is only Homebrew's if it sits under the prefix of the |
| 473 | 536 | // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. |
| 474 | | const fileSystem = yield* FileSystem.FileSystem; |
| 475 | 537 | const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); |
| 476 | 538 | const realBrewPrefix = brewPrefix |
| 477 | 539 | ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) |
| ... | ... | @@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 480 | 542 | !realBrewPrefix || |
| 481 | 543 | normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix) |
| 482 | 544 | ) { |
| 483 | | return manual; |
| 545 | return fallback; |
| 484 | 546 | } |
| 485 | 547 | const args = |
| 486 | 548 | homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; |
| ... | ... | @@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 498 | 560 | }); |
| 499 | 561 | } |
| 500 | 562 | |
| 501 | | return manual; |
| 563 | return fallback; |
| 564 | }); |
| 565 | |
| 566 | /** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */ |
| 567 | const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( |
| 568 | context: ProviderMaintenanceResolutionContext, |
| 569 | ) { |
| 570 | const fileSystem = yield* FileSystem.FileSystem; |
| 571 | const size = yield* fileSystem.stat(context.realCommandPath).pipe( |
| 572 | Effect.map((info) => Number(info.size)), |
| 573 | Effect.orElseSucceed(() => Infinity), |
| 574 | ); |
| 575 | if (size > MISE_WRAPPER_MAX_BYTES) { |
| 576 | return false; |
| 577 | } |
| 578 | const script = yield* fileSystem |
| 579 | .readFileString(context.realCommandPath) |
| 580 | .pipe(Effect.orElseSucceed(() => "")); |
| 581 | return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script); |
| 582 | }); |
| 583 | |
| 584 | /** |
| 585 | * Volta's `bin/<cmd>` is a link to its `volta-shim`, which picks the package |
| 586 | * at run time; the package's own image directory proves Volta installed it. |
| 587 | */ |
| 588 | const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* ( |
| 589 | context: ProviderMaintenanceResolutionContext, |
| 590 | packageName: string, |
| 591 | ) { |
| 592 | const path = yield* Path.Path; |
| 593 | if ( |
| 594 | path.basename(normalizeCommandPath(context.realCommandPath)).replace(/\.exe$/, "") !== |
| 595 | "volta-shim" |
| 596 | ) { |
| 597 | return false; |
| 598 | } |
| 599 | const voltaHome = path.dirname(path.dirname(context.resolvedCommandPath)); |
| 600 | const packageDir = path.join(voltaHome, "tools", "image", "packages", ...packageName.split("/")); |
| 601 | const fileSystem = yield* FileSystem.FileSystem; |
| 602 | return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false)); |
| 502 | 603 | }); |
| 503 | 604 | |
| 504 | 605 | /** |