Repository navigation
fix(server): offer one-click provider updates for every install #15416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string>; | ||
| 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/<ver>/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/<cmd>` is a link to its `volta-shim`, which picks the package | ||
| * at run time; the package's own image directory proves Volta installed it. | ||
| */ | ||
|
Comment on lines
+570
to
+587
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '20,34p;245,265p;412,445p;498,540p;558,591p' apps/server/src/provider/providerMaintenance.ts
rg -n 'mise wrapper|mise x|mise exec|manual-only' docs/internals/providers.md apps/server/src/provider/providerMaintenance.test.tsRepository: pingdotgg/t3code Length of output: 6474 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- PR diff ---'
git diff --unified=8 dab26f582cf7826fec67fbed64b46f59f3421280 f9d039b5fce8d5d18b8c71b0ae65e555a9e8657f -- apps/server/src/provider/providerMaintenance.ts
printf '%s\n' '--- relevant provider source ---'
rg -n 'resolveProviderMaintenance|isMiseCommandPath|isMiseWrapperScript|ProviderMaintenanceResolutionContext|nativeUpdate|commandPath' apps/server/src/provider/providerMaintenance.ts
printf '%s\n' '--- manual-only contract ---'
sed -n '72,108p' docs/internals/providers.md
printf '%s\n' '--- tests for mise and fallback selection ---'
rg -n -C 5 'mise|native updater|nativeUpdate|node_modules|launcher|wrapper' apps/server/src/provider/providerMaintenance.test.ts
printf '%s\n' '--- public resolver callers ---'
rg -n -C 3 'resolveProviderMaintenance|providerMaintenance' apps/server/src/provider apps/server/src | head -220Repository: pingdotgg/t3code Length of output: 41753 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- ownership and resolver source ---'
nl -ba apps/server/src/provider/providerMaintenance.ts | sed -n '405,590p;610,705p'
printf '%s\n' '--- native updater registrations ---'
rg -n -C 3 'nativeUpdate:|nativeUpdate\??:' apps/server/src/provider
printf '%s\n' '--- update action consumer ---'
rg -n -C 4 'capabilities\.update|updateExecutable|updateArgs|updateLockKey' apps/server/src/provider/providerMaintenanceRunner.ts
printf '%s\n' '--- provider manual-only contract with line numbers ---'
nl -ba docs/internals/providers.md | sed -n '84,106p'Repository: pingdotgg/t3code Length of output: 21206 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- Pi maintenance registration and provider wiring ---'
nl -ba apps/server/src/provider/Drivers/PiDriver.ts | sed -n '40,75p;100,155p'
rg -n -C 3 'maintenanceResolver|providerMaintenance|resolveProviderMaintenance|maintenance:' apps/server/src/provider/Drivers/PiDriver.ts apps/server/src/provider/providerMaintenance.ts apps/server/src/provider/providerMaintenanceRunner.ts
printf '%s\n' '--- native update command construction and execution ---'
nl -ba apps/server/src/provider/providerMaintenance.ts | sed -n '155,205p'
nl -ba apps/server/src/provider/providerMaintenanceRunner.ts | sed -n '305,370p'Repository: pingdotgg/t3code Length of output: 14653 🏁 Script executed: rg -n -C 8 'npmGlobalPrefixFromCommandPath|homebrewOwnershipFromCommandPath' apps/server/src/provider/providerMaintenance.tsRepository: pingdotgg/t3code Length of output: 3075 Keep oversized mise wrappers manual-only. For an oversized mise launcher at an otherwise unowned Pi command path, 🤖 Prompt for AI Agents |
||
| 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)); | ||
| }); | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
provider/providerMaintenance.ts:434For an unrecognized Codex executable,
fallbackreturnsnativeand exposes a one-click update withenv: { CODEX_HOME: sharedHomePath }.makeCodexMaintenanceResolvertherefore updates the sharedCODEX_HOMEinstallation instead of the configured binary, leaving that binary outdated while potentially modifying an unrelated installation. Keep this fallback manual for Codex unless the executable proves a standalone install or its matching home can be derived.🤖 Copy this AI Prompt to have your agent fix this: