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.
- package/CHANGELOG.md +65 -1
- package/lib/api/endpoints/admin.js +21 -0
- package/lib/api/endpoints/calendarq.js +8 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inboxq.js +39 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lens.js +34 -0
- package/lib/api/endpoints/lucid.js +18 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +6 -7
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/v1.js +57 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +86 -0
- package/lib/commands/approvals.js +5 -1
- package/lib/commands/calendar.js +27 -0
- package/lib/commands/chat.js +1 -1
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/fleet.js +164 -0
- package/lib/commands/fleetRecipe.js +174 -0
- package/lib/commands/lens.js +268 -0
- package/lib/commands/lucid.js +6 -0
- package/lib/commands/lucidGoals.js +423 -0
- package/lib/commands/mail.js +287 -7
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/mon.js +14 -0
- package/lib/commands/monitoring.js +73 -1
- package/lib/commands/notesSeal.js +380 -22
- package/lib/commands/offload.js +51 -10
- package/lib/commands/plan.js +507 -54
- package/lib/commands/research.js +78 -0
- package/lib/commands/settings.js +7 -1
- package/lib/commands/volt.js +6 -1
- package/lib/commands/wazuh.js +74 -9
- package/lib/commands/wazuhIntake.js +55 -6
- package/lib/core/commandRegistrars.js +2 -0
- package/lib/core/config.js +14 -1
- package/lib/core/offload.js +83 -0
- package/lib/core/output.js +29 -4
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/version.js +15 -2
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/api.js +4 -0
- package/lib/quoxon/app.js +258 -49
- package/lib/quoxon/approvalsView.js +3 -1
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/bang.js +7 -1
- package/lib/quoxon/commandShort.js +1 -1
- package/lib/quoxon/commands.js +5 -5
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/dnd.js +47 -0
- package/lib/quoxon/dragonCompanion.js +24 -12
- package/lib/quoxon/events.js +12 -1
- package/lib/quoxon/extensions.js +2 -0
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/inboxView.js +5 -5
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +2 -1
- package/lib/quoxon/main.js +8 -2
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/render.js +5 -2
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/secretShape.js +2 -0
- package/lib/quoxon/slash/broadcast.js +30 -6
- package/lib/quoxon/slash/broadcastRun.js +12 -6
- package/lib/quoxon/slash/cliLeaves.json +54 -0
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +41 -0
- package/lib/quoxon/slash/paletteController.js +6 -2
- package/lib/quoxon/slash/restart.js +2 -0
- package/lib/quoxon/slotChat.js +7 -3
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/whatsnew.jsonl +7 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/toolOutputShape.js +6 -3
- package/package.json +1 -1
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/architect/v2/architect-master.svg +67 -0
- package/vendor/dragonlab/design/architect/v2/character.mjs +83 -0
- package/vendor/dragonlab/design/architect/v2/review.mjs +35 -0
- package/vendor/dragonlab/design/architect/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v2/archivist-master.svg +77 -0
- package/vendor/dragonlab/design/archivist/v3/character.mjs +56 -0
- package/vendor/dragonlab/design/archivist/v4/character.mjs +46 -0
- package/vendor/dragonlab/design/archivist/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/archivist/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/argus/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/argus/v3/argus-master.svg +68 -0
- package/vendor/dragonlab/design/argus/v3/character.mjs +63 -0
- package/vendor/dragonlab/design/argus/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/argus/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/daedalus/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/daedalus/v3/daedalus-master.svg +65 -0
- package/vendor/dragonlab/design/daedalus/v4/character.mjs +47 -0
- package/vendor/dragonlab/design/daedalus/v5/character.mjs +66 -0
- package/vendor/dragonlab/design/daedalus/v6/character.mjs +32 -0
- package/vendor/dragonlab/design/daedalus/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/daedalus/v6/watch.mjs +56 -0
- package/vendor/dragonlab/design/janus/v1/janus-master.svg +70 -0
- package/vendor/dragonlab/design/janus/v2/character.mjs +56 -0
- package/vendor/dragonlab/design/janus/v3/character.mjs +7 -0
- package/vendor/dragonlab/design/janus/v4/character.mjs +150 -0
- package/vendor/dragonlab/design/janus/v5/character.mjs +55 -0
- package/vendor/dragonlab/design/janus/v5/review.mjs +35 -0
- package/vendor/dragonlab/design/janus/v5/watch.mjs +56 -0
- package/vendor/dragonlab/design/roady/v1/roady-master.svg +67 -0
- package/vendor/dragonlab/design/roady/v2/character.mjs +69 -0
- package/vendor/dragonlab/design/roady/v3/character.mjs +91 -0
- package/vendor/dragonlab/design/roady/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/roady/v3/watch.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v3/weaver-master.svg +57 -0
- package/vendor/dragonlab/design/weaver/v4/character.mjs +56 -0
- package/vendor/dragonlab/design/weaver/v5/character.mjs +52 -0
- package/vendor/dragonlab/design/weaver/v6/character.mjs +88 -0
- package/vendor/dragonlab/design/weaver/v6/review.mjs +35 -0
- package/vendor/dragonlab/design/weaver/v6/watch.mjs +56 -0
- package/vendor/dragonlab/integration/companions-v1/architect.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/archivist.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/argus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +86 -0
- package/vendor/dragonlab/integration/companions-v1/daedalus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/janus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/roady.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +4 -1
- package/vendor/dragonlab/integration/companions-v1/weaver.mjs +2 -0
package/lib/commands/admin.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/lib/commands/calendar.js
CHANGED
|
@@ -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
|
package/lib/commands/chat.js
CHANGED
|
@@ -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);
|
package/lib/commands/contacts.js
CHANGED
|
@@ -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
|
|
package/lib/commands/fleet.js
CHANGED
|
@@ -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
|
+
}
|