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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SLOT-OUTBOX: a /say or @slot message that could not be sent because QuoxCORE did not answer (fetch failed,
|
|
3
|
+
* ECONNREFUSED, socket error, timeout, 5xx) is kept on disk and sent when it answers again, never lost.
|
|
4
|
+
* Local only (~/.quox/slot-outbox.json, mode 0600, per OS user; each entry also carries the profile it was typed in,
|
|
5
|
+
* and a window only ever sends its own profile's entries, so a message never changes org).
|
|
6
|
+
*
|
|
7
|
+
* Never for a 4xx (auth, permission, no such slot): that is the user's to fix, so the text stays in the prompt.
|
|
8
|
+
*
|
|
9
|
+
* ONE-SEND RULE (the slot-say endpoint has no idempotency key, so the client must be conservative):
|
|
10
|
+
* 1. An entry is flipped to `sending` and persisted BEFORE the request goes out.
|
|
11
|
+
* 2. Success removes it (it is never sent again).
|
|
12
|
+
* 3. A clean failure (the server did not answer) puts it back to `queued`.
|
|
13
|
+
* 4. An entry still `sending` when the file is next read by a process that is not flushing is `unsure`: the window
|
|
14
|
+
* died mid-request, so the message may have been delivered. `unsure` entries are never auto-sent; /outbox shows
|
|
15
|
+
* them and `/outbox send` (explicit) is the only thing that will try them.
|
|
16
|
+
* Residual risk, stated: a timeout where the server did receive the message but the reply was lost can duplicate it
|
|
17
|
+
* once on flush. Duplicate over lost is the chosen side for a message the owner typed and saw fail.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { randomBytes } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
export const OUTBOX_CAP = 50;
|
|
25
|
+
export const defaultOutboxPath = (home = homedir()) => join(home, '.quox', 'slot-outbox.json');
|
|
26
|
+
|
|
27
|
+
/** A send result worth queueing: no HTTP status (fetch failed, refused, socket, timeout) or a 5xx. Never a 4xx, ambiguity, or a licence refusal. */
|
|
28
|
+
export function isConnectivityFailure(r) {
|
|
29
|
+
if (!r || r.ok || r.ambiguous || r.unlicensed) return false;
|
|
30
|
+
if (r.status == null) return true;
|
|
31
|
+
return Number(r.status) >= 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function slotOutbox(path = defaultOutboxPath(), { now = () => Date.now() } = {}) {
|
|
35
|
+
const read = () => {
|
|
36
|
+
try { const d = JSON.parse(readFileSync(path, 'utf8')); return Array.isArray(d?.items) ? d : { items: [] }; }
|
|
37
|
+
catch { return { items: [] }; }
|
|
38
|
+
};
|
|
39
|
+
const write = (d) => {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
42
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
43
|
+
writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 });
|
|
44
|
+
renameSync(tmp, path);
|
|
45
|
+
return true;
|
|
46
|
+
} catch { return false; }
|
|
47
|
+
};
|
|
48
|
+
let flushing = false; // this process is mid-flush: its own `sending` entry is live, not crashed
|
|
49
|
+
const settle = (d) => { // a `sending` entry nobody in this process is sending is a crashed send; persist that verdict
|
|
50
|
+
if (flushing) return d;
|
|
51
|
+
let changed = false;
|
|
52
|
+
for (const it of d.items) if (it.status === 'sending') { it.status = 'unsure'; changed = true; }
|
|
53
|
+
if (changed) write(d);
|
|
54
|
+
return d;
|
|
55
|
+
};
|
|
56
|
+
const mine = (it, profile) => profile == null || it.profile == null || it.profile === profile;
|
|
57
|
+
return {
|
|
58
|
+
/** Entries in send order (oldest first), optionally only this profile's. */
|
|
59
|
+
list: ({ profile = null } = {}) => settle(read()).items.filter(i => mine(i, profile)),
|
|
60
|
+
/** -> { ok:true, item, count } | { ok:false, full:true, count }. The cap is on entries not yet sent. */
|
|
61
|
+
enqueue({ slot, text, profile = null }) {
|
|
62
|
+
const d = settle(read());
|
|
63
|
+
if (d.items.length >= OUTBOX_CAP) return { ok: false, full: true, count: d.items.length };
|
|
64
|
+
const item = { id: `ob_${now().toString(36)}_${randomBytes(3).toString('hex')}`, slot, text: String(text), profile, created_at: new Date(now()).toISOString(), status: 'queued', tries: 0 };
|
|
65
|
+
d.items.push(item);
|
|
66
|
+
if (!write(d)) return { ok: false, unsaved: true, count: d.items.length };
|
|
67
|
+
return { ok: true, item, count: d.items.filter(i => mine(i, profile)).length };
|
|
68
|
+
},
|
|
69
|
+
/** Remove the Nth entry (1-based, as /outbox lists this profile's). -> the dropped item, or null. */
|
|
70
|
+
drop(n, { profile = null } = {}) {
|
|
71
|
+
const d = settle(read());
|
|
72
|
+
const visible = d.items.filter(i => mine(i, profile));
|
|
73
|
+
const it = visible[Number(n) - 1];
|
|
74
|
+
if (!it) return null;
|
|
75
|
+
d.items = d.items.filter(i => i.id !== it.id);
|
|
76
|
+
write(d);
|
|
77
|
+
return it;
|
|
78
|
+
},
|
|
79
|
+
/**
|
|
80
|
+
* Send what is queued, in order, once. send(item) -> { ok:true } | { ok:false, retryable }. A retryable failure stops
|
|
81
|
+
* the flush (the server is still down; order is kept). A non-retryable one marks that entry `failed` and moves on.
|
|
82
|
+
* `includeUnsure` (explicit /outbox send only) also tries entries that may already have been delivered.
|
|
83
|
+
* -> { sent:[items], failed:[items {error}], pending:n, busy? }.
|
|
84
|
+
*/
|
|
85
|
+
async flush(send, { profile = null, includeUnsure = false } = {}) {
|
|
86
|
+
if (flushing) return { sent: [], failed: [], pending: 0, busy: true };
|
|
87
|
+
flushing = true;
|
|
88
|
+
const sent = []; const failed = [];
|
|
89
|
+
try {
|
|
90
|
+
const ids = settle(read()).items.filter(i => mine(i, profile) && (i.status === 'queued' || (includeUnsure && i.status === 'unsure'))).map(i => i.id);
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const d = read();
|
|
93
|
+
const it = d.items.find(i => i.id === id);
|
|
94
|
+
if (!it || !(it.status === 'queued' || it.status === 'unsure')) continue; // dropped or sent meanwhile
|
|
95
|
+
it.status = 'sending'; it.tries = (it.tries || 0) + 1; write(d); // persisted BEFORE the request
|
|
96
|
+
let r;
|
|
97
|
+
try { r = await send(it); } catch (e) { r = { ok: false, retryable: true, error: e?.message }; }
|
|
98
|
+
const after = read();
|
|
99
|
+
const cur = after.items.find(i => i.id === id);
|
|
100
|
+
if (r?.ok) { after.items = after.items.filter(i => i.id !== id); write(after); sent.push(it); continue; }
|
|
101
|
+
if (cur) { cur.status = r?.retryable === false ? 'failed' : 'queued'; if (r?.error) cur.error = String(r.error); write(after); }
|
|
102
|
+
if (r?.retryable === false) { failed.push({ ...it, error: r.error || 'refused' }); continue; }
|
|
103
|
+
break; // still not answering: keep the rest, in order, for the next recovery
|
|
104
|
+
}
|
|
105
|
+
} finally { flushing = false; }
|
|
106
|
+
return { sent, failed, pending: settle(read()).items.filter(i => mine(i, profile) && i.status === 'queued').length };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const queuedLine = (slot, count) => `⏸ queued for ${slot}: will send when QuoxCORE answers (${count} queued) · /outbox`;
|
|
112
|
+
export const sentLine = (slot) => `✔ sent queued message to ${slot}`;
|
|
113
|
+
export const fullLine = (cap = OUTBOX_CAP) => `Outbox is full (${cap} queued). Not queued: your text is back in the prompt. /outbox shows them, /outbox drop N removes one, /outbox send retries now.`;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { findCommandEntry } from './commands.js';
|
|
9
9
|
|
|
10
10
|
// Commands that need the human's own typing: never offered, never run from a model suggestion.
|
|
11
|
-
export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'restart', 'quit', 'exit', 'login']);
|
|
11
|
+
export const HUMAN_TYPED = new Set(['approve', 'yestoall', 'deny', 'authorise', 'authorize', 'authenticate', 'override', 'verify-email', 'restart', 'quit', 'exit', 'login']);
|
|
12
12
|
// Pure views, no effect: may run without asking when explicit, and only with no arguments.
|
|
13
13
|
export const READ_ONLY_VIEWS = new Set(['approvals', 'mind', 'which', 'context', 'rooms', 'active', 'slots']);
|
|
14
14
|
|
package/lib/quoxon/term.js
CHANGED
|
@@ -48,8 +48,19 @@ export function inkStdout(out, { tap = null } = {}) {
|
|
|
48
48
|
if (cb) out.write('', cb); // nothing to send: still a barrier behind the earlier writes
|
|
49
49
|
return true;
|
|
50
50
|
} : null;
|
|
51
|
-
|
|
51
|
+
// Resize: Ink repaints its last committed tree at the new width the moment 'resize' fires, which wraps every 78-wide rule for a
|
|
52
|
+
// beat (owner 2026-10-27: top rule and model label wrapped after 79 -> 58). Listeners for 'resize' run RESIZE_SETTLE_MS late, so
|
|
53
|
+
// the app (which flags its own listener `resizeFirst`) commits the new column count first and Ink paints the right width once.
|
|
54
|
+
const lateOf = new WeakMap();
|
|
55
|
+
const lateListener = (fn) => {
|
|
56
|
+
if (fn.resizeFirst) return fn;
|
|
57
|
+
if (!lateOf.has(fn)) lateOf.set(fn, (...a) => { const t = setTimeout(() => fn(...a), RESIZE_SETTLE_MS); t.unref?.(); });
|
|
58
|
+
return lateOf.get(fn);
|
|
59
|
+
};
|
|
60
|
+
const proxy = new Proxy(out, {
|
|
52
61
|
get(target, prop) {
|
|
62
|
+
if ((prop === 'on' || prop === 'addListener' || prop === 'once') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
|
|
63
|
+
if ((prop === 'off' || prop === 'removeListener') && typeof target[prop] === 'function') return (ev, fn, ...r) => { target[prop](ev, ev === 'resize' && typeof fn === 'function' ? lateListener(fn) : fn, ...r); return proxy; };
|
|
53
64
|
if (prop === 'write' && write) return write;
|
|
54
65
|
if (prop === 'rows') return Infinity;
|
|
55
66
|
if (prop === 'termRows') return target.rows;
|
|
@@ -61,7 +72,20 @@ export function inkStdout(out, { tap = null } = {}) {
|
|
|
61
72
|
},
|
|
62
73
|
set(target, prop, value) { return Reflect.set(target, prop, value, target); }
|
|
63
74
|
});
|
|
75
|
+
return proxy;
|
|
76
|
+
}
|
|
77
|
+
const RESIZE_SETTLE_MS = 40;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The width to print history at, read from the terminal NOW. The `cols` state lags a resize (the listener attaches after mount,
|
|
81
|
+
* and the commit is async), and a <Static> item printed at the stale width is wider than the new terminal, so the terminal
|
|
82
|
+
* breaks it mid-word. Never wider than the state either (a pinned `columns` prop wins when it is narrower).
|
|
83
|
+
*/
|
|
84
|
+
export function liveCols(stdout, stateCols) {
|
|
85
|
+
const n = Number(stdout?.columns);
|
|
86
|
+
return Number.isFinite(n) && n > 0 ? Math.min(stateCols, Math.max(MIN_STATIC_COLS, n - 1)) : stateCols;
|
|
64
87
|
}
|
|
88
|
+
const MIN_STATIC_COLS = 20;
|
|
65
89
|
|
|
66
90
|
/** Real terminal height, or null when unknown (not a TTY, tests). */
|
|
67
91
|
export function termRows(stdout) {
|
package/lib/quoxon/transcript.js
CHANGED
|
@@ -11,6 +11,7 @@ import { stripMarks } from './palette.js';
|
|
|
11
11
|
import { textGate } from './events.js';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
+
import { redactSecrets } from './secretShape.js';
|
|
14
15
|
|
|
15
16
|
export const MAX_FIELD = 4000;
|
|
16
17
|
export const SILENT_GAP_MS = 10000;
|
|
@@ -33,7 +34,7 @@ const SCRUBS = [
|
|
|
33
34
|
export function scrub(s) {
|
|
34
35
|
let out = String(s);
|
|
35
36
|
for (const [re, rep] of SCRUBS) out = out.replace(re, rep);
|
|
36
|
-
return out;
|
|
37
|
+
return redactSecrets(out); // SECRET-GUARD: bank/PIN/card shapes, after the key patterns
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/** Strings are scrubbed and truncated; non-strings become a scrubbed JSON string (never a raw object). */
|
package/lib/quoxon/welcome.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
|
|
9
|
-
export const DOCS_URL = 'https://
|
|
9
|
+
export const DOCS_URL = 'https://quox.ai/cli/'; // #1100: CLI docs start page (cli.quox.ai retired 2026-10-26, moved to quox.ai/cli)
|
|
10
10
|
export const TERMS_URL = 'https://quox.ai/terms';
|
|
11
11
|
export const PRIVACY_URL = 'https://quox.ai/privacy';
|
|
12
12
|
export const LICENCE = 'Quox Community Source Licence 1.0 (source-available)';
|
package/lib/quoxon/whatsnew.js
CHANGED
|
@@ -109,7 +109,7 @@ export function listWhatsNew(arg = '', entries = ENTRIES) {
|
|
|
109
109
|
// Startup preference: quoxon.whatsNewStartup = 'on' | 'off' (default on). Both live in the synced profile settings.
|
|
110
110
|
export const startupEnabled = (settings) => String(settings?.whatsNewStartup || 'on').toLowerCase() !== 'off';
|
|
111
111
|
|
|
112
|
-
const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active');
|
|
112
|
+
const activeCommands = (registry) => registry.filter(e => e.kind === 'command' && e.status === 'active' && !e.dyn);
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Commands the user has not been shown, newest first (registry is oldest first, so reversed). With no record yet
|
|
@@ -132,3 +132,39 @@
|
|
|
132
132
|
{"id": "loop-card-polish", "date": "2026-10-26T13:00:00Z", "since": "0.3.1", "title": "The loop card keeps its own count", "text": "The card now counts the loop's own turns (turn 2 of 5, not the message count), shows the whole runway, fills a receipts bar as each one is witnessed, and says a milestone once. With an empty composer, press Down to reach its keys. A finished loop calls its dragon.", "try": "/loop list"}
|
|
133
133
|
{"id": "org-picker", "date": "2026-10-26T16:30:00Z", "since": "0.3.1", "title": "Switch org in one keystroke", "text": "Type /org for a list of your orgs, arrow to one and press Enter: this window moves there at once and the status bar follows. Press d instead to also make it your default. /org hampshire switches straight away when only one org matches, and quox org switch with no argument gives a numbered list.", "try": "/org"}
|
|
134
134
|
{"id":"quoxcli-0-4-0","date":"2026-10-26T18:00:00Z","since":"0.4.0","title":"quoxon extensions, an inbox bar, history and mouse editing","text":"Add and manage extensions with quox ext add, quox ext remove and quox ext list, or /ext in the window. /inbox and a notification bar show what is waiting. Up and Ctrl+R search your history. /mouse turns on optional mouse editing. Plan approval cards now list their steps, and live loop cards no longer freeze the window.","try":"/ext","shell":"quox ext list"}
|
|
135
|
+
{"id": "dragons-vulcan-quoxperticus", "date": "2026-10-27T09:00:00Z", "since": "0.3.1", "title": "Two new dragons: Vulcan and Quoxperticus", "text": "Two new dragons: Vulcan, the forge dragon, and Quoxperticus, the fleet conductor. Attach either agent and the dragon greets you, listens while you work, waits with you at approvals and shows when something needs attention.", "try": "/attach vulcan"}
|
|
136
|
+
{"id": "quoxon-2026-10-27-honest-window", "date": "2026-10-27T12:30:00Z", "since": "0.4.0", "title": "A calmer, more honest window", "text": "When your model hits a usage limit, the window offers a working one (y/N). Auto-restart waits at least 30 minutes between restarts and only when you are idle; inside that time a new version shows a quiet line and Enter restarts. The status bar no longer says nothing is waiting while items are open or a source can't be read. /say and /observe reach your slots in your other orgs (name@org picks one). /approvals opens faster, and a refused loop shows why instead of fake progress.", "try": "/approvals"}
|
|
137
|
+
{"id": "quoxon-2026-10-27-delete-confirm", "date": "2026-10-27T16:30:00Z", "since": "0.4.0", "title": "Deletes need the name typed", "text": "An approval that deletes something (delete_*, remove_*, purge_*, destroy_*, drop_*, wipe_*) now leads with DELETES and the object, and a single y no longer approves it: y asks you to type the name, Esc or n goes back, n on the card still denies. Bulk approve (/yestoall, /approve all) skips deletes and tells you how many are left to confirm one by one.", "try": "/approvals"}
|
|
138
|
+
{"id": "quoxon-2026-10-28-approval-burst", "date": "2026-10-28T00:30:00Z", "since": "0.4.0", "title": "A burst of approvals is one card", "text": "When one agent asks to run the same tool several times within 5 minutes (say nine wire_alerts), you get ONE card: 'VIGIL wants to run wire_alerts on 9 items'. y approves all, n denies all, p walks them one by one, Tab puts it away. Deletes and risky asks are never in the batch; they keep their own card and confirm. The inbox arrival line collapses the same way.", "try": "/approvals"}
|
|
139
|
+
{"id": "quoxon-2026-10-28-burst-across-polls", "date": "2026-10-28T04:00:00Z", "since": "0.4.0", "title": "Pack installs on many hosts are one card", "text": "DevSuite pack installs filed a few seconds apart for different hosts used to arrive as one card each. They now merge into ONE card (y approves all, n denies all, p picks), and the card says who asked in plain words ('DevSuite (skill-pack sync)') instead of an internal key. Restart, deploy and other disruptive asks still never batch.", "try": "/approvals"}
|
|
140
|
+
{"id": "quoxon-2026-10-28-afk-quiet-cards", "date": "2026-10-28T06:45:00Z", "since": "0.4.0", "title": "Away means quiet", "text": "After /afk or /sleep, new approvals no longer open a card or arm y/n (a stray key cannot answer one). They are only counted: \"⏸ N approvals waiting · /back to review\". /back, or any message you type, opens them as before. Nothing is ever answered for you."}
|
|
141
|
+
{"id": "quoxon-2026-10-28-agent-profile-offer", "date": "2026-10-28T15:00:00Z", "since": "0.4.0", "title": "Opened as an agent? Quox offers your own login", "text": "Starting quox on an agent profile while you have a signed-in human profile now asks once (y/N) to restart as you. Typing \"attach to hitl slot\" offers /slot hitl instead of going to the model."}
|
|
142
|
+
{"id": "quoxon-2026-10-28-secret-paste-guard", "date": "2026-10-28T17:00:00Z", "since": "0.4.0", "title": "Pasted credentials are caught", "text": "A message that looks like bank details, a PIN, a password, a card number or an API key now asks \"Don't send it?\" (y keeps it out, n sends anyway). Nothing is sent or saved on y, and history and transcripts store [REDACTED:kind] in place of the value either way."}
|
|
143
|
+
{"id": "quoxon-2026-10-28-do-not-disturb", "date": "2026-10-28T18:00:00Z", "since": "0.4.0", "title": "Sleep means sleep: /donotdisturb and /morningreport", "text": "/donotdisturb (or /dnd, optionally until 07:30) collects everything quietly: no notices, approval cards, bells or dragon. The status line shows \"🌙 do not disturb · N waiting · /morningreport\". /morningreport groups what came in (incidents first, repeats as ×N) and offers to turn it off. /afk and /sleep imply it, saying you are going to sleep offers it, and a restarted window restores it."}
|
|
144
|
+
{"id": "quoxon-2026-10-29-provider-test-json", "date": "2026-10-29T12:00:00Z", "since": "0.4.0", "title": "quox provider test --json says why", "text": "quox provider test --json now prints every provider's result (ok, error, reason code) and a summary, instead of nothing, and still exits 1 when one fails."}
|
|
145
|
+
{"id": "quoxon-2026-10-29-connect-server-install", "date": "2026-10-29T13:00:00Z", "since": "0.4.0", "title": "quox connect and quox server install", "text": "quox connect links this machine to a Quox server. quox server install sets up a new server on this machine: checks, download, start, owner account and a one-time sign-in link. Try quox server install --dry-run first. The sign-in link is not printed into piped or --json output unless you pass --print-login-link.", "shell": "quox server install --dry-run", "sha": "34a547724"}
|
|
146
|
+
{"id": "quoxon-2026-10-29-morningreport-fallback", "date": "2026-10-29T14:00:00Z", "since": "0.4.0", "title": "/morningreport shows the digest when nothing was held", "text": "With do not disturb off, /morningreport now says nothing was held and shows the same While you were away digest as /away, so you no longer need a second command.", "sha": "c4f2a3c90"}
|
|
147
|
+
{"id": "quoxon-2026-10-29-never-answered-offer", "date": "2026-10-29T15:00:00Z", "since": "0.4.0", "title": "A server that never answered now offers to connect elsewhere", "text": "If this machine has never reached the server a profile points at, Quox now asks whether to connect to a different one instead of waiting on a restart that is not happening. Say y, type the address, and it checks the server, shows its certificate and signs you in."}
|
|
148
|
+
{"id": "quoxon-2026-10-29-approval-outcome", "date": "2026-10-29T16:00:00Z", "since": "0.4.0", "title": "You now see whether an approved action worked", "text": "After you approve a request from another session, Quox follows up with one line saying whether it finished or failed and why. The failed-to-execute notice now carries the approval id, so quox inbox show <id> opens it."}
|
|
149
|
+
{"id": "quoxon-2026-10-29-deck-lazy-offer", "date": "2026-10-29T17:00:00Z", "since": "0.4.0", "title": "/deck now offers to install what it needs", "text": "If quoxdeck is missing, or its audio headers (libasound2-dev) are, /deck on asks once whether to install them, shows the exact commands, and runs them with your own sudo prompt. Say n and it prints the commands to run later."}
|
|
150
|
+
{"id":"quoxon-2026-10-29-connect-no-terminal","date":"2026-10-29T08:50:36Z","since":"0.4.0","title":"quox connect can run with no terminal","text":"quox connect now takes --email, --password-stdin and --fingerprint <sha256>, so a script or chat can connect and sign in with no prompts. quox server status shows the certificate fingerprint to compare, a certificate Quox does not trust now says so in plain words with the fix, and the same email on a different server no longer trips the same-user warning."}
|
|
151
|
+
{"id":"quoxcli-2026-10-29-tls-trust-hint","date":"2026-10-29T09:26:52Z","since":"0.4.0","title":"A certificate Quox does not trust now says so on login","text":"If a server uses a certificate this machine does not trust yet, quox login now says so in plain words and prints the exact quox connect command to trust it (it shows the fingerprint and asks y/N; --trust-cert or --fingerprint for no terminal), instead of just \"fetch failed\"."}
|
|
152
|
+
{"id":"quoxon-2026-10-29-gripe-queue","date":"2026-10-29T09:46:49Z","since":"0.4.0","title":"/gripe no longer loses a gripe when Quox is slow","text":"If /gripe! times out or the server errors, the gripe is saved on your machine and filed when Quox answers again, without making a duplicate issue if the first try had actually gone through. /gripes shows what is waiting and what was filed."}
|
|
153
|
+
{"id":"quoxon-2026-10-29-gripe-paste-lines","date":"2026-10-29T10:10:00Z","since":"0.4.0","title":"/gripe keeps the lines of what you paste","text":"A multi-line paste into /gripe now reaches the issue with its lines intact instead of one run-on line, and the confirm says how many more lines will be filed."}
|
|
154
|
+
{"id":"quoxon-2026-10-29-advice-independent","date":"2026-10-29T11:00:00Z","since":"0.4.0","title":"Test-edit approvals no longer echo the asker as advice","text":"On a test-edit approval card, ADVICE now cites the diff when it is known, or says there is no independent view, instead of repeating the asker's own recommendation; the starred recommended-by line stays."}
|
|
155
|
+
{"id":"quoxon-2026-10-29-resume-no-duplicate","date":"2026-10-29T12:00:00Z","since":"0.4.0","title":"quox claude --resume no longer starts a second copy","text":"Resuming a named session now finds its tmux session whatever the capitals and attaches to it, and refuses with the pid and the attach command when that conversation is already running anywhere. --force starts a second copy on purpose."}
|
|
156
|
+
{"id": "quoxon-2026-10-29-sleep-one-step", "date": "2026-10-29T09:00:00Z", "since": "0.4.0", "title": "/sleep is one step", "text": "/sleep (or /afk going to bed) sets you asleep with do not disturb on in one command. A bare /dnd now only ever turns it on; /dnd off ends it. Plain /afk offers do not disturb with one y/N."}
|
|
157
|
+
{"id": "quoxon-2026-10-29-roster-sessions", "date": "2026-10-29T17:30:00Z", "since": "0.4.0", "title": "/roster shows attached agents and duplicate sessions", "text": "/roster now lists the live agent attaches in your org and every claude --resume process on this box, grouped by name. A conversation running more than once is flagged with each pid. The same attach list is quox agent attaches."}
|
|
158
|
+
{"id": "quoxon-2026-10-29-announce-everything", "date": "2026-10-29T20:00:00Z", "since": "0.4.0", "title": "Quox announces what it does", "text": "Memory saves, approvals, slot messages, connect and vault create now print one Quox line saying what happened, success or failure, and the chat transcript shows the same line for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox memory create --type fact --content hello"}
|
|
159
|
+
{"id": "quoxon-2026-10-29-walkthrough-command", "date": "2026-10-29T22:30:00Z", "since": "0.4.0", "title": "The walkthrough shows the command", "text": "Each approval card in the walkthrough now shows the exact command in full, never cut off, and the host it runs on. When the reason was guessed from the command rather than stated by the asker, it is labelled so.", "try": "/walkthrough"}
|
|
160
|
+
{"id": "quoxon-2026-10-29-chat-quox-lines", "date": "2026-10-29T22:31:00Z", "since": "0.4.0", "title": "quox chat prints what Quox did", "text": "quox chat, streaming or --no-stream, now prints one Quox line on stderr for what a turn did (a saved memory, an approval, a delegation). Silent under --json and --quiet.", "shell": "quox chat --no-stream \"remember that the proof tag is QA-1036\""}
|
|
161
|
+
{"id": "quoxon-2026-10-30-inbox-unread-quiet-noreply", "date": "2026-10-30T07:00:00Z", "since": "0.4.0", "title": "Undo a read, quieter no-reply, honest timeouts", "text": "/inbox unread N puts an item back as unread. The no reply from a slot notice is dim and skipped when that slot spoke in the last 10 minutes. An approved action whose result timed out is titled no result in time (it may still have run) instead of FAILED.", "try": "/inbox unread 1"}
|
|
162
|
+
{"id": "quoxon-2026-10-30-reattach-deploy-line", "date": "2026-10-30T08:00:00Z", "since": "0.4.0", "title": "A slot reattach during a deploy says so", "text": "When QuoxCORE is being redeployed, a slow slot reattach now says it is restarting and reconnecting, instead of suggesting a stale URL or QUOX_COLLECTOR_URL. A URL that never answered still gets the full diagnostic.", "try": "/slot"}
|
|
163
|
+
{"id":"quoxon-2026-10-30-inbox-unread-cli","date":"2026-10-30T08:27:43Z","since":"0.4.0","title":"Mark unread from a shell, read items whole","text":"quox inbox unread <id> undoes a mark-as-read from a shell, and /inbox unread all flips back every read item. /inbox N now shows the full body of every item (it missed the stored body before), the sender and subject for mail, and the actions that apply.","try":"/inbox 1","shell":"quox inbox unread <id>"}
|
|
164
|
+
{"id":"quoxon-2026-10-30-presence-owner-level","date":"2026-10-30T08:56:21Z","since":"0.4.0","title":"Good morning waits for you; /back agrees across windows","text":"LUCID no longer says good morning at 08:00 while you are asleep or away: it waits until you are back. If another window already marked you present, /back reports that same away span instead of its own.","try":"/back"}
|
|
165
|
+
{"id":"quoxon-2026-10-30-tui-mail","date":"2026-10-30T09:30:00Z","since":"0.4.0","title":"Read your email in the terminal: /mail","text":"/mail lists your newest triaged email (sender, subject, age, zone) and /mail N opens one with its body excerpt and why it was triaged that way. Read only: nothing is sent, moved or deleted. With no mailbox connected it names the connect command. /inbox now points at /mail.","try":"/mail"}
|
|
166
|
+
{"id":"quoxon-2026-10-30-dnd-survives-restart","date":"2026-10-30T10:40:24Z","since":"0.4.0","title":"Do not disturb now survives restarts","text":"Do not disturb is saved on the server for you, not just in one window, so an auto-restart no longer drops it, even when the server is slow to answer at that moment: the window keeps retrying until it can read it.","try":"/dnd"}
|
|
167
|
+
{"id":"quoxon-2026-10-30-reply-address","date":"2026-10-30T11:00:00Z","since":"0.4.0","title":"Messages from slot holders now show their reply address","text":"A message from a session that holds a slot no longer says (no reply address): the TUI looks up the slot the sender holds, so Tab or /r replies to it. It says so only when the sender truly holds no slot."}
|
|
168
|
+
{"id":"quoxon-2026-10-30-approval-row-background","date":"2026-10-30T12:00:00Z","since":"0.4.0","title":"Approval rows flag background work","text":"/approvals rows now say when a command runs in the background, including screen, tmux and docker detached runs."}
|
|
169
|
+
{"id":"quoxon-2026-10-30-confirm-target-once","date":"2026-10-30T22:00:00Z","since":"0.4.0","title":"Approval confirmations name the hosts once","text":"The line after you approve a multi-host command no longer repeats the host list twice."}
|
|
170
|
+
{"id":"quoxon-2026-10-30-away-retry","date":"2026-10-30T22:10:00Z","since":"0.4.0","title":"While you were away retries a slow source","text":"If a source times out right after sign-in, the digest asks it once more before it says it cannot see it."}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quox server install`: the existing guided installer behind a CLI verb (ONBOARD-P1).
|
|
3
|
+
*
|
|
4
|
+
* Not a second installer. Preflight (onboarding/preflight.js), the real runner (installRunner.js: get.quox.ai ->
|
|
5
|
+
* setup.sh) and the finisher (finish.js: owner account, sign-in, one-time link) are the same modules the Getting
|
|
6
|
+
* started screen uses; this file only sequences them and turns their events into stage events:
|
|
7
|
+
*
|
|
8
|
+
* preflight docker download compose-up health owner vault link
|
|
9
|
+
*
|
|
10
|
+
* The runner's own stages fold in as: fetch + configure + build -> download, start -> compose-up, health -> health.
|
|
11
|
+
* `vault` is skipped here (no AI key is taken on argv); the next step is printed.
|
|
12
|
+
* Nothing is claimed ok that did not answer: health is the runner's own 2xx probe of the auth service.
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
import { QuoxUserError } from '../core/errors.js';
|
|
16
|
+
import { confirmInteractive } from '../core/confirm.js';
|
|
17
|
+
import { runPreflight, preflightPassed, defaultDeps as preflightDeps } from '../quoxon/onboarding/preflight.js';
|
|
18
|
+
import { defaultInstallDir } from '../quoxon/onboarding/installRunner.js';
|
|
19
|
+
import { passwordProblem, emailOk } from '../quoxon/onboarding/finish.js';
|
|
20
|
+
|
|
21
|
+
export const INSTALL_STAGES = ['preflight', 'docker', 'download', 'compose-up', 'health', 'owner', 'vault', 'link'];
|
|
22
|
+
const FOLD = { fetch: 'download', configure: 'download', build: 'download', start: 'compose-up', health: 'health' };
|
|
23
|
+
export const PLAN_STEPS = [
|
|
24
|
+
'check Docker, Compose, git, openssl, disk, RAM and ports (read-only)',
|
|
25
|
+
'download the installer from get.quox.ai and show its SHA256',
|
|
26
|
+
'clone the Quox source into the install directory',
|
|
27
|
+
'write secrets (.env) and a self-signed certificate',
|
|
28
|
+
'download the Quox images',
|
|
29
|
+
'start the services with docker compose up -d',
|
|
30
|
+
'wait until the auth service answers /health with a 2xx',
|
|
31
|
+
'create your owner account and sign in',
|
|
32
|
+
'print a one-time dashboard sign-in link (10 minutes)'
|
|
33
|
+
];
|
|
34
|
+
const AGAIN = 'quox server install';
|
|
35
|
+
const LINK_HOW = 'quox -p <profile> dashboard (mints a fresh one-time sign-in link), or re-run with --print-login-link';
|
|
36
|
+
|
|
37
|
+
/** The preflight copy is shared with the TUI ("press r"); the CLI/JSON path speaks CLI. */
|
|
38
|
+
const cliWords = (t) => String(t == null ? t : t).replace(/,? then press r to check again\.?/gi, ` Then run: ${AGAIN}`).replace(/,? then press r( to try again)?\.?/gi, '.').replace(/\s+\./g, '.').replace(/\.\s*\./g, '.');
|
|
39
|
+
/** This verb never installs Docker: the honest offer is a manual one with the doc link. */
|
|
40
|
+
const cliRow = (r) => ({
|
|
41
|
+
...r,
|
|
42
|
+
...(r.fix ? { fix: cliWords(r.fix) } : {}),
|
|
43
|
+
...(r.offer === 'install' ? { offer: 'manual' } : {})
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** First line of stdin, no trailing newline. */
|
|
47
|
+
export async function readPasswordStdin(readStdin) {
|
|
48
|
+
const first = String(await readStdin()).split(/\r?\n/)[0];
|
|
49
|
+
if (!first) throw new QuoxUserError('--password-stdin got an empty first line. Pipe the password in, for example: printf %s "$PW" | quox server install --password-stdin', { exitCode: 2, code: 'empty_password' });
|
|
50
|
+
return first;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function defaultReadStdin() {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
56
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function defaultPrompt(message, type) {
|
|
60
|
+
const { default: inquirer } = await import('inquirer');
|
|
61
|
+
const a = await inquirer.prompt([{ type, name: 'v', message, ...(type === 'password' ? { mask: '*' } : {}) }]);
|
|
62
|
+
return a.v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function defaultMakeInstall({ dir, env = process.env } = {}) {
|
|
66
|
+
const { realInstall } = await import('../quoxon/onboarding/index.js');
|
|
67
|
+
return realInstall({ env, installDir: dir });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* -> { ok, ... }. Throws QuoxUserError (exit 2) for refusals made before anything changes.
|
|
72
|
+
* opts: { dir, dryRun, yes, email, passwordStdin }; deps: see defaults; emit(event).
|
|
73
|
+
*/
|
|
74
|
+
export async function runInstallFlow(opts = {}, deps = {}, emit = () => {}) {
|
|
75
|
+
const d = {
|
|
76
|
+
runChecks: runPreflight, makeInstall: defaultMakeInstall, confirm: confirmInteractive,
|
|
77
|
+
isTTY: () => Boolean(process.stdin.isTTY), readStdin: defaultReadStdin,
|
|
78
|
+
promptEmail: () => defaultPrompt('Owner email', 'input'), promptPassword: () => defaultPrompt('Owner password (hidden)', 'password'),
|
|
79
|
+
...deps
|
|
80
|
+
};
|
|
81
|
+
const { dryRun = false, yes = false, passwordStdin = false } = opts;
|
|
82
|
+
const dir = resolve(opts.dir || defaultInstallDir());
|
|
83
|
+
const printLink = Boolean(opts.printLink);
|
|
84
|
+
const stage = (s, status, extra = {}) => emit({ type: 'stage', stage: s, status, ...extra });
|
|
85
|
+
const fail = (failedStage, error, next, extra = {}) => {
|
|
86
|
+
stage(failedStage, 'fail', { error, next });
|
|
87
|
+
const res = { ok: false, failedStage, error, next, ...extra };
|
|
88
|
+
emit({ type: 'result', ...res });
|
|
89
|
+
return res;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ---- refusals first: nothing has been touched --------------------------------------------------------------
|
|
93
|
+
if (!dryRun) {
|
|
94
|
+
if (!yes && !d.isTTY()) throw new QuoxUserError('No terminal to answer y/N here. Read the plan with --dry-run, then re-run with --yes to install.', { exitCode: 2, code: 'no_terminal' });
|
|
95
|
+
if (!d.isTTY() && !passwordStdin) throw new QuoxUserError('No terminal for the owner password prompt. Pipe it in with --password-stdin (the password is never taken as an argument).', { exitCode: 2, code: 'no_terminal' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- preflight (read-only, also in a dry run) --------------------------------------------------------------
|
|
99
|
+
stage('preflight', 'running');
|
|
100
|
+
const rows = (await d.runChecks(preflightDeps(), () => {})).map(cliRow);
|
|
101
|
+
const dockerRows = rows.filter(r => r.id === 'docker' || r.id === 'compose');
|
|
102
|
+
const otherRows = rows.filter(r => r.id !== 'docker' && r.id !== 'compose');
|
|
103
|
+
const nextFor = (rs) => rs.filter(r => r.status === 'fail').map(r => `${r.fix || r.body}`).join(' ');
|
|
104
|
+
const bad = rows.filter(r => r.status === 'fail');
|
|
105
|
+
const otherBad = bad.filter(r => r.id !== 'docker' && r.id !== 'compose');
|
|
106
|
+
if (otherBad.length) return fail('preflight', bad.map(r => r.body).join('; '), `${nextFor(bad)} Then run: ${AGAIN}`, { checks: rows });
|
|
107
|
+
stage('preflight', 'ok', { detail: otherRows.map(r => r.body).join('; ') });
|
|
108
|
+
if (bad.length) return fail('docker', bad.map(r => r.body).join('; '), `${nextFor(bad)} Then run: ${AGAIN}`, { checks: rows });
|
|
109
|
+
stage('docker', 'ok', { detail: dockerRows.map(r => r.body).join('; ') });
|
|
110
|
+
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
emit({ type: 'plan', dir, installerUrl: 'https://get.quox.ai', checks: rows.map(r => ({ id: r.id, status: r.status, body: r.body })), steps: PLAN_STEPS, changed: 'nothing' });
|
|
113
|
+
const res = { ok: true, dryRun: true };
|
|
114
|
+
emit({ type: 'result', ...res });
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- confirm, then collect the owner credentials BEFORE the long install -----------------------------------
|
|
119
|
+
if (!yes && !(await d.confirm(`Install Quox into ${dir} now?`, { flag: '--yes', purpose: 'install' }))) {
|
|
120
|
+
const res = { ok: false, declined: true, error: 'not confirmed', next: `Nothing was changed. Run ${AGAIN} --dry-run to see the plan.` };
|
|
121
|
+
emit({ type: 'result', ...res });
|
|
122
|
+
return res;
|
|
123
|
+
}
|
|
124
|
+
let email = opts.email ? String(opts.email).trim() : null;
|
|
125
|
+
if (!email && d.isTTY()) email = String(await d.promptEmail()).trim();
|
|
126
|
+
if (!emailOk(email)) throw new QuoxUserError('A valid owner email is needed: pass --email <address>.', { exitCode: 2, code: 'bad_email' });
|
|
127
|
+
const password = passwordStdin ? await readPasswordStdin(d.readStdin) : await d.promptPassword();
|
|
128
|
+
const weak = passwordProblem(password);
|
|
129
|
+
if (weak) throw new QuoxUserError(`The owner password is not accepted: ${weak}`, { exitCode: 2, code: 'weak_password' });
|
|
130
|
+
|
|
131
|
+
// ---- the install itself (the same runner Getting started uses) ----------------------------------------------
|
|
132
|
+
const { runner, finisher } = await d.makeInstall({ dir });
|
|
133
|
+
const open = {};
|
|
134
|
+
const res = await runner.run({
|
|
135
|
+
onEvent: (e) => {
|
|
136
|
+
if (e.type === 'log') { emit({ type: 'log', line: e.line }); return; }
|
|
137
|
+
if (e.type !== 'stage') return;
|
|
138
|
+
const s = FOLD[e.id]; if (!s) return;
|
|
139
|
+
if (e.status === 'running') { if (!open[s]) { open[s] = true; stage(s, 'running'); } return; }
|
|
140
|
+
if (e.status === 'ok' && (e.id === 'build' || e.id === 'start' || e.id === 'health')) { stage(s, 'ok', e.ms != null ? { ms: e.ms } : {}); open[s] = false; }
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
const failed = FOLD[res.failedStage] || res.failedStage || 'download';
|
|
145
|
+
if (res.interrupted) return fail(failed, 'interrupted', `Run ${AGAIN} again; finished steps are kept.`);
|
|
146
|
+
const next = [res.cause?.next || res.cause?.fix, res.hint || 'Finished steps are kept.', `Then run: ${AGAIN}`].filter(Boolean).join(' ');
|
|
147
|
+
return fail(failed, res.error || 'the installer failed', next, { logPath: res.logPath, ...(res.dashboardUrl ? { dashboardUrl: res.dashboardUrl } : {}) });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- owner account, sign-in, vault, link ---------------------------------------------------------------------
|
|
151
|
+
stage('owner', 'running');
|
|
152
|
+
const made = await finisher.createOwner({ email, password });
|
|
153
|
+
if (!made.ok) {
|
|
154
|
+
return fail('owner', made.error || 'the owner account was not created',
|
|
155
|
+
made.existed ? 'An owner already exists on this server. Sign in with: quox login' : `The server is running. Create the owner with: quox setup`, { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
|
|
156
|
+
}
|
|
157
|
+
const signed = await finisher.signIn({ email, password });
|
|
158
|
+
if (!signed.ok) return fail('owner', `owner created, but sign-in failed: ${signed.error}`, 'Sign in with: quox login', { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
|
|
159
|
+
stage('owner', 'ok', { detail: email });
|
|
160
|
+
stage('vault', 'skip', { detail: 'no AI key was given here', next: 'Add one with: quox provider connect' });
|
|
161
|
+
const link = await finisher.loginLink(res.dashboardUrl);
|
|
162
|
+
if (!link.ok) return fail('link', link.error || 'could not create the sign-in link', `Open ${res.dashboardUrl || 'https://localhost:3000'} and sign in with ${email}.`, { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
|
|
163
|
+
stage('link', 'ok', { ttlSeconds: link.ttlSeconds, linkIssued: true, ...(printLink ? {} : { linkHow: LINK_HOW }) });
|
|
164
|
+
const done = { ok: true, dashboardUrl: res.dashboardUrl, link: link.url, installDir: runner.installDir, logPath: res.logPath };
|
|
165
|
+
const { link: liveLink, ...safe } = done;
|
|
166
|
+
emit({ type: 'result', ...(printLink ? done : { ...safe, linkIssued: true, linkHow: LINK_HOW }) });
|
|
167
|
+
return done;
|
|
168
|
+
}
|
package/lib/server/status.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
|
+
import { X509Certificate } from 'node:crypto';
|
|
7
8
|
import { readInstalled } from './install.js';
|
|
8
9
|
|
|
9
10
|
const STATE_FILE = 'lifecycle-state.json';
|
|
@@ -67,6 +68,18 @@ async function getJson(url, fetchImpl, timeout = 3000) {
|
|
|
67
68
|
} catch (e) { return { ok: false, error: e?.cause?.code || e?.message || 'no answer' }; }
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* H3: the SHA256 fingerprint of the certificate the edge serves (ssl/quox.crt in the checkout), in the same format
|
|
73
|
+
* as the `quox connect` card, so the two can be compared by eye or by script. Never throws.
|
|
74
|
+
*/
|
|
75
|
+
export function certFingerprint(repoDir, { read = readFileSync } = {}) {
|
|
76
|
+
const file = join(repoDir || '', 'ssl', 'quox.crt');
|
|
77
|
+
try {
|
|
78
|
+
const c = new X509Certificate(read(file));
|
|
79
|
+
return { ok: true, file, fingerprint256: c.fingerprint256, validTo: c.validTo };
|
|
80
|
+
} catch (e) { return { ok: false, file, error: e?.code === 'ENOENT' ? 'no certificate file' : 'could not read the certificate' }; }
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
/** The running build, from the collector's public /version. */
|
|
71
84
|
export async function runningVersion(collectorUrl, fetchImpl = globalThis.fetch) {
|
|
72
85
|
const r = await getJson(`${collectorUrl.replace(/\/+$/, '')}/version`, fetchImpl);
|
|
@@ -99,7 +112,7 @@ export async function gatherStatus({ install, sys, fetchImpl = globalThis.fetch,
|
|
|
99
112
|
const hist = backups ? readHistory(backups) : [];
|
|
100
113
|
const lastUpdate = [...hist].reverse().find((e) => e.op === 'update' || e.op === 'rollback') || null;
|
|
101
114
|
const state = backups ? readState(backups) : null;
|
|
102
|
-
return { install, installed, running, services: svc, disk, http, lastUpdate, inFlight: state && state.status === 'running' ? state : null };
|
|
115
|
+
return { install, installed, running, services: svc, disk, http, cert: certFingerprint(install.repoDir), lastUpdate, inFlight: state && state.status === 'running' ? state : null };
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
const svcIcon = (s) => (s.state === 'running' ? (s.health && s.health !== 'healthy' ? '⚠' : '✔') : '✖');
|
|
@@ -113,6 +126,7 @@ export function statusRows(st) {
|
|
|
113
126
|
out.push(row('Version', `${i.release || '? no VERSION file'}${i.tag ? ` (${i.tag})` : ''} · checkout ${i.commit || '?'}${i.dirty ? ' · local changes' : ''}`));
|
|
114
127
|
const rv = st.running;
|
|
115
128
|
out.push(row('Running', rv.ok ? `${rv.commit || '?'}${rv.stamped === false ? ' (unstamped build)' : ''}${rv.commit && i.commit && !String(rv.commit).startsWith(i.commit) && !String(i.commit).startsWith(rv.commit) ? ' · differs from the checkout' : ''}` : `? ${rv.error}`));
|
|
129
|
+
if (st.cert) out.push(row('Certificate', st.cert.ok ? `SHA256 ${st.cert.fingerprint256} · valid to ${st.cert.validTo} (compare with the card \`quox connect\` shows)` : `? ${st.cert.error} (${st.cert.file})`));
|
|
116
130
|
for (const h of st.http) out.push(row(`${h.ok ? '✔' : '✖'} ${h.name}`, `${h.url} · ${h.detail} · ${h.ms}ms`));
|
|
117
131
|
if (!st.services.ok) out.push(row('✖ Services', `? ${st.services.error}`));
|
|
118
132
|
else {
|
package/lib/toolOutputShape.js
CHANGED
|
@@ -270,6 +270,59 @@ function smallTopFields(body, found) {
|
|
|
270
270
|
return Object.keys(out).length ? out : null;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* OVERSIZED-IDS: the oversized summary used to carry only a sample of whole rows, which a big row (logs, blobs)
|
|
276
|
+
* pushes out entirely, so the model lost every id/name and answered from another tool (keeper_backup_list,
|
|
277
|
+
* bob_log_tail). id_index keeps the first ID_INDEX_ROWS rows of every list (top level, or under a top key) reduced
|
|
278
|
+
* to identifier/status fields, inside ID_INDEX_BUDGET chars in total, and says how many rows were left out.
|
|
279
|
+
*/
|
|
280
|
+
export const ID_INDEX_ROWS = 20;
|
|
281
|
+
export const ID_INDEX_BUDGET = 6000;
|
|
282
|
+
const ID_FIELD_RE = /^(id|name|title|status|state|created_at|started_at|key|host|host_id|hostname|slug|label|kind|type)$|_id$/i;
|
|
283
|
+
const ID_VALUE_MAX = 80;
|
|
284
|
+
|
|
285
|
+
function idRow(row) {
|
|
286
|
+
if (!isObj(row)) return (row === null || typeof row === 'object') ? null : (typeof row === 'string' ? row.slice(0, ID_VALUE_MAX) : row);
|
|
287
|
+
const r = {};
|
|
288
|
+
for (const [k, v] of Object.entries(row)) {
|
|
289
|
+
if (BLOCKED.has(k) || !ID_FIELD_RE.test(k)) continue;
|
|
290
|
+
if (v === null || typeof v === 'object') continue;
|
|
291
|
+
r[k] = typeof v === 'string' && v.length > ID_VALUE_MAX ? `${v.slice(0, ID_VALUE_MAX)}...` : v;
|
|
292
|
+
}
|
|
293
|
+
return r;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function idIndex(body, found) {
|
|
297
|
+
const lists = [];
|
|
298
|
+
if (Array.isArray(body)) lists.push(['(root)', body]);
|
|
299
|
+
else if (isObj(body)) {
|
|
300
|
+
for (const [k, v] of Object.entries(body)) if (!BLOCKED.has(k) && Array.isArray(v) && v.length) lists.push([k, v]);
|
|
301
|
+
if (!lists.length && found && found.path) lists.push([found.path, found.list]);
|
|
302
|
+
}
|
|
303
|
+
if (!lists.length) return null;
|
|
304
|
+
// the main list first, then the rest by size
|
|
305
|
+
const mainPath = found && found.path;
|
|
306
|
+
lists.sort((a, b) => ((b[0] === mainPath) - (a[0] === mainPath)) || b[1].length - a[1].length);
|
|
307
|
+
const out = {};
|
|
308
|
+
let used = 0;
|
|
309
|
+
for (const [path, list] of lists.slice(0, 8)) {
|
|
310
|
+
const rows = [];
|
|
311
|
+
for (const it of list.slice(0, ID_INDEX_ROWS)) {
|
|
312
|
+
const r = idRow(it);
|
|
313
|
+
if (r === null || (isObj(r) && !Object.keys(r).length)) continue;
|
|
314
|
+
const n = jsonLen(r) + 2;
|
|
315
|
+
if (used + n + 120 > ID_INDEX_BUDGET) break;
|
|
316
|
+
rows.push(r);
|
|
317
|
+
used += n;
|
|
318
|
+
}
|
|
319
|
+
if (!rows.length) continue;
|
|
320
|
+
used += 120;
|
|
321
|
+
out[path] = { total: list.length, shown: rows.length, omitted: list.length - rows.length, rows };
|
|
322
|
+
}
|
|
323
|
+
return Object.keys(out).length ? out : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
273
326
|
export function capInline(value, { maxChars = INLINE_MAX_CHARS } = {}) {
|
|
274
327
|
const size = jsonLen(value);
|
|
275
328
|
if (size <= maxChars) return { value, capped: false };
|
|
@@ -299,6 +352,8 @@ export function capInline(value, { maxChars = INLINE_MAX_CHARS } = {}) {
|
|
|
299
352
|
: `--select '${lp ? `${lp}[field=value].name` : '[0]'}', --select 'count(${lp || ''})' or --select '${lp ? `${lp}[*].field` : '[*].field'}'`;
|
|
300
353
|
const next = `Result is ${fullKb} KB, too large to show whole, so this is the --summary view. For the exact part you need, run the same quox tool exec again with ${example} (or --summary --limit N for a bigger sample)${via ? `; the list is inside ${via}` : ''}. Never read or post-process a saved output with a shell.`;
|
|
301
354
|
const head = { oversized: true, full_size_kb: fullKb, next, top_keys: isObj(body) ? Object.keys(body).slice(0, 40) : undefined };
|
|
355
|
+
const idx = idIndex(body, found);
|
|
356
|
+
if (idx) head.id_index = idx;
|
|
302
357
|
const extra = smallTopFields(body, found);
|
|
303
358
|
for (const limit of [DEFAULT_SAMPLE, 5, 3, 1, 0]) {
|
|
304
359
|
const s = summarize(body, { limit });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quoxcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Quox: the conversational front door to your self-hosted AI agent control plane",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"!lib/core/websiteSession.js",
|
|
62
62
|
"!lib/quoxon/whatsnew.md",
|
|
63
63
|
"README.md",
|
|
64
|
+
"CHANGELOG.md",
|
|
64
65
|
"LICENSE"
|
|
65
66
|
],
|
|
66
67
|
"repository": {
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"url": "git+https://github.com/quoxai/quoxcore.git",
|
|
69
70
|
"directory": "cli"
|
|
70
71
|
},
|
|
71
|
-
"homepage": "https://
|
|
72
|
+
"homepage": "https://quox.ai/cli",
|
|
72
73
|
"bugs": {
|
|
73
74
|
"url": "https://github.com/quoxai/quoxcore/issues"
|
|
74
75
|
},
|
package/vendor/dragonlab/SOURCE
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
repo: quoxai/dragonlab
|
|
2
|
-
commit:
|
|
3
|
-
branch:
|
|
4
|
-
files:
|
|
2
|
+
commit: a4dda5ef12c417689768664022b8de1245794c56
|
|
3
|
+
branch: integration/companions-vulcan-quoxperticus
|
|
4
|
+
files: 66
|