@shardflux/cli 0.19.1 → 0.22.0

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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -11,10 +11,10 @@
11
11
  * Hub info (`cli-proxy.json`, 0600, in the config directory, in the hub and in the machine template so every VM has
12
12
  * it): { version, api_url, workspace_id, callback_url, callback_created_at, management_key }. Never printed or logged.
13
13
  */
14
- import { randomBytes } from 'node:crypto';
14
+ import { createHash, randomBytes } from 'node:crypto';
15
15
  import { readFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
- import { Document, isMap, parseDocument } from 'yaml';
17
+ import { Document, isMap, isScalar, isSeq, parseDocument } from 'yaml';
18
18
  import { ShardfluxApiError } from '@shardflux/sdk';
19
19
  import { writePrivateFile } from "./credentials.js";
20
20
  import { CodedError, UsageError } from "./errors.js";
@@ -42,21 +42,20 @@ export const HUB_TEMPLATE = 'codex-connector';
42
42
  /**
43
43
  * The codex-connector version a hub runs at least: the first with the hub's boot service and an sf-agent that serves
44
44
  * the VM port (cli-proxy-filter). New hubs are created on it; an older hub is upgraded in place (its home, and so its
45
- * logins and settings, stay) before VMs get the hub's address.
45
+ * logins and settings, stay) before VMs get the hub's address. A hub on a newer version keeps it (never downgraded).
46
46
  */
47
47
  export const HUB_TEMPLATE_VERSION = 4;
48
- /**
49
- * The codex-connector version whose sf-agent runs the account pool router: a hub set up for pooled accounts is created
50
- * on it, or upgraded to it. HUB_TEMPLATE_VERSION moves to it once it is registered in every environment.
51
- */
52
- export const POOL_HUB_TEMPLATE_VERSION = 5;
53
48
  export const HUB_CAPS = { cpu_millis: 1000, memory_mib: 256, disk_gib: 2, allocation_mode: 'fixed' };
54
49
  export const HUB_INFO_FILE = 'cli-proxy.json';
55
50
  /** Paths in the hub workspace. */
56
51
  export const HUB_DIR = '/home/user/.cli-proxy-hub';
57
52
  export const HUB_AUTH_DIR = '/home/user/.cli-proxy-api';
58
- /** Pool mode: the router reads the API URL and the hub token (`sfh_…`) from these hub files (0600). */
59
- export const HUB_POOL_FILES = { apiUrl: `${HUB_DIR}/api-url`, apiToken: `${HUB_DIR}/api-token` };
53
+ /**
54
+ * Each provider's active login: `{ "<provider type>": "<auth file, relative to the auth directory>" }` (0600). The one
55
+ * login of each provider that stays enabled in the hub; every other login of that provider is kept there with
56
+ * `"disabled": true`. Written by the CLI and by the hub's sf-agent, both under the hub's `logins.lock`.
57
+ */
58
+ export const HUB_ACTIVE_FILE = `${HUB_DIR}/active.json`;
60
59
  const CALLBACK_PATH = /^\/__shardflux\/callback\/sfcb_[A-Za-z0-9_-]+\/$/;
61
60
  function callbackUrlOk(cb) {
62
61
  try {
@@ -315,7 +314,7 @@ export function sdkHubCloud(cloud, opts = {}) {
315
314
  }
316
315
  },
317
316
  async authFiles() {
318
- const r = await hub.sh(`[ -d ${HUB_AUTH_DIR} ] || exit 0; cd ${HUB_AUTH_DIR} && find . -type f -name '*.json' -printf '%P\\n'`);
317
+ const r = await hub.sh(AUTH_FILES_SCRIPT);
319
318
  if (r.code !== 0)
320
319
  throw new HubError(`Could not list the hub’s logins: ${r.stderr.trim() || `exit ${r.code}`}`);
321
320
  return r.stdout.split('\n').filter(Boolean);
@@ -380,6 +379,11 @@ export function sdkHubCloud(cloud, opts = {}) {
380
379
  probe: (url) => probeCallback(url),
381
380
  };
382
381
  }
382
+ /** The hub's auth files, oldest first (modification time, then path in byte order), one relative path per line. */
383
+ export const AUTH_FILES_SCRIPT = `set -euo pipefail
384
+ [ -d ${HUB_AUTH_DIR} ] || exit 0; cd ${HUB_AUTH_DIR}
385
+ find . -type f -name '*.json' -printf '%T@ %P\\n' | LC_ALL=C sort -t ' ' -k1,1n -k2 | cut -d ' ' -f 2-
386
+ `;
383
387
  /** Installs the pinned release once: download, verify its SHA-256, then move the binary into place. */
384
388
  export const INSTALL_SCRIPT = `set -euo pipefail
385
389
  v=${CLI_PROXY_VERSION}; dir=${HUB_DIR}; bin="$dir/bin/cli-proxy-api-$v"
@@ -600,12 +604,12 @@ export async function ensureCliProxyHub(cloud, opts) {
600
604
  const progress = opts.progress ?? (() => { });
601
605
  const local = readHubInfo(opts.dir, opts.apiUrl);
602
606
  progress('Opening the CLIProxyAPI hub…');
603
- // Pooled accounts need the router's template; everything else keeps the general one.
604
- const want = opts.pool ? POOL_HUB_TEMPLATE_VERSION : HUB_TEMPLATE_VERSION;
607
+ const want = HUB_TEMPLATE_VERSION;
605
608
  let opened = await hubCloud.open(!opts.repairOnly, want);
606
609
  if (!opened)
607
610
  throw new HubError('This account has no hosted CLIProxyAPI yet.', 'move yours: shard cli-proxy import');
608
- // A hub on an older template is upgraded before VMs get its address (a plain repair only starts it again).
611
+ // A hub on an older template is upgraded before VMs get its address (a plain repair only starts it again). One on a
612
+ // newer version (codex-connector 5) keeps it: its VM port serves exactly as on 4 once retirePool has run.
609
613
  const template = opened.template;
610
614
  if (!opened.created && (!opts.repairOnly || opts.renewInvalid) && hubCloud.upgrade && template?.version != null && template.version < want && (template.upgradeTo ?? 0) >= want) {
611
615
  progress('Updating the CLIProxyAPI hub…');
@@ -654,14 +658,11 @@ export async function ensureCliProxyHub(cloud, opts) {
654
658
  }
655
659
  if ((await ws.list(HUB_AUTH_DIR)) === null)
656
660
  await ws.sh(`mkdir -p -m 0700 ${HUB_AUTH_DIR}`);
657
- if (opts.pool) {
658
- // The router is in the hub's sf-agent: an older hub that could not be upgraded above cannot pool.
659
- const running = opened.template?.version;
660
- if (running != null && running < POOL_HUB_TEMPLATE_VERSION) {
661
- throw new HubError(`Pooled accounts need the hub on ${HUB_TEMPLATE} ${POOL_HUB_TEMPLATE_VERSION}; it runs ${running} and no update is published yet.`);
662
- }
663
- await setUpPool(ws, opts.apiUrl, opts.pool);
664
- }
661
+ // A hub that once ran the retired account pool router: its files go, and its sf-agent starts again without it.
662
+ if (await retirePool(ws))
663
+ restart = true;
664
+ // One login per provider serves requests: the rest stay in the hub, disabled.
665
+ const logins = await ws.withLock('logins', () => enforceActiveLogins(ws));
665
666
  const started = await ws.sh(START_SCRIPT, [restart ? '1' : '0'], { timeoutMs: 180_000 });
666
667
  const startLines = started.stdout.trim().split('\n');
667
668
  const state = startLines.find((l) => ['running', 'started', 'restarted'].includes(l));
@@ -681,46 +682,222 @@ export async function ensureCliProxyHub(cloud, opts) {
681
682
  };
682
683
  if (!local || hubInfoText(local) !== hubInfoText(info))
683
684
  writeHubInfo(opts.dir, info);
684
- return { info, created, process: state, callbackReplaced, config };
685
+ return { info, created, process: state, callbackReplaced, config, logins };
686
+ }
687
+ /** Parses active.json: provider type → auth file. Anything else (missing, not JSON, other values) counts as empty. */
688
+ export function parseActive(text) {
689
+ let v;
690
+ try {
691
+ v = JSON.parse(text ?? '');
692
+ }
693
+ catch {
694
+ return {};
695
+ }
696
+ if (!v || typeof v !== 'object' || Array.isArray(v))
697
+ return {};
698
+ const out = {};
699
+ for (const [k, f] of Object.entries(v))
700
+ if (typeof f === 'string' && f)
701
+ out[k.toLowerCase()] = f;
702
+ return out;
703
+ }
704
+ const docOf = (text) => {
705
+ try {
706
+ const v = JSON.parse(text ?? '');
707
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ };
713
+ /**
714
+ * The hub's logins, oldest first, as they are (nothing changes): `active` marks the one active.json names while it is
715
+ * enabled. Files the retired account pool router left are not listed (they stay disabled).
716
+ */
717
+ export async function listHubLogins(ws) {
718
+ const logins = await readHubLogins(ws, parseActive(await ws.read(HUB_ACTIVE_FILE)));
719
+ return logins.filter((l) => !l.pool).map(({ file, provider, email, disabled, active }) => ({ file, provider, email, disabled, active }));
720
+ }
721
+ /** The hub's logins, oldest first (the order authFiles lists them in). */
722
+ async function readHubLogins(ws, stored) {
723
+ const files = await ws.authFiles();
724
+ const texts = await Promise.all(files.map((f) => ws.read(`${HUB_AUTH_DIR}/${f}`)));
725
+ return files.map((file, i) => {
726
+ const text = texts[i] ?? null;
727
+ const doc = docOf(text);
728
+ const type = typeof doc?.type === 'string' && doc.type.trim() ? doc.type.trim().toLowerCase() : null;
729
+ const email = typeof doc?.email === 'string' && doc.email ? doc.email : null;
730
+ // The retired pool router's files (`sfa-<account id>.json`, `"prefix": "sfa-…"`) are never a user's active login.
731
+ const pool = /^sfa-[^/]+\.json$/.test(file) || (typeof doc?.prefix === 'string' && doc.prefix.startsWith('sfa-'));
732
+ const disabled = doc?.disabled === true;
733
+ return { file, provider: type, email, disabled, active: !pool && !disabled && type !== null && stored[type] === file, text, doc, pool };
734
+ });
735
+ }
736
+ /** The auth file's text with `disabled` set (true) or removed (false), every other field kept; null when unchanged. */
737
+ export function withDisabled(text, disabled) {
738
+ const doc = docOf(text);
739
+ if (!doc || (doc.disabled === true) === disabled)
740
+ return null;
741
+ if (disabled)
742
+ doc.disabled = true;
743
+ else
744
+ delete doc.disabled;
745
+ return `${JSON.stringify(doc, null, text.trimEnd().includes('\n') ? 2 : undefined)}${text.endsWith('\n') ? '\n' : ''}`;
685
746
  }
686
747
  /**
687
- * The hub config with the providers' rate-limit headers passed through (`requests.passthrough-headers`, or the legacy
688
- * top-level key): CLIProxyAPI 8.0.23 records them per login only for non-streaming answers, so the pool router reads
689
- * them on its own answers. Returns the text unchanged when it already passes them.
748
+ * Replaces one auth file atomically (0600, a temporary file in its folder then rename), only while it still holds what
749
+ * the device read ($2, its SHA-256): CLIProxyAPI rewrites a file when it refreshes the login, and a newer token must
750
+ * never be overwritten. The modification time stays, so the order of the hub's logins (oldest first) does not change.
751
+ * $1 is the path relative to the auth directory, the new content comes on standard input. Prints `written`, `changed`
752
+ * or `gone`.
690
753
  */
691
- export function poolHubConfig(text) {
692
- let doc = parseDocument(text || '{}');
754
+ export const SET_AUTH_SCRIPT = `set -euo pipefail
755
+ a=${HUB_AUTH_DIR}; rel=$1; want=$2
756
+ case "$rel" in ''|/*|*..*) echo "not a path inside the auth folder" >&2; exit 2 ;; esac
757
+ f="$a/$rel"
758
+ [ -f "$f" ] || { echo gone; exit 0; }
759
+ tmp=$(mktemp "$(dirname "$f")/.writing.XXXXXX"); trap 'rm -f "$tmp"' EXIT
760
+ chmod 0600 "$tmp"; cat > "$tmp"; touch -r "$f" "$tmp"
761
+ if [ "$(sha256sum < "$f" | cut -d' ' -f1)" != "$want" ]; then echo changed; exit 0; fi
762
+ mv -f "$tmp" "$f"; trap - EXIT
763
+ echo written
764
+ `;
765
+ /** Writes active.json atomically (0600) from standard input. */
766
+ export const ACTIVE_SCRIPT = `set -euo pipefail
767
+ dir=${HUB_DIR}; mkdir -p "$dir"; umask 077
768
+ tmp=$(mktemp "$dir/.active.XXXXXX"); trap 'rm -f "$tmp"' EXIT
769
+ cat > "$tmp"; mv -f "$tmp" "$dir/active.json"; trap - EXIT
770
+ echo written
771
+ `;
772
+ const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
773
+ /** Sets one login's `disabled` (true) or clears it (false), reading the file again when CLIProxyAPI changed it meanwhile. */
774
+ async function setDisabled(ws, login, disabled) {
775
+ let text = login.text;
776
+ for (let attempt = 0; attempt < 4; attempt++) {
777
+ if (text === null)
778
+ return;
779
+ const next = withDisabled(text, disabled);
780
+ if (next === null)
781
+ return;
782
+ const r = await ws.sh(SET_AUTH_SCRIPT, [login.file, sha256(text)], { stdin: next });
783
+ const out = r.stdout.trim();
784
+ if (r.code !== 0)
785
+ throw new HubError(`Could not update ${login.file} in the hub: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, 'run: shard cli-proxy import');
786
+ if (out === 'written' || out === 'gone')
787
+ return;
788
+ text = await ws.read(`${HUB_AUTH_DIR}/${login.file}`);
789
+ }
790
+ throw new HubError(`${login.file} kept changing in the hub while it was being ${disabled ? 'disabled' : 'enabled'}.`, 'run: shard cli-proxy import');
791
+ }
792
+ /**
793
+ * Keeps exactly one login enabled per provider (call it under the hub's `logins` lock, so two computers do not race):
794
+ * `prefer[provider]` when given, else the one active.json names while it is present and enabled, else the oldest.
795
+ * active.json records the choice first, then every other login of that provider is disabled (all other fields kept), then
796
+ * the chosen one is enabled. Files without a `type` are left alone; the retired pool router's files are
797
+ * disabled and never chosen. Returns the logins as they are now, oldest first (without the pool router's).
798
+ */
799
+ export async function enforceActiveLogins(ws, prefer = {}) {
800
+ const storedText = await ws.read(HUB_ACTIVE_FILE);
801
+ const stored = parseActive(storedText);
802
+ const logins = await readHubLogins(ws, stored);
803
+ const chosen = {};
804
+ for (const login of logins) {
805
+ const p = login.provider;
806
+ if (p === null || login.pool || chosen[p] !== undefined)
807
+ continue;
808
+ const mine = logins.filter((l) => l.provider === p && !l.pool);
809
+ const wanted = prefer[p];
810
+ if (wanted !== undefined && !mine.some((l) => l.file === wanted))
811
+ throw new HubError(`The hub has no ${p} login ${wanted} any more.`, 'see the hub’s logins: shard cli-proxy');
812
+ const current = mine.find((l) => l.file === stored[p] && !l.disabled);
813
+ chosen[p] = wanted ?? current?.file ?? login.file;
814
+ }
815
+ // Record the choice first, then disable the rest, then enable the chosen login: two logins of one provider are never
816
+ // enabled at the same moment (CLIProxyAPI would fail over between them), at the cost of an instant with none during a
817
+ // switch. The hub's sf-agent keeps one enabled login per provider on every change (the one active.json names, else
818
+ // the oldest enabled, never enabling one), so in this order it never disables the chosen login.
819
+ const text = `${JSON.stringify(Object.fromEntries(Object.entries(chosen).sort(([a], [b]) => a.localeCompare(b))), null, 2)}\n`;
820
+ if (text !== storedText && (storedText !== null || Object.keys(chosen).length > 0)) {
821
+ const r = await ws.sh(ACTIVE_SCRIPT, [], { stdin: text });
822
+ if (r.code !== 0)
823
+ throw new HubError(`The hub did not record its active logins: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, 'run: shard cli-proxy import');
824
+ }
825
+ for (const l of logins)
826
+ if (l.provider !== null && chosen[l.provider] !== l.file && !l.disabled)
827
+ await setDisabled(ws, l, true);
828
+ for (const l of logins)
829
+ if (l.provider !== null && chosen[l.provider] === l.file && l.disabled)
830
+ await setDisabled(ws, l, false);
831
+ return logins.filter((l) => !l.pool).map(({ file, provider, email, disabled }) => {
832
+ const active = provider !== null && chosen[provider] === file;
833
+ return { file, provider, email, disabled: provider === null ? disabled : !active, active };
834
+ });
835
+ }
836
+ /**
837
+ * One login per provider on a hub that may predate it, without waking the hub: a hub that is not running is left
838
+ * alone (it is checked again later). The forwarder on every connected computer runs this at start and every 15
839
+ * minutes, so a hub set up before this rule stops spreading requests across logins without anyone running a command.
840
+ */
841
+ export async function enforceHubLoginsIfRunning(hub, workspaceId) {
842
+ const state = await hub.state(workspaceId);
843
+ if (state === null)
844
+ return 'missing';
845
+ if (state !== 'running')
846
+ return 'asleep';
847
+ const opened = await hub.open(false);
848
+ if (!opened || opened.ws.id !== workspaceId)
849
+ return 'missing';
850
+ await opened.ws.withLock('logins', () => enforceActiveLogins(opened.ws));
851
+ return 'enforced';
852
+ }
853
+ /** What the retired account pool router kept in the hub (codex-connector 5 with pooled accounts). */
854
+ const POOL_LEFTOVERS = ['api-token', 'api-url', 'pool.json', 'pool-creds.json', 'pool-status.json', 'pool-client.key', 'pool.lock'];
855
+ /** Removes the router's files; prints `retired` when its hub token was there (its sf-agent then starts again without it). */
856
+ export const RETIRE_POOL_SCRIPT = `set -euo pipefail
857
+ cd ${HUB_DIR} 2>/dev/null || exit 0
858
+ router=no; if [ -e api-token ] || [ -e api-url ]; then router=yes; fi
859
+ rm -f ${POOL_LEFTOVERS.join(' ')}
860
+ if [ "$router" = yes ]; then echo retired; fi
861
+ `;
862
+ /** The config without the router's own client key (`api-keys`, or v8 `access.api-keys`); the text itself when absent. */
863
+ export function withoutClientKey(text, key) {
864
+ const doc = parseDocument(text);
693
865
  if (doc.errors.length > 0 || !isMap(doc.contents))
694
- doc = new Document({});
695
- const v8 = doc.hasIn(['config-version']) || V8_SECTIONS.some((k) => isMap(doc.getIn([k])));
696
- const path = v8 ? ['requests', 'passthrough-headers'] : ['passthrough-headers'];
697
- if (doc.getIn(path) === true)
698
866
  return text;
699
- if (v8 && doc.hasIn(['passthrough-headers']))
700
- doc.deleteIn(['passthrough-headers']);
701
- doc.setIn(path, true);
702
- return doc.toString();
703
- }
704
- const HUB_TOKEN_RE = /^sfh_[A-Za-z0-9_-]{43}$/;
705
- /** Pool mode in the hub: the API URL, the hub token (minted once, or again with renew) and header passthrough. */
706
- async function setUpPool(ws, apiUrl, pool) {
707
- await ws.withLock('pool', async () => {
708
- if ((await ws.read(HUB_POOL_FILES.apiUrl))?.trim() !== apiUrl)
709
- await ws.write(HUB_POOL_FILES.apiUrl, `${apiUrl}\n`, '0600');
710
- const have = (await ws.read(HUB_POOL_FILES.apiToken))?.trim() ?? '';
711
- if (pool.renew || !HUB_TOKEN_RE.test(have)) {
712
- const token = (await pool.hubToken(ws.id)).trim();
713
- if (!HUB_TOKEN_RE.test(token))
714
- throw new HubError('The Shardflux API did not return a hub token.');
715
- await ws.write(HUB_POOL_FILES.apiToken, `${token}\n`, '0600');
867
+ let changed = false;
868
+ for (const path of [['api-keys'], ['access', 'api-keys']]) {
869
+ const seq = doc.getIn(path, true);
870
+ if (!isSeq(seq))
871
+ continue;
872
+ const keep = seq.items.filter((item) => (isScalar(item) ? item.value : item) !== key);
873
+ if (keep.length !== seq.items.length) {
874
+ seq.items = keep;
875
+ changed = true;
716
876
  }
717
- const configPath = `${HUB_DIR}/config.yaml`;
718
- const config = (await ws.read(configPath)) ?? '';
719
- const next = poolHubConfig(config);
720
- // A running CLIProxyAPI reloads its config by itself; the router starts within a minute of the token.
877
+ }
878
+ return changed ? doc.toString() : text;
879
+ }
880
+ /**
881
+ * A hub that ran the account pool router (codex-connector 5): its hub token, API URL and router state go, and so does
882
+ * the router's client key in the hub's config. Returns whether the router could be running (the hub then restarts its
883
+ * sf-agent, which starts without it). A hub that never pooled costs one directory listing.
884
+ */
885
+ async function retirePool(ws) {
886
+ const names = (await ws.list(HUB_DIR)) ?? [];
887
+ if (!names.some((n) => POOL_LEFTOVERS.includes(n)))
888
+ return false;
889
+ const key = (await ws.read(`${HUB_DIR}/pool-client.key`))?.trim();
890
+ const configPath = `${HUB_DIR}/config.yaml`;
891
+ const config = key ? await ws.read(configPath) : null;
892
+ if (key && config !== null) {
893
+ const next = withoutClientKey(config, key);
721
894
  if (next !== config)
722
895
  await ws.write(configPath, next, '0600');
723
- });
896
+ }
897
+ const r = await ws.sh(RETIRE_POOL_SCRIPT);
898
+ if (r.code !== 0)
899
+ throw new HubError(`The hub did not remove the account pool’s files: ${r.stderr.trim().split('\n').at(-1) || `exit ${r.code}`}`, 'run: shard cli-proxy import');
900
+ return r.stdout.trim() === 'retired';
724
901
  }
725
902
  /**
726
903
  * The hub's one callback URL. Under the hub's callback lock: the stored one is kept while the port's callback still
@@ -211,7 +211,16 @@ export interface HubAccount {
211
211
  email: string | null;
212
212
  status: string | null;
213
213
  disabled: boolean;
214
+ /** The auth file, relative to the hub's auth directory (null when CLIProxyAPI does not name it). */
215
+ file: string | null;
216
+ /** The one login of its provider that serves requests (the others stay in the hub, disabled). */
217
+ active: boolean;
214
218
  }
219
+ /**
220
+ * Marks each provider's active login: an enabled login whose file is one of `activeFiles` (the hub's active.json).
221
+ * Without them (unreadable), a provider's only enabled login is its active one.
222
+ */
223
+ export declare function markActive(accounts: HubAccount[], activeFiles: ReadonlySet<string> | null): HubAccount[];
215
224
  /** The hub's logins from its management API (wakes the hub). */
216
225
  export declare function hubAccounts(info: HubInfo, timeoutMs?: number): Promise<HubAccount[]>;
217
226
  /** Whether the hub answers /healthz through its callback URL, and the edge's reason when it does not. */
@@ -230,6 +239,12 @@ export interface ImportOptions {
230
239
  machine?: boolean;
231
240
  platform?: NodeJS.Platform;
232
241
  }
242
+ /** A login named by provider, email and auth file (never its tokens). */
243
+ export interface LoginRef {
244
+ provider: string;
245
+ email: string | null;
246
+ file: string;
247
+ }
233
248
  export interface ImportResult {
234
249
  hub: {
235
250
  workspace_id: string;
@@ -238,7 +253,8 @@ export interface ImportResult {
238
253
  };
239
254
  /**
240
255
  * added: accounts this import gave the hub; already: accounts the hub had (its tokens stay); moved_aside: where this
241
- * computer's auth files went once the hub had them (`<dir>.moved-to-shardflux`).
256
+ * computer's auth files went once the hub had them (`<dir>.moved-to-shardflux`). active: each provider's one login
257
+ * that serves requests; disabled: the provider's other logins, kept in the hub (shard cli-proxy use switches).
242
258
  */
243
259
  logins: {
244
260
  uploaded: string[];
@@ -252,6 +268,8 @@ export interface ImportResult {
252
268
  email: string | null;
253
269
  }>;
254
270
  moved_aside: string[];
271
+ active: LoginRef[];
272
+ disabled: LoginRef[];
255
273
  };
256
274
  /** Whether the hub's config was made from this computer's, or the hub kept the one it has. */
257
275
  config: 'from_this_computer' | 'kept';
@@ -271,11 +289,21 @@ export interface ImportResult {
271
289
  dropped: string[];
272
290
  accounts: HubAccount[] | null;
273
291
  }
292
+ /** The logins a `--use` or `shard cli-proxy use` value names: by email (any case), path in the auth folder, or file name. */
293
+ export declare function matchLogins<T extends {
294
+ provider: string | null;
295
+ email: string | null;
296
+ file: string;
297
+ }>(spec: string, logins: readonly T[]): T[];
274
298
  /**
275
299
  * Moves CLIProxyAPI from this computer into the account (or connects this computer to the account's hub): ensure the
276
300
  * hub from the user's config, stop the local copy, upload the auth files the hub does not have yet (never overwriting
277
301
  * one: the hub's copy may hold a newer refresh token), start the forwarder on 127.0.0.1:8317 and put the hub info into
278
302
  * the machine template.
303
+ *
304
+ * One login per provider serves requests. `--use <email|file>` (repeatable, one per provider) picks it; otherwise a
305
+ * provider the hub already serves keeps its active login, and a provider new to the hub starts with this computer's
306
+ * oldest login of it. Every other login is uploaded with `"disabled": true` and kept in the hub.
279
307
  */
280
308
  export declare function importCliProxy(ctx: Ctx, opts?: ImportOptions): Promise<ImportResult>;
281
309
  export interface ExportOptions {
@@ -306,6 +334,47 @@ export interface ExportResult {
306
334
  * Undone by `shard cli-proxy import`.
307
335
  */
308
336
  export declare function exportCliProxy(ctx: Ctx, opts?: ExportOptions): Promise<ExportResult>;
337
+ export interface UseResult {
338
+ /** Each provider this command set, with the login it had and the one it has now. */
339
+ switched: Array<{
340
+ provider: string;
341
+ from: LoginRef | null;
342
+ to: LoginRef;
343
+ }>;
344
+ /** Every provider's active login, and the other logins kept in the hub, disabled. */
345
+ active: LoginRef[];
346
+ disabled: LoginRef[];
347
+ }
348
+ /**
349
+ * `shard cli-proxy use <email|file>`: makes that login its provider's active one in the hub (an email with a Claude
350
+ * and a Codex login sets both); the provider's other logins stay in the hub, disabled. Under the hub's logins lock.
351
+ */
352
+ export declare function useCliProxyLogin(ctx: Ctx, spec: string, opts?: {
353
+ hub?: HubCloud;
354
+ }): Promise<UseResult>;
355
+ /** How often the forwarder checks that the hub serves one login per provider while it runs. */
356
+ export declare const HUB_LOGIN_CHECK_MS: number;
357
+ /** How soon it looks again when the hub was asleep (a VM's request may wake it any moment; looking never wakes it). */
358
+ export declare const HUB_LOGIN_ASLEEP_MS = 60000;
359
+ /** The least time between two checks a request asks for (requests come in bursts). */
360
+ export declare const HUB_LOGIN_NUDGE_MS = 10000;
361
+ export interface HubLoginChecks {
362
+ /** A request arrived: the hub is awake or waking. Checks now unless a check found it running and fixed recently. */
363
+ nudge(): void;
364
+ stop(): void;
365
+ }
366
+ /**
367
+ * The forwarder's check that the hub serves one login per provider (enforceHubLoginsIfRunning), never waking the hub:
368
+ * at start; then every HUB_LOGIN_CHECK_MS while it runs and every HUB_LOGIN_ASLEEP_MS while it sleeps; and at the first
369
+ * request after it slept (nudge), so a hub set up before one login per provider cannot fail over between logins for
370
+ * long after it wakes. One check at a time.
371
+ */
372
+ export declare function hubLoginChecks(ctx: Ctx, source: () => HubInfo | null, log: (line: string) => void, opts?: {
373
+ hub?: HubCloud;
374
+ everyMs?: number;
375
+ asleepMs?: number;
376
+ nudgeMs?: number;
377
+ }): HubLoginChecks;
309
378
  /**
310
379
  * After the edge refused the hub's callback URL (`callback_invalid`: the API key that created it was revoked):
311
380
  * replaces the URLs the edge refuses (under the hub's lock), saves them locally and gives VMs the new VM address.