aweshare 0.7.2 → 0.7.4

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 (54) hide show
  1. package/README.ai.md +1 -1
  2. package/README.md +41 -9
  3. package/apps/agent/dist/config.d.ts +1 -1
  4. package/apps/agent/dist/config.js +4 -0
  5. package/apps/agent/dist/config.js.map +1 -1
  6. package/apps/agent/dist/doctor.js +4 -2
  7. package/apps/agent/dist/doctor.js.map +1 -1
  8. package/apps/hub/dist/auth.js +0 -12
  9. package/apps/hub/dist/auth.js.map +1 -1
  10. package/apps/hub/dist/backups.d.ts +43 -0
  11. package/apps/hub/dist/backups.js +122 -0
  12. package/apps/hub/dist/backups.js.map +1 -0
  13. package/apps/hub/dist/cli.js +353 -121
  14. package/apps/hub/dist/cli.js.map +1 -1
  15. package/apps/hub/dist/config.d.ts +2 -0
  16. package/apps/hub/dist/config.js +1 -0
  17. package/apps/hub/dist/config.js.map +1 -1
  18. package/apps/hub/dist/consumer.d.ts +3 -0
  19. package/apps/hub/dist/consumer.js +190 -57
  20. package/apps/hub/dist/consumer.js.map +1 -1
  21. package/apps/hub/dist/db.d.ts +7 -10
  22. package/apps/hub/dist/db.js +20 -22
  23. package/apps/hub/dist/db.js.map +1 -1
  24. package/apps/hub/dist/http.d.ts +7 -1
  25. package/apps/hub/dist/http.js +6 -1
  26. package/apps/hub/dist/http.js.map +1 -1
  27. package/apps/hub/dist/invites.js +9 -2
  28. package/apps/hub/dist/invites.js.map +1 -1
  29. package/apps/hub/dist/relay.js +7 -0
  30. package/apps/hub/dist/relay.js.map +1 -1
  31. package/apps/hub/dist/sanitize.js +11 -0
  32. package/apps/hub/dist/sanitize.js.map +1 -1
  33. package/apps/hub/dist/server.d.ts +7 -3
  34. package/apps/hub/dist/server.js +23 -8
  35. package/apps/hub/dist/server.js.map +1 -1
  36. package/apps/hub/dist/table.d.ts +1 -3
  37. package/apps/hub/dist/table.js +2 -6
  38. package/apps/hub/dist/table.js.map +1 -1
  39. package/apps/hub/dist/tunnel.d.ts +0 -2
  40. package/apps/hub/dist/tunnel.js +2 -16
  41. package/apps/hub/dist/tunnel.js.map +1 -1
  42. package/package.json +1 -1
  43. package/packages/producer-core/dist/adapters.js +1 -1
  44. package/packages/producer-core/dist/adapters.js.map +1 -1
  45. package/packages/producer-core/dist/catalog.d.ts +7 -0
  46. package/packages/producer-core/dist/catalog.js +38 -1
  47. package/packages/producer-core/dist/catalog.js.map +1 -1
  48. package/packages/producer-core/dist/credentials.d.ts +5 -1
  49. package/packages/producer-core/dist/credentials.js +6 -2
  50. package/packages/producer-core/dist/credentials.js.map +1 -1
  51. package/packages/producer-core/dist/probe.js +1 -1
  52. package/packages/producer-core/dist/probe.js.map +1 -1
  53. package/packages/protocol/dist/models.js +6 -1
  54. package/packages/protocol/dist/models.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]
@@ -189,11 +202,13 @@ Options:
189
202
  --limit N Usage --details only: rows to show, 1..1000 (default 100).
190
203
  --json Print the raw API rows instead of a table.
191
204
  -h, --help Show this message and exit.`,
192
- limits: `Usage: aweshare hub limits NAME [OPTIONS]
205
+ limits: `Usage: aweshare hub limits NAME... [OPTIONS]
193
206
 
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.
207
+ Show, set or clear consumers' limit overrides one consumer or several
208
+ (space- or comma-separated). Unset keys keep the hub-wide defaults
209
+ (AWESHARE_CONSUMER_* env vars); setting merges into what is already
210
+ stored. One failure does not stop the rest; with --json a single name
211
+ keeps the plain object, several become an array.
197
212
 
198
213
  Options:
199
214
  --rps N Requests per second (may be fractional).
@@ -205,6 +220,34 @@ Options:
205
220
  --clear Drop every override (back to hub-wide defaults).
206
221
  --json Print the raw API response instead of text.
207
222
  -h, --help Show this message and exit.`,
223
+ backups: `Usage: aweshare hub backups [ALIAS] [OPTIONS]
224
+
225
+ Per-alias failover queues in the data dir's config.backups.toml: when an
226
+ alias cannot serve (producer offline, daily budget spent, degraded
227
+ backend, blocked, share window closed, busy), the hub retries the queue
228
+ in order before answering with the alias's own error. Entries resolve
229
+ to nothing are skipped; a queue is exactly what was written — no
230
+ implicit hops, no expansion. Edits hot-reload within ~2s. The file is
231
+ this machine's, so the command edits it locally: on a remote hub run it
232
+ there (ssh / docker exec), like every other config.toml change.
233
+
234
+ Bare ALIAS shows its queue; no ALIAS lists every queue.
235
+
236
+ Examples:
237
+ aweshare hub backups # every queue
238
+ aweshare hub backups peng/qwen3-coder # one queue
239
+ aweshare hub backups peng/qwen3-coder --add alice/qwen3-coder
240
+ aweshare hub backups peng/qwen3-coder --add hub/glm-4.6 --first
241
+ aweshare hub backups peng/qwen3-coder --remove hub/glm-4.6
242
+ aweshare hub backups peng/qwen3-coder --clear
243
+
244
+ Options:
245
+ --add ALIASES Append (comma-separated aliases accepted); --first prepends instead.
246
+ --first With --add: put the new backups at the front of the queue.
247
+ --remove A Remove one backup alias from ALIAS's queue.
248
+ --clear Drop ALIAS's queue entirely (the key disappears).
249
+ --json Print the queue(s) as JSON.
250
+ -h, --help Show this message and exit.`,
208
251
  status: `Usage: aweshare hub status
209
252
 
210
253
  The hub's live dashboard: capacity, the last-5m health, admission
@@ -393,6 +436,52 @@ function fmtLimits(name, overrides) {
393
436
  ? `${name}: ${set.join(', ')}`
394
437
  : `${name}: no overrides — hub-wide defaults apply (AWESHARE_CONSUMER_* env vars)`;
395
438
  }
439
+ /** Split a comma-separated alias list ('a/b,c/d') — aliases cannot contain
440
+ * commas, so the split is unambiguous. */
441
+ function splitAliases(raw) {
442
+ return raw
443
+ .split(',')
444
+ .map((entry) => entry.trim())
445
+ .filter((entry) => entry.length > 0);
446
+ }
447
+ /** Several targets on one command line: spaces or commas both work
448
+ * ('18 19 22' or '18,19,22' — ids, names and aliases contain no commas). */
449
+ function parseTargets(raw) {
450
+ return raw
451
+ .flatMap((arg) => arg.split(','))
452
+ .map((t) => t.trim())
453
+ .filter((t) => t.length > 0);
454
+ }
455
+ /** --json across batch commands: one target keeps the plain object, several
456
+ * become an array. */
457
+ function printJsonBatch(entries) {
458
+ console.log(JSON.stringify(entries.length === 1 ? entries[0] : entries, null, 2));
459
+ }
460
+ /** One API call per target, one line each — a failure does not stop the rest
461
+ * (same contract as `produce refresh --all`), and a nonzero exit code closes
462
+ * the run so scripts notice. Failed targets become {target, error} entries,
463
+ * so a --json run stays parseable. */
464
+ async function runBatch(targets, verb, json, each) {
465
+ const entries = [];
466
+ let failed = 0;
467
+ for (const target of targets) {
468
+ try {
469
+ entries.push(await each(target));
470
+ }
471
+ catch (err) {
472
+ failed++;
473
+ const error = errorMessage(err);
474
+ entries.push({ target, error });
475
+ if (!json)
476
+ console.log(`✗ ${target} — ${error}`);
477
+ }
478
+ }
479
+ if (failed > 0) {
480
+ console.error(`error: ${failed} of ${targets.length} ${verb} failed`);
481
+ process.exitCode = 1;
482
+ }
483
+ return entries;
484
+ }
396
485
  /** The five readable tables; bare `list` defaults to the ledger (a read,
397
486
  * and the one operators reach for first). */
398
487
  const LIST_TARGETS = ['invites', 'producers', 'consumers', 'offerings', 'usage'];
@@ -444,6 +533,10 @@ async function main() {
444
533
  writeFileSync(paths.configFile, HUB_CONFIG_TEMPLATE, { mode: 0o600 });
445
534
  console.log(`config file: ${paths.configFile} (every key commented out — edit to override)`);
446
535
  }
536
+ if (!existsSync(paths.backupsFile)) {
537
+ writeFileSync(paths.backupsFile, serializeBackups(new Map()), { mode: 0o600 });
538
+ console.log(`backups file: ${paths.backupsFile} (empty — per-alias failover queues, see: aweshare hub backups -h)`);
539
+ }
447
540
  const hub = createHubServer({ paths }); // creates the db schema
448
541
  await hub.close();
449
542
  console.log(`data dir: ${paths.dataDir}`);
@@ -494,46 +587,50 @@ async function main() {
494
587
  return;
495
588
  }
496
589
  checkFlags('produce', flags, ['add', 'clear', 'all', 'json']);
497
- const raw = positional[1];
590
+ const raws = parseTargets(positional.slice(1));
498
591
  const all = flags.get('all') === 'true';
499
- if (positional[2])
500
- fail(`unexpected argument '${positional[2]}' (see: aweshare hub produce -h)`);
501
592
  if (all) {
502
- if (raw)
503
- fail(`--all refreshes every hub/ model — drop the alias '${raw}'`);
593
+ if (raws.length > 0)
594
+ fail(`--all refreshes every hub/ model — drop the alias '${raws[0]}'`);
504
595
  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');
596
+ fail('--all is a bare refresh of every hub/ model — --add/--clear are per-alias');
506
597
  const api = adminApi();
507
598
  await runProduceRefreshAll(api, flags.get('json') === 'true');
508
599
  return;
509
600
  }
510
- if (!raw)
601
+ if (raws.length === 0)
511
602
  fail('produce refresh: alias is required (hub/model — the hub/ prefix is optional), or --all for every hub/ model');
512
603
  // hub-hosted offerings live in the reserved hub/ namespace — accept
513
604
  // 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 });
605
+ const aliases = raws.map((raw) => (raw.includes('/') ? raw : `hub/${raw}`));
516
606
  const add = flags.get('add');
607
+ let addN;
517
608
  if (add !== undefined) {
518
- const n = Number(add);
519
- if (!Number.isSafeInteger(n) || n <= 0)
609
+ addN = Number(add);
610
+ if (!Number.isSafeInteger(addN) || addN <= 0)
520
611
  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
612
  }
613
+ const clear = flags.get('clear') === 'true';
614
+ if (clear && add !== undefined)
615
+ fail('--clear drops both markers — drop --add too');
616
+ const json = flags.get('json') === 'true';
528
617
  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`);
618
+ const entries = await runBatch(aliases, 'refresh(es)', json, async (alias) => {
619
+ const params = new URLSearchParams({ alias });
620
+ if (addN !== undefined)
621
+ params.set('add', String(addN));
622
+ if (clear)
623
+ params.set('clear', 'true');
624
+ const out = (await api('POST', `/admin/v1/offerings/refresh?${params}`));
625
+ if (!json) {
626
+ printRefreshResult(out);
627
+ if (out.reset)
628
+ console.log(` undo: aweshare hub produce refresh ${out.alias} --clear`);
629
+ }
630
+ return out;
631
+ });
632
+ if (json)
633
+ printJsonBatch(entries);
537
634
  return;
538
635
  }
539
636
  case 'admin': {
@@ -578,8 +675,9 @@ async function main() {
578
675
  if (role === 'consumer' && count && count !== '1')
579
676
  fail('consumer invites are one per name — drop --count and mint one per consumer');
580
677
  // 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.
678
+ // deliberate choice (`none` here; API callers may omit expiresAt). The
679
+ // expiry bounds the redemption window only; the minted identity
680
+ // never expires.
583
681
  const expiresIn = flags.get('expires-in') ?? '7d';
584
682
  if (expiresIn !== 'none') {
585
683
  body.expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString();
@@ -608,24 +706,27 @@ async function main() {
608
706
  return;
609
707
  }
610
708
  checkFlags('admin invite', flags, action === 'extend' ? ['expires-in'] : []);
611
- const id = positional[2];
612
- if (!id)
709
+ const ids = parseTargets(positional.slice(2));
710
+ if (ids.length === 0)
613
711
  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
712
  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}`);
713
+ const revokedIds = [];
714
+ await runBatch(ids, 'revoke(s)', false, async (id) => {
715
+ const out = (await api('DELETE', `/admin/v1/invites/${id}`));
716
+ if (!out.revoked && !out.suspended) {
717
+ console.log(`- invite ${id} was already revoked — nothing to do`);
718
+ return;
719
+ }
720
+ revokedIds.push(id);
721
+ if (out.suspended) {
722
+ console.log(`✓ invite ${id} revoked — ${out.suspended.role} '${out.suspended.name}' suspended, its token no longer works`);
723
+ }
724
+ else {
725
+ console.log(`✓ invite ${id} revoked — the code no longer pairs`);
726
+ }
727
+ });
728
+ if (revokedIds.length > 0)
729
+ console.log(` undo: aweshare hub admin invite restore ${revokedIds.join(' ')}`);
629
730
  return;
630
731
  }
631
732
  if (action === 'extend') {
@@ -635,26 +736,33 @@ async function main() {
635
736
  const expiresAt = expiresIn === 'none'
636
737
  ? null
637
738
  : 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`);
739
+ await runBatch(ids, 'extend(s)', false, async (id) => {
740
+ const out = (await api('POST', `/admin/v1/invites/${id}/extend`, { expiresAt }));
741
+ if (out.redeemed)
742
+ console.log(`✓ invite ${id} is redeemed — its token never expires, nothing to extend`);
743
+ else
744
+ console.log(out.expiresAt === null
745
+ ? `✓ invite ${id} never expires`
746
+ : `✓ invite ${id} expires ${fmtTime(out.expiresAt)}`);
747
+ });
647
748
  return;
648
749
  }
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
- }
750
+ await runBatch(ids, 'restore(s)', false, async (id) => {
751
+ const out = (await api('POST', `/admin/v1/invites/${id}/restore`));
752
+ if (!out.restored) {
753
+ console.log(`- invite ${id} was not revoked — nothing to restore`);
754
+ return;
755
+ }
756
+ if (out.restoredProducer) {
757
+ console.log(`✓ invite ${id} restored — producer '${out.restoredProducer.name}' is back`);
758
+ }
759
+ else if (out.restoredConsumer) {
760
+ console.log(`✓ invite ${id} restored — consumer '${out.restoredConsumer.name}' is back`);
761
+ }
762
+ else {
763
+ console.log(`✓ invite ${id} restored — the code pairs again (its original expiry still applies)`);
764
+ }
765
+ });
658
766
  return;
659
767
  }
660
768
  // offering: the per-alias scalpel ('block' renamed to 'revoke' — the
@@ -666,19 +774,24 @@ async function main() {
666
774
  fail(`unknown offering action '${action}' — expected revoke or restore`);
667
775
  }
668
776
  checkFlags('admin offering', flags, []);
669
- const alias = positional[2];
670
- if (!alias)
777
+ const aliases = parseTargets(positional.slice(2));
778
+ if (aliases.length === 0)
671
779
  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
780
  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}`);
781
+ const revokedAliases = [];
782
+ await runBatch(aliases, 'revoke(s)', false, async (alias) => {
783
+ const out = (await api('POST', `/admin/v1/offerings/block?alias=${encodeURIComponent(alias)}`));
784
+ revokedAliases.push(alias);
785
+ console.log(`✓ ${alias} revoked (${out.offerings} protocol row(s)) — new requests get 503 OFFERING_BLOCKED`);
786
+ });
787
+ if (revokedAliases.length > 0)
788
+ console.log(` undo: aweshare hub admin offering restore ${revokedAliases.join(' ')}`);
678
789
  return;
679
790
  }
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`);
791
+ await runBatch(aliases, 'restore(s)', false, async (alias) => {
792
+ const out = (await api('POST', `/admin/v1/offerings/restore?alias=${encodeURIComponent(alias)}`));
793
+ console.log(`✓ ${alias} restored (${out.offerings} protocol row(s)) — requests flow again`);
794
+ });
682
795
  return;
683
796
  }
684
797
  case 'list': {
@@ -816,13 +929,11 @@ async function main() {
816
929
  }
817
930
  case 'limits': {
818
931
  checkFlags('limits', flags, [...LIMIT_FLAGS.map(([flag]) => flag), 'clear', 'json']);
819
- const name = positional[0];
820
- if (!name)
932
+ const names = parseTargets(positional);
933
+ if (names.length === 0)
821
934
  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
935
  const api = adminApi();
825
- const path = `/admin/v1/consumers/${encodeURIComponent(name)}/limits`;
936
+ const path = (name) => `/admin/v1/consumers/${encodeURIComponent(name)}/limits`;
826
937
  const json = flags.get('json') === 'true';
827
938
  const set = {};
828
939
  for (const [flag, key] of LIMIT_FLAGS) {
@@ -837,20 +948,141 @@ async function main() {
837
948
  if (flags.get('clear') === 'true') {
838
949
  if (Object.keys(set).length > 0)
839
950
  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`);
951
+ const entries = await runBatch(names, 'clear(s)', json, async (name) => {
952
+ const out = (await api('DELETE', path(name)));
953
+ if (!json)
954
+ console.log(`✓ ${out.name}: overrides cleared — hub-wide defaults apply`);
955
+ return out;
956
+ });
957
+ if (json)
958
+ printJsonBatch(entries);
844
959
  return;
845
960
  }
846
961
  if (Object.keys(set).length > 0) {
847
962
  // 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)}`);
963
+ const entries = await runBatch(names, 'update(s)', json, async (name) => {
964
+ const out = (await api('PUT', path(name), set));
965
+ if (!json)
966
+ console.log(`✓ ${fmtLimits(out.name, out.overrides)}`);
967
+ return out;
968
+ });
969
+ if (json)
970
+ printJsonBatch(entries);
850
971
  return;
851
972
  }
852
- const out = (await api('GET', path));
853
- console.log(json ? JSON.stringify(out, null, 2) : fmtLimits(out.name, out.overrides));
973
+ const entries = await runBatch(names, 'lookup(s)', json, async (name) => {
974
+ const out = (await api('GET', path(name)));
975
+ if (!json)
976
+ console.log(fmtLimits(out.name, out.overrides));
977
+ return out;
978
+ });
979
+ if (json)
980
+ printJsonBatch(entries);
981
+ return;
982
+ }
983
+ case 'backups': {
984
+ checkFlags('backups', flags, ['add', 'first', 'remove', 'clear', 'json']);
985
+ const alias = positional[0];
986
+ if (positional[1])
987
+ fail(`unexpected argument '${positional[1]}' (see: aweshare hub backups -h)`);
988
+ const json = flags.get('json') === 'true';
989
+ const add = flags.get('add');
990
+ const remove = flags.get('remove');
991
+ const clear = flags.get('clear') === 'true';
992
+ const mutating = add !== undefined || remove !== undefined || clear;
993
+ if (mutating && alias === undefined)
994
+ fail('which queue to modify: pass ALIAS (see: aweshare hub backups -h)');
995
+ if (clear && (add !== undefined || remove !== undefined))
996
+ fail('--clear drops the whole queue — drop --add/--remove too');
997
+ if (flags.get('first') === 'true' && add === undefined)
998
+ fail('--first only makes sense with --add');
999
+ let queues;
1000
+ try {
1001
+ queues = readBackupsFile(paths.backupsFile);
1002
+ }
1003
+ catch (err) {
1004
+ fail(err instanceof Error ? err.message : String(err));
1005
+ }
1006
+ if (!mutating) {
1007
+ const print = (key, queue) => console.log(queue && queue.length > 0
1008
+ ? `${key} → ${queue.join(', ')}`
1009
+ : `${key}: no backups configured`);
1010
+ if (!alias) {
1011
+ if (queues.size === 0) {
1012
+ console.log('no backup queues configured — create one:');
1013
+ console.log(' aweshare hub backups SOME/ALIAS --add OTHER/BACKUP');
1014
+ return;
1015
+ }
1016
+ if (json)
1017
+ console.log(JSON.stringify(Object.fromEntries(queues), null, 2));
1018
+ else
1019
+ for (const [key, queue] of queues)
1020
+ print(key, queue);
1021
+ return;
1022
+ }
1023
+ const queue = queues.get(alias);
1024
+ if (json)
1025
+ console.log(JSON.stringify({ [alias]: queue ?? [] }, null, 2));
1026
+ else
1027
+ print(alias, queue);
1028
+ return;
1029
+ }
1030
+ // unreachable in plain control flow (mutating implies ALIAS, checked
1031
+ // above) but needed so the compiler sees the string
1032
+ if (alias === undefined)
1033
+ fail('which queue to modify: pass ALIAS (see: aweshare hub backups -h)');
1034
+ if (!isValidAlias(alias))
1035
+ fail(`'${alias}' is not an alias (namespace/name, lowercase)`);
1036
+ // every mutation lands in memory first and the file is written once,
1037
+ // so a rejected flag leaves the file untouched
1038
+ if (clear) {
1039
+ if (!queues.delete(alias))
1040
+ fail(`${alias}: no queue configured (see: aweshare hub backups)`);
1041
+ }
1042
+ if (remove !== undefined) {
1043
+ for (const target of splitAliases(remove)) {
1044
+ const queue = queues.get(alias);
1045
+ if (!queue?.includes(target))
1046
+ fail(`${alias}: '${target}' is not in its queue`);
1047
+ const rest = queue.filter((entry) => entry !== target);
1048
+ if (rest.length > 0)
1049
+ queues.set(alias, rest);
1050
+ else
1051
+ queues.delete(alias);
1052
+ }
1053
+ }
1054
+ if (add !== undefined) {
1055
+ const additions = splitAliases(add);
1056
+ if (new Set(additions).size !== additions.length)
1057
+ fail(`--add repeats an alias: '${add}'`);
1058
+ for (const entry of additions) {
1059
+ if (!isValidAlias(entry))
1060
+ fail(`'${entry}' is not an alias (namespace/name, lowercase)`);
1061
+ if (entry === alias)
1062
+ fail(`${alias}: it cannot be its own backup`);
1063
+ if (queues.get(alias)?.includes(entry))
1064
+ fail(`${alias}: '${entry}' is already in its queue`);
1065
+ }
1066
+ const queue = queues.get(alias) ?? [];
1067
+ queues.set(alias, flags.get('first') === 'true' ? [...additions, ...queue] : [...queue, ...additions]);
1068
+ }
1069
+ try {
1070
+ // revalidate the whole file the way the hub will parse it
1071
+ parseBackups(serializeBackups(queues));
1072
+ }
1073
+ catch (err) {
1074
+ fail(err instanceof Error ? err.message : String(err));
1075
+ }
1076
+ writeBackupsFile(paths.backupsFile, queues);
1077
+ const queue = queues.get(alias);
1078
+ if (json) {
1079
+ console.log(JSON.stringify(Object.fromEntries(queues), null, 2));
1080
+ }
1081
+ else {
1082
+ const shown = queue && queue.length > 0 ? queue.join(', ') : '(none — queue removed)';
1083
+ console.log(`✓ ${alias} → ${shown}`);
1084
+ console.log('(config.backups.toml written — the hub picks it up within ~2s)');
1085
+ }
854
1086
  return;
855
1087
  }
856
1088
  case 'status': {