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";...@@ -48,6 +48,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
48import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";48import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
49import {49import {
50 enrichProviderSnapshotWithVersionAdvisory,50 enrichProviderSnapshotWithVersionAdvisory,
51 makeCachedProviderMaintenanceResolution,
51 makePackageManagedProviderMaintenanceResolver,52 makePackageManagedProviderMaintenanceResolver,
52 normalizeCommandPath,53 normalizeCommandPath,
53 resolveProviderMaintenanceCapabilitiesEffect,54 resolveProviderMaintenanceCapabilitiesEffect,
...@@ -76,11 +77,8 @@ function isClaudeNativeCommandPath(commandPath: string): boolean {...@@ -76,11 +77,8 @@ function isClaudeNativeCommandPath(commandPath: string): boolean {
76const UPDATE = makePackageManagedProviderMaintenanceResolver({77const UPDATE = makePackageManagedProviderMaintenanceResolver({
77 provider: DRIVER_KIND,78 provider: DRIVER_KIND,
78 npmPackageName: "@anthropic-ai/claude-code",79 npmPackageName: "@anthropic-ai/claude-code",
79 homebrewFormula: "claude-code",
80 nativeUpdate: {80 nativeUpdate: {
81 executable: "claude",
82 args: ["update"],81 args: ["update"],
83 lockKey: "claude-native",
84 isCommandPath: isClaudeNativeCommandPath,82 isCommandPath: isClaudeNativeCommandPath,
85 },83 },
86});84});
...@@ -122,10 +120,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -122,10 +120,16 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
122 instanceId,120 instanceId,
123 });121 });
124 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;122 const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings;
125 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {123 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
126 binaryPath: effectiveConfig.binaryPath,124 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
127 env: processEnv,125 binaryPath: effectiveConfig.binaryPath,
128 });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 );
129 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);133 const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig);
130 const stampIdentity = withInstanceIdentity({134 const stampIdentity = withInstanceIdentity({
131 instanceId,135 instanceId,
...@@ -189,7 +193,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -189,7 +193,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
189193
190 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);194 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
191 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<ClaudeSettings>>({195 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<ClaudeSettings>>({
192 maintenanceCapabilities,196 resolveMaintenance,
193 getSettings: snapshotSettings.getSettings,197 getSettings: snapshotSettings.getSettings,
194 streamSettings: snapshotSettings.streamSettings,198 streamSettings: snapshotSettings.streamSettings,
195 haveSettingsChanged: haveProviderSnapshotSettingsChanged,199 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -202,9 +206,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {...@@ -202,9 +206,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
202 ),206 ),
203 checkProvider,207 checkProvider,
204 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>208 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
205 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {209 resolveMaintenance().pipe(
206 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,210 Effect.flatMap((maintenanceCapabilities) =>
207 }).pipe(211 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
212 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
213 }),
214 ),
208 Effect.provideService(HttpClient.HttpClient, httpClient),215 Effect.provideService(HttpClient.HttpClient, httpClient),
209 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),216 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
210 ),217 ),
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";...@@ -55,7 +55,9 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
55import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";55import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
56import {56import {
57 enrichProviderSnapshotWithVersionAdvisory,57 enrichProviderSnapshotWithVersionAdvisory,
58 makeCachedProviderMaintenanceResolution,
58 makePackageManagedProviderMaintenanceResolver,59 makePackageManagedProviderMaintenanceResolver,
60 normalizeCommandPath,
59 resolveProviderMaintenanceCapabilitiesEffect,61 resolveProviderMaintenanceCapabilitiesEffect,
60} from "../providerMaintenance.ts";62} from "../providerMaintenance.ts";
61import {63import {
...@@ -71,12 +73,29 @@ import {...@@ -71,12 +73,29 @@ import {
71const decodeCodexSettings = Schema.decodeSync(CodexSettings);73const decodeCodexSettings = Schema.decodeSync(CodexSettings);
7274
73const DRIVER_KIND = ProviderDriverKind.make("codex");75const DRIVER_KIND = ProviderDriverKind.make("codex");
74const UPDATE = makePackageManagedProviderMaintenanceResolver({76// The standalone installer lays out `<CODEX_HOME>/packages/standalone/…`;
75 provider: DRIVER_KIND,77// CODEX_HOME is not always `~/.codex`.
76 npmPackageName: "@openai/codex",78function isCodexStandaloneCommandPath(commandPath: string): boolean {
77 homebrewFormula: "codex",79 return normalizeCommandPath(commandPath).includes("/packages/standalone/");
78 nativeUpdate: null,80}
79});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
81/**100/**
82 * Services the driver needs to materialize an instance. Surfaced as the101 * Services the driver needs to materialize an instance. Surfaced as the
...@@ -108,6 +127,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -108,6 +127,8 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
108 Effect.gen(function* () {127 Effect.gen(function* () {
109 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;128 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
110 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;129 const resetCreditCoordinator = yield* CodexResetCreditCoordinator;
130 const fileSystem = yield* FileSystem.FileSystem;
131 const pathService = yield* Path.Path;
111 const httpClient = yield* HttpClient.HttpClient;132 const httpClient = yield* HttpClient.HttpClient;
112 const serverSettings = yield* ServerSettingsService;133 const serverSettings = yield* ServerSettingsService;
113 const eventLoggers = yield* ProviderEventLoggers;134 const eventLoggers = yield* ProviderEventLoggers;
...@@ -138,10 +159,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -138,10 +159,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
138 enabled,159 enabled,
139 homePath: homeLayout.effectiveHomePath ?? "",160 homePath: homeLayout.effectiveHomePath ?? "",
140 } satisfies CodexSettings;161 } satisfies CodexSettings;
141 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {162 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
142 binaryPath: effectiveConfig.binaryPath,163 resolveProviderMaintenanceCapabilitiesEffect(
143 env: processEnv,164 makeCodexMaintenanceResolver(homeLayout.sharedHomePath),
144 });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
146 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error176 // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error
147 // channels at construction time — their failure modes are all on the177 // channels at construction time — their failure modes are all on the
...@@ -177,7 +207,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -177,7 +207,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
177 );207 );
178 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);208 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
179 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({209 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CodexSettings>>({
180 maintenanceCapabilities,210 resolveMaintenance,
181 getSettings: snapshotSettings.getSettings,211 getSettings: snapshotSettings.getSettings,
182 streamSettings: snapshotSettings.streamSettings,212 streamSettings: snapshotSettings.streamSettings,
183 haveSettingsChanged: haveProviderSnapshotSettingsChanged,213 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -190,9 +220,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {...@@ -190,9 +220,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
190 ),220 ),
191 checkProvider,221 checkProvider,
192 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>222 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
193 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {223 resolveMaintenance().pipe(
194 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,224 Effect.flatMap((maintenanceCapabilities) =>
195 }).pipe(225 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
226 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
227 }),
228 ),
196 Effect.provideService(HttpClient.HttpClient, httpClient),229 Effect.provideService(HttpClient.HttpClient, httpClient),
197 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),230 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
198 ),231 ),
apps/server/src/provider/Drivers/CursorDriver.test.ts+49-12
...@@ -1,8 +1,12 @@...@@ -1,8 +1,12 @@
1// @effect-diagnostics nodeBuiltinImport:off
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
2import { expect, it } from "@effect/vitest";5import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";6import { ProviderInstanceId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";11import { HttpClient } from "effect/unstable/http";
8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";12import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
...@@ -32,26 +36,59 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {...@@ -32,26 +36,59 @@ const testLayer = ServerConfig.layerTest(process.cwd(), {
32 ),36 ),
33);37);
3438
39// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
40const windowsHost = HostProcessPlatform.defaultValue() === "win32";
41
35it.layer(testLayer)("CursorDriver", (it) => {42it.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", () =>
37 Effect.gen(function* () {79 Effect.gen(function* () {
38 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\cursor-agent.exe";
39 const instance = yield* CursorDriver.create({80 const instance = yield* CursorDriver.create({
40 instanceId: ProviderInstanceId.make("cursor-copy-command"),81 instanceId: ProviderInstanceId.make("cursor-missing"),
41 displayName: "Cursor test",82 displayName: "Cursor test",
42 enabled: false,83 enabled: false,
43 environment: [],84 environment: [],
44 config: { ...CursorDriver.defaultConfig(), binaryPath },85 config: {
45 });86 ...CursorDriver.defaultConfig(),
4687 binaryPath: NodePath.join(NodeOS.tmpdir(), "t3-cursor-missing", "cursor-agent"),
47 expect(instance.snapshot.maintenanceCapabilities.update).toMatchObject({88 },
48 command: `& '${binaryPath}' update`,
49 executable: binaryPath,
50 args: ["update"],
51 });89 });
52 expect((yield* instance.snapshot.refresh).status).toBe("disabled");90 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
53 }).pipe(91 }).pipe(
54 Effect.provideService(HostProcessPlatform, "win32"),
55 Effect.provideService(92 Effect.provideService(
56 ChildProcessSpawner.ChildProcessSpawner,93 ChildProcessSpawner.ChildProcessSpawner,
57 ChildProcessSpawner.make(() => Effect.die("Disabled Cursor must not spawn a process")),94 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 {...@@ -41,6 +41,8 @@ import {
41import { withInstanceIdentity } from "./instanceIdentity.ts";41import { withInstanceIdentity } from "./instanceIdentity.ts";
42import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";42import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
43import {43import {
44 makeCachedProviderMaintenanceResolution,
45 makeManualOnlyProviderMaintenanceCapabilities,
44 makeProviderMaintenanceCapabilities,46 makeProviderMaintenanceCapabilities,
45 type ProviderMaintenanceCapabilitiesResolver,47 type ProviderMaintenanceCapabilitiesResolver,
46 resolveProviderMaintenanceCapabilitiesEffect,48 resolveProviderMaintenanceCapabilitiesEffect,
...@@ -54,16 +56,25 @@ import { probeCursorSkills } from "./CursorSkills.ts";...@@ -54,16 +56,25 @@ import { probeCursorSkills } from "./CursorSkills.ts";
54const decodeCursorSettings = Schema.decodeSync(CursorSettings);56const decodeCursorSettings = Schema.decodeSync(CursorSettings);
5557
56const DRIVER_KIND = ProviderDriverKind.make("cursor");58const 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".
57const UPDATE: ProviderMaintenanceCapabilitiesResolver = {61const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
58 resolve: (options) =>62 resolve: (context) =>
59 makeProviderMaintenanceCapabilities({63 Effect.succeed(
60 provider: DRIVER_KIND,64 context
61 packageName: null,65 ? makeProviderMaintenanceCapabilities({
62 updateExecutable: options?.binaryPath?.trim() || "cursor-agent",66 provider: DRIVER_KIND,
63 updateArgs: ["update"],67 packageName: null,
64 updateLockKey: "cursor-agent",68 updateExecutable: context.resolvedCommandPath,
65 ...(options?.platform ? { platform: options.platform } : {}),69 updateArgs: ["update"],
66 }),70 updateLockKey: "cursor-agent",
71 platform: context.platform,
72 })
73 : makeManualOnlyProviderMaintenanceCapabilities({
74 provider: DRIVER_KIND,
75 packageName: null,
76 }),
77 ),
67};78};
6879
69export type CursorDriverEnv =80export type CursorDriverEnv =
...@@ -107,10 +118,16 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -107,10 +118,16 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
107 continuationGroupKey: continuationIdentity.continuationKey,118 continuationGroupKey: continuationIdentity.continuationKey,
108 });119 });
109 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;120 const effectiveConfig = { ...config, enabled } satisfies CursorSettings;
110 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {121 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
111 binaryPath: effectiveConfig.binaryPath,122 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
112 env: processEnv,123 binaryPath: effectiveConfig.binaryPath,
113 });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
115 const adapter = yield* makeCursorAdapter(effectiveConfig, {132 const adapter = yield* makeCursorAdapter(effectiveConfig, {
116 environment: processEnv,133 environment: processEnv,
...@@ -129,7 +146,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -129,7 +146,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
129146
130 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);147 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
131 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({148 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({
132 maintenanceCapabilities,149 resolveMaintenance,
133 getSettings: snapshotSettings.getSettings,150 getSettings: snapshotSettings.getSettings,
134 streamSettings: snapshotSettings.streamSettings,151 streamSettings: snapshotSettings.streamSettings,
135 haveSettingsChanged: haveProviderSnapshotSettingsChanged,152 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -139,15 +156,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -139,15 +156,20 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
139 // Model catalog and capabilities come exclusively from Cursor's156 // Model catalog and capabilities come exclusively from Cursor's
140 // list_available_models extension method during provider checks.157 // list_available_models extension method during provider checks.
141 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>158 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
142 enrichCursorSnapshot({159 resolveMaintenance().pipe(
143 settings: settings.provider,160 Effect.flatMap((maintenanceCapabilities) =>
144 snapshot: currentSnapshot,161 enrichCursorSnapshot({
145 maintenanceCapabilities,162 settings: settings.provider,
146 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,163 snapshot: currentSnapshot,
147 publishSnapshot,164 maintenanceCapabilities,
148 stampIdentity,165 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
149 httpClient,166 publishSnapshot,
150 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),167 stampIdentity,
168 httpClient,
169 }),
170 ),
171 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
172 ),
151 }).pipe(173 }).pipe(
152 Effect.mapError(174 Effect.mapError(
153 (cause) =>175 (cause) =>
apps/server/src/provider/Drivers/GrokDriver.ts+7-18
...@@ -28,11 +28,7 @@ import {...@@ -28,11 +28,7 @@ import {
28import { withInstanceIdentity } from "./instanceIdentity.ts";28import { withInstanceIdentity } from "./instanceIdentity.ts";
29import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";29import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
30import { discoverGrokSkills } from "./GrokSkills.ts";30import { discoverGrokSkills } from "./GrokSkills.ts";
31import {31import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
32 makeManualOnlyProviderMaintenanceCapabilities,
33 makeStaticProviderMaintenanceResolver,
34 resolveProviderMaintenanceCapabilitiesEffect,
35} from "../providerMaintenance.ts";
36import {32import {
37 haveProviderSnapshotSettingsChanged,33 haveProviderSnapshotSettingsChanged,
38 makeProviderSnapshotSettingsSource,34 makeProviderSnapshotSettingsSource,
...@@ -41,12 +37,10 @@ import {...@@ -41,12 +37,10 @@ import {
41const decodeGrokSettings = Schema.decodeSync(GrokSettings);37const decodeGrokSettings = Schema.decodeSync(GrokSettings);
4238
43const DRIVER_KIND = ProviderDriverKind.make("grok");39const DRIVER_KIND = ProviderDriverKind.make("grok");
44const UPDATE = makeStaticProviderMaintenanceResolver(40const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
45 makeManualOnlyProviderMaintenanceCapabilities({41 provider: DRIVER_KIND,
46 provider: DRIVER_KIND,42 packageName: null,
47 packageName: null,43});
48 }),
49);
5044
51export type GrokDriverEnv =45export type GrokDriverEnv =
52 | BackgroundPolicy.BackgroundPolicy46 | BackgroundPolicy.BackgroundPolicy
...@@ -88,11 +82,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -88,11 +82,6 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
88 continuationGroupKey: continuationIdentity.continuationKey,82 continuationGroupKey: continuationIdentity.continuationKey,
89 });83 });
90 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;84 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
91 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
92 binaryPath: effectiveConfig.binaryPath,
93 env: processEnv,
94 });
95
96 const adapter = yield* makeGrokAdapter(effectiveConfig, {85 const adapter = yield* makeGrokAdapter(effectiveConfig, {
97 environment: processEnv,86 environment: processEnv,
98 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),87 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
...@@ -108,7 +97,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -108,7 +97,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
10897
109 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);98 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
110 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({99 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
111 maintenanceCapabilities,100 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
112 getSettings: snapshotSettings.getSettings,101 getSettings: snapshotSettings.getSettings,
113 streamSettings: snapshotSettings.streamSettings,102 streamSettings: snapshotSettings.streamSettings,
114 haveSettingsChanged: haveProviderSnapshotSettingsChanged,103 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -118,7 +107,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -118,7 +107,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
118 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>107 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
119 enrichGrokSnapshot({108 enrichGrokSnapshot({
120 snapshot: currentSnapshot,109 snapshot: currentSnapshot,
121 maintenanceCapabilities,110 maintenanceCapabilities: MAINTENANCE_CAPABILITIES,
122 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,111 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
123 publishSnapshot,112 publishSnapshot,
124 httpClient,113 httpClient,
apps/server/src/provider/Drivers/OpenCodeDriver.ts+21-11
...@@ -45,6 +45,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";...@@ -45,6 +45,7 @@ import { withInstanceIdentity } from "./instanceIdentity.ts";
45import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";45import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
46import {46import {
47 enrichProviderSnapshotWithVersionAdvisory,47 enrichProviderSnapshotWithVersionAdvisory,
48 makeCachedProviderMaintenanceResolution,
48 makePackageManagedProviderMaintenanceResolver,49 makePackageManagedProviderMaintenanceResolver,
49 normalizeCommandPath,50 normalizeCommandPath,
50 resolveProviderMaintenanceCapabilitiesEffect,51 resolveProviderMaintenanceCapabilitiesEffect,
...@@ -69,11 +70,8 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean {...@@ -69,11 +70,8 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean {
69const UPDATE = makePackageManagedProviderMaintenanceResolver({70const UPDATE = makePackageManagedProviderMaintenanceResolver({
70 provider: DRIVER_KIND,71 provider: DRIVER_KIND,
71 npmPackageName: "opencode-ai",72 npmPackageName: "opencode-ai",
72 homebrewFormula: "anomalyco/tap/opencode",
73 nativeUpdate: {73 nativeUpdate: {
74 executable: "opencode",
75 args: ["upgrade"],74 args: ["upgrade"],
76 lockKey: "opencode-native",
77 isCommandPath: isOpenCodeNativeCommandPath,75 isCommandPath: isOpenCodeNativeCommandPath,
78 },76 },
79});77});
...@@ -100,6 +98,9 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -100,6 +98,9 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
100 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),98 defaultConfig: (): OpenCodeSettings => decodeOpenCodeSettings({}),
101 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>99 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
102 Effect.gen(function* () {100 Effect.gen(function* () {
101 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
102 const fileSystem = yield* FileSystem.FileSystem;
103 const pathService = yield* Path.Path;
103 const openCodeRuntime = yield* OpenCodeRuntime;104 const openCodeRuntime = yield* OpenCodeRuntime;
104 const serverConfig = yield* ServerConfig;105 const serverConfig = yield* ServerConfig;
105 const httpClient = yield* HttpClient.HttpClient;106 const httpClient = yield* HttpClient.HttpClient;
...@@ -118,10 +119,16 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -118,10 +119,16 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
118 continuationGroupKey: continuationIdentity.continuationKey,119 continuationGroupKey: continuationIdentity.continuationKey,
119 });120 });
120 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;121 const effectiveConfig = { ...config, enabled } satisfies OpenCodeSettings;
121 const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {122 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
122 binaryPath: effectiveConfig.binaryPath,123 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
123 env: processEnv,124 binaryPath: effectiveConfig.binaryPath,
124 });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
126 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {133 const adapter = yield* makeOpenCodeAdapter(effectiveConfig, {
127 instanceId,134 instanceId,
...@@ -195,7 +202,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -195,7 +202,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
195 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);202 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
196 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(203 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
197 {204 {
198 maintenanceCapabilities,205 resolveMaintenance,
199 getSettings: snapshotSettings.getSettings,206 getSettings: snapshotSettings.getSettings,
200 streamSettings: snapshotSettings.streamSettings,207 streamSettings: snapshotSettings.streamSettings,
201 haveSettingsChanged: haveProviderSnapshotSettingsChanged,208 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -205,9 +212,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>...@@ -205,9 +212,12 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
205 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),212 makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
206 checkProvider,213 checkProvider,
207 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>214 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
208 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {215 resolveMaintenance().pipe(
209 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,216 Effect.flatMap((maintenanceCapabilities) =>
210 }).pipe(217 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
218 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
219 }),
220 ),
211 Effect.provideService(HttpClient.HttpClient, httpClient),221 Effect.provideService(HttpClient.HttpClient, httpClient),
212 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),222 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
213 ),223 ),
apps/server/src/provider/Layers/AntigravityProvider.ts+7-6
...@@ -232,13 +232,14 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func...@@ -232,13 +232,14 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
232 return yield* options.stampIdentity(next.draft);232 return yield* options.stampIdentity(next.draft);
233 });233 });
234234
235 const maintenanceCapabilities =
236 options.maintenanceCapabilities ??
237 makeManualOnlyProviderMaintenanceCapabilities({
238 provider: ProviderDriverKind.make("antigravity"),
239 packageName: null,
240 });
235 const managed = yield* makeManagedServerProvider({241 const managed = yield* makeManagedServerProvider({
236 maintenanceCapabilities:242 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
237 options.maintenanceCapabilities ??
238 makeManualOnlyProviderMaintenanceCapabilities({
239 provider: ProviderDriverKind.make("antigravity"),
240 packageName: null,
241 }),
242 getSettings: Effect.succeed(settings),243 getSettings: Effect.succeed(settings),
243 streamSettings: Stream.empty,244 streamSettings: Stream.empty,
244 haveSettingsChanged: () => false,245 haveSettingsChanged: () => false,
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+7-4
...@@ -111,10 +111,13 @@ const makeFakeInstance = (...@@ -111,10 +111,13 @@ const makeFakeInstance = (
111 displayName: undefined,111 displayName: undefined,
112 enabled: true,112 enabled: true,
113 snapshot: {113 snapshot: {
114 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({114 resolveMaintenance: () =>
115 provider: driverKind,115 Effect.succeed(
116 packageName: null,116 makeManualOnlyProviderMaintenanceCapabilities({
117 }),117 provider: driverKind,
118 packageName: null,
119 }),
120 ),
118 getSnapshot: Effect.succeed({} as unknown as ServerProvider),121 getSnapshot: Effect.succeed({} as unknown as ServerProvider),
119 refresh: Effect.succeed({} as unknown as ServerProvider),122 refresh: Effect.succeed({} as unknown as ServerProvider),
120 streamChanges: Stream.empty,123 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...@@ -1033,10 +1033,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1033 displayName: undefined,1033 displayName: undefined,
1034 enabled: true,1034 enabled: true,
1035 snapshot: {1035 snapshot: {
1036 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1036 resolveMaintenance: () =>
1037 provider: cachedProvider.driver,1037 Effect.succeed(
1038 packageName: null,1038 makeManualOnlyProviderMaintenanceCapabilities({
1039 }),1039 provider: cachedProvider.driver,
1040 packageName: null,
1041 }),
1042 ),
1040 getSnapshot: Effect.succeed(pendingProvider),1043 getSnapshot: Effect.succeed(pendingProvider),
1041 refresh: Ref.get(nextProvider),1044 refresh: Ref.get(nextProvider),
1042 streamChanges: Stream.empty,1045 streamChanges: Stream.empty,
...@@ -1288,10 +1291,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1288,10 +1291,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1288 displayName: undefined,1291 displayName: undefined,
1289 enabled: true,1292 enabled: true,
1290 snapshot: {1293 snapshot: {
1291 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1294 resolveMaintenance: () =>
1292 provider: codexDriver,1295 Effect.succeed(
1293 packageName: null,1296 makeManualOnlyProviderMaintenanceCapabilities({
1294 }),1297 provider: codexDriver,
1298 packageName: null,
1299 }),
1300 ),
1295 getSnapshot: Effect.succeed(initialProvider),1301 getSnapshot: Effect.succeed(initialProvider),
1296 refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(1302 refresh: Ref.update(refreshCalls, (count) => count + 1).pipe(
1297 Effect.andThen(Effect.never),1303 Effect.andThen(Effect.never),
...@@ -1380,10 +1386,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1380,10 +1386,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1380 displayName: undefined,1386 displayName: undefined,
1381 enabled: true,1387 enabled: true,
1382 snapshot: {1388 snapshot: {
1383 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1389 resolveMaintenance: () =>
1384 provider: driver,1390 Effect.succeed(
1385 packageName: null,1391 makeManualOnlyProviderMaintenanceCapabilities({
1386 }),1392 provider: driver,
1393 packageName: null,
1394 }),
1395 ),
1387 getSnapshot: Effect.succeed(provider),1396 getSnapshot: Effect.succeed(provider),
1388 refresh: Effect.succeed(provider),1397 refresh: Effect.succeed(provider),
1389 streamChanges: Stream.empty,1398 streamChanges: Stream.empty,
...@@ -1569,10 +1578,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1569,10 +1578,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1569 displayName: undefined,1578 displayName: undefined,
1570 enabled: true,1579 enabled: true,
1571 snapshot: {1580 snapshot: {
1572 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1581 resolveMaintenance: () =>
1573 provider: codexDriver,1582 Effect.succeed(
1574 packageName: null,1583 makeManualOnlyProviderMaintenanceCapabilities({
1575 }),1584 provider: codexDriver,
1585 packageName: null,
1586 }),
1587 ),
1576 getSnapshot: Effect.succeed(codexProvider),1588 getSnapshot: Effect.succeed(codexProvider),
1577 refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(1589 refresh: Ref.update(codexRefreshCalls, (count) => count + 1).pipe(
1578 Effect.as(codexProvider),1590 Effect.as(codexProvider),
...@@ -1593,10 +1605,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1593,10 +1605,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1593 displayName: undefined,1605 displayName: undefined,
1594 enabled: true,1606 enabled: true,
1595 snapshot: {1607 snapshot: {
1596 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1608 resolveMaintenance: () =>
1597 provider: openCodeDriver,1609 Effect.succeed(
1598 packageName: null,1610 makeManualOnlyProviderMaintenanceCapabilities({
1599 }),1611 provider: openCodeDriver,
1612 packageName: null,
1613 }),
1614 ),
1600 getSnapshot: Effect.succeed(failedOpenCodeProvider),1615 getSnapshot: Effect.succeed(failedOpenCodeProvider),
1601 refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(1616 refresh: Ref.update(openCodeRefreshCalls, (count) => count + 1).pipe(
1602 Effect.andThen(Ref.get(catalogSnapshot)),1617 Effect.andThen(Ref.get(catalogSnapshot)),
...@@ -1717,10 +1732,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1717,10 +1732,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1717 displayName: undefined,1732 displayName: undefined,
1718 enabled: true,1733 enabled: true,
1719 snapshot: {1734 snapshot: {
1720 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1735 resolveMaintenance: () =>
1721 provider: cursorDriver,1736 Effect.succeed(
1722 packageName: null,1737 makeManualOnlyProviderMaintenanceCapabilities({
1723 }),1738 provider: cursorDriver,
1739 packageName: null,
1740 }),
1741 ),
1724 getSnapshot: Effect.succeed(initialProvider),1742 getSnapshot: Effect.succeed(initialProvider),
1725 refresh: Effect.succeed(refreshedProvider),1743 refresh: Effect.succeed(refreshedProvider),
1726 streamChanges: Stream.fromPubSub(changes),1744 streamChanges: Stream.fromPubSub(changes),
...@@ -1839,10 +1857,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1839,10 +1857,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1839 displayName: undefined,1857 displayName: undefined,
1840 enabled: true,1858 enabled: true,
1841 snapshot: {1859 snapshot: {
1842 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1860 resolveMaintenance: () =>
1843 provider: openCodeDriver,1861 Effect.succeed(
1844 packageName: null,1862 makeManualOnlyProviderMaintenanceCapabilities({
1845 }),1863 provider: openCodeDriver,
1864 packageName: null,
1865 }),
1866 ),
1846 getSnapshot: Effect.succeed(initialProvider),1867 getSnapshot: Effect.succeed(initialProvider),
1847 refresh: Effect.succeed(authoritativeProvider),1868 refresh: Effect.succeed(authoritativeProvider),
1848 streamChanges: Stream.fromPubSub(changes),1869 streamChanges: Stream.fromPubSub(changes),
...@@ -1939,10 +1960,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1939,10 +1960,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1939 displayName: undefined,1960 displayName: undefined,
1940 enabled: true,1961 enabled: true,
1941 snapshot: {1962 snapshot: {
1942 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({1963 resolveMaintenance: () =>
1943 provider: codexDriver,1964 Effect.succeed(
1944 packageName: null,1965 makeManualOnlyProviderMaintenanceCapabilities({
1945 }),1966 provider: codexDriver,
1967 packageName: null,
1968 }),
1969 ),
1946 getSnapshot: Effect.succeed(cachedProvider),1970 getSnapshot: Effect.succeed(cachedProvider),
1947 refresh: Effect.die(new Error("simulated refresh failure")),1971 refresh: Effect.die(new Error("simulated refresh failure")),
1948 streamChanges: Stream.empty,1972 streamChanges: Stream.empty,
...@@ -2033,10 +2057,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2033,10 +2057,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2033 displayName: undefined,2057 displayName: undefined,
2034 enabled: true,2058 enabled: true,
2035 snapshot: {2059 snapshot: {
2036 maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({2060 resolveMaintenance: () =>
2037 provider: provider.driver,2061 Effect.succeed(
2038 packageName: null,2062 makeManualOnlyProviderMaintenanceCapabilities({
2039 }),2063 provider: provider.driver,
2064 packageName: null,
2065 }),
2066 ),
2040 getSnapshot: Effect.succeed(provider),2067 getSnapshot: Effect.succeed(provider),
2041 refresh: Effect.succeed(provider),2068 refresh: Effect.succeed(provider),
2042 streamChanges: Stream.empty,2069 streamChanges: Stream.empty,
apps/server/src/provider/Layers/ProviderRegistry.ts+13-8
...@@ -532,14 +532,19 @@ export const ProviderRegistryLive = Layer.effect(...@@ -532,14 +532,19 @@ export const ProviderRegistryLive = Layer.effect(
532532
533 const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(533 const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
534 "getProviderMaintenanceCapabilitiesForInstance",534 "getProviderMaintenanceCapabilitiesForInstance",
535 )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) {535 )(function* (
536 const instance = Array.from((yield* Ref.get(liveSubsRef)).values()).find(536 instanceId: ProviderInstanceId,
537 (candidate) => candidate.instanceId === instanceId,537 provider: ProviderDriverKind,
538 );538 options?: { readonly fresh?: boolean },
539 return (539 ) {
540 instance?.snapshot.maintenanceCapabilities ??540 // Read the instance registry, not `liveSubsRef`: the latter trails
541 makeManualProviderMaintenanceCapabilities(provider)541 // reconciliation, and an update must never run a retired instance's
542 );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);
543 });548 });
544549
545 /**550 /**
apps/server/src/provider/Services/ProviderRegistry.ts+2
...@@ -56,10 +56,12 @@ export interface ProviderRegistryShape {...@@ -56,10 +56,12 @@ export interface ProviderRegistryShape {
56 /**56 /**
57 * Resolve the maintenance capabilities owned by one live provider instance.57 * Resolve the maintenance capabilities owned by one live provider instance.
58 * Falls back to manual-only capabilities when the instance is not live.58 * Falls back to manual-only capabilities when the instance is not live.
59 * `fresh` re-derives ownership from the executable instead of the cache.
59 */60 */
60 readonly getProviderMaintenanceCapabilitiesForInstance: (61 readonly getProviderMaintenanceCapabilitiesForInstance: (
61 instanceId: ProviderInstanceId,62 instanceId: ProviderInstanceId,
62 provider: ProviderDriverKind,63 provider: ProviderDriverKind,
64 options?: { readonly fresh?: boolean },
63 ) => Effect.Effect<ProviderMaintenanceCapabilities>;65 ) => Effect.Effect<ProviderMaintenanceCapabilities>;
6466
65 /**67 /**
apps/server/src/provider/Services/ServerProvider.ts+8-1
...@@ -4,7 +4,14 @@ import type * as Stream from "effect/Stream";...@@ -4,7 +4,14 @@ import type * as Stream from "effect/Stream";
4import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";4import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
55
6export interface ServerProviderShape {6export 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>;
8 readonly getSnapshot: Effect.Effect<ServerProvider>;15 readonly getSnapshot: Effect.Effect<ServerProvider>;
9 readonly refresh: Effect.Effect<ServerProvider>;16 readonly refresh: Effect.Effect<ServerProvider>;
10 readonly streamChanges: Stream.Stream<ServerProvider>;17 readonly streamChanges: Stream.Stream<ServerProvider>;
apps/server/src/provider/makeManagedServerProvider.test.ts+11-11
...@@ -158,7 +158,7 @@ describe("makeManagedServerProvider", () => {...@@ -158,7 +158,7 @@ describe("makeManagedServerProvider", () => {
158 const checkCalls = yield* Ref.make(0);158 const checkCalls = yield* Ref.make(0);
159 const releaseCheck = yield* Deferred.make<void>();159 const releaseCheck = yield* Deferred.make<void>();
160 const provider = yield* makeManagedServerProvider<TestSettings>({160 const provider = yield* makeManagedServerProvider<TestSettings>({
161 maintenanceCapabilities,161 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
162 getSettings: Effect.succeed({ enabled: true }),162 getSettings: Effect.succeed({ enabled: true }),
163 streamSettings: Stream.empty,163 streamSettings: Stream.empty,
164 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,164 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -198,7 +198,7 @@ describe("makeManagedServerProvider", () => {...@@ -198,7 +198,7 @@ describe("makeManagedServerProvider", () => {
198 const checkCalls = yield* Ref.make(0);198 const checkCalls = yield* Ref.make(0);
199 const initialCheckDone = yield* Deferred.make<void>();199 const initialCheckDone = yield* Deferred.make<void>();
200 yield* makeManagedServerProvider<TestSettings>({200 yield* makeManagedServerProvider<TestSettings>({
201 maintenanceCapabilities,201 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
202 getSettings: Effect.succeed({ enabled: true }),202 getSettings: Effect.succeed({ enabled: true }),
203 streamSettings: Stream.empty,203 streamSettings: Stream.empty,
204 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,204 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -229,7 +229,7 @@ describe("makeManagedServerProvider", () => {...@@ -229,7 +229,7 @@ describe("makeManagedServerProvider", () => {
229 const checkCalls = yield* Ref.make(0);229 const checkCalls = yield* Ref.make(0);
230 const initialCheckDone = yield* Deferred.make<void>();230 const initialCheckDone = yield* Deferred.make<void>();
231 yield* makeManagedServerProvider<TestSettings>({231 yield* makeManagedServerProvider<TestSettings>({
232 maintenanceCapabilities,232 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
233 getSettings: Effect.succeed({ enabled: true }),233 getSettings: Effect.succeed({ enabled: true }),
234 streamSettings: Stream.empty,234 streamSettings: Stream.empty,
235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,235 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -256,7 +256,7 @@ describe("makeManagedServerProvider", () => {...@@ -256,7 +256,7 @@ describe("makeManagedServerProvider", () => {
256 const checkCalls = yield* Ref.make(0);256 const checkCalls = yield* Ref.make(0);
257 const initialCheckDone = yield* Deferred.make<void>();257 const initialCheckDone = yield* Deferred.make<void>();
258 const provider = yield* makeManagedServerProvider<TestSettings>({258 const provider = yield* makeManagedServerProvider<TestSettings>({
259 maintenanceCapabilities,259 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
260 getSettings: Effect.succeed({ enabled: true }),260 getSettings: Effect.succeed({ enabled: true }),
261 streamSettings: Stream.empty,261 streamSettings: Stream.empty,
262 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,262 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -311,7 +311,7 @@ describe("makeManagedServerProvider", () => {...@@ -311,7 +311,7 @@ describe("makeManagedServerProvider", () => {
311 const periodicCheckDone = yield* Deferred.make<void>();311 const periodicCheckDone = yield* Deferred.make<void>();
312312
313 yield* makeManagedServerProvider<TestSettings>({313 yield* makeManagedServerProvider<TestSettings>({
314 maintenanceCapabilities,314 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
315 getSettings: Effect.succeed({ enabled: true }),315 getSettings: Effect.succeed({ enabled: true }),
316 streamSettings: Stream.empty,316 streamSettings: Stream.empty,
317 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,317 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -353,7 +353,7 @@ describe("makeManagedServerProvider", () => {...@@ -353,7 +353,7 @@ describe("makeManagedServerProvider", () => {
353 const releaseInitialCheck = yield* Deferred.make<void>();353 const releaseInitialCheck = yield* Deferred.make<void>();
354 const releaseSettingsCheck = yield* Deferred.make<void>();354 const releaseSettingsCheck = yield* Deferred.make<void>();
355 const provider = yield* makeManagedServerProvider<TestSettings>({355 const provider = yield* makeManagedServerProvider<TestSettings>({
356 maintenanceCapabilities,356 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
357 getSettings: Ref.get(settingsRef),357 getSettings: Ref.get(settingsRef),
358 streamSettings: Stream.fromPubSub(settingsChanges),358 streamSettings: Stream.fromPubSub(settingsChanges),
359 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,359 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -397,7 +397,7 @@ describe("makeManagedServerProvider", () => {...@@ -397,7 +397,7 @@ describe("makeManagedServerProvider", () => {
397 const initialCheckDone = yield* Deferred.make<void>();397 const initialCheckDone = yield* Deferred.make<void>();
398 const enrichmentCalls = yield* Ref.make(0);398 const enrichmentCalls = yield* Ref.make(0);
399 yield* makeManagedServerProvider<TestSettings>({399 yield* makeManagedServerProvider<TestSettings>({
400 maintenanceCapabilities,400 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
401 getSettings: Effect.succeed({ enabled: true }),401 getSettings: Effect.succeed({ enabled: true }),
402 streamSettings: Stream.fromPubSub(settingsChanges),402 streamSettings: Stream.fromPubSub(settingsChanges),
403 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,403 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -430,7 +430,7 @@ describe("makeManagedServerProvider", () => {...@@ -430,7 +430,7 @@ describe("makeManagedServerProvider", () => {
430 const releaseEnrichment = yield* Deferred.make<void>();430 const releaseEnrichment = yield* Deferred.make<void>();
431 const releaseCheck = yield* Deferred.make<void>();431 const releaseCheck = yield* Deferred.make<void>();
432 const provider = yield* makeManagedServerProvider<TestSettings>({432 const provider = yield* makeManagedServerProvider<TestSettings>({
433 maintenanceCapabilities,433 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
434 getSettings: Effect.succeed({ enabled: true }),434 getSettings: Effect.succeed({ enabled: true }),
435 streamSettings: Stream.empty,435 streamSettings: Stream.empty,
436 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,436 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -471,7 +471,7 @@ describe("makeManagedServerProvider", () => {...@@ -471,7 +471,7 @@ describe("makeManagedServerProvider", () => {
471 const secondCallbackReady = yield* Deferred.make<void>();471 const secondCallbackReady = yield* Deferred.make<void>();
472 const allowFirstRefresh = yield* Deferred.make<void>();472 const allowFirstRefresh = yield* Deferred.make<void>();
473 const provider = yield* makeManagedServerProvider<TestSettings>({473 const provider = yield* makeManagedServerProvider<TestSettings>({
474 maintenanceCapabilities,474 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
475 getSettings: Effect.succeed({ enabled: true }),475 getSettings: Effect.succeed({ enabled: true }),
476 streamSettings: Stream.empty,476 streamSettings: Stream.empty,
477 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,477 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -527,7 +527,7 @@ describe("makeManagedServerProvider", () => {...@@ -527,7 +527,7 @@ describe("makeManagedServerProvider", () => {
527 Effect.scoped(527 Effect.scoped(
528 Effect.gen(function* () {528 Effect.gen(function* () {
529 const provider = yield* makeManagedServerProvider<TestSettings>({529 const provider = yield* makeManagedServerProvider<TestSettings>({
530 maintenanceCapabilities,530 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
531 getSettings: Effect.succeed({ enabled: true }),531 getSettings: Effect.succeed({ enabled: true }),
532 streamSettings: Stream.empty,532 streamSettings: Stream.empty,
533 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,533 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
...@@ -595,7 +595,7 @@ describe("makeManagedServerProvider", () => {...@@ -595,7 +595,7 @@ describe("makeManagedServerProvider", () => {
595 windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }],595 windows: [{ id: "primary", kind: "session", label: "Session", usedPercent: 10 }],
596 } as const;596 } as const;
597 const provider = yield* makeManagedServerProvider<TestSettings>({597 const provider = yield* makeManagedServerProvider<TestSettings>({
598 maintenanceCapabilities,598 resolveMaintenance: () => Effect.succeed(maintenanceCapabilities),
599 getSettings: Effect.succeed({ enabled: true }),599 getSettings: Effect.succeed({ enabled: true }),
600 streamSettings: Stream.empty,600 streamSettings: Stream.empty,
601 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,601 haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled,
apps/server/src/provider/makeManagedServerProvider.ts+2-2
...@@ -39,7 +39,7 @@ function withUsageLimits(...@@ -39,7 +39,7 @@ function withUsageLimits(
39export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <39export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(function* <
40 Settings,40 Settings,
41>(input: {41>(input: {
42 readonly maintenanceCapabilities: ServerProviderShape["maintenanceCapabilities"];42 readonly resolveMaintenance: ServerProviderShape["resolveMaintenance"];
43 readonly getSettings: Effect.Effect<Settings, ServerSettingsError>;43 readonly getSettings: Effect.Effect<Settings, ServerSettingsError>;
44 readonly streamSettings: Stream.Stream<Settings>;44 readonly streamSettings: Stream.Stream<Settings>;
45 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;45 readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean;
...@@ -283,7 +283,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(...@@ -283,7 +283,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")(
283 );283 );
284284
285 return {285 return {
286 maintenanceCapabilities: input.maintenanceCapabilities,286 resolveMaintenance: input.resolveMaintenance,
287 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),287 getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)),
288 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),288 refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie),
289 applyUsageLimits,289 applyUsageLimits,
apps/server/src/provider/providerMaintenance.test.ts+468-464
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import * as NodeFS from "node:fs";
4import * as NodeChildProcess from "node:child_process";3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeOS from "node:os";6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";7import * as NodePath from "node:path";
...@@ -9,26 +9,31 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3...@@ -9,26 +9,31 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3
9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
10import * as Crypto from "effect/Crypto";10import * as Crypto from "effect/Crypto";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";12import * as Sink from "effect/Sink";
13import * as Stream from "effect/Stream";
13import { HttpClient } from "effect/unstable/http";14import { HttpClient } from "effect/unstable/http";
15import { ChildProcessSpawner } from "effect/unstable/process";
14import {16import {
15 createProviderVersionAdvisory,17 createProviderVersionAdvisory,
16 enrichProviderSnapshotWithVersionAdvisory,18 enrichProviderSnapshotWithVersionAdvisory,
19 homebrewOwnershipFromCommandPath,
20 makeCachedProviderMaintenanceResolution,
17 makePackageManagedProviderMaintenanceResolver,21 makePackageManagedProviderMaintenanceResolver,
18 makeProviderMaintenanceCapabilities,22 makeProviderMaintenanceCapabilities,
19 makeStaticProviderMaintenanceResolver,
20 normalizeCommandPath,23 normalizeCommandPath,
24 npmGlobalPrefixFromCommandPath,
25 parseHomebrewLatestVersion,
21 ProviderVersionCache,26 ProviderVersionCache,
22 resolveLatestProviderVersion,27 resolveLatestProviderVersion,
23 resolveProviderMaintenanceCapabilitiesEffect,28 resolveProviderMaintenanceCapabilitiesEffect,
29 type ProviderMaintenanceCapabilities,
24} from "./providerMaintenance.ts";30} from "./providerMaintenance.ts";
25import { symlinksSupported } from "@t3tools/shared/testing/symlinks";31import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
2632
27const driver = (value: string) => ProviderDriverKind.make(value);33const driver = (value: string) => ProviderDriverKind.make(value);
28// These write `#!/bin/sh` stubs and resolve them through a darwin-mocked34// These write `#!/bin/sh` stubs and evaluate them with darwin/linux path
29// PATH walk; a Windows temp path cannot be split on `:`.35// semantics; a Windows temp path cannot be split on `:`.
30const windowsHost = HostProcessPlatform.defaultValue() === "win32";36const windowsHost = HostProcessPlatform.defaultValue() === "win32";
31
32const makeTempDir = (name: string) =>37const makeTempDir = (name: string) =>
33 Crypto.Crypto.pipe(38 Crypto.Crypto.pipe(
34 Effect.flatMap((crypto) => crypto.randomUUIDv4),39 Effect.flatMap((crypto) => crypto.randomUUIDv4),
...@@ -41,40 +46,16 @@ const isNativeTestCommandPath =...@@ -41,40 +46,16 @@ const isNativeTestCommandPath =
41const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({46const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({
42 provider: driver("packageTool"),47 provider: driver("packageTool"),
43 npmPackageName: "@example/package-tool",48 npmPackageName: "@example/package-tool",
44 homebrewFormula: "package-tool",
45 nativeUpdate: null,49 nativeUpdate: null,
46});50});
47const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({51const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({
48 provider: driver("nativePackageTool"),52 provider: driver("nativePackageTool"),
49 npmPackageName: "@example/native-package-tool",53 npmPackageName: "@example/native-package-tool",
50 homebrewFormula: "native-package-tool",
51 nativeUpdate: {54 nativeUpdate: {
52 executable: "native-package-tool",
53 args: ["update"],55 args: ["update"],
54 lockKey: "native-package-tool-native",
55 isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"),56 isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"),
56 },57 },
57});58});
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);
78const installedPackageToolProvider: ServerProvider = {59const installedPackageToolProvider: ServerProvider = {
79 instanceId: ProviderInstanceId.make("packageTool"),60 instanceId: ProviderInstanceId.make("packageTool"),
80 driver: driver("packageTool"),61 driver: driver("packageTool"),
...@@ -88,10 +69,59 @@ const installedPackageToolProvider: ServerProvider = {...@@ -88,10 +69,59 @@ const installedPackageToolProvider: ServerProvider = {
88 slashCommands: [],69 slashCommands: [],
89 skills: [],70 skills: [],
90};71};
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
92it.layer(NodeServices.layer)("providerMaintenance", (it) => {122it.layer(NodeServices.layer)("providerMaintenance", (it) => {
93 it.effect("reads cached versions through the injectable cache reference", () =>123 it.effect("reads cached versions through the injectable cache reference", () =>
94 resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe(124 resolveLatestProviderVersion(manualPackageTool).pipe(
95 Effect.provideService(125 Effect.provideService(
96 ProviderVersionCache,126 ProviderVersionCache,
97 new Map([127 new Map([
...@@ -116,14 +146,25 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {...@@ -116,14 +146,25 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
116 ),146 ),
117 );147 );
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
119 it.effect("does not fetch latest provider versions when update checks are disabled", () =>164 it.effect("does not fetch latest provider versions when update checks are disabled", () =>
120 enrichProviderSnapshotWithVersionAdvisory(165 enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, {
121 installedPackageToolProvider,166 enableProviderUpdateChecks: false,
122 packageToolUpdate.resolve(),167 }).pipe(
123 {
124 enableProviderUpdateChecks: false,
125 },
126 ).pipe(
127 Effect.provideService(ProviderVersionCache, new Map()),168 Effect.provideService(ProviderVersionCache, new Map()),
128 Effect.provideService(169 Effect.provideService(
129 HttpClient.HttpClient,170 HttpClient.HttpClient,
...@@ -171,562 +212,525 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {...@@ -171,562 +212,525 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
171 });212 });
172 });213 });
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", () => {
175 expect(216 expect(
176 createProviderVersionAdvisory({217 createProviderVersionAdvisory({
177 driver: driver("nativePackageTool"),218 driver: driver("packageTool"),
178 currentVersion: "2.1.110",219 currentVersion: "2.1.110",
179 latestVersion: "2.1.117",220 latestVersion: "2.1.117",
180 maintenanceCapabilities: nativePackageToolUpdate.resolve(),221 maintenanceCapabilities: manualPackageTool,
181 }),222 }),
182 ).toMatchObject({223 ).toMatchObject({
183 status: "behind_latest",224 status: "behind_latest",
184 currentVersion: "2.1.110",
185 latestVersion: "2.1.117",225 latestVersion: "2.1.117",
186 updateCommand:226 updateCommand: null,
187 "npm install -g --allow-scripts=@example/native-package-tool @example/native-package-tool@latest",227 canUpdate: false,
188 canUpdate: true,
189 message: "Install the update now or review provider settings.",228 message: "Install the update now or review provider settings.",
190 });229 });
191 });230 });
192231
193 it("keeps update commands owned by provider maintenance capabilities", () => {232 it.effect("stays manual-only when the binary cannot be located", () =>
194 expect(staticToolUpdate.resolve()).toEqual({233 resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, {
195 provider: driver("staticTool"),234 binaryPath: "package-tool",
196 packageName: null,235 env: { PATH: "" },
197 update: {236 }).pipe(
198 command: "static-tool update",237 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
199238 Effect.map((capabilities) => {
200 executable: "static-tool",239 expect(capabilities).toEqual(manualPackageTool);
201240 }),
202 args: ["update"],241 ),
203242 );
204 lockKey: "static-tool",
205 },
206 });
207 });
208243
209 it.effect.skipIf(windowsHost)(244 it.effect.skipIf(!symlinksSupported)(
210 "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",245 "pins npm updates to the global prefix that owns the package",
211 () =>246 () =>
212 Effect.gen(function* () {247 Effect.gen(function* () {
213 const tempDir = yield* makeTempDir("t3-vite-plus-capabilities");248 const tempDir = yield* makeTempDir("t3-npm-capabilities");
214 const vitePlusBinDir = NodePath.join(tempDir, ".vite-plus", "bin");249 const link = linkIntoPackage(tempDir, "package-tool", [
215 NodeFS.mkdirSync(vitePlusBinDir, { recursive: true });250 "lib",
216 const packageToolPath = NodePath.join(vitePlusBinDir, "package-tool");251 "node_modules",
217 NodeFS.writeFileSync(packageToolPath, "#!/bin/sh\n");252 "@example",
218 NodeFS.chmodSync(packageToolPath, 0o755);253 "package-tool",
254 ]);
219255
220 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(256 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
221 packageToolUpdate,257 packageToolUpdate,
222 {258 {
223 binaryPath: "package-tool",259 binaryPath: link,
224 env: {260 env: { PATH: "" },
225 PATH: vitePlusBinDir,
226 },
227 },261 },
228 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));262 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
229263
230 expect(capabilities).toEqual({264 expect(capabilities).toEqual({
231 provider: driver("packageTool"),265 provider: driver("packageTool"),
232 packageName: "@example/package-tool",266 packageName: "@example/package-tool",
233 update: {267 update: {
234 command: "vp i -g @example/package-tool",268 command: `npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`,
235269 executable: "npm",
236 executable: "vp",270 args: [
237271 "install",
238 args: ["i", "-g", "@example/package-tool"],272 "-g",
239273 "--prefix",
240 lockKey: "vite-plus-global",274 tempDir,
275 "--allow-scripts=@example/package-tool",
276 "@example/package-tool@latest",
277 ],
278 lockKey: `npm-global:${normalizeCommandPath(tempDir)}`,
241 },279 },
242 });280 });
243 }),281 }),
244 );282 );
245283
246 it.effect(284 it("derives the npm prefix only from the global lib/node_modules layout", () => {
247 "switches package-managed providers to bun updates when the resolved binary lives in bun's global bin",285 expect(
248 () =>286 npmGlobalPrefixFromCommandPath(
249 Effect.gen(function* () {287 "/usr/local/lib/node_modules/@openai/codex/bin/codex.js",
250 const tempDir = yield* makeTempDir("t3-bun-capabilities");288 "@openai/codex",
251 const bunBinDir = NodePath.join(tempDir, ".bun", "bin");289 ),
252 NodeFS.mkdirSync(bunBinDir, { recursive: true });290 ).toBe("/usr/local");
253 NodeFS.writeFileSync(NodePath.join(bunBinDir, "native-package-tool.exe"), "MZ");291 // A copy nested inside another package is not a global install.
254292 expect(
255 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(293 npmGlobalPrefixFromCommandPath(
256 nativePackageToolUpdate,294 "/usr/local/lib/node_modules/other/node_modules/@openai/codex/bin/codex.js",
257 {295 "@openai/codex",
258 binaryPath: "native-package-tool",296 ),
259 env: {297 ).toBeNull();
260 PATH: bunBinDir,298 expect(
261 PATHEXT: ".COM;.EXE;.BAT;.CMD",299 npmGlobalPrefixFromCommandPath(
262 },300 "/lib/node_modules/@openai/codex/bin/codex.js",
263 },301 "@openai/codex",
264 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));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({313 it.effect("proves Windows npm ownership from the package manifest beside the shim", () =>
267 provider: driver("nativePackageTool"),314 Effect.gen(function* () {
268 packageName: "@example/native-package-tool",315 const tempDir = yield* makeTempDir("t3-npm-windows-capabilities");
269 update: {316 const shim = NodePath.join(tempDir, "package-tool.cmd");
270 command: "bun i -g @example/native-package-tool@latest",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",340 // The same layout on POSIX is a project checkout, not a global install.
277 },341 const script = NodePath.join(tempDir, "package-tool");
278 });342 writeExecutable(script);
279 }),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 }),
280 );352 );
281353
282 it.effect.skipIf(windowsHost)(354 it.effect.skipIf(!symlinksSupported)(
283 "switches package-managed providers to pnpm updates when the resolved binary lives in pnpm's global bin",355 "switches to pnpm updates when the real path lives in pnpm's global store",
284 () =>356 () =>
285 Effect.gen(function* () {357 Effect.gen(function* () {
286 const tempDir = yield* makeTempDir("t3-pnpm-capabilities");358 const tempDir = yield* makeTempDir("t3-pnpm-capabilities");
287 const pnpmHomeDir = NodePath.join(tempDir, ".local", "share", "pnpm");359 const link = linkIntoPackage(tempDir, "package-tool", [
288 NodeFS.mkdirSync(pnpmHomeDir, { recursive: true });360 ".local",
289 const scopedPackageToolPath = NodePath.join(pnpmHomeDir, "scoped-package-tool");361 "share",
290 NodeFS.writeFileSync(scopedPackageToolPath, "#!/bin/sh\n");362 "pnpm",
291 NodeFS.chmodSync(scopedPackageToolPath, 0o755);363 "global",
364 "5",
365 "node_modules",
366 "@example",
367 "package-tool",
368 ]);
292369
293 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(370 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
294 scopedPackageToolUpdate,371 packageToolUpdate,
295 {372 {
296 binaryPath: "scoped-package-tool",373 binaryPath: link,
297 env: {374 env: { PATH: "" },
298 PATH: pnpmHomeDir,
299 },
300 },375 },
301 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));376 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
302377
303 expect(capabilities).toEqual({378 expect(capabilities.update).toMatchObject({
304 provider: driver("scopedPackageTool"),379 command: "pnpm add -g @example/package-tool@latest",
305 packageName: "@example/scoped-package-tool",380 lockKey: "pnpm-global",
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 },
315 });381 });
316 }),382 }),
317 );383 );
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
342 it.effect.skipIf(windowsHost)(385 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",
344 () =>387 () =>
345 Effect.gen(function* () {388 Effect.gen(function* () {
346 const tempDir = yield* makeTempDir("t3-native-package-tool-native-capabilities");389 const tempDir = yield* makeTempDir("t3-bun-capabilities");
347 const nativeBinDir = NodePath.join(tempDir, ".local", "bin");390 const bunBinDir = NodePath.join(tempDir, ".bun", "bin");
348 NodeFS.mkdirSync(nativeBinDir, { recursive: true });391 writeExecutable(NodePath.join(bunBinDir, "package-tool"));
349 const nativePackageToolPath = NodePath.join(nativeBinDir, "native-package-tool");
350 NodeFS.writeFileSync(nativePackageToolPath, "#!/bin/sh\n");
351 NodeFS.chmodSync(nativePackageToolPath, 0o755);
352392
353 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(393 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
354 nativePackageToolUpdate,394 packageToolUpdate,
355 {395 {
356 binaryPath: "native-package-tool",396 binaryPath: "package-tool",
357 env: {397 env: { PATH: bunBinDir },
358 PATH: nativeBinDir,
359 },
360 },398 },
361 ).pipe(Effect.provideService(HostProcessPlatform, "darwin"));399 ).pipe(
362400 Effect.provideService(HostProcessPlatform, "darwin"),
363 expect(capabilities).toEqual({401 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn),
364 provider: driver("nativePackageTool"),402 );
365 packageName: "@example/native-package-tool",
366 update: {
367 command: `${nativePackageToolPath} update`,
368
369 executable: nativePackageToolPath,
370
371 args: ["update"],
372403
373 lockKey: "native-package-tool-native",404 expect(capabilities.update).toMatchObject({
374 },405 command: "bun i -g @example/package-tool@latest",
406 lockKey: "bun-global",
375 });407 });
376 }),408 }),
377 );409 );
378410
379 it.effect.skipIf(windowsHost)(411 it.effect.skipIf(windowsHost)("switches to native updates and runs the resolved executable", () =>
380 "switches scoped-package-tool to native upgrades when the binary resolves through the standalone installer",412 Effect.gen(function* () {
381 () =>413 const tempDir = yield* makeTempDir("t3-native-capabilities");
382 Effect.gen(function* () {414 const nativeBinDir = NodePath.join(tempDir, ".local", "bin");
383 const tempDir = yield* makeTempDir("t3-scoped-package-tool-native-capabilities");415 const nativePath = NodePath.join(nativeBinDir, "native-package-tool");
384 const nativeBinDir = NodePath.join(tempDir, ".scoped-package-tool", "bin");416 writeExecutable(nativePath);
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,
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",429 expect(capabilities).toEqual({
411 },430 provider: driver("nativePackageTool"),
412 });431 packageName: "@example/native-package-tool",
413 }),432 update: {
433 command: `${nativePath} update`,
434 executable: nativePath,
435 args: ["update"],
436 lockKey: "nativePackageTool-native",
437 },
438 });
439 }),
414 );440 );
415441
442 // Regression for #9850: an explicit native path outside PATH, with spaces,
443 // must be what actually gets spawned.
416 it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () =>444 it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () =>
417 Effect.gen(function* () {445 Effect.gen(function* () {
418 const fs = yield* FileSystem.FileSystem;446 const tempDir = yield* makeTempDir("t3-native-update");
419 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-native-update-" });447 const nativePath = NodePath.join(
420 const nativeBinDir = NodePath.join(tempDir, "with spaces", ".scoped-package-tool", "bin");448 tempDir,
421 yield* fs.makeDirectory(nativeBinDir, { recursive: true });449 "with spaces",
422 const binaryPath = NodePath.join(nativeBinDir, "scoped-package-tool");450 ".local",
423 yield* fs.writeFileString(binaryPath, "#!/bin/sh\nprintf '%s' \"$1\"\n");451 "bin",
424 yield* fs.chmod(binaryPath, 0o755);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
426 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(458 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
427 scopedPackageToolUpdate,459 nativePackageToolUpdate,
428 { binaryPath, env: { PATH: "" } },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" },
429 );468 );
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();
438 expect(result.status).toBe(0);469 expect(result.status).toBe(0);
439 expect(result.stdout).toBe("upgrade");470 expect(result.stdout).toBe("update");
440 }).pipe(Effect.scoped),471 }),
441 );472 );
442473
443 it.each([474 it.effect.skipIf(!symlinksSupported)(
444 "/Users/example/.local/bin/native-package-tool",475 "prefers npm ownership over the Node keg the package lives under",
445 "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe",476 () =>
446 ])("preserves a configured native executable path: %s", (binaryPath) => {477 Effect.gen(function* () {
447 expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath);478 // `brew install node` keeps npm globals inside the node keg.
448 });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"]) {495 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
451 it.effect.skipIf(windowsHost)(496 packageToolUpdate,
452 `runs the copied native update command from a ${directoryName} path`,497 {
453 () =>498 binaryPath: link,
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], {
473 env: { PATH: "" },499 env: { PATH: "" },
474 encoding: "utf8",500 },
475 });501 ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn));
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 });
508502
509 it.effect("uses the environment's platform for copied native update commands", () =>503 expect(capabilities.update).toMatchObject({
510 Effect.gen(function* () {504 executable: "npm",
511 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe";505 args: expect.arrayContaining(["--prefix", keg]),
512 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(506 lockKey: `npm-global:${normalizeCommandPath(keg)}`,
513 nativePackageToolUpdate,507 });
514 { binaryPath, env: { PATH: "" } },508 }),
515 );
516 expect(capabilities.update?.command).toBe(`& '${binaryPath}' update`);
517 expect(capabilities.update?.executable).toBe(binaryPath);
518 }).pipe(Effect.provideService(HostProcessPlatform, "win32")),
519 );509 );
520510
521 it("uses the resolved launcher when its symlink target identifies a native install", () => {511 it("quotes copyable command words the shell would split", () => {
522 const launcher = "/custom tools/native-launcher";512 const posix = makeProviderMaintenanceCapabilities({
523 const capabilities = nativePackageToolUpdate.resolve({513 provider: driver("packageTool"),
524 binaryPath: launcher,514 packageName: "@example/package-tool",
525 resolvedCommandPath: launcher,515 updateExecutable: "npm",
526 realCommandPath: "/Users/example/.local/bin/native-package-tool",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",
527 });536 });
528 expect(capabilities.update?.executable).toBe(launcher);537 expect(windows.update?.command).toBe("& 'C:\\Program Files\\Tool\\tool.exe' update");
529 });538 });
530539
531 it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {540 it.effect.skipIf(windowsHost)("carries the native updater's environment into the action", () =>
532 expect(541 Effect.gen(function* () {
533 nativePackageToolUpdate.resolve({542 const tempDir = yield* makeTempDir("t3-native-env");
534 binaryPath: "/opt/homebrew/bin/native-package-tool",543 const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool");
535 env: {544 writeExecutable(nativePath);
536 PATH: "",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 },
537 },552 },
538 }),553 });
539 ).toEqual({
540 provider: driver("nativePackageTool"),
541 packageName: "@example/native-package-tool",
542 update: {
543 command: "brew upgrade native-package-tool",
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",564 it("recognizes Homebrew kegs and casks from the real executable path", () => {
550 },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",
551 });579 });
552 });580 });
553581
554 it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {582 it.effect.skipIf(windowsHost)(
555 expect(583 "stays manual-only for an explicit binary path that does not exist",
556 scopedPackageToolUpdate.resolve({584 () =>
557 binaryPath: "/opt/homebrew/bin/scoped-package-tool",585 Effect.gen(function* () {
558 env: {586 const tempDir = yield* makeTempDir("t3-missing-native-capabilities");
559 PATH: "",587 const missingPath = NodePath.join(tempDir, ".local", "bin", "native-package-tool");
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",
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",594 expect(capabilities.update).toBeNull();
573 },595 }),
574 });596 );
575 });
576597
577 it.effect.skipIf(!symlinksSupported)(598 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",
579 () =>600 () =>
580 Effect.gen(function* () {601 Effect.gen(function* () {
581 const tempDir = yield* makeTempDir("t3-npm-capabilities");602 const tempDir = yield* makeTempDir("t3-homebrew-capabilities");
582 const binDir = NodePath.join(tempDir, "bin");603 const brewBinDir = NodePath.join(tempDir, "brew-bin");
583 const packageBinDir = NodePath.join(604 const brewPath = NodePath.join(brewBinDir, "brew");
605 writeExecutable(brewPath);
606 const caskBinary = NodePath.join(
584 tempDir,607 tempDir,
585 "lib",608 "Caskroom",
586 "node_modules",609 "package-tool",
587 "@example",610 "0.148.0",
588 "package-tool",611 "package-tool",
589 "bin",
590 );612 );
591 NodeFS.mkdirSync(binDir, { recursive: true });613 writeExecutable(caskBinary);
592 NodeFS.mkdirSync(packageBinDir, { recursive: true });614 const link = NodePath.join(tempDir, "bin", "package-tool");
593 const packageBinPath = NodePath.join(packageBinDir, "package-tool.js");615 NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true });
594 const symlinkPath = NodePath.join(binDir, "package-tool");616 NodeFS.symlinkSync(caskBinary, link);
595 NodeFS.writeFileSync(packageBinPath, "#!/usr/bin/env node\n");617 const spawned: Array<ReadonlyArray<string>> = [];
596 NodeFS.chmodSync(packageBinPath, 0o755);
597 NodeFS.symlinkSync(packageBinPath, symlinkPath);
598618
599 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(619 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
600 packageToolUpdate,620 packageToolUpdate,
601 {621 {
602 binaryPath: symlinkPath,622 binaryPath: link,
603 env: {623 env: { PATH: brewBinDir },
604 PATH: "",
605 },
606 },624 },
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 ),
607 );636 );
608637
638 expect(spawned).toEqual([
639 [brewPath, "--prefix"],
640 [brewPath, "info", "--json=v2", "package-tool"],
641 ]);
609 expect(capabilities).toEqual({642 expect(capabilities).toEqual({
610 provider: driver("packageTool"),643 provider: driver("packageTool"),
611 packageName: "@example/package-tool",644 packageName: "@example/package-tool",
645 latestVersion: "0.148.0",
612 update: {646 update: {
613 command:647 command: "brew upgrade --cask package-tool",
614 "npm install -g --allow-scripts=@example/package-tool @example/package-tool@latest",648 executable: brewPath,
615649 args: ["upgrade", "--cask", "package-tool"],
616 executable: "npm",650 lockKey: "homebrew",
617
618 args: [
619 "install",
620 "-g",
621 "--allow-scripts=@example/package-tool",
622 "@example/package-tool@latest",
623 ],
624
625 lockKey: "npm-global",
626 },651 },
627 });652 });
628 }),653 }),
629 );654 );
630655
631 it.effect.skipIf(!symlinksSupported)(656 it.effect.skipIf(windowsHost)(
632 "uses Effect FileSystem realPath when detecting pnpm global symlinks",657 "stays manual-only when the keg is not under the resolved brew's prefix",
633 () =>658 () =>
634 Effect.gen(function* () {659 Effect.gen(function* () {
635 const tempDir = yield* makeTempDir("t3-pnpm-realpath-capabilities");660 const tempDir = yield* makeTempDir("t3-homebrew-foreign-prefix");
636 const binDir = NodePath.join(tempDir, "bin");661 const brewBinDir = NodePath.join(tempDir, "brew-bin");
637 const packageBinDir = NodePath.join(662 writeExecutable(NodePath.join(brewBinDir, "brew"));
663 const kegBinary = NodePath.join(
638 tempDir,664 tempDir,
639 ".local",665 "elsewhere",
640 "share",666 "Cellar",
641 "pnpm",
642 "global",
643 "5",
644 "node_modules",
645 "@example",
646 "package-tool",667 "package-tool",
668 "1.0.0",
647 "bin",669 "bin",
670 "package-tool",
648 );671 );
649 NodeFS.mkdirSync(binDir, { recursive: true });672 writeExecutable(kegBinary);
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);
656673
657 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(674 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
658 packageToolUpdate,675 packageToolUpdate,
659 {676 {
660 binaryPath: symlinkPath,677 binaryPath: kegBinary,
661 env: {678 env: { PATH: brewBinDir },
662 PATH: "",
663 },
664 },679 },
680 ).pipe(
681 Effect.provideService(HostProcessPlatform, "darwin"),
682 Effect.provideService(
683 ChildProcessSpawner.ChildProcessSpawner,
684 stdoutSpawner(() => "/opt/homebrew\n"),
685 ),
665 );686 );
666687
667 expect(capabilities).toEqual({688 expect(capabilities).toEqual(manualPackageTool);
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 });
680 }),689 }),
681 );690 );
682691
683 it("allows the package's own install scripts in npm global updates", () => {692 it("reads the stable formula version from brew info", () => {
684 const claudeUpdate = makePackageManagedProviderMaintenanceResolver({693 const info = JSON.stringify({ formulae: [{ versions: { stable: "2.1.5" } }] });
685 provider: driver("claudeAgent"),694 const formula = { kind: "formula", name: "claude-code", prefix: "/opt/homebrew" } as const;
686 npmPackageName: "@anthropic-ai/claude-code",695 expect(parseHomebrewLatestVersion(info, formula)).toBe("2.1.5");
687 homebrewFormula: "claude-code",696 expect(parseHomebrewLatestVersion("not json", formula)).toBeNull();
688 nativeUpdate: {697 });
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",
704698
705 args: [699 it.effect.skipIf(windowsHost)(
706 "install",700 "disables one-click updates for explicit custom binary paths it cannot safely map",
707 "-g",701 () =>
708 "--allow-scripts=@anthropic-ai/claude-code",702 Effect.gen(function* () {
709 "@anthropic-ai/claude-code@latest",703 const tempDir = yield* makeTempDir("t3-custom-capabilities");
710 ],704 const customPath = NodePath.join(tempDir, "tools", "package-tool");
705 writeExecutable(customPath);
711706
712 lockKey: "npm-global",707 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
713 },708 packageToolUpdate,
714 });709 {
715 });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", () => {715 expect(capabilities).toEqual(manualPackageTool);
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 },
725 }),716 }),
726 ).toEqual({717 );
727 provider: driver("packageTool"),718
728 packageName: "@example/package-tool",719 it.effect("caches resolution until a fresh read is requested", () =>
729 update: null,720 Effect.gen(function* () {
730 });721 let resolutions = 0;
731 });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 );
732});736});
apps/server/src/provider/providerMaintenance.ts+384-215
...@@ -4,21 +4,37 @@ import {...@@ -4,21 +4,37 @@ import {
4 type ServerProviderVersionAdvisory,4 type ServerProviderVersionAdvisory,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { compareSemverVersions } from "@t3tools/shared/semver";6import { compareSemverVersions } from "@t3tools/shared/semver";
7import { resolveCommandPath } from "@t3tools/shared/shell";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";7import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
8import { causeErrorTag } from "@t3tools/shared/observability";
9import { resolveCommandPath } from "@t3tools/shared/shell";
9import * as Config from "effect/Config";10import * as Config from "effect/Config";
10import * as Context from "effect/Context";11import * as Context from "effect/Context";
11import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
13import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";15import * as FileSystem from "effect/FileSystem";
14import * as Option from "effect/Option";16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
19import * as Stream from "effect/Stream";
16import { HttpClient, HttpClientRequest } from "effect/unstable/http";20import { HttpClient, HttpClientRequest } from "effect/unstable/http";
21import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
22
23import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1724
18const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;25const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
19const LATEST_VERSION_TIMEOUT_MS = 4_000;26const LATEST_VERSION_TIMEOUT_MS = 4_000;
27const HOMEBREW_INFO_TIMEOUT_MS = 10_000;
28const HOMEBREW_INFO_MAX_BYTES = 256 * 1_024;
20const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";29const 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
22const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>38const compactEnv = (input: Record<string, Option.Option<string>>): NodeJS.ProcessEnv =>
23 Object.fromEntries(39 Object.fromEntries(
24 Object.entries(input).flatMap(([key, value]) =>40 Object.entries(input).flatMap(([key, value]) =>
...@@ -42,6 +58,13 @@ export interface ProviderMaintenanceCapabilities {...@@ -42,6 +58,13 @@ export interface ProviderMaintenanceCapabilities {
42 readonly provider: ProviderDriverKind;58 readonly provider: ProviderDriverKind;
43 readonly packageName: string | null;59 readonly packageName: string | null;
44 readonly update: ProviderMaintenanceCommandAction | null;60 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;
45}68}
4669
47export interface ProviderMaintenanceCommandAction {70export interface ProviderMaintenanceCommandAction {
...@@ -49,31 +72,44 @@ export interface ProviderMaintenanceCommandAction {...@@ -49,31 +72,44 @@ export interface ProviderMaintenanceCommandAction {
49 readonly executable: string;72 readonly executable: string;
50 readonly args: ReadonlyArray<string>;73 readonly args: ReadonlyArray<string>;
51 readonly lockKey: string;74 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;
52}82}
5383
54export interface ProviderMaintenanceCapabilityResolutionOptions {84/** Where the provider executable was found; every path is absolute. */
55 readonly binaryPath?: string | null;85export interface ProviderMaintenanceResolutionContext {
56 readonly platform?: NodeJS.Platform;86 readonly binaryPath: string;
57 readonly env?: NodeJS.ProcessEnv;87 readonly resolvedCommandPath: string;
58 readonly resolvedCommandPath?: string | null;88 readonly realCommandPath: string;
59 readonly realCommandPath?: string | null;89 readonly env: NodeJS.ProcessEnv;
90 /** Host platform; decides how the copyable command quotes the executable. */
91 readonly platform: NodeJS.Platform;
60}92}
6193
94export type ProviderMaintenanceResolverServices =
95 | FileSystem.FileSystem
96 | Path.Path
97 | ChildProcessSpawner.ChildProcessSpawner;
98
62export interface ProviderMaintenanceCapabilitiesResolver {99export interface ProviderMaintenanceCapabilitiesResolver {
63 readonly resolve: (100 readonly resolve: (
64 options?: ProviderMaintenanceCapabilityResolutionOptions,101 context: ProviderMaintenanceResolutionContext | null,
65 ) => ProviderMaintenanceCapabilities;102 ) => Effect.Effect<ProviderMaintenanceCapabilities, never, ProviderMaintenanceResolverServices>;
66}103}
67104
68export interface PackageManagedProviderMaintenanceDefinition {105export interface PackageManagedProviderMaintenanceDefinition {
69 readonly provider: ProviderDriverKind;106 readonly provider: ProviderDriverKind;
70 readonly npmPackageName: string;107 readonly npmPackageName: string;
71 readonly homebrewFormula: string | null;
72 readonly nativeUpdate: {108 readonly nativeUpdate: {
73 readonly executable: string;
74 readonly args: ReadonlyArray<string>;109 readonly args: ReadonlyArray<string>;
75 readonly lockKey: string;
76 readonly isCommandPath: (commandPath: string) => boolean;110 readonly isCommandPath: (commandPath: string) => boolean;
111 /** Environment the native updater needs to target this instance's install. */
112 readonly env?: NodeJS.ProcessEnv;
77 } | null;113 } | null;
78}114}
79115
...@@ -96,13 +132,22 @@ function nonEmptyString(value: unknown): string | null {...@@ -96,13 +132,22 @@ function nonEmptyString(value: unknown): string | null {
96 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;132 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
97}133}
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
99function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {147function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {
100 const safePath = platform === "win32" ? /^[\w./:\\-]+$/ : /^[\w./:-]+$/;148 const quoted = quoteShellWord(executable, platform);
101 if (safePath.test(executable)) return executable;
102 // Windows terminals default to PowerShell, where a quoted executable needs &.149 // Windows terminals default to PowerShell, where a quoted executable needs &.
103 return platform === "win32"150 return platform === "win32" && quoted !== executable ? `& ${quoted}` : quoted;
104 ? `& '${executable.replace(/['\u2018\u2019]/g, "$&$&")}'`
105 : `'${executable.replaceAll("'", "'\\''")}'`;
106}151}
107152
108export function makeProviderMaintenanceCapabilities(input: {153export function makeProviderMaintenanceCapabilities(input: {
...@@ -111,27 +156,33 @@ export function makeProviderMaintenanceCapabilities(input: {...@@ -111,27 +156,33 @@ export function makeProviderMaintenanceCapabilities(input: {
111 readonly updateExecutable: string | null;156 readonly updateExecutable: string | null;
112 readonly updateArgs: ReadonlyArray<string>;157 readonly updateArgs: ReadonlyArray<string>;
113 readonly updateLockKey: string | null;158 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;
114 readonly platform?: NodeJS.Platform;161 readonly platform?: NodeJS.Platform;
162 readonly env?: NodeJS.ProcessEnv;
163 readonly latestVersion?: string | null;
115}): ProviderMaintenanceCapabilities {164}): ProviderMaintenanceCapabilities {
165 const platform = input.platform ?? HostProcessPlatform.defaultValue();
116 const update =166 const update =
117 input.updateExecutable === null || input.updateLockKey === null167 input.updateExecutable === null || input.updateLockKey === null
118 ? null168 ? null
119 : {169 : {
120 command: [170 command:
121 quoteUpdateExecutable(171 input.updateCommand ??
122 input.updateExecutable,172 [
123 input.platform ?? HostProcessPlatform.defaultValue(),173 quoteUpdateExecutable(input.updateExecutable, platform),
124 ),174 ...input.updateArgs.map((arg) => quoteShellWord(arg, platform)),
125 ...input.updateArgs,175 ].join(" "),
126 ].join(" "),
127 executable: input.updateExecutable,176 executable: input.updateExecutable,
128 args: input.updateArgs,177 args: input.updateArgs,
129 lockKey: input.updateLockKey,178 lockKey: input.updateLockKey,
179 ...(input.env ? { env: input.env } : {}),
130 };180 };
131 return {181 return {
132 provider: input.provider,182 provider: input.provider,
133 packageName: input.packageName,183 packageName: input.packageName,
134 update,184 update,
185 ...("latestVersion" in input ? { latestVersion: input.latestVersion } : {}),
135 };186 };
136}187}
137188
...@@ -148,118 +199,18 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {...@@ -148,118 +199,18 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: {
148 });199 });
149}200}
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
251export function normalizeCommandPath(commandPath: string): string {202export function normalizeCommandPath(commandPath: string): string {
252 return commandPath.replaceAll("\\", "/").toLowerCase();203 return commandPath.replaceAll("\\", "/").toLowerCase();
253}204}
254205
255function isBunGlobalCommandPath(commandPath: string): boolean {
256 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
257}
258
259function isVitePlusGlobalCommandPath(commandPath: string): boolean {206function isVitePlusGlobalCommandPath(commandPath: string): boolean {
260 return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");207 return normalizeCommandPath(commandPath).includes("/.vite-plus/bin/");
261}208}
262209
210function isBunGlobalCommandPath(commandPath: string): boolean {
211 return normalizeCommandPath(commandPath).includes("/.bun/bin/");
212}
213
263function isPnpmGlobalCommandPath(commandPath: string): boolean {214function isPnpmGlobalCommandPath(commandPath: string): boolean {
264 const normalized = normalizeCommandPath(commandPath);215 const normalized = normalizeCommandPath(commandPath);
265 return (216 return (
...@@ -271,100 +222,287 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean {...@@ -271,100 +222,287 @@ function isPnpmGlobalCommandPath(commandPath: string): boolean {
271 );222 );
272}223}
273224
274function isNpmGlobalCommandPath(commandPath: string): boolean {225/**
275 const normalized = normalizeCommandPath(commandPath);226 * The npm global prefix that owns a package, derived from the real path of
276 return (227 * its entry point: `<prefix>/lib/node_modules/<pkg>/…`. Windows global
277 normalized.includes("/node_modules/.bin/") ||228 * installs have no `lib` segment and are proven by the shim instead (see
278 normalized.includes("/lib/node_modules/") ||229 * `resolveNpmGlobalPrefix`). A project-local `node_modules` is not a global
279 normalized.includes("/npm/node_modules/")230 * install and yields null.
280 );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);
281}244}
282245
283function isHomebrewCommandPath(commandPath: string): boolean {246// `<prefix>/Cellar/<name>/<version>/…` or `<prefix>/Caskroom/<name>/<version>/…`.
284 const normalized = normalizeCommandPath(commandPath);247// Homebrew always nests a version directory under the keg.
285 return (248const HOMEBREW_KEG_PATTERN = /^(.*)\/(cellar|caskroom)\/([^/]+)\/[^/]+\//i;
286 normalized.includes("/opt/homebrew/cellar/") ||249
287 normalized.includes("/usr/local/cellar/") ||250export interface HomebrewOwnership {
288 normalized.includes("/homebrew/cellar/") ||251 readonly kind: "formula" | "cask";
289 normalized.includes("/opt/homebrew/caskroom/") ||252 readonly name: string;
290 normalized.includes("/usr/local/caskroom/") ||253 /** The Homebrew prefix the keg sits under; must match `brew --prefix`. */
291 normalized.includes("/homebrew/caskroom/") ||254 readonly prefix: string;
292 normalized.startsWith("/opt/homebrew/bin/") ||255}
293 normalized.startsWith("/usr/local/bin/")256
294 );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 };
295}274}
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* (
298 definition: PackageManagedProviderMaintenanceDefinition,348 definition: PackageManagedProviderMaintenanceDefinition,
299 options?: ProviderMaintenanceCapabilityResolutionOptions,349 context: ProviderMaintenanceResolutionContext | null,
300): ProviderMaintenanceCapabilities {350) {
301 const binaryPath = nonEmptyString(options?.binaryPath);351 const manual = makeManualOnlyProviderMaintenanceCapabilities({
302 if (!binaryPath) {352 provider: definition.provider,
303 return makeNpmGlobalProviderMaintenanceCapabilities(definition);353 packageName: definition.npmPackageName,
354 });
355 if (!context) {
356 return manual;
304 }357 }
358 const commandPaths = [context.resolvedCommandPath, context.realCommandPath];
359 const packageName = definition.npmPackageName;
305360
306 const resolvedCommandPath =361 const nativeUpdate = definition.nativeUpdate;
307 options?.resolvedCommandPath ?? (hasPathSeparator(binaryPath) ? binaryPath : null);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) {401 // npm proof names the package, so it outranks a keg the path merely passes
310 const commandPaths = [402 // through: a Homebrew-installed Node keeps its globals under
311 resolvedCommandPath,403 // `Cellar/node/<ver>/lib/node_modules/`, and that is npm's install, not brew's.
312 ...(options?.realCommandPath ? [options.realCommandPath] : []),404 const npmPrefix = yield* resolveNpmGlobalPrefix(context, packageName);
313 ];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;
316 if (442 if (
317 nativeUpdate &&443 !realBrewPrefix ||
318 commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))444 normalizeCommandPath(realBrewPrefix) !== normalizeCommandPath(homebrew.prefix)
319 ) {445 ) {
320 return (446 return manual;
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);
342 }447 }
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 });
343 }462 }
344463
345 if (!hasPathSeparator(binaryPath)) {464 return manual;
346 return makeNpmGlobalProviderMaintenanceCapabilities(definition);465});
347 }
348466
349 return makeManualOnlyProviderMaintenanceCapabilities({467/**
350 provider: definition.provider,468 * POSIX npm links `<prefix>/bin/<cmd>` into the package, so the real path is
351 packageName: definition.npmPackageName,469 * proof. Windows npm writes `.cmd` shims beside `node_modules`, so the proof
352 });470 * is the package manifest next to the shim.
353}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
355export function makePackageManagedProviderMaintenanceResolver(501export function makePackageManagedProviderMaintenanceResolver(
356 definition: PackageManagedProviderMaintenanceDefinition,502 definition: PackageManagedProviderMaintenanceDefinition,
357): ProviderMaintenanceCapabilitiesResolver {503): ProviderMaintenanceCapabilitiesResolver {
358 return {504 return {
359 resolve: (options) => resolvePackageManagedProviderMaintenance(definition, options),505 resolve: (context) => resolvePackageManagedProviderMaintenance(definition, context),
360 };
361}
362
363export function makeStaticProviderMaintenanceResolver(
364 capabilities: ProviderMaintenanceCapabilities,
365): ProviderMaintenanceCapabilitiesResolver {
366 return {
367 resolve: () => capabilities,
368 };506 };
369}507}
370508
...@@ -377,40 +515,68 @@ function makeManualProviderMaintenanceCapabilities(...@@ -377,40 +515,68 @@ function makeManualProviderMaintenanceCapabilities(
377 });515 });
378}516}
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 */
380export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(523export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
381 "resolveProviderMaintenanceCapabilitiesEffect",524 "resolveProviderMaintenanceCapabilitiesEffect",
382)(function* (525)(function* (
383 resolver: ProviderMaintenanceCapabilitiesResolver,526 resolver: ProviderMaintenanceCapabilitiesResolver,
384 options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">,527 options?: {
528 readonly binaryPath?: string | null;
529 readonly env?: NodeJS.ProcessEnv;
530 },
385) {531) {
386 const binaryPath = nonEmptyString(options?.binaryPath);532 const binaryPath = nonEmptyString(options?.binaryPath);
387 const platform = options?.platform ?? (yield* HostProcessPlatform);
388 if (!binaryPath) {533 if (!binaryPath) {
389 return resolver.resolve({ ...options, platform });534 return yield* resolver.resolve(null);
390 }535 }
391536
392 const env = options?.env ?? (yield* readCommandLookupEnv);537 const env = options?.env ?? (yield* readCommandLookupEnv);
393 const resolvedCommandPath =538 // resolveCommandPath checks explicit paths for existence too, so a missing
394 (yield* resolveCommandPath(binaryPath, { env }).pipe(539 // binary always lands in the no-context branch and never gets an update
395 Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)),540 // command it cannot run.
396 )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);541 const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }).pipe(
542 Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }),
543 );
397 if (!resolvedCommandPath) {544 if (!resolvedCommandPath) {
398 return resolver.resolve({ ...options, platform });545 return yield* resolver.resolve(null);
399 }546 }
400547
401 const fileSystem = yield* FileSystem.FileSystem;548 const fileSystem = yield* FileSystem.FileSystem;
402 const realCommandPath = yield* fileSystem549 const realCommandPath = yield* fileSystem
403 .realPath(resolvedCommandPath)550 .realPath(resolvedCommandPath)
404 .pipe(Effect.orElseSucceed(() => resolvedCommandPath));551 .pipe(Effect.orElseSucceed(() => null));
405 return resolver.resolve({552 if (!realCommandPath) {
406 ...options,553 return yield* resolver.resolve(null);
407 platform,554 }
408 env,555 return yield* resolver.resolve({
556 binaryPath,
409 resolvedCommandPath,557 resolvedCommandPath,
410 realCommandPath,558 realCommandPath,
559 env,
560 platform: yield* HostProcessPlatform,
411 });561 });
412});562});
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
414function deriveVersionAdvisory(input: {580function deriveVersionAdvisory(input: {
415 readonly currentVersion: string | null;581 readonly currentVersion: string | null;
416 readonly latestVersion: string | null;582 readonly latestVersion: string | null;
...@@ -482,6 +648,9 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack...@@ -482,6 +648,9 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack
482export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (648export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* (
483 maintenanceCapabilities: ProviderMaintenanceCapabilities,649 maintenanceCapabilities: ProviderMaintenanceCapabilities,
484) {650) {
651 if (maintenanceCapabilities.latestVersion !== undefined) {
652 return maintenanceCapabilities.latestVersion;
653 }
485 const packageName = maintenanceCapabilities.packageName;654 const packageName = maintenanceCapabilities.packageName;
486 if (!packageName) {655 if (!packageName) {
487 return null;656 return null;
apps/server/src/provider/providerMaintenanceRunner.test.ts+185-3
...@@ -70,7 +70,7 @@ const baseProvider: ServerProvider = {...@@ -70,7 +70,7 @@ const baseProvider: ServerProvider = {
70 driver: CODEX_DRIVER,70 driver: CODEX_DRIVER,
71 enabled: true,71 enabled: true,
72 installed: true,72 installed: true,
73 version: null,73 version: "0.0.0",
74 status: "ready",74 status: "ready",
75 auth: { status: "authenticated" },75 auth: { status: "authenticated" },
76 checkedAt: "2026-04-10T00:00:00.000Z",76 checkedAt: "2026-04-10T00:00:00.000Z",
...@@ -129,6 +129,7 @@ function mockSpawnerLayer(...@@ -129,6 +129,7 @@ function mockSpawnerLayer(
129 handler: (129 handler: (
130 command: string,130 command: string,
131 args: ReadonlyArray<string>,131 args: ReadonlyArray<string>,
132 options: { readonly env?: NodeJS.ProcessEnv | undefined },
132 ) => {133 ) => {
133 readonly stdout?: string;134 readonly stdout?: string;
134 readonly stderr?: string;135 readonly stderr?: string;
...@@ -142,8 +143,11 @@ function mockSpawnerLayer(...@@ -142,8 +143,11 @@ function mockSpawnerLayer(
142 const childProcess = command as unknown as {143 const childProcess = command as unknown as {
143 readonly command: string;144 readonly command: string;
144 readonly args: ReadonlyArray<string>;145 readonly args: ReadonlyArray<string>;
146 readonly options: { readonly env?: NodeJS.ProcessEnv | undefined };
145 };147 };
146 return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));148 return Effect.succeed(
149 mockHandle(handler(childProcess.command, childProcess.args, childProcess.options)),
150 );
147 }),151 }),
148 );152 );
149}153}
...@@ -198,6 +202,7 @@ function makeRegistry(...@@ -198,6 +202,7 @@ function makeRegistry(
198202
199 return {203 return {
200 registry,204 registry,
205 providersRef,
201 updateStatesRef,206 updateStatesRef,
202 };207 };
203 });208 });
...@@ -210,7 +215,8 @@ const makeTestRunner = (registry: ProviderRegistryShape) =>...@@ -210,7 +215,8 @@ const makeTestRunner = (registry: ProviderRegistryShape) =>
210 Layer.provide(215 Layer.provide(
211 Layer.mergeAll(216 Layer.mergeAll(
212 Layer.succeed(ProviderRegistry, registry),217 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()),
214 ),220 ),
215 ),221 ),
216 ),222 ),
...@@ -250,6 +256,182 @@ describe("providerMaintenanceRunner", () => {...@@ -250,6 +256,182 @@ describe("providerMaintenanceRunner", () => {
250 );256 );
251 });257 });
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
253 it.effect("uses the resolved provider capabilities when choosing the update executable", () => {435 it.effect("uses the resolved provider capabilities when choosing the update executable", () => {
254 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];436 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
255 return Effect.gen(function* () {437 return Effect.gen(function* () {
apps/server/src/provider/providerMaintenanceRunner.ts+63-13
...@@ -23,7 +23,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -23,7 +23,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2323
24import { ProviderRegistry } from "./Services/ProviderRegistry.ts";24import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
25import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";25import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
26import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts";26import {
27 enrichProviderSnapshotWithVersionAdvisory,
28 type ProviderMaintenanceCommandAction,
29 ProviderVersionCache,
30} from "./providerMaintenance.ts";
27import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";31import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
28import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";32import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
29const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);33const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
...@@ -73,6 +77,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR...@@ -73,6 +77,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
73 readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];77 readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
74 readonly command: string;78 readonly command: string;
75 readonly args: ReadonlyArray<string>;79 readonly args: ReadonlyArray<string>;
80 readonly env?: NodeJS.ProcessEnv;
76 }) {81 }) {
77 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(82 const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")(
78 function* () {83 function* () {
...@@ -83,7 +88,12 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR...@@ -83,7 +88,12 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR
83 // shell. On Linux/macOS (incl. the WSL backend) this is a no-op.88 // shell. On Linux/macOS (incl. the WSL backend) this is a no-op.
84 const resolved = yield* resolveSpawnCommand(input.command, input.args);89 const resolved = yield* resolveSpawnCommand(input.command, input.args);
85 const child = yield* input.spawner90 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 )
87 .pipe(97 .pipe(
88 Effect.mapError(98 Effect.mapError(
89 (cause) =>99 (cause) =>
...@@ -181,6 +191,10 @@ function isOutdatedProvider(provider: ServerProvider | undefined): boolean {...@@ -181,6 +191,10 @@ function isOutdatedProvider(provider: ServerProvider | undefined): boolean {
181 return provider?.versionAdvisory?.status === "behind_latest";191 return provider?.versionAdvisory?.status === "behind_latest";
182}192}
183193
194function isStillInstalled(provider: ServerProvider): boolean {
195 return provider.installed;
196}
197
184function makeUpdateState(input: {198function makeUpdateState(input: {
185 readonly status: ServerProviderUpdateState["status"];199 readonly status: ServerProviderUpdateState["status"];
186 readonly startedAt: string | null;200 readonly startedAt: string | null;
...@@ -201,11 +215,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -201,11 +215,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
201 const providerRegistry = yield* ProviderRegistry;215 const providerRegistry = yield* ProviderRegistry;
202 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;216 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
203 const httpClient = yield* HttpClient.HttpClient;217 const httpClient = yield* HttpClient.HttpClient;
204 const runMaintenanceCommand = (command: string, args: ReadonlyArray<string>) =>218 const versionCache = yield* ProviderVersionCache;
219 const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) =>
205 runProviderMaintenanceCommandWithSpawner({220 runProviderMaintenanceCommandWithSpawner({
206 spawner,221 spawner,
207 command,222 command: update.executable,
208 args,223 args: update.args,
224 ...(update.env ? { env: update.env } : {}),
209 });225 });
210 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({226 const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({
211 makeAlreadyRunningError: () =>227 makeAlreadyRunningError: () =>
...@@ -258,7 +274,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -258,7 +274,10 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
258 enrichProviderSnapshotWithVersionAdvisory(274 enrichProviderSnapshotWithVersionAdvisory(
259 refreshedProvider,275 refreshedProvider,
260 maintenanceCapabilities,276 maintenanceCapabilities,
261 ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),277 ).pipe(
278 Effect.provideService(HttpClient.HttpClient, httpClient),
279 Effect.provideService(ProviderVersionCache, versionCache),
280 ),
262 {281 {
263 concurrency: "unbounded",282 concurrency: "unbounded",
264 },283 },
...@@ -337,7 +356,26 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -337,7 +356,26 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
337 }),356 }),
338 );357 );
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);
341 const finishedAt = yield* nowIso;379 const finishedAt = yield* nowIso;
342 if (result.timedOut || result.exitCode !== 0) {380 if (result.timedOut || result.exitCode !== 0) {
343 return yield* finish(381 return yield* finish(
...@@ -351,18 +389,30 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {...@@ -351,18 +389,30 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
351 );389 );
352 }390 }
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 );
354 const { verifiedProviders } = yield* verifyRefreshedProvider(398 const { verifiedProviders } = yield* verifyRefreshedProvider(
355 provider,399 provider,
356 capabilities,400 verified,
357 instanceId,401 instanceId,
358 );402 );
359 const couldNotVerify = verifiedProviders.length === 0;403 // "Succeeded" needs the provider to still be installed: an
360 const stillOutdated =404 // installer that exits 0 and leaves the binary missing is not a
361 couldNotVerify ||405 // success. A missing version alone is not held against it, since
362 verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));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 );
363 return yield* finish(413 return yield* finish(
364 makeUpdateState({414 makeUpdateState({
365 status: stillOutdated ? "unchanged" : "succeeded",415 status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded",
366 startedAt,416 startedAt,
367 finishedAt,417 finishedAt,
368 message: couldNotVerify418 message: couldNotVerify
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+18
...@@ -19,6 +19,13 @@ export type ProviderUpdateCandidate = ServerProvider & {...@@ -19,6 +19,13 @@ export type ProviderUpdateCandidate = ServerProvider & {
19 };19 };
20};20};
2121
22export type ProviderSettingsUpdateCandidate = ServerProvider & {
23 readonly versionAdvisory: NonNullable<ServerProvider["versionAdvisory"]> & {
24 readonly canUpdate: true;
25 readonly updateCommand: string;
26 };
27};
28
22export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";29export type ProviderUpdateToastType = "warning" | "loading" | "error" | "success";
23export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";30export type ProviderUpdateToastPhase = "initial" | "running" | "failed" | "unchanged" | "succeeded";
2431
...@@ -149,6 +156,17 @@ export function collectProviderUpdateCandidates(...@@ -149,6 +156,17 @@ export function collectProviderUpdateCandidates(
149 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));156 return dedupeProvidersByDriver(providers.filter(isProviderUpdateCandidate));
150}157}
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
152export function hasOneClickUpdateProviderCandidate(170export function hasOneClickUpdateProviderCandidate(
153 candidate: ProviderUpdateCandidate,171 candidate: ProviderUpdateCandidate,
154 providers: ReadonlyArray<ServerProvider>,172 providers: ReadonlyArray<ServerProvider>,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+29-38
...@@ -49,11 +49,9 @@ import {...@@ -49,11 +49,9 @@ import {
49 connectionPhasePingClassName,49 connectionPhasePingClassName,
50} from "../ConnectionStatusDot";50} from "../ConnectionStatusDot";
51import {51import {
52 canOneClickUpdateProviderCandidate,52 isProviderSettingsUpdateCandidate,
53 collectProviderUpdateCandidates,
54 hasOneClickUpdateProviderCandidate,
55 isProviderUpdateActive,53 isProviderUpdateActive,
56 type ProviderUpdateCandidate,54 type ProviderSettingsUpdateCandidate,
57} from "../ProviderUpdateLaunchNotification.logic";55} from "../ProviderUpdateLaunchNotification.logic";
58import { Button } from "../ui/button";56import { Button } from "../ui/button";
59import {57import {
...@@ -574,19 +572,20 @@ export function EnvironmentProviderSettings({...@@ -574,19 +572,20 @@ export function EnvironmentProviderSettings({
574 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(572 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
575 targetInstanceId ?? null,573 targetInstanceId ?? null,
576 );574 );
577 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<575 const [updatingProviderInstanceIds, setUpdatingProviderInstanceIds] = useState<
578 ReadonlySet<ProviderDriverKind>576 ReadonlySet<ProviderInstanceId>
579 >(() => new Set());577 >(() => new Set());
580 const refreshingRef = useRef(false);578 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 );
587 const providerUpdateCandidateByInstanceId = useMemo(581 const providerUpdateCandidateByInstanceId = useMemo(
588 () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])),582 () =>
589 [providerUpdateCandidates],583 new Map(
584 serverProviders
585 .filter(isProviderSettingsUpdateCandidate)
586 .map((candidate) => [candidate.instanceId, candidate]),
587 ),
588 [serverProviders],
590 );589 );
591 const visibleProviderSettings = PROVIDER_SETTINGS.filter(590 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
592 (providerSettings) =>591 (providerSettings) =>
...@@ -636,14 +635,14 @@ export function EnvironmentProviderSettings({...@@ -636,14 +635,14 @@ export function EnvironmentProviderSettings({
636 }, [environmentId, refreshServerProviders]);635 }, [environmentId, refreshServerProviders]);
637636
638 const runProviderUpdate = useCallback(637 const runProviderUpdate = useCallback(
639 async (candidate: ProviderUpdateCandidate) => {638 async (candidate: ProviderSettingsUpdateCandidate) => {
640 // Ref-based re-entry guard, mirroring refreshProviders: a state updater639 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
641 // may run after this function returns, so it cannot gate the dispatch.640 // 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)) {
643 return;642 return;
644 }643 }
645 updatingDriversRef.current.add(candidate.driver);644 updatingInstanceIdsRef.current.add(candidate.instanceId);
646 setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver));645 setUpdatingProviderInstanceIds((previous) => new Set(previous).add(candidate.instanceId));
647646
648 const result = await updateProvider({647 const result = await updateProvider({
649 environmentId,648 environmentId,
...@@ -665,13 +664,13 @@ export function EnvironmentProviderSettings({...@@ -665,13 +664,13 @@ export function EnvironmentProviderSettings({
665 }),664 }),
666 );665 );
667 }666 }
668 updatingDriversRef.current.delete(candidate.driver);667 updatingInstanceIdsRef.current.delete(candidate.instanceId);
669 setUpdatingProviderDrivers((previous) => {668 setUpdatingProviderInstanceIds((previous) => {
670 if (!previous.has(candidate.driver)) {669 if (!previous.has(candidate.instanceId)) {
671 return previous;670 return previous;
672 }671 }
673 const next = new Set(previous);672 const next = new Set(previous);
674 next.delete(candidate.driver);673 next.delete(candidate.instanceId);
675 return next;674 return next;
676 });675 });
677 },676 },
...@@ -872,23 +871,13 @@ export function EnvironmentProviderSettings({...@@ -872,23 +871,13 @@ export function EnvironmentProviderSettings({
872 const liveProvider = serverProviders.find(871 const liveProvider = serverProviders.find(
873 (candidate) => candidate.instanceId === row.instanceId,872 (candidate) => candidate.instanceId === row.instanceId,
874 );873 );
875 const updateCandidate = liveProvider874 const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId);
876 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)875 const isInstanceUpdateRunning =
877 : undefined;
878 const isDriverUpdateRunning =
879 updateCandidate !== undefined &&876 updateCandidate !== undefined &&
880 (updatingProviderDrivers.has(updateCandidate.driver) ||877 (updatingProviderInstanceIds.has(updateCandidate.instanceId) ||
881 serverProviders.some(878 isProviderUpdateActive(updateCandidate));
882 (provider) =>879 const showInlineUpdateButton = updateCandidate !== undefined;
883 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),880 const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning;
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);
892 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {881 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
893 hiddenModels: [],882 hiddenModels: [],
894 modelOrder: [],883 modelOrder: [],
...@@ -975,7 +964,9 @@ export function EnvironmentProviderSettings({...@@ -975,7 +964,9 @@ export function EnvironmentProviderSettings({
975 }964 }
976 : undefined965 : undefined
977 }966 }
978 isUpdating={mode === "editor" && showInlineUpdateButton ? isDriverUpdateRunning : undefined}967 isUpdating={
968 mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined
969 }
979 />970 />
980 );971 );
981 };972 };
docs/internals/providers.md+19
...@@ -55,6 +55,25 @@ run before the prompt. They reject profiles with such configuration before launc...@@ -55,6 +55,25 @@ run before the prompt. They reject profiles with such configuration before launc
55instructions and tool denial do not create a native sandbox.55instructions and tool denial do not create a native sandbox.
56See [helper constraints](../../apps/server/src/textGeneration/AntigravityTextGeneration.ts).56See [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
58## Protocol traps77## Protocol traps
5978
60Codex async questions arrive as notifications and are answered with a new user message. There is79Codex 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...@@ -81,6 +81,13 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it
81Cursor's executable is `cursor-agent`, although its login command is81Cursor's executable is `cursor-agent`, although its login command is
82`agent login`. Antigravity can use its managed runtime without a `PATH` entry.82`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
84Add another provider instance for a separate account or configuration. Each91Add another provider instance for a separate account or configuration. Each
85instance can have its own environment variables, such as API keys or a custom92instance can have its own environment variables, such as API keys or a custom
86base URL. Mark secret values as sensitive; after saving, T3 Code does not display93base URL. Mark secret values as sensitive; after saving, T3 Code does not display