From f9d039b5fce8d5d18b8c71b0ae65e555a9e8657f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 01:28:26 +0000 Subject: [PATCH] fix(server): offer one-click provider updates for every install Fall back to the provider's own updater (pi update --self, claude update, codex update, opencode upgrade) when no package manager is proven, detect Yarn and Volta globals, and use the keg's brew when Homebrew is not on PATH. --- apps/server/src/provider/Drivers/PiDriver.ts | 3 +- .../src/provider/providerMaintenance.test.ts | 116 ++++++++++++++ .../src/provider/providerMaintenance.ts | 145 +++++++++++++++--- docs/internals/providers.md | 19 +-- docs/user/install.md | 11 +- 5 files changed, 257 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 075f9a76946f..bed38746b6da 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -53,7 +53,8 @@ const DRIVER_KIND = ProviderDriverKind.make("pi"); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@earendil-works/pi-coding-agent", - nativeUpdate: null, + // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals. + nativeUpdate: { args: ["update", "--self"] }, }); export type PiDriverEnv = diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 9b56a3b16aff..c832ee8c17e9 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -769,6 +769,122 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); + it.effect.skipIf(windowsHost)( + "falls back to the provider's own updater when no installer is proven", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-self-update-fallback"); + const customPath = NodePath.join(tempDir, "tools", "package-tool"); + writeExecutable(customPath); + const selfUpdating = makePackageManagedProviderMaintenanceResolver({ + provider: driver("packageTool"), + npmPackageName: "@example/package-tool", + nativeUpdate: { args: ["update", "--self"] }, + }); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, { + binaryPath: customPath, + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + expect(capabilities.update).toMatchObject({ + executable: customPath, + args: ["update", "--self"], + lockKey: "packageTool-native", + }); + + // A mise install is pinned in mise's config, so it stays manual. + const misePath = NodePath.join(tempDir, "mise", "installs", "package-tool", "1.0.0", "bin"); + writeExecutable(NodePath.join(misePath, "package-tool")); + const mise = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, { + binaryPath: NodePath.join(misePath, "package-tool"), + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + expect(mise.update).toBeNull(); + }), + ); + + it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-yarn-capabilities"); + const link = linkIntoPackage(tempDir, "package-tool", [ + ".config", + "yarn", + "global", + "node_modules", + "@example", + "package-tool", + ]); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: link, + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect(capabilities.update).toMatchObject({ + command: "yarn global add @example/package-tool@latest", + lockKey: "yarn-global", + }); + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0")?.args).toEqual([ + "global", + "add", + "@example/package-tool@2.0.0", + ]); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "updates Volta installs only when Volta has the package", + () => + Effect.gen(function* () { + const voltaHome = NodePath.join(yield* makeTempDir("t3-volta-capabilities"), ".volta"); + const shim = NodePath.join(voltaHome, "bin", "volta-shim"); + writeExecutable(shim); + const link = NodePath.join(voltaHome, "bin", "package-tool"); + NodeFS.symlinkSync(shim, link); + const resolve = resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: link, + env: { PATH: "" }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); + + expect((yield* resolve).update).toBeNull(); + NodeFS.mkdirSync( + NodePath.join(voltaHome, "tools", "image", "packages", "@example", "package-tool"), + { recursive: true }, + ); + expect((yield* resolve).update).toMatchObject({ + command: "volta install @example/package-tool@latest", + lockKey: "volta", + }); + }), + ); + + it.effect.skipIf(windowsHost)("upgrades with the keg's own brew when brew is not on PATH", () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-homebrew-keg-brew"); + const brewPath = NodePath.join(tempDir, "bin", "brew"); + writeExecutable(brewPath); + const kegBinary = NodePath.join(tempDir, "Cellar", "package-tool", "1.0.0", "bin", "tool"); + writeExecutable(kegBinary); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: kegBinary, + env: { PATH: "" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner((_command, args) => (args[0] === "--prefix" ? `${tempDir}\n` : "{}")), + ), + ); + + expect(capabilities.update).toMatchObject({ + executable: brewPath, + args: ["upgrade", "package-tool"], + lockKey: "homebrew", + }); + }), + ); + it.effect("caches resolution until a fresh read is requested", () => Effect.gen(function* () { let resolutions = 0; diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index ace445b03060..3314ebe0bae7 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const HOMEBREW_INFO_TIMEOUT_MS = 10_000; const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024; +const MISE_WRAPPER_MAX_BYTES = 16 * 1_024; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; /** @@ -105,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver { export interface PackageManagedProviderMaintenanceDefinition { readonly provider: ProviderDriverKind; readonly npmPackageName: string; + /** + * The provider's own updater (`claude update`, `pi update --self`). It runs + * first for paths its installer owns, and otherwise only when no package + * manager is proven, because these updaters detect their installer too. + */ readonly nativeUpdate: { readonly args: ReadonlyArray; - readonly isCommandPath: (commandPath: string) => boolean; + /** Paths the provider's own installer owns; omit when it has none. */ + readonly isCommandPath?: (commandPath: string) => boolean; /** Environment the native updater needs to target this instance's install. */ readonly env?: NodeJS.ProcessEnv; } | null; @@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction( const update = capabilities.update; const packageName = capabilities.packageName; if (!update || !packageName) return null; - if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + if ( + !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test( + update.lockKey, + ) + ) return null; const packageIndex = update.args.findIndex( (arg) => arg === `${packageName}@latest` || arg === packageName, @@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean { return normalizeCommandPath(commandPath).includes("/.bun/bin/"); } +function isYarnGlobalCommandPath(commandPath: string): boolean { + // `~/.config/yarn/global/…` on POSIX, `%LOCALAPPDATA%\Yarn\Data\global\…` on Windows. + return /\/yarn\/(?:data\/)?global\/node_modules\//.test(normalizeCommandPath(commandPath)); +} + +/** Version-manager installs are pinned in its config, so updating them means editing that. */ +function isMiseCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/"); +} + function isPnpmGlobalCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* ( /** * Derive update capabilities from where the executable actually lives. Every - * branch that yields a one-click command has evidence that the named tool - * owns that path; anything unproven stays manual-only so T3 Code never runs - * a package manager against an install it did not create. + * package-manager branch has evidence that the named tool owns that path, so + * T3 Code never runs a package manager against an install it did not create. + * An unproven install falls back to the provider's own updater, which detects + * its installer itself, and stays manual-only without one. */ export const resolvePackageManagedProviderMaintenance = Effect.fn( "resolvePackageManagedProviderMaintenance", @@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const packageName = definition.npmPackageName; const nativeUpdate = definition.nativeUpdate; - if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) { - return makeProviderMaintenanceCapabilities({ - provider: definition.provider, - packageName, - updateExecutable: context.resolvedCommandPath, - updateArgs: nativeUpdate.args, - updateLockKey: `${definition.provider}-native`, - platform: context.platform, - ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), - }); + const native = nativeUpdate + ? makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + updateExecutable: context.resolvedCommandPath, + updateArgs: nativeUpdate.args, + updateLockKey: `${definition.provider}-native`, + platform: context.platform, + ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}), + }) + : manual; + if (nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath)) { + return native; } + // A `node_modules` path not proven below belongs to another package or a + // project, so the provider's own updater could act on the wrong install. + const fallback = commandPaths.some((commandPath) => + normalizeCommandPath(commandPath).includes("/node_modules/"), + ) + ? manual + : native; if (commandPaths.some(isVitePlusGlobalCommandPath)) { return makeProviderMaintenanceCapabilities({ provider: definition.provider, @@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } + if (commandPaths.some(isYarnGlobalCommandPath)) { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + updateExecutable: "yarn", + updateArgs: ["global", "add", `${packageName}@latest`], + updateLockKey: "yarn-global", + }); + } + if (yield* isVoltaPackageInstall(context, packageName)) { + return makeProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + updateExecutable: "volta", + updateArgs: ["install", `${packageName}@latest`], + updateLockKey: "volta", + }); + } + // npm proof names the package, so it outranks a keg the path merely passes // through: a Homebrew-installed Node keeps its globals under // `Cellar/node//lib/node_modules/`, and that is npm's install, not brew's. @@ -457,21 +509,31 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } + if (commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context))) { + return manual; + } + const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { // Mise shims resolve to the version manager, not the provider. if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { return manual; } - const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( - Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), - ); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // The keg's own brew works even when a GUI-launched server has no + // Homebrew on PATH; the prefix check below still applies to it. + const kegBrewPath = path.join(homebrew.prefix, "bin", "brew"); + const brewPath = (yield* fileSystem.exists(kegBrewPath).pipe(Effect.orElseSucceed(() => false))) + ? kegBrewPath + : yield* resolveCommandPath("brew", { env: context.env }).pipe( + Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), + ); if (!brewPath) { - return manual; + return fallback; } // A keg-shaped path is only Homebrew's if it sits under the prefix of the // `brew` that would upgrade it; `brew --prefix` is a cheap shell script. - const fileSystem = yield* FileSystem.FileSystem; const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env)); const realBrewPrefix = brewPrefix ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix)) @@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( !realBrewPrefix || normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix) ) { - return manual; + return fallback; } const args = homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name]; @@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } - return manual; + return fallback; +}); + +/** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */ +const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( + context: ProviderMaintenanceResolutionContext, +) { + const fileSystem = yield* FileSystem.FileSystem; + const size = yield* fileSystem.stat(context.realCommandPath).pipe( + Effect.map((info) => Number(info.size)), + Effect.orElseSucceed(() => Infinity), + ); + if (size > MISE_WRAPPER_MAX_BYTES) { + return false; + } + const script = yield* fileSystem + .readFileString(context.realCommandPath) + .pipe(Effect.orElseSucceed(() => "")); + return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script); +}); + +/** + * Volta's `bin/` is a link to its `volta-shim`, which picks the package + * at run time; the package's own image directory proves Volta installed it. + */ +const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* ( + context: ProviderMaintenanceResolutionContext, + packageName: string, +) { + const path = yield* Path.Path; + if ( + path.basename(normalizeCommandPath(context.realCommandPath)).replace(/\.exe$/, "") !== + "volta-shim" + ) { + return false; + } + const voltaHome = path.dirname(path.dirname(context.resolvedCommandPath)); + const packageDir = path.join(voltaHome, "tools", "image", "packages", ...packageName.split("/")); + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.exists(packageDir).pipe(Effect.orElseSucceed(() => false)); }); /** diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a68980e54845..52003163e882 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -80,16 +80,17 @@ See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGen ## Provider updates run only through the owning installer -A one-click update is offered only when the resolved executable's path proves which installer owns -it. Homebrew and npm are proven by the real path (symlinks followed): a versioned keg or cask under +A package manager runs only when the resolved executable's path proves it owns the install. Homebrew +and npm are proven by the real path (symlinks followed): a versioned keg or cask under `brew --prefix`, or `/lib/node_modules//` (Windows: the shim beside `node_modules`). -Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either -the resolved path or its real target, since those installers place real files or their own symlinks -there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its -own installer, so any resolved executable runs ` update`. Anything unproven stays -manual-only but still reports the version gap. npm updates pin -`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the -provider. Homebrew +Native installer layouts and the global directories of pnpm, Bun, Yarn, and Vite+ may match on +either the resolved path or its real target, since those installers place real files or their own +symlinks there. Volta is proven by its `volta-shim` link plus the package's image directory. When +nothing is proven, the provider's own updater (`claude update`, `codex update`, `opencode upgrade`, +`pi update --self`, `grok update`) runs instead, because each one detects its installer itself; +the runner's version check catches an updater that exits 0 without updating. Mise installs stay +manual-only because their version is pinned in mise's config. npm updates pin `--prefix` because the +`npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew compares against `brew info` since casks trail npm by hours; native installs share npm's version train, so the registry stays authoritative for them. See the [resolver](../../apps/server/src/provider/providerMaintenance.ts). diff --git a/docs/user/install.md b/docs/user/install.md index 77b09863d746..372d8e6e5607 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -142,11 +142,12 @@ you can install the recommendation there. Otherwise use the provider's installer on the environment's machine. An unlisted version is unverified. When a provider CLI is behind its latest release, its provider card shows the -available version. **Update now** appears only when T3 Code can tell which -installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, -bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same -way you installed it. Homebrew installs compare against the version Homebrew -offers, which can trail the npm release by a few hours. +available version. **Update now** runs the installer that owns the CLI +(Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the +CLI's own update command when T3 Code cannot tell. Update a CLI installed with +mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs +compare against the version Homebrew offers, which can trail the npm release by +a few hours. Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom