quoxcli 0.5.0 → 0.6.1

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 (130) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/lib/api/endpoints/admin.js +21 -0
  3. package/lib/api/endpoints/calendarq.js +8 -0
  4. package/lib/api/endpoints/governance.js +7 -0
  5. package/lib/api/endpoints/inboxq.js +39 -0
  6. package/lib/api/endpoints/index.js +4 -0
  7. package/lib/api/endpoints/lens.js +34 -0
  8. package/lib/api/endpoints/lucid.js +18 -0
  9. package/lib/api/endpoints/mcp.js +35 -0
  10. package/lib/api/endpoints/mon.js +6 -7
  11. package/lib/api/endpoints/notesq.js +2 -0
  12. package/lib/api/endpoints/quoxplan.js +51 -0
  13. package/lib/api/endpoints/v1.js +57 -0
  14. package/lib/commands/actions.js +51 -13
  15. package/lib/commands/admin.js +86 -0
  16. package/lib/commands/approvals.js +5 -1
  17. package/lib/commands/calendar.js +27 -0
  18. package/lib/commands/chat.js +1 -1
  19. package/lib/commands/contacts.js +2 -0
  20. package/lib/commands/fleet.js +164 -0
  21. package/lib/commands/fleetRecipe.js +174 -0
  22. package/lib/commands/lens.js +268 -0
  23. package/lib/commands/lucid.js +6 -0
  24. package/lib/commands/lucidGoals.js +423 -0
  25. package/lib/commands/mail.js +287 -7
  26. package/lib/commands/mcp.js +190 -0
  27. package/lib/commands/mon.js +14 -0
  28. package/lib/commands/monitoring.js +73 -1
  29. package/lib/commands/notesSeal.js +380 -22
  30. package/lib/commands/offload.js +51 -10
  31. package/lib/commands/plan.js +507 -54
  32. package/lib/commands/research.js +78 -0
  33. package/lib/commands/settings.js +7 -1
  34. package/lib/commands/volt.js +6 -1
  35. package/lib/commands/wazuh.js +74 -9
  36. package/lib/commands/wazuhIntake.js +55 -6
  37. package/lib/core/commandRegistrars.js +2 -0
  38. package/lib/core/config.js +14 -1
  39. package/lib/core/offload.js +83 -0
  40. package/lib/core/output.js +29 -4
  41. package/lib/core/selfUpdate.js +2 -0
  42. package/lib/core/version.js +15 -2
  43. package/lib/dragon/select.js +2 -2
  44. package/lib/quoxon/api.js +4 -0
  45. package/lib/quoxon/app.js +258 -49
  46. package/lib/quoxon/approvalsView.js +3 -1
  47. package/lib/quoxon/ask.js +4 -2
  48. package/lib/quoxon/bang.js +7 -1
  49. package/lib/quoxon/commandShort.js +1 -1
  50. package/lib/quoxon/commands.js +5 -5
  51. package/lib/quoxon/delegationDragon.js +49 -0
  52. package/lib/quoxon/dnd.js +47 -0
  53. package/lib/quoxon/dragonCompanion.js +24 -12
  54. package/lib/quoxon/events.js +12 -1
  55. package/lib/quoxon/extensions.js +2 -0
  56. package/lib/quoxon/foreignText.js +49 -0
  57. package/lib/quoxon/inboxView.js +5 -5
  58. package/lib/quoxon/launcher.js +2 -1
  59. package/lib/quoxon/loopCard.js +2 -1
  60. package/lib/quoxon/main.js +8 -2
  61. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  62. package/lib/quoxon/onboarding/view.js +1 -1
  63. package/lib/quoxon/render.js +5 -2
  64. package/lib/quoxon/resend.js +14 -0
  65. package/lib/quoxon/rows.js +3 -1
  66. package/lib/quoxon/secretShape.js +2 -0
  67. package/lib/quoxon/slash/broadcast.js +30 -6
  68. package/lib/quoxon/slash/broadcastRun.js +12 -6
  69. package/lib/quoxon/slash/cliLeaves.json +54 -0
  70. package/lib/quoxon/slash/dashboard.js +2 -0
  71. package/lib/quoxon/slash/listView.js +2 -1
  72. package/lib/quoxon/slash/palette.js +41 -0
  73. package/lib/quoxon/slash/paletteController.js +6 -2
  74. package/lib/quoxon/slash/restart.js +2 -0
  75. package/lib/quoxon/slotChat.js +7 -3
  76. package/lib/quoxon/slotRun.js +2 -0
  77. package/lib/quoxon/webPty.js +15 -0
  78. package/lib/quoxon/whatsnew.jsonl +7 -0
  79. package/lib/render/planBrief.js +614 -0
  80. package/lib/toolOutputShape.js +6 -3
  81. package/package.json +1 -1
  82. package/vendor/dragonlab/SOURCE +3 -3
  83. package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
  84. package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
  85. package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
  86. package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
  87. package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
  88. package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
  89. package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
  90. package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
  91. package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
  92. package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
  93. package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
  94. package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
  95. package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
  96. package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
  97. package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
  98. package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
  99. package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
  100. package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
  101. package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
  102. package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
  103. package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
  104. package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
  105. package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
  106. package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
  107. package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
  108. package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
  109. package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
  110. package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
  111. package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
  112. package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
  113. package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
  114. package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
  115. package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
  116. package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
  117. package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
  118. package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
  119. package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
  120. package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
  121. package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
  122. package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
  123. package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
  124. package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
  125. package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
  126. package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
  127. package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
  128. package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
  129. package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
  130. package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
@@ -407,4 +407,90 @@ export function registerAdminCommands(program, ctx) {
407
407
  const data = await client.get(resolveEndpoint('admin.retention.stats.list').path, { service: resolveEndpoint('admin.retention.stats.list').service });
408
408
  formatDetail(data.stats || data, { format: program.opts().output, title: 'Retention Stats' });
409
409
  });
410
+
411
+ // AQ-9H LADON-P1B (plan v6.2 A9, owner decision S9): the scratch-org registry. Fixture mail and calendar
412
+ // providers load only for an org marked here, and only on a deployment that sets QUOX_FIXTURE_PROVIDERS=1.
413
+ // The collector refuses every caller but a human instance admin (requireInstanceAdminMw), so this verb is a
414
+ // human step: an agent or service key gets a 403.
415
+ const scratchOrgs = admin
416
+ .command('scratch-orgs')
417
+ .description('Scratch orgs: only a human instance admin can mark an org, which enables fixture mail and calendar providers for that org only');
418
+
419
+ scratchOrgs
420
+ .command('list')
421
+ .description('List the orgs marked scratch (human instance admin)')
422
+ .action(async () => {
423
+ auth.requireAuth();
424
+ const ep = resolveEndpoint('admin.scratch_orgs.list');
425
+ let data;
426
+ try {
427
+ data = await client.get(ep.path, { service: ep.service });
428
+ } catch (err) {
429
+ announceFailure('quox', 'admin scratch-orgs list', err);
430
+ throw err;
431
+ }
432
+ const orgs = data.orgs || [];
433
+ announce('quox', 'admin scratch-orgs list', data.corrupt
434
+ ? { result: 'registry unreadable', status: 'warn' }
435
+ : { result: `${orgs.length} scratch org(s)` });
436
+ if (program.opts().output === 'json') {
437
+ console.log(JSON.stringify(data, null, 2));
438
+ return;
439
+ }
440
+ if (data.corrupt) {
441
+ console.error(`Scratch-org registry is unreadable, so NO org is treated as scratch: ${data.error || 'unknown error'}`);
442
+ process.exitCode = 1;
443
+ }
444
+ formatOutput({
445
+ columns: ['orgId', 'markedBy', 'markedAt', 'note'],
446
+ headers: ['Org', 'Marked by', 'Marked at', 'Note'],
447
+ rows: orgs.map((o) => ({ orgId: o.orgId, markedBy: o.markedBy || '-', markedAt: o.markedAt || '-', note: o.note || '' })),
448
+ }, { format: program.opts().output, raw: orgs, emptyMessage: 'No org is marked scratch.' });
449
+ if (program.opts().output !== 'quiet' && program.opts().output !== 'csv') {
450
+ console.log(data.fixtureProvidersEnv
451
+ ? 'Fixture providers are enabled on this deployment (QUOX_FIXTURE_PROVIDERS=1).'
452
+ : 'Fixture providers are off on this deployment (QUOX_FIXTURE_PROVIDERS is not 1), so marking has no effect yet.');
453
+ }
454
+ });
455
+
456
+ scratchOrgs
457
+ .command('mark')
458
+ .description('Mark an org scratch so fixture providers load for that org only (human instance admin; audited)')
459
+ .argument('<orgId>', 'the org id to mark')
460
+ .option('--note <text>', 'why this org is scratch (stored with the mark, 200 characters max)')
461
+ .action(async (orgId, opts) => {
462
+ auth.requireAuth();
463
+ const ep = resolveEndpoint('admin.scratch_orgs.mark', { orgId });
464
+ let data;
465
+ try {
466
+ data = await client.put(ep.path, { service: ep.service, body: opts.note ? { note: opts.note } : {} });
467
+ } catch (err) {
468
+ announceFailure('quox', `admin scratch-orgs mark ${orgId}`, err);
469
+ throw err;
470
+ }
471
+ announce('quox', `admin scratch-orgs mark ${orgId}`, { result: 'marked scratch' });
472
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
473
+ printSuccess(`Org ${orgId} is marked scratch. Fixture providers load for this org only, on a deployment with QUOX_FIXTURE_PROVIDERS=1.`);
474
+ });
475
+
476
+ scratchOrgs
477
+ .command('unmark')
478
+ .description('Remove the scratch mark from an org, so fixture providers no longer load for it (human instance admin; audited)')
479
+ .argument('<orgId>', 'the org id to unmark')
480
+ .action(async (orgId) => {
481
+ auth.requireAuth();
482
+ const ep = resolveEndpoint('admin.scratch_orgs.unmark', { orgId });
483
+ let data;
484
+ try {
485
+ data = await client.delete(ep.path, { service: ep.service });
486
+ } catch (err) {
487
+ announceFailure('quox', `admin scratch-orgs unmark ${orgId}`, err);
488
+ throw err;
489
+ }
490
+ announce('quox', `admin scratch-orgs unmark ${orgId}`, { result: data.removed ? 'unmarked' : 'was not marked' });
491
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
492
+ printSuccess(data.removed
493
+ ? `Org ${orgId} is no longer scratch. Fixture providers no longer load for it.`
494
+ : `Org ${orgId} was not marked scratch. Nothing changed.`);
495
+ });
410
496
  }
@@ -142,7 +142,11 @@ export function registerApprovalsCommands(program, ctx) {
142
142
  body.patterns = options.pattern || [];
143
143
  }
144
144
  const ep = resolveEndpoint('v1.approvals.grants.create');
145
- const data = await client.post(ep.path, { service: ep.service, body });
145
+ // VULCAN: a standing grant pre-authorises ssh_exec for days, so the server asks for a step-up PIN (scope override).
146
+ const data = await withStepUp(
147
+ (headers) => client.post(ep.path, { service: ep.service, headers, body }),
148
+ { client, orgId: auth.getOrgId?.(program.opts().org) || null, scope: 'override', io: ctx.stepUpIO, what: 'Creating a standing grant' },
149
+ );
146
150
  if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); return; }
147
151
  const g = data.grant || {};
148
152
  announce('approvals', `grant created ${g.tool || 'ssh_exec'}@${g.host || options.host}`, { result: g.grant_id });
@@ -28,6 +28,33 @@ export function registerCalendarCommands(program, ctx) {
28
28
  .command('calendar')
29
29
  .description('Calendar-Q: connected calendars, agenda, coverage, sync');
30
30
 
31
+ // ── connections add (AQ-9H LADON-P1B: fixture only) ─────────────────────────────
32
+
33
+ const connections = calendar
34
+ .command('connections')
35
+ .description('Calendar connections (Google connects through the OAuth flow; add here is fixture only)');
36
+
37
+ connections
38
+ .command('add')
39
+ .description('Connect a seeded fixture calendar for scratch-org proof runs (--provider fixture --seed <name>). The server decides: it refuses unless this deployment enables fixture providers and a platform admin marked the org scratch. Google calendars connect through the OAuth flow, not this command.')
40
+ .requiredOption('--provider <name>', 'calendar provider; only fixture is accepted here')
41
+ .option('--seed <name>', 'fixture seed to load (e.g. scratch-office-day)')
42
+ .option('--run <id>', 'proof run id substituted into the seed ({{run}})')
43
+ .action(async (options) => {
44
+ auth.requireAuth();
45
+ if (options.provider !== 'fixture') {
46
+ throw new Error(`--provider ${options.provider} is not connected here. Only --provider fixture is; Google calendars connect through the OAuth flow.`);
47
+ }
48
+ if (!options.seed) throw new Error('--seed <name> is required with --provider fixture (e.g. --seed scratch-office-day).');
49
+ const body = { provider: 'fixture', seed: options.seed };
50
+ if (options.run) body.run = options.run;
51
+ const ep = resolveEndpoint('calendarq.connections.create');
52
+ const data = await client.post(ep.path, { service: ep.service, body });
53
+ if (isJsonOutput(program)) return printJson(data);
54
+ const conn = data.connection || data;
55
+ printSuccess(`Connected fixture calendar ${conn.resourceLabel || ''} (${conn.id}) from seed ${options.seed}.`);
56
+ });
57
+
31
58
  // ── status ───────────────────────────────────────────────────────────
32
59
 
33
60
  calendar
@@ -221,7 +221,7 @@ async function awaitTurn(client, turnId) {
221
221
  }
222
222
 
223
223
  /** One-shot (non-streaming) chat via POST /chat/claude, kept server side as a pollable turn. */
224
- async function sendOneShot(client, { message, agent, sessionId, resumeTurn }) {
224
+ export async function sendOneShot(client, { message, agent, sessionId, resumeTurn }) {
225
225
  let data;
226
226
  if (resumeTurn) {
227
227
  data = await awaitTurn(client, resumeTurn);
@@ -408,6 +408,8 @@ export function registerContactsCommands(program, ctx) {
408
408
  if (isJson(program)) return printJson(d);
409
409
  console.log(`channel: ${d.channel || 'none allowed'}`);
410
410
  for (const a of d.alternatives || []) console.log(` ${a.channel}: ${a.decision}${(a.reasons || []).length ? ` (${a.reasons.join(', ')})` : ''}`);
411
+ if (d.evaluated_for) console.log(`for: ${d.evaluated_for === 'agent' ? 'an agent' : 'you (a person)'}`);
412
+ if ((d.quiet_hours_for_agents || []).length) console.log(`quiet hours: an agent reaching them by ${d.quiet_hours_for_agents.join(', ')} now needs approval`);
411
413
  announce('contacts best-channel', d.channel || 'none allowed');
412
414
  });
413
415
 
@@ -10,6 +10,51 @@ import { formatOutput, formatDetail, printSuccess, printWarning, printInfo } fro
10
10
  import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
11
11
  import { parseJSON } from './helpers.js';
12
12
  import { resolveEndpoint } from '../api/endpoints.js';
13
+ import { registerFleetRecipeCommands } from './fleetRecipe.js';
14
+
15
+ /** HYPERION-NEXT P10: one `quox fleet hw` summary row. */
16
+ function formatHwRow(h) {
17
+ const c = h.components || {};
18
+ return {
19
+ host: h.host,
20
+ overall: h.overall,
21
+ fans: c.fans || '-', psu: c.psu || '-', memory: c.memory || '-', cpu: c.cpu || '-',
22
+ raid: c.raid || '-', disks: c.disks || '-', temps: c.temps || '-',
23
+ findings: h.findings ?? 0,
24
+ checked: h.checked_at ? h.checked_at.replace('T', ' ').slice(0, 16) : '-',
25
+ };
26
+ }
27
+
28
+ /**
29
+ * When a finding was seen. Event findings carry the earliest and latest REAL dated event (an undated "Pre-Init"
30
+ * SEL entry has none); a run over several days shows both ends, and no date is said plainly, never invented.
31
+ */
32
+ function hwWhen(f) {
33
+ const first = f.first_seen ? f.first_seen.slice(0, 10) : null;
34
+ const last = f.last_seen ? f.last_seen.slice(0, 10) : null;
35
+ if (first && last && last !== first) return ` (first ${first}, last ${last})`;
36
+ if (first) return ` (since ${first})`;
37
+ if (f.basis === 'events' && !/date not recorded/.test(f.title || '')) return ' (date not recorded by the BMC)';
38
+ return '';
39
+ }
40
+
41
+ /** HYPERION-NEXT P10: `quox fleet hw <host>`: every finding with what it means and what to do. */
42
+ function printHwHost(r, baseline) {
43
+ console.log(`${r.host || '?'} ${String(r.overall || 'unknown').toUpperCase()} ${r.model || ''} checked ${r.checked_at || '-'}`);
44
+ const c = r.components || {};
45
+ console.log(Object.keys(c).map((k) => `${k} ${c[k]}`).join(' '));
46
+ if (r.dimms) console.log(`memory: ${r.dimms.count} modules, ${r.dimms.total_gb} GB${baseline ? ` (baseline ${baseline.count} modules, ${baseline.total_gb} GB)` : ''}`);
47
+ if (r.power_restore_policy) console.log(`power restore policy: ${r.power_restore_policy}`);
48
+ const findings = r.findings || [];
49
+ if (!findings.length) console.log('\nNo findings.');
50
+ for (const f of findings) {
51
+ console.log(`\n[${f.severity}] ${f.component}: ${f.title}${hwWhen(f)}`);
52
+ console.log(` What it means: ${f.meaning}`);
53
+ console.log(` What to do: ${f.action}`);
54
+ for (const e of (f.evidence || []).slice(0, 3)) console.log(` evidence: ${e}`);
55
+ }
56
+ for (const g of r.gaps || []) printWarning(`not checked: ${g}`);
57
+ }
13
58
 
14
59
  /**
15
60
  * Map a raw /api/v1/gpu/nodes row to the flat shape `fleet gpu`'s table
@@ -95,6 +140,7 @@ export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
95
140
 
96
141
  /** Tools whose input schema has a `host` field (collector reads input.host, not body.host_id). */
97
142
  const TOOLS_READING_INPUT_HOST = new Set(['ssh_exec']);
143
+ const HW_HOST_RE = /^(?:[A-Za-z0-9][A-Za-z0-9_-]{0,62}|\d{1,3}(?:\.\d{1,3}){3})$/; // same shape the collector's fleet hw routes accept
98
144
 
99
145
  /** Locates the vendored (bwrap-capable) codex binary on the host. */
100
146
  export const CODEX_VENDOR_FIND_COMMAND =
@@ -635,6 +681,121 @@ export function registerFleetCommands(program, ctx) {
635
681
  }, { format: program.opts().output, emptyMessage: 'No GPU nodes enrolled yet.' });
636
682
  });
637
683
 
684
+ // HYPERION-NEXT P10: bare-metal hardware health (fans, power supplies, temperatures, memory, CPU, RAID, disks).
685
+ const hw = fleet
686
+ .command('hw')
687
+ .description('Hardware health of the bare-metal hosts (fans, PSUs, memory, CPU, RAID, disks); give a host for its findings')
688
+ .argument('[host]', 'one host: list its findings with what they mean and what to do')
689
+ .action(async (host) => {
690
+ auth.requireAuth();
691
+ const json = program.opts().output === 'json';
692
+ if (host) {
693
+ const ep = resolveEndpoint('v1.fleet.hw.get', { host });
694
+ const data = await client.get(ep.path, { service: ep.service });
695
+ if (json) { formatDetail(data, { format: 'json' }); return; }
696
+ printHwHost(data.report || {}, data.baseline);
697
+ return;
698
+ }
699
+ const ep = resolveEndpoint('v1.fleet.hw.list');
700
+ const data = await client.get(ep.path, { service: ep.service });
701
+ if (json) { formatDetail(data, { format: 'json' }); return; }
702
+ formatOutput({
703
+ columns: ['host', 'overall', 'fans', 'psu', 'memory', 'cpu', 'raid', 'disks', 'temps', 'findings', 'checked'],
704
+ headers: ['Host', 'Overall', 'Fans', 'PSU', 'Memory', 'CPU', 'RAID', 'Disks', 'Temps', 'Findings', 'Checked'],
705
+ rows: (data.hosts || []).map(formatHwRow),
706
+ }, { format: program.opts().output, emptyMessage: data.no_fleet_for_org ? (data.message || 'This organisation has no fleet hosts on this Quox instance.') : 'No hardware reports yet. Run: quox fleet hw refresh' });
707
+ const o = data.overall || {};
708
+ if ((data.hosts || []).length) printInfo(`${o.fault || 0} fault, ${o.warn || 0} warn, ${o.ok || 0} ok, ${o.unknown || 0} unknown. Details: quox fleet hw <host>`);
709
+ if (data.poller) printInfo(`Poller: ${data.poller.armed ? `on, every ${Math.round((data.poller.interval_ms || 0) / 60000)} min` : 'off (FLEET_HW_POLL=1 turns it on)'}${data.poller.state && data.poller.state.last_outcome ? `, last tick ${data.poller.state.last_outcome}` : ''}`);
710
+ if (data.alerts) printInfo(`Alerts: ${data.alerts.enabled ? 'on, one inbox notification per new fault' : `off (${data.alerts.env_flag || 'FLEET_HW_ALERTS'}=1 turns them on)`}`);
711
+ });
712
+
713
+ hw
714
+ .command('events')
715
+ .description('Hardware event log (IPMI SEL) entries across the bare-metal hosts')
716
+ .option('--since <window>', 'only events since this window (30m, 12h, 1d, 2w) or ISO time', '1d')
717
+ .option('--host <host>', 'one host only')
718
+ .option('--severity <level>', 'fault, warn or info')
719
+ .option('--limit <n>', 'most events to return (max 1000)', '200')
720
+ .action(async (options) => {
721
+ auth.requireAuth();
722
+ const ep = resolveEndpoint('v1.fleet.hw.events');
723
+ const query = { since: options.since, limit: options.limit };
724
+ if (options.host) query.host = options.host;
725
+ if (options.severity) query.severity = options.severity;
726
+ const data = await client.get(ep.path, { service: ep.service, query });
727
+ if (program.opts().output === 'json') { formatDetail(data, { format: 'json' }); return; }
728
+ formatOutput({
729
+ columns: ['when', 'host', 'severity', 'sensor', 'event'],
730
+ headers: ['When', 'Host', 'Severity', 'Sensor', 'Event'],
731
+ rows: (data.events || []).map((e) => ({
732
+ when: e.ts ? e.ts.replace('T', ' ').slice(0, 19) : (e.raw_time || '-'),
733
+ host: e.host,
734
+ severity: e.severity,
735
+ sensor: e.sensor || '-',
736
+ event: [e.event, e.direction, e.detail].filter(Boolean).join(' | '),
737
+ })),
738
+ }, { format: program.opts().output, emptyMessage: `No hardware events since ${options.since}.` });
739
+ });
740
+
741
+ hw
742
+ .command('refresh')
743
+ .description('Check the bare-metal hardware now (read-only IPMI, RAID and SMART reads) and store a fresh report')
744
+ .argument('[host]', 'one host only (default: every bare-metal host)')
745
+ .action(async (host) => {
746
+ auth.requireAuth();
747
+ const ep = resolveEndpoint('v1.fleet.hw.refresh');
748
+ const data = await client.post(ep.path, { service: ep.service, body: host ? { hosts: [host] } : {}, timeout: 600000 });
749
+ if (program.opts().output === 'json') { formatDetail(data, { format: 'json' }); return; }
750
+ formatOutput({
751
+ columns: ['host', 'overall', 'faults', 'warns', 'top'],
752
+ headers: ['Host', 'Overall', 'Faults', 'Warnings', 'Top finding'],
753
+ rows: (data.hosts || []).map((h) => ({ host: h.host, overall: h.overall, faults: h.faults ?? 0, warns: h.warns ?? 0, top: (h.findings && h.findings[0] && h.findings[0].title) || '-' })),
754
+ }, { format: program.opts().output, emptyMessage: 'No bare-metal host answered.' });
755
+ for (const u of data.unreachable || []) printWarning(`${u.host}: no hardware answer (${u.error})`);
756
+ printInfo(`${(data.hosts || []).length} host(s) checked, ${data.reads || 0} read(s), ${data.failures || 0} failure(s). Collection ${data.collection_id || '-'}`);
757
+ });
758
+
759
+ // HYPERION-NEXT P10 slice 2: blink the chassis identify light so a person can find the server in the rack.
760
+ // An ACTION on the BMC, never card-free: it goes through ssh_exec, where `ipmitool chassis identify <n>` is not an
761
+ // allowlisted read, so the call is held for a human approval card like any other fleet write.
762
+ hw
763
+ .command('identify')
764
+ .description('Blink a host\'s chassis identify light so it can be found in the rack (held for approval)')
765
+ .argument('<host>', 'the bare-metal host')
766
+ .option('--seconds <n>', 'how long the light blinks, 30 to 600 seconds', '120')
767
+ .option('--reason <text>', 'why; shown on the approval card')
768
+ .option('--org <id>', 'org ID (overrides active org; required for QUOX_SERVICE_KEY automation callers unless QUOX_ORG_ID is set)')
769
+ .action(async (host, options) => {
770
+ auth.requireAuth();
771
+ if (!HW_HOST_RE.test(String(host))) throw new Error(`not a safe short hostname: ${JSON.stringify(String(host).slice(0, 40))}`);
772
+ const raw = String(options.seconds);
773
+ const seconds = /^\d{1,3}$/.test(raw) ? Number(raw) : NaN;
774
+ if (!(seconds >= 30 && seconds <= 600)) throw new Error(`--seconds must be a whole number from 30 to 600 (got ${JSON.stringify(raw.slice(0, 20))})`);
775
+ const body = {
776
+ tool_name: 'ssh_exec',
777
+ input: { host, command: `ipmitool chassis identify ${seconds}` },
778
+ reason: options.reason ? String(options.reason) : `Blink the identify light on ${host} for ${seconds} seconds so someone can find it in the rack`,
779
+ };
780
+ const orgId = auth.getOrgId(options.org || process.env.QUOX_ORG_ID);
781
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
782
+ throw new Error('QUOX_SERVICE_KEY is set but no org could be resolved. Set QUOX_ORG_ID=<org> for automation callers, or pass --org <id>.');
783
+ }
784
+ if (orgId) body.org_id = orgId;
785
+ const ep = resolveEndpoint('v1.tools.execute');
786
+ const data = await client.post(ep.path, { service: ep.service, body, timeout: 120000 });
787
+ if (program.opts().output === 'json') { console.log(JSON.stringify(data, null, 2)); }
788
+ const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
789
+ const failed = (data && data.success === false) || (data && data.result && data.result.success === false);
790
+ if (program.opts().output !== 'json') {
791
+ if (data && data.pending) printInfo(`Held for approval (${heldId || 'pending'}). Once approved, the identify light on ${host} blinks for ${seconds} seconds.`);
792
+ else if (failed) printWarning(`The identify light on ${host} could not be turned on: ${(data && (data.error || (data.result && data.result.error))) || 'the command failed'}`);
793
+ else console.log(`The identify light on ${host} is blinking for ${seconds} seconds.`);
794
+ }
795
+ if (heldId) console.error(`after approval run: quox approvals result ${heldId}`);
796
+ if (failed && !(data && data.pending)) process.exitCode = 1;
797
+ });
798
+
638
799
  fleet
639
800
  .command('tools')
640
801
  .description('List available fleet tools')
@@ -716,6 +877,9 @@ export function registerFleetCommands(program, ctx) {
716
877
  if (failed && !(data && data.pending)) process.exitCode = 1;
717
878
  });
718
879
 
880
+ // BASTION-JANUS P3: the governed fleet recipe library (lib/commands/fleetRecipe.js).
881
+ registerFleetRecipeCommands(fleet, { client, auth, program });
882
+
719
883
  fleet
720
884
  .command('host-update <hostId>')
721
885
  .description('Update a host in the Bastion registry (link/clear its bootstrap credential)')
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `quox fleet recipe ...` (BASTION-JANUS P3): the org's governed fleet recipe library.
3
+ *
4
+ * A recipe is a saved list of exact shell lines plus a risk tier, stored server-side per org.
5
+ * `run` never runs anything itself: the collector renders the recipe and hands it to the same
6
+ * ssh_exec gate as `quox fleet exec`, so a change raises one approval card (up to 25 hosts) and
7
+ * an allowlisted read runs without one. Output of a held run: `quox approvals result <id>`.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import yaml from 'js-yaml';
11
+ import { formatOutput, formatDetail, printInfo } from '../core/output.js';
12
+ import { printEvent, line as glyphLine } from '../core/quoxglyph.js';
13
+ import { resolveEndpoint } from '../api/endpoints.js';
14
+
15
+ /** Parse a recipe file (YAML or JSON) into a plain object. */
16
+ export function parseRecipeFile(text, filename = '') {
17
+ let doc;
18
+ if (/\.json$/i.test(filename)) doc = JSON.parse(text);
19
+ else doc = yaml.load(text, { schema: yaml.JSON_SCHEMA });
20
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
21
+ throw new Error(`${filename || 'recipe file'} must hold one recipe object (name, risk, commands, ...)`);
22
+ }
23
+ return doc;
24
+ }
25
+
26
+ /** `--param k=v` (repeatable) into { k: v }. */
27
+ export function parseParamFlags(list = []) {
28
+ const out = {};
29
+ for (const p of list) {
30
+ const i = String(p).indexOf('=');
31
+ if (i < 1) throw new Error(`--param ${JSON.stringify(p)} must be k=v`);
32
+ const k = p.slice(0, i);
33
+ if (Object.prototype.hasOwnProperty.call(out, k)) throw new Error(`--param ${k} given twice`);
34
+ out[k] = p.slice(i + 1);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** `--host a,b --host c` into ['a', 'b', 'c']. */
40
+ export function parseHostList(list = []) {
41
+ const hosts = [].concat(list || []).flatMap((h) => String(h).split(',')).map((h) => h.trim()).filter(Boolean);
42
+ if (!hosts.length) throw new Error('Give at least one --host <h>[,h...] (up to 25)');
43
+ return hosts;
44
+ }
45
+
46
+ const collect = (v, prev) => (prev || []).concat([v]);
47
+
48
+ /** Org for automation callers, same precedence as `fleet exec`: --org > QUOX_ORG_ID > the profile's org. */
49
+ function orgFor(auth, program) {
50
+ const orgId = auth.getOrgId(program.opts().org || process.env.QUOX_ORG_ID);
51
+ if (!orgId && process.env.QUOX_SERVICE_KEY) {
52
+ throw new Error('QUOX_SERVICE_KEY is set but no org could be resolved. Set QUOX_ORG_ID=<org> or pass --org <id>.');
53
+ }
54
+ return orgId || null;
55
+ }
56
+
57
+ export function registerFleetRecipeCommands(fleet, { client, auth, program }) {
58
+ const isJson = () => (program.opts().output === 'json');
59
+ const recipe = fleet
60
+ .command('recipe')
61
+ .description('Governed fleet recipe library: saved command sets, run through the ssh_exec approval gate');
62
+
63
+ recipe
64
+ .command('list')
65
+ .description('List the built-in and org fleet recipes')
66
+ .action(async () => {
67
+ auth.requireAuth();
68
+ const ep = resolveEndpoint('v1.fleet.recipes.list');
69
+ const org = orgFor(auth, program);
70
+ const data = await client.get(ep.path, { service: ep.service, ...(org ? { query: { org_id: org } } : {}) });
71
+ if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
72
+ formatOutput({
73
+ columns: ['name', 'risk', 'source', 'detached', 'commands', 'title'],
74
+ headers: ['Name', 'Risk', 'Source', 'Detached', 'Lines', 'Title'],
75
+ rows: (data.recipes || []).map((r) => ({
76
+ name: r.name, risk: r.risk, source: r.builtin ? 'built-in' : 'org',
77
+ detached: r.detached ? 'yes' : 'no', commands: r.command_count, title: r.title,
78
+ })),
79
+ }, { format: program.opts().output, emptyMessage: 'No fleet recipes.' });
80
+ });
81
+
82
+ recipe
83
+ .command('show <name>')
84
+ .description('Show one fleet recipe, every command line included')
85
+ .action(async (name) => {
86
+ auth.requireAuth();
87
+ const ep = resolveEndpoint('v1.fleet.recipes.get', { name });
88
+ const org = orgFor(auth, program);
89
+ const data = await client.get(ep.path, { service: ep.service, ...(org ? { query: { org_id: org } } : {}) });
90
+ if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
91
+ const r = data.recipe || {};
92
+ formatDetail({
93
+ name: r.name, title: r.title, risk: r.risk, source: r.builtin ? 'built-in (read-only)' : 'org',
94
+ detached: r.detached ? 'yes (systemd-run unit, log in the journal)' : 'no',
95
+ params: (r.params || []).map((p) => `${p.name} (${p.kind})`).join(', ') || 'none',
96
+ description: r.description || '',
97
+ }, { format: program.opts().output });
98
+ console.log('\nCommands:');
99
+ for (const c of r.commands || []) console.log(` ${c}`);
100
+ });
101
+
102
+ recipe
103
+ .command('add')
104
+ .description('Add an org fleet recipe from a YAML or JSON file (human org admin or operator)')
105
+ .requiredOption('--file <path>', 'recipe file: name, title, description, risk (read|change|destructive), commands[], detached, params[]')
106
+ .action(async (options) => {
107
+ auth.requireAuth();
108
+ const body = parseRecipeFile(readFileSync(options.file, 'utf8'), options.file);
109
+ const ep = resolveEndpoint('v1.fleet.recipes.create');
110
+ const data = await client.post(ep.path, { service: ep.service, body });
111
+ if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
112
+ const r = data.recipe || body;
113
+ printEvent(glyphLine('bastion', 'recipe added', { result: `${r.name} (${r.risk})`, status: 'ok' }));
114
+ });
115
+
116
+ recipe
117
+ .command('remove <name>')
118
+ .description('Remove an org fleet recipe (human org admin or operator; built-ins cannot be removed)')
119
+ .action(async (name) => {
120
+ auth.requireAuth();
121
+ const ep = resolveEndpoint('v1.fleet.recipes.delete', { name });
122
+ const data = await client.delete(ep.path, { service: ep.service });
123
+ if (isJson()) { console.log(JSON.stringify(data, null, 2)); return; }
124
+ printEvent(glyphLine('bastion', 'recipe removed', { result: data.removed || name, status: 'ok' }));
125
+ });
126
+
127
+ recipe
128
+ .command('run <name>')
129
+ .description('Run a fleet recipe on 1-25 hosts through the governed ssh_exec gate (a change waits on one approval card)')
130
+ .requiredOption('--host <h[,h...]>', 'target host(s); repeat or comma-separate, up to 25', collect)
131
+ .option('--param <k=v>', 'recipe parameter (repeatable)', collect)
132
+ .option('--reason <text>', 'why you are running this; shown on the approval card')
133
+ .option('--session-label <name>', 'name this session on approval cards (default: $QUOX_SESSION)')
134
+ .action(async (name, options) => {
135
+ auth.requireAuth();
136
+ const body = { recipe: name, hosts: parseHostList(options.host), params: parseParamFlags(options.param || []) };
137
+ if (options.reason) body.reason = String(options.reason);
138
+ if (options.sessionLabel) body.session_label = String(options.sessionLabel);
139
+ const org = orgFor(auth, program);
140
+ if (org) body.org_id = org;
141
+ const ep = resolveEndpoint('v1.fleet.recipes.run');
142
+ const data = await client.post(ep.path, { service: ep.service, body, timeout: 180000 });
143
+ const run = (data && data.recipe_run) || {};
144
+ const heldId = data && data.pending && (data.approvalId || (data._approval && data._approval.id));
145
+ const failed = data && data.success === false && !data.pending;
146
+ if (isJson()) {
147
+ console.log(JSON.stringify(data, null, 2));
148
+ } else {
149
+ printEvent(glyphLine('bastion', `recipe ${name}`, {
150
+ result: heldId ? `held ${heldId}` : (failed ? (data.error || 'failed') : `${run.mode || 'ran'} on ${body.hosts.length} host(s)`),
151
+ status: heldId ? 'busy' : (failed ? 'fail' : 'ok'),
152
+ }));
153
+ if (Array.isArray(data.steps)) {
154
+ for (const s of data.steps) {
155
+ console.log(`$ ${s.command}`);
156
+ const r = s.result !== undefined ? s.result : s;
157
+ console.log(typeof r === 'string' ? r : JSON.stringify(r, null, 2));
158
+ }
159
+ } else if (data.result !== undefined) {
160
+ console.log(typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2));
161
+ } else if (failed) {
162
+ console.error(data.error || JSON.stringify(data));
163
+ }
164
+ if (heldId) printInfo(`waiting on approval ${heldId}; after approval run: quox approvals result ${heldId}`);
165
+ if (run.detached && run.detached.follow_up) {
166
+ printInfo(`detached as unit ${run.detached.unit}; read its log (allowlisted, no card): `
167
+ + `quox fleet exec --tool ssh_exec --host <h> --input '${JSON.stringify({ command: run.detached.follow_up })}'`);
168
+ }
169
+ }
170
+ if (failed) process.exitCode = 1;
171
+ });
172
+
173
+ return recipe;
174
+ }