anyray-connect 0.11.75 → 0.11.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/cli.js +564 -301
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/desktop.js +6 -1
  4. package/dist/commands/desktop.js.map +1 -1
  5. package/dist/commands/doctor.js +43 -16
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/migrate.js +17 -4
  8. package/dist/commands/migrate.js.map +1 -1
  9. package/dist/commands/projectApply.js +267 -0
  10. package/dist/commands/projectApply.js.map +1 -0
  11. package/dist/commands/quitWatch.js +124 -0
  12. package/dist/commands/quitWatch.js.map +1 -0
  13. package/dist/commands/status.js +11 -0
  14. package/dist/commands/status.js.map +1 -1
  15. package/dist/target.js +1 -1
  16. package/dist/target.js.map +1 -1
  17. package/dist/tools/claudeCode.js +1 -1
  18. package/dist/tools/claudeCode.js.map +1 -1
  19. package/dist/tools/claudeDesktop.js +232 -1
  20. package/dist/tools/claudeDesktop.js.map +1 -1
  21. package/dist/tools/claudeDesktopNative.js +9 -1
  22. package/dist/tools/claudeDesktopNative.js.map +1 -1
  23. package/dist/tools/claudeDesktopProfileNotice.js +28 -19
  24. package/dist/tools/claudeDesktopProfileNotice.js.map +1 -1
  25. package/dist/tools/copilotChat.js +22 -24
  26. package/dist/tools/copilotChat.js.map +1 -1
  27. package/dist/tools/copilotCli.js +20 -44
  28. package/dist/tools/copilotCli.js.map +1 -1
  29. package/dist/tools/cursor.js +126 -38
  30. package/dist/tools/cursor.js.map +1 -1
  31. package/dist/types.js.map +1 -1
  32. package/dist/util/appQuit.js +331 -0
  33. package/dist/util/appQuit.js.map +1 -0
  34. package/dist/util/desktopProfileMigrate.js +39 -4
  35. package/dist/util/desktopProfileMigrate.js.map +1 -1
  36. package/dist/util/devRefresh.js +6 -0
  37. package/dist/util/devRefresh.js.map +1 -1
  38. package/dist/util/historyStore.js +0 -0
  39. package/dist/util/historyStore.js.map +1 -1
  40. package/dist/util/pendingOps.js +416 -0
  41. package/dist/util/pendingOps.js.map +1 -0
  42. package/dist/util/proxyService.js +15 -0
  43. package/dist/util/proxyService.js.map +1 -1
  44. package/dist/util/refreshScheduler.js +19 -0
  45. package/dist/util/refreshScheduler.js.map +1 -1
  46. package/dist/util/remoteContext.js +55 -0
  47. package/dist/util/remoteContext.js.map +1 -0
  48. package/dist/util/vscodeProcess.js +119 -0
  49. package/dist/util/vscodeProcess.js.map +1 -0
  50. package/dist/version.js +1 -1
  51. package/package.json +1 -1
  52. package/dist/commands/cursorWatch.js +0 -95
  53. package/dist/commands/cursorWatch.js.map +0 -1
  54. package/dist/util/cursorPending.js +0 -188
  55. package/dist/util/cursorPending.js.map +0 -1
package/dist/cli.js CHANGED
@@ -12,11 +12,13 @@
12
12
  import { isPersonalClientKey, isInsecureRemoteOrigin, normalizeOrigin, resolveOrigin, resolveTarget, withLocalProxy, } from "./target.js";
13
13
  import { pickLoopbackPort, installProxyService, uninstallProxyService, probeHealth, DEFAULT_LOOPBACK_PORT, } from "./util/proxyService.js";
14
14
  import { installRefreshScheduler, uninstallRefreshScheduler, } from "./util/refreshScheduler.js";
15
+ import { detectRemoteDevBackend, isContainerBackend, } from "./util/remoteContext.js";
15
16
  import { runRefreshProxy } from "./commands/refreshProxy.js";
16
17
  import { loopbackEngaged } from "./util/loopback.js";
17
- import { runCursorWatch } from "./commands/cursorWatch.js";
18
+ import { runQuitWatch } from "./commands/quitWatch.js";
18
19
  import { runManaged } from "./commands/managed.js";
19
20
  import { runPrintKey } from "./commands/printKey.js";
21
+ import { runProjectApply } from "./commands/projectApply.js";
20
22
  import { runClaudeDesktopCredential, } from "./commands/claudeDesktopCredential.js";
21
23
  import { runDesktop } from "./commands/desktop.js";
22
24
  import { runRecallCommand, runRetrieveCommand } from "./commands/retrieval.js";
@@ -33,6 +35,7 @@ import { runSelfUpdate } from "./util/selfUpdate.js";
33
35
  import { REGISTRY, byId } from "./tools/index.js";
34
36
  import { staleShellExportsForTargets } from "./tools/shellEnv.js";
35
37
  import { effectiveSubscription, metadataHeaderValue } from "./types.js";
38
+ import { quitTargetKey, quitTargetLabel, relaunchResolvable, quitTargetSupported, relaunchApp, requestGracefulQuit, waitForQuit, } from "./util/appQuit.js";
36
39
  import { bold, cyan, dim, fail, heading, info, note, ok, warn, } from "./util/log.js";
37
40
  import { clearDevKey, clearEnrollment, hasEnrollment, loadProfile, recordManagedTools, saveProfile, saveDevKey, loadDevKey, stateDir, ensureStateDir, purgeRetiredFallbackState, } from "./util/persist.js";
38
41
  import { confirm, isInteractive, prompt, resolveDisplayName, resolveTeam, } from "./commands/shared.js";
@@ -57,6 +60,7 @@ import { runAcpAgent } from "./commands/acpAgent.js";
57
60
  import { deriveMachineId } from "./util/machineId.js";
58
61
  import { relaunchFromDurableRuntime } from "./util/durableRuntime.js";
59
62
  import { CLAUDE_DESKTOP_CREDENTIAL_ARG, } from "./tools/claudeDesktopCredentialProtocol.js";
63
+ import { platform } from "node:os";
60
64
  const HELP = `${bold("anyray-connect")} — point your local coding tools at the Anyray gateway
61
65
 
62
66
  ${bold("Usage")}
@@ -176,6 +180,12 @@ ${bold("Options")}
176
180
  LiteLLM virtual key). Stored locally (mode 600) and in the
177
181
  tool config, never sent to Anyray's control plane.
178
182
  --tools <ids> Comma-separated subset: ${REGISTRY.map((t) => t.id).join(", ")}
183
+ --project Write committable, credential-free routing config into the
184
+ CURRENT repo (.claude/settings.json + .codex/config.toml)
185
+ instead of this machine's user config — so devcontainers,
186
+ Codespaces, SSH checkouts, and CI route without a per-machine
187
+ apply. Org API lane only; each machine still enrolls once.
188
+ Combine with --revert to remove it.
179
189
  --dry-run Show what would change without writing anything
180
190
  --yes, -y Don't prompt for confirmation (for scripted rollout)
181
191
  --force Skip the gateway reachability check before writing
@@ -186,6 +196,15 @@ ${bold("Options")}
186
196
  daemon fills a freshly re-minted key on each request so a
187
197
  long session never 401s on an expired key mid-flight; this
188
198
  forces the legacy lane that bakes the key once at launch.
199
+ --quit-apps Close the apps this run reconfigures — and reopen them when
200
+ it finishes — without asking. Only apps that are open AND
201
+ whose configuration actually changes; the quit is always the
202
+ polite one the app may refuse, never a force kill. An
203
+ interactive run is asked once and defaults to NO; --yes, a
204
+ non-TTY, --dry-run and the managed/MDM payloads close
205
+ nothing unless this flag is passed explicitly.
206
+ --no-quit-apps Never close anything, overriding --quit-apps. Apps that were
207
+ open keep their old routing until you restart them yourself.
189
208
  --revert Undo a previous run: unpick every tool config we wrote and
190
209
  clear the stored enrollment. Reconnecting needs --sso (or a
191
210
  fresh --enroll link). Cannot be combined with --sso or
@@ -278,6 +297,9 @@ export const parseArgs = (argv) => {
278
297
  .map((s) => s.trim())
279
298
  .filter(Boolean);
280
299
  break;
300
+ case "--project":
301
+ args.project = true;
302
+ break;
281
303
  case "--dry-run":
282
304
  args.dryRun = true;
283
305
  break;
@@ -298,6 +320,12 @@ export const parseArgs = (argv) => {
298
320
  case "--no-key-refresh":
299
321
  args.noKeyRefresh = true;
300
322
  break;
323
+ case "--quit-apps":
324
+ args.quitApps = true;
325
+ break;
326
+ case "--no-quit-apps":
327
+ args.noQuitApps = true;
328
+ break;
301
329
  case "--revert":
302
330
  args.revert = true;
303
331
  break;
@@ -488,6 +516,10 @@ explicitIds) => {
488
516
  : await adapter.apply(target, {
489
517
  ...opts,
490
518
  explicit: explicitIds?.has(adapter.id) === true,
519
+ // This loop prints every message below, so an adapter may take a
520
+ // side action on the developer's own files here. The background
521
+ // lanes call `apply` directly and never set this.
522
+ reportsToUser: true,
491
523
  });
492
524
  for (const m of result.messages)
493
525
  info(` ${m}`);
@@ -536,6 +568,153 @@ export const manualStepsNotice = (titles) => {
536
568
  "Then restart or reload the app as instructed; use `npx anyray-connect status` for the available route checks.",
537
569
  ];
538
570
  };
571
+ /**
572
+ * The plan pass: which open apps would this apply actually affect?
573
+ *
574
+ * An editor caches its environment at launch and Cursor / Claude Desktop cannot
575
+ * have their stores rewritten while they are open, so a config written now does
576
+ * not take effect until the app restarts. Rather than printing "restart X" and
577
+ * hoping, Connect offers to do it — but only for an adapter that is
578
+ * installed, declares how its app is named to each platform
579
+ * (`quitGated.quitTarget`, read off the REGISTRY so this file never names an
580
+ * app), is running, and is NOT already pointed at this gateway.
581
+ *
582
+ * That last clause is what keeps a no-op re-run silent: nothing changes, so
583
+ * nobody's apps get closed.
584
+ *
585
+ * KNOWN BLIND SPOT, deliberately not guessed at: `pointsAt` reads the tool's
586
+ * CONFIG on disk, so it cannot see "configured, but the running process still
587
+ * holds the environment it cached at launch" — that app reads as correctly
588
+ * configured here and is left alone. `doctor` owns that case; inferring it
589
+ * would mean offering to close apps on every single re-run.
590
+ *
591
+ * Read-only and best-effort throughout: an adapter probe that throws simply
592
+ * drops out of the plan.
593
+ */
594
+ export const planAppRestarts = async (adapters, origin,
595
+ // Explicit so a test can assert a platform deliberately. Reading the host's
596
+ // real platform made three of these tests pass on macOS and fail on Linux CI,
597
+ // because the fixture named no Linux spelling and the skip was invisible.
598
+ runtimePlatform = platform()) => {
599
+ const candidates = [];
600
+ // One APP, not one adapter. VS Code carries both Copilot adapters, so without
601
+ // this the developer is asked twice about the same window and it is closed
602
+ // twice — the second quit landing on an app that is already gone.
603
+ const claimedApps = new Set();
604
+ for (const adapter of adapters) {
605
+ const seam = adapter.quitGated;
606
+ const quitTarget = seam?.quitTarget;
607
+ if (!seam || !quitTarget)
608
+ continue;
609
+ // A target that doesn't name this platform can't be honoured here, and an
610
+ // offer we can't honour reads as a broken promise.
611
+ if (!quitTargetSupported(quitTarget, runtimePlatform))
612
+ continue;
613
+ // Never close what we cannot reopen (Flatpak/AppImage editors on Linux).
614
+ if (!relaunchResolvable(quitTarget, runtimePlatform))
615
+ continue;
616
+ const app = quitTargetKey(quitTarget, runtimePlatform);
617
+ if (claimedApps.has(app))
618
+ continue;
619
+ try {
620
+ const detected = await adapter.detect();
621
+ if (!detected.installed)
622
+ continue;
623
+ if (!seam.isRunning())
624
+ continue;
625
+ if ((await adapter.pointsAt?.(origin)) === true)
626
+ continue;
627
+ }
628
+ catch {
629
+ // Never let a probe failure turn into an offer to close someone's editor.
630
+ continue;
631
+ }
632
+ claimedApps.add(app);
633
+ candidates.push({
634
+ id: adapter.id,
635
+ // The APP's name, not the adapter's — see `quitTargetLabel`.
636
+ title: quitTargetLabel(quitTarget),
637
+ quitTarget,
638
+ isRunning: () => seam.isRunning(),
639
+ });
640
+ }
641
+ return candidates;
642
+ };
643
+ /**
644
+ * May this invocation close apps at all? Consent has to be present on the
645
+ * command line or at a prompt:
646
+ * - `--no-quit-apps` refuses, and wins over everything else.
647
+ * - `--dry-run` writes nothing, so it closes nothing (it reports the offer).
648
+ * - `--quit-apps` is an operator asking for it explicitly — the only way a
649
+ * scripted run gets it.
650
+ * - otherwise: an interactive TTY that was not handed `--yes` may ASK (the
651
+ * prompt itself defaults to no).
652
+ *
653
+ * `--yes`, a non-TTY and the managed/MDM payloads therefore close NOTHING by
654
+ * default. That is the hard rule: a buyer rolling Connect out to 500 seats must
655
+ * never have it close an app on someone's machine.
656
+ */
657
+ export const quitAppsAllowed = (args, interactive) => {
658
+ if (args.noQuitApps === true)
659
+ return false;
660
+ if (args.dryRun)
661
+ return false;
662
+ if (args.quitApps === true)
663
+ return true;
664
+ return !args.yes && interactive;
665
+ };
666
+ /** The one-line offer. Returned as a string so it's testable without a TTY. */
667
+ export const appRestartOffer = (titles) => {
668
+ const which = titles.length === 1
669
+ ? `${titles[0]} is open and won't pick this up until it restarts. Quit it now?`
670
+ : `${titles.slice(0, -1).join(", ")} and ${titles[titles.length - 1]} are open and won't pick this up until they restart. Quit them now?`;
671
+ return `\n${which}`;
672
+ };
673
+ /**
674
+ * Ask each accepted app to quit and confirm it actually went away. Returns the
675
+ * ones Connect really closed — the exact set the relaunch is allowed to reopen.
676
+ *
677
+ * An app that refuses (an unsaved buffer, a modal) is a legitimate answer: say
678
+ * so plainly and carry on with the apply, which leaves the adapter's own
679
+ * queue-on-quit path to finish the job whenever the developer closes it. Never
680
+ * escalate to a force kill, never retry — these apps write state on exit.
681
+ */
682
+ export const quitAppsNow = async (candidates, deps = {}) => {
683
+ const requestQuit = deps.requestQuit ?? ((target) => requestGracefulQuit(target));
684
+ const waitFor = deps.waitFor ?? ((isRunning) => waitForQuit(isRunning));
685
+ const closed = [];
686
+ for (const candidate of candidates) {
687
+ const delivered = requestQuit(candidate.quitTarget);
688
+ const gone = delivered && (await waitFor(candidate.isRunning));
689
+ if (gone) {
690
+ closed.push(candidate);
691
+ ok(` Closed ${candidate.title}.`);
692
+ continue;
693
+ }
694
+ warn(` ${candidate.title} is still open — leaving it alone.`);
695
+ note(` Its configuration is written either way; ${candidate.title} picks it up the next time you close it.`);
696
+ }
697
+ return closed;
698
+ };
699
+ /**
700
+ * Reopen exactly what we closed, under the consent that closed it — never a
701
+ * second prompt, and never an app the developer closed themselves. Leaving
702
+ * someone's IDE shut is a worse outcome than never having offered.
703
+ */
704
+ export const relaunchApps = (closed, deps = {}) => {
705
+ const relaunch = deps.relaunch ?? ((target) => relaunchApp(target));
706
+ const reopened = [];
707
+ for (const candidate of closed) {
708
+ if (relaunch(candidate.quitTarget)) {
709
+ reopened.push(candidate.title);
710
+ ok(` Reopened ${candidate.title}.`);
711
+ }
712
+ else {
713
+ warn(` Could not reopen ${candidate.title} — start it when you're ready.`);
714
+ }
715
+ }
716
+ return reopened;
717
+ };
539
718
  /**
540
719
  * May the wizard put this tool in its prefilled selection?
541
720
  *
@@ -706,9 +885,13 @@ export const setupLoopbackProxy = async (target, port, deps = {}) => {
706
885
  ? // Install succeeded but the port never bound — a transient miss the
707
886
  // next run retries against the same supervisor.
708
887
  " It will retry on the next run; check the service logs if it persists."
709
- : // No supervised daemon on this platform (svc.messages said why); the
710
- // scheduled ~30-min re-mint still keeps the seat's key fresh.
711
- " Your key still refreshes on a schedule no daemon needed here."));
888
+ : isContainerBackend(detectRemoteDevBackend())
889
+ ? // Container: the OS schedule is skipped too (no service manager), so
890
+ // don't promise one the helper + re-runs are what refresh here.
891
+ " In a container the key refreshes via Claude Code's helper and on each `anyray-connect` run instead."
892
+ : // No supervised daemon on this platform (svc.messages said why); the
893
+ // scheduled ~30-min re-mint still keeps the seat's key fresh.
894
+ " Your key still refreshes on a schedule — no daemon needed here."));
712
895
  return target;
713
896
  };
714
897
  /**
@@ -873,12 +1056,14 @@ export const run = async (argv) => {
873
1056
  if (argv[0] === "__anyray-fallback-proxy") {
874
1057
  return runRefreshProxy();
875
1058
  }
876
- // Hidden entrypoint: the Cursor adapter spawns this DETACHED when Cursor was
877
- // running at apply/revert time. It waits for Cursor to quit, completes the
1059
+ // Hidden entrypoint: a quit-gated adapter spawns this DETACHED when its app
1060
+ // was running at apply time. It waits for that app to quit, completes the
878
1061
  // queued operation through the normal adapter path, and exits. Silent +
879
- // best-effort (never throws, never prints).
880
- if (argv[0] === "__anyray-cursor-watch") {
881
- return runCursorWatch();
1062
+ // best-effort (never throws, never prints). Takes the adapter id, so it
1063
+ // serves Cursor's exact-row write and Claude Desktop's profile migration
1064
+ // through one mechanism.
1065
+ if (argv[0] === "__anyray-quit-watch") {
1066
+ return runQuitWatch(argv.slice(1));
882
1067
  }
883
1068
  // Hidden credential-helper protocol for Claude Desktop subscription mode.
884
1069
  // Dispatch before durable-runtime relaunch and all human-facing logging:
@@ -1008,6 +1193,22 @@ const runApplyCommand = async (argv, forceWizard) => {
1008
1193
  fail("--revert cannot be combined with --sso or --enroll. It removes Anyray configuration; re-run with --sso <link> --yes (without --revert) to configure a new CLI.");
1009
1194
  return 1;
1010
1195
  }
1196
+ if (args.project) {
1197
+ // Project scope writes a machine-independent artifact: enrollment is
1198
+ // per-machine (--sso/--enroll) and seat/BYO lanes are per-dev credentials —
1199
+ // none of them can ride a committed file.
1200
+ if (args.sso || args.enroll || args.subscription || args.upstream) {
1201
+ fail("--project cannot be combined with --sso/--enroll (per-machine enrollment) or --subscription/--upstream (per-dev credential lanes). It writes an org-lane, credential-free repo config.");
1202
+ return 1;
1203
+ }
1204
+ return runProjectApply({
1205
+ gateway: args.gateway,
1206
+ tools: args.tools,
1207
+ revert: args.revert,
1208
+ dryRun: args.dryRun,
1209
+ yes: args.yes,
1210
+ });
1211
+ }
1011
1212
  if (Boolean(args.upstream) !== Boolean(args.upstreamKey)) {
1012
1213
  fail("--upstream and --upstream-key go together: the URL says where your personal key is valid, the key is what the gateway forwards there.");
1013
1214
  return 1;
@@ -1445,317 +1646,379 @@ const runApplyCommand = async (argv, forceWizard) => {
1445
1646
  }
1446
1647
  ok(`Gateway reachable (${probe.detail}).`);
1447
1648
  }
1448
- // Adapters now keep durable helper/journal files below ~/.anyray. Existing
1449
- // global-install users may still have the historical plain-file profile at
1450
- // that path, so migrate it once before any apply/revert mutation. Dry-run
1451
- // stays strictly read-only.
1452
- if (!args.dryRun) {
1453
- try {
1454
- await ensureStateDir();
1455
- }
1456
- catch {
1457
- fail("Could not prepare the owner-only Anyray state directory.");
1458
- return 1;
1459
- }
1460
- }
1461
- // Seamless key refresh: ON by default for subscription/passthrough Claude Code
1462
- // + Codex the only tools whose traffic actually traverses the loopback. They
1463
- // bake a short-TTL client key as a STATIC header read once at launch, so
1464
- // without the daemon a long session 401s the moment the key lapses mid-flight,
1465
- // curable only by a re-run + a tool relaunch. The daemon's surface is
1466
- // deliberately tiny (fill the empty key slot, forward the bytes unchanged) —
1467
- // that is what lets it default on where the retired provider-direct lane could
1468
- // not. --no-key-refresh forces the legacy direct static-key lane. Other tools
1469
- // (native Cursor, the Copilot extension, ACP launchers) rebuild their own
1470
- // target and bypass the loopback, so they are unaffected either way — see the
1471
- // scope boundary in commands/refreshProxy.ts.
1472
- const keyRefreshSeatSelected = (adapters.some((a) => a.id === "claude-code") &&
1473
- effectiveSubscription(target, "anthropic")) ||
1474
- (adapters.some((a) => a.id === "codex") &&
1475
- effectiveSubscription(target, "openai"));
1476
- const wantKeyRefresh = !args.revert && args.noKeyRefresh !== true && keyRefreshSeatSelected;
1477
- // A scoped apply (`--tools x`) that doesn't itself engage the loopback must
1478
- // not tear down the DEFAULT-ON key-refresh lane the tools it did NOT name
1479
- // still depend on — the apply-side mirror of the scoped-revert credential rule
1480
- // below. Without this, `anyray-connect --tools cursor` after a full connect
1481
- // would uninstall the refresh daemon and drop Claude Code / Codex back to the
1482
- // 401-prone static-key lane. An explicit --no-key-refresh still wins, and a
1483
- // full, unscoped apply falls through to the normal want-driven teardown below.
1484
- const scopedApply = !args.revert && (args.tools?.length ?? 0) > 0;
1485
- const preserveKeyRefresh = scopedApply &&
1486
- args.noKeyRefresh !== true &&
1487
- Boolean(profile.keyRefreshEnabled);
1488
- // The refresh SCHEDULER is the universal trigger: a per-user launchd/systemd/
1489
- // Task Scheduler task that runs `__anyray-refresh` every ~30 min to re-mint
1490
- // the short-TTL ark_ key for EVERY configured tool. The loopback daemon above
1491
- // only covers
1492
- // CC/Codex (their traffic traverses it); Cursor/Copilot/Claude-Desktop/
1493
- // shell-env read a static key from their own config with no daemon or hook to
1494
- // renew it, so they 401 mid-session when it lapses — the exact bug this fixes.
1495
- // Installed for every ENROLLED seat (an env-key seat has no cert to refresh),
1496
- // gated by the same --no-key-refresh opt-out the daemon lane uses. It coexists
1497
- // with the daemon: the shared refresh-state throttle stops the two triggers
1498
- // double-minting, and if the daemon is ever down the scheduler's refresh
1499
- // degrades those seats' tools to gateway-direct with a fresh key.
1500
- const usingEnvironmentKey = Boolean(envClientKey && target.clientKey === envClientKey && !enrolledCert);
1501
- const hasRefreshableCert = !usingEnvironmentKey && Boolean(enrolledCert ?? profile.enrolledCert);
1502
- const wantRefreshScheduler = !args.revert && args.noKeyRefresh !== true && hasRefreshableCert;
1503
- let applyTarget = target;
1504
- const resolvedKeyRefreshEnabled = wantKeyRefresh || preserveKeyRefresh;
1505
- // Hold the port when the loopback is wanted now, or preserved for the tools
1506
- // this scoped apply didn't name; otherwise clear it so the persisted profile
1507
- // and the teardown below agree the daemon is gone.
1508
- let resolvedFallbackPort = resolvedKeyRefreshEnabled
1509
- ? profile.fallbackPort
1510
- : undefined;
1511
- if (wantKeyRefresh && !args.dryRun) {
1512
- resolvedFallbackPort = profile.fallbackPort ?? (await pickLoopbackPort());
1513
- }
1514
- // ACP agents and Claude Desktop's credential helper intentionally persist no
1515
- // static key: they load this owner-only profile when the app launches them.
1516
- // Commit the complete target + enrollment pair before any adapter or loopback
1517
- // service can register such a launcher. A persistence failure is fatal here,
1518
- // while no tool configuration has changed yet.
1519
- if (!args.revert && !args.dryRun) {
1520
- // An explicit pre-minted environment key is a complete, non-refreshable
1521
- // credential source (see `usingEnvironmentKey` above). Never retain a
1522
- // possibly unrelated enrollment cert or expiry beside it: a later helper
1523
- // refresh could otherwise replace the operator-supplied identity with the
1524
- // old certificate's identity.
1525
- const nextProfile = {
1526
- ...profile,
1527
- gateway: target.origin,
1528
- name: resolvedUser,
1529
- team: resolvedTeam,
1530
- clientKey: target.clientKey ?? profile.clientKey,
1531
- enrolledCert: usingEnvironmentKey
1532
- ? undefined
1533
- : (enrolledCert ?? profile.enrolledCert),
1534
- enrolledCertExpiry: usingEnvironmentKey
1535
- ? undefined
1536
- : (enrolledCertExpiry ?? profile.enrolledCertExpiry),
1537
- enrolledTokenHash: usingEnvironmentKey
1538
- ? undefined
1539
- : (enrolledTokenHash ?? profile.enrolledTokenHash),
1540
- ssoLink: usingEnvironmentKey
1541
- ? undefined
1542
- : ssoEnrollment
1543
- ? enrolledSsoLink
1544
- : enrolledCert
1545
- ? undefined
1546
- : profile.ssoLink,
1547
- clientKeyExpiresAt: usingEnvironmentKey
1548
- ? undefined
1549
- : (clientKeyExpiresAt ??
1550
- (target.clientKey === profile.clientKey?.trim()
1551
- ? profile.clientKeyExpiresAt
1552
- : undefined)),
1553
- authMode: target.authMode,
1554
- byoUpstream: target.byoUpstream,
1555
- keyRefreshEnabled: resolvedKeyRefreshEnabled,
1556
- refreshSchedulerEnabled: wantRefreshScheduler,
1557
- fallbackPort: resolvedFallbackPort,
1558
- };
1559
- try {
1560
- await persistApplyProfile(profile, nextProfile, enrolledPrivateKeyPem, undefined, usingEnvironmentKey);
1561
- }
1562
- catch {
1563
- fail("Could not save the owner-only Anyray runtime profile. No tool configuration was changed.");
1564
- return 1;
1649
+ // Quit-aware apply, planned BEFORE anything is written and after the preflight
1650
+ // (no point closing an editor for a gateway that turned out to be down). The
1651
+ // plan only contains apps that are open and that this run actually changes —
1652
+ // see `planAppRestarts` for the "already pointed at us" filter and for what it
1653
+ // deliberately cannot see. Revert is out of scope: the filter is apply-shaped
1654
+ // (a revert has work precisely when the tool IS pointed at us), and the
1655
+ // adapters' queue-on-quit path already covers a revert that finds an app open.
1656
+ const quitPlan = args.revert
1657
+ ? []
1658
+ : await planAppRestarts(adapters, target.origin);
1659
+ let closedByConnect = [];
1660
+ /**
1661
+ * Give back every app we took away — from ANY exit after the quit, not just
1662
+ * the happy one. Two failure paths below return before the apply finishes,
1663
+ * and each of them used to leave the developer's editors closed with nothing
1664
+ * ever reopening them. Closing someone's editor is only defensible if
1665
+ * reopening it is unconditional.
1666
+ */
1667
+ const reopenClosed = () => {
1668
+ if (closedByConnect.length === 0)
1669
+ return;
1670
+ heading("Reopening");
1671
+ relaunchApps(closedByConnect);
1672
+ closedByConnect = [];
1673
+ };
1674
+ if (quitPlan.length > 0) {
1675
+ const titles = quitPlan.map((candidate) => candidate.title);
1676
+ const plural = titles.length === 1 ? "it" : "them";
1677
+ heading("Open apps");
1678
+ // One gate decides whether anything closes, so a path that must not act
1679
+ // cannot drift out of step with the one that may.
1680
+ if (!quitAppsAllowed(args, isInteractive())) {
1681
+ // Headless-complete: report, never act. Dry-run says what it would offer.
1682
+ const hint = args.noQuitApps === true ? "" : " (or pass --quit-apps)";
1683
+ note(args.dryRun
1684
+ ? ` Would offer to quit ${titles.join(", ")} dry run, nothing was closed.`
1685
+ : ` ${titles.join(", ")} ${titles.length === 1 ? "is" : "are"} open; restart ${plural} to pick this up${hint}.`);
1565
1686
  }
1566
- // This run minted a key (--enroll / --sso). Share that with the
1567
- // auto-refresher's bookkeeping: with no successful mint on record, the next
1568
- // detached refresh sees a credential "not rotated in hours", re-mints
1569
- // immediately, and rotates the key out from under the session just set up.
1570
- if (clientKeyExpiresAt && !usingEnvironmentKey) {
1571
- await recordMintSuccess({ expiresAt: clientKeyExpiresAt });
1687
+ else {
1688
+ // `--quit-apps` is consent already given; otherwise ask once, default NO.
1689
+ const agreed = args.quitApps === true || (await confirm(appRestartOffer(titles)));
1690
+ if (agreed)
1691
+ closedByConnect = await quitAppsNow(quitPlan);
1692
+ else
1693
+ note(` Leaving ${plural} open — restart when convenient.`);
1572
1694
  }
1573
1695
  }
1574
- if (wantKeyRefresh && !args.dryRun) {
1575
- applyTarget = await setupLoopbackProxy(target, resolvedFallbackPort);
1576
- }
1577
- // Drop the enrollment credential FIRST, before a single config is unpicked —
1578
- // see util/persist `clearEnrollment` for why a revert that leaves it behind
1579
- // undoes itself. Order matters: `refreshIfDue` re-reads the profile on every
1580
- // run, so clearing up front means a hook that fires *during* the revert finds
1581
- // nothing to mint and leaves our work alone. Clearing last would leave a
1582
- // window config already removed, cert still live where that hook restores
1583
- // the configs and we then strip the cert they now depend on, stranding the
1584
- // tools on a key that can never refresh.
1585
- //
1586
- // A partial revert (`--tools`) is scoped to the tools it names, so it must
1587
- // leave the credential alone: the tools it did NOT touch still need to
1588
- // refresh their key.
1589
- const fullRevert = args.revert && (args.tools?.length ?? 0) === 0;
1590
- if (fullRevert) {
1591
- if (args.dryRun) {
1592
- if (await hasEnrollment()) {
1593
- heading("Enrollment");
1594
- info(" would remove the stored gateway credential");
1696
+ // Everything from here on runs with the developer's editors CLOSED, so
1697
+ // the reopen has to be in a `finally`. Guarding only the explicit early
1698
+ // returns was not enough: any throw in between — a filesystem error, an
1699
+ // adapter probe, the loopback setup would unwind past it and leave
1700
+ // them shut. Closing someone's editor is only defensible if reopening
1701
+ // it happens on every path out.
1702
+ try {
1703
+ // Adapters now keep durable helper/journal files below ~/.anyray. Existing
1704
+ // global-install users may still have the historical plain-file profile at
1705
+ // that path, so migrate it once before any apply/revert mutation. Dry-run
1706
+ // stays strictly read-only.
1707
+ if (!args.dryRun) {
1708
+ try {
1709
+ await ensureStateDir();
1710
+ }
1711
+ catch {
1712
+ fail("Could not prepare the owner-only Anyray state directory.");
1713
+ return 1;
1595
1714
  }
1596
1715
  }
1597
- else {
1716
+ // Seamless key refresh: ON by default for subscription/passthrough Claude Code
1717
+ // + Codex — the only tools whose traffic actually traverses the loopback. They
1718
+ // bake a short-TTL client key as a STATIC header read once at launch, so
1719
+ // without the daemon a long session 401s the moment the key lapses mid-flight,
1720
+ // curable only by a re-run + a tool relaunch. The daemon's surface is
1721
+ // deliberately tiny (fill the empty key slot, forward the bytes unchanged) —
1722
+ // that is what lets it default on where the retired provider-direct lane could
1723
+ // not. --no-key-refresh forces the legacy direct static-key lane. Other tools
1724
+ // (native Cursor, the Copilot extension, ACP launchers) rebuild their own
1725
+ // target and bypass the loopback, so they are unaffected either way — see the
1726
+ // scope boundary in commands/refreshProxy.ts.
1727
+ const keyRefreshSeatSelected = (adapters.some((a) => a.id === "claude-code") &&
1728
+ effectiveSubscription(target, "anthropic")) ||
1729
+ (adapters.some((a) => a.id === "codex") &&
1730
+ effectiveSubscription(target, "openai"));
1731
+ const wantKeyRefresh = !args.revert && args.noKeyRefresh !== true && keyRefreshSeatSelected;
1732
+ // A scoped apply (`--tools x`) that doesn't itself engage the loopback must
1733
+ // not tear down the DEFAULT-ON key-refresh lane the tools it did NOT name
1734
+ // still depend on — the apply-side mirror of the scoped-revert credential rule
1735
+ // below. Without this, `anyray-connect --tools cursor` after a full connect
1736
+ // would uninstall the refresh daemon and drop Claude Code / Codex back to the
1737
+ // 401-prone static-key lane. An explicit --no-key-refresh still wins, and a
1738
+ // full, unscoped apply falls through to the normal want-driven teardown below.
1739
+ const scopedApply = !args.revert && (args.tools?.length ?? 0) > 0;
1740
+ const preserveKeyRefresh = scopedApply &&
1741
+ args.noKeyRefresh !== true &&
1742
+ Boolean(profile.keyRefreshEnabled);
1743
+ // The refresh SCHEDULER is the universal trigger: a per-user launchd/systemd/
1744
+ // Task Scheduler task that runs `__anyray-refresh` every ~30 min to re-mint
1745
+ // the short-TTL ark_ key for EVERY configured tool. The loopback daemon above
1746
+ // only covers
1747
+ // CC/Codex (their traffic traverses it); Cursor/Copilot/Claude-Desktop/
1748
+ // shell-env read a static key from their own config with no daemon or hook to
1749
+ // renew it, so they 401 mid-session when it lapses — the exact bug this fixes.
1750
+ // Installed for every ENROLLED seat (an env-key seat has no cert to refresh),
1751
+ // gated by the same --no-key-refresh opt-out the daemon lane uses. It coexists
1752
+ // with the daemon: the shared refresh-state throttle stops the two triggers
1753
+ // double-minting, and if the daemon is ever down the scheduler's refresh
1754
+ // degrades those seats' tools to gateway-direct with a fresh key.
1755
+ const usingEnvironmentKey = Boolean(envClientKey && target.clientKey === envClientKey && !enrolledCert);
1756
+ const hasRefreshableCert = !usingEnvironmentKey && Boolean(enrolledCert ?? profile.enrolledCert);
1757
+ const wantRefreshScheduler = !args.revert && args.noKeyRefresh !== true && hasRefreshableCert;
1758
+ let applyTarget = target;
1759
+ const resolvedKeyRefreshEnabled = wantKeyRefresh || preserveKeyRefresh;
1760
+ // Hold the port when the loopback is wanted now, or preserved for the tools
1761
+ // this scoped apply didn't name; otherwise clear it so the persisted profile
1762
+ // and the teardown below agree the daemon is gone.
1763
+ let resolvedFallbackPort = resolvedKeyRefreshEnabled
1764
+ ? profile.fallbackPort
1765
+ : undefined;
1766
+ if (wantKeyRefresh && !args.dryRun) {
1767
+ resolvedFallbackPort = profile.fallbackPort ?? (await pickLoopbackPort());
1768
+ }
1769
+ // ACP agents and Claude Desktop's credential helper intentionally persist no
1770
+ // static key: they load this owner-only profile when the app launches them.
1771
+ // Commit the complete target + enrollment pair before any adapter or loopback
1772
+ // service can register such a launcher. A persistence failure is fatal here,
1773
+ // while no tool configuration has changed yet.
1774
+ if (!args.revert && !args.dryRun) {
1775
+ // An explicit pre-minted environment key is a complete, non-refreshable
1776
+ // credential source (see `usingEnvironmentKey` above). Never retain a
1777
+ // possibly unrelated enrollment cert or expiry beside it: a later helper
1778
+ // refresh could otherwise replace the operator-supplied identity with the
1779
+ // old certificate's identity.
1780
+ const nextProfile = {
1781
+ ...profile,
1782
+ gateway: target.origin,
1783
+ name: resolvedUser,
1784
+ team: resolvedTeam,
1785
+ clientKey: target.clientKey ?? profile.clientKey,
1786
+ enrolledCert: usingEnvironmentKey
1787
+ ? undefined
1788
+ : (enrolledCert ?? profile.enrolledCert),
1789
+ enrolledCertExpiry: usingEnvironmentKey
1790
+ ? undefined
1791
+ : (enrolledCertExpiry ?? profile.enrolledCertExpiry),
1792
+ enrolledTokenHash: usingEnvironmentKey
1793
+ ? undefined
1794
+ : (enrolledTokenHash ?? profile.enrolledTokenHash),
1795
+ ssoLink: usingEnvironmentKey
1796
+ ? undefined
1797
+ : ssoEnrollment
1798
+ ? enrolledSsoLink
1799
+ : enrolledCert
1800
+ ? undefined
1801
+ : profile.ssoLink,
1802
+ clientKeyExpiresAt: usingEnvironmentKey
1803
+ ? undefined
1804
+ : (clientKeyExpiresAt ??
1805
+ (target.clientKey === profile.clientKey?.trim()
1806
+ ? profile.clientKeyExpiresAt
1807
+ : undefined)),
1808
+ authMode: target.authMode,
1809
+ byoUpstream: target.byoUpstream,
1810
+ keyRefreshEnabled: resolvedKeyRefreshEnabled,
1811
+ refreshSchedulerEnabled: wantRefreshScheduler,
1812
+ fallbackPort: resolvedFallbackPort,
1813
+ };
1598
1814
  try {
1599
- if (await clearEnrollment()) {
1600
- heading("Enrollment");
1601
- ok(" Removed the stored gateway credential.");
1602
- note(" Reconnect any time with `npx anyray-connect@latest --sso`.");
1603
- }
1815
+ await persistApplyProfile(profile, nextProfile, enrolledPrivateKeyPem, undefined, usingEnvironmentKey);
1604
1816
  }
1605
- catch (error) {
1606
- // Local disk failure say so plainly and keep going: the tool configs
1607
- // below still come out, and a half-reverted install is worth more than
1608
- // a stack trace. Never echo the error value (it can quote a path).
1609
- heading("Enrollment");
1610
- warn(" Could not remove the stored gateway credential.");
1611
- warn(` Delete ${stateDir()} by hand, or the key-refresh hook may restore your tool config.`);
1612
- void error;
1817
+ catch {
1818
+ fail("Could not save the owner-only Anyray runtime profile. No tool configuration was changed.");
1819
+ return 1;
1820
+ }
1821
+ // This run minted a key (--enroll / --sso). Share that with the
1822
+ // auto-refresher's bookkeeping: with no successful mint on record, the next
1823
+ // detached refresh sees a credential "not rotated in hours", re-mints
1824
+ // immediately, and rotates the key out from under the session just set up.
1825
+ if (clientKeyExpiresAt && !usingEnvironmentKey) {
1826
+ await recordMintSuccess({ expiresAt: clientKeyExpiresAt });
1613
1827
  }
1614
1828
  }
1615
- }
1616
- else if (args.revert && !args.dryRun) {
1617
- note("\nKeeping the stored enrollment — the tools you did not name still need it.");
1618
- note("Run --revert with no --tools to remove it.");
1619
- }
1620
- // A named `--tools` list (the wizard writes its checklist here too) is the
1621
- // developer choosing these adapters by hand; the default sweep names none.
1622
- const { changes, manual, results } = await runApply(adapters, applyTarget, opts, args.revert, new Set(args.tools ?? []));
1623
- // Keep the managed-tool record in step: apply records landed tools, revert
1624
- // removes them, a full revert clears the record. Drift repair (`doctor
1625
- // --repair`, the detached refresh) only ever re-applies recorded ids, so
1626
- // this is what makes an intentional revert stick (#1031).
1627
- if (!args.dryRun) {
1628
- await recordManagedTools(results, args.revert, fullRevert).catch(() => undefined);
1629
- }
1630
- // Tear down the loopback daemon whenever it isn't wanted but a previous run
1631
- // left it installed — covers an explicit --revert, --no-key-refresh, or a
1632
- // subscription→api-key switch that no longer needs the refresh lane. Runs
1633
- // AFTER the adapters above re-pointed tools straight at the gateway, so
1634
- // uninstalling the loopback service strands nothing.
1635
- if (!resolvedKeyRefreshEnabled &&
1636
- !args.dryRun &&
1637
- (loopbackEngaged(profile) || profile.fallbackPort)) {
1638
- heading("Local loopback proxy");
1639
- const svc = await uninstallProxyService(profile.fallbackPort);
1640
- for (const m of svc.messages)
1641
- info(` ${m}`);
1642
- // Re-read: on a --revert `clearEnrollment` already stripped the credential
1643
- // from disk, so spreading the pre-run `profile` snapshot would write it
1644
- // straight back. loadProfile() reflects what is actually persisted now.
1645
- await saveProfile({
1646
- ...(await loadProfile()),
1647
- keyRefreshEnabled: false,
1648
- fallbackPort: undefined,
1649
- });
1650
- ok(" Removed the local loopback proxy.");
1651
- }
1652
- // Reconcile the universal refresh scheduler: (re)install it for an enrolled
1653
- // seat, or tear down one a prior run left when the seat no longer wants it (a
1654
- // full revert dropped the enrollment, or --no-key-refresh opted out). A SCOPED
1655
- // revert (`--tools x --revert`) leaves it — the tools it did not name still
1656
- // depend on the refresh. Independent of the daemon teardown above: a
1657
- // Cursor-only seat has the scheduler but never had a daemon.
1658
- await reconcileRefreshScheduler({
1659
- want: wantRefreshScheduler,
1660
- announcePrimary: !wantKeyRefresh,
1661
- scopedRevert: Boolean(args.revert) && !fullRevert,
1662
- dryRun: Boolean(args.dryRun),
1663
- wasEnabled: Boolean(profile.refreshSchedulerEnabled),
1664
- });
1665
- heading("Summary");
1666
- if (args.dryRun) {
1667
- note("Dry run complete — re-run without --dry-run to apply.");
1668
- }
1669
- else if (args.revert) {
1670
- ok(`Reverted (${changes} file(s) touched).`);
1671
- // Editors read our hooks at startup and keep them in memory, so one that was
1672
- // already open goes on running them until it restarts. Say so — otherwise
1673
- // the leftover hook lines read as a revert that missed something.
1674
- note("Restart any editor or agent that was running — it loaded our hooks at startup.");
1675
- // The same "gone from disk, still live in memory" trap, but for the shell
1676
- // exports — and this one doesn't just look wrong, it BREAKS the dev's tools
1677
- // (see tools/shellEnv `staleShellExports`). We can prove it: the vars are in
1678
- // our own env. Say it loudly and hand over the exact command.
1679
- const priorTargets = [
1680
- target,
1681
- ...(profile.fallbackPort
1682
- ? [withLocalProxy(target, profile.fallbackPort)]
1683
- : []),
1684
- ];
1685
- const stale = staleShellExportsForTargets(priorTargets);
1686
- if (stale.length > 0) {
1687
- heading("This terminal still points at the gateway");
1688
- warn(` Still exported here: ${stale.join(", ")}`);
1689
- warn(" Removing the block from your shell profile cannot reach a shell");
1690
- warn(" that already ran it. Until these are gone, your tools keep");
1691
- warn(" reaching the gateway and now fail with 401.");
1692
- info("\n Fix this terminal:");
1693
- info(dim(` unset ${stale.join(" ")}`));
1694
- info("\n Every other open terminal needs the same — or just open a new one.");
1695
- info(" VS Code injects a copy it captured when IT launched, so its");
1696
- info(" terminals stay stale even if you open a new one: quit the app");
1697
- info(" entirely (Cmd+Q). Reloading the window keeps the old copy.");
1829
+ if (wantKeyRefresh && !args.dryRun) {
1830
+ applyTarget = await setupLoopbackProxy(target, resolvedFallbackPort);
1698
1831
  }
1699
- }
1700
- else {
1701
- ok(`Done (${changes} file(s) written).`);
1702
- // Tools whose config we can't script (Claude desktop, JetBrains, Windsurf)
1703
- // are the easiest thing to overlook the loop printed their steps, but a
1704
- // dev skimming for the green "Done" can miss that no file was actually
1705
- // written for them. Re-surface them loudly, and on an interactive run make
1706
- // the dev explicitly acknowledge before we move on. Scripted runs
1707
- // (--yes / non-TTY) just get the reminder — never a hanging prompt.
1708
- if (manual.length > 0) {
1709
- heading("Action required manual steps");
1710
- for (const line of manualStepsNotice(manual))
1711
- warn(line);
1712
- if (isInteractive() && !args.yes) {
1713
- const acknowledged = await confirm("Have you applied the manual settings shown above?");
1714
- if (acknowledged) {
1715
- ok("Acknowledged.");
1832
+ // Drop the enrollment credential FIRST, before a single config is unpicked —
1833
+ // see util/persist `clearEnrollment` for why a revert that leaves it behind
1834
+ // undoes itself. Order matters: `refreshIfDue` re-reads the profile on every
1835
+ // run, so clearing up front means a hook that fires *during* the revert finds
1836
+ // nothing to mint and leaves our work alone. Clearing last would leave a
1837
+ // window config already removed, cert still live where that hook restores
1838
+ // the configs and we then strip the cert they now depend on, stranding the
1839
+ // tools on a key that can never refresh.
1840
+ //
1841
+ // A partial revert (`--tools`) is scoped to the tools it names, so it must
1842
+ // leave the credential alone: the tools it did NOT touch still need to
1843
+ // refresh their key.
1844
+ const fullRevert = args.revert && (args.tools?.length ?? 0) === 0;
1845
+ if (fullRevert) {
1846
+ if (args.dryRun) {
1847
+ if (await hasEnrollment()) {
1848
+ heading("Enrollment");
1849
+ info(" would remove the stored gateway credential");
1716
1850
  }
1717
- else {
1718
- warn("Apply them before using the tool, or it will keep sending traffic straight to the provider (unattributed).");
1851
+ }
1852
+ else {
1853
+ try {
1854
+ if (await clearEnrollment()) {
1855
+ heading("Enrollment");
1856
+ ok(" Removed the stored gateway credential.");
1857
+ note(" Reconnect any time with `npx anyray-connect@latest --sso`.");
1858
+ }
1859
+ }
1860
+ catch (error) {
1861
+ // Local disk failure — say so plainly and keep going: the tool configs
1862
+ // below still come out, and a half-reverted install is worth more than
1863
+ // a stack trace. Never echo the error value (it can quote a path).
1864
+ heading("Enrollment");
1865
+ warn(" Could not remove the stored gateway credential.");
1866
+ warn(` Delete ${stateDir()} by hand, or the key-refresh hook may restore your tool config.`);
1867
+ void error;
1719
1868
  }
1720
1869
  }
1721
1870
  }
1722
- // Smoke-test the connection so a routing/sign-in problem surfaces NOW with a
1723
- // concrete fix, not as a cryptic 4xx on the dev's first real request.
1724
- if (args.skipVerify) {
1725
- note("\nSkipped the connection smoke test (--skip-verify).");
1871
+ else if (args.revert && !args.dryRun) {
1872
+ note("\nKeeping the stored enrollment the tools you did not name still need it.");
1873
+ note("Run --revert with no --tools to remove it.");
1726
1874
  }
1727
- else {
1728
- // MUST probe `target` (the remote gateway), NOT `applyTarget` (the local
1729
- // key-refresh daemon the tools were pointed at). The verify classifiers
1730
- // sniff response bodies, and a daemon-synthesized 502 ("gateway
1731
- // unreachable") would confuse them into reporting a routing fault the
1732
- // gateway itself never returned. The daemon hop is health-checked
1733
- // separately at install time (and by `doctor`).
1734
- await runVerify(target, adapters.map((a) => a.id));
1875
+ // A named `--tools` list (the wizard writes its checklist here too) is the
1876
+ // developer choosing these adapters by hand; the default sweep names none.
1877
+ const { changes, manual, results } = await runApply(adapters, applyTarget, opts, args.revert, new Set(args.tools ?? []));
1878
+ // Keep the managed-tool record in step: apply records landed tools, revert
1879
+ // removes them, a full revert clears the record. Drift repair (`doctor
1880
+ // --repair`, the detached refresh) only ever re-applies recorded ids, so
1881
+ // this is what makes an intentional revert stick (#1031).
1882
+ if (!args.dryRun) {
1883
+ await recordManagedTools(results, args.revert, fullRevert).catch(() => undefined);
1884
+ }
1885
+ // Tear down the loopback daemon whenever it isn't wanted but a previous run
1886
+ // left it installed — covers an explicit --revert, --no-key-refresh, or a
1887
+ // subscription→api-key switch that no longer needs the refresh lane. Runs
1888
+ // AFTER the adapters above re-pointed tools straight at the gateway, so
1889
+ // uninstalling the loopback service strands nothing.
1890
+ if (!resolvedKeyRefreshEnabled &&
1891
+ !args.dryRun &&
1892
+ (loopbackEngaged(profile) || profile.fallbackPort)) {
1893
+ heading("Local loopback proxy");
1894
+ const svc = await uninstallProxyService(profile.fallbackPort);
1895
+ for (const m of svc.messages)
1896
+ info(` ${m}`);
1897
+ // Re-read: on a --revert `clearEnrollment` already stripped the credential
1898
+ // from disk, so spreading the pre-run `profile` snapshot would write it
1899
+ // straight back. loadProfile() reflects what is actually persisted now.
1900
+ await saveProfile({
1901
+ ...(await loadProfile()),
1902
+ keyRefreshEnabled: false,
1903
+ fallbackPort: undefined,
1904
+ });
1905
+ ok(" Removed the local loopback proxy.");
1906
+ }
1907
+ // Reconcile the universal refresh scheduler: (re)install it for an enrolled
1908
+ // seat, or tear down one a prior run left when the seat no longer wants it (a
1909
+ // full revert dropped the enrollment, or --no-key-refresh opted out). A SCOPED
1910
+ // revert (`--tools x --revert`) leaves it — the tools it did not name still
1911
+ // depend on the refresh. Independent of the daemon teardown above: a
1912
+ // Cursor-only seat has the scheduler but never had a daemon.
1913
+ await reconcileRefreshScheduler({
1914
+ want: wantRefreshScheduler,
1915
+ announcePrimary: !wantKeyRefresh,
1916
+ scopedRevert: Boolean(args.revert) && !fullRevert,
1917
+ dryRun: Boolean(args.dryRun),
1918
+ wasEnabled: Boolean(profile.refreshSchedulerEnabled),
1919
+ });
1920
+ // Reopen here on the happy path so it reads in the right order, before the
1921
+ // summary. `reopenClosed` clears its list, so the `finally` below is a
1922
+ // no-op once this has run — it exists for the paths that never reach here.
1923
+ reopenClosed();
1924
+ heading("Summary");
1925
+ if (args.dryRun) {
1926
+ note("Dry run complete — re-run without --dry-run to apply.");
1735
1927
  }
1736
- info("\nRe-test by hand any time:");
1737
- const nativeCursorOnly = target.authMode !== "placeholder" &&
1738
- adapters.length === 1 &&
1739
- adapters[0]?.id === "cursor";
1740
- if (nativeCursorOnly) {
1741
- info(dim(` curl -sS ${target.origin}/connect/savings \\\n` +
1742
- ` -H "x-anyray-api-key: $(anyray-connect print-key)"`));
1743
- info(dim(" Validate Opus itself inside Cursor: keep the Team login, select a native Claude Opus model, and run an Agent prompt."));
1928
+ else if (args.revert) {
1929
+ ok(`Reverted (${changes} file(s) touched).`);
1930
+ // Editors read our hooks at startup and keep them in memory, so one that was
1931
+ // already open goes on running them until it restarts. Say so — otherwise
1932
+ // the leftover hook lines read as a revert that missed something.
1933
+ note("Restart any editor or agent that was running — it loaded our hooks at startup.");
1934
+ // The same "gone from disk, still live in memory" trap, but for the shell
1935
+ // exports and this one doesn't just look wrong, it BREAKS the dev's tools
1936
+ // (see tools/shellEnv `staleShellExports`). We can prove it: the vars are in
1937
+ // our own env. Say it loudly and hand over the exact command.
1938
+ const priorTargets = [
1939
+ target,
1940
+ ...(profile.fallbackPort
1941
+ ? [withLocalProxy(target, profile.fallbackPort)]
1942
+ : []),
1943
+ ];
1944
+ const stale = staleShellExportsForTargets(priorTargets);
1945
+ if (stale.length > 0) {
1946
+ heading("This terminal still points at the gateway");
1947
+ warn(` Still exported here: ${stale.join(", ")}`);
1948
+ warn(" Removing the block from your shell profile cannot reach a shell");
1949
+ warn(" that already ran it. Until these are gone, your tools keep");
1950
+ warn(" reaching the gateway and now fail with 401.");
1951
+ info("\n Fix this terminal:");
1952
+ info(dim(` unset ${stale.join(" ")}`));
1953
+ info("\n Every other open terminal needs the same — or just open a new one.");
1954
+ info(" VS Code injects a copy it captured when IT launched, so its");
1955
+ info(" terminals stay stale even if you open a new one: quit the app");
1956
+ info(" entirely (Cmd+Q). Reloading the window keeps the old copy.");
1957
+ }
1744
1958
  }
1745
1959
  else {
1746
- // A minimal chat completion is the one request every API-routed gateway
1747
- // serves. `anyray-default` lets gateway policy choose model/provider.
1748
- info(dim(
1749
- // Never echo a personal key point at the tool config it landed in.
1750
- ` curl -sS ${target.openaiBaseUrl}/chat/completions \\\n` +
1751
- ` -H "Content-Type: application/json" \\\n` +
1752
- ` -H "Authorization: Bearer ${target.clientKey
1753
- ? `<your personal key (${maskKey(target.clientKey)}) see your tool config>`
1754
- : "<enrollment required>"}" \\\n` +
1755
- ` -d '{"model":"anyray-default","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'`));
1960
+ ok(`Done (${changes} file(s) written).`);
1961
+ // Tools whose config we can't script (Claude desktop, JetBrains, Windsurf)
1962
+ // are the easiest thing to overlook — the loop printed their steps, but a
1963
+ // dev skimming for the green "Done" can miss that no file was actually
1964
+ // written for them. Re-surface them loudly, and on an interactive run make
1965
+ // the dev explicitly acknowledge before we move on. Scripted runs
1966
+ // (--yes / non-TTY) just get the reminder — never a hanging prompt.
1967
+ if (manual.length > 0) {
1968
+ heading("Action required manual steps");
1969
+ for (const line of manualStepsNotice(manual))
1970
+ warn(line);
1971
+ if (isInteractive() && !args.yes) {
1972
+ const acknowledged = await confirm("Have you applied the manual settings shown above?");
1973
+ if (acknowledged) {
1974
+ ok("Acknowledged.");
1975
+ }
1976
+ else {
1977
+ warn("Apply them before using the tool, or it will keep sending traffic straight to the provider (unattributed).");
1978
+ }
1979
+ }
1980
+ }
1981
+ // Smoke-test the connection so a routing/sign-in problem surfaces NOW with a
1982
+ // concrete fix, not as a cryptic 4xx on the dev's first real request.
1983
+ if (args.skipVerify) {
1984
+ note("\nSkipped the connection smoke test (--skip-verify).");
1985
+ }
1986
+ else {
1987
+ // MUST probe `target` (the remote gateway), NOT `applyTarget` (the local
1988
+ // key-refresh daemon the tools were pointed at). The verify classifiers
1989
+ // sniff response bodies, and a daemon-synthesized 502 ("gateway
1990
+ // unreachable") would confuse them into reporting a routing fault the
1991
+ // gateway itself never returned. The daemon hop is health-checked
1992
+ // separately at install time (and by `doctor`).
1993
+ await runVerify(target, adapters.map((a) => a.id));
1994
+ }
1995
+ info("\nRe-test by hand any time:");
1996
+ const nativeCursorOnly = target.authMode !== "placeholder" &&
1997
+ adapters.length === 1 &&
1998
+ adapters[0]?.id === "cursor";
1999
+ if (nativeCursorOnly) {
2000
+ info(dim(` curl -sS ${target.origin}/connect/savings \\\n` +
2001
+ ` -H "x-anyray-api-key: $(anyray-connect print-key)"`));
2002
+ info(dim(" Validate Opus itself inside Cursor: keep the Team login, select a native Claude Opus model, and run an Agent prompt."));
2003
+ }
2004
+ else {
2005
+ // A minimal chat completion is the one request every API-routed gateway
2006
+ // serves. `anyray-default` lets gateway policy choose model/provider.
2007
+ info(dim(
2008
+ // Never echo a personal key — point at the tool config it landed in.
2009
+ ` curl -sS ${target.openaiBaseUrl}/chat/completions \\\n` +
2010
+ ` -H "Content-Type: application/json" \\\n` +
2011
+ ` -H "Authorization: Bearer ${target.clientKey
2012
+ ? `<your personal key (${maskKey(target.clientKey)}) — see your tool config>`
2013
+ : "<enrollment required>"}" \\\n` +
2014
+ ` -d '{"model":"anyray-default","messages":[{"role":"user","content":"ping"}],"max_tokens":16}'`));
2015
+ }
2016
+ info(dim(" Revert: npx anyray-connect --revert"));
1756
2017
  }
1757
- info(dim(" Revert: npx anyray-connect --revert"));
2018
+ return 0;
2019
+ }
2020
+ finally {
2021
+ reopenClosed();
1758
2022
  }
1759
- return 0;
1760
2023
  };
1761
2024
  //# sourceMappingURL=cli.js.map