anyray-connect 0.11.89 → 0.11.91

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 (51) hide show
  1. package/dist/cli.js +364 -103
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/doctor.js +38 -0
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/login.js +4 -1
  6. package/dist/commands/login.js.map +1 -1
  7. package/dist/commands/mcpServer.js +75 -7
  8. package/dist/commands/mcpServer.js.map +1 -1
  9. package/dist/commands/shared.js +38 -18
  10. package/dist/commands/shared.js.map +1 -1
  11. package/dist/tools/claudeDesktop.js +3 -0
  12. package/dist/tools/claudeDesktop.js.map +1 -1
  13. package/dist/tools/copilotChat.js +39 -1
  14. package/dist/tools/copilotChat.js.map +1 -1
  15. package/dist/tools/cursor.js +22 -12
  16. package/dist/tools/cursor.js.map +1 -1
  17. package/dist/types.js.map +1 -1
  18. package/dist/util/appQuit.js +44 -4
  19. package/dist/util/appQuit.js.map +1 -1
  20. package/dist/util/connectHealthReport.js +32 -1
  21. package/dist/util/connectHealthReport.js.map +1 -1
  22. package/dist/util/devVerify.js +9 -1
  23. package/dist/util/devVerify.js.map +1 -1
  24. package/dist/util/directHeartbeatMigration.js +2 -3
  25. package/dist/util/directHeartbeatMigration.js.map +1 -1
  26. package/dist/util/enrollLink.js +1 -0
  27. package/dist/util/enrollLink.js.map +1 -1
  28. package/dist/util/fleetPlan.js +67 -0
  29. package/dist/util/fleetPlan.js.map +1 -0
  30. package/dist/util/identity.js +26 -0
  31. package/dist/util/identity.js.map +1 -1
  32. package/dist/util/log.js +11 -4
  33. package/dist/util/log.js.map +1 -1
  34. package/dist/util/multiSelect.js +180 -0
  35. package/dist/util/multiSelect.js.map +1 -0
  36. package/dist/util/policySync.js +21 -51
  37. package/dist/util/policySync.js.map +1 -1
  38. package/dist/util/proxyService.js +13 -0
  39. package/dist/util/proxyService.js.map +1 -1
  40. package/dist/util/retrieval.js +38 -3
  41. package/dist/util/retrieval.js.map +1 -1
  42. package/dist/util/spinner.js +32 -0
  43. package/dist/util/spinner.js.map +1 -0
  44. package/dist/util/verify.js +87 -1
  45. package/dist/util/verify.js.map +1 -1
  46. package/dist/util/vscodeExtension.js +32 -0
  47. package/dist/util/vscodeExtension.js.map +1 -1
  48. package/dist/util/vscodeProcess.js +7 -0
  49. package/dist/util/vscodeProcess.js.map +1 -1
  50. package/dist/version.js +1 -1
  51. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * personal gateway authentication, and content-free attribution.
11
11
  */
12
12
  import { isPersonalClientKey, isInsecureRemoteOrigin, normalizeOrigin, resolveOrigin, resolveTarget, withLocalProxy, } from "./target.js";
13
- import { pickLoopbackPort, installProxyService, uninstallProxyService, probeHealth, DEFAULT_LOOPBACK_PORT, } from "./util/proxyService.js";
13
+ import { pickLoopbackPort, installProxyService, uninstallProxyService, probeHealth, proxyUnitFileExists, DEFAULT_LOOPBACK_PORT, } from "./util/proxyService.js";
14
+ import { stat } from "node:fs/promises";
14
15
  import { isDraining } from "./util/drainMarker.js";
15
16
  import { installRefreshScheduler, uninstallRefreshScheduler, } from "./util/refreshScheduler.js";
16
17
  import { detectRemoteDevBackend, isContainerBackend, } from "./util/remoteContext.js";
@@ -32,8 +33,10 @@ import { checkGateway } from "./util/preflight.js";
32
33
  import { maskKey } from "./util/redeem.js";
33
34
  import { parseEnrollLink, isEnrollArg, resolveEnrollLink, } from "./util/enrollLink.js";
34
35
  import { canReuseEnrolledCert, enrollAndVerify, enrollTokenHash, GatewayVerifyError, STABLE_KEY_HEARTBEAT_CAPABILITY, verifyWithGateway, } from "./util/devVerify.js";
35
- import { detectDisplayName } from "./util/identity.js";
36
+ import { detectEnrollmentIdentity, } from "./util/identity.js";
36
37
  import { parseSsoLink, ssoEnroll } from "./util/ssoEnroll.js";
38
+ import { canMultiSelect, multiSelect } from "./util/multiSelect.js";
39
+ import { withSpinner } from "./util/spinner.js";
37
40
  import { recordMintSuccess, refreshIfDue, repathManagedTools, reserveMint, } from "./util/devRefresh.js";
38
41
  import { runSelfUpdate } from "./util/selfUpdate.js";
39
42
  import { migrateToDirectHeartbeat } from "./util/directHeartbeatMigration.js";
@@ -42,10 +45,10 @@ import { staleShellExportsForTargets } from "./tools/shellEnv.js";
42
45
  import { effectiveSubscription, metadataHeaderValue } from "./types.js";
43
46
  import { quitTargetKey, quitTargetLabel, relaunchResolvable, quitTargetSupported, relaunchApp, requestGracefulQuit, waitForQuit, } from "./util/appQuit.js";
44
47
  import { bold, cyan, dim, fail, heading, info, note, ok, warn, } from "./util/log.js";
45
- import { clearDevKey, clearEnrollment, hasEnrollment, loadProfile, recordManagedTools, saveProfile, saveDevKey, loadDevKey, stateDir, ensureStateDir, purgeRetiredFallbackState, } from "./util/persist.js";
48
+ import { clearDevKey, clearEnrollment, hasEnrollment, loadProfile, profilePath, recordManagedTools, saveProfile, saveDevKey, loadDevKey, stateDir, ensureStateDir, purgeRetiredFallbackState, } from "./util/persist.js";
46
49
  import { revokeDeviceToken } from "./util/deviceToken.js";
47
50
  import { isFleetLockedTool } from "./util/policySync.js";
48
- import { confirm, isInteractive, prompt, resolveDisplayName, resolveTeam, } from "./commands/shared.js";
51
+ import { confirm, isInteractive, PromptCancelledError, prompt, resolveDisplayName, resolveTeam, } from "./commands/shared.js";
49
52
  import { verifyConnection } from "./util/verify.js";
50
53
  import { detectSeatState } from "./util/seatState.js";
51
54
  import { isRemoteDevContext } from "./tools/copilotChat.js";
@@ -71,6 +74,42 @@ import { CLAUDE_DESKTOP_CREDENTIAL_ARG, } from "./tools/claudeDesktopCredentialP
71
74
  import { platform } from "node:os";
72
75
  const HELP = `${bold("anyray-connect")} — point your local coding tools at the Anyray gateway
73
76
 
77
+ ${bold("Usage")}
78
+ npx anyray-connect [options]
79
+ npx anyray-connect <gateway-url> [options]
80
+
81
+ ${bold("Subcommands")}
82
+ login Sign in through browser SSO
83
+ wizard Interactive guided setup (also the default on a TTY)
84
+ status Show the current gateway and connected tools
85
+ doctor Diagnose gateway reachability and tool configuration
86
+ sync Pull the team's shared skills and instructions
87
+ update Update Connect or configure automatic updates
88
+
89
+ ${bold("Options")}
90
+ --gateway <url> Gateway origin
91
+ --enroll <link> Connect with a passwordless enrollment link or token
92
+ --sso <link> Connect with an enterprise SSO link or tenant id
93
+ --tools <ids> Comma-separated subset: ${REGISTRY.map((t) => t.id).join(", ")}
94
+ --user <id> Attribute spend to this user
95
+ --team <id> Attribute spend to this team
96
+ --dry-run Show what would change without writing anything
97
+ --yes, -y Don't prompt for confirmation (for scripted rollout)
98
+ --revert Undo a previous run and clear stored enrollment
99
+ --verbose Show the curl re-test recipe and revert hint
100
+ --help, -h Show this help
101
+
102
+ Run \`npx anyray-connect --help --all\` for every command and option.
103
+
104
+ ${bold("What it does")}
105
+ Writes each tool's supported routing config plus the enrolled developer's
106
+ personal gateway key so requests flow through the gateway. The real provider
107
+ key stays server-side (ANYRAY_PROVIDER_KEY_*); your tools never hold it. Only
108
+ metadata (user/team) is attached — never prompt content.
109
+ ~/.anyray (mode 600) remembers your gateway, name/team, personal key, and the
110
+ per-skill versions ${dim("sync")} last wrote — for zero-question re-runs.`;
111
+ const HELP_ALL = `${bold("anyray-connect")} — point your local coding tools at the Anyray gateway
112
+
74
113
  ${bold("Usage")}
75
114
  npx anyray-connect <gateway-url> [options]
76
115
  npx anyray-connect [options] (gateway from $ANYRAY_GATEWAY_URL, the ~/.anyray cache, or http://localhost:8787)
@@ -224,6 +263,7 @@ ${bold("Options")}
224
263
  clear the stored enrollment. Reconnecting needs --sso (or a
225
264
  fresh --enroll link). Cannot be combined with --sso or
226
265
  --enroll. Restart any editor that was running.
266
+ --verbose Show the curl re-test recipe and revert hint after apply
227
267
  --help, -h Show this help
228
268
 
229
269
  ${bold("What it does")}
@@ -237,16 +277,16 @@ ${bold("What it does")}
237
277
  * The identity submitted with an `--enroll` redemption. A SHARED link binds no
238
278
  * identity, and the control plane refuses an identity-less redemption (it has
239
279
  * nothing to put in the DevCert / attribute spend to) — so when `--user` is
240
- * absent, fall back to the same detected identity the apply flow attributes
241
- * spend with. Personal links ignore this server-side (pre-bound identity wins),
280
+ * absent, use an email-first automatic identity with a human-readable
281
+ * fallback. Personal links ignore this server-side (pre-bound identity wins),
242
282
  * and connect adopts the cert's user afterwards, so roster, cert, and spend
243
- * attribution all carry one identity. `--user ''` is the documented cache-clear
244
- * sentinel (and `?? ` would pass it through), so blank values fall back too —
245
- * the CP refuses an identity-less shared redemption.
283
+ * attribution all carry one verified identity. `--user ''` is the documented
284
+ * cache-clear sentinel (and `?? ` would pass it through), so blank values fall
285
+ * back too — the CP refuses an identity-less shared redemption.
246
286
  */
247
287
  export const enrollIdentity = (user) => {
248
288
  const explicit = user?.trim();
249
- return explicit ? explicit : detectDisplayName();
289
+ return explicit ? explicit : detectEnrollmentIdentity();
250
290
  };
251
291
  export const parseArgs = (argv) => {
252
292
  const args = {
@@ -257,7 +297,9 @@ export const parseArgs = (argv) => {
257
297
  revert: false,
258
298
  force: false,
259
299
  skipVerify: false,
300
+ verbose: false,
260
301
  help: false,
302
+ helpAll: false,
261
303
  };
262
304
  for (let i = 0; i < argv.length; i++) {
263
305
  const a = argv[i];
@@ -362,10 +404,23 @@ export const parseArgs = (argv) => {
362
404
  case "--security-review":
363
405
  args.securityReview = true;
364
406
  break;
407
+ case "--verbose":
408
+ args.verbose = true;
409
+ break;
365
410
  case "--help":
366
411
  case "-h":
367
412
  args.help = true;
368
413
  break;
414
+ case "--help-all":
415
+ args.help = true;
416
+ args.helpAll = true;
417
+ break;
418
+ case "--all":
419
+ if (!argv.includes("--help") && !argv.includes("-h")) {
420
+ throw new Error(`unknown option: ${a}`);
421
+ }
422
+ args.helpAll = true;
423
+ break;
369
424
  default:
370
425
  // First non-flag token is the gateway URL; a second positional is an error.
371
426
  if (!a.startsWith("-") && args.gateway === undefined) {
@@ -440,6 +495,18 @@ const printTarget = (target, adapters) => {
440
495
  }
441
496
  info(` Attribution: ${metadataHeaderValue(target.metadata) ?? dim("(none — pass --user)")}`);
442
497
  };
498
+ const printSelectedTools = async (adapters, mode) => {
499
+ heading("Selected tools");
500
+ for (const adapter of adapters) {
501
+ const detected = await adapter.detect();
502
+ if (detected.installed) {
503
+ info(` ${adapter.title} ${dim(`(${adapter.id})`)} — ${mode === "plan" ? "will configure" : "would configure after enrollment"}${detected.detail ? `; ${detected.detail}` : ""}`);
504
+ }
505
+ else {
506
+ note(` ${adapter.title} ${dim(`(${adapter.id})`)} — not found${detected.detail ? `; ${detected.detail}` : ""}`);
507
+ }
508
+ }
509
+ };
443
510
  /**
444
511
  * Reuse a cached key only when it has the required `ark_` shape and its locally
445
512
  * recorded expiry, when present, is still in the future. Older profiles did not
@@ -521,13 +588,14 @@ explicitIds) => {
521
588
  let changes = 0;
522
589
  const manual = [];
523
590
  const results = [];
591
+ const skipped = [];
524
592
  for (const adapter of adapters) {
525
593
  const detected = await adapter.detect();
526
- heading(`${adapter.title} ${dim(`(${adapter.id})`)}`);
527
594
  if (!detected.installed && !revert) {
528
- note(` not found — ${detected.detail ?? "skipping"}`);
595
+ skipped.push(adapter.title);
529
596
  continue;
530
597
  }
598
+ heading(`${adapter.title} ${dim(`(${adapter.id})`)}`);
531
599
  try {
532
600
  const result = revert
533
601
  ? await adapter.revert(opts)
@@ -567,6 +635,9 @@ explicitIds) => {
567
635
  fail(` failed: ${error instanceof Error ? error.message : String(error)}`);
568
636
  }
569
637
  }
638
+ if (skipped.length > 0) {
639
+ note(`Skipped ${skipped.length} not installed (${skipped.join(", ")})`);
640
+ }
570
641
  // Upgrade cleanup, deliberately AFTER the adapters: every managed tool now
571
642
  // names the current runtime, so the copies left by earlier npx-launched
572
643
  // versions are unreferenced and can go. Nothing ever reaped them before, so an
@@ -752,55 +823,154 @@ export const relaunchApps = (closed, deps = {}) => {
752
823
  * which the adapter must be named to get (see `ToolAdapter.changesBilling`).
753
824
  */
754
825
  export const wizardSuggests = (adapter) => adapter.changesBilling !== true;
755
- /**
756
- * Interactive wizard guided first-run setup on a TTY. Detects installed
757
- * tools, lets the dev pick which to configure, confirms the gateway URL, name,
758
- * and team, then hands back the chosen args for the apply flow. Mutates and
759
- * returns `args`; falls through untouched if anything is already specified.
760
- *
761
- * Only the questions the dev hasn't already answered via flags are asked, so
762
- * `wizard --user x` stays terse. The apply flow still does the real work (and
763
- * its own preflight + persistence) — the wizard only gathers input.
764
- */
765
- const runWizard = async (args, profile) => {
826
+ /** Gather first-run choices without writing configuration. */
827
+ export const runWizard = async (args, profile, deps = {}) => {
828
+ const enrollmentPresent = deps.hasEnrollment ?? hasEnrollment;
829
+ const supportsMultiSelect = deps.canMultiSelect ?? canMultiSelect;
830
+ const selectMany = deps.multiSelect ?? multiSelect;
831
+ const ask = deps.prompt ?? prompt;
832
+ const registry = deps.registry ?? REGISTRY;
766
833
  info(bold("Anyray connect — guided setup"));
767
834
  info(dim("Point your local coding tools at the Anyray gateway. Ctrl-C to abort."));
768
- // Tool selection: show what's installed, let the dev narrow it down.
835
+ if (await enrollmentPresent()) {
836
+ info("Stored enrollment will be reused. Run with --revert to clear it.");
837
+ }
838
+ else if (!args.enroll && !args.sso && !args.gateway) {
839
+ heading("How do you want to connect?");
840
+ let method;
841
+ for (let attempt = 0; attempt < 3 && !method; attempt++) {
842
+ if (supportsMultiSelect()) {
843
+ const outcome = await selectMany({
844
+ title: "Choose a connection method",
845
+ single: true,
846
+ items: [
847
+ { value: "enroll", label: "Paste an enrollment link", selected: true },
848
+ { value: "sso", label: "Sign in with SSO" },
849
+ { value: "gateway", label: "I already have a gateway URL" },
850
+ ],
851
+ });
852
+ if (outcome.kind === "cancelled")
853
+ throw new PromptCancelledError();
854
+ method = outcome.values[0];
855
+ }
856
+ else {
857
+ info(" 1. Paste an enrollment link");
858
+ info(" 2. Sign in with SSO");
859
+ info(" 3. I already have a gateway URL");
860
+ const choice = await ask("? Connection method (1-3)", "1");
861
+ method =
862
+ choice === "1"
863
+ ? "enroll"
864
+ : choice === "2"
865
+ ? "sso"
866
+ : choice === "3"
867
+ ? "gateway"
868
+ : undefined;
869
+ }
870
+ if (!method)
871
+ warn("Choose one of the three connection methods.");
872
+ }
873
+ if (!method)
874
+ throw new PromptCancelledError();
875
+ for (let attempt = 0; attempt < 3; attempt++) {
876
+ if (method === "enroll") {
877
+ const answer = await ask("? Enrollment link or token");
878
+ if (answer && isEnrollArg(answer) && parseEnrollLink(answer)) {
879
+ args.enroll = answer;
880
+ break;
881
+ }
882
+ warn("That is not a valid enrollment link or token. Try again.");
883
+ }
884
+ else if (method === "sso") {
885
+ const answer = await ask("? SSO link or tenant id");
886
+ if (answer && parseSsoLink(answer)) {
887
+ args.sso = answer;
888
+ break;
889
+ }
890
+ warn("That is not a valid SSO link or tenant id. Try again.");
891
+ }
892
+ else {
893
+ const answer = await ask("? Gateway URL", profile.gateway || process.env.ANYRAY_GATEWAY_URL);
894
+ if (answer && isEnrollArg(answer)) {
895
+ args.enroll = answer;
896
+ info("That looks like an enrollment link — using passwordless enrollment.");
897
+ break;
898
+ }
899
+ try {
900
+ if (!answer)
901
+ throw new Error("missing gateway");
902
+ args.gateway = resolveOrigin(answer);
903
+ break;
904
+ }
905
+ catch {
906
+ warn("That is not a valid http(s) gateway URL. Try again.");
907
+ }
908
+ }
909
+ }
910
+ if (!args.enroll && !args.sso && !args.gateway) {
911
+ throw new PromptCancelledError();
912
+ }
913
+ }
769
914
  if (!args.tools || args.tools.length === 0) {
770
915
  heading("Tools detected");
771
- // Billing-switching tools are listed with their consequence but kept OUT of
772
- // the prefilled default: pressing Enter accepts a suggestion, which is not
773
- // a decision about where someone's inference is billed. Naming one is.
774
- const suggested = [];
775
- for (const adapter of REGISTRY) {
916
+ const installed = [];
917
+ const absent = [];
918
+ for (const adapter of registry) {
776
919
  const detected = await adapter.detect();
777
920
  if (!detected.installed) {
778
- note(` ${adapter.title} — not installed`);
921
+ absent.push(adapter);
779
922
  continue;
780
923
  }
781
- if (wizardSuggests(adapter)) {
782
- suggested.push(adapter.id);
783
- ok(` ${adapter.title} ${dim(`(${adapter.id})`)}`);
784
- }
785
- else {
786
- note(` ${adapter.title} ${dim(`(${adapter.id})`)} routing this moves its spend off your subscription onto the org API bill; add it by name to opt in`);
924
+ installed.push(adapter);
925
+ }
926
+ const suggested = installed
927
+ .filter((adapter) => wizardSuggests(adapter))
928
+ .map((adapter) => adapter.id);
929
+ if (installed.length > 0) {
930
+ for (let attempt = 0; attempt < 3 && !args.tools; attempt++) {
931
+ let chosen;
932
+ if (supportsMultiSelect()) {
933
+ const outcome = await selectMany({
934
+ title: "Tools to configure",
935
+ items: installed.map((adapter) => ({
936
+ value: adapter.id,
937
+ label: `${adapter.title} (${adapter.id})`,
938
+ hint: wizardSuggests(adapter)
939
+ ? undefined
940
+ : "moves inference spend from your subscription to the org API bill",
941
+ selected: wizardSuggests(adapter),
942
+ requiresExplicitChoice: !wizardSuggests(adapter),
943
+ })),
944
+ });
945
+ if (outcome.kind === "cancelled")
946
+ throw new PromptCancelledError();
947
+ chosen = outcome.values;
948
+ }
949
+ else {
950
+ const pick = await ask("? Tools to configure (comma-separated, blank = all suggested)", suggested.join(",") || undefined);
951
+ chosen = (pick ?? "")
952
+ .split(",")
953
+ .map((value) => value.trim())
954
+ .filter(Boolean);
955
+ const installedIds = new Set(installed.map((adapter) => adapter.id));
956
+ const invalid = chosen.filter((id) => !installedIds.has(id));
957
+ if (invalid.length > 0) {
958
+ warn(`Unknown or unavailable tool id${invalid.length === 1 ? "" : "s"}. Choose from: ${installed.map((adapter) => adapter.id).join(", ")}.`);
959
+ continue;
960
+ }
961
+ }
962
+ if (chosen.length === 0) {
963
+ warn("Choose at least one installed tool.");
964
+ continue;
965
+ }
966
+ args.tools = chosen;
787
967
  }
968
+ if (!args.tools)
969
+ throw new PromptCancelledError();
970
+ }
971
+ if (absent.length > 0) {
972
+ note(`${absent.length} others not installed (${absent.map((adapter) => adapter.id).join(", ")})`);
788
973
  }
789
- const pick = await prompt("? Tools to configure (comma-separated, blank = all suggested)", suggested.join(",") || undefined);
790
- const chosen = (pick ?? "")
791
- .split(",")
792
- .map((s) => s.trim())
793
- .filter(Boolean);
794
- if (chosen.length > 0)
795
- args.tools = chosen;
796
- }
797
- // Gateway URL: default to the cached/env/default origin.
798
- if (!args.gateway) {
799
- heading("Gateway");
800
- const fallback = profile.gateway || process.env.ANYRAY_GATEWAY_URL;
801
- const answer = await prompt("? Gateway URL", fallback);
802
- if (answer)
803
- args.gateway = answer;
804
974
  }
805
975
  // Identity is resolved by the apply flow's resolveDisplayName/resolveTeam,
806
976
  // which already prompt on a TTY — leave args.user/args.team untouched so the
@@ -819,7 +989,7 @@ const runVerify = async (target, toolIds) => {
819
989
  heading("Verifying the connection");
820
990
  // Pass the configured tool ids so the org-key remedy can be tool-specific
821
991
  // (e.g. OpenCode's Claude provider has no personal-subscription fallback).
822
- const verdict = await verifyConnection(target, { tools: toolIds });
992
+ const verdict = await withSpinner("Checking the gateway…", () => verifyConnection(target, { tools: toolIds }));
823
993
  if (verdict.kind === "ok")
824
994
  ok(` ${verdict.detail}`);
825
995
  else if (verdict.kind === "unreachable" ||
@@ -839,13 +1009,37 @@ const runVerify = async (target, toolIds) => {
839
1009
  const RECONCILE_RETRY_PROBE_TIMEOUT_MS = 5_000;
840
1010
  const RECONCILE_PROBE_GAP_MS = 500;
841
1011
  const RECONCILE_PROBE_ATTEMPTS = 3;
842
- // Scheduler-tick self-heal: EDR kills/quarantines the daemon service; a silent
843
- // port with key refresh enabled reinstalls it (the probe-first check is what
844
- // leaves a live daemon alone — including a healthy one on an older build).
1012
+ const PROFILE_SETTLE_MS = 10 * 60 * 1000;
1013
+ const defaultProfileAgeMs = async () => {
1014
+ try {
1015
+ const s = await stat(await profilePath());
1016
+ return Date.now() - s.mtimeMs;
1017
+ }
1018
+ catch {
1019
+ return Number.POSITIVE_INFINITY;
1020
+ }
1021
+ };
1022
+ // Scheduler-tick self-heal, both directions: reinstall a daemon EDR killed
1023
+ // while refresh is on; reap a disowned unit file (it re-registers on login).
845
1024
  export const reconcileProxyService = async (deps = {}) => {
846
1025
  try {
847
1026
  const profile = await (deps.load ?? loadProfile)();
848
- if (profile.keyRefreshEnabled !== true || !profile.gateway)
1027
+ if (profile.keyRefreshEnabled !== true) {
1028
+ if ((deps.draining ?? isDraining)())
1029
+ return;
1030
+ // Unit-file-gated: a port probe would reap the profile-less fleet
1031
+ // daemon (RFC 0007); a file-less zombie dies at reboot anyway.
1032
+ if (!(await (deps.unitExists ?? proxyUnitFileExists)()))
1033
+ return;
1034
+ // An apply persists the direct profile before repointing tools — only
1035
+ // sweep a settled profile so a racing tick never strands them.
1036
+ const age = await (deps.profileAgeMs ?? defaultProfileAgeMs)();
1037
+ if (age < PROFILE_SETTLE_MS)
1038
+ return;
1039
+ await (deps.uninstall ?? uninstallProxyService)(profile.fallbackPort ?? DEFAULT_LOOPBACK_PORT);
1040
+ return;
1041
+ }
1042
+ if (!profile.gateway)
849
1043
  return;
850
1044
  // RFC 0010 §6 P4: while a verified signed tray app owns launch-at-login,
851
1045
  // it — not launchd — respawns a dead daemon, so reinstalling the
@@ -1027,7 +1221,31 @@ const SUBCOMMANDS = new Set([
1027
1221
  "sync",
1028
1222
  "device-url",
1029
1223
  ]);
1030
- export const run = async (argv) => {
1224
+ const runApplySafely = async (argv, forceWizard) => {
1225
+ try {
1226
+ return await runApplyCommand(argv, forceWizard);
1227
+ }
1228
+ catch (error) {
1229
+ if (error instanceof PromptCancelledError) {
1230
+ console.error(dim("Cancelled — nothing was written."));
1231
+ return 130;
1232
+ }
1233
+ throw error;
1234
+ }
1235
+ };
1236
+ export const confirmApplyPlan = async (args, wizardRan, targetCount, deps = {}) => {
1237
+ if (args.yes || args.dryRun)
1238
+ return "proceed";
1239
+ if (!(deps.isInteractive ?? isInteractive)())
1240
+ return "non-interactive";
1241
+ // Flag-driven runs keep their original question so scripted use is unchanged.
1242
+ const verb = args.revert ? "remove Anyray config from" : "configure";
1243
+ const question = wizardRan
1244
+ ? "Apply this configuration?"
1245
+ : `\n${verb} ${targetCount} tool target(s)?`;
1246
+ return (await (deps.confirm ?? confirm)(question)) ? "proceed" : "declined";
1247
+ };
1248
+ const dispatch = async (argv) => {
1031
1249
  // Hidden hook entrypoint: Claude Code invokes `anyray-connect __anyray-hook`
1032
1250
  // as a PostToolUse hook. It speaks the hook stdin/stdout JSON protocol, so it
1033
1251
  // must run BEFORE any human-facing logging touches stdout. Never throws.
@@ -1247,7 +1465,7 @@ export const run = async (argv) => {
1247
1465
  return 1;
1248
1466
  }
1249
1467
  if (parsed.help) {
1250
- info(HELP);
1468
+ info(parsed.helpAll ? HELP_ALL : HELP);
1251
1469
  return 0;
1252
1470
  }
1253
1471
  if (sub === "status")
@@ -1267,11 +1485,25 @@ export const run = async (argv) => {
1267
1485
  if (sub === "device-url")
1268
1486
  return runDeviceUrl();
1269
1487
  // 'wizard' → apply flow with the wizard enabled.
1270
- return runApplyCommand(rest, true);
1488
+ return runApplySafely(rest, true);
1489
+ }
1490
+ return runApplySafely(argv, false);
1491
+ };
1492
+ // Not just apply: without this, index.ts dumps the raw error and exits 1.
1493
+ export const run = async (argv) => {
1494
+ try {
1495
+ return await dispatch(argv);
1496
+ }
1497
+ catch (error) {
1498
+ if (error instanceof PromptCancelledError) {
1499
+ console.error(dim("Cancelled."));
1500
+ return 130;
1501
+ }
1502
+ throw error;
1271
1503
  }
1272
- return runApplyCommand(argv, false);
1273
1504
  };
1274
- const runApplyCommand = async (argv, forceWizard) => {
1505
+ export const runApplyCommand = async (argv, forceWizard, deps = {}) => {
1506
+ const interactive = deps.isInteractive ?? isInteractive;
1275
1507
  let args;
1276
1508
  try {
1277
1509
  args = parseArgs(argv);
@@ -1282,7 +1514,7 @@ const runApplyCommand = async (argv, forceWizard) => {
1282
1514
  return 1;
1283
1515
  }
1284
1516
  if (args.help) {
1285
- info(HELP);
1517
+ info(args.helpAll ? HELP_ALL : HELP);
1286
1518
  return 0;
1287
1519
  }
1288
1520
  // These commands have opposite effects. In particular, accepting
@@ -1372,8 +1604,10 @@ const runApplyCommand = async (argv, forceWizard) => {
1372
1604
  !args.dryRun &&
1373
1605
  !args.enroll &&
1374
1606
  !args.sso;
1375
- if ((forceWizard || bareInteractive) && isInteractive()) {
1376
- args = await runWizard(args, profile);
1607
+ let wizardRan = false;
1608
+ if ((forceWizard || bareInteractive) && interactive()) {
1609
+ args = await (deps.runWizard ?? runWizard)(args, profile);
1610
+ wizardRan = true;
1377
1611
  }
1378
1612
  let adapters;
1379
1613
  try {
@@ -1390,21 +1624,32 @@ const runApplyCommand = async (argv, forceWizard) => {
1390
1624
  if (args.gateway && !args.enroll && isEnrollArg(args.gateway)) {
1391
1625
  args.enroll = args.gateway;
1392
1626
  args.gateway = undefined;
1393
- note("That looks like an enrollment link — treating it as --enroll.");
1394
- }
1395
- // An --enroll link carries no gateway URL of its own; resolve the bound
1396
- // gateway from the control plane's enroll-info endpoint so `--enroll <link>`
1397
- // is self-sufficient. An explicit --gateway wins we only fill the gap. The
1398
- // lookup is read-only and does not consume the enrollment token.
1627
+ info("That looks like an enrollment link — treating it as --enroll.");
1628
+ }
1629
+ // Resolve enrollment info when the gateway is unknown or a cached cert might
1630
+ // be reused. Besides the gateway URL it carries a personal link's pre-bound
1631
+ // identity. For a shared link, the absence of that identity tells reuse logic
1632
+ // to compare the stored cert with today's automatic identity, so a legacy
1633
+ // `unknown` binding can self-heal on re-run. A fresh enrollment with an
1634
+ // explicit gateway keeps the old one-round-trip path. The lookup is read-only
1635
+ // and does not consume the token.
1399
1636
  let enrollDeploymentId;
1400
- if (args.enroll && !args.gateway) {
1637
+ let enrollBoundIdentity;
1638
+ let enrollInfoResolved = false;
1639
+ if (args.enroll && (!args.gateway || profile.enrolledCert)) {
1401
1640
  const enrollLink = parseEnrollLink(args.enroll);
1402
1641
  if (enrollLink) {
1403
1642
  try {
1404
- const resolved = await resolveEnrollLink(enrollLink);
1405
- args.gateway = resolved.gatewayUrl;
1643
+ const resolved = await withSpinner("Resolving enrollment link…", () => resolveEnrollLink(enrollLink));
1644
+ const filledGateway = !args.gateway;
1645
+ if (filledGateway)
1646
+ args.gateway = resolved.gatewayUrl;
1406
1647
  enrollDeploymentId = resolved.deploymentId;
1407
- note(`Enrollment link${resolved.org ? ` for ${resolved.org}` : ""}: gateway ${resolved.gatewayUrl}`);
1648
+ enrollBoundIdentity = resolved.email;
1649
+ enrollInfoResolved = true;
1650
+ if (filledGateway) {
1651
+ note(`Enrollment link${resolved.org ? ` for ${resolved.org}` : ""}: gateway ${resolved.gatewayUrl}`);
1652
+ }
1408
1653
  if (resolved.seatMode && !args.subscription) {
1409
1654
  args.subscription = true;
1410
1655
  note("Subscription org: using pass-through auth (--subscription) — your own seat sign-in is used.");
@@ -1415,7 +1660,9 @@ const runApplyCommand = async (argv, forceWizard) => {
1415
1660
  // one (e.g. a returning dev re-minting while the control plane is briefly
1416
1661
  // unreachable) — the cache/env logic below fills it. Only fail when the
1417
1662
  // gateway is genuinely unknown.
1418
- const fallback = process.env.ANYRAY_GATEWAY_URL?.trim() || profile.gateway;
1663
+ const fallback = args.gateway ||
1664
+ process.env.ANYRAY_GATEWAY_URL?.trim() ||
1665
+ profile.gateway;
1419
1666
  if (fallback) {
1420
1667
  note(`Could not resolve the gateway from the enrollment link — using ${fallback}.`);
1421
1668
  }
@@ -1442,12 +1689,12 @@ const runApplyCommand = async (argv, forceWizard) => {
1442
1689
  return 1;
1443
1690
  }
1444
1691
  try {
1445
- const result = await ssoEnroll({
1692
+ const result = await withSpinner("Waiting for SSO sign-in…", () => ssoEnroll({
1446
1693
  cpOrigin: ssoLink.cpOrigin,
1447
1694
  tenantId: ssoLink.tenantId,
1448
1695
  gatewayUrl: args.gateway,
1449
1696
  log: (m) => note(m),
1450
- });
1697
+ }));
1451
1698
  ssoEnrollment = {
1452
1699
  cert: result.cert,
1453
1700
  privateKeyPem: result.privateKeyPem,
@@ -1526,7 +1773,7 @@ const runApplyCommand = async (argv, forceWizard) => {
1526
1773
  // the window once we already hold a key.
1527
1774
  await reserveMint();
1528
1775
  const presentedTokenHash = enrollTokenHash(input.token);
1529
- const storedValid = canReuseEnrolledCert(profile.enrolledCert, profile.enrolledCertExpiry, input.deploymentId, Date.now(), presentedTokenHash, profile.enrolledTokenHash);
1776
+ const storedValid = canReuseEnrolledCert(profile.enrolledCert, profile.enrolledCertExpiry, input.deploymentId, Date.now(), presentedTokenHash, profile.enrolledTokenHash, input.expectedUser);
1530
1777
  const storedPrivateKey = storedValid ? await loadDevKey() : undefined;
1531
1778
  const reuse = storedValid && storedPrivateKey && profile.enrolledCert
1532
1779
  ? {
@@ -1593,13 +1840,20 @@ const runApplyCommand = async (argv, forceWizard) => {
1593
1840
  }
1594
1841
  try {
1595
1842
  const machineId = args.machine ? deriveMachineId() : undefined;
1843
+ const detectedIdentity = enrollBoundIdentity ?? enrollIdentity(args.user);
1844
+ const expectedUser = args.machine
1845
+ ? `machine:${machineId}`
1846
+ : enrollInfoResolved
1847
+ ? detectedIdentity
1848
+ : undefined;
1596
1849
  await runEnrollment({
1597
1850
  cpOrigin: link.cpOrigin,
1598
1851
  token: link.token,
1599
- email: enrollIdentity(args.user),
1852
+ email: detectedIdentity,
1600
1853
  team: args.team,
1601
1854
  machineId,
1602
1855
  deploymentId: enrollDeploymentId,
1856
+ expectedUser,
1603
1857
  });
1604
1858
  }
1605
1859
  catch (error) {
@@ -1714,34 +1968,27 @@ const runApplyCommand = async (argv, forceWizard) => {
1714
1968
  }
1715
1969
  warn(` ${message}`);
1716
1970
  note(" Dry run only — no placeholder configuration will be written.");
1717
- heading("Selected tools");
1718
- for (const adapter of adapters) {
1719
- const detected = await adapter.detect();
1720
- if (detected.installed) {
1721
- info(` ${adapter.title} ${dim(`(${adapter.id})`)} — would configure after enrollment${detected.detail ? `; ${detected.detail}` : ""}`);
1722
- }
1723
- else {
1724
- note(` ${adapter.title} ${dim(`(${adapter.id})`)} — not found${detected.detail ? `; ${detected.detail}` : ""}`);
1725
- }
1726
- }
1971
+ await printSelectedTools(adapters, "after-enrollment");
1727
1972
  note("\nDry run complete — no files were written.");
1728
1973
  return 0;
1729
1974
  }
1730
1975
  if (!args.revert)
1731
1976
  printTarget(target, adapters);
1977
+ if (wizardRan && !args.revert) {
1978
+ await printSelectedTools(adapters, "plan");
1979
+ }
1732
1980
  if (args.dryRun)
1733
1981
  note("\nDry run — no files will be written.");
1734
- if (!args.yes && !args.dryRun) {
1735
- if (!isInteractive()) {
1736
- fail("\nNon-interactive session — cannot ask for confirmation. Pass --yes to proceed.");
1737
- return 1;
1738
- }
1739
- const verb = args.revert ? "remove Anyray config from" : "configure";
1740
- const proceed = await confirm(`\n${verb} ${adapters.length} tool target(s)?`);
1741
- if (!proceed) {
1742
- note("Aborted. Nothing changed.");
1743
- return 0;
1744
- }
1982
+ const confirmation = await confirmApplyPlan(args, wizardRan, adapters.length, { isInteractive: interactive, confirm: deps.confirm });
1983
+ if (confirmation === "non-interactive") {
1984
+ fail("\nNon-interactive session — cannot ask for confirmation. Pass --yes to proceed.");
1985
+ return 1;
1986
+ }
1987
+ if (confirmation === "declined") {
1988
+ note(wizardRan
1989
+ ? "Nothing was written. Re-run `npx anyray-connect wizard` when you're ready."
1990
+ : "Aborted. Nothing changed.");
1991
+ return 0;
1745
1992
  }
1746
1993
  // Preflight: never repoint a working tool at a gateway that isn't answering.
1747
1994
  // Skipped for dry-run (writes nothing) and revert (removing config, not adding
@@ -2023,8 +2270,8 @@ const runApplyCommand = async (argv, forceWizard) => {
2023
2270
  }
2024
2271
  }
2025
2272
  else if (args.revert && !args.dryRun) {
2026
- note("\nKeeping the stored enrollment — the tools you did not name still need it.");
2027
- note("Run --revert with no --tools to remove it.");
2273
+ info("\nKeeping the stored enrollment — the tools you did not name still need it.");
2274
+ info("Run --revert with no --tools to remove it.");
2028
2275
  }
2029
2276
  // A named `--tools` list (the wizard writes its checklist here too) is the
2030
2277
  // developer choosing these adapters by hand; the default sweep names none.
@@ -2165,7 +2412,21 @@ const runApplyCommand = async (argv, forceWizard) => {
2165
2412
  // separately at install time (and by `doctor`).
2166
2413
  await runVerify(target, adapters.map((a) => a.id));
2167
2414
  }
2168
- info("\nRe-test by hand any time:");
2415
+ heading("What's next");
2416
+ const configuredIds = new Set(results
2417
+ .filter((result) => result.status === "configured")
2418
+ .map((result) => result.id));
2419
+ const connected = adapters
2420
+ .filter((adapter) => configuredIds.has(adapter.id))
2421
+ .map((adapter) => adapter.title);
2422
+ info(connected.length > 0
2423
+ ? ` Connected: ${connected.join(", ")}`
2424
+ : " Connected: no tools completed automatic configuration.");
2425
+ info(" Restart any editor that was already running — it loaded our config at startup.");
2426
+ info(" If something looks wrong: npx anyray-connect doctor");
2427
+ if (!args.verbose)
2428
+ return 0;
2429
+ info("\n Re-test by hand any time:");
2169
2430
  const nativeCursorOnly = target.authMode !== "placeholder" &&
2170
2431
  adapters.length === 1 &&
2171
2432
  adapters[0]?.id === "cursor";