aweshare 0.7.3 → 0.7.5

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 (52) hide show
  1. package/README.ai.md +1 -1
  2. package/README.md +43 -13
  3. package/apps/agent/dist/cli.js +17 -10
  4. package/apps/agent/dist/cli.js.map +1 -1
  5. package/apps/agent/dist/hub.d.ts +2 -0
  6. package/apps/agent/dist/hub.js +2 -0
  7. package/apps/agent/dist/hub.js.map +1 -1
  8. package/apps/agent/dist/table.d.ts +2 -2
  9. package/apps/agent/dist/table.js +48 -14
  10. package/apps/agent/dist/table.js.map +1 -1
  11. package/apps/hub/dist/admin.js +26 -18
  12. package/apps/hub/dist/admin.js.map +1 -1
  13. package/apps/hub/dist/auth.js +0 -12
  14. package/apps/hub/dist/auth.js.map +1 -1
  15. package/apps/hub/dist/backups.d.ts +43 -0
  16. package/apps/hub/dist/backups.js +122 -0
  17. package/apps/hub/dist/backups.js.map +1 -0
  18. package/apps/hub/dist/cli.js +371 -131
  19. package/apps/hub/dist/cli.js.map +1 -1
  20. package/apps/hub/dist/config.d.ts +2 -0
  21. package/apps/hub/dist/config.js +1 -0
  22. package/apps/hub/dist/config.js.map +1 -1
  23. package/apps/hub/dist/consumer.d.ts +3 -0
  24. package/apps/hub/dist/consumer.js +169 -54
  25. package/apps/hub/dist/consumer.js.map +1 -1
  26. package/apps/hub/dist/db.d.ts +9 -10
  27. package/apps/hub/dist/db.js +20 -22
  28. package/apps/hub/dist/db.js.map +1 -1
  29. package/apps/hub/dist/http.d.ts +7 -1
  30. package/apps/hub/dist/http.js +6 -1
  31. package/apps/hub/dist/http.js.map +1 -1
  32. package/apps/hub/dist/invites.js +9 -2
  33. package/apps/hub/dist/invites.js.map +1 -1
  34. package/apps/hub/dist/server.d.ts +7 -3
  35. package/apps/hub/dist/server.js +23 -8
  36. package/apps/hub/dist/server.js.map +1 -1
  37. package/apps/hub/dist/table.d.ts +3 -4
  38. package/apps/hub/dist/table.js +52 -20
  39. package/apps/hub/dist/table.js.map +1 -1
  40. package/apps/hub/dist/tunnel.d.ts +0 -2
  41. package/apps/hub/dist/tunnel.js +2 -16
  42. package/apps/hub/dist/tunnel.js.map +1 -1
  43. package/package.json +3 -2
  44. package/packages/producer-core/dist/catalog.js +9 -1
  45. package/packages/producer-core/dist/catalog.js.map +1 -1
  46. package/packages/producer-core/dist/job.js +1 -1
  47. package/packages/producer-core/dist/job.js.map +1 -1
  48. package/packages/protocol/dist/models.js +6 -1
  49. package/packages/protocol/dist/models.js.map +1 -1
  50. package/packages/protocol/dist/schedule.d.ts +8 -3
  51. package/packages/protocol/dist/schedule.js +124 -11
  52. package/packages/protocol/dist/schedule.js.map +1 -1
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { fmtDate, fmtTime, fmtTokens } from '../../../packages/protocol/dist/index.js';
3
+ import { fmtDate, fmtTime, fmtTokens, isValidAlias } from '../../../packages/protocol/dist/index.js';
4
+ import { parseBackups, readBackupsFile, serializeBackups, writeBackupsFile } from './backups.js';
4
5
  import { ensureDataDir, HUB_CONFIG_TEMPLATE, initHubProduceFiles, loadHubConfig, readAdminToken, resolvePaths, } from './config.js';
5
6
  import { createHubServer, installConfigReloader } from './server.js';
6
7
  import { refreshTargets, renderConsumers, renderHubStatus, renderInvites, renderOfferings, renderProducers, renderUsage, renderUsageSummary, } from './table.js';
@@ -22,7 +23,8 @@ Commands:
22
23
  admin Govern: invite mint|revoke|restore, offering revoke|restore.
23
24
  list Read hub state: invites (default), producers, consumers, offerings, usage.
24
25
  status Live dashboard: capacity, 5m health, admission pressure, defaults.
25
- limits Show, set or clear one consumer's limit overrides.`;
26
+ limits Show, set or clear one or more consumers' limit overrides.
27
+ backups Per-alias failover queues: show, add, remove or clear.`;
26
28
  const COMMAND_HELP = {
27
29
  init: `Usage: aweshare hub init
28
30
 
@@ -61,19 +63,20 @@ Options:
61
63
  initializes the Hub data directory, database, pepper and admin token.
62
64
 
63
65
  ` +
64
- '`refresh ALIAS`' +
66
+ '`refresh ALIAS...`' +
65
67
  ` works on a running hub, no restart: re-anchor the daily token
66
- budget of one hub/... model at this moment (past usage today stops
67
- counting), or raise it for the rest of today. Producer-registered models
68
- (ns/...) are not the hub's to refresh — that producer runs
69
- 'aweshare producer refresh' itself. A permanent raise belongs in
70
- config.produce.toml (dailyTokens), which hot-reloads. --all
68
+ budget of one or several hub/... models at this moment (past usage today
69
+ stops counting), or raise it for the rest of today — several aliases per
70
+ call, space- or comma-separated, one failure does not stop the rest.
71
+ Producer-registered models (ns/...) are not the hub's to refresh — that
72
+ producer runs 'aweshare producer refresh' itself. A permanent raise
73
+ belongs in config.produce.toml (dailyTokens), which hot-reloads. --all
71
74
  bare-refreshes every hub/ model with a daily cap in one run; unlimited
72
- ones (dailyTokens = 0) are reported and skipped, and one failure does
73
- not stop the rest.
75
+ ones (dailyTokens = 0) are reported and skipped.
74
76
 
75
77
  Examples:
76
78
  aweshare hub produce refresh glm-5.3 # today restarts now
79
+ aweshare hub produce refresh glm-5.3 glm-4.6 # several at once
77
80
  aweshare hub produce refresh --all # every hub/ model restarts today
78
81
  aweshare hub produce refresh glm-5.3 --add 2000000 # today's cap +2M
79
82
  aweshare hub produce refresh glm-5.3 --clear # drop both markers
@@ -92,15 +95,19 @@ Options:
92
95
 
93
96
  Commands:
94
97
  invite mint [...] Mint one-time invite codes.
95
- invite revoke N Kill invite N — the sledgehammer: a pending
98
+ invite revoke N... Kill invites — the sledgehammer: a pending
96
99
  code stops pairing, a redeemed one suspends
97
100
  the whole producer or consumer it minted.
98
- invite restore N Undo an invite revoke.
99
- invite extend N [...] Set an invite and its minted identity's expiry.
100
- offering revoke ALIAS The scalpel: 503 OFFERING_BLOCKED for new
101
+ invite restore N... Undo an invite revoke.
102
+ invite extend N [...] Set a pending invite's expiry; a redeemed
103
+ invite's token never expires.
104
+ offering revoke ALIAS... The scalpel: 503 OFFERING_BLOCKED for new
101
105
  requests to one alias while the producer's
102
106
  other offerings keep serving.
103
- offering restore ALIAS Undo an offering revoke.
107
+ offering restore ALIAS... Undo an offering revoke.
108
+
109
+ Every N/ALIAS argument accepts several targets, space- or comma-separated;
110
+ one failure does not stop the rest.
104
111
 
105
112
  Run 'aweshare hub admin invite -h' or 'aweshare hub admin offering -h'
106
113
  for the full semantics of each group.
@@ -112,20 +119,24 @@ Options:
112
119
  The invite lifecycle. One code, one identity — and the invite is the
113
120
  operator's handle from mint to suspension: revoking a redeemed code
114
121
  suspends the producer or consumer it minted (a producer's tunnel closes,
115
- a consumer's key stops working); restoring brings both back.
122
+ a consumer's key stops working); restoring brings both back. The code's
123
+ expiry bounds only the redemption window — the token a redeem mints never
124
+ expires; revoke/restore is its only lifecycle handle.
116
125
 
117
126
  Commands:
118
- mint Mint one-time invite codes. Printed once; recover
119
- with: aweshare hub list invites --reveal. Listing lives under
120
- 'list invites' on purpose — minting sends real codes, reading
121
- the ledger never should.
122
- revoke N Kill invite N: a pending code stops pairing, a redeemed one
123
- suspends its identity (find ids with: aweshare hub list invites).
124
- restore N Undo a revoke: pending pairs again, redeemed revives its
125
- identity. Restoring a redeemed producer needs a free slot
126
- (403 HUB_FULL at AWESHARE_MAX_PRODUCERS).
127
- extend N Set expiry from now for a pending code or its redeemed identity.
128
- It does not restore a manually revoked identity.
127
+ mint Mint one-time invite codes. Printed once; recover
128
+ with: aweshare hub list invites --reveal. Listing lives under
129
+ 'list invites' on purpose — minting sends real codes, reading
130
+ the ledger never should.
131
+ revoke N... Kill invites: a pending code stops pairing, a redeemed one
132
+ suspends its identity (find ids with: aweshare hub list
133
+ invites).
134
+ restore N... Undo a revoke: pending pairs again, redeemed revives its
135
+ identity. Restoring a redeemed producer needs a free slot
136
+ (403 HUB_FULL at AWESHARE_MAX_PRODUCERS).
137
+ extend N... Reset a pending code's expiry from now ('none' = no
138
+ deadline). A redeemed invite has nothing to extend — its
139
+ token never expires — and says so.
129
140
 
130
141
  Options:
131
142
  --role ROLE producer (default) or consumer — a consumer code redeems into
@@ -133,15 +144,14 @@ Options:
133
144
  --name NAME Bind the codes to one name (required for --role consumer;
134
145
  producers may leave it for redeem-time name + email).
135
146
  --count N Mint N codes, 1..100 (default 1; producer codes only).
136
- --expires-in D How long codes stay usable: 30m, 12h or 7d (default 7d) —
137
- since identity expiry landed, this also bounds the minted
138
- token's lifetime; 'none' mints a code (and identity) that
139
- never expires. With extend, this is required and resets
140
- expiry from now.
147
+ --expires-in D How long the code stays redeemable: 30m, 12h or 7d
148
+ (default 7d); 'none' = no deadline. It bounds the code
149
+ only — the minted token never expires. With extend, this
150
+ is required.
141
151
  -h, --help Show this message and exit.`,
142
- 'admin offering': `Usage: aweshare hub admin offering revoke|restore ALIAS
152
+ 'admin offering': `Usage: aweshare hub admin offering revoke|restore ALIAS...
143
153
 
144
- Revoke one offering alias (every protocol row of it) or bring it back —
154
+ Revoke offering aliases (every protocol row of them) or bring them back —
145
155
  the scalpel between doing nothing and revoking the whole producer
146
156
  ('admin invite revoke'). A revoke 503s new requests with
147
157
  OFFERING_BLOCKED while the producer's other offerings keep serving;
@@ -150,6 +160,9 @@ Options:
150
160
  autoBlockModelMismatch is enabled) clear once the producer re-declares
151
161
  a different upstreamModel.
152
162
 
163
+ Several aliases per call, space- or comma-separated; one failure does
164
+ not stop the rest.
165
+
153
166
  Options:
154
167
  -h, --help Show this message and exit.`,
155
168
  list: `Usage: aweshare hub list [invites|producers|consumers|offerings|usage] [OPTIONS]
@@ -167,10 +180,11 @@ Options:
167
180
  other side), with observed model, caps, live occupancy and
168
181
  today's remaining tokens.
169
182
  usage Who used how much: aggregated on the hub's SQLite, one row per
170
- consumer × model (default), most recently used first. --details
171
- switches to the request log instead: newest first, one row per
172
- request, zero content stored, each row naming the consumer
173
- that made it.
183
+ consumer × model (default), most recently used first, closing
184
+ with a TOTAL row. --details switches to the request log
185
+ instead: newest first, one row per request, zero content
186
+ stored, each row naming the consumer that made it. Both take
187
+ the same --since window (default all time).
174
188
 
175
189
  Options:
176
190
  --reveal Invites only: show the full invite codes (asi_...).
@@ -181,7 +195,8 @@ Options:
181
195
  --producer NAME Usage only: only rows for this producer.
182
196
  --alias ALIAS Usage only: only rows for this alias (ns/model).
183
197
  --group-by KEY Usage only: consumer × model (default), consumer or alias.
184
- --since DUR Usage only: window: 30m, 12h, 7d (default), 30d… or all.
198
+ --since DUR Usage only: window (summary and --details): all
199
+ (default), or 30m, 12h, 7d, 30d…
185
200
  --sort KEY Usage summary only: time (default, newest first), consumer,
186
201
  producer or model (alphabetical, newest first within), or
187
202
  tokens / requests (busiest first; tokens keeps a person's
@@ -189,11 +204,13 @@ Options:
189
204
  --limit N Usage --details only: rows to show, 1..1000 (default 100).
190
205
  --json Print the raw API rows instead of a table.
191
206
  -h, --help Show this message and exit.`,
192
- limits: `Usage: aweshare hub limits NAME [OPTIONS]
207
+ limits: `Usage: aweshare hub limits NAME... [OPTIONS]
193
208
 
194
- Show, set or clear one consumer's limit overrides. Unset keys keep the
195
- hub-wide defaults (AWESHARE_CONSUMER_* env vars); setting merges into
196
- what is already stored.
209
+ Show, set or clear consumers' limit overrides — one consumer or several
210
+ (space- or comma-separated). Unset keys keep the hub-wide defaults
211
+ (AWESHARE_CONSUMER_* env vars); setting merges into what is already
212
+ stored. One failure does not stop the rest; with --json a single name
213
+ keeps the plain object, several become an array.
197
214
 
198
215
  Options:
199
216
  --rps N Requests per second (may be fractional).
@@ -205,6 +222,34 @@ Options:
205
222
  --clear Drop every override (back to hub-wide defaults).
206
223
  --json Print the raw API response instead of text.
207
224
  -h, --help Show this message and exit.`,
225
+ backups: `Usage: aweshare hub backups [ALIAS] [OPTIONS]
226
+
227
+ Per-alias failover queues in the data dir's config.backups.toml: when an
228
+ alias cannot serve (producer offline, daily budget spent, degraded
229
+ backend, blocked, share window closed, busy), the hub retries the queue
230
+ in order before answering with the alias's own error. Entries resolve
231
+ to nothing are skipped; a queue is exactly what was written — no
232
+ implicit hops, no expansion. Edits hot-reload within ~2s. The file is
233
+ this machine's, so the command edits it locally: on a remote hub run it
234
+ there (ssh / docker exec), like every other config.toml change.
235
+
236
+ Bare ALIAS shows its queue; no ALIAS lists every queue.
237
+
238
+ Examples:
239
+ aweshare hub backups # every queue
240
+ aweshare hub backups peng/qwen3-coder # one queue
241
+ aweshare hub backups peng/qwen3-coder --add alice/qwen3-coder
242
+ aweshare hub backups peng/qwen3-coder --add hub/glm-4.6 --first
243
+ aweshare hub backups peng/qwen3-coder --remove hub/glm-4.6
244
+ aweshare hub backups peng/qwen3-coder --clear
245
+
246
+ Options:
247
+ --add ALIASES Append (comma-separated aliases accepted); --first prepends instead.
248
+ --first With --add: put the new backups at the front of the queue.
249
+ --remove A Remove one backup alias from ALIAS's queue.
250
+ --clear Drop ALIAS's queue entirely (the key disappears).
251
+ --json Print the queue(s) as JSON.
252
+ -h, --help Show this message and exit.`,
208
253
  status: `Usage: aweshare hub status
209
254
 
210
255
  The hub's live dashboard: capacity, the last-5m health, admission
@@ -393,6 +438,52 @@ function fmtLimits(name, overrides) {
393
438
  ? `${name}: ${set.join(', ')}`
394
439
  : `${name}: no overrides — hub-wide defaults apply (AWESHARE_CONSUMER_* env vars)`;
395
440
  }
441
+ /** Split a comma-separated alias list ('a/b,c/d') — aliases cannot contain
442
+ * commas, so the split is unambiguous. */
443
+ function splitAliases(raw) {
444
+ return raw
445
+ .split(',')
446
+ .map((entry) => entry.trim())
447
+ .filter((entry) => entry.length > 0);
448
+ }
449
+ /** Several targets on one command line: spaces or commas both work
450
+ * ('18 19 22' or '18,19,22' — ids, names and aliases contain no commas). */
451
+ function parseTargets(raw) {
452
+ return raw
453
+ .flatMap((arg) => arg.split(','))
454
+ .map((t) => t.trim())
455
+ .filter((t) => t.length > 0);
456
+ }
457
+ /** --json across batch commands: one target keeps the plain object, several
458
+ * become an array. */
459
+ function printJsonBatch(entries) {
460
+ console.log(JSON.stringify(entries.length === 1 ? entries[0] : entries, null, 2));
461
+ }
462
+ /** One API call per target, one line each — a failure does not stop the rest
463
+ * (same contract as `produce refresh --all`), and a nonzero exit code closes
464
+ * the run so scripts notice. Failed targets become {target, error} entries,
465
+ * so a --json run stays parseable. */
466
+ async function runBatch(targets, verb, json, each) {
467
+ const entries = [];
468
+ let failed = 0;
469
+ for (const target of targets) {
470
+ try {
471
+ entries.push(await each(target));
472
+ }
473
+ catch (err) {
474
+ failed++;
475
+ const error = errorMessage(err);
476
+ entries.push({ target, error });
477
+ if (!json)
478
+ console.log(`✗ ${target} — ${error}`);
479
+ }
480
+ }
481
+ if (failed > 0) {
482
+ console.error(`error: ${failed} of ${targets.length} ${verb} failed`);
483
+ process.exitCode = 1;
484
+ }
485
+ return entries;
486
+ }
396
487
  /** The five readable tables; bare `list` defaults to the ledger (a read,
397
488
  * and the one operators reach for first). */
398
489
  const LIST_TARGETS = ['invites', 'producers', 'consumers', 'offerings', 'usage'];
@@ -444,6 +535,10 @@ async function main() {
444
535
  writeFileSync(paths.configFile, HUB_CONFIG_TEMPLATE, { mode: 0o600 });
445
536
  console.log(`config file: ${paths.configFile} (every key commented out — edit to override)`);
446
537
  }
538
+ if (!existsSync(paths.backupsFile)) {
539
+ writeFileSync(paths.backupsFile, serializeBackups(new Map()), { mode: 0o600 });
540
+ console.log(`backups file: ${paths.backupsFile} (empty — per-alias failover queues, see: aweshare hub backups -h)`);
541
+ }
447
542
  const hub = createHubServer({ paths }); // creates the db schema
448
543
  await hub.close();
449
544
  console.log(`data dir: ${paths.dataDir}`);
@@ -494,46 +589,50 @@ async function main() {
494
589
  return;
495
590
  }
496
591
  checkFlags('produce', flags, ['add', 'clear', 'all', 'json']);
497
- const raw = positional[1];
592
+ const raws = parseTargets(positional.slice(1));
498
593
  const all = flags.get('all') === 'true';
499
- if (positional[2])
500
- fail(`unexpected argument '${positional[2]}' (see: aweshare hub produce -h)`);
501
594
  if (all) {
502
- if (raw)
503
- fail(`--all refreshes every hub/ model — drop the alias '${raw}'`);
595
+ if (raws.length > 0)
596
+ fail(`--all refreshes every hub/ model — drop the alias '${raws[0]}'`);
504
597
  if (flags.get('add') !== undefined || flags.get('clear') === 'true')
505
- fail('--all is a bare refresh of every hub/ model — --add/--clear stay single-alias');
598
+ fail('--all is a bare refresh of every hub/ model — --add/--clear are per-alias');
506
599
  const api = adminApi();
507
600
  await runProduceRefreshAll(api, flags.get('json') === 'true');
508
601
  return;
509
602
  }
510
- if (!raw)
603
+ if (raws.length === 0)
511
604
  fail('produce refresh: alias is required (hub/model — the hub/ prefix is optional), or --all for every hub/ model');
512
605
  // hub-hosted offerings live in the reserved hub/ namespace — accept
513
606
  // the bare name too; anything else the hub rejects as not its own
514
- const alias = raw.includes('/') ? raw : `hub/${raw}`;
515
- const params = new URLSearchParams({ alias });
607
+ const aliases = raws.map((raw) => (raw.includes('/') ? raw : `hub/${raw}`));
516
608
  const add = flags.get('add');
609
+ let addN;
517
610
  if (add !== undefined) {
518
- const n = Number(add);
519
- if (!Number.isSafeInteger(n) || n <= 0)
611
+ addN = Number(add);
612
+ if (!Number.isSafeInteger(addN) || addN <= 0)
520
613
  fail(`--add must be a positive integer, got '${add}'`);
521
- params.set('add', String(n));
522
- }
523
- if (flags.get('clear') === 'true') {
524
- if (add !== undefined)
525
- fail('--clear drops both markers — drop --add too');
526
- params.set('clear', 'true');
527
614
  }
615
+ const clear = flags.get('clear') === 'true';
616
+ if (clear && add !== undefined)
617
+ fail('--clear drops both markers — drop --add too');
618
+ const json = flags.get('json') === 'true';
528
619
  const api = adminApi();
529
- const out = (await api('POST', `/admin/v1/offerings/refresh?${params}`));
530
- if (flags.get('json') === 'true') {
531
- console.log(JSON.stringify(out, null, 2));
532
- return;
533
- }
534
- printRefreshResult(out);
535
- if (out.reset)
536
- console.log(` undo: aweshare hub produce refresh ${out.alias} --clear`);
620
+ const entries = await runBatch(aliases, 'refresh(es)', json, async (alias) => {
621
+ const params = new URLSearchParams({ alias });
622
+ if (addN !== undefined)
623
+ params.set('add', String(addN));
624
+ if (clear)
625
+ params.set('clear', 'true');
626
+ const out = (await api('POST', `/admin/v1/offerings/refresh?${params}`));
627
+ if (!json) {
628
+ printRefreshResult(out);
629
+ if (out.reset)
630
+ console.log(` undo: aweshare hub produce refresh ${out.alias} --clear`);
631
+ }
632
+ return out;
633
+ });
634
+ if (json)
635
+ printJsonBatch(entries);
537
636
  return;
538
637
  }
539
638
  case 'admin': {
@@ -578,8 +677,9 @@ async function main() {
578
677
  if (role === 'consumer' && count && count !== '1')
579
678
  fail('consumer invites are one per name — drop --count and mint one per consumer');
580
679
  // absent flag still mints a 7d code — an unexpiring secret should be a
581
- // deliberate choice (`none` here; API callers may omit expiresAt). Since
582
- // v12 the expiry also bounds the minted identity's lifetime.
680
+ // deliberate choice (`none` here; API callers may omit expiresAt). The
681
+ // expiry bounds the redemption window only; the minted identity
682
+ // never expires.
583
683
  const expiresIn = flags.get('expires-in') ?? '7d';
584
684
  if (expiresIn !== 'none') {
585
685
  body.expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString();
@@ -608,24 +708,27 @@ async function main() {
608
708
  return;
609
709
  }
610
710
  checkFlags('admin invite', flags, action === 'extend' ? ['expires-in'] : []);
611
- const id = positional[2];
612
- if (!id)
711
+ const ids = parseTargets(positional.slice(2));
712
+ if (ids.length === 0)
613
713
  fail(`invite ${action}: id is required (find ids with: aweshare hub list invites)`);
614
- if (positional[3])
615
- fail(`unexpected argument '${positional[3]}' (see: aweshare hub admin invite -h)`);
616
714
  if (action === 'revoke') {
617
- const out = (await api('DELETE', `/admin/v1/invites/${id}`));
618
- if (!out.revoked && !out.suspended) {
619
- console.log(`invite ${id} was already revoked — nothing to do`);
620
- return;
621
- }
622
- if (out.suspended) {
623
- console.log(`✓ invite ${id} revoked — ${out.suspended.role} '${out.suspended.name}' suspended, its token no longer works`);
624
- }
625
- else {
626
- console.log(`✓ invite ${id} revoked — the code no longer pairs`);
627
- }
628
- console.log(` undo: aweshare hub admin invite restore ${id}`);
715
+ const revokedIds = [];
716
+ await runBatch(ids, 'revoke(s)', false, async (id) => {
717
+ const out = (await api('DELETE', `/admin/v1/invites/${id}`));
718
+ if (!out.revoked && !out.suspended) {
719
+ console.log(`- invite ${id} was already revoked — nothing to do`);
720
+ return;
721
+ }
722
+ revokedIds.push(id);
723
+ if (out.suspended) {
724
+ console.log(`✓ invite ${id} revoked — ${out.suspended.role} '${out.suspended.name}' suspended, its token no longer works`);
725
+ }
726
+ else {
727
+ console.log(`✓ invite ${id} revoked — the code no longer pairs`);
728
+ }
729
+ });
730
+ if (revokedIds.length > 0)
731
+ console.log(` undo: aweshare hub admin invite restore ${revokedIds.join(' ')}`);
629
732
  return;
630
733
  }
631
734
  if (action === 'extend') {
@@ -635,26 +738,33 @@ async function main() {
635
738
  const expiresAt = expiresIn === 'none'
636
739
  ? null
637
740
  : new Date(Date.now() + parseDuration(expiresIn)).toISOString();
638
- const out = (await api('POST', `/admin/v1/invites/${id}/extend`, { expiresAt }));
639
- console.log(out.expiresAt === null
640
- ? `✓ invite ${id} never expires`
641
- : `✓ invite ${id} expires ${fmtTime(out.expiresAt)}`);
642
- return;
643
- }
644
- const out = (await api('POST', `/admin/v1/invites/${id}/restore`));
645
- if (!out.restored) {
646
- console.log(`invite ${id} was not revoked — nothing to restore`);
741
+ await runBatch(ids, 'extend(s)', false, async (id) => {
742
+ const out = (await api('POST', `/admin/v1/invites/${id}/extend`, { expiresAt }));
743
+ if (out.redeemed)
744
+ console.log(`✓ invite ${id} is redeemed — its token never expires, nothing to extend`);
745
+ else
746
+ console.log(out.expiresAt === null
747
+ ? `✓ invite ${id} never expires`
748
+ : `✓ invite ${id} expires ${fmtTime(out.expiresAt)}`);
749
+ });
647
750
  return;
648
751
  }
649
- if (out.restoredProducer) {
650
- console.log(`✓ invite ${id} restored — producer '${out.restoredProducer.name}' is back`);
651
- }
652
- else if (out.restoredConsumer) {
653
- console.log(`✓ invite ${id} restored — consumer '${out.restoredConsumer.name}' is back`);
654
- }
655
- else {
656
- console.log(`✓ invite ${id} restored — the code pairs again (its original expiry still applies)`);
657
- }
752
+ await runBatch(ids, 'restore(s)', false, async (id) => {
753
+ const out = (await api('POST', `/admin/v1/invites/${id}/restore`));
754
+ if (!out.restored) {
755
+ console.log(`- invite ${id} was not revoked — nothing to restore`);
756
+ return;
757
+ }
758
+ if (out.restoredProducer) {
759
+ console.log(`✓ invite ${id} restored — producer '${out.restoredProducer.name}' is back`);
760
+ }
761
+ else if (out.restoredConsumer) {
762
+ console.log(`✓ invite ${id} restored — consumer '${out.restoredConsumer.name}' is back`);
763
+ }
764
+ else {
765
+ console.log(`✓ invite ${id} restored — the code pairs again (its original expiry still applies)`);
766
+ }
767
+ });
658
768
  return;
659
769
  }
660
770
  // offering: the per-alias scalpel ('block' renamed to 'revoke' — the
@@ -666,19 +776,24 @@ async function main() {
666
776
  fail(`unknown offering action '${action}' — expected revoke or restore`);
667
777
  }
668
778
  checkFlags('admin offering', flags, []);
669
- const alias = positional[2];
670
- if (!alias)
779
+ const aliases = parseTargets(positional.slice(2));
780
+ if (aliases.length === 0)
671
781
  fail(`offering ${action}: alias is required (ns/model)`);
672
- if (positional[3])
673
- fail(`unexpected argument '${positional[3]}' (see: aweshare hub admin offering -h)`);
674
782
  if (action === 'revoke') {
675
- const out = (await api('POST', `/admin/v1/offerings/block?alias=${encodeURIComponent(alias)}`));
676
- console.log(`✓ ${alias} revoked (${out.offerings} protocol row(s)) — new requests get 503 OFFERING_BLOCKED`);
677
- console.log(` undo: aweshare hub admin offering restore ${alias}`);
783
+ const revokedAliases = [];
784
+ await runBatch(aliases, 'revoke(s)', false, async (alias) => {
785
+ const out = (await api('POST', `/admin/v1/offerings/block?alias=${encodeURIComponent(alias)}`));
786
+ revokedAliases.push(alias);
787
+ console.log(`✓ ${alias} revoked (${out.offerings} protocol row(s)) — new requests get 503 OFFERING_BLOCKED`);
788
+ });
789
+ if (revokedAliases.length > 0)
790
+ console.log(` undo: aweshare hub admin offering restore ${revokedAliases.join(' ')}`);
678
791
  return;
679
792
  }
680
- const out = (await api('POST', `/admin/v1/offerings/restore?alias=${encodeURIComponent(alias)}`));
681
- console.log(`✓ ${alias} restored (${out.offerings} protocol row(s)) — requests flow again`);
793
+ await runBatch(aliases, 'restore(s)', false, async (alias) => {
794
+ const out = (await api('POST', `/admin/v1/offerings/restore?alias=${encodeURIComponent(alias)}`));
795
+ console.log(`✓ ${alias} restored (${out.offerings} protocol row(s)) — requests flow again`);
796
+ });
682
797
  return;
683
798
  }
684
799
  case 'list': {
@@ -747,7 +862,7 @@ async function main() {
747
862
  const group = flags.get('group-by') ?? 'consumer-alias';
748
863
  if (group !== 'consumer-alias' && group !== 'consumer' && group !== 'alias')
749
864
  fail(`--group-by must be 'consumer', 'alias' or 'consumer-alias' (default), got '${group}'`);
750
- const since = flags.get('since') ?? '7d';
865
+ const since = flags.get('since') ?? 'all';
751
866
  if (since !== 'all')
752
867
  parseDuration(since); // validate early with a local error
753
868
  const sort = flags.get('sort') ?? 'time';
@@ -784,20 +899,26 @@ async function main() {
784
899
  fail(`--limit must be an integer between 1 and 1000, got '${limit}'`);
785
900
  params.set('limit', String(n));
786
901
  }
787
- // the details endpoint has no window/grouping — reject instead of
788
- // silently showing unfiltered rows (mirror of --limit on the summary)
789
- if (flags.get('since') !== undefined)
790
- fail('--since applies to the summary, not --details');
902
+ // the details log is a timeline — newest first, nothing to re-sort;
903
+ // --since works like on the summary (default all time)
791
904
  if (flags.get('group-by') !== undefined)
792
905
  fail('--group-by applies to the summary, not --details');
793
906
  if (flags.get('sort') !== undefined)
794
907
  fail('--sort applies to the summary, not --details (the log is newest first)');
908
+ const since = flags.get('since') ?? 'all';
909
+ if (since !== 'all')
910
+ parseDuration(since); // validate early with a local error
911
+ params.set('since', since);
795
912
  const qs = params.toString();
796
913
  const rows = (await api('GET', `/admin/v1/usage${qs ? `?${qs}` : ''}`));
797
914
  if (flags.get('json') === 'true') {
798
915
  console.log(JSON.stringify(rows, null, 2));
799
916
  return;
800
917
  }
918
+ const window = since === 'all'
919
+ ? 'all time'
920
+ : `since ${fmtDate(new Date(Date.now() - parseDuration(since)))} (${since})`;
921
+ console.log(`usage ${window} — details, newest first`);
801
922
  // current hubs join consumer names into the rows; the roster is only
802
923
  // fetched when an older remote hub answered without them
803
924
  const names = new Map();
@@ -816,13 +937,11 @@ async function main() {
816
937
  }
817
938
  case 'limits': {
818
939
  checkFlags('limits', flags, [...LIMIT_FLAGS.map(([flag]) => flag), 'clear', 'json']);
819
- const name = positional[0];
820
- if (!name)
940
+ const names = parseTargets(positional);
941
+ if (names.length === 0)
821
942
  fail('consumer name is required (see: aweshare hub limits -h)');
822
- if (positional[1])
823
- fail(`unexpected argument '${positional[1]}' (see: aweshare hub limits -h)`);
824
943
  const api = adminApi();
825
- const path = `/admin/v1/consumers/${encodeURIComponent(name)}/limits`;
944
+ const path = (name) => `/admin/v1/consumers/${encodeURIComponent(name)}/limits`;
826
945
  const json = flags.get('json') === 'true';
827
946
  const set = {};
828
947
  for (const [flag, key] of LIMIT_FLAGS) {
@@ -837,20 +956,141 @@ async function main() {
837
956
  if (flags.get('clear') === 'true') {
838
957
  if (Object.keys(set).length > 0)
839
958
  fail('--clear drops every override — drop the value flags too');
840
- const out = (await api('DELETE', path));
841
- console.log(json
842
- ? JSON.stringify(out, null, 2)
843
- : `✓ ${name}: overrides cleared — hub-wide defaults apply`);
959
+ const entries = await runBatch(names, 'clear(s)', json, async (name) => {
960
+ const out = (await api('DELETE', path(name)));
961
+ if (!json)
962
+ console.log(`✓ ${out.name}: overrides cleared — hub-wide defaults apply`);
963
+ return out;
964
+ });
965
+ if (json)
966
+ printJsonBatch(entries);
844
967
  return;
845
968
  }
846
969
  if (Object.keys(set).length > 0) {
847
970
  // integer/precision rules live server-side; its error lists all problems
848
- const out = (await api('PUT', path, set));
849
- console.log(json ? JSON.stringify(out, null, 2) : `✓ ${fmtLimits(out.name, out.overrides)}`);
971
+ const entries = await runBatch(names, 'update(s)', json, async (name) => {
972
+ const out = (await api('PUT', path(name), set));
973
+ if (!json)
974
+ console.log(`✓ ${fmtLimits(out.name, out.overrides)}`);
975
+ return out;
976
+ });
977
+ if (json)
978
+ printJsonBatch(entries);
850
979
  return;
851
980
  }
852
- const out = (await api('GET', path));
853
- console.log(json ? JSON.stringify(out, null, 2) : fmtLimits(out.name, out.overrides));
981
+ const entries = await runBatch(names, 'lookup(s)', json, async (name) => {
982
+ const out = (await api('GET', path(name)));
983
+ if (!json)
984
+ console.log(fmtLimits(out.name, out.overrides));
985
+ return out;
986
+ });
987
+ if (json)
988
+ printJsonBatch(entries);
989
+ return;
990
+ }
991
+ case 'backups': {
992
+ checkFlags('backups', flags, ['add', 'first', 'remove', 'clear', 'json']);
993
+ const alias = positional[0];
994
+ if (positional[1])
995
+ fail(`unexpected argument '${positional[1]}' (see: aweshare hub backups -h)`);
996
+ const json = flags.get('json') === 'true';
997
+ const add = flags.get('add');
998
+ const remove = flags.get('remove');
999
+ const clear = flags.get('clear') === 'true';
1000
+ const mutating = add !== undefined || remove !== undefined || clear;
1001
+ if (mutating && alias === undefined)
1002
+ fail('which queue to modify: pass ALIAS (see: aweshare hub backups -h)');
1003
+ if (clear && (add !== undefined || remove !== undefined))
1004
+ fail('--clear drops the whole queue — drop --add/--remove too');
1005
+ if (flags.get('first') === 'true' && add === undefined)
1006
+ fail('--first only makes sense with --add');
1007
+ let queues;
1008
+ try {
1009
+ queues = readBackupsFile(paths.backupsFile);
1010
+ }
1011
+ catch (err) {
1012
+ fail(err instanceof Error ? err.message : String(err));
1013
+ }
1014
+ if (!mutating) {
1015
+ const print = (key, queue) => console.log(queue && queue.length > 0
1016
+ ? `${key} → ${queue.join(', ')}`
1017
+ : `${key}: no backups configured`);
1018
+ if (!alias) {
1019
+ if (queues.size === 0) {
1020
+ console.log('no backup queues configured — create one:');
1021
+ console.log(' aweshare hub backups SOME/ALIAS --add OTHER/BACKUP');
1022
+ return;
1023
+ }
1024
+ if (json)
1025
+ console.log(JSON.stringify(Object.fromEntries(queues), null, 2));
1026
+ else
1027
+ for (const [key, queue] of queues)
1028
+ print(key, queue);
1029
+ return;
1030
+ }
1031
+ const queue = queues.get(alias);
1032
+ if (json)
1033
+ console.log(JSON.stringify({ [alias]: queue ?? [] }, null, 2));
1034
+ else
1035
+ print(alias, queue);
1036
+ return;
1037
+ }
1038
+ // unreachable in plain control flow (mutating implies ALIAS, checked
1039
+ // above) but needed so the compiler sees the string
1040
+ if (alias === undefined)
1041
+ fail('which queue to modify: pass ALIAS (see: aweshare hub backups -h)');
1042
+ if (!isValidAlias(alias))
1043
+ fail(`'${alias}' is not an alias (namespace/name, lowercase)`);
1044
+ // every mutation lands in memory first and the file is written once,
1045
+ // so a rejected flag leaves the file untouched
1046
+ if (clear) {
1047
+ if (!queues.delete(alias))
1048
+ fail(`${alias}: no queue configured (see: aweshare hub backups)`);
1049
+ }
1050
+ if (remove !== undefined) {
1051
+ for (const target of splitAliases(remove)) {
1052
+ const queue = queues.get(alias);
1053
+ if (!queue?.includes(target))
1054
+ fail(`${alias}: '${target}' is not in its queue`);
1055
+ const rest = queue.filter((entry) => entry !== target);
1056
+ if (rest.length > 0)
1057
+ queues.set(alias, rest);
1058
+ else
1059
+ queues.delete(alias);
1060
+ }
1061
+ }
1062
+ if (add !== undefined) {
1063
+ const additions = splitAliases(add);
1064
+ if (new Set(additions).size !== additions.length)
1065
+ fail(`--add repeats an alias: '${add}'`);
1066
+ for (const entry of additions) {
1067
+ if (!isValidAlias(entry))
1068
+ fail(`'${entry}' is not an alias (namespace/name, lowercase)`);
1069
+ if (entry === alias)
1070
+ fail(`${alias}: it cannot be its own backup`);
1071
+ if (queues.get(alias)?.includes(entry))
1072
+ fail(`${alias}: '${entry}' is already in its queue`);
1073
+ }
1074
+ const queue = queues.get(alias) ?? [];
1075
+ queues.set(alias, flags.get('first') === 'true' ? [...additions, ...queue] : [...queue, ...additions]);
1076
+ }
1077
+ try {
1078
+ // revalidate the whole file the way the hub will parse it
1079
+ parseBackups(serializeBackups(queues));
1080
+ }
1081
+ catch (err) {
1082
+ fail(err instanceof Error ? err.message : String(err));
1083
+ }
1084
+ writeBackupsFile(paths.backupsFile, queues);
1085
+ const queue = queues.get(alias);
1086
+ if (json) {
1087
+ console.log(JSON.stringify(Object.fromEntries(queues), null, 2));
1088
+ }
1089
+ else {
1090
+ const shown = queue && queue.length > 0 ? queue.join(', ') : '(none — queue removed)';
1091
+ console.log(`✓ ${alias} → ${shown}`);
1092
+ console.log('(config.backups.toml written — the hub picks it up within ~2s)');
1093
+ }
854
1094
  return;
855
1095
  }
856
1096
  case 'status': {