| ... | @@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; | ... | @@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; |
| 26 | const LATEST_VERSION_TIMEOUT_MS = 4_000; | 26 | const LATEST_VERSION_TIMEOUT_MS = 4_000; |
| 27 | const HOMEBREW_INFO_TIMEOUT_MS = 10_000; | 27 | const HOMEBREW_INFO_TIMEOUT_MS = 10_000; |
| 28 | const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; | 28 | const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; |
| | 29 | const MISE_WRAPPER_MAX_BYTES = 16 * 1_024; |
| 29 | const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; | 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,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver { |
| 105 | export interface PackageManagedProviderMaintenanceDefinition { | 106 | export interface PackageManagedProviderMaintenanceDefinition { |
| 106 | readonly provider: ProviderDriverKind; | 107 | readonly provider: ProviderDriverKind; |
| 107 | readonly npmPackageName: string; | 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 | readonly nativeUpdate: { | 114 | readonly nativeUpdate: { |
| 109 | readonly args: ReadonlyArray<string>; | 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 | /** Environment the native updater needs to target this instance's install. */ | 118 | /** Environment the native updater needs to target this instance's install. */ |
| 112 | readonly env?: NodeJS.ProcessEnv; | 119 | readonly env?: NodeJS.ProcessEnv; |
| 113 | } | null; | 120 | } | null; |
| ... | @@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction( | ... | @@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction( |
| 195 | const update = capabilities.update; | 202 | const update = capabilities.update; |
| 196 | const packageName = capabilities.packageName; | 203 | const packageName = capabilities.packageName; |
| 197 | if (!update || !packageName) return null; | 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 | return null; | 210 | return null; |
| 200 | const packageIndex = update.args.findIndex( | 211 | const packageIndex = update.args.findIndex( |
| 201 | (arg) => arg === `${packageName}@latest` || arg === packageName, | 212 | (arg) => arg === `${packageName}@latest` || arg === packageName, |
| ... | @@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean { | ... | @@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean { |
| 238 | return normalizeCommandPath(commandPath).includes("/.bun/bin/"); | 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 | function isPnpmGlobalCommandPath(commandPath: string): boolean { | 263 | function isPnpmGlobalCommandPath(commandPath: string): boolean { |
| 242 | const normalized = normalizeCommandPath(commandPath); | 264 | const normalized = normalizeCommandPath(commandPath); |
| 243 | return ( | 265 | return ( |
| ... | @@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* ( | ... | @@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* ( |
| 371 | | 393 | |
| 372 | /** | 394 | /** |
| 373 | * Derive update capabilities from where the executable actually lives. Every | 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 | 396 | * package-manager branch has evidence that the named tool owns that path, so |
| 375 | * owns that path; anything unproven stays manual-only so T3 Code never runs | 397 | * T3 Code never runs a package manager against an install it did not create. |
| 376 | * 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 | export const resolvePackageManagedProviderMaintenance = Effect.fn( | 401 | export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 379 | "resolvePackageManagedProviderMaintenance", | 402 | "resolvePackageManagedProviderMaintenance", |
| ... | @@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( | ... | @@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 392 | const packageName = definition.npmPackageName; | 415 | const packageName = definition.npmPackageName; |
| 393 | | 416 | |
| 394 | const nativeUpdate = definition.nativeUpdate; | 417 | const nativeUpdate = definition.nativeUpdate; |
| 395 | if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) { | 418 | const native = nativeUpdate |
| 396 | return makeProviderMaintenanceCapabilities({ | 419 | ? makeProviderMaintenanceCapabilities({ |
| 397 | provider: definition.provider, | 420 | provider: definition.provider, |
| 398 | packageName, | 421 | packageName, |
| 399 | updateExecutable: context.resolvedCommandPath, | 422 | updateExecutable: context.resolvedCommandPath, |
| 400 | updateArgs: nativeUpdate.args, | 423 | updateArgs: nativeUpdate.args, |
| 401 | updateLockKey: `${definition.provider}-native`, | 424 | updateLockKey: `${definition.provider}-native`, |
| 402 | platform: context.platform, | 425 | platform: context.platform, |
| 403 | ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), | 426 | ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), |
| 404 | }); | 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 | if (commandPaths.some(isVitePlusGlobalCommandPath)) { | 439 | if (commandPaths.some(isVitePlusGlobalCommandPath)) { |
| 407 | return makeProviderMaintenanceCapabilities({ | 440 | return makeProviderMaintenanceCapabilities({ |
| 408 | provider: definition.provider, | 441 | provider: definition.provider, |
| ... | @@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( | ... | @@ -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 | // npm proof names the package, so it outranks a keg the path merely passes | 486 | // npm proof names the package, so it outranks a keg the path merely passes |
| 435 | // through: a Homebrew-installed Node keeps its globals under | 487 | // through: a Homebrew-installed Node keeps its globals under |
| 436 | // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's. | 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,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 | const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); | 516 | const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); |
| 461 | if (homebrew) { | 517 | if (homebrew) { |
| 462 | // Mise shims resolve to the version manager, not the provider. | 518 | // Mise shims resolve to the version manager, not the provider. |
| 463 | if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { | 519 | if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { |
| 464 | return manual; | 520 | return manual; |
| 465 | } | 521 | } |
| 466 | const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( | 522 | const fileSystem = yield* FileSystem.FileSystem; |
| 467 | Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), | 523 | const path = yield* Path.Path; |
| 468 | ); | 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 | if (!brewPath) { | 532 | if (!brewPath) { |
| 470 | return manual; | 533 | return fallback; |
| 471 | } | 534 | } |
| 472 | // A keg-shaped path is only Homebrew's if it sits under the prefix of the | 535 | // A keg-shaped path is only Homebrew's if it sits under the prefix of the |
| 473 | // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. | 536 | // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. |
| 474 | const fileSystem = yield* FileSystem.FileSystem; | | |
| 475 | const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); | 537 | const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); |
| 476 | const realBrewPrefix = brewPrefix | 538 | const realBrewPrefix = brewPrefix |
| 477 | ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) | 539 | ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) |
| ... | @@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( | ... | @@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( |
| 480 | !realBrewPrefix || | 542 | !realBrewPrefix || |
| 481 | normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix) | 543 | normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix) |
| 482 | ) { | 544 | ) { |
| 483 | return manual; | 545 | return fallback; |
| 484 | } | 546 | } |
| 485 | const args = | 547 | const args = |
| 486 | homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; | 548 | homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; |
| ... | @@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( | ... | @@ -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 | /** |