1// @effect-diagnostics nodeBuiltinImport:off
2import { expect, it } from "@effect/vitest";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeFS from "node:fs";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Crypto from "effect/Crypto";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as Sink from "effect/Sink";
15import * as Stream from "effect/Stream";
16import * as HttpClient from "effect/http/HttpClient";
17import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
18import * as ProviderLatestVersions from "./ProviderLatestVersions.ts";
19import {
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";
35import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
36
37const 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 `:`.
40const windowsHost = HostProcess.Platform.defaultValue() === "win32";
41const 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 );
46const isNativeTestCommandPath =
47 (expectedPathSegment: string) =>
48 (commandPath: string): boolean =>
49 normalizeCommandPath(commandPath).includes(expectedPathSegment);
50const packageToolUpdate = makePackageManagedProviderMaintenanceResolver({
51 provider: driver("packageTool"),
52 npmPackageName: "@example/package-tool",
53 nativeUpdate: null,
54});
55const 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});
63const 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};
76const manualPackageTool: ProviderMaintenanceCapabilities = {
77 provider: driver("packageTool"),
78 packageName: "@example/package-tool",
79 update: null,
80};
81
82function 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. */
89function 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
98const noSpawn = ChildProcessSpawner.make(() =>
99 Effect.die("maintenance resolution should not spawn a process here"),
100);
101
102function 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
126it.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
956it("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});