authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 21:17:31-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 21:17:31-07:00
log2fb99a7a664faf045f1884f38c620016b34874cb
tree5382f5d77fb81818784a63df537d79a76cef974a
parent94cc8152fff5fd06baafd25964f54d7ec1a34f8d
signature Signed by PGP key B5690EEEBB952194

fix(server): only run provider updates through the installer that owns the binary (#9325)

Co-authored-by: Claude Code <noreply@anthropic.com>

23 files changed, 1578 insertions(+), 890 deletions(-)

apps/server/src/provider/Drivers/ClaudeDriver.ts+18-11
......@@ -48,6 +48,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
4848import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
4949import {
5050 enrichProviderSnapshotWithVersionAdvisory,
51 makeCachedProviderMaintenanceResolution,
5152 makePackageManagedProviderMaintenanceResolver,
5253 normalizeCommandPath,
5354 resolveProviderMaintenanceCapabilitiesEffect,
......@@ -76,11 +77,8 @@ function isClaudeNativeCommandPath(commandPath: string): boolean {
7677const UPDATE = makePackageManagedProviderMaintenanceResolver({
7778 provider: DRIVER_KIND,
7879 npmPackageName: "@anthropic-ai/claude-code",
79 homebrewFormula: "claude-code",
8080 nativeUpdate: {
81 executable: "claude",
8281 args: ["update"],
83 lockKey: "claude-native",
8482 isCommandPath: isClaudeNativeCommandPath,
8583 },
8684});
......@@ -122,10 +120,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
122120 instanceId,
123121 });
124122 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
125 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
126 binaryPath: effectiveConfig.binaryPath,
127 env: processEnv,
128 });
123 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
124 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
125 binaryPath: effectiveConfig.binaryPath,
126 env: processEnv,
127 }).pipe(
128 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
129 Effect.provideService(FileSystem.FileSystem, fileSystem),
130 Effect.provideService(Path.Path, path),
131 ),
132 );
129133 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);
130134 const stampIdentity = withInstanceIdentity({
131135 instanceId,
......@@ -189,7 +193,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
189193
190194 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
191195 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<ClaudeSettings>>({
192 maintenanceCapabilities,
196 resolveMaintenance,
193197 getSettings: snapshotSettings.getSettings,
194198 streamSettings: snapshotSettings.streamSettings,
195199 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -202,9 +206,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
202206 ),
203207 checkProvider,
204208 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
205 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
206 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
207 }).pipe(
209 resolveMaintenance().pipe(
210 Effect.flatMap((maintenanceCapabilities) =>
211 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
212 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
213 }),
214 ),
208215 Effect.provideService(HttpClient.HttpClient, httpClient),
209216 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
210217 ),
apps/server/src/provider/Drivers/CodexDriver.test.ts created+105
......@@ -0,0 +1,105 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { expect, it } from "@effect/vitest";
6import { ProviderInstanceId } from "@t3tools/contracts";
7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import { HttpClient } from "effect/unstable/http";
12import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
13
14import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
15import { ServerConfig } from "../../config.ts";
16import { ServerSettingsService } from "../../serverSettings.ts";
17import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts";
18import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
19import * as ModelManifest from "../ModelManifest.ts";
20import { CodexDriver } from "./CodexDriver.ts";
21
22const testLayer = ServerConfig.layerTest(process.cwd(), {
23 prefix: "t3-codex-driver-maintenance-",
24}).pipe(
25 Layer.provideMerge(NodeServices.layer),
26 Layer.provideMerge(ServerSettingsService.layerTest()),
27 Layer.provideMerge(ModelManifest.layerTest),
28 Layer.provideMerge(codexResetCreditLayerTest),
29 Layer.provideMerge(
30 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
31 shouldRunScopeWork: () => Effect.succeed(false),
32 }),
33 ),
34 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
35 Layer.provideMerge(
36 Layer.succeed(
37 HttpClient.HttpClient,
38 HttpClient.make(() => Effect.die("Disabled Codex must not make an HTTP request")),
39 ),
40 ),
41);
42
43// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
44const windowsHost = HostProcessPlatform.defaultValue() === "win32";
45
46const noSpawn = ChildProcessSpawner.make(() =>
47 Effect.die("Disabled Codex must not spawn a process"),
48);
49
50it.layer(testLayer)("CodexDriver", (it) => {
51 it.effect.skipIf(windowsHost)(
52 "runs the standalone updater against the shared home, not the shadow home",
53 () =>
54 Effect.gen(function* () {
55 const fs = yield* FileSystem.FileSystem;
56 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-driver-" });
57 const sharedHome = NodePath.join(tempDir, "codex-home");
58 const shadowHome = NodePath.join(tempDir, "codex-shadow");
59 const binaryPath = NodePath.join(sharedHome, "packages", "standalone", "bin", "codex");
60 yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true });
61 yield* fs.writeFileString(binaryPath, "#!/bin/sh\n");
62 yield* fs.chmod(binaryPath, 0o755);
63
64 const instance = yield* CodexDriver.create({
65 instanceId: ProviderInstanceId.make("codex-shadow"),
66 displayName: "Codex test",
67 enabled: false,
68 environment: [],
69 config: {
70 ...CodexDriver.defaultConfig(),
71 binaryPath,
72 homePath: sharedHome,
73 shadowHomePath: shadowHome,
74 },
75 });
76
77 const capabilities = yield* instance.snapshot.resolveMaintenance();
78 expect(capabilities.update).toMatchObject({
79 executable: binaryPath,
80 args: ["update"],
81 lockKey: "codex-native",
82 env: { CODEX_HOME: sharedHome },
83 });
84 }).pipe(
85 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
86 Effect.scoped,
87 ),
88 );
89
90 it.effect("stays manual-only when the configured executable does not exist", () =>
91 Effect.gen(function* () {
92 const instance = yield* CodexDriver.create({
93 instanceId: ProviderInstanceId.make("codex-missing"),
94 displayName: "Codex test",
95 enabled: false,
96 environment: [],
97 config: {
98 ...CodexDriver.defaultConfig(),
99 binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-codex-missing", "codex"),
100 },
101 });
102 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
103 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped),
104 );
105});
apps/server/src/provider/Drivers/CodexDriver.ts+47-14
......@@ -55,7 +55,9 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
5555import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
5656import {
5757 enrichProviderSnapshotWithVersionAdvisory,
58 makeCachedProviderMaintenanceResolution,
5859 makePackageManagedProviderMaintenanceResolver,
60 normalizeCommandPath,
5961 resolveProviderMaintenanceCapabilitiesEffect,
6062} from "../providerMaintenance.ts";
6163import {
......@@ -71,12 +73,29 @@ import {
7173const decodeCodexSettings = Schema.decodeSync(CodexSettings);
7274
7375const DRIVER_KIND = ProviderDriverKind.make("codex");
74const UPDATE = makePackageManagedProviderMaintenanceResolver({
75 provider: DRIVER_KIND,
76 npmPackageName: "@openai/codex",
77 homebrewFormula: "codex",
78 nativeUpdate: null,
79});
76// The standalone installer lays out `<CODEX_HOME>/packages/standalone/…`;
77// CODEX_HOME is not always `~/.codex`.
78function isCodexStandaloneCommandPath(commandPath: string): boolean {
79 return normalizeCommandPath(commandPath).includes("/packages/standalone/");
80}
81
82/**
83 * `codex update` replaces the standalone tree under `CODEX_HOME`. That tree
84 * lives in the shared home even when an auth-overlay shadow home is in use
85 * (the overlay only carries auth and a few local entries), so the updater
86 * runs against `sharedHomePath` rather than the instance's effective home.
87 */
88function makeCodexMaintenanceResolver(sharedHomePath: string) {
89 return makePackageManagedProviderMaintenanceResolver({
90 provider: DRIVER_KIND,
91 npmPackageName: "@openai/codex",
92 nativeUpdate: {
93 args: ["update"],
94 isCommandPath: isCodexStandaloneCommandPath,
95 env: { CODEX_HOME: sharedHomePath },
96 },
97 });
98}
8099
81100/**
82101 * Services the driver needs to materialize an instance. Surfaced as the
......@@ -108,6 +127,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
108127 Effect.gen(function* () {
109128 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
110129 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;
130 const fileSystem = yield* FileSystem.FileSystem;
131 const pathService = yield* Path.Path;
111132 const httpClient = yield* HttpClient.HttpClient;
112133 const serverSettings = yield* ServerSettingsService;
113134 const eventLoggers = yield* ProviderEventLoggers;
......@@ -138,10 +159,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
138159 enabled,
139160 homePath: homeLayout.effectiveHomePath ?? "",
140161 } satisfies CodexSettings;
141 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
142 binaryPath: effectiveConfig.binaryPath,
143 env: processEnv,
144 });
162 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
163 resolveProviderMaintenanceCapabilitiesEffect(
164 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),
165 {
166 binaryPath: effectiveConfig.binaryPath,
167 env: processEnv,
168 },
169 ).pipe(
170 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
171 Effect.provideService(FileSystem.FileSystem, fileSystem),
172 Effect.provideService(Path.Path, pathService),
173 ),
174 );
145175
146176 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
147177 // channels at construction time — their failure modes are all on the
......@@ -177,7 +207,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
177207 );
178208 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
179209 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
180 maintenanceCapabilities,
210 resolveMaintenance,
181211 getSettings: snapshotSettings.getSettings,
182212 streamSettings: snapshotSettings.streamSettings,
183213 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -190,9 +220,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
190220 ),
191221 checkProvider,
192222 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
193 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
194 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
195 }).pipe(
223 resolveMaintenance().pipe(
224 Effect.flatMap((maintenanceCapabilities) =>
225 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
226 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
227 }),
228 ),
196229 Effect.provideService(HttpClient.HttpClient, httpClient),
197230 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
198231 ),
apps/server/src/provider/Drivers/CursorDriver.test.ts+49-12
......@@ -1,8 +1,12 @@
1// @effect-diagnostics nodeBuiltinImport:off
12import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
25import { expect, it } from "@effect/vitest";
36import { ProviderInstanceId } from "@t3tools/contracts";
47import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
58import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
610import * as Layer from "effect/Layer";
711import { HttpClient } from "effect/unstable/http";
812import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
......@@ -32,26 +36,59 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
3236 ),
3337);
3438
39// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
40const windowsHost = HostProcessPlatform.defaultValue() === "win32";
41
3542it.layer(testLayer)("CursorDriver", (it) => {
36 it.effect("formats a copied update command for the injected Windows environment", () =>
43 it.effect.skipIf(windowsHost)(
44 "quotes a configured executable path in the copyable update command",
45 () =>
46 Effect.gen(function* () {
47 const fs = yield* FileSystem.FileSystem;
48 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-driver-" });
49 const binaryPath = NodePath.join(tempDir, "Cursor Tools", "bin", "cursor-agent");
50 yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true });
51 yield* fs.writeFileString(binaryPath, "#!/bin/sh\n");
52 yield* fs.chmod(binaryPath, 0o755);
53
54 const instance = yield* CursorDriver.create({
55 instanceId: ProviderInstanceId.make("cursor-copy-command"),
56 displayName: "Cursor test",
57 enabled: false,
58 environment: [],
59 config: { ...CursorDriver.defaultConfig(), binaryPath },
60 });
61
62 const capabilities = yield* instance.snapshot.resolveMaintenance();
63 expect(capabilities.update).toMatchObject({
64 command: `'${binaryPath}' update`,
65 executable: binaryPath,
66 args: ["update"],
67 });
68 expect((yield* instance.snapshot.refresh).status).toBe("disabled");
69 }).pipe(
70 Effect.provideService(
71 ChildProcessSpawner.ChildProcessSpawner,
72 ChildProcessSpawner.make(() => Effect.die("Disabled Cursor must not spawn a process")),
73 ),
74 Effect.scoped,
75 ),
76 );
77
78 it.effect("stays manual-only when the configured executable does not exist", () =>
3779 Effect.gen(function* () {
38 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\cursor-agent.exe";
3980 const instance = yield* CursorDriver.create({
40 instanceId: ProviderInstanceId.make("cursor-copy-command"),
81 instanceId: ProviderInstanceId.make("cursor-missing"),
4182 displayName: "Cursor test",
4283 enabled: false,
4384 environment: [],
44 config: { ...CursorDriver.defaultConfig(), binaryPath },
45 });
46
47 expect(instance.snapshot.maintenanceCapabilities.update).toMatchObject({
48 command: `& '${binaryPath}' update`,
49 executable: binaryPath,
50 args: ["update"],
85 config: {
86 ...CursorDriver.defaultConfig(),
87 binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-cursor-missing", "cursor-agent"),
88 },
5189 });
52 expect((yield* instance.snapshot.refresh).status).toBe("disabled");
90 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
5391 }).pipe(
54 Effect.provideService(HostProcessPlatform, "win32"),
5592 Effect.provideService(
5693 ChildProcessSpawner.ChildProcessSpawner,
5794 ChildProcessSpawner.make(() => Effect.die("Disabled Cursor must not spawn a process")),
apps/server/src/provider/Drivers/CursorDriver.ts+45-23
......@@ -41,6 +41,8 @@ import {
4141import { withInstanceIdentity } from "./instanceIdentity.ts";
4242import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
4343import {
44 makeCachedProviderMaintenanceResolution,
45 makeManualOnlyProviderMaintenanceCapabilities,
4446 makeProviderMaintenanceCapabilities,
4547 type ProviderMaintenanceCapabilitiesResolver,
4648 resolveProviderMaintenanceCapabilitiesEffect,
......@@ -54,16 +56,25 @@ import { probeCursorSkills } from "./CursorSkills.ts";
5456const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5557
5658const DRIVER_KIND = ProviderDriverKind.make("cursor");
59// cursor-agent updates itself, so the resolved executable is its own updater.
60// No executable means nothing to update, not "whatever is on PATH".
5761const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
58 resolve: (options) =>
59 makeProviderMaintenanceCapabilities({
60 provider: DRIVER_KIND,
61 packageName: null,
62 updateExecutable: options?.binaryPath?.trim() || "cursor-agent",
63 updateArgs: ["update"],
64 updateLockKey: "cursor-agent",
65 ...(options?.platform ? { platform: options.platform } : {}),
66 }),
62 resolve: (context) =>
63 Effect.succeed(
64 context
65 ? makeProviderMaintenanceCapabilities({
66 provider: DRIVER_KIND,
67 packageName: null,
68 updateExecutable: context.resolvedCommandPath,
69 updateArgs: ["update"],
70 updateLockKey: "cursor-agent",
71 platform: context.platform,
72 })
73 : makeManualOnlyProviderMaintenanceCapabilities({
74 provider: DRIVER_KIND,
75 packageName: null,
76 }),
77 ),
6778};
6879
6980export type CursorDriverEnv =
......@@ -107,10 +118,16 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
107118 continuationGroupKey: continuationIdentity.continuationKey,
108119 });
109120 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
110 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
111 binaryPath: effectiveConfig.binaryPath,
112 env: processEnv,
113 });
121 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
122 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
123 binaryPath: effectiveConfig.binaryPath,
124 env: processEnv,
125 }).pipe(
126 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
127 Effect.provideService(FileSystem.FileSystem, fileSystem),
128 Effect.provideService(Path.Path, path),
129 ),
130 );
114131
115132 const adapter = yield* makeCursorAdapter(effectiveConfig, {
116133 environment: processEnv,
......@@ -129,7 +146,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
129146
130147 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
131148 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({
132 maintenanceCapabilities,
149 resolveMaintenance,
133150 getSettings: snapshotSettings.getSettings,
134151 streamSettings: snapshotSettings.streamSettings,
135152 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -139,15 +156,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
139156 // Model catalog and capabilities come exclusively from Cursor's
140157 // list_available_models extension method during provider checks.
141158 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
142 enrichCursorSnapshot({
143 settings: settings.provider,
144 snapshot: currentSnapshot,
145 maintenanceCapabilities,
146 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
147 publishSnapshot,
148 stampIdentity,
149 httpClient,
150 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
159 resolveMaintenance().pipe(
160 Effect.flatMap((maintenanceCapabilities) =>
161 enrichCursorSnapshot({
162 settings: settings.provider,
163 snapshot: currentSnapshot,
164 maintenanceCapabilities,
165 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
166 publishSnapshot,
167 stampIdentity,
168 httpClient,
169 }),
170 ),
171 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
172 ),
151173 }).pipe(
152174 Effect.mapError(
153175 (cause) =>
apps/server/src/provider/Drivers/GrokDriver.ts+7-18
......@@ -28,11 +28,7 @@ import {
2828import { withInstanceIdentity } from "./instanceIdentity.ts";
2929import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
3030import { discoverGrokSkills } from "./GrokSkills.ts";
31import {
32 makeManualOnlyProviderMaintenanceCapabilities,
33 makeStaticProviderMaintenanceResolver,
34 resolveProviderMaintenanceCapabilitiesEffect,
35} from "../providerMaintenance.ts";
31import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
3632import {
3733 haveProviderSnapshotSettingsChanged,
3834 makeProviderSnapshotSettingsSource,
......@@ -41,12 +37,10 @@ import {
4137const decodeGrokSettings = Schema.decodeSync(GrokSettings);
4238
4339const DRIVER_KIND = ProviderDriverKind.make("grok");
44const UPDATE = makeStaticProviderMaintenanceResolver(
45 makeManualOnlyProviderMaintenanceCapabilities({
46 provider: DRIVER_KIND,
47 packageName: null,
48 }),
49);
40const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
41 provider: DRIVER_KIND,
42 packageName: null,
43});
5044
5145export type GrokDriverEnv =
5246 | BackgroundPolicy.BackgroundPolicy
......@@ -88,11 +82,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
8882 continuationGroupKey: continuationIdentity.continuationKey,
8983 });
9084 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
91 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
92 binaryPath: effectiveConfig.binaryPath,
93 env: processEnv,
94 });
95
9685 const adapter = yield* makeGrokAdapter(effectiveConfig, {
9786 environment: processEnv,
9887 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
......@@ -108,7 +97,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
10897
10998 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
11099 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
111 maintenanceCapabilities,
100 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
112101 getSettings: snapshotSettings.getSettings,
113102 streamSettings: snapshotSettings.streamSettings,
114103 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -118,7 +107,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
118107 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
119108 enrichGrokSnapshot({
120109 snapshot: currentSnapshot,
121 maintenanceCapabilities,
110 maintenanceCapabilities: MAINTENANCE_CAPABILITIES,
122111 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
123112 publishSnapshot,
124113 httpClient,
apps/server/src/provider/Drivers/OpenCodeDriver.ts+21-11
......@@ -45,6 +45,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
4545import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
4646import {
4747 enrichProviderSnapshotWithVersionAdvisory,
48 makeCachedProviderMaintenanceResolution,
4849 makePackageManagedProviderMaintenanceResolver,
4950 normalizeCommandPath,
5051 resolveProviderMaintenanceCapabilitiesEffect,
......@@ -69,11 +70,8 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean {
6970const UPDATE = makePackageManagedProviderMaintenanceResolver({
7071 provider: DRIVER_KIND,
7172 npmPackageName: "opencode-ai",
72 homebrewFormula: "anomalyco/tap/opencode",
7373 nativeUpdate: {
74 executable: "opencode",
7574 args: ["upgrade"],
76 lockKey: "opencode-native",
7775 isCommandPath: isOpenCodeNativeCommandPath,
7876 },
7977});
......@@ -100,6 +98,9 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
10098 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
10199 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
102100 Effect.gen(function* () {
101 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
102 const fileSystem = yield* FileSystem.FileSystem;
103 const pathService = yield* Path.Path;
103104 const openCodeRuntime = yield* OpenCodeRuntime;
104105 const serverConfig = yield* ServerConfig;
105106 const httpClient = yield* HttpClient.HttpClient;
......@@ -118,10 +119,16 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
118119 continuationGroupKey: continuationIdentity.continuationKey,
119120 });
120121 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
121 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
122 binaryPath: effectiveConfig.binaryPath,
123 env: processEnv,
124 });
122 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
123 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
124 binaryPath: effectiveConfig.binaryPath,
125 env: processEnv,
126 }).pipe(
127 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
128 Effect.provideService(FileSystem.FileSystem, fileSystem),
129 Effect.provideService(Path.Path, pathService),
130 ),
131 );
125132
126133 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {
127134 instanceId,
......@@ -195,7 +202,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
195202 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
196203 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
197204 {
198 maintenanceCapabilities,
205 resolveMaintenance,
199206 getSettings: snapshotSettings.getSettings,
200207 streamSettings: snapshotSettings.streamSettings,
201208 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -205,9 +212,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
205212 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
206213 checkProvider,
207214 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
208 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
209 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
210 }).pipe(
215 resolveMaintenance().pipe(
216 Effect.flatMap((maintenanceCapabilities) =>
217 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
218 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
219 }),
220 ),
211221 Effect.provideService(HttpClient.HttpClient, httpClient),
212222 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
213223 ),
apps/server/src/provider/Layers/AntigravityProvider.ts+7-6
......@@ -232,13 +232,14 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
232232 return yield* options.stampIdentity(next.draft);
233233 });
234234
235 const maintenanceCapabilities =
236 options.maintenanceCapabilities ??
237 makeManualOnlyProviderMaintenanceCapabilities({
238 provider: ProviderDriverKind.make("antigravity"),
239 packageName: null,
240 });
235241 const managed = yield* makeManagedServerProvider({
236 maintenanceCapabilities:
237 options.maintenanceCapabilities ??
238 makeManualOnlyProviderMaintenanceCapabilities({
239 provider: ProviderDriverKind.make("antigravity"),
240 packageName: null,
241 }),
242 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
242243 getSettings: Effect.succeed(settings),
243244 streamSettings: Stream.empty,
244245 haveSettingsChanged: () => false,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+7-4
......@@ -111,10 +111,13 @@ const makeFakeInstance = (
111111 displayName: undefined,
112112 enabled: true,
113113 snapshot: {
114 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
115 provider: driverKind,
116 packageName: null,
117 }),
114 resolveMaintenance: () =>
115 Effect.succeed(
116 makeManualOnlyProviderMaintenanceCapabilities({
117 provider: driverKind,
118 packageName: null,
119 }),
120 ),
118121 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
119122 refresh: Effect.succeed({} as unknown as ServerProvider),
120123 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+63-36
......@@ -1033,10 +1033,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
10331033 displayName: undefined,
10341034 enabled: true,
10351035 snapshot: {
1036 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1037 provider: cachedProvider.driver,
1038 packageName: null,
1039 }),
1036 resolveMaintenance: () =>
1037 Effect.succeed(
1038 makeManualOnlyProviderMaintenanceCapabilities({
1039 provider: cachedProvider.driver,
1040 packageName: null,
1041 }),
1042 ),
10401043 getSnapshot: Effect.succeed(pendingProvider),
10411044 refresh: Ref.get(nextProvider),
10421045 streamChanges: Stream.empty,
......@@ -1288,10 +1291,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
12881291 displayName: undefined,
12891292 enabled: true,
12901293 snapshot: {
1291 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1292 provider: codexDriver,
1293 packageName: null,
1294 }),
1294 resolveMaintenance: () =>
1295 Effect.succeed(
1296 makeManualOnlyProviderMaintenanceCapabilities({
1297 provider: codexDriver,
1298 packageName: null,
1299 }),
1300 ),
12951301 getSnapshot: Effect.succeed(initialProvider),
12961302 refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(
12971303 Effect.andThen(Effect.never),
......@@ -1380,10 +1386,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
13801386 displayName: undefined,
13811387 enabled: true,
13821388 snapshot: {
1383 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1384 provider: driver,
1385 packageName: null,
1386 }),
1389 resolveMaintenance: () =>
1390 Effect.succeed(
1391 makeManualOnlyProviderMaintenanceCapabilities({
1392 provider: driver,
1393 packageName: null,
1394 }),
1395 ),
13871396 getSnapshot: Effect.succeed(provider),
13881397 refresh: Effect.succeed(provider),
13891398 streamChanges: Stream.empty,
......@@ -1569,10 +1578,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
15691578 displayName: undefined,
15701579 enabled: true,
15711580 snapshot: {
1572 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1573 provider: codexDriver,
1574 packageName: null,
1575 }),
1581 resolveMaintenance: () =>
1582 Effect.succeed(
1583 makeManualOnlyProviderMaintenanceCapabilities({
1584 provider: codexDriver,
1585 packageName: null,
1586 }),
1587 ),
15761588 getSnapshot: Effect.succeed(codexProvider),
15771589 refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(
15781590 Effect.as(codexProvider),
......@@ -1593,10 +1605,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
15931605 displayName: undefined,
15941606 enabled: true,
15951607 snapshot: {
1596 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1597 provider: openCodeDriver,
1598 packageName: null,
1599 }),
1608 resolveMaintenance: () =>
1609 Effect.succeed(
1610 makeManualOnlyProviderMaintenanceCapabilities({
1611 provider: openCodeDriver,
1612 packageName: null,
1613 }),
1614 ),
16001615 getSnapshot: Effect.succeed(failedOpenCodeProvider),
16011616 refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(
16021617 Effect.andThen(Ref.get(catalogSnapshot)),
......@@ -1717,10 +1732,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
17171732 displayName: undefined,
17181733 enabled: true,
17191734 snapshot: {
1720 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1721 provider: cursorDriver,
1722 packageName: null,
1723 }),
1735 resolveMaintenance: () =>
1736 Effect.succeed(
1737 makeManualOnlyProviderMaintenanceCapabilities({
1738 provider: cursorDriver,
1739 packageName: null,
1740 }),
1741 ),
17241742 getSnapshot: Effect.succeed(initialProvider),
17251743 refresh: Effect.succeed(refreshedProvider),
17261744 streamChanges: Stream.fromPubSub(changes),
......@@ -1839,10 +1857,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
18391857 displayName: undefined,
18401858 enabled: true,
18411859 snapshot: {
1842 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1843 provider: openCodeDriver,
1844 packageName: null,
1845 }),
1860 resolveMaintenance: () =>
1861 Effect.succeed(
1862 makeManualOnlyProviderMaintenanceCapabilities({
1863 provider: openCodeDriver,
1864 packageName: null,
1865 }),
1866 ),
18461867 getSnapshot: Effect.succeed(initialProvider),
18471868 refresh: Effect.succeed(authoritativeProvider),
18481869 streamChanges: Stream.fromPubSub(changes),
......@@ -1939,10 +1960,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19391960 displayName: undefined,
19401961 enabled: true,
19411962 snapshot: {
1942 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1943 provider: codexDriver,
1944 packageName: null,
1945 }),
1963 resolveMaintenance: () =>
1964 Effect.succeed(
1965 makeManualOnlyProviderMaintenanceCapabilities({
1966 provider: codexDriver,
1967 packageName: null,
1968 }),
1969 ),
19461970 getSnapshot: Effect.succeed(cachedProvider),
19471971 refresh: Effect.die(new Error("simulated refresh failure")),
19481972 streamChanges: Stream.empty,
......@@ -2033,10 +2057,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20332057 displayName: undefined,
20342058 enabled: true,
20352059 snapshot: {
2036 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
2037 provider: provider.driver,
2038 packageName: null,
2039 }),
2060 resolveMaintenance: () =>
2061 Effect.succeed(
2062 makeManualOnlyProviderMaintenanceCapabilities({
2063 provider: provider.driver,
2064 packageName: null,
2065 }),
2066 ),
20402067 getSnapshot: Effect.succeed(provider),
20412068 refresh: Effect.succeed(provider),
20422069 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderRegistry.ts+13-8
......@@ -532,14 +532,19 @@ export const ProviderRegistryLive = Layer.effect(
532532
533533 const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
534534 "getProviderMaintenanceCapabilitiesForInstance",
535 )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) {
536 const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(
537 (candidate) => candidate.instanceId === instanceId,
538 );
539 return (
540 instance?.snapshot.maintenanceCapabilities ??
541 makeManualProviderMaintenanceCapabilities(provider)
542 );
535 )(function* (
536 instanceId: ProviderInstanceId,
537 provider: ProviderDriverKind,
538 options?: { readonly fresh?: boolean },
539 ) {
540 // Read the instance registry, not `liveSubsRef`: the latter trails
541 // reconciliation, and an update must never run a retired instance's
542 // command against a freshly configured executable.
543 const instance = yield* instanceRegistry.getInstance(instanceId);
544 if (!instance || instance.driverKind !== provider) {
545 return makeManualProviderMaintenanceCapabilities(provider);
546 }
547 return yield* instance.snapshot.resolveMaintenance(options);
543548 });
544549
545550 /**
apps/server/src/provider/Services/ProviderRegistry.ts+2
......@@ -56,10 +56,12 @@ export interface ProviderRegistryShape {
5656 /**
5757 * Resolve the maintenance capabilities owned by one live provider instance.
5858 * Falls back to manual-only capabilities when the instance is not live.
59 * `fresh` re-derives ownership from the executable instead of the cache.
5960 */
6061 readonly getProviderMaintenanceCapabilitiesForInstance: (
6162 instanceId: ProviderInstanceId,
6263 provider: ProviderDriverKind,
64 options?: { readonly fresh?: boolean },
6365 ) => Effect.Effect<ProviderMaintenanceCapabilities>;
6466
6567 /**
apps/server/src/provider/Services/ServerProvider.ts+8-1
......@@ -4,7 +4,14 @@ import type * as Stream from "effect/Stream";
44import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
55
66export interface ServerProviderShape {
7 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
7 /**
8 * Ownership-derived update capabilities. Cached between reads; pass
9 * `{ fresh: true }` before executing an update so it never trusts a
10 * resolution older than the click.
11 */
12 readonly resolveMaintenance: (options?: {
13 readonly fresh?: boolean;
14 }) => Effect.Effect<ProviderMaintenanceCapabilities>;
815 readonly getSnapshot: Effect.Effect<ServerProvider>;
916 readonly refresh: Effect.Effect<ServerProvider>;
1017 readonly streamChanges: Stream.Stream<ServerProvider>;
apps/server/src/provider/makeManagedServerProvider.test.ts+11-11
......@@ -158,7 +158,7 @@ describe("makeManagedServerProvider", () => {
158158 const checkCalls = yield* Ref.make(0);
159159 const releaseCheck = yield* Deferred.make<void>();
160160 const provider = yield* makeManagedServerProvider<TestSettings>({
161 maintenanceCapabilities,
161 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
162162 getSettings: Effect.succeed({ enabled: true }),
163163 streamSettings: Stream.empty,
164164 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -198,7 +198,7 @@ describe("makeManagedServerProvider", () => {
198198 const checkCalls = yield* Ref.make(0);
199199 const initialCheckDone = yield* Deferred.make<void>();
200200 yield* makeManagedServerProvider<TestSettings>({
201 maintenanceCapabilities,
201 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
202202 getSettings: Effect.succeed({ enabled: true }),
203203 streamSettings: Stream.empty,
204204 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -229,7 +229,7 @@ describe("makeManagedServerProvider", () => {
229229 const checkCalls = yield* Ref.make(0);
230230 const initialCheckDone = yield* Deferred.make<void>();
231231 yield* makeManagedServerProvider<TestSettings>({
232 maintenanceCapabilities,
232 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
233233 getSettings: Effect.succeed({ enabled: true }),
234234 streamSettings: Stream.empty,
235235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -256,7 +256,7 @@ describe("makeManagedServerProvider", () => {
256256 const checkCalls = yield* Ref.make(0);
257257 const initialCheckDone = yield* Deferred.make<void>();
258258 const provider = yield* makeManagedServerProvider<TestSettings>({
259 maintenanceCapabilities,
259 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
260260 getSettings: Effect.succeed({ enabled: true }),
261261 streamSettings: Stream.empty,
262262 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -311,7 +311,7 @@ describe("makeManagedServerProvider", () => {
311311 const periodicCheckDone = yield* Deferred.make<void>();
312312
313313 yield* makeManagedServerProvider<TestSettings>({
314 maintenanceCapabilities,
314 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
315315 getSettings: Effect.succeed({ enabled: true }),
316316 streamSettings: Stream.empty,
317317 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -353,7 +353,7 @@ describe("makeManagedServerProvider", () => {
353353 const releaseInitialCheck = yield* Deferred.make<void>();
354354 const releaseSettingsCheck = yield* Deferred.make<void>();
355355 const provider = yield* makeManagedServerProvider<TestSettings>({
356 maintenanceCapabilities,
356 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
357357 getSettings: Ref.get(settingsRef),
358358 streamSettings: Stream.fromPubSub(settingsChanges),
359359 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -397,7 +397,7 @@ describe("makeManagedServerProvider", () => {
397397 const initialCheckDone = yield* Deferred.make<void>();
398398 const enrichmentCalls = yield* Ref.make(0);
399399 yield* makeManagedServerProvider<TestSettings>({
400 maintenanceCapabilities,
400 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
401401 getSettings: Effect.succeed({ enabled: true }),
402402 streamSettings: Stream.fromPubSub(settingsChanges),
403403 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -430,7 +430,7 @@ describe("makeManagedServerProvider", () => {
430430 const releaseEnrichment = yield* Deferred.make<void>();
431431 const releaseCheck = yield* Deferred.make<void>();
432432 const provider = yield* makeManagedServerProvider<TestSettings>({
433 maintenanceCapabilities,
433 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
434434 getSettings: Effect.succeed({ enabled: true }),
435435 streamSettings: Stream.empty,
436436 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -471,7 +471,7 @@ describe("makeManagedServerProvider", () => {
471471 const secondCallbackReady = yield* Deferred.make<void>();
472472 const allowFirstRefresh = yield* Deferred.make<void>();
473473 const provider = yield* makeManagedServerProvider<TestSettings>({
474 maintenanceCapabilities,
474 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
475475 getSettings: Effect.succeed({ enabled: true }),
476476 streamSettings: Stream.empty,
477477 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -527,7 +527,7 @@ describe("makeManagedServerProvider", () => {
527527 Effect.scoped(
528528 Effect.gen(function* () {
529529 const provider = yield* makeManagedServerProvider<TestSettings>({
530 maintenanceCapabilities,
530 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
531531 getSettings: Effect.succeed({ enabled: true }),
532532 streamSettings: Stream.empty,
533533 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
......@@ -595,7 +595,7 @@ describe("makeManagedServerProvider", () => {
595595 windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }],
596596 } as const;
597597 const provider = yield* makeManagedServerProvider<TestSettings>({
598 maintenanceCapabilities,
598 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
599599 getSettings: Effect.succeed({ enabled: true }),
600600 streamSettings: Stream.empty,
601601 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
apps/server/src/provider/makeManagedServerProvider.ts+2-2
......@@ -39,7 +39,7 @@ function withUsageLimits(
3939export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
4040 Settings,
4141>(input: {
42 readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"];
42 readonly resolveMaintenance: ServerProviderShape["resolveMaintenance"];
4343 readonly getSettings: Effect.Effect<Settings, ServerSettingsError>;
4444 readonly streamSettings: Stream.Stream<Settings>;
4545 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
......@@ -283,7 +283,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
283283 );
284284
285285 return {
286 maintenanceCapabilities: input.maintenanceCapabilities,
286 resolveMaintenance: input.resolveMaintenance,
287287 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),
288288 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),
289289 applyUsageLimits,
apps/server/src/provider/providerMaintenance.test.ts+468-464
......@@ -1,7 +1,7 @@
11// @effect-diagnostics nodeBuiltinImport:off
22import { expect, it } from "@effect/vitest";
3import * as NodeFS from "node:fs";
43import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
55import * as NodeServices from "@effect/platform-node/NodeServices";
66import * as NodeOS from "node:os";
77import * as NodePath from "node:path";
......@@ -9,26 +9,31 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
99import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1010import * as Crypto from "effect/Crypto";
1111import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
1314import { HttpClient } from "effect/unstable/http";
15import { ChildProcessSpawner } from "effect/unstable/process";
1416import {
1517 createProviderVersionAdvisory,
1618 enrichProviderSnapshotWithVersionAdvisory,
19 homebrewOwnershipFromCommandPath,
20 makeCachedProviderMaintenanceResolution,
1721 makePackageManagedProviderMaintenanceResolver,
1822 makeProviderMaintenanceCapabilities,
19 makeStaticProviderMaintenanceResolver,
2023 normalizeCommandPath,
24 npmGlobalPrefixFromCommandPath,
25 parseHomebrewLatestVersion,
2126 ProviderVersionCache,
2227 resolveLatestProviderVersion,
2328 resolveProviderMaintenanceCapabilitiesEffect,
29 type ProviderMaintenanceCapabilities,
2430} from "./providerMaintenance.ts";
2531import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2632
2733const driver = (value: string) => ProviderDriverKind.make(value);
28// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked
29// PATH walk; a Windows temp path cannot be split on `:`.
34// These write `#!/bin/sh` stubs and evaluate them with darwin/linux path
35// semantics; a Windows temp path cannot be split on `:`.
3036const windowsHost = HostProcessPlatform.defaultValue() === "win32";
31
3237const makeTempDir = (name: string) =>
3338 Crypto.Crypto.pipe(
3439 Effect.flatMap((crypto) => crypto.randomUUIDv4),
......@@ -41,40 +46,16 @@ const isNativeTestCommandPath =
4146const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({
4247 provider: driver("packageTool"),
4348 npmPackageName: "@example/package-tool",
44 homebrewFormula: "package-tool",
4549 nativeUpdate: null,
4650});
4751const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({
4852 provider: driver("nativePackageTool"),
4953 npmPackageName: "@example/native-package-tool",
50 homebrewFormula: "native-package-tool",
5154 nativeUpdate: {
52 executable: "native-package-tool",
5355 args: ["update"],
54 lockKey: "native-package-tool-native",
5556 isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"),
5657 },
5758});
58const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({
59 provider: driver("scopedPackageTool"),
60 npmPackageName: "@example/scoped-package-tool",
61 homebrewFormula: "example/tap/scoped-package-tool",
62 nativeUpdate: {
63 executable: "scoped-package-tool",
64 args: ["upgrade"],
65 lockKey: "scoped-package-tool-native",
66 isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"),
67 },
68});
69const staticToolUpdate = makeStaticProviderMaintenanceResolver(
70 makeProviderMaintenanceCapabilities({
71 provider: driver("staticTool"),
72 packageName: null,
73 updateExecutable: "static-tool",
74 updateArgs: ["update"],
75 updateLockKey: "static-tool",
76 }),
77);
7859const installedPackageToolProvider: ServerProvider = {
7960 instanceId: ProviderInstanceId.make("packageTool"),
8061 driver: driver("packageTool"),
......@@ -88,10 +69,59 @@ const installedPackageToolProvider: ServerProvider = {
8869 slashCommands: [],
8970 skills: [],
9071};
72const manualPackageTool: ProviderMaintenanceCapabilities = {
73 provider: driver("packageTool"),
74 packageName: "@example/package-tool",
75 update: null,
76};
77
78function writeExecutable(path: string) {
79 NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
80 NodeFS.writeFileSync(path, "#!/bin/sh\n");
81 NodeFS.chmodSync(path, 0o755);
82}
83
84/** Symlink `<tempDir>/bin/<name>` into a package entry point, like npm/pnpm do. */
85function linkIntoPackage(tempDir: string, name: string, packageSegments: ReadonlyArray<string>) {
86 const target = NodePath.join(tempDir, ...packageSegments, "bin", `${name}.js`);
87 writeExecutable(target);
88 const link = NodePath.join(tempDir, "bin", name);
89 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
90 NodeFS.symlinkSync(target, link);
91 return link;
92}
93
94const noSpawn = ChildProcessSpawner.make(() =>
95 Effect.die("maintenance resolution should not spawn a process here"),
96);
97
98function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray<string>) => string) {
99 return ChildProcessSpawner.make((command) => {
100 const { command: executable, args } = command as unknown as {
101 readonly command: string;
102 readonly args: ReadonlyArray<string>;
103 };
104 return Effect.succeed(
105 ChildProcessSpawner.makeHandle({
106 pid: ChildProcessSpawner.ProcessId(1),
107 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
108 isRunning: Effect.succeed(false),
109 kill: () => Effect.void,
110 unref: Effect.succeed(Effect.void),
111 stdin: Sink.drain,
112 stdout: Stream.encodeText(Stream.make(onSpawn(executable, args))),
113 stderr: Stream.empty,
114 all: Stream.empty,
115 getInputFd: () => Sink.drain,
116 getOutputFd: () => Stream.empty,
117 }),
118 );
119 });
120}
91121
92122it.layer(NodeServices.layer)("providerMaintenance", (it) => {
93123 it.effect("reads cached versions through the injectable cache reference", () =>
94 resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe(
124 resolveLatestProviderVersion(manualPackageTool).pipe(
95125 Effect.provideService(
96126 ProviderVersionCache,
97127 new Map([
......@@ -116,14 +146,25 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
116146 ),
117147 );
118148
149 it.effect("prefers the installer's own latest version over the npm registry", () =>
150 resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe(
151 Effect.provideService(ProviderVersionCache, new Map()),
152 Effect.provideService(
153 HttpClient.HttpClient,
154 HttpClient.make(() =>
155 Effect.die("installer-reported latest should not make an HTTP request"),
156 ),
157 ),
158 Effect.map((version) => {
159 expect(version).toBe("1.2.0");
160 }),
161 ),
162 );
163
119164 it.effect("does not fetch latest provider versions when update checks are disabled", () =>
120 enrichProviderSnapshotWithVersionAdvisory(
121 installedPackageToolProvider,
122 packageToolUpdate.resolve(),
123 {
124 enableProviderUpdateChecks: false,
125 },
126 ).pipe(
165 enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, {
166 enableProviderUpdateChecks: false,
167 }).pipe(
127168 Effect.provideService(ProviderVersionCache, new Map()),
128169 Effect.provideService(
129170 HttpClient.HttpClient,
......@@ -171,562 +212,525 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
171212 });
172213 });
173214
174 it("marks installed providers behind latest when a newer provider version is available", () => {
215 it("keeps the manual update hint when the install is behind but unowned", () => {
175216 expect(
176217 createProviderVersionAdvisory({
177 driver: driver("nativePackageTool"),
218 driver: driver("packageTool"),
178219 currentVersion: "2.1.110",
179220 latestVersion: "2.1.117",
180 maintenanceCapabilities: nativePackageToolUpdate.resolve(),
221 maintenanceCapabilities: manualPackageTool,
181222 }),
182223 ).toMatchObject({
183224 status: "behind_latest",
184 currentVersion: "2.1.110",
185225 latestVersion: "2.1.117",
186 updateCommand:
187 "npm install -g --allow-scripts=@example/native-package-tool @example/native-package-tool@latest",
188 canUpdate: true,
226 updateCommand: null,
227 canUpdate: false,
189228 message: "Install the update now or review provider settings.",
190229 });
191230 });
192231
193 it("keeps update commands owned by provider maintenance capabilities", () => {
194 expect(staticToolUpdate.resolve()).toEqual({
195 provider: driver("staticTool"),
196 packageName: null,
197 update: {
198 command: "static-tool update",
199
200 executable: "static-tool",
201
202 args: ["update"],
203
204 lockKey: "static-tool",
205 },
206 });
207 });
232 it.effect("stays manual-only when the binary cannot be located", () =>
233 resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
234 binaryPath: "package-tool",
235 env: { PATH: "" },
236 }).pipe(
237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
238 Effect.map((capabilities) => {
239 expect(capabilities).toEqual(manualPackageTool);
240 }),
241 ),
242 );
208243
209 it.effect.skipIf(windowsHost)(
210 "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
244 it.effect.skipIf(!symlinksSupported)(
245 "pins npm updates to the global prefix that owns the package",
211246 () =>
212247 Effect.gen(function* () {
213 const tempDir = yield* makeTempDir("t3-vite-plus-capabilities");
214 const vitePlusBinDir = NodePath.join(tempDir, ".vite-plus", "bin");
215 NodeFS.mkdirSync(vitePlusBinDir, { recursive: true });
216 const packageToolPath = NodePath.join(vitePlusBinDir, "package-tool");
217 NodeFS.writeFileSync(packageToolPath, "#!/bin/sh\n");
218 NodeFS.chmodSync(packageToolPath, 0o755);
248 const tempDir = yield* makeTempDir("t3-npm-capabilities");
249 const link = linkIntoPackage(tempDir, "package-tool", [
250 "lib",
251 "node_modules",
252 "@example",
253 "package-tool",
254 ]);
219255
220256 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
221257 packageToolUpdate,
222258 {
223 binaryPath: "package-tool",
224 env: {
225 PATH: vitePlusBinDir,
226 },
259 binaryPath: link,
260 env: { PATH: "" },
227261 },
228 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
262 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
229263
230264 expect(capabilities).toEqual({
231265 provider: driver("packageTool"),
232266 packageName: "@example/package-tool",
233267 update: {
234 command: "vp i -g @example/package-tool",
235
236 executable: "vp",
237
238 args: ["i", "-g", "@example/package-tool"],
239
240 lockKey: "vite-plus-global",
268 command: `npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`,
269 executable: "npm",
270 args: [
271 "install",
272 "-g",
273 "--prefix",
274 tempDir,
275 "--allow-scripts=@example/package-tool",
276 "@example/package-tool@latest",
277 ],
278 lockKey: `npm-global:${normalizeCommandPath(tempDir)}`,
241279 },
242280 });
243281 }),
244282 );
245283
246 it.effect(
247 "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin",
248 () =>
249 Effect.gen(function* () {
250 const tempDir = yield* makeTempDir("t3-bun-capabilities");
251 const bunBinDir = NodePath.join(tempDir, ".bun", "bin");
252 NodeFS.mkdirSync(bunBinDir, { recursive: true });
253 NodeFS.writeFileSync(NodePath.join(bunBinDir, "native-package-tool.exe"), "MZ");
254
255 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
256 nativePackageToolUpdate,
257 {
258 binaryPath: "native-package-tool",
259 env: {
260 PATH: bunBinDir,
261 PATHEXT: ".COM;.EXE;.BAT;.CMD",
262 },
263 },
264 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
284 it("derives the npm prefix only from the global lib/node_modules layout", () => {
285 expect(
286 npmGlobalPrefixFromCommandPath(
287 "/usr/local/lib/node_modules/@openai/codex/bin/codex.js",
288 "@openai/codex",
289 ),
290 ).toBe("/usr/local");
291 // A copy nested inside another package is not a global install.
292 expect(
293 npmGlobalPrefixFromCommandPath(
294 "/usr/local/lib/node_modules/other/node_modules/@openai/codex/bin/codex.js",
295 "@openai/codex",
296 ),
297 ).toBeNull();
298 expect(
299 npmGlobalPrefixFromCommandPath(
300 "/lib/node_modules/@openai/codex/bin/codex.js",
301 "@openai/codex",
302 ),
303 ).toBe("/");
304 // Neither is a project-local dependency.
305 expect(
306 npmGlobalPrefixFromCommandPath(
307 "/work/app/node_modules/@openai/codex/bin/codex.js",
308 "@openai/codex",
309 ),
310 ).toBeNull();
311 });
265312
266 expect(capabilities).toEqual({
267 provider: driver("nativePackageTool"),
268 packageName: "@example/native-package-tool",
269 update: {
270 command: "bun i -g @example/native-package-tool@latest",
313 it.effect("proves Windows npm ownership from the package manifest beside the shim", () =>
314 Effect.gen(function* () {
315 const tempDir = yield* makeTempDir("t3-npm-windows-capabilities");
316 const shim = NodePath.join(tempDir, "package-tool.cmd");
317 NodeFS.mkdirSync(tempDir, { recursive: true });
318 NodeFS.writeFileSync(shim, "@echo off\r\n");
319 NodeFS.mkdirSync(NodePath.join(tempDir, "node_modules", "@example", "package-tool"), {
320 recursive: true,
321 });
322 NodeFS.writeFileSync(
323 NodePath.join(tempDir, "node_modules", "@example", "package-tool", "package.json"),
324 "{}",
325 );
271326
272 executable: "bun",
327 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
328 binaryPath: shim,
329 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
330 }).pipe(
331 Effect.provideService(HostProcessPlatform, "win32"),
332 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
333 );
273334
274 args: ["i", "-g", "@example/native-package-tool@latest"],
335 expect(capabilities.update).toMatchObject({
336 executable: "npm",
337 args: ["install", "-g", "--prefix", tempDir, expect.any(String), expect.any(String)],
338 });
275339
276 lockKey: "bun-global",
277 },
278 });
279 }),
340 // The same layout on POSIX is a project checkout, not a global install.
341 const script = NodePath.join(tempDir, "package-tool");
342 writeExecutable(script);
343 const posix = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
344 binaryPath: script,
345 env: { PATH: "" },
346 }).pipe(
347 Effect.provideService(HostProcessPlatform, "linux"),
348 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
349 );
350 expect(posix.update).toBeNull();
351 }),
280352 );
281353
282 it.effect.skipIf(windowsHost)(
283 "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin",
354 it.effect.skipIf(!symlinksSupported)(
355 "switches to pnpm updates when the real path lives in pnpm's global store",
284356 () =>
285357 Effect.gen(function* () {
286358 const tempDir = yield* makeTempDir("t3-pnpm-capabilities");
287 const pnpmHomeDir = NodePath.join(tempDir, ".local", "share", "pnpm");
288 NodeFS.mkdirSync(pnpmHomeDir, { recursive: true });
289 const scopedPackageToolPath = NodePath.join(pnpmHomeDir, "scoped-package-tool");
290 NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
291 NodeFS.chmodSync(scopedPackageToolPath, 0o755);
359 const link = linkIntoPackage(tempDir, "package-tool", [
360 ".local",
361 "share",
362 "pnpm",
363 "global",
364 "5",
365 "node_modules",
366 "@example",
367 "package-tool",
368 ]);
292369
293370 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
294 scopedPackageToolUpdate,
371 packageToolUpdate,
295372 {
296 binaryPath: "scoped-package-tool",
297 env: {
298 PATH: pnpmHomeDir,
299 },
373 binaryPath: link,
374 env: { PATH: "" },
300375 },
301 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
376 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
302377
303 expect(capabilities).toEqual({
304 provider: driver("scopedPackageTool"),
305 packageName: "@example/scoped-package-tool",
306 update: {
307 command: "pnpm add -g @example/scoped-package-tool@latest",
308
309 executable: "pnpm",
310
311 args: ["add", "-g", "@example/scoped-package-tool@latest"],
312
313 lockKey: "pnpm-global",
314 },
378 expect(capabilities.update).toMatchObject({
379 command: "pnpm add -g @example/package-tool@latest",
380 lockKey: "pnpm-global",
315381 });
316382 }),
317383 );
318384
319 it("switches package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
320 expect(
321 packageToolUpdate.resolve({
322 binaryPath: "/opt/homebrew/bin/package-tool",
323 env: {
324 PATH: "",
325 },
326 }),
327 ).toEqual({
328 provider: driver("packageTool"),
329 packageName: "@example/package-tool",
330 update: {
331 command: "brew upgrade package-tool",
332
333 executable: "brew",
334
335 args: ["upgrade", "package-tool"],
336
337 lockKey: "homebrew",
338 },
339 });
340 });
341
342385 it.effect.skipIf(windowsHost)(
343 "switches native-package-tool to native updates when the binary resolves through the native installer",
386 "switches to bun updates when the resolved binary lives in bun's global bin",
344387 () =>
345388 Effect.gen(function* () {
346 const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities");
347 const nativeBinDir = NodePath.join(tempDir, ".local", "bin");
348 NodeFS.mkdirSync(nativeBinDir, { recursive: true });
349 const nativePackageToolPath = NodePath.join(nativeBinDir, "native-package-tool");
350 NodeFS.writeFileSync(nativePackageToolPath, "#!/bin/sh\n");
351 NodeFS.chmodSync(nativePackageToolPath, 0o755);
389 const tempDir = yield* makeTempDir("t3-bun-capabilities");
390 const bunBinDir = NodePath.join(tempDir, ".bun", "bin");
391 writeExecutable(NodePath.join(bunBinDir, "package-tool"));
352392
353393 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
354 nativePackageToolUpdate,
394 packageToolUpdate,
355395 {
356 binaryPath: "native-package-tool",
357 env: {
358 PATH: nativeBinDir,
359 },
396 binaryPath: "package-tool",
397 env: { PATH: bunBinDir },
360398 },
361 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
362
363 expect(capabilities).toEqual({
364 provider: driver("nativePackageTool"),
365 packageName: "@example/native-package-tool",
366 update: {
367 command: `${nativePackageToolPath} update`,
368
369 executable: nativePackageToolPath,
370
371 args: ["update"],
399 ).pipe(
400 Effect.provideService(HostProcessPlatform, "darwin"),
401 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
402 );
372403
373 lockKey: "native-package-tool-native",
374 },
404 expect(capabilities.update).toMatchObject({
405 command: "bun i -g @example/package-tool@latest",
406 lockKey: "bun-global",
375407 });
376408 }),
377409 );
378410
379 it.effect.skipIf(windowsHost)(
380 "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer",
381 () =>
382 Effect.gen(function* () {
383 const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities");
384 const nativeBinDir = NodePath.join(tempDir, ".scoped-package-tool", "bin");
385 NodeFS.mkdirSync(nativeBinDir, { recursive: true });
386 const scopedPackageToolPath = NodePath.join(nativeBinDir, "scoped-package-tool");
387 NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");
388 NodeFS.chmodSync(scopedPackageToolPath, 0o755);
389
390 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
391 scopedPackageToolUpdate,
392 {
393 binaryPath: "scoped-package-tool",
394 env: {
395 PATH: nativeBinDir,
396 },
397 },
398 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));
399
400 expect(capabilities).toEqual({
401 provider: driver("scopedPackageTool"),
402 packageName: "@example/scoped-package-tool",
403 update: {
404 command: `${scopedPackageToolPath} upgrade`,
405
406 executable: scopedPackageToolPath,
411 it.effect.skipIf(windowsHost)("switches to native updates and runs the resolved executable", () =>
412 Effect.gen(function* () {
413 const tempDir = yield* makeTempDir("t3-native-capabilities");
414 const nativeBinDir = NodePath.join(tempDir, ".local", "bin");
415 const nativePath = NodePath.join(nativeBinDir, "native-package-tool");
416 writeExecutable(nativePath);
407417
408 args: ["upgrade"],
418 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
419 nativePackageToolUpdate,
420 {
421 binaryPath: "native-package-tool",
422 env: { PATH: nativeBinDir },
423 },
424 ).pipe(
425 Effect.provideService(HostProcessPlatform, "darwin"),
426 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
427 );
409428
410 lockKey: "scoped-package-tool-native",
411 },
412 });
413 }),
429 expect(capabilities).toEqual({
430 provider: driver("nativePackageTool"),
431 packageName: "@example/native-package-tool",
432 update: {
433 command: `${nativePath} update`,
434 executable: nativePath,
435 args: ["update"],
436 lockKey: "nativePackageTool-native",
437 },
438 });
439 }),
414440 );
415441
442 // Regression for #9850: an explicit native path outside PATH, with spaces,
443 // must be what actually gets spawned.
416444 it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () =>
417445 Effect.gen(function* () {
418 const fs = yield* FileSystem.FileSystem;
419 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-update-" });
420 const nativeBinDir = NodePath.join(tempDir, "with spaces", ".scoped-package-tool", "bin");
421 yield* fs.makeDirectory(nativeBinDir, { recursive: true });
422 const binaryPath = NodePath.join(nativeBinDir, "scoped-package-tool");
423 yield* fs.writeFileString(binaryPath, "#!/bin/sh\nprintf '%s' \"$1\"\n");
424 yield* fs.chmod(binaryPath, 0o755);
446 const tempDir = yield* makeTempDir("t3-native-update");
447 const nativePath = NodePath.join(
448 tempDir,
449 "with spaces",
450 ".local",
451 "bin",
452 "native-package-tool",
453 );
454 NodeFS.mkdirSync(NodePath.dirname(nativePath), { recursive: true });
455 NodeFS.writeFileSync(nativePath, "#!/bin/sh\nprintf '%s' \"$1\"\n");
456 NodeFS.chmodSync(nativePath, 0o755);
425457
426458 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
427 scopedPackageToolUpdate,
428 { binaryPath, env: { PATH: "" } },
459 nativePackageToolUpdate,
460 { binaryPath: nativePath, env: { PATH: "" } },
461 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
462
463 expect(capabilities.update?.executable).toBe(nativePath);
464 const result = NodeChildProcess.spawnSync(
465 capabilities.update!.executable,
466 capabilities.update!.args,
467 { env: { PATH: "" }, encoding: "utf8" },
429468 );
430 const update = capabilities.update;
431 expect(update).not.toBeNull();
432 if (!update) return;
433 const result = NodeChildProcess.spawnSync(update.executable, update.args, {
434 env: { PATH: "" },
435 encoding: "utf8",
436 });
437 expect(result.error).toBeUndefined();
438469 expect(result.status).toBe(0);
439 expect(result.stdout).toBe("upgrade");
440 }).pipe(Effect.scoped),
470 expect(result.stdout).toBe("update");
471 }),
441472 );
442473
443 it.each([
444 "/Users/example/.local/bin/native-package-tool",
445 "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe",
446 ])("preserves a configured native executable path: %s", (binaryPath) => {
447 expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath);
448 });
474 it.effect.skipIf(!symlinksSupported)(
475 "prefers npm ownership over the Node keg the package lives under",
476 () =>
477 Effect.gen(function* () {
478 // `brew install node` keeps npm globals inside the node keg.
479 const tempDir = yield* makeTempDir("t3-homebrew-node-capabilities");
480 const keg = NodePath.join(tempDir, "Cellar", "node", "22.1.0");
481 const target = NodePath.join(
482 keg,
483 "lib",
484 "node_modules",
485 "@example",
486 "package-tool",
487 "bin",
488 "package-tool.js",
489 );
490 writeExecutable(target);
491 const link = NodePath.join(tempDir, "bin", "package-tool");
492 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
493 NodeFS.symlinkSync(target, link);
449494
450 for (const directoryName of ["plain", "with spaces", "with 'quotes' and $dollars"]) {
451 it.effect.skipIf(windowsHost)(
452 `runs the copied native update command from a ${directoryName} path`,
453 () =>
454 Effect.gen(function* () {
455 const fs = yield* FileSystem.FileSystem;
456 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-copy-native-update-" });
457 const binDir = NodePath.join(tempDir, directoryName, ".scoped-package-tool", "bin");
458 yield* fs.makeDirectory(binDir, { recursive: true });
459 const binaryPath = NodePath.join(binDir, "scoped-package-tool");
460 yield* fs.writeFileString(binaryPath, '#!/bin/sh\nprintf "%s" "$1"\n');
461 yield* fs.chmod(binaryPath, 0o755);
462
463 const capabilities = scopedPackageToolUpdate.resolve({ binaryPath });
464 const advisory = createProviderVersionAdvisory({
465 driver: driver("scopedPackageTool"),
466 currentVersion: "1.0.0",
467 latestVersion: "2.0.0",
468 maintenanceCapabilities: capabilities,
469 });
470 expect(advisory.updateCommand).not.toBeNull();
471 if (!advisory.updateCommand) return;
472 const result = NodeChildProcess.spawnSync("/bin/sh", ["-c", advisory.updateCommand], {
495 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
496 packageToolUpdate,
497 {
498 binaryPath: link,
473499 env: { PATH: "" },
474 encoding: "utf8",
475 });
476 expect(result.error).toBeUndefined();
477 expect(result.status, result.stderr).toBe(0);
478 expect(result.stdout).toBe("upgrade");
479 }).pipe(Effect.scoped),
480 );
481 }
482
483 it.each([
484 {
485 binaryPath: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe",
486 command: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe update",
487 },
488 {
489 binaryPath: "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe",
490 command: "& 'C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe' update",
491 },
492 {
493 binaryPath: "C:\\Users\\O'Connor\\.local\\bin\\native-package-tool.exe",
494 command: "& 'C:\\Users\\O''Connor\\.local\\bin\\native-package-tool.exe' update",
495 },
496 {
497 binaryPath: "C:\\Users\\O\u2019Connor\\.local\\bin\\native-package-tool.exe",
498 command: "& 'C:\\Users\\O\u2019\u2019Connor\\.local\\bin\\native-package-tool.exe' update",
499 },
500 ])("formats a copied PowerShell native update for $binaryPath", ({ binaryPath, command }) => {
501 expect(nativePackageToolUpdate.resolve({ binaryPath, platform: "win32" }).update).toEqual({
502 command,
503 executable: binaryPath,
504 args: ["update"],
505 lockKey: "native-package-tool-native",
506 });
507 });
500 },
501 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
508502
509 it.effect("uses the environment's platform for copied native update commands", () =>
510 Effect.gen(function* () {
511 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe";
512 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
513 nativePackageToolUpdate,
514 { binaryPath, env: { PATH: "" } },
515 );
516 expect(capabilities.update?.command).toBe(`& '${binaryPath}' update`);
517 expect(capabilities.update?.executable).toBe(binaryPath);
518 }).pipe(Effect.provideService(HostProcessPlatform, "win32")),
503 expect(capabilities.update).toMatchObject({
504 executable: "npm",
505 args: expect.arrayContaining(["--prefix", keg]),
506 lockKey: `npm-global:${normalizeCommandPath(keg)}`,
507 });
508 }),
519509 );
520510
521 it("uses the resolved launcher when its symlink target identifies a native install", () => {
522 const launcher = "/custom tools/native-launcher";
523 const capabilities = nativePackageToolUpdate.resolve({
524 binaryPath: launcher,
525 resolvedCommandPath: launcher,
526 realCommandPath: "/Users/example/.local/bin/native-package-tool",
511 it("quotes copyable command words the shell would split", () => {
512 const posix = makeProviderMaintenanceCapabilities({
513 provider: driver("packageTool"),
514 packageName: "@example/package-tool",
515 updateExecutable: "npm",
516 updateArgs: [
517 "install",
518 "-g",
519 "--prefix",
520 "/Users/Jane Doe/.npm-global",
521 "@example/package-tool@latest",
522 ],
523 updateLockKey: "npm-global",
524 platform: "darwin",
525 });
526 expect(posix.update?.command).toBe(
527 "npm install -g --prefix '/Users/Jane Doe/.npm-global' @example/package-tool@latest",
528 );
529 const windows = makeProviderMaintenanceCapabilities({
530 provider: driver("packageTool"),
531 packageName: null,
532 updateExecutable: "C:\\Program Files\\Tool\\tool.exe",
533 updateArgs: ["update"],
534 updateLockKey: "tool",
535 platform: "win32",
527536 });
528 expect(capabilities.update?.executable).toBe(launcher);
537 expect(windows.update?.command).toBe("& 'C:\\Program Files\\Tool\\tool.exe' update");
529538 });
530539
531 it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
532 expect(
533 nativePackageToolUpdate.resolve({
534 binaryPath: "/opt/homebrew/bin/native-package-tool",
535 env: {
536 PATH: "",
540 it.effect.skipIf(windowsHost)("carries the native updater's environment into the action", () =>
541 Effect.gen(function* () {
542 const tempDir = yield* makeTempDir("t3-native-env");
543 const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool");
544 writeExecutable(nativePath);
545 const resolver = makePackageManagedProviderMaintenanceResolver({
546 provider: driver("nativePackageTool"),
547 npmPackageName: "@example/native-package-tool",
548 nativeUpdate: {
549 args: ["update"],
550 isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"),
551 env: { TOOL_HOME: tempDir },
537552 },
538 }),
539 ).toEqual({
540 provider: driver("nativePackageTool"),
541 packageName: "@example/native-package-tool",
542 update: {
543 command: "brew upgrade native-package-tool",
553 });
544554
545 executable: "brew",
555 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(resolver, {
556 binaryPath: nativePath,
557 env: { PATH: "" },
558 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
546559
547 args: ["upgrade", "native-package-tool"],
560 expect(capabilities.update?.env).toEqual({ TOOL_HOME: tempDir });
561 }),
562 );
548563
549 lockKey: "homebrew",
550 },
564 it("recognizes Homebrew kegs and casks from the real executable path", () => {
565 expect(
566 homebrewOwnershipFromCommandPath("/opt/homebrew/Cellar/claude-code@latest/2.1.0/bin/claude"),
567 ).toEqual({ kind: "formula", name: "claude-code@latest", prefix: "/opt/homebrew" });
568 expect(homebrewOwnershipFromCommandPath("/usr/local/Caskroom/codex/0.148.0/codex")).toEqual({
569 kind: "cask",
570 name: "codex",
571 prefix: "/usr/local",
572 });
573 // A plain /usr/local/bin binary is not evidence of Homebrew (#8832).
574 expect(homebrewOwnershipFromCommandPath("/usr/local/bin/codex")).toBeNull();
575 // A keg elsewhere reports its prefix so the resolver can reject it against
576 // `brew --prefix`.
577 expect(homebrewOwnershipFromCommandPath("/srv/Cellar/claude/1.0.0/bin/claude")).toMatchObject({
578 prefix: "/srv",
551579 });
552580 });
553581
554 it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
555 expect(
556 scopedPackageToolUpdate.resolve({
557 binaryPath: "/opt/homebrew/bin/scoped-package-tool",
558 env: {
559 PATH: "",
560 },
561 }),
562 ).toEqual({
563 provider: driver("scopedPackageTool"),
564 packageName: "@example/scoped-package-tool",
565 update: {
566 command: "brew upgrade example/tap/scoped-package-tool",
567
568 executable: "brew",
582 it.effect.skipIf(windowsHost)(
583 "stays manual-only for an explicit binary path that does not exist",
584 () =>
585 Effect.gen(function* () {
586 const tempDir = yield* makeTempDir("t3-missing-native-capabilities");
587 const missingPath = NodePath.join(tempDir, ".local", "bin", "native-package-tool");
569588
570 args: ["upgrade", "example/tap/scoped-package-tool"],
589 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
590 nativePackageToolUpdate,
591 { binaryPath: missingPath, env: { PATH: "" } },
592 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
571593
572 lockKey: "homebrew",
573 },
574 });
575 });
594 expect(capabilities.update).toBeNull();
595 }),
596 );
576597
577598 it.effect.skipIf(!symlinksSupported)(
578 "keeps npm updates for binaries symlinked into npm's global node_modules tree",
599 "upgrades the Homebrew cask that owns the binary and compares against its version",
579600 () =>
580601 Effect.gen(function* () {
581 const tempDir = yield* makeTempDir("t3-npm-capabilities");
582 const binDir = NodePath.join(tempDir, "bin");
583 const packageBinDir = NodePath.join(
602 const tempDir = yield* makeTempDir("t3-homebrew-capabilities");
603 const brewBinDir = NodePath.join(tempDir, "brew-bin");
604 const brewPath = NodePath.join(brewBinDir, "brew");
605 writeExecutable(brewPath);
606 const caskBinary = NodePath.join(
584607 tempDir,
585 "lib",
586 "node_modules",
587 "@example",
608 "Caskroom",
609 "package-tool",
610 "0.148.0",
588611 "package-tool",
589 "bin",
590612 );
591 NodeFS.mkdirSync(binDir, { recursive: true });
592 NodeFS.mkdirSync(packageBinDir, { recursive: true });
593 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
594 const symlinkPath = NodePath.join(binDir, "package-tool");
595 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
596 NodeFS.chmodSync(packageBinPath, 0o755);
597 NodeFS.symlinkSync(packageBinPath, symlinkPath);
613 writeExecutable(caskBinary);
614 const link = NodePath.join(tempDir, "bin", "package-tool");
615 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
616 NodeFS.symlinkSync(caskBinary, link);
617 const spawned: Array<ReadonlyArray<string>> = [];
598618
599619 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
600620 packageToolUpdate,
601621 {
602 binaryPath: symlinkPath,
603 env: {
604 PATH: "",
605 },
622 binaryPath: link,
623 env: { PATH: brewBinDir },
606624 },
625 ).pipe(
626 Effect.provideService(HostProcessPlatform, "darwin"),
627 Effect.provideService(
628 ChildProcessSpawner.ChildProcessSpawner,
629 stdoutSpawner((command, args) => {
630 spawned.push([command, ...args]);
631 return args[0] === "--prefix"
632 ? `${tempDir}\n`
633 : JSON.stringify({ casks: [{ version: "0.148.0,42" }] });
634 }),
635 ),
607636 );
608637
638 expect(spawned).toEqual([
639 [brewPath, "--prefix"],
640 [brewPath, "info", "--json=v2", "package-tool"],
641 ]);
609642 expect(capabilities).toEqual({
610643 provider: driver("packageTool"),
611644 packageName: "@example/package-tool",
645 latestVersion: "0.148.0",
612646 update: {
613 command:
614 "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest",
615
616 executable: "npm",
617
618 args: [
619 "install",
620 "-g",
621 "--allow-scripts=@example/package-tool",
622 "@example/package-tool@latest",
623 ],
624
625 lockKey: "npm-global",
647 command: "brew upgrade --cask package-tool",
648 executable: brewPath,
649 args: ["upgrade", "--cask", "package-tool"],
650 lockKey: "homebrew",
626651 },
627652 });
628653 }),
629654 );
630655
631 it.effect.skipIf(!symlinksSupported)(
632 "uses Effect FileSystem realPath when detecting pnpm global symlinks",
656 it.effect.skipIf(windowsHost)(
657 "stays manual-only when the keg is not under the resolved brew's prefix",
633658 () =>
634659 Effect.gen(function* () {
635 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");
636 const binDir = NodePath.join(tempDir, "bin");
637 const packageBinDir = NodePath.join(
660 const tempDir = yield* makeTempDir("t3-homebrew-foreign-prefix");
661 const brewBinDir = NodePath.join(tempDir, "brew-bin");
662 writeExecutable(NodePath.join(brewBinDir, "brew"));
663 const kegBinary = NodePath.join(
638664 tempDir,
639 ".local",
640 "share",
641 "pnpm",
642 "global",
643 "5",
644 "node_modules",
645 "@example",
665 "elsewhere",
666 "Cellar",
646667 "package-tool",
668 "1.0.0",
647669 "bin",
670 "package-tool",
648671 );
649 NodeFS.mkdirSync(binDir, { recursive: true });
650 NodeFS.mkdirSync(packageBinDir, { recursive: true });
651 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");
652 const symlinkPath = NodePath.join(binDir, "package-tool");
653 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");
654 NodeFS.chmodSync(packageBinPath, 0o755);
655 NodeFS.symlinkSync(packageBinPath, symlinkPath);
672 writeExecutable(kegBinary);
656673
657674 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
658675 packageToolUpdate,
659676 {
660 binaryPath: symlinkPath,
661 env: {
662 PATH: "",
663 },
677 binaryPath: kegBinary,
678 env: { PATH: brewBinDir },
664679 },
680 ).pipe(
681 Effect.provideService(HostProcessPlatform, "darwin"),
682 Effect.provideService(
683 ChildProcessSpawner.ChildProcessSpawner,
684 stdoutSpawner(() => "/opt/homebrew\n"),
685 ),
665686 );
666687
667 expect(capabilities).toEqual({
668 provider: driver("packageTool"),
669 packageName: "@example/package-tool",
670 update: {
671 command: "pnpm add -g @example/package-tool@latest",
672
673 executable: "pnpm",
674
675 args: ["add", "-g", "@example/package-tool@latest"],
676
677 lockKey: "pnpm-global",
678 },
679 });
688 expect(capabilities).toEqual(manualPackageTool);
680689 }),
681690 );
682691
683 it("allows the package's own install scripts in npm global updates", () => {
684 const claudeUpdate = makePackageManagedProviderMaintenanceResolver({
685 provider: driver("claudeAgent"),
686 npmPackageName: "@anthropic-ai/claude-code",
687 homebrewFormula: "claude-code",
688 nativeUpdate: {
689 executable: "claude",
690 args: ["update"],
691 lockKey: "claude-native",
692 isCommandPath: isNativeTestCommandPath("/.local/bin/claude"),
693 },
694 });
695
696 expect(claudeUpdate.resolve()).toEqual({
697 provider: driver("claudeAgent"),
698 packageName: "@anthropic-ai/claude-code",
699 update: {
700 command:
701 "npm install -g --allow-scripts=@anthropic-ai/claude-code @anthropic-ai/claude-code@latest",
702
703 executable: "npm",
692 it("reads the stable formula version from brew info", () => {
693 const info = JSON.stringify({ formulae: [{ versions: { stable: "2.1.5" } }] });
694 const formula = { kind: "formula", name: "claude-code", prefix: "/opt/homebrew" } as const;
695 expect(parseHomebrewLatestVersion(info, formula)).toBe("2.1.5");
696 expect(parseHomebrewLatestVersion("not json", formula)).toBeNull();
697 });
704698
705 args: [
706 "install",
707 "-g",
708 "--allow-scripts=@anthropic-ai/claude-code",
709 "@anthropic-ai/claude-code@latest",
710 ],
699 it.effect.skipIf(windowsHost)(
700 "disables one-click updates for explicit custom binary paths it cannot safely map",
701 () =>
702 Effect.gen(function* () {
703 const tempDir = yield* makeTempDir("t3-custom-capabilities");
704 const customPath = NodePath.join(tempDir, "tools", "package-tool");
705 writeExecutable(customPath);
711706
712 lockKey: "npm-global",
713 },
714 });
715 });
707 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
708 packageToolUpdate,
709 {
710 binaryPath: customPath,
711 env: { PATH: "" },
712 },
713 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
716714
717 it("disables one-click updates for explicit custom binary paths it cannot safely map", () => {
718 expect(
719 packageToolUpdate.resolve({
720 binaryPath: "C:\\Tools\\package-tool\\package-tool.exe",
721 env: {
722 PATH: "",
723 PATHEXT: ".COM;.EXE;.BAT;.CMD",
724 },
715 expect(capabilities).toEqual(manualPackageTool);
725716 }),
726 ).toEqual({
727 provider: driver("packageTool"),
728 packageName: "@example/package-tool",
729 update: null,
730 });
731 });
717 );
718
719 it.effect("caches resolution until a fresh read is requested", () =>
720 Effect.gen(function* () {
721 let resolutions = 0;
722 const resolve = yield* makeCachedProviderMaintenanceResolution(
723 Effect.sync(() => {
724 resolutions += 1;
725 return manualPackageTool;
726 }),
727 );
728 yield* resolve();
729 yield* resolve();
730 expect(resolutions).toBe(1);
731 yield* resolve({ fresh: true });
732 yield* resolve();
733 expect(resolutions).toBe(2);
734 }),
735 );
732736});
apps/server/src/provider/providerMaintenance.ts+384-215
......@@ -4,21 +4,37 @@ import {
44 type ServerProviderVersionAdvisory,
55} from "@t3tools/contracts";
66import { compareSemverVersions } from "@t3tools/shared/semver";
7import { resolveCommandPath } from "@t3tools/shared/shell";
87import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { causeErrorTag } from "@t3tools/shared/observability";
9import { resolveCommandPath } from "@t3tools/shared/shell";
910import * as Config from "effect/Config";
1011import * as Context from "effect/Context";
1112import * as DateTime from "effect/DateTime";
13import * as Duration from "effect/Duration";
1214import * as Effect from "effect/Effect";
1315import * as FileSystem from "effect/FileSystem";
1416import * as Option from "effect/Option";
17import * as Path from "effect/Path";
1518import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
1620import { HttpClient, HttpClientRequest } from "effect/unstable/http";
21import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
22
23import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1724
1825const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
1926const LATEST_VERSION_TIMEOUT_MS = 4_000;
27const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
28const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
2029const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
2130
31/**
32 * Ownership is re-derived from the executable this often. Installs do not
33 * move on their own, so this mostly bounds how stale a Homebrew "latest" can
34 * get; the npm registry check keeps its own cache.
35 */
36export const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1);
37
2238const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
2339 Object.fromEntries(
2440 Object.entries(input).flatMap(([key, value]) =>
......@@ -42,6 +58,13 @@ export interface ProviderMaintenanceCapabilities {
4258 readonly provider: ProviderDriverKind;
4359 readonly packageName: string | null;
4460 readonly update: ProviderMaintenanceCommandAction | null;
61 /**
62 * Latest version reported by the installer that owns the executable.
63 * `undefined` means the installer has no channel of its own and the npm
64 * registry entry for `packageName` is authoritative; `null` means the
65 * installer was asked and did not know.
66 */
67 readonly latestVersion?: string | null;
4568}
4669
4770export interface ProviderMaintenanceCommandAction {
......@@ -49,31 +72,44 @@ export interface ProviderMaintenanceCommandAction {
4972 readonly executable: string;
5073 readonly args: ReadonlyArray<string>;
5174 readonly lockKey: string;
75 /**
76 * Extra environment for the spawned updater, on top of the server's own.
77 * A native updater finds its install through the same variables the
78 * provider runs with (e.g. `CODEX_HOME`), so an instance with a custom home
79 * must update that home and not the default one.
80 */
81 readonly env?: NodeJS.ProcessEnv;
5282}
5383
54export interface ProviderMaintenanceCapabilityResolutionOptions {
55 readonly binaryPath?: string | null;
56 readonly platform?: NodeJS.Platform;
57 readonly env?: NodeJS.ProcessEnv;
58 readonly resolvedCommandPath?: string | null;
59 readonly realCommandPath?: string | null;
84/** Where the provider executable was found; every path is absolute. */
85export interface ProviderMaintenanceResolutionContext {
86 readonly binaryPath: string;
87 readonly resolvedCommandPath: string;
88 readonly realCommandPath: string;
89 readonly env: NodeJS.ProcessEnv;
90 /** Host platform; decides how the copyable command quotes the executable. */
91 readonly platform: NodeJS.Platform;
6092}
6193
94export type ProviderMaintenanceResolverServices =
95 | FileSystem.FileSystem
96 | Path.Path
97 | ChildProcessSpawner.ChildProcessSpawner;
98
6299export interface ProviderMaintenanceCapabilitiesResolver {
63100 readonly resolve: (
64 options?: ProviderMaintenanceCapabilityResolutionOptions,
65 ) => ProviderMaintenanceCapabilities;
101 context: ProviderMaintenanceResolutionContext | null,
102 ) => Effect.Effect<ProviderMaintenanceCapabilities, never, ProviderMaintenanceResolverServices>;
66103}
67104
68105export interface PackageManagedProviderMaintenanceDefinition {
69106 readonly provider: ProviderDriverKind;
70107 readonly npmPackageName: string;
71 readonly homebrewFormula: string | null;
72108 readonly nativeUpdate: {
73 readonly executable: string;
74109 readonly args: ReadonlyArray<string>;
75 readonly lockKey: string;
76110 readonly isCommandPath: (commandPath: string) => boolean;
111 /** Environment the native updater needs to target this instance's install. */
112 readonly env?: NodeJS.ProcessEnv;
77113 } | null;
78114}
79115
......@@ -96,13 +132,22 @@ function nonEmptyString(value: unknown): string | null {
96132 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
97133}
98134
135/**
136 * The copyable command must paste into a shell as-is, so an executable path
137 * with spaces or quotes is quoted for the host's default shell.
138 */
139function quoteShellWord(word: string, platform: NodeJS.Platform): string {
140 const safeWord = platform === "win32" ? /^[\w./:\\@=-]+$/ : /^[\w./:@=-]+$/;
141 if (safeWord.test(word)) return word;
142 return platform === "win32"
143 ? `'${word.replace(/['\u2018\u2019]/g, "$&$&")}'`
144 : `'${word.replaceAll("'", "'\\''")}'`;
145}
146
99147function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {
100 const safePath = platform === "win32" ? /^[\w./:\\-]+$/ : /^[\w./:-]+$/;
101 if (safePath.test(executable)) return executable;
148 const quoted = quoteShellWord(executable, platform);
102149 // Windows terminals default to PowerShell, where a quoted executable needs &.
103 return platform === "win32"
104 ? `& '${executable.replace(/['\u2018\u2019]/g, "$&$&")}'`
105 : `'${executable.replaceAll("'", "'\\''")}'`;
150 return platform === "win32" && quoted !== executable ? `& ${quoted}` : quoted;
106151}
107152
108153export function makeProviderMaintenanceCapabilities(input: {
......@@ -111,27 +156,33 @@ export function makeProviderMaintenanceCapabilities(input: {
111156 readonly updateExecutable: string | null;
112157 readonly updateArgs: ReadonlyArray<string>;
113158 readonly updateLockKey: string | null;
159 /** Shown to the user instead of `<executable> <args>`; use for a bare tool name like `brew`. */
160 readonly updateCommand?: string;
114161 readonly platform?: NodeJS.Platform;
162 readonly env?: NodeJS.ProcessEnv;
163 readonly latestVersion?: string | null;
115164}): ProviderMaintenanceCapabilities {
165 const platform = input.platform ?? HostProcessPlatform.defaultValue();
116166 const update =
117167 input.updateExecutable === null || input.updateLockKey === null
118168 ? null
119169 : {
120 command: [
121 quoteUpdateExecutable(
122 input.updateExecutable,
123 input.platform ?? HostProcessPlatform.defaultValue(),
124 ),
125 ...input.updateArgs,
126 ].join(" "),
170 command:
171 input.updateCommand ??
172 [
173 quoteUpdateExecutable(input.updateExecutable, platform),
174 ...input.updateArgs.map((arg) => quoteShellWord(arg, platform)),
175 ].join(" "),
127176 executable: input.updateExecutable,
128177 args: input.updateArgs,
129178 lockKey: input.updateLockKey,
179 ...(input.env ? { env: input.env } : {}),
130180 };
131181 return {
132182 provider: input.provider,
133183 packageName: input.packageName,
134184 update,
185 ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}),
135186 };
136187}
137188
......@@ -148,118 +199,18 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
148199 });
149200}
150201
151function makeNpmGlobalProviderMaintenanceCapabilities(
152 definition: PackageManagedProviderMaintenanceDefinition,
153): ProviderMaintenanceCapabilities {
154 return makeProviderMaintenanceCapabilities({
155 provider: definition.provider,
156 packageName: definition.npmPackageName,
157 updateExecutable: "npm",
158 // npm 12 blocks install scripts by default (empty allow-scripts allowlist)
159 // and still exits 0, so a package whose postinstall finishes the install
160 // (claude copies its native binary over a placeholder stub) is left broken
161 // while the update reports success. Allow this one package's scripts.
162 // Older npm warns about the unknown config and continues.
163 updateArgs: [
164 "install",
165 "-g",
166 `--allow-scripts=${definition.npmPackageName}`,
167 `${definition.npmPackageName}@latest`,
168 ],
169 updateLockKey: "npm-global",
170 });
171}
172
173function makeBunGlobalProviderMaintenanceCapabilities(
174 definition: PackageManagedProviderMaintenanceDefinition,
175): ProviderMaintenanceCapabilities {
176 return makeProviderMaintenanceCapabilities({
177 provider: definition.provider,
178 packageName: definition.npmPackageName,
179 updateExecutable: "bun",
180 updateArgs: ["i", "-g", `${definition.npmPackageName}@latest`],
181 updateLockKey: "bun-global",
182 });
183}
184
185function makePnpmGlobalProviderMaintenanceCapabilities(
186 definition: PackageManagedProviderMaintenanceDefinition,
187): ProviderMaintenanceCapabilities {
188 return makeProviderMaintenanceCapabilities({
189 provider: definition.provider,
190 packageName: definition.npmPackageName,
191 updateExecutable: "pnpm",
192 updateArgs: ["add", "-g", `${definition.npmPackageName}@latest`],
193 updateLockKey: "pnpm-global",
194 });
195}
196
197function makeVitePlusGlobalProviderMaintenanceCapabilities(
198 definition: PackageManagedProviderMaintenanceDefinition,
199): ProviderMaintenanceCapabilities {
200 return makeProviderMaintenanceCapabilities({
201 provider: definition.provider,
202 packageName: definition.npmPackageName,
203 updateExecutable: "vp",
204 updateArgs: ["i", "-g", definition.npmPackageName],
205 updateLockKey: "vite-plus-global",
206 });
207}
208
209function makeHomebrewProviderMaintenanceCapabilities(
210 definition: PackageManagedProviderMaintenanceDefinition,
211): ProviderMaintenanceCapabilities {
212 if (!definition.homebrewFormula) {
213 return makeManualOnlyProviderMaintenanceCapabilities({
214 provider: definition.provider,
215 packageName: definition.npmPackageName,
216 });
217 }
218
219 return makeProviderMaintenanceCapabilities({
220 provider: definition.provider,
221 packageName: definition.npmPackageName,
222 updateExecutable: "brew",
223 updateArgs: ["upgrade", definition.homebrewFormula],
224 updateLockKey: "homebrew",
225 });
226}
227
228function makeNativeProviderMaintenanceCapabilities(
229 definition: PackageManagedProviderMaintenanceDefinition,
230 commandPath: string,
231 platform: NodeJS.Platform,
232): ProviderMaintenanceCapabilities | null {
233 if (!definition.nativeUpdate) {
234 return null;
235 }
236
237 return makeProviderMaintenanceCapabilities({
238 provider: definition.provider,
239 packageName: definition.npmPackageName,
240 updateExecutable: commandPath,
241 updateArgs: definition.nativeUpdate.args,
242 updateLockKey: definition.nativeUpdate.lockKey,
243 platform,
244 });
245}
246
247export function hasPathSeparator(value: string): boolean {
248 return value.includes("/") || value.includes("\\");
249}
250
251202export function normalizeCommandPath(commandPath: string): string {
252203 return commandPath.replaceAll("\\", "/").toLowerCase();
253204}
254205
255function isBunGlobalCommandPath(commandPath: string): boolean {
256 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
257}
258
259206function isVitePlusGlobalCommandPath(commandPath: string): boolean {
260207 return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");
261208}
262209
210function isBunGlobalCommandPath(commandPath: string): boolean {
211 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
212}
213
263214function isPnpmGlobalCommandPath(commandPath: string): boolean {
264215 const normalized = normalizeCommandPath(commandPath);
265216 return (
......@@ -271,100 +222,287 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean {
271222 );
272223}
273224
274function isNpmGlobalCommandPath(commandPath: string): boolean {
275 const normalized = normalizeCommandPath(commandPath);
276 return (
277 normalized.includes("/node_modules/.bin/") ||
278 normalized.includes("/lib/node_modules/") ||
279 normalized.includes("/npm/node_modules/")
280 );
225/**
226 * The npm global prefix that owns a package, derived from the real path of
227 * its entry point: `<prefix>/lib/node_modules/<pkg>/…`. Windows global
228 * installs have no `lib` segment and are proven by the shim instead (see
229 * `resolveNpmGlobalPrefix`). A project-local `node_modules` is not a global
230 * install and yields null.
231 */
232export function npmGlobalPrefixFromCommandPath(
233 realCommandPath: string,
234 packageName: string,
235): string | null {
236 const slashPath = realCommandPath.replaceAll("\\", "/");
237 const normalized = slashPath.toLowerCase();
238 const packageSegment = `/lib/node_modules/${packageName.toLowerCase()}/`;
239 const packageIndex = normalized.lastIndexOf(packageSegment);
240 if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) {
241 return null;
242 }
243 return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex);
281244}
282245
283function isHomebrewCommandPath(commandPath: string): boolean {
284 const normalized = normalizeCommandPath(commandPath);
285 return (
286 normalized.includes("/opt/homebrew/cellar/") ||
287 normalized.includes("/usr/local/cellar/") ||
288 normalized.includes("/homebrew/cellar/") ||
289 normalized.includes("/opt/homebrew/caskroom/") ||
290 normalized.includes("/usr/local/caskroom/") ||
291 normalized.includes("/homebrew/caskroom/") ||
292 normalized.startsWith("/opt/homebrew/bin/") ||
293 normalized.startsWith("/usr/local/bin/")
294 );
246// `<prefix>/Cellar/<name>/<version>/…` or `<prefix>/Caskroom/<name>/<version>/…`.
247// Homebrew always nests a version directory under the keg.
248const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i;
249
250export interface HomebrewOwnership {
251 readonly kind: "formula" | "cask";
252 readonly name: string;
253 /** The Homebrew prefix the keg sits under; must match `brew --prefix`. */
254 readonly prefix: string;
255}
256
257/**
258 * Homebrew looks like the owner when the real path runs through a versioned
259 * keg or cask. It is only proven once the prefix matches the `brew` that will
260 * run the upgrade (see `resolvePackageManagedProviderMaintenance`).
261 */
262export function homebrewOwnershipFromCommandPath(
263 realCommandPath: string,
264): HomebrewOwnership | null {
265 const match = HOMEBREW_KEG_PATTERN.exec(realCommandPath.replaceAll("\\", "/"));
266 if (!match) {
267 return null;
268 }
269 return {
270 kind: match[2]!.toLowerCase() === "cellar" ? "formula" : "cask",
271 name: match[3]!,
272 prefix: match[1]!,
273 };
295274}
296275
297export function resolvePackageManagedProviderMaintenance(
276const HomebrewInfoResponse = Schema.Struct({
277 formulae: Schema.optional(
278 Schema.Array(
279 Schema.Struct({
280 versions: Schema.optional(Schema.Struct({ stable: Schema.optional(Schema.String) })),
281 }),
282 ),
283 ),
284 casks: Schema.optional(Schema.Array(Schema.Struct({ version: Schema.optional(Schema.String) }))),
285});
286
287const decodeHomebrewInfo = Schema.decodeUnknownOption(Schema.fromJsonString(HomebrewInfoResponse));
288
289/** Cask versions may carry a build suffix after a comma (`1.2.3,456`). */
290export function parseHomebrewLatestVersion(
291 infoJson: string,
292 ownership: HomebrewOwnership,
293): string | null {
294 const decoded = decodeHomebrewInfo(infoJson);
295 if (Option.isNone(decoded)) {
296 return null;
297 }
298 const raw =
299 ownership.kind === "formula"
300 ? decoded.value.formulae?.[0]?.versions?.stable
301 : decoded.value.casks?.[0]?.version?.split(",", 1)[0];
302 return nonEmptyString(raw);
303}
304
305/** Run `brew <args>` and return stdout, or null on failure, timeout, or oversized output. */
306const runHomebrew = Effect.fn("runHomebrew")(function* (
307 brewPath: string,
308 args: ReadonlyArray<string>,
309 env: NodeJS.ProcessEnv,
310) {
311 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
312 const collect = Effect.gen(function* () {
313 const child = yield* spawner.spawn(ChildProcess.make(brewPath, args, { env, extendEnv: true }));
314 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
315 // stderr is drained so a chatty brew cannot block on a full pipe.
316 const [stdout, exitCode] = yield* Effect.all(
317 [
318 collectUint8StreamText({ stream: child.stdout, maxBytes: HOMEBREW_INFO_MAX_BYTES }),
319 child.exitCode,
320 Stream.runDrain(child.stderr),
321 ],
322 { concurrency: "unbounded" },
323 );
324 return Number(exitCode) !== 0 || stdout.truncated ? null : stdout.text;
325 });
326 return yield* collect.pipe(
327 Effect.scoped,
328 Effect.timeoutOption(Duration.millis(HOMEBREW_INFO_TIMEOUT_MS)),
329 Effect.map(Option.getOrNull),
330 Effect.catchCause((cause) =>
331 Effect.logWarning("Homebrew probe failed", {
332 subcommand: args[0],
333 errorTag: causeErrorTag(cause),
334 }).pipe(Effect.as(null)),
335 ),
336 );
337});
338
339/**
340 * Derive update capabilities from where the executable actually lives. Every
341 * branch that yields a one-click command has evidence that the named tool
342 * owns that path; anything unproven stays manual-only so T3 Code never runs
343 * a package manager against an install it did not create.
344 */
345export const resolvePackageManagedProviderMaintenance = Effect.fn(
346 "resolvePackageManagedProviderMaintenance",
347)(function* (
298348 definition: PackageManagedProviderMaintenanceDefinition,
299 options?: ProviderMaintenanceCapabilityResolutionOptions,
300): ProviderMaintenanceCapabilities {
301 const binaryPath = nonEmptyString(options?.binaryPath);
302 if (!binaryPath) {
303 return makeNpmGlobalProviderMaintenanceCapabilities(definition);
349 context: ProviderMaintenanceResolutionContext | null,
350) {
351 const manual = makeManualOnlyProviderMaintenanceCapabilities({
352 provider: definition.provider,
353 packageName: definition.npmPackageName,
354 });
355 if (!context) {
356 return manual;
304357 }
358 const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
359 const packageName = definition.npmPackageName;
305360
306 const resolvedCommandPath =
307 options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
361 const nativeUpdate = definition.nativeUpdate;
362 if (nativeUpdate && commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))) {
363 return makeProviderMaintenanceCapabilities({
364 provider: definition.provider,
365 packageName,
366 updateExecutable: context.resolvedCommandPath,
367 updateArgs: nativeUpdate.args,
368 updateLockKey: `${definition.provider}-native`,
369 platform: context.platform,
370 ...(nativeUpdate.env ? { env: nativeUpdate.env } : {}),
371 });
372 }
373 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
374 return makeProviderMaintenanceCapabilities({
375 provider: definition.provider,
376 packageName,
377 updateExecutable: "vp",
378 updateArgs: ["i", "-g", packageName],
379 updateLockKey: "vite-plus-global",
380 });
381 }
382 if (commandPaths.some(isBunGlobalCommandPath)) {
383 return makeProviderMaintenanceCapabilities({
384 provider: definition.provider,
385 packageName,
386 updateExecutable: "bun",
387 updateArgs: ["i", "-g", `${packageName}@latest`],
388 updateLockKey: "bun-global",
389 });
390 }
391 if (commandPaths.some(isPnpmGlobalCommandPath)) {
392 return makeProviderMaintenanceCapabilities({
393 provider: definition.provider,
394 packageName,
395 updateExecutable: "pnpm",
396 updateArgs: ["add", "-g", `${packageName}@latest`],
397 updateLockKey: "pnpm-global",
398 });
399 }
308400
309 if (resolvedCommandPath) {
310 const commandPaths = [
311 resolvedCommandPath,
312 ...(options?.realCommandPath ? [options.realCommandPath] : []),
313 ];
401 // npm proof names the package, so it outranks a keg the path merely passes
402 // through: a Homebrew-installed Node keeps its globals under
403 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.
404 const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);
405 if (npmPrefix) {
406 // npm 12 blocks install scripts by default (empty allow-scripts allowlist)
407 // and still exits 0, so a package whose postinstall finishes the install
408 // (claude copies its native binary over a placeholder stub) is left broken
409 // while the update reports success. Allow this one package's scripts.
410 // Older npm warns about the unknown config and continues.
411 return makeProviderMaintenanceCapabilities({
412 provider: definition.provider,
413 packageName,
414 updateExecutable: "npm",
415 updateArgs: [
416 "install",
417 "-g",
418 "--prefix",
419 npmPrefix,
420 `--allow-scripts=${packageName}`,
421 `${packageName}@latest`,
422 ],
423 updateLockKey: `npm-global:${normalizeCommandPath(npmPrefix)}`,
424 });
425 }
314426
315 const nativeUpdate = definition.nativeUpdate;
427 const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath);
428 if (homebrew) {
429 const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe(
430 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
431 );
432 if (!brewPath) {
433 return manual;
434 }
435 // A keg-shaped path is only Homebrew's if it sits under the prefix of the
436 // `brew` that would upgrade it; `brew --prefix` is a cheap shell script.
437 const fileSystem = yield* FileSystem.FileSystem;
438 const brewPrefix = nonEmptyString(yield* runHomebrew(brewPath, ["--prefix"], context.env));
439 const realBrewPrefix = brewPrefix
440 ? yield* fileSystem.realPath(brewPrefix).pipe(Effect.orElseSucceed(() => brewPrefix))
441 : null;
316442 if (
317 nativeUpdate &&
318 commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))
443 !realBrewPrefix ||
444 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
319445 ) {
320 return (
321 makeNativeProviderMaintenanceCapabilities(
322 definition,
323 resolvedCommandPath,
324 options?.platform ?? HostProcessPlatform.defaultValue(),
325 ) ?? makeNpmGlobalProviderMaintenanceCapabilities(definition)
326 );
327 }
328 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
329 return makeVitePlusGlobalProviderMaintenanceCapabilities(definition);
330 }
331 if (commandPaths.some(isBunGlobalCommandPath)) {
332 return makeBunGlobalProviderMaintenanceCapabilities(definition);
333 }
334 if (commandPaths.some(isPnpmGlobalCommandPath)) {
335 return makePnpmGlobalProviderMaintenanceCapabilities(definition);
336 }
337 if (commandPaths.some(isNpmGlobalCommandPath)) {
338 return makeNpmGlobalProviderMaintenanceCapabilities(definition);
339 }
340 if (commandPaths.some(isHomebrewCommandPath)) {
341 return makeHomebrewProviderMaintenanceCapabilities(definition);
446 return manual;
342447 }
448 const args =
449 homebrew.kind === "cask" ? ["upgrade", "--cask", homebrew.name] : ["upgrade", homebrew.name];
450 // Homebrew lags npm by hours on every release, so compare against what
451 // `brew upgrade` can actually deliver.
452 const info = yield* runHomebrew(brewPath, ["info", "--json=v2", homebrew.name], context.env);
453 return makeProviderMaintenanceCapabilities({
454 provider: definition.provider,
455 packageName,
456 updateExecutable: brewPath,
457 updateArgs: args,
458 updateLockKey: "homebrew",
459 updateCommand: ["brew", ...args].join(" "),
460 latestVersion: info ? parseHomebrewLatestVersion(info, homebrew) : null,
461 });
343462 }
344463
345 if (!hasPathSeparator(binaryPath)) {
346 return makeNpmGlobalProviderMaintenanceCapabilities(definition);
347 }
464 return manual;
465});
348466
349 return makeManualOnlyProviderMaintenanceCapabilities({
350 provider: definition.provider,
351 packageName: definition.npmPackageName,
352 });
353}
467/**
468 * POSIX npm links `<prefix>/bin/<cmd>` into the package, so the real path is
469 * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof
470 * is the package manifest next to the shim.
471 */
472const resolveNpmGlobalPrefix = Effect.fn("resolveNpmGlobalPrefix")(function* (
473 context: ProviderMaintenanceResolutionContext,
474 packageName: string,
475) {
476 const fromRealPath = npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName);
477 if (fromRealPath) {
478 return fromRealPath;
479 }
480 if ((yield* HostProcessPlatform) !== "win32") {
481 return null;
482 }
483 const fileSystem = yield* FileSystem.FileSystem;
484 const path = yield* Path.Path;
485 const shimDir = path.dirname(context.resolvedCommandPath);
486 const manifestPath = path.join(
487 shimDir,
488 "node_modules",
489 ...packageName.split("/"),
490 "package.json",
491 );
492 // npm writes both `<cmd>.cmd` and an extensionless sh script into the
493 // Windows prefix; either one sits directly beside `node_modules`. A POSIX
494 // project checkout has the same shape, which is why this is Windows-only.
495 const hasManifest = yield* fileSystem
496 .exists(manifestPath)
497 .pipe(Effect.orElseSucceed(() => false));
498 return hasManifest ? shimDir : null;
499});
354500
355501export function makePackageManagedProviderMaintenanceResolver(
356502 definition: PackageManagedProviderMaintenanceDefinition,
357503): ProviderMaintenanceCapabilitiesResolver {
358504 return {
359 resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options),
360 };
361}
362
363export function makeStaticProviderMaintenanceResolver(
364 capabilities: ProviderMaintenanceCapabilities,
365): ProviderMaintenanceCapabilitiesResolver {
366 return {
367 resolve: () => capabilities,
505 resolve: (context) => resolvePackageManagedProviderMaintenance(definition, context),
368506 };
369507}
370508
......@@ -377,40 +515,68 @@ function makeManualProviderMaintenanceCapabilities(
377515 });
378516}
379517
518/**
519 * Locate the configured provider executable, follow symlinks, and hand the
520 * result to the resolver. A binary that cannot be found yields the resolver's
521 * no-context answer.
522 */
380523export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
381524 "resolveProviderMaintenanceCapabilitiesEffect",
382525)(function* (
383526 resolver: ProviderMaintenanceCapabilitiesResolver,
384 options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">,
527 options?: {
528 readonly binaryPath?: string | null;
529 readonly env?: NodeJS.ProcessEnv;
530 },
385531) {
386532 const binaryPath = nonEmptyString(options?.binaryPath);
387 const platform = options?.platform ?? (yield* HostProcessPlatform);
388533 if (!binaryPath) {
389 return resolver.resolve({ ...options, platform });
534 return yield* resolver.resolve(null);
390535 }
391536
392537 const env = options?.env ?? (yield* readCommandLookupEnv);
393 const resolvedCommandPath =
394 (yield* resolveCommandPath(binaryPath, { env }).pipe(
395 Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)),
396 )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
538 // resolveCommandPath checks explicit paths for existence too, so a missing
539 // binary always lands in the no-context branch and never gets an update
540 // command it cannot run.
541 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }).pipe(
542 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
543 );
397544 if (!resolvedCommandPath) {
398 return resolver.resolve({ ...options, platform });
545 return yield* resolver.resolve(null);
399546 }
400547
401548 const fileSystem = yield* FileSystem.FileSystem;
402549 const realCommandPath = yield* fileSystem
403550 .realPath(resolvedCommandPath)
404 .pipe(Effect.orElseSucceed(() => resolvedCommandPath));
405 return resolver.resolve({
406 ...options,
407 platform,
408 env,
551 .pipe(Effect.orElseSucceed(() => null));
552 if (!realCommandPath) {
553 return yield* resolver.resolve(null);
554 }
555 return yield* resolver.resolve({
556 binaryPath,
409557 resolvedCommandPath,
410558 realCommandPath,
559 env,
560 platform: yield* HostProcessPlatform,
411561 });
412562});
413563
564/**
565 * Turn a one-shot resolution into the shape drivers expose: a cached read for
566 * advisories and a `fresh` read that update execution uses so it never trusts
567 * ownership derived before the user clicked.
568 */
569export const makeCachedProviderMaintenanceResolution = Effect.fn(
570 "makeCachedProviderMaintenanceResolution",
571)(function* (resolve: Effect.Effect<ProviderMaintenanceCapabilities>) {
572 const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL(
573 resolve,
574 MAINTENANCE_CAPABILITIES_CACHE_TTL,
575 );
576 return (options?: { readonly fresh?: boolean }) =>
577 options?.fresh ? invalidate.pipe(Effect.andThen(cached)) : cached;
578});
579
414580function deriveVersionAdvisory(input: {
415581 readonly currentVersion: string | null;
416582 readonly latestVersion: string | null;
......@@ -482,6 +648,9 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack
482648export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (
483649 maintenanceCapabilities: ProviderMaintenanceCapabilities,
484650) {
651 if (maintenanceCapabilities.latestVersion !== undefined) {
652 return maintenanceCapabilities.latestVersion;
653 }
485654 const packageName = maintenanceCapabilities.packageName;
486655 if (!packageName) {
487656 return null;
apps/server/src/provider/providerMaintenanceRunner.test.ts+185-3
......@@ -70,7 +70,7 @@ const baseProvider: ServerProvider = {
7070 driver: CODEX_DRIVER,
7171 enabled: true,
7272 installed: true,
73 version: null,
73 version: "0.0.0",
7474 status: "ready",
7575 auth: { status: "authenticated" },
7676 checkedAt: "2026-04-10T00:00:00.000Z",
......@@ -129,6 +129,7 @@ function mockSpawnerLayer(
129129 handler: (
130130 command: string,
131131 args: ReadonlyArray<string>,
132 options: { readonly env?: NodeJS.ProcessEnv | undefined },
132133 ) => {
133134 readonly stdout?: string;
134135 readonly stderr?: string;
......@@ -142,8 +143,11 @@ function mockSpawnerLayer(
142143 const childProcess = command as unknown as {
143144 readonly command: string;
144145 readonly args: ReadonlyArray<string>;
146 readonly options: { readonly env?: NodeJS.ProcessEnv | undefined };
145147 };
146 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
148 return Effect.succeed(
149 mockHandle(handler(childProcess.command, childProcess.args, childProcess.options)),
150 );
147151 }),
148152 );
149153}
......@@ -198,6 +202,7 @@ function makeRegistry(
198202
199203 return {
200204 registry,
205 providersRef,
201206 updateStatesRef,
202207 };
203208 });
......@@ -210,7 +215,8 @@ const makeTestRunner = (registry: ProviderRegistryShape) =>
210215 Layer.provide(
211216 Layer.mergeAll(
212217 Layer.succeed(ProviderRegistry, registry),
213 Layer.succeed(ProviderVersionCache, new Map()),
218 // Fresh per runner so a version cached by one test cannot leak into another.
219 Layer.sync(ProviderVersionCache, () => new Map()),
214220 ),
215221 ),
216222 ),
......@@ -250,6 +256,182 @@ describe("providerMaintenanceRunner", () => {
250256 );
251257 });
252258
259 it.effect("reports unchanged when the updater exits 0 but the provider is gone", () => {
260 return Effect.gen(function* () {
261 const { registry, providersRef } = yield* makeRegistry(baseProvider);
262 // After the update, the refreshed snapshot no longer sees an install.
263 const updater = yield* makeTestRunner({
264 ...registry,
265 refreshInstance: () =>
266 Ref.updateAndGet(providersRef, (providers) =>
267 providers.map((provider) => ({ ...provider, installed: false, version: null })),
268 ),
269 });
270
271 const result = yield* updater.updateProvider(CODEX_DRIVER);
272 assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged");
273 assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/);
274 }).pipe(
275 Effect.provide(
276 Layer.mergeAll(
277 NonWindowsPlatform,
278 latestVersionHttpClient("0.0.0"),
279 mockSpawnerLayer(() => ({ stdout: "updated" })),
280 ),
281 ),
282 );
283 });
284
285 it.effect(
286 "keeps a successful update when the binary is present but its version is unreadable",
287 () => {
288 return Effect.gen(function* () {
289 const { registry, providersRef } = yield* makeRegistry(baseCursorProvider);
290 // Cursor's `agent about` probe can fail right after an update while the
291 // new binary is perfectly fine.
292 const updater = yield* makeTestRunner({
293 ...registry,
294 refreshInstance: () =>
295 Ref.updateAndGet(providersRef, (providers) =>
296 providers.map((provider) => ({ ...provider, installed: true, version: null })),
297 ),
298 });
299
300 const result = yield* updater.updateProvider(CURSOR_DRIVER);
301 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
302 }).pipe(
303 Effect.provide(
304 Layer.mergeAll(
305 NonWindowsPlatform,
306 latestVersionHttpClient("0.0.0"),
307 mockSpawnerLayer(() => ({ stdout: "updated" })),
308 ),
309 ),
310 );
311 },
312 );
313
314 it.effect("spawns the updater with the environment its capabilities declare", () => {
315 const seen: Array<NodeJS.ProcessEnv | undefined> = [];
316 return Effect.gen(function* () {
317 const { registry } = yield* makeRegistry(baseProvider);
318 const updater = yield* makeTestRunner({
319 ...registry,
320 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
321 Effect.succeed({
322 ...lifecycleFor(provider),
323 update: {
324 command: "codex update",
325 executable: "/work/codex-home/packages/standalone/bin/codex",
326 args: ["update"],
327 lockKey: "codex-native",
328 env: { CODEX_HOME: "/work/codex-home" },
329 },
330 }),
331 });
332
333 yield* updater.updateProvider(CODEX_DRIVER);
334 assert.deepStrictEqual(seen, [{ CODEX_HOME: "/work/codex-home" }]);
335 }).pipe(
336 Effect.provide(
337 Layer.mergeAll(
338 NonWindowsPlatform,
339 latestVersionHttpClient("0.0.0"),
340 mockSpawnerLayer((_command, _args, options) => {
341 seen.push(options.env);
342 return { stdout: "updated" };
343 }),
344 ),
345 ),
346 );
347 });
348
349 it.effect("re-resolves ownership before running and executes the fresh command", () => {
350 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
351 const fresh: Array<boolean> = [];
352 return Effect.gen(function* () {
353 const { registry } = yield* makeRegistry(baseProvider);
354 const updater = yield* makeTestRunner({
355 ...registry,
356 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider, options) => {
357 fresh.push(options?.fresh === true);
358 return Effect.succeed(
359 makeProviderMaintenanceCapabilities({
360 provider,
361 packageName: "@openai/codex",
362 updateExecutable: options?.fresh ? "/opt/homebrew/bin/brew" : "brew",
363 updateArgs: ["upgrade", "--cask", "codex"],
364 updateLockKey: "homebrew",
365 }),
366 );
367 },
368 });
369
370 yield* updater.updateProvider(CODEX_DRIVER);
371 // Cached read picks the lock; the two fresh reads bracket the command.
372 assert.deepStrictEqual(fresh, [false, true, true]);
373 assert.deepStrictEqual(calls, [
374 { command: "/opt/homebrew/bin/brew", args: ["upgrade", "--cask", "codex"] },
375 ]);
376 }).pipe(
377 Effect.provide(
378 Layer.mergeAll(
379 NonWindowsPlatform,
380 latestVersionHttpClient("0.0.0"),
381 mockSpawnerLayer((command, args) => {
382 calls.push({ command, args });
383 return { stdout: "updated" };
384 }),
385 ),
386 ),
387 );
388 });
389
390 it.effect("aborts without running when the installation changed since the advisory", () => {
391 const calls: Array<string> = [];
392 return Effect.gen(function* () {
393 const { registry, updateStatesRef } = yield* makeRegistry(baseProvider);
394 const updater = yield* makeTestRunner({
395 ...registry,
396 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider, options) =>
397 Effect.succeed(
398 options?.fresh
399 ? makeProviderMaintenanceCapabilities({
400 provider,
401 packageName: "@openai/codex",
402 updateExecutable: null,
403 updateArgs: [],
404 updateLockKey: null,
405 })
406 : lifecycleFor(provider),
407 ),
408 });
409
410 const result = yield* updater.updateProvider(CODEX_DRIVER);
411 assert.deepStrictEqual(calls, []);
412 assert.strictEqual(result.providers[0]?.updateState?.status, "failed");
413 assert.strictEqual(
414 result.providers[0]?.updateState?.message,
415 "Provider installation changed. Refresh and try again.",
416 );
417 assert.deepStrictEqual(
418 (yield* Ref.get(updateStatesRef)).map((state) => state.status),
419 ["queued", "running", "failed"],
420 );
421 }).pipe(
422 Effect.provide(
423 Layer.mergeAll(
424 NonWindowsPlatform,
425 latestVersionHttpClient("0.0.0"),
426 mockSpawnerLayer((command) => {
427 calls.push(command);
428 return { stdout: "updated" };
429 }),
430 ),
431 ),
432 );
433 });
434
253435 it.effect("uses the resolved provider capabilities when choosing the update executable", () => {
254436 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
255437 return Effect.gen(function* () {
apps/server/src/provider/providerMaintenanceRunner.ts+63-13
......@@ -23,7 +23,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2323
2424import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
2525import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
26import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";
26import {
27 enrichProviderSnapshotWithVersionAdvisory,
28 type ProviderMaintenanceCommandAction,
29 ProviderVersionCache,
30} from "./providerMaintenance.ts";
2731import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
2832import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
2933const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
......@@ -73,6 +77,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
7377 readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
7478 readonly command: string;
7579 readonly args: ReadonlyArray<string>;
80 readonly env?: NodeJS.ProcessEnv;
7681 }) {
7782 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
7883 function* () {
......@@ -83,7 +88,12 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
8388 // shell. On Linux/macOS (incl. the WSL backend) this is a no-op.
8489 const resolved = yield* resolveSpawnCommand(input.command, input.args);
8590 const child = yield* input.spawner
86 .spawn(ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell }))
91 .spawn(
92 ChildProcess.make(resolved.command, resolved.args, {
93 shell: resolved.shell,
94 ...(input.env ? { env: input.env, extendEnv: true } : {}),
95 }),
96 )
8797 .pipe(
8898 Effect.mapError(
8999 (cause) =>
......@@ -181,6 +191,10 @@ function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
181191 return provider?.versionAdvisory?.status === "behind_latest";
182192}
183193
194function isStillInstalled(provider: ServerProvider): boolean {
195 return provider.installed;
196}
197
184198function makeUpdateState(input: {
185199 readonly status: ServerProviderUpdateState["status"];
186200 readonly startedAt: string | null;
......@@ -201,11 +215,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
201215 const providerRegistry = yield* ProviderRegistry;
202216 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
203217 const httpClient = yield* HttpClient.HttpClient;
204 const runMaintenanceCommand = (command: string, args: ReadonlyArray<string>) =>
218 const versionCache = yield* ProviderVersionCache;
219 const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) =>
205220 runProviderMaintenanceCommandWithSpawner({
206221 spawner,
207 command,
208 args,
222 command: update.executable,
223 args: update.args,
224 ...(update.env ? { env: update.env } : {}),
209225 });
210226 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
211227 makeAlreadyRunningError: () =>
......@@ -258,7 +274,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
258274 enrichProviderSnapshotWithVersionAdvisory(
259275 refreshedProvider,
260276 maintenanceCapabilities,
261 ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
277 ).pipe(
278 Effect.provideService(HttpClient.HttpClient, httpClient),
279 Effect.provideService(ProviderVersionCache, versionCache),
280 ),
262281 {
263282 concurrency: "unbounded",
264283 },
......@@ -337,7 +356,26 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
337356 }),
338357 );
339358
340 const result = yield* runMaintenanceCommand(update.executable, update.args);
359 // The cached capabilities chose the lock; re-derive ownership
360 // now so the command that runs matches the executable as it is
361 // at click time, not as it was at the last health refresh.
362 const fresh = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
363 instanceId,
364 provider,
365 { fresh: true },
366 );
367 if (!fresh.update || fresh.update.lockKey !== update.lockKey) {
368 return yield* finish(
369 makeUpdateState({
370 status: "failed",
371 startedAt,
372 finishedAt: yield* nowIso,
373 message: "Provider installation changed. Refresh and try again.",
374 }),
375 );
376 }
377
378 const result = yield* runMaintenanceCommand(fresh.update);
341379 const finishedAt = yield* nowIso;
342380 if (result.timedOut || result.exitCode !== 0) {
343381 return yield* finish(
......@@ -351,18 +389,30 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
351389 );
352390 }
353391
392 // Homebrew's "latest" moves once the upgrade lands; read it again.
393 const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
394 instanceId,
395 provider,
396 { fresh: true },
397 );
354398 const { verifiedProviders } = yield* verifyRefreshedProvider(
355399 provider,
356 capabilities,
400 verified,
357401 instanceId,
358402 );
359 const couldNotVerify = verifiedProviders.length === 0;
360 const stillOutdated =
361 couldNotVerify ||
362 verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));
403 // "Succeeded" needs the provider to still be installed: an
404 // installer that exits 0 and leaves the binary missing is not a
405 // success. A missing version alone is not held against it, since
406 // Cursor's `about` probe can fail transiently on a healthy binary.
407 const couldNotVerify =
408 verifiedProviders.length === 0 ||
409 verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider));
410 const stillOutdated = verifiedProviders.some((verifiedProvider) =>
411 isOutdatedProvider(verifiedProvider),
412 );
363413 return yield* finish(
364414 makeUpdateState({
365 status: stillOutdated ? "unchanged" : "succeeded",
415 status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded",
366416 startedAt,
367417 finishedAt,
368418 message: couldNotVerify
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+18
......@@ -19,6 +19,13 @@ export type ProviderUpdateCandidate = ServerProvider & {
1919 };
2020};
2121
22export type ProviderSettingsUpdateCandidate = ServerProvider & {
23 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
24 readonly canUpdate: true;
25 readonly updateCommand: string;
26 };
27};
28
2229export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";
2330export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";
2431
......@@ -149,6 +156,17 @@ export function collectProviderUpdateCandidates(
149156 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
150157}
151158
159export function isProviderSettingsUpdateCandidate(
160 provider: ServerProvider,
161): provider is ProviderSettingsUpdateCandidate {
162 return (
163 provider.enabled &&
164 provider.versionAdvisory?.status === "behind_latest" &&
165 provider.versionAdvisory.canUpdate === true &&
166 provider.versionAdvisory.updateCommand !== null
167 );
168}
169
152170export function hasOneClickUpdateProviderCandidate(
153171 candidate: ProviderUpdateCandidate,
154172 providers: ReadonlyArray<ServerProvider>,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+29-38
......@@ -49,11 +49,9 @@ import {
4949 connectionPhasePingClassName,
5050} from "../ConnectionStatusDot";
5151import {
52 canOneClickUpdateProviderCandidate,
53 collectProviderUpdateCandidates,
54 hasOneClickUpdateProviderCandidate,
52 isProviderSettingsUpdateCandidate,
5553 isProviderUpdateActive,
56 type ProviderUpdateCandidate,
54 type ProviderSettingsUpdateCandidate,
5755} from "../ProviderUpdateLaunchNotification.logic";
5856import { Button } from "../ui/button";
5957import {
......@@ -574,19 +572,20 @@ export function EnvironmentProviderSettings({
574572 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
575573 targetInstanceId ?? null,
576574 );
577 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
578 ReadonlySet<ProviderDriverKind>
575 const [updatingProviderInstanceIds, setUpdatingProviderInstanceIds] = useState<
576 ReadonlySet<ProviderInstanceId>
579577 >(() => new Set());
580578 const refreshingRef = useRef(false);
581 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
579 const updatingInstanceIdsRef = useRef<Set<ProviderInstanceId>>(new Set());
582580
583 const providerUpdateCandidates = useMemo(
584 () => collectProviderUpdateCandidates(serverProviders),
585 [serverProviders],
586 );
587581 const providerUpdateCandidateByInstanceId = useMemo(
588 () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),
589 [providerUpdateCandidates],
582 () =>
583 new Map(
584 serverProviders
585 .filter(isProviderSettingsUpdateCandidate)
586 .map((candidate) => [candidate.instanceId, candidate]),
587 ),
588 [serverProviders],
590589 );
591590 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
592591 (providerSettings) =>
......@@ -636,14 +635,14 @@ export function EnvironmentProviderSettings({
636635 }, [environmentId, refreshServerProviders]);
637636
638637 const runProviderUpdate = useCallback(
639 async (candidate: ProviderUpdateCandidate) => {
638 async (candidate: ProviderSettingsUpdateCandidate) => {
640639 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
641640 // may run after this function returns, so it cannot gate the dispatch.
642 if (updatingDriversRef.current.has(candidate.driver)) {
641 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {
643642 return;
644643 }
645 updatingDriversRef.current.add(candidate.driver);
646 setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver));
644 updatingInstanceIdsRef.current.add(candidate.instanceId);
645 setUpdatingProviderInstanceIds((previous) => new Set(previous).add(candidate.instanceId));
647646
648647 const result = await updateProvider({
649648 environmentId,
......@@ -665,13 +664,13 @@ export function EnvironmentProviderSettings({
665664 }),
666665 );
667666 }
668 updatingDriversRef.current.delete(candidate.driver);
669 setUpdatingProviderDrivers((previous) => {
670 if (!previous.has(candidate.driver)) {
667 updatingInstanceIdsRef.current.delete(candidate.instanceId);
668 setUpdatingProviderInstanceIds((previous) => {
669 if (!previous.has(candidate.instanceId)) {
671670 return previous;
672671 }
673672 const next = new Set(previous);
674 next.delete(candidate.driver);
673 next.delete(candidate.instanceId);
675674 return next;
676675 });
677676 },
......@@ -872,23 +871,13 @@ export function EnvironmentProviderSettings({
872871 const liveProvider = serverProviders.find(
873872 (candidate) => candidate.instanceId === row.instanceId,
874873 );
875 const updateCandidate = liveProvider
876 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
877 : undefined;
878 const isDriverUpdateRunning =
874 const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId);
875 const isInstanceUpdateRunning =
879876 updateCandidate !== undefined &&
880 (updatingProviderDrivers.has(updateCandidate.driver) ||
881 serverProviders.some(
882 (provider) =>
883 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
884 ));
885 const showInlineUpdateButton =
886 updateCandidate !== undefined &&
887 hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
888 const canRunInlineUpdate =
889 updateCandidate !== undefined &&
890 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
891 !updatingProviderDrivers.has(updateCandidate.driver);
877 (updatingProviderInstanceIds.has(updateCandidate.instanceId) ||
878 isProviderUpdateActive(updateCandidate));
879 const showInlineUpdateButton = updateCandidate !== undefined;
880 const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning;
892881 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
893882 hiddenModels: [],
894883 modelOrder: [],
......@@ -975,7 +964,9 @@ export function EnvironmentProviderSettings({
975964 }
976965 : undefined
977966 }
978 isUpdating={mode === "editor" && showInlineUpdateButton ? isDriverUpdateRunning : undefined}
967 isUpdating={
968 mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined
969 }
979970 />
980971 );
981972 };
docs/internals/providers.md+19
......@@ -55,6 +55,25 @@ run before the prompt. They reject profiles with such configuration before launc
5555instructions and tool denial do not create a native sandbox.
5656See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGeneration.ts).
5757
58## Provider updates run only through the owning installer
59
60A one-click update is offered only when the resolved executable's path proves which installer owns
61it. Homebrew and npm are proven by the real path (symlinks followed): a versioned keg or cask under
62`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).
63Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either
64the resolved path or its real target, since those installers place real files or their own symlinks
65there. Anything unproven stays manual-only but still reports the version gap. npm updates pin
66`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the
67provider. Homebrew
68compares against `brew info` since casks trail npm by hours; native installs share npm's version
69train, so the registry stays authoritative for them.
70See the [resolver](../../apps/server/src/provider/providerMaintenance.ts).
71
72Ownership is cached per instance and re-read immediately before an update runs. The
73[runner](../../apps/server/src/provider/providerMaintenanceRunner.ts) refuses when the lock key
74changed since the advisory, and reports success only when the refreshed provider is still installed
75with a readable, current version.
76
5877## Protocol traps
5978
6079Codex async questions arrive as notifications and are answered with a new user message. There is
docs/user/install.md+7
......@@ -81,6 +81,13 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it
8181Cursor's executable is `cursor-agent`, although its login command is
8282`agent login`. Antigravity can use its managed runtime without a `PATH` entry.
8383
84When a provider CLI is behind its latest release, its provider card shows the
85available version. **Update now** appears only when T3 Code can tell which
86installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm,
87bun, or Vite+ install) and runs that installer. Otherwise update the CLI the same
88way you installed it. Homebrew installs compare against the version Homebrew
89offers, which can trail the npm release by a few hours.
90
8491Add another provider instance for a separate account or configuration. Each
8592instance can have its own environment variables, such as API keys or a custom
8693base URL. Mark secret values as sensitive; after saving, T3 Code does not display