@shardflux/cli 0.20.0 → 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.
- package/CHANGELOG.md +214 -1
- package/README.md +215 -75
- package/dist/account-args.js +7 -2
- package/dist/account-commands.js +56 -106
- package/dist/account-pool.d.ts +19 -15
- package/dist/account-pool.js +47 -96
- package/dist/account-scope.d.ts +65 -0
- package/dist/account-scope.js +246 -0
- package/dist/acp-login.d.ts +4 -3
- package/dist/acp-login.js +7 -5
- package/dist/acp-models.js +20 -15
- package/dist/acp.js +66 -13
- package/dist/agent-commands.d.ts +4 -1
- package/dist/agent-commands.js +86 -32
- package/dist/agent-config-return.d.ts +46 -2
- package/dist/agent-config-return.js +110 -4
- package/dist/agent-credentials.js +7 -3
- package/dist/agent-repository.d.ts +16 -3
- package/dist/agent-repository.js +149 -74
- package/dist/agent-setup.d.ts +14 -3
- package/dist/agent-setup.js +78 -36
- package/dist/agent-target-commands.js +24 -13
- package/dist/args.d.ts +1 -1
- package/dist/args.js +94 -54
- package/dist/bin.js +5 -1
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -14
- package/dist/claude-cloud-login.d.ts +24 -2
- package/dist/claude-cloud-login.js +151 -11
- package/dist/cli-proxy-forward.d.ts +2 -0
- package/dist/cli-proxy-forward.js +9 -2
- package/dist/cli-proxy-hub.d.ts +69 -25
- package/dist/cli-proxy-hub.js +231 -54
- package/dist/cli-proxy.d.ts +70 -1
- package/dist/cli-proxy.js +298 -32
- package/dist/client-settings.d.ts +39 -0
- package/dist/client-settings.js +91 -0
- package/dist/codex-cloud.d.ts +5 -0
- package/dist/codex-cloud.js +88 -17
- package/dist/command-center-actions.js +5 -1
- package/dist/commands.js +171 -22
- package/dist/conversation-commands.d.ts +5 -0
- package/dist/conversation-commands.js +8 -5
- package/dist/conversation-terminal.d.ts +10 -2
- package/dist/conversation-terminal.js +77 -13
- package/dist/conversations.d.ts +73 -1
- package/dist/conversations.js +257 -23
- package/dist/device-bridge.d.ts +1 -1
- package/dist/device-bridge.js +6 -4
- package/dist/device-helper.js +43 -22
- package/dist/errors.d.ts +27 -1
- package/dist/errors.js +45 -5
- package/dist/filesystem-commands.d.ts +16 -9
- package/dist/filesystem-commands.js +108 -54
- package/dist/format.d.ts +2 -1
- package/dist/format.js +10 -1
- package/dist/handlers.js +3 -1
- package/dist/initial-prompt.d.ts +8 -0
- package/dist/initial-prompt.js +43 -1
- package/dist/large-media.d.ts +14 -0
- package/dist/large-media.js +100 -0
- package/dist/linux-terminal.d.ts +5 -3
- package/dist/linux-terminal.js +6 -20
- package/dist/local-sessions.d.ts +26 -2
- package/dist/local-sessions.js +113 -24
- package/dist/machine-setup.d.ts +11 -1
- package/dist/machine-setup.js +69 -22
- package/dist/machine-status.d.ts +67 -0
- package/dist/machine-status.js +140 -0
- package/dist/main.js +11 -13
- package/dist/move-worktrees-restore.py +144 -0
- package/dist/move-worktrees.d.ts +63 -0
- package/dist/move-worktrees.js +201 -0
- package/dist/move.d.ts +10 -1
- package/dist/move.js +116 -16
- package/dist/onboarding.d.ts +11 -7
- package/dist/onboarding.js +59 -27
- package/dist/prepared-repository.d.ts +202 -0
- package/dist/prepared-repository.js +860 -0
- package/dist/prepared-repository.py +1306 -0
- package/dist/project-preparation.d.ts +48 -0
- package/dist/project-preparation.js +304 -0
- package/dist/project-sync-manifest.d.ts +4 -0
- package/dist/project-sync-manifest.js +5 -1
- package/dist/project-sync-transfer.js +1 -1
- package/dist/project-sync.d.ts +19 -4
- package/dist/project-sync.js +31 -8
- package/dist/project-template-setup.d.ts +33 -1
- package/dist/project-template-setup.js +59 -2
- package/dist/secret-ask.d.ts +72 -0
- package/dist/secret-ask.js +351 -0
- package/dist/seed-phases.d.ts +8 -1
- package/dist/seed-phases.js +17 -2
- package/dist/setup-cache.d.ts +7 -0
- package/dist/setup-cache.js +32 -0
- package/dist/t3-launch.js +1 -4
- package/dist/t3-mode.d.ts +8 -2
- package/dist/t3-mode.js +19 -4
- package/dist/t3-phone.d.ts +4 -2
- package/dist/t3-phone.js +12 -4
- package/dist/terminal-apps.d.ts +157 -0
- package/dist/terminal-apps.js +561 -0
- package/dist/terminal-onboarding.js +6 -1
- package/dist/this-computer.d.ts +11 -0
- package/dist/this-computer.js +36 -0
- package/dist/thread-on-computer.js +4 -1
- package/dist/update.js +11 -3
- package/dist/worktree-twin.d.ts +10 -2
- package/dist/worktree-twin.js +27 -9
- package/package.json +5 -5
package/dist/cli-proxy-hub.js
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
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
|
|
692
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
package/dist/cli-proxy.d.ts
CHANGED
|
@@ -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.
|