quoxcli 0.3.1 → 0.5.0
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 +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +27 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +295 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +47 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +61 -7
- package/lib/commands/approvals.js +198 -3
- package/lib/commands/ask.js +4 -2
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/blog.js +110 -0
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +142 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +7 -33
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +155 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +1123 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +318 -0
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +53 -3
- package/lib/commands/offload.js +2 -3
- package/lib/commands/org.js +21 -4
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +87 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +20 -2
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +150 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +24 -5
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/vm.js +2 -2
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +37 -3
- package/lib/core/commandRegistrars.js +17 -0
- package/lib/core/config.js +29 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +136 -8
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +73 -7
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +8 -8
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +146 -0
- package/lib/quoxon/api.js +364 -27
- package/lib/quoxon/app.js +1629 -183
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +64 -8
- package/lib/quoxon/approvalSentence.js +48 -0
- package/lib/quoxon/approvalsView.js +124 -7
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +22 -3
- package/lib/quoxon/commands.js +110 -15
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +285 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +160 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +67 -10
- package/lib/quoxon/extensions.js +636 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +182 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +4 -4
- package/lib/quoxon/loopCard.js +460 -0
- package/lib/quoxon/loopCards.js +189 -0
- package/lib/quoxon/loopStream.js +98 -0
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +29 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +49 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +8 -3
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +187 -41
- package/lib/quoxon/roster.js +36 -17
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +139 -2
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +23 -2
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +31 -4
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +34 -3
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +81 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +4 -2
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compliance commands: scheduled compliance report jobs.
|
|
3
|
+
*
|
|
4
|
+
* `compliance jobs list` calls GET /compliance/jobs (services/collector/server.js),
|
|
5
|
+
* the same route the compliance_list_jobs agent tool uses. Jobs can carry an
|
|
6
|
+
* optional name (set via the compliance_schedule_job tool / POST body `name`).
|
|
7
|
+
*
|
|
8
|
+
* `compliance reports list|show` read GET /compliance/reports and /compliance/reports/:id, the
|
|
9
|
+
* routes compliance_get_reports reads (org-scoped by the caller's session). The ORG column is
|
|
10
|
+
* always shown: a row with no org (org_id null) is printed as `none`, never hidden.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatOutput, formatDetail } from '../core/output.js';
|
|
14
|
+
import { resolveOrgId } from './helpers.js';
|
|
15
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
16
|
+
|
|
17
|
+
export function registerComplianceCommands(program, ctx) {
|
|
18
|
+
const { client, auth } = ctx;
|
|
19
|
+
|
|
20
|
+
const compliance = program
|
|
21
|
+
.command('compliance')
|
|
22
|
+
.description('Compliance: generated reports and scheduled report jobs');
|
|
23
|
+
|
|
24
|
+
const jobs = compliance
|
|
25
|
+
.command('jobs')
|
|
26
|
+
.description('Scheduled compliance report jobs');
|
|
27
|
+
|
|
28
|
+
jobs
|
|
29
|
+
.command('list')
|
|
30
|
+
.description('List scheduled compliance report jobs (with NAME)')
|
|
31
|
+
.option('--status <status>', 'filter by status (active|paused|error)')
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
auth.requireAuth();
|
|
34
|
+
const orgId = resolveOrgId(auth, program);
|
|
35
|
+
const ep = resolveEndpoint('compliance.jobs.list');
|
|
36
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
|
|
37
|
+
let rows = data.jobs || [];
|
|
38
|
+
if (options.status) rows = rows.filter((j) => j.status === options.status);
|
|
39
|
+
formatOutput({
|
|
40
|
+
columns: ['id', 'name', 'format', 'schedule_interval', 'status', 'next_run'],
|
|
41
|
+
headers: ['ID', 'NAME', 'Format', 'Interval', 'Status', 'Next run'],
|
|
42
|
+
rows: rows.map((j) => ({ ...j, name: j.name || '-' })),
|
|
43
|
+
}, { format: program.opts().output, emptyMessage: 'No compliance jobs found.', raw: rows });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const reports = compliance
|
|
47
|
+
.command('reports')
|
|
48
|
+
.description('Generated compliance reports (read only)');
|
|
49
|
+
|
|
50
|
+
const scoreOf = (r) => (r.summary && typeof r.summary === 'object' && r.summary.overallScore != null ? r.summary.overallScore : '-');
|
|
51
|
+
|
|
52
|
+
reports
|
|
53
|
+
.command('list')
|
|
54
|
+
.description('List generated compliance reports: framework, period, score, sampled evidence, ORG')
|
|
55
|
+
.option('--format <format>', 'only this framework (soc2, hipaa, gdpr, iso27001, euAiAct, iso42001, nistAiRmf)')
|
|
56
|
+
.option('--limit <n>', 'max rows', '20')
|
|
57
|
+
.action(async (options) => {
|
|
58
|
+
auth.requireAuth();
|
|
59
|
+
const orgId = resolveOrgId(auth, program);
|
|
60
|
+
const ep = resolveEndpoint('compliance.reports.list');
|
|
61
|
+
const query = { org_id: orgId, limit: String(options.limit || 20) };
|
|
62
|
+
if (options.format) query.format = options.format;
|
|
63
|
+
const data = await client.get(ep.path, { service: ep.service, query });
|
|
64
|
+
let rows = data.reports || [];
|
|
65
|
+
// the route does not filter by format, so the flag is applied here too
|
|
66
|
+
if (options.format) rows = rows.filter((r) => r.format === options.format);
|
|
67
|
+
formatOutput({
|
|
68
|
+
columns: ['id', 'format', 'generated_at', 'period_days', 'score', 'status', 'sample', 'org'],
|
|
69
|
+
headers: ['ID', 'Format', 'Generated', 'Days', 'Score', 'Status', 'Evidence sample', 'ORG'],
|
|
70
|
+
rows: rows.map((r) => ({
|
|
71
|
+
...r,
|
|
72
|
+
period_days: r.period_days ?? '-',
|
|
73
|
+
score: scoreOf(r),
|
|
74
|
+
status: (r.summary && r.summary.overallStatus) || '-',
|
|
75
|
+
sample: (r.summary && Array.isArray(r.summary.evidenceSample) && r.summary.evidenceSample.length) ? r.summary.evidenceSample.join(' ') : '-',
|
|
76
|
+
org: r.org_id || 'none',
|
|
77
|
+
})),
|
|
78
|
+
}, { format: program.opts().output, emptyMessage: 'No compliance reports found.', raw: rows });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
reports
|
|
82
|
+
.command('show <id>')
|
|
83
|
+
.description('Show one generated report: framework, period, score, per-control status, sampled evidence')
|
|
84
|
+
.action(async (id) => {
|
|
85
|
+
auth.requireAuth();
|
|
86
|
+
const orgId = resolveOrgId(auth, program);
|
|
87
|
+
const ep = resolveEndpoint('compliance.reports.get', { id });
|
|
88
|
+
const data = await client.get(ep.path, { service: ep.service, query: { org_id: orgId } });
|
|
89
|
+
const r = data.report || data;
|
|
90
|
+
const fmt = program.opts().output;
|
|
91
|
+
if (fmt && fmt !== 'table') { formatDetail(r, { format: fmt }); return; }
|
|
92
|
+
const s = (r.summary && typeof r.summary === 'object') ? r.summary : {};
|
|
93
|
+
const body = (r.report_data && typeof r.report_data === 'object') ? r.report_data : {};
|
|
94
|
+
const lines = [
|
|
95
|
+
`Report ${r.id}`,
|
|
96
|
+
`Framework ${s.framework || body.framework || r.format}`,
|
|
97
|
+
`Org ${r.org_id || 'none'}`,
|
|
98
|
+
`Period ${r.period_start || '-'} .. ${r.period_end || '-'} (${r.period_days ?? '-'} days)`,
|
|
99
|
+
`Score ${s.overallScore ?? body.overallScore ?? '-'} status ${s.overallStatus || body.overallStatus || '-'}`,
|
|
100
|
+
`Evidence ${Array.isArray(s.evidenceSample) && s.evidenceSample.length ? s.evidenceSample.join(' ') : '-'}`,
|
|
101
|
+
];
|
|
102
|
+
const sections = body.sections && typeof body.sections === 'object' ? body.sections : {};
|
|
103
|
+
for (const [key, sec] of Object.entries(sections)) {
|
|
104
|
+
lines.push('', `${key} ${sec && sec.name ? sec.name : ''}`.trimEnd());
|
|
105
|
+
for (const c of (sec && Array.isArray(sec.controls) ? sec.controls : [])) lines.push(` ${String(c.id || '-').padEnd(10)} ${String(c.status || '-').padEnd(16)} ${c.name || ''}`);
|
|
106
|
+
}
|
|
107
|
+
for (const l of lines) console.log(l);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quox connect <url>: point a profile at a QuoxCORE server on another machine and sign in (ONBOARD-P1).
|
|
3
|
+
*
|
|
4
|
+
* Probes <url>/health, and when the server answers with its own certificate shows its SHA256 fingerprint and,
|
|
5
|
+
* after a yes (--trust-cert, or the y/N), stores that certificate for THAT PROFILE ONLY. It is never the global
|
|
6
|
+
* NODE_EXTRA_CA_CERTS and TLS checks are never turned off. Then hands to `quox -p <profile> login`.
|
|
7
|
+
* One implementation with the TUI's /connect server (lib/quoxon/onboarding/connectServer.js).
|
|
8
|
+
*/
|
|
9
|
+
import { announce, announceOnFail } from '../core/announce.js';
|
|
10
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
11
|
+
import { confirmInteractive } from '../core/confirm.js';
|
|
12
|
+
import { profileNameForUrl } from '../core/profileTrust.js';
|
|
13
|
+
import { loadConfig } from '../core/config.js';
|
|
14
|
+
|
|
15
|
+
/** H1: no terminal and no pin: the safer non-interactive choice is the fingerprint pin, --trust-cert the alternative. */
|
|
16
|
+
export const noTerminalTrustMessage = () => 'No terminal to answer y/N here. Read the certificate fingerprint above, then re-run with --fingerprint <sha256> to trust only that exact certificate (safer), or --trust-cert to trust whatever this server presents.';
|
|
17
|
+
|
|
18
|
+
export function registerConnectCommand(program, ctx) {
|
|
19
|
+
program
|
|
20
|
+
.command('connect')
|
|
21
|
+
.description('Connect to a Quox server on another machine: probe it, trust its certificate for one profile, sign in')
|
|
22
|
+
.argument('<url>', 'the server address, for example https://192.0.2.10:3000')
|
|
23
|
+
.option('--profile <name>', 'profile to create or update (default: named from the address; -p also works)')
|
|
24
|
+
.option('--trust-cert', "trust the server's own certificate for this profile without asking (its fingerprint is still printed)")
|
|
25
|
+
.option('--fingerprint <sha256>', "trust the server's own certificate only if its SHA256 fingerprint is exactly this (colons optional); implies --trust-cert for that one certificate")
|
|
26
|
+
.option('--email <email>', 'sign in with this email without a prompt (use with --password-stdin to run with no terminal)')
|
|
27
|
+
.option('--password-stdin', 'read the password from stdin, one line (never an argument)')
|
|
28
|
+
.addHelpText('after', '\nA new QuoxCORE serves its own self-signed certificate. Compare the SHA256 printed here with the one `quox server status` shows on the server before you trust it. The certificate is stored for the one profile; TLS checks are never turned off.')
|
|
29
|
+
.action(announceOnFail('quox', (url) => `connect ${url}`, async (url, options, cmd) => {
|
|
30
|
+
const { connectServer } = await import('../quoxon/onboarding/connectServer.js');
|
|
31
|
+
const root = cmd.parent?.opts?.() || {};
|
|
32
|
+
const profile = options.profile || root.profile || profileNameForUrl(url);
|
|
33
|
+
// H1: the password comes from stdin only, never argv.
|
|
34
|
+
let password = null;
|
|
35
|
+
if (options.passwordStdin) {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
38
|
+
password = Buffer.concat(chunks).toString('utf8').split('\n')[0].replace(/\r$/, '');
|
|
39
|
+
if (!password) throw new QuoxUserError('--password-stdin given but stdin was empty. Pipe the password in, for example: printf %s "$PW" | quox connect <url> --email <email> --password-stdin', { exitCode: 2, code: 'empty_password' });
|
|
40
|
+
}
|
|
41
|
+
const email = options.email ? String(options.email).trim() : null;
|
|
42
|
+
const pin = options.fingerprint ? String(options.fingerprint).replace(/[:\s]/g, '').toUpperCase() : null;
|
|
43
|
+
const trust = async (card, peer) => {
|
|
44
|
+
if (pin) {
|
|
45
|
+
const got = String(peer?.chain?.[0]?.fingerprint256 || '').replace(/:/g, '').toUpperCase();
|
|
46
|
+
if (got === pin) return true;
|
|
47
|
+
throw new QuoxUserError(`The certificate ${url} presents has SHA256 ${peer?.chain?.[0]?.fingerprint256 || '(unreadable)'}, not the --fingerprint you gave. Nothing was saved.`, { exitCode: 2, code: 'fingerprint_mismatch' });
|
|
48
|
+
}
|
|
49
|
+
if (options.trustCert) return true;
|
|
50
|
+
if (!process.stdin.isTTY) throw new QuoxUserError(noTerminalTrustMessage(), { exitCode: 2, code: 'no_terminal' });
|
|
51
|
+
return confirmInteractive(`Trust this server's certificate for profile "${profile}" only?`, { flag: '--trust-cert', purpose: 'trust it' });
|
|
52
|
+
};
|
|
53
|
+
// loadConfig, not ctx.config: fresh each time, because the sign-in runs in a child process and writes the file.
|
|
54
|
+
// ctx.connectOverrides is for tests only.
|
|
55
|
+
const r = await connectServer(url, { profile, trust, email, password, loadConfigImpl: loadConfig, ...(ctx.connectOverrides || {}) });
|
|
56
|
+
if (r.action === 'connected') {
|
|
57
|
+
announce('quox', `connected ${url}`, { result: `profile ${profile}` });
|
|
58
|
+
console.log(`Connected as profile "${profile}". Use it with: quox -p ${profile} <command>. Make it your default with: quox config profile use ${profile}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// H1: no terminal and no way to sign in: say which flags are missing, exit 2 (not 130, which means "cancelled").
|
|
62
|
+
const missing = [!email && '--email <email>', !password && '--password-stdin'].filter(Boolean);
|
|
63
|
+
if (!process.stdin.isTTY && missing.length && r.action !== 'connected') {
|
|
64
|
+
throw new QuoxUserError(`No terminal to sign in with. Re-run with ${missing.join(' and ')}; nothing was left saved.`, { exitCode: 2, code: 'no_terminal' });
|
|
65
|
+
}
|
|
66
|
+
if (r.action === 'connect-cancelled') { announce('quox', `connect ${url}`, { result: 'cancelled', status: 'warn' }); process.exitCode = 130; return; }
|
|
67
|
+
throw new QuoxUserError(`Could not connect to ${url}. See the message above; nothing was left saved.`, { exitCode: 1, code: 'connect_failed' });
|
|
68
|
+
}));
|
|
69
|
+
}
|
package/lib/commands/contacts.js
CHANGED
|
@@ -14,6 +14,7 @@ import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
|
|
|
14
14
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
15
15
|
import { runSelftest } from './contactsSelftest.js';
|
|
16
16
|
import { resolveOrgId } from './helpers.js';
|
|
17
|
+
import { atToSendAt } from './contactsSendAt.js';
|
|
17
18
|
|
|
18
19
|
const KINDS = ['person', 'organisation'];
|
|
19
20
|
const REASONS = ['dnc', 'do_not_market', 'bounced'];
|
|
@@ -38,6 +39,8 @@ async function call(fn, { forbidden } = {}) {
|
|
|
38
39
|
return await fn();
|
|
39
40
|
} catch (e) {
|
|
40
41
|
if (e instanceof QuoxApiError) {
|
|
42
|
+
// A refusal that says why (scheduled/immediate send) is shown as said, never swapped for a generic line.
|
|
43
|
+
if ((e.status === 403 || e.status === 409) && e.body && typeof e.body.reason === 'string' && e.body.reason) throw new QuoxError(e.body.reason, 1);
|
|
41
44
|
if (e.status === 403 && forbidden) throw new QuoxError(forbidden, 1);
|
|
42
45
|
if (e.status === 404 && e.body && e.body.error === 'tombstone_not_found') {
|
|
43
46
|
throw new QuoxError('No erasure tombstone exists for that identifier in this org.', 1);
|
|
@@ -109,12 +112,15 @@ export function registerContactsCommands(program, ctx) {
|
|
|
109
112
|
if (options.status) query.status = options.status;
|
|
110
113
|
if (options.relationship) query.relationship = options.relationship;
|
|
111
114
|
if (options.limit !== undefined) query.limit = Number(options.limit);
|
|
115
|
+
if (options.offset !== undefined) query.offset = Number(options.offset);
|
|
112
116
|
const ep = resolveEndpoint('contactsq.contacts.list');
|
|
113
117
|
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
114
118
|
if (isJson(program)) return printJson(data);
|
|
115
119
|
const rows = (data.contacts || []).map(cardRow);
|
|
116
120
|
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows }, { format: program.opts().output });
|
|
117
|
-
|
|
121
|
+
const total = Number.isInteger(data.total) ? data.total : rows.length;
|
|
122
|
+
const next = (data.offset || 0) + rows.length;
|
|
123
|
+
announce('contacts list', next < total ? `${rows.length} of ${total} contacts, next: --offset ${next}` : `${rows.length} of ${total} contacts`);
|
|
118
124
|
};
|
|
119
125
|
|
|
120
126
|
addNameOptions(contacts
|
|
@@ -186,9 +192,10 @@ export function registerContactsCommands(program, ctx) {
|
|
|
186
192
|
.command('list')
|
|
187
193
|
.description('List contacts')
|
|
188
194
|
.option('--q <text>', 'name filter')
|
|
189
|
-
.option('--status <s>', 'active | candidate | archived')
|
|
195
|
+
.option('--status <s>', 'active | candidate | archived | all (default: everyone except archived)')
|
|
190
196
|
.option('--relationship <r>', 'primary relationship, e.g. customer | supplier | partner')
|
|
191
|
-
.option('--limit <n>', 'max
|
|
197
|
+
.option('--limit <n>', 'page size (default 25, max 100)')
|
|
198
|
+
.option('--offset <n>', 'skip this many (paging: the footer prints the next --offset)')
|
|
192
199
|
.action((options) => list(options));
|
|
193
200
|
|
|
194
201
|
contacts
|
|
@@ -480,7 +487,7 @@ export function registerContactsCommands(program, ctx) {
|
|
|
480
487
|
.description('Dial a contact by id through the phone bridge; you never see or type the number')
|
|
481
488
|
.argument('<id>', 'contact id (find it with `quox contacts search`)')
|
|
482
489
|
.option('--reason <text>', 'what the call is about (the agent opens with it)')
|
|
483
|
-
.option('--purpose <p>', 'service | transactional | marketing | research', 'service')
|
|
490
|
+
.option('--purpose <p>', 'service | transactional | marketing | research | personal', 'service')
|
|
484
491
|
.action(async (id, options) => {
|
|
485
492
|
auth.requireAuth();
|
|
486
493
|
const body = { contact_id: id, projection: 'dial', channel: 'voice', purpose: options.purpose };
|
|
@@ -495,6 +502,80 @@ export function registerContactsCommands(program, ctx) {
|
|
|
495
502
|
announce('contacts call', `${l.id || '?'} queued for the phone bridge`);
|
|
496
503
|
});
|
|
497
504
|
|
|
505
|
+
// ---- send: SMS or email now or at a time (the server delivers; you approve by running it) ----
|
|
506
|
+
const DEFAULT_TZ = 'Europe/London';
|
|
507
|
+
async function resolveContactArg(arg) {
|
|
508
|
+
if (/^ct_[a-z0-9_]+$/i.test(arg)) return arg;
|
|
509
|
+
const ep = resolveEndpoint('contactsq.search');
|
|
510
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: { q: arg } }));
|
|
511
|
+
const c = data.candidates || [];
|
|
512
|
+
if (!c.length) throw new QuoxError(`No contact matches "${arg}". Try \`quox contacts search\`.`, 1);
|
|
513
|
+
if (data.ambiguous || c.length > 1) {
|
|
514
|
+
throw new QuoxError(`"${arg}" matches more than one contact (${c.slice(0, 5).map((x) => `${x.display_name} ${x.id}`).join('; ')}). Pass the contact id.`, 1);
|
|
515
|
+
}
|
|
516
|
+
return c[0].id;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
contacts
|
|
520
|
+
.command('send')
|
|
521
|
+
.description('Send an SMS or email to a contact now, or at a time with --at (the server delivers it; the answer says exactly when)')
|
|
522
|
+
.argument('<contact>', 'contact id (ct_...) or an unambiguous name')
|
|
523
|
+
.argument('<text>', 'the message')
|
|
524
|
+
.option('--channel <c>', 'sms | email', 'sms')
|
|
525
|
+
.option('--at <time>', 'send later: ISO time with offset, or a clock time like 10am / 14:30 (next occurrence, in --tz)')
|
|
526
|
+
.option('--tz <zone>', 'IANA zone for --at', DEFAULT_TZ)
|
|
527
|
+
.option('--title <t>', 'subject (email)')
|
|
528
|
+
.option('--purpose <p>', 'service | transactional | marketing | research | personal', 'service')
|
|
529
|
+
.option('--reason <text>', 'why (recorded as a hash only)')
|
|
530
|
+
.action(async (contactArg, text, options) => {
|
|
531
|
+
auth.requireAuth();
|
|
532
|
+
checkEnum(options.channel, ['sms', 'email'], '--channel');
|
|
533
|
+
checkEnum(options.purpose, ['service', 'transactional', 'marketing', 'research', 'personal'], '--purpose');
|
|
534
|
+
const contactId = await resolveContactArg(contactArg);
|
|
535
|
+
const body = { contact_id: contactId, channel: options.channel, message: text, purpose: options.purpose };
|
|
536
|
+
if (options.title) body.title = options.title;
|
|
537
|
+
if (options.reason) body.reason = options.reason;
|
|
538
|
+
if (options.at) { body.send_at = atToSendAt(options.at, options.tz); body.timezone = options.tz; }
|
|
539
|
+
const ep = resolveEndpoint('contactsq.sends.create');
|
|
540
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body, retries: 0 }), {
|
|
541
|
+
forbidden: 'Blocked by contact policy, or you are not a human user. Run `quox contacts best-channel <id>` to see why.',
|
|
542
|
+
});
|
|
543
|
+
if (isJson(program)) return printJson(data);
|
|
544
|
+
if (data.job) {
|
|
545
|
+
formatDetail({ job: data.job.id, status: data.job.status, channel: data.job.channel, when: data.job.when, send_at: data.job.send_at });
|
|
546
|
+
return announce('contacts send', `scheduled ${data.job.id}: ${data.job.when}`);
|
|
547
|
+
}
|
|
548
|
+
if (data.status === 'sent') { formatDetail({ lease: data.lease_id, status: 'sent' }); return announce('contacts send', `${options.channel} sent`); }
|
|
549
|
+
formatDetail({ lease: data.lease_id, status: data.status, error: data.error, reason: data.reason });
|
|
550
|
+
announce('contacts send', `FAILED: ${data.reason || data.error}`);
|
|
551
|
+
process.exitCode = 1;
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const scheduled = contacts.command('scheduled').description('Scheduled sends: what is queued, and cancel one before it goes out');
|
|
555
|
+
scheduled
|
|
556
|
+
.command('list')
|
|
557
|
+
.option('--status <s>', 'comma list of pending,sending,sent,failed,cancelled (default pending,sending)')
|
|
558
|
+
.action(async (options) => {
|
|
559
|
+
auth.requireAuth();
|
|
560
|
+
const ep = resolveEndpoint('contactsq.sends.scheduled');
|
|
561
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: options.status ? { status: options.status } : {} }), { forbidden: 'Scheduled sends are for human users.' });
|
|
562
|
+
if (isJson(program)) return printJson(data);
|
|
563
|
+
const rows = (data.jobs || []).map((j) => ({ id: j.id, contact: j.contact_id, channel: j.channel, status: j.status, when: j.when || j.finished_at || '', outcome: j.outcome_reason || j.outcome_code || '' }));
|
|
564
|
+
formatOutput({ columns: ['id', 'contact', 'channel', 'status', 'when', 'outcome'], rows }, { format: program.opts().output });
|
|
565
|
+
announce('contacts scheduled list', `${rows.length} jobs`);
|
|
566
|
+
});
|
|
567
|
+
scheduled
|
|
568
|
+
.command('cancel')
|
|
569
|
+
.argument('<id>', 'scheduled send id (ss_...)')
|
|
570
|
+
.action(async (id) => {
|
|
571
|
+
auth.requireAuth();
|
|
572
|
+
const ep = resolveEndpoint('contactsq.sends.cancel', { id });
|
|
573
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: {} }), { forbidden: 'Cancelling a scheduled send is for human users.' });
|
|
574
|
+
if (isJson(program)) return printJson(data);
|
|
575
|
+
formatDetail({ job: data.job.id, status: data.job.status });
|
|
576
|
+
announce('contacts scheduled cancel', data.job.id);
|
|
577
|
+
});
|
|
578
|
+
|
|
498
579
|
contacts
|
|
499
580
|
.command('leases')
|
|
500
581
|
.description('Lease + use ledger for a contact (who was given access, when, why; never the values)')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { QuoxError } from '../core/errors.js';
|
|
2
|
+
|
|
3
|
+
/** "10am", "10:30", "22:15" -> the next such wall-clock time in tz as a bare local ISO the server reads in that zone. ISO input passes through. */
|
|
4
|
+
export function atToSendAt(at, tz, nowMs = Date.now()) {
|
|
5
|
+
const raw = String(at).trim();
|
|
6
|
+
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(raw) || /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) return raw;
|
|
7
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i.exec(raw);
|
|
8
|
+
if (!m) throw new QuoxError(`--at must be an ISO time (2026-10-25T10:00:00+00:00) or a clock time such as 10am or 14:30, got "${raw}"`, 2);
|
|
9
|
+
let h = Number(m[1]);
|
|
10
|
+
const mi = Number(m[2] || 0);
|
|
11
|
+
if (m[3]) { if (h < 1 || h > 12) throw new QuoxError('--at hour must be 1 to 12 with am/pm', 2); h = (h % 12) + (m[3].toLowerCase() === 'pm' ? 12 : 0); }
|
|
12
|
+
if (h > 23 || mi > 59) throw new QuoxError('--at is not a valid clock time', 2);
|
|
13
|
+
const parts = (ms) => Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
|
14
|
+
const n = parts(nowMs);
|
|
15
|
+
const pad = (v) => String(v).padStart(2, '0');
|
|
16
|
+
let day = Date.UTC(Number(n.year), Number(n.month) - 1, Number(n.day));
|
|
17
|
+
if (h * 60 + mi <= Number(n.hour) * 60 + Number(n.minute)) day += 86400000; // already passed today: tomorrow
|
|
18
|
+
const d = new Date(day);
|
|
19
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(h)}:${pad(mi)}`;
|
|
20
|
+
}
|
|
21
|
+
|
package/lib/commands/dev.js
CHANGED
|
@@ -325,14 +325,12 @@ export function registerDevCommands(program, ctx) {
|
|
|
325
325
|
printInfo(`Terms hash: ${offer.termsSha256} (presentation ${offer.presentationVersion})`);
|
|
326
326
|
|
|
327
327
|
if (!options.iAccept) {
|
|
328
|
-
const {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}]);
|
|
335
|
-
if (!ans.confirm) {
|
|
328
|
+
const { confirmInteractive } = await import('../core/confirm.js');
|
|
329
|
+
const accepted = await confirmInteractive(
|
|
330
|
+
`Do you accept these terms, on behalf of your organisation, for exactly this pack content?`,
|
|
331
|
+
{ flag: '--i-accept', purpose: 'accept them as yourself' },
|
|
332
|
+
);
|
|
333
|
+
if (!accepted) {
|
|
336
334
|
printInfo('Not accepted. Nothing was recorded.');
|
|
337
335
|
return;
|
|
338
336
|
}
|
package/lib/commands/doctor.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
11
14
|
import { SERVICE_PORTS } from '../core/config.js';
|
|
12
15
|
import { emitCliEvent } from '../core/aee.js';
|
|
13
16
|
import { resolveCliVersion, checkCliDrift } from '../core/version.js';
|
|
@@ -20,7 +23,7 @@ const CHECKS = [
|
|
|
20
23
|
{ name: 'QuoxFlow', service: 'quoxflow', port: 3200, path: '/health' },
|
|
21
24
|
{ name: 'Files service', service: 'files', port: 3102, path: '/health' },
|
|
22
25
|
// External services
|
|
23
|
-
{ name: 'QuoxAgent daemon', port: 9847, path: '/health', url: 'http://localhost:9847' },
|
|
26
|
+
{ name: 'QuoxAgent daemon', port: 9847, path: '/health', url: 'http://localhost:9847', optionalUnlessEnrolled: true },
|
|
24
27
|
{ name: 'Qdrant vectors', port: 6333, path: '/', url: 'http://localhost:6333' },
|
|
25
28
|
];
|
|
26
29
|
|
|
@@ -40,11 +43,20 @@ async function checkEndpoint(url, path, timeoutMs = 3000) {
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* F2 (ONBOARD-P1): is a QuoxAgent installed/enrolled on THIS machine? Its config is the evidence: the system
|
|
48
|
+
* install writes /etc/quoxagent/quoxagent.yaml, a user install ~/.quoxagent/. Absent both, nothing is enrolled
|
|
49
|
+
* and a daemon that is not running is the normal state of a single-box install, not a critical.
|
|
50
|
+
*/
|
|
51
|
+
export function defaultAgentEnrolled({ exists = existsSync, home = homedir() } = {}) {
|
|
52
|
+
return ['/etc/quoxagent/quoxagent.yaml', join(home, '.quoxagent', 'quoxagent.yaml'), join(home, '.quoxagent', 'config.yaml')].some((p) => exists(p));
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
/**
|
|
44
56
|
* Every doctor check, in this process: services, auth token, CLI version. Shared by `quox doctor` and the
|
|
45
57
|
* conversation's /doctor so the two can never disagree. -> [{ name, status, detail?, latency_ms?, ... }].
|
|
46
58
|
*/
|
|
47
|
-
export async function runDoctorChecks({ config }) {
|
|
59
|
+
export async function runDoctorChecks({ config, agentEnrolled = defaultAgentEnrolled } = {}) {
|
|
48
60
|
// Check all services in parallel — per-item try/catch so one failure doesn't lose all results
|
|
49
61
|
const checks = await Promise.all(
|
|
50
62
|
CHECKS.map(async (check) => {
|
|
@@ -71,6 +83,9 @@ export async function runDoctorChecks({ config }) {
|
|
|
71
83
|
}
|
|
72
84
|
}
|
|
73
85
|
}
|
|
86
|
+
if (result.status === 'fail' && check.optionalUnlessEnrolled && !agentEnrolled()) {
|
|
87
|
+
result = { status: 'skip', latency_ms: null, detail: 'optional: not running, and no QuoxAgent is enrolled on this machine (install one with the fleet enrol steps if you want a fleet)' };
|
|
88
|
+
}
|
|
74
89
|
return {
|
|
75
90
|
name: check.name,
|
|
76
91
|
port: check.port,
|
|
@@ -130,6 +145,7 @@ export function registerDoctorCommand(program, ctx) {
|
|
|
130
145
|
program
|
|
131
146
|
.command('doctor')
|
|
132
147
|
.description('Run platform health diagnostics')
|
|
148
|
+
.addHelpText('after', '\nAdd --json for machine output: { checks: [...], summary: { passed, warnings, failed } }.')
|
|
133
149
|
.action(async (opts, cmd) => {
|
|
134
150
|
const outputFormat = cmd.parent?.opts()?.output || 'table';
|
|
135
151
|
const isJson = outputFormat === 'json' || cmd.parent?.opts()?.json;
|
|
@@ -140,9 +156,10 @@ export function registerDoctorCommand(program, ctx) {
|
|
|
140
156
|
const passed = checks.filter(c => c.status === 'pass').length;
|
|
141
157
|
const warnings = checks.filter(c => c.status === 'warn').length;
|
|
142
158
|
const failed = checks.filter(c => c.status === 'fail').length;
|
|
159
|
+
const skipped = checks.filter(c => c.status === 'skip').length;
|
|
143
160
|
|
|
144
161
|
if (isJson) {
|
|
145
|
-
console.log(JSON.stringify({ checks, summary: { passed, warnings, failed } }, null, 2));
|
|
162
|
+
console.log(JSON.stringify({ checks, summary: { passed, warnings, failed, skipped } }, null, 2));
|
|
146
163
|
return;
|
|
147
164
|
}
|
|
148
165
|
|
|
@@ -151,6 +168,7 @@ export function registerDoctorCommand(program, ctx) {
|
|
|
151
168
|
for (const c of checks) {
|
|
152
169
|
const icon = c.status === 'pass' ? chalk.green(' ✓')
|
|
153
170
|
: c.status === 'warn' ? chalk.yellow(' ⚠')
|
|
171
|
+
: c.status === 'skip' ? chalk.dim(' ·')
|
|
154
172
|
: chalk.red(' ✗');
|
|
155
173
|
|
|
156
174
|
const latency = c.latency_ms != null ? chalk.dim(` (${c.latency_ms}ms)`) : '';
|
|
@@ -165,6 +183,7 @@ export function registerDoctorCommand(program, ctx) {
|
|
|
165
183
|
if (passed) parts.push(chalk.green(`${passed} passed`));
|
|
166
184
|
if (warnings) parts.push(chalk.yellow(`${warnings} warning${warnings > 1 ? 's' : ''}`));
|
|
167
185
|
if (failed) parts.push(chalk.red(`${failed} critical`));
|
|
186
|
+
if (skipped) parts.push(chalk.dim(`${skipped} optional, not set up`));
|
|
168
187
|
console.log(` ${parts.join(' ')}`);
|
|
169
188
|
console.log('');
|
|
170
189
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* quox engagement create <name> --environment-tier <tier> [--risk-ceiling <tier>]
|
|
5
5
|
* quox engagement list
|
|
6
6
|
* quox engagement show <id>
|
|
7
|
+
* quox engagement verify <id>
|
|
7
8
|
* quox engagement add-target <id> <type> <value> [--verification-method <method>]
|
|
8
9
|
* quox engagement verify-target <id> <targetId>
|
|
9
10
|
*
|
|
@@ -20,6 +21,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
20
21
|
import { formatOutput, formatDetail, printSuccess, printInfo } from '../core/output.js';
|
|
21
22
|
import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
|
|
22
23
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
24
|
+
import { gatherEvidence, evidenceChanged } from '../core/findingReconcile.js';
|
|
23
25
|
|
|
24
26
|
export function registerEngagementCommands(program, ctx) {
|
|
25
27
|
const { client, auth } = ctx;
|
|
@@ -80,6 +82,31 @@ export function registerEngagementCommands(program, ctx) {
|
|
|
80
82
|
}, { format: program.opts().output, emptyMessage: 'No targets added yet.' });
|
|
81
83
|
});
|
|
82
84
|
|
|
85
|
+
engagement
|
|
86
|
+
.command('verify <id>')
|
|
87
|
+
.description('Verify the WARD witness for an engagement (recompute receipt + Ed25519 tip signature)')
|
|
88
|
+
.action(async (id) => {
|
|
89
|
+
auth.requireAuth();
|
|
90
|
+
const ep = resolveEndpoint('engagement.verify', { id });
|
|
91
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
92
|
+
if (program.opts().output === 'json') {
|
|
93
|
+
console.log(JSON.stringify(data, null, 2));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const ok = data.pass === true;
|
|
97
|
+
console.log(ok ? '✓ WITNESS VERIFIED' : '✗ NOT VERIFIED');
|
|
98
|
+
console.log(` engagement ${data.engagement_id || id}`);
|
|
99
|
+
console.log(` witnessed ${data.witnessed ? 'yes' : 'no'}`);
|
|
100
|
+
console.log(` chain ${data.chainId || '-'}`);
|
|
101
|
+
if (data.seq != null) console.log(` entry #${data.seq}`);
|
|
102
|
+
if (data.chainHash) console.log(` chain hash ${String(data.chainHash).slice(0, 40)}`);
|
|
103
|
+
console.log(` chain recompute ${data.chainHashRecomputed ? 'ok' : 'FAILED'}`);
|
|
104
|
+
const sig = data.signature || {};
|
|
105
|
+
console.log(` tip signature ${sig.status}${sig.keyId ? ` (key ${String(sig.keyId).slice(0, 16)})` : ''}`);
|
|
106
|
+
if (data.signedTip && data.signedTip.tipId) console.log(` signed tip ${data.signedTip.tipId} (seq ${data.signedTip.tipSeq})`);
|
|
107
|
+
if (!ok && data.reason) console.log(` reason ${data.reason}`);
|
|
108
|
+
});
|
|
109
|
+
|
|
83
110
|
engagement
|
|
84
111
|
.command('set-gripe-target <id>')
|
|
85
112
|
.description('Set which repo a finding filed from this engagement goes to, and which org GitHub connector token files it. Lets a customer-site engagement file into the customer repo with the customer org token. Pass --clear (or --repo "") to fall back to the platform default (quoxai/quox) and the engagement own org.')
|
|
@@ -575,6 +602,69 @@ export function registerEngagementCommands(program, ctx) {
|
|
|
575
602
|
printSuccess(`Schedule "${scheduleId}" deleted.`);
|
|
576
603
|
});
|
|
577
604
|
|
|
605
|
+
// SITREP-FINDINGS: reconcile open findings against their issue and fix. Evidence is gathered
|
|
606
|
+
// here (git + gh on this host); the collector decides and keeps the audit trail. Resolves only
|
|
607
|
+
// on a closed issue AND a fix in the running build; a critical is never auto-resolved.
|
|
608
|
+
engagement
|
|
609
|
+
.command('reconcile [findingId]')
|
|
610
|
+
.description('Reconcile open findings against their linked issue and fix commit (landed on main, deployed in the running build). Resolves only when the issue is closed AND the fix is deployed; a critical never auto-resolves. Every change is audited')
|
|
611
|
+
.option('--link <ref...>', 'link evidence: <findingId>=<owner/repo#N | #N | commit-sha> (repeatable)')
|
|
612
|
+
.option('--repo-dir <dir>', 'git checkout to read origin/main from (default: ~/quox-dashboard)')
|
|
613
|
+
.option('--build-dir <dir>', 'the tree the running build was built from (default: ~/.quox-build/quox-dashboard)')
|
|
614
|
+
.option('--issue-repo <owner/repo>', 'repo a bare #N refers to', 'quoxai/quox')
|
|
615
|
+
.option('--no-fetch', 'do not fetch origin/main first')
|
|
616
|
+
.option('--dry-run', 'show the evidence without recording anything')
|
|
617
|
+
.action(async (findingId, options) => {
|
|
618
|
+
auth.requireAuth();
|
|
619
|
+
const { spawnSync } = await import('node:child_process');
|
|
620
|
+
const run = (cmd, args) => {
|
|
621
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 30000 });
|
|
622
|
+
return { ok: r.status === 0, out: String(r.stdout || '') };
|
|
623
|
+
};
|
|
624
|
+
const home = (await import('node:os')).homedir();
|
|
625
|
+
options.repoDir = options.repoDir || `${home}/quox-dashboard`;
|
|
626
|
+
options.buildDir = options.buildDir || `${home}/.quox-build/quox-dashboard`;
|
|
627
|
+
const links = {};
|
|
628
|
+
for (const l of options.link || []) {
|
|
629
|
+
const m = /^(FND_[0-9A-Z]+)=(.+)$/.exec(String(l));
|
|
630
|
+
if (!m) throw new Error(`--link must look like FND_...=<issue or sha>, got "${l}"`);
|
|
631
|
+
const v = m[2].trim();
|
|
632
|
+
links[m[1]] = { ...(links[m[1]] || {}), ...(/^[0-9a-f]{7,40}$/i.test(v) ? { commit: v } : { issue: v }) };
|
|
633
|
+
}
|
|
634
|
+
if (options.fetch !== false) run('git', ['-C', options.repoDir, 'fetch', '--quiet', 'origin', 'main']);
|
|
635
|
+
const ep = resolveEndpoint('engagement.findings.open', {});
|
|
636
|
+
const all = (await client.get(ep.path, { service: ep.service })).findings || [];
|
|
637
|
+
const findings = findingId ? all.filter((f) => f.id === findingId) : all;
|
|
638
|
+
if (findingId && !findings.length) throw new Error(`Finding "${findingId}" is not open in this org`);
|
|
639
|
+
const rows = [];
|
|
640
|
+
for (const f of findings) {
|
|
641
|
+
const ev = gatherEvidence(f, links[f.id] || {}, { repoDir: options.repoDir, buildDir: options.buildDir, issueRepo: options.issueRepo, run });
|
|
642
|
+
const row = { id: f.id, severity: f.severity, status: f.status, issue: ev.issue_ref ? `${ev.issue_ref}${ev.issue_state ? ` (${ev.issue_state})` : ''}` : '',
|
|
643
|
+
fix: ev.fix_commit ? `${ev.fix_commit.slice(0, 9)} ${ev.fix_state}` : '', outcome: '' };
|
|
644
|
+
if (!Object.keys(ev).length) { row.outcome = 'no evidence'; rows.push(row); continue; }
|
|
645
|
+
if (!evidenceChanged(f, ev)) { row.outcome = 'unchanged'; rows.push(row); continue; }
|
|
646
|
+
if (options.dryRun) { row.outcome = 'would record'; rows.push(row); continue; }
|
|
647
|
+
const rep = resolveEndpoint('engagement.findings.reconcile', { findingId: f.id });
|
|
648
|
+
const data = await client.post(rep.path, { service: rep.service, body: ev });
|
|
649
|
+
row.outcome = `${data.outcome}: ${data.reason}`;
|
|
650
|
+
row.status = data.finding?.status || row.status;
|
|
651
|
+
rows.push(row);
|
|
652
|
+
}
|
|
653
|
+
formatOutput({ columns: ['id', 'severity', 'status', 'issue', 'fix', 'outcome'], headers: ['Finding', 'Severity', 'Status', 'Issue', 'Fix', 'Outcome'], rows },
|
|
654
|
+
{ format: program.opts().output, emptyMessage: 'No open findings.' });
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
engagement
|
|
658
|
+
.command('reconciliations <findingId>')
|
|
659
|
+
.description('Show the reconciliation audit trail of one finding (what evidence was recorded, when, by whom, and the outcome)')
|
|
660
|
+
.action(async (findingId) => {
|
|
661
|
+
auth.requireAuth();
|
|
662
|
+
const ep = resolveEndpoint('engagement.findings.reconciliations', { findingId });
|
|
663
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
664
|
+
formatOutput({ columns: ['created_at', 'outcome', 'reason', 'actor', 'evidence'], headers: ['When', 'Outcome', 'Reason', 'Actor', 'Evidence'], rows: data.reconciliations || [] },
|
|
665
|
+
{ format: program.opts().output, emptyMessage: 'No reconciliations recorded.' });
|
|
666
|
+
});
|
|
667
|
+
|
|
578
668
|
engagement
|
|
579
669
|
.command('sync-issues <id>')
|
|
580
670
|
.description('Reconcile this engagement\'s filed findings against their GitHub issue state. A closed issue auto-resolves its finding. Honest no-op if GitHub is not configured')
|