quoxcli 0.4.0 → 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/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- 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 +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- 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/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/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- 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 +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- 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/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -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/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- 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 +1 -1
- package/lib/quoxon/term.js +25 -1
- 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 +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- 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/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/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON (owner 2026-11-05): the render layer for `quox setup license link`.
|
|
3
|
+
*
|
|
4
|
+
* The owner linked the wrong licence twice in one day (one granting 0
|
|
5
|
+
* plugins) because neither the web page nor the CLI said what was being
|
|
6
|
+
* picked. This view frames the code and link, counts the code down live,
|
|
7
|
+
* and on approval names WHAT was linked: licence id, plan, plugins, seats.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure (strings in, strings out) so it is testable without
|
|
10
|
+
* a terminal. Colour comes from the house QuoxGlyph module (lib/core/
|
|
11
|
+
* quoxglyph.js: line grammar, brand gradient, status colours) and the spinner
|
|
12
|
+
* frames from the TUI's house spinner; nothing here invents a parallel style.
|
|
13
|
+
* With colour off every function returns plain lines with no escape codes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { line, setColor, GRAD, STATUS, celebrate } from '../core/quoxglyph.js';
|
|
17
|
+
import { SPIN_FRAMES } from '../quoxon/slashSpinner.js';
|
|
18
|
+
import { motionAllowed } from '../quoxon/motion/clock.js';
|
|
19
|
+
|
|
20
|
+
const R = '\x1b[0m';
|
|
21
|
+
const BOLD = '\x1b[1m';
|
|
22
|
+
const DIM = '\x1b[2m';
|
|
23
|
+
const UNDERLINE = '\x1b[4m';
|
|
24
|
+
const fg = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
|
|
25
|
+
const AMBER = STATUS.warn.rgb;
|
|
26
|
+
const GREEN = STATUS.ok.rgb;
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line no-control-regex
|
|
29
|
+
const ESC = /\x1b\]8;;[^\x07]*\x07|\x1b\[[0-9;]*[A-Za-z]/g;
|
|
30
|
+
/** Visible width: escape codes stripped, one column per code point. */
|
|
31
|
+
const vis = (s) => [...String(s).replace(ESC, '')].length;
|
|
32
|
+
const padTo = (s, w) => s + ' '.repeat(Math.max(0, w - vis(s)));
|
|
33
|
+
|
|
34
|
+
/** Run a QuoxGlyph render with colour forced on or off, then hand control back to auto. */
|
|
35
|
+
function withColor(color, fn) {
|
|
36
|
+
setColor(color);
|
|
37
|
+
try { return fn(); } finally { setColor(null); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Should this terminal get colour? Same rule as quoxglyph, decided for an injected stream. */
|
|
41
|
+
export function colorFor(term, env = process.env) {
|
|
42
|
+
if (env.QUOX_COLOR === '0') return false;
|
|
43
|
+
if (env.QUOX_COLOR === '1' || env.QUOX_COLOR === '2') return true;
|
|
44
|
+
return Boolean(term && term.isTTY) && !env.NO_COLOR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Should the waiting line animate? TTY only, and never under reduced motion. */
|
|
48
|
+
export const animateFor = (term, env = process.env) => motionAllowed(Boolean(term && term.isTTY), env);
|
|
49
|
+
|
|
50
|
+
/** m:ss, floored at 0:00. */
|
|
51
|
+
export function fmtCountdown(ms) {
|
|
52
|
+
const total = Math.max(0, Math.floor(Number(ms) / 1000) || 0);
|
|
53
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** WQXP-7T4K -> "W Q X P - 7 T 4 K". */
|
|
57
|
+
export const spacedCode = (code) => [...String(code || '')].join(' ');
|
|
58
|
+
|
|
59
|
+
/** Paint text in the brand gradient (per visible character), bold. */
|
|
60
|
+
function gradient(text) {
|
|
61
|
+
const chars = [...text];
|
|
62
|
+
const last = Math.max(1, chars.length - 1);
|
|
63
|
+
return BOLD + chars.map((ch, i) => (ch === ' ' ? ch
|
|
64
|
+
: fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + ch)).join('') + R;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ▰▰▰▱▱ in the brand gradient, for a 0..1 ratio. */
|
|
68
|
+
function bar(ratio, width = 10) {
|
|
69
|
+
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
70
|
+
const last = Math.max(1, width - 1);
|
|
71
|
+
let out = '';
|
|
72
|
+
for (let i = 0; i < filled; i++) out += fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + '▰';
|
|
73
|
+
return out + (filled ? R : '') + DIM + '▱'.repeat(width - filled) + R;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** OSC 8 hyperlink whose visible text is the full URL, so it is clickable either way. */
|
|
77
|
+
const hyperlink = (url) => `\x1b]8;;${url}\x07${UNDERLINE}${fg(GRAD[0])}${url}${R}\x1b]8;;\x07`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A rounded frame around rows (already styled). The title sits in the top
|
|
81
|
+
* border. The frame grows to fit its widest row (a URL is never clipped).
|
|
82
|
+
*/
|
|
83
|
+
function frame(rows, { title = '', cols = 80, tint = null } = {}) {
|
|
84
|
+
const b = (s) => (tint ? fg(tint) + s + R : DIM + s + R);
|
|
85
|
+
const titleText = title ? ` ${title} ` : '';
|
|
86
|
+
const inner = Math.max(44, Math.min(Math.max(44, cols - 6), 76), ...rows.map((r) => vis(r) + 4), vis(titleText) + 4);
|
|
87
|
+
const top = b('╭─') + titleText + b('─'.repeat(Math.max(0, inner - 1 - vis(titleText))) + '╮');
|
|
88
|
+
const body = rows.map((r) => b('│') + padTo(` ${r}`, inner) + b('│'));
|
|
89
|
+
const bottom = b('╰' + '─'.repeat(inner) + '╯');
|
|
90
|
+
return [top, ...body, bottom].map((l) => ` ${l}`).join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Greedy wrap of items into lines no wider than `width`, joined by `sep`. */
|
|
94
|
+
function wrapList(items, width, sep = ' · ') {
|
|
95
|
+
const lines = [];
|
|
96
|
+
let cur = '';
|
|
97
|
+
for (const item of items) {
|
|
98
|
+
const next = cur ? cur + sep + item : item;
|
|
99
|
+
if (cur && vis(next) > width) { lines.push(cur); cur = item; } else cur = next;
|
|
100
|
+
}
|
|
101
|
+
if (cur) lines.push(cur);
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const minutes = (ms) => Math.max(1, Math.round(ms / 60000));
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The card shown while the code waits for approval.
|
|
109
|
+
* @param {{userCode:string,url:string,remainingMs:number,color:boolean,cols?:number}} o
|
|
110
|
+
*/
|
|
111
|
+
export function renderLinkCard({ userCode, url, remainingMs, color, cols = 80 }) {
|
|
112
|
+
const header = withColor(color, () => line('quox', 'licence link', { result: 'approve on quox.ai', status: 'busy' }));
|
|
113
|
+
if (!color) {
|
|
114
|
+
return [
|
|
115
|
+
header,
|
|
116
|
+
`Open ${url} and enter this code:`,
|
|
117
|
+
'',
|
|
118
|
+
` ${userCode}`,
|
|
119
|
+
'',
|
|
120
|
+
`The code is good for ${minutes(remainingMs)} minutes.`,
|
|
121
|
+
'Pick the licence you want on quox.ai: this instance gets exactly what you approve.',
|
|
122
|
+
'Waiting for approval...',
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
|
125
|
+
const label = (s) => `${DIM}${s.padEnd(11)}${R}`;
|
|
126
|
+
const rows = [
|
|
127
|
+
'',
|
|
128
|
+
`${label('Your code')}${gradient(spacedCode(userCode))}`,
|
|
129
|
+
'',
|
|
130
|
+
`${label('Open')}${hyperlink(url)}`,
|
|
131
|
+
`${label('Expires in')}${bar(1)} ${BOLD}${fmtCountdown(remainingMs)}${R}`,
|
|
132
|
+
'',
|
|
133
|
+
`${fg(AMBER)}Pick the licence you want on quox.ai.${R}`,
|
|
134
|
+
`${DIM}This instance gets exactly what you approve.${R}`,
|
|
135
|
+
];
|
|
136
|
+
return [header, frame(rows, { title: `${BOLD}Link this instance${R}`, cols })].join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One frame of the live waiting line (TTY only): spinner, label, countdown.
|
|
141
|
+
* @param {{frame:number,remainingMs:number,totalMs?:number,color:boolean,note?:string}} o
|
|
142
|
+
*/
|
|
143
|
+
export function renderWaitingLine({ frame: i, remainingMs, totalMs = 600000, color, note = '' }) {
|
|
144
|
+
const spin = SPIN_FRAMES[i % SPIN_FRAMES.length];
|
|
145
|
+
const left = `${fmtCountdown(remainingMs)} left`;
|
|
146
|
+
const extra = note ? ` · ${note}` : '';
|
|
147
|
+
if (!color) return `${spin} Waiting for approval on quox.ai · ${left}${extra}`;
|
|
148
|
+
const ratio = totalMs > 0 ? remainingMs / totalMs : 0;
|
|
149
|
+
const tone = ratio < 0.2 ? fg(AMBER) : '';
|
|
150
|
+
return ` ${fg(GRAD[0])}${spin}${R} Waiting for approval on quox.ai ${DIM}│${R} ${bar(ratio)} ${tone}${BOLD}${left}${R}${note ? `${DIM}${extra}${R}` : ''}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const bareId = (raw) => String(raw).split('@')[0];
|
|
154
|
+
const orgAllowance = (raw) => {
|
|
155
|
+
const m = /@orgs=(\d+)/.exec(String(raw));
|
|
156
|
+
return m ? Number(m[1]) : null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What was linked, from the approved lease plus the read-back of
|
|
161
|
+
* GET /api/setup/licensing/entitlements (the route `quox licence status`
|
|
162
|
+
* reads). The read-back wins where it has the licence; the lease is the
|
|
163
|
+
* fallback, so a failed read never hides what was activated.
|
|
164
|
+
*
|
|
165
|
+
* @returns {{license_id, plans:string[], entitlements:string[], seats:Array<{plugin,used,total}>, warnings:string[], servers?:{used,total}}}
|
|
166
|
+
*/
|
|
167
|
+
export function linkedDetails(lease = {}, status = null, poll = {}) {
|
|
168
|
+
const id = lease.license_id || null;
|
|
169
|
+
const row = (status && Array.isArray(status.licenses))
|
|
170
|
+
? status.licenses.find((l) => l && l.license_id === id) : null;
|
|
171
|
+
|
|
172
|
+
let rawEntitlements;
|
|
173
|
+
let plans;
|
|
174
|
+
if (row) {
|
|
175
|
+
rawEntitlements = row.entitlements || [];
|
|
176
|
+
plans = row.plans || [];
|
|
177
|
+
} else {
|
|
178
|
+
const raw = lease.entitlements || [];
|
|
179
|
+
plans = raw.filter((e) => String(e).toLowerCase().startsWith('plan.')).map((e) => bareId(e).slice(5).toLowerCase()).filter(Boolean);
|
|
180
|
+
rawEntitlements = raw.filter((e) => !String(e).toLowerCase().startsWith('plan.'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bands = (status && status.org_bands && typeof status.org_bands === 'object') ? status.org_bands : {};
|
|
184
|
+
const entitlements = [...new Set(rawEntitlements.map(bareId))];
|
|
185
|
+
const seats = [];
|
|
186
|
+
for (const raw of rawEntitlements) {
|
|
187
|
+
const plugin = bareId(raw);
|
|
188
|
+
const band = bands[plugin];
|
|
189
|
+
if (band) seats.push({ plugin, used: Number(band.assigned_count ?? 0), total: Number(band.allowance ?? 0) });
|
|
190
|
+
else if (orgAllowance(raw) != null) seats.push({ plugin, used: null, total: orgAllowance(raw) });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const out = { license_id: id, plans, entitlements, seats, warnings: entitlements.length === 0 ? ['zero_entitlements'] : [] };
|
|
194
|
+
// Server seats travel only when the poll response carries them (the
|
|
195
|
+
// control plane sends max_servers and bound; additive, never guessed).
|
|
196
|
+
if (poll && poll.max_servers != null) {
|
|
197
|
+
out.servers = { used: Array.isArray(poll.bound) ? poll.bound.length : null, total: Number(poll.max_servers) };
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const seatText = (s) => (s.used == null ? `${s.plugin} up to ${s.total} orgs` : `${s.plugin} ${s.used} of ${s.total} orgs`);
|
|
203
|
+
const leaseText = (iso) => {
|
|
204
|
+
const t = Date.parse(iso);
|
|
205
|
+
return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : null;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** The exact command that unlinks this licence and starts a fresh link. */
|
|
209
|
+
export function relinkCommand(licenseId, profile) {
|
|
210
|
+
const q = profile ? `quox -p ${profile}` : 'quox';
|
|
211
|
+
return `${q} setup license deactivate --license-id ${licenseId} --yes && ${q} setup license link`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The result after approval: compact, celebratory when the licence grants
|
|
216
|
+
* something, amber with the one fix command when it grants nothing.
|
|
217
|
+
* @param {ReturnType<typeof linkedDetails>} d
|
|
218
|
+
* @param {{color:boolean, cols?:number, leaseExpiresAt?:string, profile?:string, readBackError?:string}} o
|
|
219
|
+
*/
|
|
220
|
+
export function renderLinkedCard(d, { color, cols = 80, leaseExpiresAt = null, profile = null, readBackError = null } = {}) {
|
|
221
|
+
const zero = d.warnings.includes('zero_entitlements');
|
|
222
|
+
const header = withColor(color, () => (zero
|
|
223
|
+
? line('quox', 'licence linked', { result: 'grants 0 plugins', status: 'warn' })
|
|
224
|
+
: line('quox', 'licence linked', { result: color ? celebrate('instance activated') : 'instance activated', status: 'ok', max: 400 })));
|
|
225
|
+
const plan = d.plans.length ? d.plans.join(', ') : '(none on this licence)';
|
|
226
|
+
const renews = leaseText(leaseExpiresAt);
|
|
227
|
+
const fix = relinkCommand(d.license_id, profile);
|
|
228
|
+
const warnText = 'This licence grants 0 plugins, so nothing unlocks. You may have picked the wrong licence on quox.ai.';
|
|
229
|
+
const readBack = readBackError
|
|
230
|
+
? `Could not read the licence details back (${readBackError}). Showing what the lease carried; run \`quox licence status\` to check.`
|
|
231
|
+
: null;
|
|
232
|
+
|
|
233
|
+
if (!color) {
|
|
234
|
+
const out = [header];
|
|
235
|
+
if (readBack) out.push(readBack);
|
|
236
|
+
out.push(`Licence: ${d.license_id}`);
|
|
237
|
+
out.push(`Plan: ${plan}`);
|
|
238
|
+
out.push(`Plugins (${d.entitlements.length}): ${d.entitlements.join(', ') || 'none'}`);
|
|
239
|
+
if (d.seats.length) out.push(`Seats: ${d.seats.map(seatText).join(', ')}`);
|
|
240
|
+
if (d.servers) out.push(`Servers: ${d.servers.used ?? '?'} of ${d.servers.total} in use`);
|
|
241
|
+
if (renews) out.push(`Lease renews by: ${renews}`);
|
|
242
|
+
if (zero) {
|
|
243
|
+
out.push(`⚠ ${warnText}`);
|
|
244
|
+
out.push('Unlink it and link the right one:');
|
|
245
|
+
out.push(` ${fix}`);
|
|
246
|
+
} else {
|
|
247
|
+
out.push('This instance is activated. No key was ever pasted into it.');
|
|
248
|
+
}
|
|
249
|
+
return out.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const label = (s) => `${DIM}${s.padEnd(10)}${R}`;
|
|
253
|
+
const width = Math.min(Math.max(44, cols - 6), 76) - 14;
|
|
254
|
+
const rows = [
|
|
255
|
+
`${label('Licence')}${BOLD}${d.license_id}${R}`,
|
|
256
|
+
`${label('Plan')}${d.plans.length ? gradient(plan) : `${DIM}${plan}${R}`}`,
|
|
257
|
+
];
|
|
258
|
+
if (zero) {
|
|
259
|
+
rows.push(`${label('Plugins')}${fg(AMBER)}${BOLD}0${R}${fg(AMBER)} entitled${R}`);
|
|
260
|
+
} else {
|
|
261
|
+
const list = wrapList(d.entitlements, width);
|
|
262
|
+
rows.push(`${label('Plugins')}${BOLD}${d.entitlements.length}${R} ${DIM}entitled${R}`);
|
|
263
|
+
for (const l of list) rows.push(`${' '.repeat(10)}${fg(GREEN)}${l}${R}`);
|
|
264
|
+
}
|
|
265
|
+
for (const [n, s] of d.seats.entries()) {
|
|
266
|
+
const ratio = s.used == null || !s.total ? 0 : s.used / s.total;
|
|
267
|
+
const text = s.used == null ? `up to ${s.total} orgs` : `${s.used} of ${s.total} orgs`;
|
|
268
|
+
rows.push(`${label(n === 0 ? 'Seats' : '')}${s.plugin} ${bar(ratio, 6)} ${text}`);
|
|
269
|
+
}
|
|
270
|
+
if (d.servers) rows.push(`${label('Servers')}${d.servers.used ?? '?'} of ${d.servers.total} in use`);
|
|
271
|
+
if (renews) rows.push(`${label('Lease')}${DIM}renews by ${renews}${R}`);
|
|
272
|
+
|
|
273
|
+
const title = zero
|
|
274
|
+
? `${fg(AMBER)}${BOLD}${STATUS.warn.glyph} Linked, but it grants 0 plugins${R}`
|
|
275
|
+
: `${fg(GREEN)}${BOLD}${STATUS.ok.glyph} Licence linked${R}`;
|
|
276
|
+
const out = [header];
|
|
277
|
+
if (readBack) out.push(` ${fg(AMBER)}${readBack}${R}`);
|
|
278
|
+
out.push(frame(rows, { title, cols, tint: zero ? AMBER : GREEN }));
|
|
279
|
+
if (zero) {
|
|
280
|
+
out.push(` ${fg(AMBER)}${BOLD}${STATUS.warn.glyph}${R} ${fg(AMBER)}${warnText}${R}`);
|
|
281
|
+
out.push(` ${DIM}Unlink it and link the right one:${R}`);
|
|
282
|
+
out.push(` ${BOLD}${fix}${R}`);
|
|
283
|
+
} else {
|
|
284
|
+
out.push(` ${DIM}No key was ever pasted into this instance.${R}`);
|
|
285
|
+
}
|
|
286
|
+
return out.join('\n');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The live waiting indicator. On an animating TTY it redraws one line in
|
|
291
|
+
* place every 80 ms (house spinner pace) with the countdown; with motion off
|
|
292
|
+
* it draws once. `note()` prints a line above it without tearing the spinner.
|
|
293
|
+
* `stop()` clears the line. Never used when the stream is not a TTY.
|
|
294
|
+
*/
|
|
295
|
+
export function startWaiting({ term, deadline, totalMs, color, animate, now = () => Date.now(), frameMs = 80 }) {
|
|
296
|
+
let i = 0;
|
|
297
|
+
let note = '';
|
|
298
|
+
const draw = () => {
|
|
299
|
+
term.write('\r\x1b[2K' + renderWaitingLine({ frame: i++, remainingMs: deadline - now(), totalMs, color, note }));
|
|
300
|
+
};
|
|
301
|
+
draw();
|
|
302
|
+
const timer = animate ? setInterval(draw, frameMs) : null;
|
|
303
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
304
|
+
return {
|
|
305
|
+
note(text) { note = text; draw(); },
|
|
306
|
+
stop() {
|
|
307
|
+
if (timer) clearInterval(timer);
|
|
308
|
+
term.write('\r\x1b[2K');
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
package/lib/commands/linkedin.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* quox linkedin post "<text>" --as <author_urn> [--visibility PUBLIC|CONNECTIONS] [--link <url>] [--image-url <https url>]
|
|
5
5
|
* quox linkedin comment <post_urn> "<text>" --as <actor_urn>
|
|
6
6
|
* quox linkedin posts <author_urn> [--count <n>]
|
|
7
|
+
* quox linkedin whoami
|
|
7
8
|
* quox linkedin org <org_id>
|
|
8
9
|
*
|
|
9
10
|
* Both write verbs execute the linkedin connector tools through the governed
|
|
@@ -154,6 +155,24 @@ export function registerLinkedInCommands(program, ctx) {
|
|
|
154
155
|
}
|
|
155
156
|
});
|
|
156
157
|
|
|
158
|
+
// ── linkedin me (read) ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
linkedin
|
|
161
|
+
.command('whoami')
|
|
162
|
+
.description('Show the connected member identity and the member URN to use with --as on posts (read-only). Needs the openid and profile scopes on the token.')
|
|
163
|
+
.action(async () => {
|
|
164
|
+
const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_get_me', {});
|
|
165
|
+
|
|
166
|
+
if (program.opts().output === 'json') {
|
|
167
|
+
formatDetail(data, { format: 'json' });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const me = data.result || {};
|
|
171
|
+
printSuccess(`${me.name || 'Unknown'} (${me.member_urn || 'no member URN returned'})`);
|
|
172
|
+
if (me.email) printInfo(`Email: ${me.email}`);
|
|
173
|
+
if (me.member_urn) printInfo(`Use as author: --as ${me.member_urn}`);
|
|
174
|
+
});
|
|
175
|
+
|
|
157
176
|
// ── linkedin org (read) ──────────────────────────────────────────────────
|
|
158
177
|
|
|
159
178
|
linkedin
|
package/lib/commands/loop.js
CHANGED
|
@@ -29,6 +29,32 @@ function toMs(ts) {
|
|
|
29
29
|
return Number.isNaN(n) ? null : n;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const LIVE_STATES = new Set(['pending', 'starting', 'decomposing', 'running', 'waiting']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One row, one vocabulary. The list route counts turn events, which loops driven by the runner never write, so a
|
|
36
|
+
* finished loop could read 0 there. The server now sends a real 0 (started, ran nothing) and null (no record at
|
|
37
|
+
* all); a number from the server is kept, null stays unknown unless the loop is live. objective_status is the objective row's word ('cancelled' for a
|
|
38
|
+
* human halt); `state` is the loop's, so the row says one thing and the raw word is kept as objective_row_status.
|
|
39
|
+
*/
|
|
40
|
+
export function normLoop(l) {
|
|
41
|
+
const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
|
|
42
|
+
const count = n(l.turn) || n(l.iteration_count);
|
|
43
|
+
// the server's turn is authoritative: a real 0 (started, ran nothing) stays 0; only null/absent means unknown
|
|
44
|
+
const turn = count || (l.turn === 0 || LIVE_STATES.has(l.state) ? 0 : null);
|
|
45
|
+
const out = { ...l, turn };
|
|
46
|
+
if (l.objective_status !== undefined && l.state && l.objective_status !== l.state) {
|
|
47
|
+
out.objective_row_status = l.objective_status;
|
|
48
|
+
out.objective_status = l.state;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function turnLabel(l) {
|
|
54
|
+
if (l.turn === null) return '-';
|
|
55
|
+
return l.max_turns ? `${l.turn}/${l.max_turns}` : String(l.turn);
|
|
56
|
+
}
|
|
57
|
+
|
|
32
58
|
const SETTLE_MAX_MS = 90000;
|
|
33
59
|
const SETTLE_POLL_MS = 4000;
|
|
34
60
|
|
|
@@ -149,7 +175,7 @@ function oneLineErr(err) {
|
|
|
149
175
|
|
|
150
176
|
export function statusLines(s, { diagnostics = false } = {}) {
|
|
151
177
|
const lines = [];
|
|
152
|
-
const turns = `turn ${s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
|
|
178
|
+
const turns = `turn ${s.turn === null || s.turn === undefined ? '-' : s.turn}${s.max_turns ? `/${s.max_turns}` : ''}`;
|
|
153
179
|
lines.push(loopLine(s.title || s.objective_id, `${s.state}, ${turns}`, s.state === 'running' ? 'busy' : (s.state === 'completed' ? 'ok' : (s.state === 'completed_with_errors' || s.state === 'halted' || s.state === 'interrupted' ? 'warn' : (s.finished ? 'fail' : 'busy')))));
|
|
154
180
|
lines.push(` id ${s.objective_id}`);
|
|
155
181
|
lines.push(` host ${s.host || '-'} backend ${s.backend || '-'} scope ${(s.scope || []).join(',') || '-'}`);
|
|
@@ -240,11 +266,12 @@ export function registerLoopCommands(program, ctx) {
|
|
|
240
266
|
auth.requireAuth();
|
|
241
267
|
const ep = resolveEndpoint('loops.list');
|
|
242
268
|
const data = await client.get(ep.path, { service: ep.service, query: { limit: options.limit } });
|
|
269
|
+
const loops = (data.loops || []).map(normLoop);
|
|
243
270
|
formatOutput({
|
|
244
271
|
columns: ['objective_id', 'title', 'state', 'turn', 'backend', 'created_at'],
|
|
245
272
|
headers: ['ID', 'Title', 'State', 'Turn', 'Backend', 'Created'],
|
|
246
|
-
rows:
|
|
247
|
-
}, { format: program.opts().output, emptyMessage: 'No loops in this org yet.' });
|
|
273
|
+
rows: loops.map((l) => ({ ...l, turn: turnLabel(l) })),
|
|
274
|
+
}, { format: program.opts().output, raw: loops, emptyMessage: 'No loops in this org yet.' });
|
|
248
275
|
});
|
|
249
276
|
|
|
250
277
|
// SECURITY containment (2026-10-26): which orgs may run loops in-server (core-local) while a security review is open.
|
package/lib/commands/lucid.js
CHANGED
|
@@ -105,6 +105,7 @@ export function registerLucidCommands(program, ctx) {
|
|
|
105
105
|
if (o.markdown) { const { renderSitrepMarkdown } = await import('../lucid/markdown.js'); console.log(renderSitrepMarkdown(data)); return; }
|
|
106
106
|
const { renderSitrep } = await import('../lucid/sitrep.js');
|
|
107
107
|
const tty = Boolean(process.stdout.isTTY);
|
|
108
|
+
if (tty) process.stderr.write('Tip: for a shorter form use --spoken (sentences), --agent (compact) or --markdown (one chat message).\n');
|
|
108
109
|
process.stdout.write(renderSitrep(data, {
|
|
109
110
|
width: process.stdout.columns || 100,
|
|
110
111
|
color: tty && !process.env.NO_COLOR && process.env.QUOX_COLOR !== '0',
|
package/lib/commands/mail.js
CHANGED
|
@@ -48,6 +48,8 @@ function parseCsvFile(path) {
|
|
|
48
48
|
const VALID_ZONES = ['focus', 'fyi', 'auto', 'screened'];
|
|
49
49
|
const VALID_KINDS = ['file', 'block', 'draft'];
|
|
50
50
|
const VALID_DECISIONS = ['approve', 'deny'];
|
|
51
|
+
// quox#1518: past tense comes from a map, never verb + 'd' ("denyd").
|
|
52
|
+
const DECISION_PAST_TENSE = { approve: 'approved', deny: 'denied', reject: 'rejected' };
|
|
51
53
|
|
|
52
54
|
// Small CLI-local copy of src/config/emailProviders.js's EMAIL_PROVIDERS.
|
|
53
55
|
// The cli/ package can't import from src/, so the host/port defaults for
|
|
@@ -67,6 +69,21 @@ function printJson(data) {
|
|
|
67
69
|
console.log(JSON.stringify(data, null, 2));
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// quox#1504: a failure is printed as a failure (stderr, non-zero exit), never
|
|
73
|
+
// dressed up as an empty success.
|
|
74
|
+
function printFailure(message) {
|
|
75
|
+
console.error(message);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pinHint(accountId, error) {
|
|
80
|
+
return /^TLS: /.test(error || '')
|
|
81
|
+
? `If this server's certificate is one you trust, an org admin can pin it: quox mail account pin ${accountId} --sha256 <fingerprint>`
|
|
82
|
+
: null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
86
|
+
|
|
70
87
|
export function registerMailCommands(program, ctx) {
|
|
71
88
|
const { client, auth } = ctx;
|
|
72
89
|
|
|
@@ -85,9 +102,9 @@ export function registerMailCommands(program, ctx) {
|
|
|
85
102
|
const data = await client.get(ep.path, { service: ep.service });
|
|
86
103
|
if (isJsonOutput(program)) return printJson(data);
|
|
87
104
|
formatOutput({
|
|
88
|
-
columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', '
|
|
89
|
-
headers: ['ID', 'Email', 'Name', 'Polling', '
|
|
90
|
-
rows: data.accounts || [],
|
|
105
|
+
columns: ['id', 'emailAddress', 'displayName', 'pollEnabled', 'tlsPin', 'lastError'],
|
|
106
|
+
headers: ['ID', 'Email', 'Name', 'Polling', 'TLS pin', 'Last error'],
|
|
107
|
+
rows: (data.accounts || []).map((a) => ({ ...a, tlsPin: a.tlsPinSha256 ? 'sha256 pinned' : '', lastError: a.lastError || '' })),
|
|
91
108
|
}, { format: program.opts().output });
|
|
92
109
|
});
|
|
93
110
|
|
|
@@ -288,31 +305,123 @@ export function registerMailCommands(program, ctx) {
|
|
|
288
305
|
|
|
289
306
|
mail
|
|
290
307
|
.command('sync')
|
|
291
|
-
.description('Sync a mail account now (fetch new messages, run triage)')
|
|
308
|
+
.description('Sync a mail account now (fetch new messages, run triage). Waits for the result and shows the real cause if the mailbox cannot be reached (TLS, login, network).')
|
|
292
309
|
.argument('<accountId>', 'account ID')
|
|
293
|
-
.
|
|
310
|
+
.option('--wait <seconds>', 'how long to wait for a background sync to finish (0 = do not wait)', '120')
|
|
311
|
+
.action(async (accountId, options) => {
|
|
294
312
|
auth.requireAuth();
|
|
295
313
|
const ep = resolveEndpoint('inboxq.sync');
|
|
296
|
-
// A first full-mailbox pull
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// "timed out"). Give sync a generous 4-minute ceiling (nginx allows 300s).
|
|
314
|
+
// A first full-mailbox pull can be slow; the collector usually runs it
|
|
315
|
+
// detached and returns {started:true} at once. Keep a generous ceiling
|
|
316
|
+
// for the inline shape (nginx allows 300s).
|
|
300
317
|
const data = await client.post(ep.path, { service: ep.service, body: { accountId }, timeout: 240000 });
|
|
318
|
+
|
|
319
|
+
// Detached run: poll sync-status until it finishes, then report the
|
|
320
|
+
// OUTCOME. quox#1504: this used to print "No folders reported" while the
|
|
321
|
+
// background run failed TLS and nobody saw why.
|
|
322
|
+
if (data && (data.started || data.status === 'already_syncing')) {
|
|
323
|
+
const sep = resolveEndpoint('inboxq.syncStatus');
|
|
324
|
+
const deadline = Date.now() + Math.max(0, Number(options.wait) || 0) * 1000;
|
|
325
|
+
let st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
326
|
+
while (st && st.syncing && Date.now() < deadline) {
|
|
327
|
+
await sleep(2000);
|
|
328
|
+
st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
329
|
+
}
|
|
330
|
+
if (isJsonOutput(program)) return printJson({ ...data, status: st });
|
|
331
|
+
if (st && st.lastError) {
|
|
332
|
+
printFailure(`Sync of ${accountId} failed: ${st.lastError}`);
|
|
333
|
+
for (const f of st.folderErrors || []) printFailure(` ${f.folder}: ${f.error} (failures in a row: ${f.consecutiveFailures}${f.retired ? ', retired' : ''})`);
|
|
334
|
+
const hint = pinHint(accountId, st.lastError);
|
|
335
|
+
if (hint) console.error(hint);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (st && st.syncing) {
|
|
339
|
+
printSuccess(`Sync of ${accountId} is still running (${st.stored ?? 0} messages stored so far). Check: quox mail sync-status ${accountId}`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
printSuccess(`Synced ${accountId}. ${st?.stored ?? 0} messages stored.`);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
301
346
|
if (isJsonOutput(program)) return printJson(data);
|
|
302
347
|
const summary = data.summary || {};
|
|
303
348
|
const folders = summary.folders || [];
|
|
349
|
+
const failed = folders.filter((f) => f.failed);
|
|
304
350
|
if (folders.length === 0) {
|
|
305
|
-
|
|
351
|
+
if (data.lastError) { printFailure(`Sync of ${accountId} failed: ${data.lastError}`); return; }
|
|
352
|
+
printSuccess(`Synced ${accountId}. No folders were eligible this pass (all in backoff or retired); see quox mail sync-status ${accountId}.`);
|
|
306
353
|
return;
|
|
307
354
|
}
|
|
308
355
|
formatOutput({
|
|
309
|
-
columns: ['folder', 'new', '
|
|
310
|
-
headers: ['Folder', 'New', '
|
|
311
|
-
rows: folders,
|
|
356
|
+
columns: ['folder', 'new', 'error'],
|
|
357
|
+
headers: ['Folder', 'New', 'Error'],
|
|
358
|
+
rows: folders.map((f) => ({ ...f, new: f.new ?? '', error: f.error || f.skipped || '' })),
|
|
312
359
|
}, { format: program.opts().output });
|
|
360
|
+
if (failed.length) {
|
|
361
|
+
printFailure(`Sync of ${accountId}: ${failed.length} of ${folders.length} folder(s) failed: ${failed[0].error}`);
|
|
362
|
+
const hint = pinHint(accountId, failed[0].error);
|
|
363
|
+
if (hint) console.error(hint);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
313
366
|
printSuccess(`Synced ${accountId}. LLM calls: ${summary.llmCalls ?? 0}`);
|
|
314
367
|
});
|
|
315
368
|
|
|
369
|
+
mail
|
|
370
|
+
.command('sync-status')
|
|
371
|
+
.description('Show whether an account is syncing, how many messages are stored, and the last connect/TLS/login error if any')
|
|
372
|
+
.argument('<accountId>', 'account ID')
|
|
373
|
+
.action(async (accountId) => {
|
|
374
|
+
auth.requireAuth();
|
|
375
|
+
const sep = resolveEndpoint('inboxq.syncStatus');
|
|
376
|
+
const st = await client.get(sep.path, { service: sep.service, query: { accountId } });
|
|
377
|
+
if (isJsonOutput(program)) return printJson(st);
|
|
378
|
+
formatDetail({
|
|
379
|
+
syncing: Boolean(st.syncing),
|
|
380
|
+
stored: st.stored ?? 0,
|
|
381
|
+
folders: st.folders ? `${st.folders.backfilled}/${st.folders.total} fully imported` : '',
|
|
382
|
+
tlsPin: st.tlsPinSha256 || 'none (default certificate verification)',
|
|
383
|
+
lastError: st.lastError || 'none',
|
|
384
|
+
lastErrorAt: st.lastErrorAt || '',
|
|
385
|
+
}, { format: program.opts().output });
|
|
386
|
+
if ((st.folderErrors || []).length) {
|
|
387
|
+
formatOutput({
|
|
388
|
+
columns: ['folder', 'error', 'consecutiveFailures', 'retired', 'nextAttemptAt'],
|
|
389
|
+
headers: ['Folder', 'Error', 'Failures', 'Retired', 'Next attempt'],
|
|
390
|
+
rows: st.folderErrors,
|
|
391
|
+
}, { format: program.opts().output });
|
|
392
|
+
}
|
|
393
|
+
const hint = pinHint(accountId, st.lastError);
|
|
394
|
+
if (hint) console.error(hint);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── account (per-account settings) ───────────────────────────────────
|
|
398
|
+
|
|
399
|
+
const account = mail
|
|
400
|
+
.command('account')
|
|
401
|
+
.description('Per-account settings for a connected mailbox');
|
|
402
|
+
|
|
403
|
+
account
|
|
404
|
+
.command('pin')
|
|
405
|
+
.description('Pin the TLS certificate (SHA-256 fingerprint) an account trusts, for a mail server with a self-signed or private-CA certificate. Only that exact certificate is accepted; anything else still fails. Org admin only. Get the fingerprint with: openssl s_client -connect <host>:<port> </dev/null | openssl x509 -noout -fingerprint -sha256')
|
|
406
|
+
.argument('<accountId>', 'account ID')
|
|
407
|
+
.option('--sha256 <fingerprint>', 'SHA-256 certificate fingerprint (64 hex characters, colons optional; the openssl output line is accepted)')
|
|
408
|
+
.option('--clear', 'remove the pin (back to default certificate verification)')
|
|
409
|
+
.action(async (accountId, options) => {
|
|
410
|
+
auth.requireAuth();
|
|
411
|
+
if (Boolean(options.sha256) === Boolean(options.clear)) {
|
|
412
|
+
printFailure('Give exactly one of --sha256 <fingerprint> or --clear.');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const ep = resolveEndpoint('inboxq.accounts.tlsPin', { accountId });
|
|
416
|
+
const body = options.clear ? { clear: true } : { sha256: options.sha256 };
|
|
417
|
+
const data = await client.put(ep.path, { service: ep.service, body });
|
|
418
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
419
|
+
const pin = data?.account?.tlsPinSha256;
|
|
420
|
+
printSuccess(pin
|
|
421
|
+
? `Pinned ${accountId} to TLS certificate sha256 ${pin}. Run quox mail sync ${accountId} to retry now.`
|
|
422
|
+
: `Removed the TLS pin from ${accountId}; it uses default certificate verification again.`);
|
|
423
|
+
});
|
|
424
|
+
|
|
316
425
|
// ── messages ─────────────────────────────────────────────────────────
|
|
317
426
|
|
|
318
427
|
mail
|
|
@@ -408,20 +517,52 @@ export function registerMailCommands(program, ctx) {
|
|
|
408
517
|
|
|
409
518
|
watch
|
|
410
519
|
.command('add')
|
|
411
|
-
.description('Add a watch')
|
|
520
|
+
.description('Add a watch (awaited-mail alert). Give --match, or at least one of --from/--subject/--text/--zone.')
|
|
412
521
|
.requiredOption('--label <label>', 'watch label')
|
|
413
|
-
.
|
|
414
|
-
.option('--
|
|
522
|
+
.option('--match <json>', 'match rule as a JSON object (advanced; alternative to the --from/--subject/--text/--zone flags)')
|
|
523
|
+
.option('--from <addr>', 'match: sender address/name contains this')
|
|
524
|
+
.option('--subject <text>', 'match: subject contains this')
|
|
525
|
+
.option('--text <keyword>', 'match: message text contains this')
|
|
526
|
+
.option('--zone <zone>', 'match: only mail triaged into this zone (focus|fyi|auto|screened)')
|
|
527
|
+
.option('--notify <csv>', 'alert channels, comma list of hitl,lucid,telegram,discord,call,sms', 'hitl')
|
|
528
|
+
.option('--account <id>', 'restrict the watch to one account (omit to watch all your mailboxes)')
|
|
415
529
|
.action(async (options) => {
|
|
416
530
|
auth.requireAuth();
|
|
417
|
-
|
|
418
|
-
|
|
531
|
+
let match;
|
|
532
|
+
if (options.match) {
|
|
533
|
+
match = parseJSON(options.match, '--match');
|
|
534
|
+
} else {
|
|
535
|
+
match = {};
|
|
536
|
+
if (options.from) match.from = options.from;
|
|
537
|
+
if (options.subject) match.subject = options.subject;
|
|
538
|
+
if (options.text) match.text = options.text;
|
|
539
|
+
if (options.zone) match.zone = options.zone;
|
|
540
|
+
}
|
|
541
|
+
if (!match || typeof match !== 'object' || Object.keys(match).length === 0) {
|
|
542
|
+
throw new Error('Give --match, or at least one of --from, --subject, --text, --zone, or the watch would match nothing in particular.');
|
|
543
|
+
}
|
|
544
|
+
const notify = String(options.notify).split(',').map((s) => s.trim()).filter(Boolean);
|
|
545
|
+
const body = { label: options.label, match, notify };
|
|
419
546
|
if (options.account) body.accountId = options.account;
|
|
420
547
|
const ep = resolveEndpoint('inboxq.watches.create');
|
|
421
548
|
const data = await client.post(ep.path, { service: ep.service, body });
|
|
422
549
|
if (isJsonOutput(program)) return printJson(data);
|
|
423
550
|
const created = data.watch || data;
|
|
424
|
-
printSuccess(`Added watch "${options.label}" (${created.id})
|
|
551
|
+
printSuccess(`Added watch "${options.label}" (${created.id}); alerts via ${notify.join(', ')}.`);
|
|
552
|
+
const ext = notify.filter((c) => ['telegram', 'discord', 'call', 'sms'].includes(c));
|
|
553
|
+
if (ext.length) console.log(`Note: ${ext.join(', ')} need a destination configured, or they report unconfigured; hitl and lucid work out of the box.`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
watch
|
|
557
|
+
.command('rm')
|
|
558
|
+
.description('Delete a watch')
|
|
559
|
+
.argument('<watchId>', 'watch ID')
|
|
560
|
+
.action(async (watchId) => {
|
|
561
|
+
auth.requireAuth();
|
|
562
|
+
const ep = resolveEndpoint('inboxq.watches.delete', { watchId });
|
|
563
|
+
const data = await client.delete(ep.path, { service: ep.service });
|
|
564
|
+
if (isJsonOutput(program)) return printJson(data);
|
|
565
|
+
printSuccess(`Watch ${watchId} deleted.`);
|
|
425
566
|
});
|
|
426
567
|
|
|
427
568
|
watch
|
|
@@ -493,7 +634,7 @@ export function registerMailCommands(program, ctx) {
|
|
|
493
634
|
const ep = resolveEndpoint('inboxq.proposals.resolve', { proposalId });
|
|
494
635
|
const data = await client.post(ep.path, { service: ep.service, body: { decision: options.decision } });
|
|
495
636
|
if (isJsonOutput(program)) return printJson(data);
|
|
496
|
-
printSuccess(`Proposal ${proposalId} ${options.decision}
|
|
637
|
+
printSuccess(`Proposal ${proposalId} ${DECISION_PAST_TENSE[options.decision] || options.decision}.`);
|
|
497
638
|
});
|
|
498
639
|
|
|
499
640
|
// ── moves (AQ-9E U2: reversible moves) ─────────────────────────────────
|