| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as NodeChildProcess from "node:child_process"; |
| 4 | import * as NodeFS from "node:fs"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import * as NodeOS from "node:os"; |
| 7 | import * as NodePath from "node:path"; |
| 8 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 9 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 10 | import * as Crypto from "effect/Crypto"; |
| 11 | import * as Deferred from "effect/Deferred"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as Sink from "effect/Sink"; |
| 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as HttpClient from "effect/http/HttpClient"; |
| 17 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 18 | import * as ProviderLatestVersions from "./ProviderLatestVersions.ts"; |
| 19 | import { |
| 20 | createProviderVersionAdvisory, |
| 21 | makeTargetedProviderUpdateAction, |
| 22 | enrichProviderSnapshotWithVersionAdvisory, |
| 23 | homebrewOwnershipFromCommandPath, |
| 24 | makeCachedProviderMaintenanceResolution, |
| 25 | makePackageManagedProviderMaintenanceResolver, |
| 26 | makeProviderMaintenanceCapabilities, |
| 27 | normalizeCommandPath, |
| 28 | npmGlobalPrefixFromCommandPath, |
| 29 | parseHomebrewLatestVersion, |
| 30 | resolveLatestProviderVersion, |
| 31 | resolvePackageManagedProviderMaintenance, |
| 32 | resolveProviderMaintenanceCapabilitiesEffect, |
| 33 | type ProviderMaintenanceCapabilities, |
| 34 | } from "./maintenanceResolver.ts"; |
| 35 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 36 | |
| 37 | const driver = (value: string) => ProviderDriverKind.make(value); |
| 38 | // These write `#!/bin/sh` stubs and evaluate them with darwin/linux path |
| 39 | // semantics; a Windows temp path cannot be split on `:`. |
| 40 | const windowsHost = HostProcess.Platform.defaultValue() === "win32"; |
| 41 | const makeTempDir = (name: string) => |
| 42 | Crypto.Crypto.pipe( |
| 43 | Effect.flatMap((crypto) => crypto.randomUUIDv4()), |
| 44 | Effect.map((id) => NodePath.join(NodeFS.realpathSync(NodeOS.tmpdir()), `${name}-${id}`)), |
| 45 | ); |
| 46 | const isNativeTestCommandPath = |
| 47 | (expectedPathSegment: string) => |
| 48 | (commandPath: string): boolean => |
| 49 | normalizeCommandPath(commandPath).includes(expectedPathSegment); |
| 50 | const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({ |
| 51 | provider: driver("packageTool"), |
| 52 | npmPackageName: "@example/package-tool", |
| 53 | nativeUpdate: null, |
| 54 | }); |
| 55 | const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ |
| 56 | provider: driver("nativePackageTool"), |
| 57 | npmPackageName: "@example/native-package-tool", |
| 58 | nativeUpdate: { |
| 59 | args: ["update"], |
| 60 | isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), |
| 61 | }, |
| 62 | }); |
| 63 | const installedPackageToolProvider: ServerProvider = { |
| 64 | instanceId: ProviderInstanceId.make("packageTool"), |
| 65 | driver: driver("packageTool"), |
| 66 | enabled: true, |
| 67 | installed: true, |
| 68 | version: "1.0.0", |
| 69 | status: "ready", |
| 70 | auth: { status: "authenticated" }, |
| 71 | checkedAt: "2026-04-10T00:00:00.000Z", |
| 72 | models: [], |
| 73 | slashCommands: [], |
| 74 | skills: [], |
| 75 | }; |
| 76 | const manualPackageTool: ProviderMaintenanceCapabilities = { |
| 77 | provider: driver("packageTool"), |
| 78 | packageName: "@example/package-tool", |
| 79 | update: null, |
| 80 | }; |
| 81 | |
| 82 | function writeExecutable(path: string) { |
| 83 | NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); |
| 84 | NodeFS.writeFileSync(path, "#!/bin/sh\n"); |
| 85 | NodeFS.chmodSync(path, 0o755); |
| 86 | } |
| 87 | |
| 88 | /** Symlink `<tempDir>/bin/<name>` into a package entry point, like npm/pnpm do. */ |
| 89 | function linkIntoPackage(tempDir: string, name: string, packageSegments: ReadonlyArray<string>) { |
| 90 | const target = NodePath.join(tempDir, ...packageSegments, "bin", `${name}.js`); |
| 91 | writeExecutable(target); |
| 92 | const link = NodePath.join(tempDir, "bin", name); |
| 93 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); |
| 94 | NodeFS.symlinkSync(target, link); |
| 95 | return link; |
| 96 | } |
| 97 | |
| 98 | const noSpawn = ChildProcessSpawner.make(() => |
| 99 | Effect.die("maintenance resolution should not spawn a process here"), |
| 100 | ); |
| 101 | |
| 102 | function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray<string>) => string) { |
| 103 | return ChildProcessSpawner.make((command) => { |
| 104 | const { command: executable, args } = command as unknown as { |
| 105 | readonly command: string; |
| 106 | readonly args: ReadonlyArray<string>; |
| 107 | }; |
| 108 | return Effect.succeed( |
| 109 | ChildProcessSpawner.makeHandle({ |
| 110 | pid: ChildProcessSpawner.ProcessId(1), |
| 111 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), |
| 112 | isRunning: Effect.succeed(false), |
| 113 | kill: () => Effect.void, |
| 114 | unref: Effect.succeed(Effect.void), |
| 115 | stdin: Sink.drain, |
| 116 | stdout: Stream.encodeText(Stream.make(onSpawn(executable, args))), |
| 117 | stderr: Stream.empty, |
| 118 | all: Stream.empty, |
| 119 | getInputFd: () => Sink.drain, |
| 120 | getOutputFd: () => Stream.empty, |
| 121 | }), |
| 122 | ); |
| 123 | }); |
| 124 | } |
| 125 | |
| 126 | it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 127 | it.effect("reads cached versions through ProviderLatestVersions", () => |
| 128 | resolveLatestProviderVersion(manualPackageTool).pipe( |
| 129 | Effect.provideServiceEffect( |
| 130 | ProviderLatestVersions.ProviderLatestVersions, |
| 131 | ProviderLatestVersions.make([["@example/package-tool", "9.9.9"]]), |
| 132 | ), |
| 133 | Effect.provideService( |
| 134 | HttpClient.HttpClient, |
| 135 | HttpClient.make(() => |
| 136 | Effect.die("cached provider version should not make an HTTP request"), |
| 137 | ), |
| 138 | ), |
| 139 | Effect.map((version) => { |
| 140 | expect(version).toBe("9.9.9"); |
| 141 | }), |
| 142 | ), |
| 143 | ); |
| 144 | |
| 145 | it.effect("prefers the installer's own latest version over the npm registry", () => |
| 146 | resolveLatestProviderVersion({ ...manualPackageTool, latestVersion: "1.2.0" }).pipe( |
| 147 | Effect.provide(ProviderLatestVersions.layer), |
| 148 | Effect.provideService( |
| 149 | HttpClient.HttpClient, |
| 150 | HttpClient.make(() => |
| 151 | Effect.die("installer-reported latest should not make an HTTP request"), |
| 152 | ), |
| 153 | ), |
| 154 | Effect.map((version) => { |
| 155 | expect(version).toBe("1.2.0"); |
| 156 | }), |
| 157 | ), |
| 158 | ); |
| 159 | |
| 160 | it.effect("does not fetch latest provider versions when update checks are disabled", () => |
| 161 | enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, { |
| 162 | enableProviderUpdateChecks: false, |
| 163 | }).pipe( |
| 164 | Effect.provide(ProviderLatestVersions.layer), |
| 165 | Effect.provideService( |
| 166 | HttpClient.HttpClient, |
| 167 | HttpClient.make(() => |
| 168 | Effect.die("disabled provider update checks should not make an HTTP request"), |
| 169 | ), |
| 170 | ), |
| 171 | Effect.map((provider) => { |
| 172 | expect(provider.versionAdvisory).toMatchObject({ |
| 173 | status: "unknown", |
| 174 | currentVersion: "1.0.0", |
| 175 | latestVersion: null, |
| 176 | checkedAt: "2026-04-10T00:00:00.000Z", |
| 177 | }); |
| 178 | }), |
| 179 | ), |
| 180 | ); |
| 181 | |
| 182 | it("marks providers with unknown current versions as unknown", () => { |
| 183 | expect( |
| 184 | createProviderVersionAdvisory({ |
| 185 | driver: driver("packageTool"), |
| 186 | currentVersion: null, |
| 187 | latestVersion: "9.9.9", |
| 188 | }), |
| 189 | ).toMatchObject({ |
| 190 | status: "unknown", |
| 191 | currentVersion: null, |
| 192 | latestVersion: "9.9.9", |
| 193 | }); |
| 194 | }); |
| 195 | |
| 196 | it("marks providers with unknown latest versions as unknown", () => { |
| 197 | expect( |
| 198 | createProviderVersionAdvisory({ |
| 199 | driver: driver("packageTool"), |
| 200 | currentVersion: "1.0.0", |
| 201 | latestVersion: null, |
| 202 | }), |
| 203 | ).toMatchObject({ |
| 204 | status: "unknown", |
| 205 | currentVersion: "1.0.0", |
| 206 | latestVersion: null, |
| 207 | message: null, |
| 208 | }); |
| 209 | }); |
| 210 | |
| 211 | it("keeps the manual update hint when the install is behind but unowned", () => { |
| 212 | expect( |
| 213 | createProviderVersionAdvisory({ |
| 214 | driver: driver("packageTool"), |
| 215 | currentVersion: "2.1.110", |
| 216 | latestVersion: "2.1.117", |
| 217 | maintenanceCapabilities: manualPackageTool, |
| 218 | }), |
| 219 | ).toMatchObject({ |
| 220 | status: "behind_latest", |
| 221 | latestVersion: "2.1.117", |
| 222 | updateCommand: null, |
| 223 | canUpdate: false, |
| 224 | message: "Install the update now or review provider settings.", |
| 225 | }); |
| 226 | }); |
| 227 | |
| 228 | it.effect("stays manual-only when the binary cannot be located", () => |
| 229 | resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 230 | binaryPath: "package-tool", |
| 231 | env: { PATH: "" }, |
| 232 | }).pipe( |
| 233 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 234 | Effect.map((capabilities) => { |
| 235 | expect(capabilities).toEqual(manualPackageTool); |
| 236 | }), |
| 237 | ), |
| 238 | ); |
| 239 | |
| 240 | it.effect.skipIf(!symlinksSupported)( |
| 241 | "pins npm updates to the global prefix that owns the package", |
| 242 | () => |
| 243 | Effect.gen(function* () { |
| 244 | const tempDir = yield* makeTempDir("t3-npm-capabilities"); |
| 245 | const link = linkIntoPackage(tempDir, "package-tool", [ |
| 246 | "lib", |
| 247 | "node_modules", |
| 248 | "@example", |
| 249 | "package-tool", |
| 250 | ]); |
| 251 | |
| 252 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 253 | packageToolUpdate, |
| 254 | { |
| 255 | binaryPath: link, |
| 256 | env: { PATH: "" }, |
| 257 | }, |
| 258 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 259 | |
| 260 | expect(capabilities).toEqual({ |
| 261 | provider: driver("packageTool"), |
| 262 | packageName: "@example/package-tool", |
| 263 | update: { |
| 264 | command: `npm install -g --prefix ${tempDir} --allow-scripts=@example/package-tool @example/package-tool@latest`, |
| 265 | executable: "npm", |
| 266 | args: [ |
| 267 | "install", |
| 268 | "-g", |
| 269 | "--prefix", |
| 270 | tempDir, |
| 271 | "--allow-scripts=@example/package-tool", |
| 272 | "@example/package-tool@latest", |
| 273 | ], |
| 274 | lockKey: `npm-global:${normalizeCommandPath(tempDir)}`, |
| 275 | }, |
| 276 | }); |
| 277 | }), |
| 278 | ); |
| 279 | |
| 280 | it("derives the npm prefix only from the global lib/node_modules layout", () => { |
| 281 | expect( |
| 282 | npmGlobalPrefixFromCommandPath( |
| 283 | "/usr/local/lib/node_modules/@openai/codex/bin/codex.js", |
| 284 | "@openai/codex", |
| 285 | ), |
| 286 | ).toBe("/usr/local"); |
| 287 | // A copy nested inside another package is not a global install. |
| 288 | expect( |
| 289 | npmGlobalPrefixFromCommandPath( |
| 290 | "/usr/local/lib/node_modules/other/node_modules/@openai/codex/bin/codex.js", |
| 291 | "@openai/codex", |
| 292 | ), |
| 293 | ).toBeNull(); |
| 294 | expect( |
| 295 | npmGlobalPrefixFromCommandPath( |
| 296 | "/lib/node_modules/@openai/codex/bin/codex.js", |
| 297 | "@openai/codex", |
| 298 | ), |
| 299 | ).toBe("/"); |
| 300 | // Neither is a project-local dependency. |
| 301 | expect( |
| 302 | npmGlobalPrefixFromCommandPath( |
| 303 | "/work/app/node_modules/@openai/codex/bin/codex.js", |
| 304 | "@openai/codex", |
| 305 | ), |
| 306 | ).toBeNull(); |
| 307 | }); |
| 308 | |
| 309 | // The Codex Windows installer exposes `%LOCALAPPDATA%\\Programs\\OpenAI\\Codex\\bin` |
| 310 | // as a junction into `%CODEX_HOME%\\packages\\standalone\\current\\bin`. Node's |
| 311 | // realpath follows junctions, so the real path carries the standalone marker |
| 312 | // even though the visible path does not. |
| 313 | it.effect("recognizes a Windows standalone install through its junctioned bin dir", () => |
| 314 | Effect.gen(function* () { |
| 315 | const visiblePath = |
| 316 | "C:\\Users\\Theo\\AppData\\Local\\Programs\\OpenAI\\Codex\\bin\\codex.exe"; |
| 317 | const realPath = |
| 318 | "C:\\Users\\Theo\\.codex\\packages\\standalone\\releases\\0.120.0-x86_64\\bin\\codex.exe"; |
| 319 | const capabilities = yield* resolvePackageManagedProviderMaintenance( |
| 320 | { |
| 321 | provider: driver("codex"), |
| 322 | npmPackageName: "@openai/codex", |
| 323 | nativeUpdate: { |
| 324 | args: ["update"], |
| 325 | isCommandPath: isNativeTestCommandPath("/packages/standalone/"), |
| 326 | }, |
| 327 | }, |
| 328 | { |
| 329 | binaryPath: "codex", |
| 330 | resolvedCommandPath: visiblePath, |
| 331 | realCommandPath: realPath, |
| 332 | env: {}, |
| 333 | platform: "win32", |
| 334 | }, |
| 335 | ).pipe(Effect.provideService(HostProcess.Platform, "win32")); |
| 336 | |
| 337 | expect(capabilities.update).toMatchObject({ |
| 338 | executable: visiblePath, |
| 339 | args: ["update"], |
| 340 | lockKey: "codex-native", |
| 341 | }); |
| 342 | }), |
| 343 | ); |
| 344 | |
| 345 | it.effect("proves Windows npm ownership from the package manifest beside the shim", () => |
| 346 | Effect.gen(function* () { |
| 347 | const tempDir = yield* makeTempDir("t3-npm-windows-capabilities"); |
| 348 | const shim = NodePath.join(tempDir, "package-tool.cmd"); |
| 349 | NodeFS.mkdirSync(tempDir, { recursive: true }); |
| 350 | NodeFS.writeFileSync(shim, "@echo off\r\n"); |
| 351 | NodeFS.mkdirSync(NodePath.join(tempDir, "node_modules", "@example", "package-tool"), { |
| 352 | recursive: true, |
| 353 | }); |
| 354 | NodeFS.writeFileSync( |
| 355 | NodePath.join(tempDir, "node_modules", "@example", "package-tool", "package.json"), |
| 356 | "{}", |
| 357 | ); |
| 358 | |
| 359 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 360 | binaryPath: shim, |
| 361 | env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, |
| 362 | }).pipe( |
| 363 | Effect.provideService(HostProcess.Platform, "win32"), |
| 364 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 365 | ); |
| 366 | |
| 367 | expect(capabilities.update).toMatchObject({ |
| 368 | executable: "npm", |
| 369 | args: ["install", "-g", "--prefix", tempDir, expect.any(String), expect.any(String)], |
| 370 | }); |
| 371 | |
| 372 | // The same layout on POSIX is a project checkout, not a global install. |
| 373 | const script = NodePath.join(tempDir, "package-tool"); |
| 374 | writeExecutable(script); |
| 375 | const posix = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 376 | binaryPath: script, |
| 377 | env: { PATH: "" }, |
| 378 | }).pipe( |
| 379 | Effect.provideService(HostProcess.Platform, "linux"), |
| 380 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 381 | ); |
| 382 | expect(posix.update).toBeNull(); |
| 383 | }), |
| 384 | ); |
| 385 | |
| 386 | it.effect.skipIf(!symlinksSupported)( |
| 387 | "switches to pnpm updates when the real path lives in pnpm's global store", |
| 388 | () => |
| 389 | Effect.gen(function* () { |
| 390 | const tempDir = yield* makeTempDir("t3-pnpm-capabilities"); |
| 391 | const link = linkIntoPackage(tempDir, "package-tool", [ |
| 392 | ".local", |
| 393 | "share", |
| 394 | "pnpm", |
| 395 | "global", |
| 396 | "5", |
| 397 | "node_modules", |
| 398 | "@example", |
| 399 | "package-tool", |
| 400 | ]); |
| 401 | |
| 402 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 403 | packageToolUpdate, |
| 404 | { |
| 405 | binaryPath: link, |
| 406 | env: { PATH: "" }, |
| 407 | }, |
| 408 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 409 | |
| 410 | expect(capabilities.update).toMatchObject({ |
| 411 | command: "pnpm add -g @example/package-tool@latest", |
| 412 | lockKey: "pnpm-global", |
| 413 | }); |
| 414 | }), |
| 415 | ); |
| 416 | |
| 417 | it.effect.skipIf(windowsHost)( |
| 418 | "switches to bun updates when the resolved binary lives in bun's global bin", |
| 419 | () => |
| 420 | Effect.gen(function* () { |
| 421 | const tempDir = yield* makeTempDir("t3-bun-capabilities"); |
| 422 | const bunBinDir = NodePath.join(tempDir, ".bun", "bin"); |
| 423 | writeExecutable(NodePath.join(bunBinDir, "package-tool")); |
| 424 | |
| 425 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 426 | packageToolUpdate, |
| 427 | { |
| 428 | binaryPath: "package-tool", |
| 429 | env: { PATH: bunBinDir }, |
| 430 | }, |
| 431 | ).pipe( |
| 432 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 433 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 434 | ); |
| 435 | |
| 436 | expect(capabilities.update).toMatchObject({ |
| 437 | command: "bun i -g @example/package-tool@latest", |
| 438 | lockKey: "bun-global", |
| 439 | }); |
| 440 | }), |
| 441 | ); |
| 442 | |
| 443 | it.effect.skipIf(windowsHost)("switches to native updates and runs the resolved executable", () => |
| 444 | Effect.gen(function* () { |
| 445 | const tempDir = yield* makeTempDir("t3-native-capabilities"); |
| 446 | const nativeBinDir = NodePath.join(tempDir, ".local", "bin"); |
| 447 | const nativePath = NodePath.join(nativeBinDir, "native-package-tool"); |
| 448 | writeExecutable(nativePath); |
| 449 | |
| 450 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 451 | nativePackageToolUpdate, |
| 452 | { |
| 453 | binaryPath: "native-package-tool", |
| 454 | env: { PATH: nativeBinDir }, |
| 455 | }, |
| 456 | ).pipe( |
| 457 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 458 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), |
| 459 | ); |
| 460 | |
| 461 | expect(capabilities).toEqual({ |
| 462 | provider: driver("nativePackageTool"), |
| 463 | packageName: "@example/native-package-tool", |
| 464 | update: { |
| 465 | command: `${nativePath} update`, |
| 466 | executable: nativePath, |
| 467 | args: ["update"], |
| 468 | lockKey: "nativePackageTool-native", |
| 469 | }, |
| 470 | }); |
| 471 | }), |
| 472 | ); |
| 473 | |
| 474 | // Regression for #9850: an explicit native path outside PATH, with spaces, |
| 475 | // must be what actually gets spawned. |
| 476 | it.effect.skipIf(windowsHost)("runs an explicit native updater outside PATH", () => |
| 477 | Effect.gen(function* () { |
| 478 | const tempDir = yield* makeTempDir("t3-native-update"); |
| 479 | const nativePath = NodePath.join( |
| 480 | tempDir, |
| 481 | "with spaces", |
| 482 | ".local", |
| 483 | "bin", |
| 484 | "native-package-tool", |
| 485 | ); |
| 486 | NodeFS.mkdirSync(NodePath.dirname(nativePath), { recursive: true }); |
| 487 | NodeFS.writeFileSync(nativePath, "#!/bin/sh\nprintf '%s' \"$1\"\n"); |
| 488 | NodeFS.chmodSync(nativePath, 0o755); |
| 489 | |
| 490 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 491 | nativePackageToolUpdate, |
| 492 | { binaryPath: nativePath, env: { PATH: "" } }, |
| 493 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 494 | |
| 495 | expect(capabilities.update?.executable).toBe(nativePath); |
| 496 | const result = NodeChildProcess.spawnSync( |
| 497 | capabilities.update!.executable, |
| 498 | capabilities.update!.args, |
| 499 | { env: { PATH: "" }, encoding: "utf8" }, |
| 500 | ); |
| 501 | expect(result.status).toBe(0); |
| 502 | expect(result.stdout).toBe("update"); |
| 503 | }), |
| 504 | ); |
| 505 | |
| 506 | it.effect.skipIf(!symlinksSupported)( |
| 507 | "prefers npm ownership over the Node keg the package lives under", |
| 508 | () => |
| 509 | Effect.gen(function* () { |
| 510 | // `brew install node` keeps npm globals inside the node keg. |
| 511 | const tempDir = yield* makeTempDir("t3-homebrew-node-capabilities"); |
| 512 | const keg = NodePath.join(tempDir, "Cellar", "node", "22.1.0"); |
| 513 | const target = NodePath.join( |
| 514 | keg, |
| 515 | "lib", |
| 516 | "node_modules", |
| 517 | "@example", |
| 518 | "package-tool", |
| 519 | "bin", |
| 520 | "package-tool.js", |
| 521 | ); |
| 522 | writeExecutable(target); |
| 523 | const link = NodePath.join(tempDir, "bin", "package-tool"); |
| 524 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); |
| 525 | NodeFS.symlinkSync(target, link); |
| 526 | |
| 527 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 528 | packageToolUpdate, |
| 529 | { |
| 530 | binaryPath: link, |
| 531 | env: { PATH: "" }, |
| 532 | }, |
| 533 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 534 | |
| 535 | expect(capabilities.update).toMatchObject({ |
| 536 | executable: "npm", |
| 537 | args: expect.arrayContaining(["--prefix", keg]), |
| 538 | lockKey: `npm-global:${normalizeCommandPath(keg)}`, |
| 539 | }); |
| 540 | }), |
| 541 | ); |
| 542 | |
| 543 | it("quotes copyable command words the shell would split", () => { |
| 544 | const posix = makeProviderMaintenanceCapabilities({ |
| 545 | provider: driver("packageTool"), |
| 546 | packageName: "@example/package-tool", |
| 547 | updateExecutable: "npm", |
| 548 | updateArgs: [ |
| 549 | "install", |
| 550 | "-g", |
| 551 | "--prefix", |
| 552 | "/Users/Jane Doe/.npm-global", |
| 553 | "@example/package-tool@latest", |
| 554 | ], |
| 555 | updateLockKey: "npm-global", |
| 556 | platform: "darwin", |
| 557 | }); |
| 558 | expect(posix.update?.command).toBe( |
| 559 | "npm install -g --prefix '/Users/Jane Doe/.npm-global' @example/package-tool@latest", |
| 560 | ); |
| 561 | const windows = makeProviderMaintenanceCapabilities({ |
| 562 | provider: driver("packageTool"), |
| 563 | packageName: null, |
| 564 | updateExecutable: "C:\\Program Files\\Tool\\tool.exe", |
| 565 | updateArgs: ["update"], |
| 566 | updateLockKey: "tool", |
| 567 | platform: "win32", |
| 568 | }); |
| 569 | expect(windows.update?.command).toBe("& 'C:\\Program Files\\Tool\\tool.exe' update"); |
| 570 | }); |
| 571 | |
| 572 | it.effect.skipIf(windowsHost)("carries the native updater's environment into the action", () => |
| 573 | Effect.gen(function* () { |
| 574 | const tempDir = yield* makeTempDir("t3-native-env"); |
| 575 | const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); |
| 576 | writeExecutable(nativePath); |
| 577 | const resolver = makePackageManagedProviderMaintenanceResolver({ |
| 578 | provider: driver("nativePackageTool"), |
| 579 | npmPackageName: "@example/native-package-tool", |
| 580 | nativeUpdate: { |
| 581 | args: ["update"], |
| 582 | isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), |
| 583 | env: { TOOL_HOME: tempDir }, |
| 584 | }, |
| 585 | }); |
| 586 | |
| 587 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(resolver, { |
| 588 | binaryPath: nativePath, |
| 589 | env: { PATH: "" }, |
| 590 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 591 | |
| 592 | expect(capabilities.update?.env).toEqual({ TOOL_HOME: tempDir }); |
| 593 | }), |
| 594 | ); |
| 595 | |
| 596 | it("recognizes Homebrew kegs and casks from the real executable path", () => { |
| 597 | expect( |
| 598 | homebrewOwnershipFromCommandPath("/opt/homebrew/Cellar/claude-code@latest/2.1.0/bin/claude"), |
| 599 | ).toEqual({ kind: "formula", name: "claude-code@latest", prefix: "/opt/homebrew" }); |
| 600 | expect(homebrewOwnershipFromCommandPath("/usr/local/Caskroom/codex/0.148.0/codex")).toEqual({ |
| 601 | kind: "cask", |
| 602 | name: "codex", |
| 603 | prefix: "/usr/local", |
| 604 | }); |
| 605 | // A plain /usr/local/bin binary is not evidence of Homebrew (#8832). |
| 606 | expect(homebrewOwnershipFromCommandPath("/usr/local/bin/codex")).toBeNull(); |
| 607 | // A keg elsewhere reports its prefix so the resolver can reject it against |
| 608 | // `brew --prefix`. |
| 609 | expect(homebrewOwnershipFromCommandPath("/srv/Cellar/claude/1.0.0/bin/claude")).toMatchObject({ |
| 610 | prefix: "/srv", |
| 611 | }); |
| 612 | }); |
| 613 | |
| 614 | it.effect.skipIf(windowsHost)( |
| 615 | "stays manual-only for an explicit binary path that does not exist", |
| 616 | () => |
| 617 | Effect.gen(function* () { |
| 618 | const tempDir = yield* makeTempDir("t3-missing-native-capabilities"); |
| 619 | const missingPath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); |
| 620 | |
| 621 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 622 | nativePackageToolUpdate, |
| 623 | { binaryPath: missingPath, env: { PATH: "" } }, |
| 624 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 625 | |
| 626 | expect(capabilities.update).toBeNull(); |
| 627 | }), |
| 628 | ); |
| 629 | |
| 630 | it.effect.each([ |
| 631 | { directory: "Caskroom", name: "package-tool", kind: "cask" }, |
| 632 | { directory: "Cellar", name: "package-tool", kind: "formula" }, |
| 633 | { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, |
| 634 | ] as const)( |
| 635 | "upgrades the owning Homebrew $kind $name through an executable alias", |
| 636 | (fixture) => |
| 637 | Effect.gen(function* () { |
| 638 | const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); |
| 639 | const brewBinDir = NodePath.join(tempDir, "brew-bin"); |
| 640 | const brewPath = NodePath.join(brewBinDir, "brew"); |
| 641 | writeExecutable(brewPath); |
| 642 | const ownedBinary = NodePath.join( |
| 643 | tempDir, |
| 644 | fixture.directory, |
| 645 | fixture.name, |
| 646 | "0.148.0", |
| 647 | "package-tool-0.148.0", |
| 648 | ); |
| 649 | writeExecutable(ownedBinary); |
| 650 | const link = NodePath.join(tempDir, "bin", "custom-package-tool"); |
| 651 | NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); |
| 652 | NodeFS.symlinkSync(ownedBinary, link); |
| 653 | const spawned: Array<ReadonlyArray<string>> = []; |
| 654 | |
| 655 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 656 | packageToolUpdate, |
| 657 | { |
| 658 | binaryPath: link, |
| 659 | env: { PATH: brewBinDir }, |
| 660 | }, |
| 661 | ).pipe( |
| 662 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 663 | Effect.provideService( |
| 664 | ChildProcessSpawner.ChildProcessSpawner, |
| 665 | stdoutSpawner((command, args) => { |
| 666 | spawned.push([command, ...args]); |
| 667 | return args[0] === "--prefix" |
| 668 | ? `${tempDir}\n` |
| 669 | : JSON.stringify( |
| 670 | fixture.kind === "cask" |
| 671 | ? { casks: [{ version: "0.148.0,42" }] } |
| 672 | : { formulae: [{ versions: { stable: "0.148.0" } }] }, |
| 673 | ); |
| 674 | }), |
| 675 | ), |
| 676 | ); |
| 677 | |
| 678 | expect(spawned).toEqual([ |
| 679 | [brewPath, "--prefix"], |
| 680 | [brewPath, "info", "--json=v2", fixture.name], |
| 681 | ]); |
| 682 | expect(capabilities).toEqual({ |
| 683 | provider: driver("packageTool"), |
| 684 | packageName: "@example/package-tool", |
| 685 | latestVersion: "0.148.0", |
| 686 | update: { |
| 687 | command: |
| 688 | fixture.kind === "cask" |
| 689 | ? `brew upgrade --cask ${fixture.name}` |
| 690 | : `brew upgrade ${fixture.name}`, |
| 691 | executable: brewPath, |
| 692 | args: |
| 693 | fixture.kind === "cask" |
| 694 | ? ["upgrade", "--cask", fixture.name] |
| 695 | : ["upgrade", fixture.name], |
| 696 | lockKey: "homebrew", |
| 697 | }, |
| 698 | }); |
| 699 | }), |
| 700 | { skip: !symlinksSupported }, |
| 701 | ); |
| 702 | |
| 703 | it.effect.skipIf(windowsHost)( |
| 704 | "stays manual-only when the keg is not under the resolved brew's prefix", |
| 705 | () => |
| 706 | Effect.gen(function* () { |
| 707 | const tempDir = yield* makeTempDir("t3-homebrew-foreign-prefix"); |
| 708 | const brewBinDir = NodePath.join(tempDir, "brew-bin"); |
| 709 | writeExecutable(NodePath.join(brewBinDir, "brew")); |
| 710 | const kegBinary = NodePath.join( |
| 711 | tempDir, |
| 712 | "elsewhere", |
| 713 | "Cellar", |
| 714 | "package-tool", |
| 715 | "1.0.0", |
| 716 | "bin", |
| 717 | "package-tool", |
| 718 | ); |
| 719 | writeExecutable(kegBinary); |
| 720 | |
| 721 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 722 | packageToolUpdate, |
| 723 | { |
| 724 | binaryPath: kegBinary, |
| 725 | env: { PATH: brewBinDir }, |
| 726 | }, |
| 727 | ).pipe( |
| 728 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 729 | Effect.provideService( |
| 730 | ChildProcessSpawner.ChildProcessSpawner, |
| 731 | stdoutSpawner(() => "/opt/homebrew\n"), |
| 732 | ), |
| 733 | ); |
| 734 | |
| 735 | expect(capabilities).toEqual(manualPackageTool); |
| 736 | }), |
| 737 | ); |
| 738 | |
| 739 | it("reads the stable formula version from brew info", () => { |
| 740 | const info = JSON.stringify({ formulae: [{ versions: { stable: "2.1.5" } }] }); |
| 741 | const formula = { kind: "formula", name: "claude-code", prefix: "/opt/homebrew" } as const; |
| 742 | expect(parseHomebrewLatestVersion(info, formula)).toBe("2.1.5"); |
| 743 | expect(parseHomebrewLatestVersion("not json", formula)).toBeNull(); |
| 744 | }); |
| 745 | |
| 746 | it.effect.skipIf(windowsHost)( |
| 747 | "disables one-click updates for explicit custom binary paths it cannot safely map", |
| 748 | () => |
| 749 | Effect.gen(function* () { |
| 750 | const tempDir = yield* makeTempDir("t3-custom-capabilities"); |
| 751 | const customPath = NodePath.join(tempDir, "tools", "package-tool"); |
| 752 | writeExecutable(customPath); |
| 753 | |
| 754 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( |
| 755 | packageToolUpdate, |
| 756 | { |
| 757 | binaryPath: customPath, |
| 758 | env: { PATH: "" }, |
| 759 | }, |
| 760 | ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 761 | |
| 762 | expect(capabilities).toEqual(manualPackageTool); |
| 763 | }), |
| 764 | ); |
| 765 | |
| 766 | it.effect.skipIf(windowsHost)( |
| 767 | "falls back to the provider's own updater when no installer is proven", |
| 768 | () => |
| 769 | Effect.gen(function* () { |
| 770 | const tempDir = yield* makeTempDir("t3-self-update-fallback"); |
| 771 | const customPath = NodePath.join(tempDir, "tools", "package-tool"); |
| 772 | writeExecutable(customPath); |
| 773 | const selfUpdating = makePackageManagedProviderMaintenanceResolver({ |
| 774 | provider: driver("packageTool"), |
| 775 | npmPackageName: "@example/package-tool", |
| 776 | nativeUpdate: { args: ["update", "--self"] }, |
| 777 | }); |
| 778 | |
| 779 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, { |
| 780 | binaryPath: customPath, |
| 781 | env: { PATH: "" }, |
| 782 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 783 | expect(capabilities.update).toMatchObject({ |
| 784 | executable: customPath, |
| 785 | args: ["update", "--self"], |
| 786 | lockKey: "packageTool-native", |
| 787 | }); |
| 788 | |
| 789 | // A mise install is pinned in mise's config, so it stays manual. |
| 790 | const misePath = NodePath.join(tempDir, "mise", "installs", "package-tool", "1.0.0", "bin"); |
| 791 | writeExecutable(NodePath.join(misePath, "package-tool")); |
| 792 | const mise = yield* resolveProviderMaintenanceCapabilitiesEffect(selfUpdating, { |
| 793 | binaryPath: NodePath.join(misePath, "package-tool"), |
| 794 | env: { PATH: "" }, |
| 795 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 796 | expect(mise.update).toBeNull(); |
| 797 | }), |
| 798 | ); |
| 799 | |
| 800 | it.effect.skipIf(!symlinksSupported)("updates Yarn global installs with yarn", () => |
| 801 | Effect.gen(function* () { |
| 802 | const tempDir = yield* makeTempDir("t3-yarn-capabilities"); |
| 803 | const link = linkIntoPackage(tempDir, "package-tool", [ |
| 804 | ".config", |
| 805 | "yarn", |
| 806 | "global", |
| 807 | "node_modules", |
| 808 | "@example", |
| 809 | "package-tool", |
| 810 | ]); |
| 811 | |
| 812 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 813 | binaryPath: link, |
| 814 | env: { PATH: "" }, |
| 815 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 816 | |
| 817 | expect(capabilities.update).toMatchObject({ |
| 818 | command: "yarn global add @example/package-tool@latest", |
| 819 | lockKey: "yarn-global", |
| 820 | }); |
| 821 | expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0")?.args).toEqual([ |
| 822 | "global", |
| 823 | "add", |
| 824 | "@example/package-tool@2.0.0", |
| 825 | ]); |
| 826 | }), |
| 827 | ); |
| 828 | |
| 829 | it.effect.skipIf(!symlinksSupported)( |
| 830 | "updates Volta installs only when Volta has the package", |
| 831 | () => |
| 832 | Effect.gen(function* () { |
| 833 | const voltaHome = NodePath.join(yield* makeTempDir("t3-volta-capabilities"), ".volta"); |
| 834 | const shim = NodePath.join(voltaHome, "bin", "volta-shim"); |
| 835 | writeExecutable(shim); |
| 836 | const link = NodePath.join(voltaHome, "bin", "package-tool"); |
| 837 | NodeFS.symlinkSync(shim, link); |
| 838 | const resolve = resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 839 | binaryPath: link, |
| 840 | env: { PATH: "" }, |
| 841 | }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); |
| 842 | |
| 843 | expect((yield* resolve).update).toBeNull(); |
| 844 | NodeFS.mkdirSync( |
| 845 | NodePath.join(voltaHome, "tools", "image", "packages", "@example", "package-tool"), |
| 846 | { recursive: true }, |
| 847 | ); |
| 848 | expect((yield* resolve).update).toMatchObject({ |
| 849 | command: "volta install @example/package-tool@latest", |
| 850 | lockKey: "volta", |
| 851 | }); |
| 852 | }), |
| 853 | ); |
| 854 | |
| 855 | it.effect.skipIf(windowsHost)("upgrades with the keg's own brew when brew is not on PATH", () => |
| 856 | Effect.gen(function* () { |
| 857 | const tempDir = yield* makeTempDir("t3-homebrew-keg-brew"); |
| 858 | const brewPath = NodePath.join(tempDir, "bin", "brew"); |
| 859 | writeExecutable(brewPath); |
| 860 | const kegBinary = NodePath.join(tempDir, "Cellar", "package-tool", "1.0.0", "bin", "tool"); |
| 861 | writeExecutable(kegBinary); |
| 862 | |
| 863 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { |
| 864 | binaryPath: kegBinary, |
| 865 | env: { PATH: "" }, |
| 866 | }).pipe( |
| 867 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 868 | Effect.provideService( |
| 869 | ChildProcessSpawner.ChildProcessSpawner, |
| 870 | stdoutSpawner((_command, args) => (args[0] === "--prefix" ? `${tempDir}\n` : "{}")), |
| 871 | ), |
| 872 | ); |
| 873 | |
| 874 | expect(capabilities.update).toMatchObject({ |
| 875 | executable: brewPath, |
| 876 | args: ["upgrade", "package-tool"], |
| 877 | lockKey: "homebrew", |
| 878 | }); |
| 879 | }), |
| 880 | ); |
| 881 | |
| 882 | it.effect("caches resolution until a fresh read is requested", () => |
| 883 | Effect.gen(function* () { |
| 884 | let resolutions = 0; |
| 885 | const resolve = yield* makeCachedProviderMaintenanceResolution( |
| 886 | Effect.sync(() => { |
| 887 | resolutions += 1; |
| 888 | return manualPackageTool; |
| 889 | }), |
| 890 | ); |
| 891 | yield* resolve(); |
| 892 | yield* resolve(); |
| 893 | expect(resolutions).toBe(1); |
| 894 | yield* resolve({ fresh: true }); |
| 895 | yield* resolve(); |
| 896 | expect(resolutions).toBe(2); |
| 897 | }), |
| 898 | ); |
| 899 | |
| 900 | it.effect("starts a fresh read instead of joining the resolution already running", () => |
| 901 | Effect.gen(function* () { |
| 902 | const releaseStale = yield* Deferred.make<void>(); |
| 903 | let resolutions = 0; |
| 904 | const resolve = yield* makeCachedProviderMaintenanceResolution( |
| 905 | Effect.suspend(() => { |
| 906 | resolutions += 1; |
| 907 | return resolutions === 1 |
| 908 | ? Deferred.await(releaseStale).pipe(Effect.as(manualPackageTool)) |
| 909 | : Effect.succeed(manualPackageTool); |
| 910 | }), |
| 911 | ); |
| 912 | |
| 913 | // An advisory read starts a resolution that is still running when the |
| 914 | // user clicks Update; the fresh read must not wait on or reuse it. |
| 915 | const advisory = yield* Effect.forkChild(resolve(), { startImmediately: true }); |
| 916 | const fresh = yield* Effect.forkChild(resolve({ fresh: true }), { startImmediately: true }); |
| 917 | // The fresh read runs its own resolution, so it finishes while the stale |
| 918 | // one is still waiting. |
| 919 | expect(resolutions).toBe(2); |
| 920 | expect(fresh.pollUnsafe()).toBeDefined(); |
| 921 | yield* Deferred.succeed(releaseStale, undefined); |
| 922 | expect(yield* Fiber.join(fresh)).toEqual(manualPackageTool); |
| 923 | yield* Fiber.join(advisory); |
| 924 | }), |
| 925 | ); |
| 926 | |
| 927 | it.effect("resolves again after the first read is interrupted", () => |
| 928 | Effect.gen(function* () { |
| 929 | const release = yield* Deferred.make<void>(); |
| 930 | let resolutions = 0; |
| 931 | const resolve = yield* makeCachedProviderMaintenanceResolution( |
| 932 | Effect.suspend(() => { |
| 933 | resolutions += 1; |
| 934 | return resolutions === 1 |
| 935 | ? Effect.never |
| 936 | : Deferred.await(release).pipe(Effect.as(manualPackageTool)); |
| 937 | }), |
| 938 | ); |
| 939 | |
| 940 | const first = yield* Effect.forkChild(resolve(), { startImmediately: true }); |
| 941 | yield* Fiber.interrupt(first); |
| 942 | |
| 943 | // The next read resolves again, and a reader that joined it keeps it |
| 944 | // running when the reader that started it goes away. |
| 945 | const owner = yield* Effect.forkChild(resolve(), { startImmediately: true }); |
| 946 | const waiter = yield* Effect.forkChild(resolve(), { startImmediately: true }); |
| 947 | yield* Fiber.interrupt(owner); |
| 948 | yield* Deferred.succeed(release, undefined); |
| 949 | expect(yield* Fiber.join(waiter)).toEqual(manualPackageTool); |
| 950 | expect(yield* resolve()).toEqual(manualPackageTool); |
| 951 | expect(resolutions).toBe(2); |
| 952 | }), |
| 953 | ); |
| 954 | }); |
| 955 | |
| 956 | it("pins only owned package-manager installs and preserves their execution context", () => { |
| 957 | const capabilities = makeProviderMaintenanceCapabilities({ |
| 958 | provider: driver("codex"), |
| 959 | packageName: "@openai/codex", |
| 960 | updateExecutable: "npm", |
| 961 | updateLockKey: "npm-global:/opt/node", |
| 962 | updateArgs: [ |
| 963 | "install", |
| 964 | "-g", |
| 965 | "--prefix", |
| 966 | "/opt/node", |
| 967 | "--allow-scripts=@openai/codex", |
| 968 | "@openai/codex@latest", |
| 969 | ], |
| 970 | env: { PATH: "/opt/node/bin" }, |
| 971 | }); |
| 972 | const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); |
| 973 | expect(pinned).toMatchObject({ |
| 974 | executable: "npm", |
| 975 | lockKey: capabilities.update?.lockKey, |
| 976 | env: capabilities.update?.env, |
| 977 | args: [ |
| 978 | "install", |
| 979 | "-g", |
| 980 | "--prefix", |
| 981 | "/opt/node", |
| 982 | "--allow-scripts=@openai/codex", |
| 983 | "@openai/codex@2.0.0", |
| 984 | ], |
| 985 | }); |
| 986 | expect(pinned?.command).toContain("@openai/codex@2.0.0"); |
| 987 | for (const lockKey of ["codex-native", "homebrew", "manual"]) { |
| 988 | expect( |
| 989 | makeTargetedProviderUpdateAction( |
| 990 | { ...capabilities, update: { ...capabilities.update!, lockKey } }, |
| 991 | "2.0.0", |
| 992 | ), |
| 993 | ).toBeNull(); |
| 994 | } |
| 995 | expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); |
| 996 | }); |