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