claude-multiacc 1.0.13 → 1.0.14
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/README.md +249 -14
- package/bin/claude +402 -38
- package/bin/claude-accounts +141 -45
- package/bin/codex +254 -26
- package/bin/codex-accounts +157 -52
- package/install.sh +175 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +355 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1053 -1
package/bin/codex-accounts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# codex-accounts — manage the codex-multiacc account pool (OpenAI Codex CLI).
|
|
3
3
|
# Subcommands: list status add import adopt dedupe remove login expired relogin
|
|
4
4
|
# sync verify limits post-sync health self-update
|
|
5
|
+
# export-credential import-credential
|
|
5
6
|
set -u
|
|
6
7
|
# lib/codex_audit.py is imported by several subcommands; keep the install tree free
|
|
7
8
|
# of __pycache__ (it may be root-owned, read-only, or an npm global prefix).
|
|
@@ -25,8 +26,8 @@ usage() {
|
|
|
25
26
|
codex-accounts — multi-account pool manager for codex-multiacc (OpenAI Codex CLI)
|
|
26
27
|
|
|
27
28
|
USAGE
|
|
28
|
-
codex-accounts list
|
|
29
|
-
codex-accounts status
|
|
29
|
+
codex-accounts list [--json] brief account list (--json: machine-readable)
|
|
30
|
+
codex-accounts status [--json] full health: auth, per-window limits, markers
|
|
30
31
|
codex-accounts add [email] [--browser] [--force]
|
|
31
32
|
login-FIRST: runs the Codex DEVICE-CODE sign-in by default — it prints a URL
|
|
32
33
|
+ one-time code you can open in ANY browser (this machine, your laptop, a
|
|
@@ -55,17 +56,32 @@ USAGE
|
|
|
55
56
|
codex-accounts adopt <acct-NN> make acct-NN THIS machine's existing
|
|
56
57
|
default ~/.codex login (dir symlink —
|
|
57
58
|
single credential file, no grant fork)
|
|
59
|
+
codex-accounts export-credential <acct-NN> [--out PATH] [--identity-only]
|
|
60
|
+
codex has NO portable credential class: auth.json carries a rotating refresh
|
|
61
|
+
token, so a copy breaks both machines. This ALWAYS refuses a credential
|
|
62
|
+
export (exit 3) and says what to do instead (sign in on that machine with
|
|
63
|
+
the device-code flow). --identity-only exports the registry entry
|
|
64
|
+
(id/email/home) so a daemon can seed the account list on another machine.
|
|
65
|
+
codex-accounts import-credential [acct-NN] [--in PATH|-] [--home mac|server]
|
|
66
|
+
[--force] [--no-sync]
|
|
67
|
+
install a blob from export-credential (stdin by default). For codex that is
|
|
68
|
+
always an identity-only blob: the account is registered and then needs ONE
|
|
69
|
+
interactive sign-in here (codex-accounts login <acct-NN>).
|
|
58
70
|
codex-accounts remove <acct-NN> [--yes] delete account (propagates to server)
|
|
59
71
|
codex-accounts dedupe [--yes] remove any account registered twice
|
|
60
72
|
(same email), keeping one per email
|
|
61
|
-
codex-accounts sync
|
|
62
|
-
manifest 'peers' (extra machines). Mac
|
|
63
|
-
auth.json is machine-local, NEVER
|
|
64
|
-
A pool with a 'sync-role' file saying
|
|
73
|
+
codex-accounts sync [--no-server] push manifest+seeds to the sync target AND
|
|
74
|
+
any manifest 'peers' (extra machines). Mac
|
|
75
|
+
only. auth.json is machine-local, NEVER
|
|
76
|
+
synced. A pool with a 'sync-role' file saying
|
|
65
77
|
'replica' never pushes (it receives).
|
|
78
|
+
--no-server (or a pool whose target is
|
|
79
|
+
'none') keeps sync LOCAL: validate + seed +
|
|
80
|
+
fix perms, push nowhere — for pools a panel
|
|
81
|
+
or runner daemon distributes.
|
|
66
82
|
codex-accounts verify [--quick] auth matrix; full mode runs a real
|
|
67
83
|
`codex exec` per account
|
|
68
|
-
codex-accounts limits [--quiet] [--force]
|
|
84
|
+
codex-accounts limits [--quiet] [--force] [--json]
|
|
69
85
|
refresh usage windows from the ChatGPT usage endpoint, apply >=90% markers.
|
|
70
86
|
Auto-refreshes long-expired access tokens via the OAuth refresh-token grant
|
|
71
87
|
(rotated credential is persisted), so idle accounts keep fresh telemetry and
|
|
@@ -77,7 +93,12 @@ USAGE
|
|
|
77
93
|
codex-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
78
94
|
|
|
79
95
|
ENV
|
|
80
|
-
|
|
96
|
+
CODEX_ACCOUNTS_ROOT pool root, overriding ~/.codex-accounts — one isolated pool
|
|
97
|
+
per app-robot instance on a shared machine (legacy spelling
|
|
98
|
+
CODEX_ACCOUNTS_DIR still works)
|
|
99
|
+
CODEX_MULTIACC_SYNC_TARGET sync target (user@host), overriding the manifest;
|
|
100
|
+
'none' = local-only, nothing is pushed anywhere
|
|
101
|
+
CODEX_MULTIACC_SYNC_ROOT / _SYNC_REPO remote pool root / addon repo for it
|
|
81
102
|
CODEX_ACCOUNT pin the shim to one account
|
|
82
103
|
CODEX_SHIM_RETRY=0 disable the `codex exec` auto-retry
|
|
83
104
|
CODEX_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
@@ -99,17 +120,19 @@ next_id() {
|
|
|
99
120
|
done
|
|
100
121
|
}
|
|
101
122
|
|
|
102
|
-
manifest_add_account() { # id email home
|
|
103
|
-
"$PYBIN" - "$MANIFEST" "$1" "$2" "$3" <<'PYEOF'
|
|
123
|
+
manifest_add_account() { # id email home [added_at]
|
|
124
|
+
"$PYBIN" - "$MANIFEST" "$1" "$2" "$3" "${4:-}" <<'PYEOF'
|
|
104
125
|
import json, sys, time
|
|
105
|
-
path, aid, email, home = sys.argv[1:
|
|
126
|
+
path, aid, email, home, added_at = sys.argv[1:6]
|
|
106
127
|
doc = json.load(open(path))
|
|
107
128
|
accounts = [a for a in doc.get('accounts', []) if a['id'] != aid]
|
|
108
129
|
accounts.append({
|
|
109
130
|
'id': aid,
|
|
110
131
|
'email': email,
|
|
111
132
|
'home': home,
|
|
112
|
-
|
|
133
|
+
# An imported account keeps the added_at it was registered with on the machine it
|
|
134
|
+
# came from, so the same account reads identically across the fleet.
|
|
135
|
+
'added_at': added_at or time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
|
113
136
|
})
|
|
114
137
|
accounts.sort(key=lambda a: a['id'])
|
|
115
138
|
doc['accounts'] = accounts
|
|
@@ -132,6 +155,17 @@ for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
|
132
155
|
PYEOF
|
|
133
156
|
}
|
|
134
157
|
|
|
158
|
+
manifest_email_of() { # prints the email registered for <id>, empty if unknown
|
|
159
|
+
[ -f "$MANIFEST" ] || return 0
|
|
160
|
+
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
|
|
161
|
+
import json, sys
|
|
162
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
163
|
+
if isinstance(a, dict) and a.get('id') == sys.argv[2]:
|
|
164
|
+
print(a.get('email', ''))
|
|
165
|
+
break
|
|
166
|
+
PYEOF
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
manifest_del_account() { # id
|
|
136
170
|
"$PYBIN" - "$MANIFEST" "$1" <<'PYEOF'
|
|
137
171
|
import json, sys
|
|
@@ -177,8 +211,11 @@ login_lock_busy() { # $1 = acct dir; true when a LIVE login ceremony holds it
|
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
180
|
-
[ "$(machine_kind)" = "mac" ] || return 0
|
|
181
214
|
[ "${CODEX_MULTIACC_NO_SYNC:-0}" = "1" ] && return 0
|
|
215
|
+
# Local-only pool (target 'none'): there is nothing to push — the panel/runner
|
|
216
|
+
# daemon distributes accounts — so a mutation must not warn about a missing server.
|
|
217
|
+
sync_target_is_local "$(sync_target)" && return 0
|
|
218
|
+
[ "$(machine_kind)" = "mac" ] || return 0
|
|
182
219
|
# A replica pool never pushes (the source machine owns the account set) —
|
|
183
220
|
# silently, so every mutation on a replica does not nag about it.
|
|
184
221
|
sync_is_replica && return 0
|
|
@@ -188,6 +225,14 @@ auto_sync() { # best effort after mutations, Mac only, loud on failure
|
|
|
188
225
|
|
|
189
226
|
cmd_list() {
|
|
190
227
|
require_manifest
|
|
228
|
+
local json=0
|
|
229
|
+
while [ $# -gt 0 ]; do
|
|
230
|
+
case "$1" in
|
|
231
|
+
--json) json=1; shift ;;
|
|
232
|
+
*) die "unknown option: $1 (usage: codex-accounts list [--json])" ;;
|
|
233
|
+
esac
|
|
234
|
+
done
|
|
235
|
+
if [ "$json" = "1" ]; then emit_report_json list; return $?; fi
|
|
191
236
|
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
192
237
|
import json, os, re, sys
|
|
193
238
|
doc = json.load(open(sys.argv[1]))
|
|
@@ -236,6 +281,14 @@ PYEOF
|
|
|
236
281
|
|
|
237
282
|
cmd_status() {
|
|
238
283
|
require_manifest
|
|
284
|
+
local json=0
|
|
285
|
+
while [ $# -gt 0 ]; do
|
|
286
|
+
case "$1" in
|
|
287
|
+
--json) json=1; shift ;;
|
|
288
|
+
*) die "unknown option: $1 (usage: codex-accounts status [--json])" ;;
|
|
289
|
+
esac
|
|
290
|
+
done
|
|
291
|
+
if [ "$json" = "1" ]; then emit_report_json status; return $?; fi
|
|
239
292
|
"$PYBIN" - "$MANIFEST" "$ACC_ROOT" "$LIB_DIR" "$(machine_kind)" <<'PYEOF'
|
|
240
293
|
import json, os, sys, time
|
|
241
294
|
doc = json.load(open(sys.argv[1]))
|
|
@@ -527,20 +580,29 @@ cmd_import() {
|
|
|
527
580
|
link) link_target="$(canon_path "$auth")" ;;
|
|
528
581
|
*)
|
|
529
582
|
mkdir -p "$ACC_ROOT/tmp"
|
|
530
|
-
|
|
583
|
+
# mktemp, not a predictable name: the staged file briefly holds a live
|
|
584
|
+
# credential, and a predictable path in a shared pool root could be
|
|
585
|
+
# pre-planted as a symlink and written through.
|
|
586
|
+
stage="$(mktemp "$ACC_ROOT/tmp/import-auth.XXXXXX")" \
|
|
587
|
+
|| die "could not create a staging file under $ACC_ROOT/tmp"
|
|
588
|
+
IMPORT_STAGE="$stage"
|
|
589
|
+
trap import_cred_cleanup EXIT
|
|
531
590
|
case "$mode" in
|
|
532
|
-
copy) ( umask 077;
|
|
533
|
-
move)
|
|
534
|
-
esac || {
|
|
591
|
+
copy) ( umask 077; cat "$auth" > "$stage" ) ;;
|
|
592
|
+
move) cat "$auth" > "$stage" && rm -f "$auth" ;;
|
|
593
|
+
esac || { import_cred_cleanup; trap - EXIT; die "could not stage $auth (mode=$mode) — nothing registered"; }
|
|
594
|
+
chmod 600 "$stage" 2>/dev/null || true
|
|
535
595
|
;;
|
|
536
596
|
esac
|
|
537
597
|
fi
|
|
538
598
|
# Id allocation + duplicate check + manifest write run under the mutation lock, so
|
|
539
599
|
# two parallel imports can never claim the same slot or drop each other's entry.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
600
|
+
IMPORT_STAGE="$stage"
|
|
601
|
+
mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
|
|
602
|
+
# A CONSTANT trap program (import_cred_cleanup): interpolating the staged path would
|
|
603
|
+
# let a pool root containing a quote inject shell code into the trap body.
|
|
604
|
+
trap import_cred_cleanup EXIT
|
|
605
|
+
fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
|
|
544
606
|
[ -n "$id" ] || id="$(next_id)"
|
|
545
607
|
case "$id" in acct-[0-9][0-9]) ;; *) fail_locked "id must look like acct-NN" ;; esac
|
|
546
608
|
local owner
|
|
@@ -555,6 +617,7 @@ cmd_import() {
|
|
|
555
617
|
mv -f "$stage" "$d/auth.json" \
|
|
556
618
|
|| fail_locked "could not adopt $auth into $d (mode=$mode) — nothing registered"
|
|
557
619
|
stage=""
|
|
620
|
+
IMPORT_STAGE=""
|
|
558
621
|
chmod 600 "$d/auth.json" 2>/dev/null || true
|
|
559
622
|
elif [ -n "$link_target" ]; then
|
|
560
623
|
ln -sf "$link_target" "$d/auth.json" \
|
|
@@ -952,11 +1015,12 @@ cmd_relogin() {
|
|
|
952
1015
|
|
|
953
1016
|
cmd_limits() {
|
|
954
1017
|
require_manifest
|
|
955
|
-
local quiet=0 force=0
|
|
1018
|
+
local quiet=0 force=0 json=0
|
|
956
1019
|
while [ $# -gt 0 ]; do
|
|
957
1020
|
case "$1" in
|
|
958
1021
|
--quiet) quiet=1; shift ;;
|
|
959
1022
|
--force) force=1; shift ;; # ignore freshness/backoff (manual override)
|
|
1023
|
+
--json) json=1; quiet=1; shift ;; # refresh silently, then emit the report
|
|
960
1024
|
*) die "unknown option: $1" ;;
|
|
961
1025
|
esac
|
|
962
1026
|
done
|
|
@@ -965,14 +1029,20 @@ cmd_limits() {
|
|
|
965
1029
|
if ! mkdir "$lock" 2>/dev/null; then
|
|
966
1030
|
local age=$(( $(epoch_now) - $(file_mtime "$lock") ))
|
|
967
1031
|
if [ "$age" -lt 120 ]; then
|
|
1032
|
+
# A --json caller still gets the document (built from the state on disk) —
|
|
1033
|
+
# a machine-readable verb must never answer a concurrent run with silence.
|
|
1034
|
+
if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
|
|
968
1035
|
[ "$quiet" = "1" ] || echo "another limits refresh is running; skipping"
|
|
969
1036
|
return 0
|
|
970
1037
|
fi
|
|
971
1038
|
rm -rf "$lock"
|
|
972
|
-
mkdir "$lock" 2>/dev/null
|
|
1039
|
+
if ! mkdir "$lock" 2>/dev/null; then
|
|
1040
|
+
if [ "$json" = "1" ]; then emit_report_json limits; return $?; fi
|
|
1041
|
+
return 0
|
|
1042
|
+
fi
|
|
973
1043
|
fi
|
|
974
|
-
|
|
975
|
-
trap
|
|
1044
|
+
LIMITS_LOCK="$lock"
|
|
1045
|
+
trap limits_lock_release EXIT
|
|
976
1046
|
rotate_log limits.log
|
|
977
1047
|
# The >=90% exclusion rule is a hard requirement: the manifest may tighten it but
|
|
978
1048
|
# never loosen it, or an account could sit at 95% and still be selected.
|
|
@@ -987,7 +1057,7 @@ import base64, datetime, json, os, sys, time, urllib.request
|
|
|
987
1057
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
988
1058
|
now = time.time()
|
|
989
1059
|
# Don't re-fetch an account whose data is younger than this (endpoint rate-limits).
|
|
990
|
-
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '
|
|
1060
|
+
MIN_FETCH_INTERVAL = int(os.environ.get('CODEX_MULTIACC_MIN_FETCH', '240'))
|
|
991
1061
|
|
|
992
1062
|
# OAuth refresh-token grant — the same endpoint + public client id the Codex CLI
|
|
993
1063
|
# itself uses to keep auth.json alive. An account that sits idle past its
|
|
@@ -1416,13 +1486,20 @@ for acct in manifest.get('accounts', []):
|
|
|
1416
1486
|
say(f"{aid}: LIMITED {worst['name']} at {worst['percent']}% (resets {worst['resets_at']})")
|
|
1417
1487
|
else:
|
|
1418
1488
|
if os.path.exists(mpath):
|
|
1419
|
-
# A shim-written
|
|
1420
|
-
#
|
|
1489
|
+
# A shim-written marker outlives a clean limits pass while its own window
|
|
1490
|
+
# is still open:
|
|
1491
|
+
# error-cooldown — the account failed a real call moments ago.
|
|
1492
|
+
# client-rate-limit — a real codex run was told by the server that this
|
|
1493
|
+
# window is spent, and recorded the reset it was
|
|
1494
|
+
# handed. That is first-hand evidence; a usage
|
|
1495
|
+
# payload that disagrees must not unpark the account
|
|
1496
|
+
# early and send work straight back into the wall.
|
|
1421
1497
|
keep = False
|
|
1422
1498
|
try:
|
|
1423
1499
|
txt = open(mpath).read()
|
|
1424
1500
|
first = txt.splitlines()[0] if txt else ''
|
|
1425
|
-
if 'reason=error-cooldown' in txt
|
|
1501
|
+
if ('reason=error-cooldown' in txt or 'reason=client-rate-limit' in txt) \
|
|
1502
|
+
and first.isdigit() and int(first) > now:
|
|
1426
1503
|
keep = True
|
|
1427
1504
|
except Exception:
|
|
1428
1505
|
pass
|
|
@@ -1433,6 +1510,18 @@ for acct in manifest.get('accounts', []):
|
|
|
1433
1510
|
detail = ' '.join(f"{b['name']}={b['percent']}%" for b in buckets)
|
|
1434
1511
|
print(f'{aid}: ok {detail}')
|
|
1435
1512
|
PYEOF
|
|
1513
|
+
local refresh_rc=$?
|
|
1514
|
+
# Release the lock BEFORE the report: a --json caller must not hold the refresh
|
|
1515
|
+
# lock while a consumer reads its output.
|
|
1516
|
+
limits_lock_release
|
|
1517
|
+
trap - EXIT
|
|
1518
|
+
# The refresher fails open per account; a NON-zero status means the pass itself
|
|
1519
|
+
# broke (unreadable manifest, dead python). Report the state anyway — stale data
|
|
1520
|
+
# beats silence — but hand the caller the failure, exactly as before --json existed.
|
|
1521
|
+
if [ "$json" = "1" ]; then
|
|
1522
|
+
emit_report_json limits || return $?
|
|
1523
|
+
fi
|
|
1524
|
+
return "$refresh_rc"
|
|
1436
1525
|
}
|
|
1437
1526
|
|
|
1438
1527
|
cmd_verify() {
|
|
@@ -1664,17 +1753,45 @@ sync_is_replica() {
|
|
|
1664
1753
|
|
|
1665
1754
|
cmd_sync() {
|
|
1666
1755
|
require_manifest
|
|
1756
|
+
local allow_empty=0 no_server=0
|
|
1757
|
+
while [ $# -gt 0 ]; do
|
|
1758
|
+
case "$1" in
|
|
1759
|
+
--allow-empty) allow_empty=1; shift ;;
|
|
1760
|
+
--no-server) no_server=1; shift ;; # this run pushes nowhere, whatever the manifest says
|
|
1761
|
+
*) die "unknown option: $1 (usage: codex-accounts sync [--no-server] [--allow-empty])" ;;
|
|
1762
|
+
esac
|
|
1763
|
+
done
|
|
1764
|
+
local server sroot srepo
|
|
1765
|
+
server="$(sync_target)"
|
|
1766
|
+
sroot="$(sync_target_root)"
|
|
1767
|
+
srepo="$(sync_target_repo)"
|
|
1768
|
+
# LOCAL-ONLY: no ssh target at all, because a panel/runner daemon distributes this
|
|
1769
|
+
# pool. Validate + fix up locally and stop — the same verb keeps working, it simply
|
|
1770
|
+
# has nowhere to push. (Not Mac-gated: a local pool is legitimate on any host.)
|
|
1771
|
+
# This mode NARROWS the replica rule, it never widens it: a replica pushes nothing,
|
|
1772
|
+
# and a local-only pool pushes nothing whether or not it is a replica. The marker is
|
|
1773
|
+
# still honored and still reported, so a replica can never start pushing by having
|
|
1774
|
+
# its sync target changed.
|
|
1775
|
+
if [ "$no_server" = "1" ] || sync_target_is_local "$server"; then
|
|
1776
|
+
rotate_log sync.log
|
|
1777
|
+
manifest_well_formed || { log_to sync.log "FAIL: manifest malformed (local sync)"; \
|
|
1778
|
+
die "manifest is not valid JSON or has no well-formed accounts — fix $MANIFEST"; }
|
|
1779
|
+
local role="source"
|
|
1780
|
+
sync_is_replica && role="replica"
|
|
1781
|
+
log_to sync.log "sync (local-only, role=$role): no server target${no_server:+ (--no-server)}"
|
|
1782
|
+
local_pool_fixup
|
|
1783
|
+
if [ "$role" = "replica" ]; then
|
|
1784
|
+
echo "sync ok (local-only, and this pool is a sync replica — nothing pushed either way)"
|
|
1785
|
+
else
|
|
1786
|
+
echo "sync ok (local-only: pool at $ACC_ROOT validated and re-seeded; nothing pushed)"
|
|
1787
|
+
fi
|
|
1788
|
+
return 0
|
|
1789
|
+
fi
|
|
1667
1790
|
[ "$(machine_kind)" = "mac" ] || die "sync runs on the Mac (source of truth), not the server"
|
|
1668
1791
|
if sync_is_replica; then
|
|
1669
1792
|
echo "this pool is a sync replica — the source machine pushes here; nothing sent"
|
|
1670
1793
|
return 0
|
|
1671
1794
|
fi
|
|
1672
|
-
local allow_empty=0
|
|
1673
|
-
[ "${1:-}" = "--allow-empty" ] && allow_empty=1
|
|
1674
|
-
local server sroot srepo
|
|
1675
|
-
server="$(manifest_get server "$DEFAULT_SERVER")"
|
|
1676
|
-
sroot="$(manifest_get server_root "$DEFAULT_SERVER_ROOT")"
|
|
1677
|
-
srepo="$(manifest_get server_repo "$DEFAULT_SERVER_REPO")"
|
|
1678
1795
|
# These land inside remote shell commands — anything but a plain target/path is a
|
|
1679
1796
|
# command-injection vector from a corrupted or hand-edited manifest. EVERY target
|
|
1680
1797
|
# (primary and peers) is validated before anything is pushed anywhere.
|
|
@@ -1706,17 +1823,8 @@ EOF
|
|
|
1706
1823
|
# never be pushed (it would blank the target pools), and must never make the removal
|
|
1707
1824
|
# propagation wipe a target's account dirs. Emptying the pool on purpose is possible
|
|
1708
1825
|
# via `sync --allow-empty`, so this can never happen by accident.
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
doc = json.load(open(sys.argv[1]))
|
|
1712
|
-
accounts = doc.get('accounts')
|
|
1713
|
-
if not isinstance(accounts, list):
|
|
1714
|
-
sys.exit(1)
|
|
1715
|
-
for a in accounts:
|
|
1716
|
-
if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
|
|
1717
|
-
or not str(a.get('email', '')).strip():
|
|
1718
|
-
sys.exit(1)
|
|
1719
|
-
PYEOF
|
|
1826
|
+
manifest_well_formed \
|
|
1827
|
+
|| fail "manifest is not valid JSON or has no well-formed accounts — refusing to sync"
|
|
1720
1828
|
if [ -z "$(account_ids)" ] && [ "$allow_empty" != "1" ]; then
|
|
1721
1829
|
fail "manifest has zero accounts — refusing to blank the target pools (use 'sync --allow-empty' if that is really intended)"
|
|
1722
1830
|
fi
|
|
@@ -1743,12 +1851,7 @@ EOF
|
|
|
1743
1851
|
|
|
1744
1852
|
cmd_post_sync() {
|
|
1745
1853
|
require_manifest
|
|
1746
|
-
|
|
1747
|
-
for id in $(account_ids); do
|
|
1748
|
-
d="$ACC_ROOT/$id"
|
|
1749
|
-
seed_account_dir "$d"
|
|
1750
|
-
[ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
|
|
1751
|
-
done
|
|
1854
|
+
local_pool_fixup
|
|
1752
1855
|
log_to sync.log "post-sync: seeded $(account_ids | wc -l | tr -d ' ') account dirs"
|
|
1753
1856
|
( cmd_limits --quiet ) || true # subshell: release the limits lock before verify
|
|
1754
1857
|
cmd_verify --quick
|
|
@@ -1837,6 +1940,8 @@ case "${1:-help}" in
|
|
|
1837
1940
|
status) shift; cmd_status "$@" ;;
|
|
1838
1941
|
add) shift; cmd_add "$@" ;;
|
|
1839
1942
|
import) shift; cmd_import "$@" ;;
|
|
1943
|
+
export-credential|export-cred) shift; cmd_export_credential "$@" ;;
|
|
1944
|
+
import-credential|import-cred) shift; cmd_import_credential "$@" ;;
|
|
1840
1945
|
adopt) shift; cmd_adopt "$@" ;;
|
|
1841
1946
|
dedupe) shift; cmd_dedupe "$@" ;;
|
|
1842
1947
|
remove) shift; cmd_remove "$@" ;;
|