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