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.
- package/dist/cli.js +564 -301
- package/dist/cli.js.map +1 -1
- package/dist/commands/desktop.js +6 -1
- package/dist/commands/desktop.js.map +1 -1
- package/dist/commands/doctor.js +43 -16
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate.js +17 -4
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/projectApply.js +267 -0
- package/dist/commands/projectApply.js.map +1 -0
- package/dist/commands/quitWatch.js +124 -0
- package/dist/commands/quitWatch.js.map +1 -0
- package/dist/commands/status.js +11 -0
- package/dist/commands/status.js.map +1 -1
- package/dist/target.js +1 -1
- package/dist/target.js.map +1 -1
- package/dist/tools/claudeCode.js +1 -1
- package/dist/tools/claudeCode.js.map +1 -1
- package/dist/tools/claudeDesktop.js +232 -1
- package/dist/tools/claudeDesktop.js.map +1 -1
- package/dist/tools/claudeDesktopNative.js +9 -1
- package/dist/tools/claudeDesktopNative.js.map +1 -1
- package/dist/tools/claudeDesktopProfileNotice.js +28 -19
- package/dist/tools/claudeDesktopProfileNotice.js.map +1 -1
- package/dist/tools/copilotChat.js +22 -24
- package/dist/tools/copilotChat.js.map +1 -1
- package/dist/tools/copilotCli.js +20 -44
- package/dist/tools/copilotCli.js.map +1 -1
- package/dist/tools/cursor.js +126 -38
- package/dist/tools/cursor.js.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util/appQuit.js +331 -0
- package/dist/util/appQuit.js.map +1 -0
- package/dist/util/desktopProfileMigrate.js +39 -4
- package/dist/util/desktopProfileMigrate.js.map +1 -1
- package/dist/util/devRefresh.js +6 -0
- package/dist/util/devRefresh.js.map +1 -1
- package/dist/util/historyStore.js +0 -0
- package/dist/util/historyStore.js.map +1 -1
- package/dist/util/pendingOps.js +416 -0
- package/dist/util/pendingOps.js.map +1 -0
- package/dist/util/proxyService.js +15 -0
- package/dist/util/proxyService.js.map +1 -1
- package/dist/util/refreshScheduler.js +19 -0
- package/dist/util/refreshScheduler.js.map +1 -1
- package/dist/util/remoteContext.js +55 -0
- package/dist/util/remoteContext.js.map +1 -0
- package/dist/util/vscodeProcess.js +119 -0
- package/dist/util/vscodeProcess.js.map +1 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/commands/cursorWatch.js +0 -95
- package/dist/commands/cursorWatch.js.map +0 -1
- package/dist/util/cursorPending.js +0 -188
- 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 {
|
|
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
|
-
:
|
|
710
|
-
//
|
|
711
|
-
|
|
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:
|
|
877
|
-
// running at apply
|
|
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
|
-
|
|
881
|
-
|
|
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
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
//
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1701
|
-
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
1705
|
-
//
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
if (
|
|
1715
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
-
|
|
1747
|
-
//
|
|
1748
|
-
|
|
1749
|
-
//
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
2018
|
+
return 0;
|
|
2019
|
+
}
|
|
2020
|
+
finally {
|
|
2021
|
+
reopenClosed();
|
|
1758
2022
|
}
|
|
1759
|
-
return 0;
|
|
1760
2023
|
};
|
|
1761
2024
|
//# sourceMappingURL=cli.js.map
|