authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 02:45:37-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 02:45:37-03:00
logfe93a5dbdd3c4496f402f71d232597c391c4973b
tree972f60cfab3d9dab7b8894f0e26cecd784a0f60b
parentf4f3abf71e98361918e360a89d48780cc65cafd1
signature Signed by PGP key B5690EEEBB952194

fix(server): offer one-click provider updates for every install (#15416)


5 files changed, 257 insertions(+), 37 deletions(-)

apps/server/src/provider/Drivers/PiDriver.ts+2-1
......@@ -53,7 +53,8 @@ const DRIVER_KIND = ProviderDriverKind.make("pi");
5353const UPDATE = makePackageManagedProviderMaintenanceResolver({
5454 provider: DRIVER_KIND,
5555 npmPackageName: "@earendil-works/pi-coding-agent",
56 nativeUpdate: null,
56 // Pi's updater covers its own installer and npm, pnpm, yarn, and bun globals.
57 nativeUpdate: { args: ["update", "--self"] },
5758});
5859
5960export type PiDriverEnv =
apps/server/src/provider/providerMaintenance.test.ts+116
......@@ -769,6 +769,122 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
769769 }),
770770 );
771771
772 it.effect.skipIf(windowsHost)(
773 "falls back to the provider's own updater when no installer is proven",
774 () =>
775 Effect.gen(function* () {
776 const tempDir = yield* makeTempDir("t3-self-update-fallback");
777 const customPath = NodePath.join(tempDir, "tools", "package-tool");
778 writeExecutable(customPath);
779 const selfUpdating = makePackageManagedProviderMaintenanceResolver({
780 provider: driver("packageTool"),
781 npmPackageName: "@example/package-tool",
782 nativeUpdate: { args: ["update", "--self"] },
783 });
784
785 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, {
786 binaryPath: customPath,
787 env: { PATH: "" },
788 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
789 expect(capabilities.update).toMatchObject({
790 executable: customPath,
791 args: ["update", "--self"],
792 lockKey: "packageTool-native",
793 });
794
795 // A mise install is pinned in mise's config, so it stays manual.
796 const misePath = NodePath.join(tempDir, "mise", "installs", "package-tool", "1.0.0", "bin");
797 writeExecutable(NodePath.join(misePath, "package-tool"));
798 const mise = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, {
799 binaryPath: NodePath.join(misePath, "package-tool"),
800 env: { PATH: "" },
801 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
802 expect(mise.update).toBeNull();
803 }),
804 );
805
806 it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () =>
807 Effect.gen(function* () {
808 const tempDir = yield* makeTempDir("t3-yarn-capabilities");
809 const link = linkIntoPackage(tempDir, "package-tool", [
810 ".config",
811 "yarn",
812 "global",
813 "node_modules",
814 "@example",
815 "package-tool",
816 ]);
817
818 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
819 binaryPath: link,
820 env: { PATH: "" },
821 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
822
823 expect(capabilities.update).toMatchObject({
824 command: "yarn global add @example/package-tool@latest",
825 lockKey: "yarn-global",
826 });
827 expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0")?.args).toEqual([
828 "global",
829 "add",
830 "@example/package-tool@2.0.0",
831 ]);
832 }),
833 );
834
835 it.effect.skipIf(!symlinksSupported)(
836 "updates Volta installs only when Volta has the package",
837 () =>
838 Effect.gen(function* () {
839 const voltaHome = NodePath.join(yield* makeTempDir("t3-volta-capabilities"), ".volta");
840 const shim = NodePath.join(voltaHome, "bin", "volta-shim");
841 writeExecutable(shim);
842 const link = NodePath.join(voltaHome, "bin", "package-tool");
843 NodeFS.symlinkSync(shim, link);
844 const resolve = resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
845 binaryPath: link,
846 env: { PATH: "" },
847 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
848
849 expect((yield* resolve).update).toBeNull();
850 NodeFS.mkdirSync(
851 NodePath.join(voltaHome, "tools", "image", "packages", "@example", "package-tool"),
852 { recursive: true },
853 );
854 expect((yield* resolve).update).toMatchObject({
855 command: "volta install @example/package-tool@latest",
856 lockKey: "volta",
857 });
858 }),
859 );
860
861 it.effect.skipIf(windowsHost)("upgrades with the keg's own brew when brew is not on PATH", () =>
862 Effect.gen(function* () {
863 const tempDir = yield* makeTempDir("t3-homebrew-keg-brew");
864 const brewPath = NodePath.join(tempDir, "bin", "brew");
865 writeExecutable(brewPath);
866 const kegBinary = NodePath.join(tempDir, "Cellar", "package-tool", "1.0.0", "bin", "tool");
867 writeExecutable(kegBinary);
868
869 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
870 binaryPath: kegBinary,
871 env: { PATH: "" },
872 }).pipe(
873 Effect.provideService(HostProcessPlatform, "darwin"),
874 Effect.provideService(
875 ChildProcessSpawner.ChildProcessSpawner,
876 stdoutSpawner((_command, args) => (args[0] === "--prefix" ? `${tempDir}\n` : "{}")),
877 ),
878 );
879
880 expect(capabilities.update).toMatchObject({
881 executable: brewPath,
882 args: ["upgrade", "package-tool"],
883 lockKey: "homebrew",
884 });
885 }),
886 );
887
772888 it.effect("caches resolution until a fresh read is requested", () =>
773889 Effect.gen(function* () {
774890 let resolutions = 0;
apps/server/src/provider/providerMaintenance.ts+123-22
......@@ -26,6 +26,7 @@ const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
2626const LATEST_VERSION_TIMEOUT_MS = 4_000;
2727const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
2828const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
29const MISE_WRAPPER_MAX_BYTES = 16 * 1_024;
2930const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
3031
3132/**
......@@ -105,9 +106,15 @@ export interface ProviderMaintenanceCapabilitiesResolver {
105106export interface PackageManagedProviderMaintenanceDefinition {
106107 readonly provider: ProviderDriverKind;
107108 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 */
108114 readonly nativeUpdate: {
109115 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;
111118 /** Environment the native updater needs to target this instance's install. */
112119 readonly env?: NodeJS.ProcessEnv;
113120 } | null;
......@@ -195,7 +202,11 @@ export function makeTargetedProviderUpdateAction(
195202 const update = capabilities.update;
196203 const packageName = capabilities.packageName;
197204 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 )
199210 return null;
200211 const packageIndex = update.args.findIndex(
201212 (arg) => arg === `${packageName}@latest` || arg === packageName,
......@@ -238,6 +249,17 @@ function isBunGlobalCommandPath(commandPath: string): boolean {
238249 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
239250}
240251
252function 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. */
258function isMiseCommandPath(commandPath: string): boolean {
259 const normalized = normalizeCommandPath(commandPath);
260 return normalized.includes("/mise/installs/") || normalized.includes("/mise/shims/");
261}
262
241263function isPnpmGlobalCommandPath(commandPath: string): boolean {
242264 const normalized = normalizeCommandPath(commandPath);
243265 return (
......@@ -371,9 +393,10 @@ const runHomebrew = Effect.fn("runHomebrew")(function* (
371393
372394/**
373395 * 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.
377400 */
378401export const resolvePackageManagedProviderMaintenance = Effect.fn(
379402 "resolvePackageManagedProviderMaintenance",
......@@ -392,17 +415,27 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
392415 const packageName = definition.npmPackageName;
393416
394417 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;
405431 }
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;
406439 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
407440 return makeProviderMaintenanceCapabilities({
408441 provider: definition.provider,
......@@ -431,6 +464,25 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
431464 });
432465 }
433466
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
434486 // npm proof names the package, so it outranks a keg the path merely passes
435487 // through: a Homebrew-installed Node keeps its globals under
436488 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.
......@@ -457,21 +509,31 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
457509 });
458510 }
459511
512 if (commandPaths.some(isMiseCommandPath) || (yield* isMiseWrapperScript(context))) {
513 return manual;
514 }
515
460516 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
461517 if (homebrew) {
462518 // Mise shims resolve to the version manager, not the provider.
463519 if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") {
464520 return manual;
465521 }
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 );
469532 if (!brewPath) {
470 return manual;
533 return fallback;
471534 }
472535 // A keg-shaped path is only Homebrew's if it sits under the prefix of the
473536 // `brew` that would upgrade it; `brew --prefix` is a cheap shell script.
474 const fileSystem = yield* FileSystem.FileSystem;
475537 const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));
476538 const realBrewPrefix = brewPrefix
477539 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
......@@ -480,7 +542,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
480542 !realBrewPrefix ||
481543 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
482544 ) {
483 return manual;
545 return fallback;
484546 }
485547 const args =
486548 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
......@@ -498,7 +560,46 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn(
498560 });
499561 }
500562
501 return manual;
563 return fallback;
564});
565
566/** A launcher script that runs the provider through mise (`exec mise x codex -- codex`). */
567const 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 */
588const 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));
502603});
503604
504605/**
docs/internals/providers.md+10-9
......@@ -80,16 +80,17 @@ See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGen
8080
8181## Provider updates run only through the owning installer
8282
83A one-click update is offered only when the resolved executable's path proves which installer owns
84it. Homebrew and npm are proven by the real path (symlinks followed): a versioned keg or cask under
83A package manager runs only when the resolved executable's path proves it owns the install. Homebrew
84and npm are proven by the real path (symlinks followed): a versioned keg or cask under
8585`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).
86Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either
87the resolved path or its real target, since those installers place real files or their own symlinks
88there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its
89own installer, so any resolved executable runs `<binary> update`. Anything unproven stays
90manual-only but still reports the version gap. npm updates pin
91`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the
92provider. Homebrew
86Native installer layouts and the global directories of pnpm, Bun, Yarn, and Vite+ may match on
87either the resolved path or its real target, since those installers place real files or their own
88symlinks there. Volta is proven by its `volta-shim` link plus the package's image directory. When
89nothing is proven, the provider's own updater (`claude update`, `codex update`, `opencode upgrade`,
90`pi update --self`, `grok update`) runs instead, because each one detects its installer itself;
91the runner's version check catches an updater that exits 0 without updating. Mise installs stay
92manual-only because their version is pinned in mise's config. npm updates pin `--prefix` because the
93`npm` on `PATH` can belong to a different Node than the one that owns the provider. Homebrew
9394compares against `brew info` since casks trail npm by hours; native installs share npm's version
9495train, so the registry stays authoritative for them.
9596See the [resolver](../../apps/server/src/provider/providerMaintenance.ts).
docs/user/install.md+6-5
......@@ -142,11 +142,12 @@ you can install the recommendation there. Otherwise use the provider's installer
142142on the environment's machine. An unlisted version is unverified.
143143
144144When a provider CLI is behind its latest release, its provider card shows the
145available version. **Update now** appears only when T3 Code can tell which
146installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm,
147bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same
148way you installed it. Homebrew installs compare against the version Homebrew
149offers, which can trail the npm release by a few hours.
145available version. **Update now** runs the installer that owns the CLI
146(Homebrew, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or the
147CLI's own update command when T3 Code cannot tell. Update a CLI installed with
148mise through mise. Cursor and Antigravity update with T3 Code. Homebrew installs
149compare against the version Homebrew offers, which can trail the npm release by
150a few hours.
150151
151152Add another provider instance for a separate account or configuration. Each
152153instance can have its own environment variables, such as API keys or a custom