quoxcli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- package/lib/core/websiteSession.js +0 -148
package/lib/core/errors.js
CHANGED
|
@@ -25,6 +25,15 @@ export class QuoxApiError extends QuoxError {
|
|
|
25
25
|
* handlers read this to render something actionable; --json emits it raw.
|
|
26
26
|
*/
|
|
27
27
|
constructor(status, code, message, detail, body) {
|
|
28
|
+
// #1005: some services (quoxflow) answer { error: { code, message, details } }.
|
|
29
|
+
// Passed through as `code`, it printed as "Status: 400 [object Object]" and the
|
|
30
|
+
// real message and field errors were lost. Lift them out.
|
|
31
|
+
const structured = code && typeof code === 'object' ? code : null;
|
|
32
|
+
if (structured) {
|
|
33
|
+
if (structured.message && (!message || /^(Stream request|Request) failed with status/.test(message))) message = structured.message;
|
|
34
|
+
if (detail == null && structured.details != null) detail = structured.details;
|
|
35
|
+
code = typeof structured.code === 'string' ? structured.code : 'api_error';
|
|
36
|
+
}
|
|
28
37
|
super(message, statusToExitCode(status));
|
|
29
38
|
this.name = 'QuoxApiError';
|
|
30
39
|
this.status = status;
|
|
@@ -73,6 +82,25 @@ export class QuoxValidationError extends QuoxError {
|
|
|
73
82
|
}
|
|
74
83
|
}
|
|
75
84
|
|
|
85
|
+
/**
|
|
86
|
+
* CLI-ERRORS (owner 2026-10-17): the shared error contract. A command that knows what went wrong throws
|
|
87
|
+
* this: `headline` says what happened and why in plain words, `next` is the ONE best next step. The
|
|
88
|
+
* renderer prints exactly those two lines; technical detail (status, code, raw server text) only with
|
|
89
|
+
* --verbose. Exit code is the caller's choice (6 for "already decided differently", 5 for not found...).
|
|
90
|
+
*/
|
|
91
|
+
export class QuoxUserError extends QuoxError {
|
|
92
|
+
constructor(headline, { next = null, exitCode = 1, code = 'user_error', status, detail, body } = {}) {
|
|
93
|
+
super(headline, exitCode);
|
|
94
|
+
this.name = 'QuoxUserError';
|
|
95
|
+
this.headline = headline;
|
|
96
|
+
this.next = next;
|
|
97
|
+
this.code = code;
|
|
98
|
+
if (status) this.status = status;
|
|
99
|
+
if (detail != null) this.detail = detail;
|
|
100
|
+
if (body && typeof body === 'object') this.body = body;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
function statusToExitCode(status) {
|
|
77
105
|
if (status === 401) return 3;
|
|
78
106
|
if (status === 403) return 4;
|
|
@@ -85,7 +113,8 @@ function statusToExitCode(status) {
|
|
|
85
113
|
const STATUS_HINTS = {
|
|
86
114
|
401: 'Run `quox login` to authenticate.',
|
|
87
115
|
403: 'You may not have permission. Try `quox org switch` to change context.',
|
|
88
|
-
|
|
116
|
+
// #995: never name a verb that may not exist; handleError names the real one when it knows the group
|
|
117
|
+
404: 'Resource not found. Check the id; `quox <command> --help` shows how to list what exists.',
|
|
89
118
|
409: 'Resource conflict. The item may already exist.',
|
|
90
119
|
429: 'Rate limited. Wait a moment and try again.',
|
|
91
120
|
500: 'Server error. Use --debug for details.',
|
|
@@ -93,40 +122,320 @@ const STATUS_HINTS = {
|
|
|
93
122
|
503: 'Service temporarily unavailable. Check `quox service health`.',
|
|
94
123
|
};
|
|
95
124
|
|
|
96
|
-
|
|
125
|
+
// quox#803: a 403 signature_invalid is not a permissions problem; the org
|
|
126
|
+
// enforces trusted plugin keys. Only real verbs are named: `quox plugin
|
|
127
|
+
// trusted-keys add` (CLI) and `quox-plugin sign` (@quoxai/quox-plugin-vite).
|
|
128
|
+
function signatureInvalidHint(reason) {
|
|
129
|
+
const why = reason ? ` (reason: ${reason})` : '';
|
|
130
|
+
return `This org enforces trusted plugin signing keys${why}. Sign the bundle with \`npx quox-plugin sign\` (from @quoxai/quox-plugin-vite) and pass \`--signature <file>\`, `
|
|
131
|
+
+ 'or have an org admin register your public key with `quox plugin trusted-keys add`.';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Z2H-3c: a 402 LICENSE_REQUIRED printed only "Request failed with status 402" (seen on `quox fleet hosts`
|
|
135
|
+
// on a fresh install). Name what is locked and the real verbs that unlock or inspect it.
|
|
136
|
+
function licenseRequiredHint(body) {
|
|
137
|
+
const plugin = body?.plugin || body?.requiredPlugin;
|
|
138
|
+
return `${plugin ? `This needs a licence that includes \`${plugin}\`` : 'This needs a licence entitlement'}, and this install does not have it. `
|
|
139
|
+
+ 'Activate one with `quox setup license activate --key-file <path>`; see what you have with `quox setup license status`.';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// An object `detail` (e.g. {holder_session}) printed as "[object Object]". Show message/code when present,
|
|
143
|
+
// else compact JSON.
|
|
144
|
+
export function renderDetail(detail) {
|
|
145
|
+
if (detail == null || detail === '') return '';
|
|
146
|
+
if (typeof detail === 'string') return detail;
|
|
147
|
+
// #1005: validation details as [{ path, message }] read as "field: problem".
|
|
148
|
+
if (Array.isArray(detail) && detail.length && detail.every((d) => d && typeof d === 'object' && typeof d.message === 'string')) {
|
|
149
|
+
return detail.map((d) => {
|
|
150
|
+
const path = Array.isArray(d.path) ? d.path.join('.') : d.path;
|
|
151
|
+
return path ? `${path}: ${d.message}` : d.message;
|
|
152
|
+
}).join('; ');
|
|
153
|
+
}
|
|
154
|
+
if (typeof detail === 'object') {
|
|
155
|
+
const parts = [detail.message, detail.code].filter((x) => typeof x === 'string' && x);
|
|
156
|
+
if (parts.length) return parts.join(' ');
|
|
157
|
+
try { return JSON.stringify(detail); } catch { return String(detail); }
|
|
158
|
+
}
|
|
159
|
+
return String(detail);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A human-only route refused this login (an agent or resident identity). Switching org cannot help; a
|
|
163
|
+
// human profile can.
|
|
164
|
+
function humanOnlyHint() {
|
|
165
|
+
return 'This needs a human login, and the active profile is not one (it is an agent or service login). '
|
|
166
|
+
+ 'Run it from a human profile: `quox -p <profile> ...`; `quox config profile list` shows them.';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function notSlotHolderHint() {
|
|
170
|
+
return 'You don\'t hold this slot. To post as a member: `quox slot join <slot> --session <S>`, then `quox slot say <slot> --room --session <S> <text>`.';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// #995: the 404 hint names `quox <group> list` only when that group really has a list verb, else its --help.
|
|
174
|
+
function notFoundHint(ctx) {
|
|
175
|
+
if (!ctx || !ctx.group) return null;
|
|
176
|
+
return (ctx.subcommands || []).includes('list')
|
|
177
|
+
? `Resource not found. \`quox ${ctx.group} list\` shows what exists.`
|
|
178
|
+
: `Resource not found. \`quox ${ctx.group} --help\` shows how to list what exists.`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const RAW_HEADLINE = /^(Stream request|Request) failed with status \d+$/;
|
|
182
|
+
|
|
183
|
+
function sentence(text) {
|
|
184
|
+
const t = String(text || '').trim();
|
|
185
|
+
if (!t) return t;
|
|
186
|
+
return /[.!?]$/.test(t) ? t : `${t}.`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function lowerFirst(text) {
|
|
190
|
+
return text ? text[0].toLowerCase() + text.slice(1) : text;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The server's own words for this error: its message, else its prose `error`, else null. */
|
|
194
|
+
function serverReason(err) {
|
|
195
|
+
const m = String(err.message || '').split('\n')[0];
|
|
196
|
+
if (m && !RAW_HEADLINE.test(m)) return m;
|
|
197
|
+
const code = typeof err.code === 'string' ? err.code : (typeof err.body?.error === 'string' ? err.body.error : '');
|
|
198
|
+
return /\s/.test(code) ? code : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* CLI-ERRORS: map an error to the contract { headline, next }, or null to keep the legacy rendering
|
|
203
|
+
* (an error whose message already says it well; existing tests pin that output, see the audit).
|
|
204
|
+
*/
|
|
205
|
+
export function explainError(err, ctx = null) {
|
|
206
|
+
if (!err) return null;
|
|
207
|
+
if (err instanceof QuoxUserError) return { headline: err.headline, next: err.next };
|
|
208
|
+
if (!(err instanceof QuoxApiError)) return null;
|
|
209
|
+
const status = err.status;
|
|
210
|
+
const body = err.body && typeof err.body === 'object' ? err.body : {};
|
|
211
|
+
const code = typeof err.code === 'string' ? err.code : '';
|
|
212
|
+
const all = `${code} ${typeof body.error === 'string' ? body.error : ''} ${err.message || ''}`;
|
|
213
|
+
// reasonFromError: the client lifted a prose `error` into the message because the body had no `message`
|
|
214
|
+
const raw = RAW_HEADLINE.test(String(err.message || '').split('\n')[0]) || err.reasonFromError === true;
|
|
215
|
+
const reason = serverReason(err);
|
|
216
|
+
|
|
217
|
+
// #8 service-key fallback: the real problem is "no stored session", say it once.
|
|
218
|
+
if (err.serviceKeyFallback && (status === 401 || status === 403)) {
|
|
219
|
+
return {
|
|
220
|
+
headline: 'This needs a signed-in user, and this profile has no stored session, so the request went out with the service key, which carries no user.',
|
|
221
|
+
next: 'run `quox login` (or `quox -p <profile> login`), then retry.',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
// #3 non-JSON error page from a proxy in front of a service
|
|
225
|
+
if ((code === 'non_json_response' || code === 'unknown') && status >= 500) {
|
|
226
|
+
return {
|
|
227
|
+
headline: 'The server answered with an error page instead of Quox data, so the service behind it is down or restarting.',
|
|
228
|
+
next: 'check `quox service health`, then retry.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// #9 / #10 sign-in
|
|
232
|
+
if (status === 401 && code === 'invalid_credentials') {
|
|
233
|
+
return {
|
|
234
|
+
headline: 'Sign-in failed: the email or password is wrong for this server.',
|
|
235
|
+
next: 'check the profile and server with `quox config profile list`, or reset the password from the dashboard sign-in page.',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (status === 401 && code === 'account_locked') {
|
|
239
|
+
return { headline: sentence(reason || 'This account is locked after too many failed sign-ins'), next: 'wait for the lock to expire, then run `quox login` again; nothing else is needed.' };
|
|
240
|
+
}
|
|
241
|
+
// #6 an agent login tried to decide a HITL item
|
|
242
|
+
if (status === 403 && /agents cannot resolve hitl/i.test(all)) {
|
|
243
|
+
return {
|
|
244
|
+
headline: 'Only a person can approve or reject inbox items, and this profile is an agent login.',
|
|
245
|
+
next: 'run it from a human profile: `quox -p <profile> ...`; `quox config profile list` shows them.',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// #13 / LICENSE_REQUIRED
|
|
249
|
+
if ((status === 402 || status === 403) && (code === 'LICENSE_REQUIRED' || body.error === 'LICENSE_REQUIRED')) {
|
|
250
|
+
if (body.reason === 'no_org_context') {
|
|
251
|
+
return { headline: 'This command needs an org, and none is selected for this profile.', next: 'pick one with `quox org switch <org>`; `quox org list` shows them.' };
|
|
252
|
+
}
|
|
253
|
+
const plugin = body.plugin || body.requiredPlugin;
|
|
254
|
+
return {
|
|
255
|
+
headline: `${plugin ? `This needs a licence that includes \`${plugin}\`` : 'This needs a licence entitlement'}, and this install does not have it.`,
|
|
256
|
+
next: 'activate one with `quox setup license activate --key-file <path>`; see what you have with `quox setup license status`.',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
// #11 / #12 prose "not licensed" (only when the headline would otherwise be raw)
|
|
260
|
+
if ((status === 402 || status === 403) && /not licen[cs]ed/i.test(all) && (raw || status === 402)) {
|
|
261
|
+
const text = (reason || 'This feature is not licensed for this org').replace(/^(\S+) not licen/i, '$1 is not licen');
|
|
262
|
+
return { headline: sentence(text), next: '`quox setup license status` shows what this install has.' };
|
|
263
|
+
}
|
|
264
|
+
// #14 / #15 approvals
|
|
265
|
+
if (/^approval (request )?has expired$/i.test(reason || '')) {
|
|
266
|
+
return { headline: 'That approval expired before anyone decided it, so nothing ran.', next: 're-run the original command to ask for a fresh approval.' };
|
|
267
|
+
}
|
|
268
|
+
if (status === 404 && /^approval not found$/i.test(reason || '')) {
|
|
269
|
+
return { headline: 'No approval with that id in this org; it may belong to another org or be past retention.', next: notFoundNext(ctx) || '`quox approvals --help` shows how to list what exists.' };
|
|
270
|
+
}
|
|
271
|
+
// #5 a role refusal is not an org-context problem
|
|
272
|
+
if (status === 403 && /insufficient permissions?|lacks?[ _]capability|requires? (an? |the )?(admin|owner) role|not an? (org )?(admin|owner)/i.test(all)) {
|
|
273
|
+
return { headline: sentence(reason || 'Your role does not allow this'), next: 'your role in this org does not allow this; ask an org owner or admin to do it or to raise your role.' };
|
|
274
|
+
}
|
|
275
|
+
// #18 a 409 that already says "already exists" needs no echo
|
|
276
|
+
if (status === 409 && /already exists/i.test(reason || '')) {
|
|
277
|
+
return { headline: sentence(reason), next: 'pick a different id, or update the existing one.' };
|
|
278
|
+
}
|
|
279
|
+
// #1 / #4 the headline would be "Request failed with status N": lead with the server's reason
|
|
280
|
+
if (raw && reason) {
|
|
281
|
+
const next = (typeof body.hint === 'string' && body.hint)
|
|
282
|
+
|| (status === 404 && notFoundNext(ctx))
|
|
283
|
+
|| CONTRACT_NEXT[status]
|
|
284
|
+
|| (STATUS_HINTS[status] ? lowerFirst(STATUS_HINTS[status]) : null);
|
|
285
|
+
return { headline: sentence(reason), next };
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The contract's own next steps: an action, never a restatement of the headline.
|
|
291
|
+
const CONTRACT_NEXT = {
|
|
292
|
+
401: 'run `quox login`, then retry.',
|
|
293
|
+
403: 'if this belongs to another org, switch with `quox org switch <org>`; otherwise ask an org owner or admin.',
|
|
294
|
+
404: 'check the id; `quox <command> --help` shows how to list what exists.',
|
|
295
|
+
409: 'it may already exist or be in use; check its current state before retrying.',
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function notFoundNext(ctx) {
|
|
299
|
+
const h = notFoundHint(ctx);
|
|
300
|
+
return h ? h.replace(/^Resource not found\. /, '') : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isVerbose(ctx) {
|
|
304
|
+
if (ctx && ctx.verbose) return true;
|
|
305
|
+
const a = process.argv || [];
|
|
306
|
+
return a.includes('--verbose') || a.includes('-v') || a.includes('--debug') || Boolean(process.env.DEBUG);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* CLI-ERRORS: the contract rendering for an API error with no specific mapping. Headline = the server's
|
|
311
|
+
* own first line; next = the most specific hint we have (the cases the legacy renderer knew), once.
|
|
312
|
+
*/
|
|
313
|
+
function fallbackExplain(err, ctx) {
|
|
314
|
+
const body = err.body && typeof err.body === 'object' ? err.body : {};
|
|
315
|
+
const tags = `${typeof err.code === 'string' ? err.code : ''} ${typeof body.error === 'string' ? body.error : ''}`;
|
|
316
|
+
const status = err.status;
|
|
317
|
+
let next;
|
|
318
|
+
if (status === 403 && /not (the current )?slot holder/i.test(tags)) next = notSlotHolderHint();
|
|
319
|
+
else if (status === 403 && /\b(only a human|requires a human)\b/i.test(`${tags} ${err.message || ''}`)) next = humanOnlyHint();
|
|
320
|
+
else if (status === 403 && (err.code === 'signature_invalid' || body.error === 'signature_invalid')) next = signatureInvalidHint(body.reason);
|
|
321
|
+
else if (status === 403 && /not licen[cs]ed/i.test(tags)) next = '`quox setup license status` shows what this install has.';
|
|
322
|
+
else if (err.code === 'PLUGIN_SERVICE_UNAVAILABLE') next = null;
|
|
323
|
+
else {
|
|
324
|
+
next = (typeof body.hint === 'string' && body.hint) || (status === 404 && notFoundNext(ctx)) || CONTRACT_NEXT[status]
|
|
325
|
+
|| (STATUS_HINTS[status] ? lowerFirst(STATUS_HINTS[status]) : null);
|
|
326
|
+
}
|
|
327
|
+
const plain = { 400: 'The server could not accept that request', 401: 'You are not signed in, or the session has expired', 402: 'This needs a licence this install does not have',
|
|
328
|
+
403: 'This profile is not allowed to do that', 404: 'That was not found', 409: 'That conflicts with its current state', 429: 'Too many requests right now' };
|
|
329
|
+
const msg = String(err.message || '').split('\n')[0];
|
|
330
|
+
const first = serverReason(err) || (RAW_HEADLINE.test(msg) || !msg ? (plain[status] || (status >= 500 ? 'The server hit an error' : 'The request was refused')) : msg);
|
|
331
|
+
return { headline: sentence(first), next: next || null };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// CLI-ERRORS: an instruction sentence (the step the user takes) vs the rest (what happened).
|
|
335
|
+
const INSTRUCTION = /^(run|check|use|retry|try|pass|fix|set|pick|switch|ask|log ?in|re-?run|please)\b/i;
|
|
336
|
+
|
|
337
|
+
function splitInstruction(text) {
|
|
338
|
+
const first = String(text || '').split('\n')[0].trim();
|
|
339
|
+
const rest = String(text || '').split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
|
|
340
|
+
const parts = first.split(/(?<=[.!?;])\s+/);
|
|
341
|
+
let idx = parts.findIndex((p, i) => i > 0 && INSTRUCTION.test(p));
|
|
342
|
+
let head = idx === -1 ? parts.join(' ') : parts.slice(0, idx).join(' ');
|
|
343
|
+
let next = idx === -1 ? null : parts[idx];
|
|
344
|
+
const more = [...(idx === -1 ? [] : parts.slice(idx + 1)), ...rest];
|
|
345
|
+
head = head.replace(/;$/, '');
|
|
346
|
+
if (next && /^please log ?in again\.?$/i.test(next)) next = 'run `quox login`, then retry.';
|
|
347
|
+
return { headline: sentence(head), next: next ? lowerFirst(next) : null, more };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* CLI-ERRORS: errors the CLI raises itself (auth, network, validation, anything else) in the same
|
|
352
|
+
* contract as API errors: one headline, at most one Next, the rest (`more`) only with --verbose.
|
|
353
|
+
*/
|
|
354
|
+
export function explainLocal(err) {
|
|
355
|
+
if (!err || err instanceof QuoxApiError || err instanceof QuoxUserError) return null;
|
|
356
|
+
const message = String(err.message || '');
|
|
357
|
+
if (err instanceof QuoxNetworkError) {
|
|
358
|
+
const code = err.cause?.cause?.code || err.cause?.code || null;
|
|
359
|
+
const m = /^Could not connect to (\S+) service at (\S+?): /.exec(message);
|
|
360
|
+
const svc = m ? m[1] : null;
|
|
361
|
+
const url = m ? m[2] : null;
|
|
362
|
+
const more = message.split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
|
|
363
|
+
if (m && code && /CERT|SELF_SIGNED|UNABLE_TO_VERIFY/.test(code)) {
|
|
364
|
+
return { headline: `Could not reach the ${svc} service at ${url}: its certificate is not trusted (${code}).`,
|
|
365
|
+
next: 'trust it with export NODE_EXTRA_CA_CERTS=<path to quox.crt> (on the server: ~/quox-install/quox/ssl/quox.crt); never disable TLS verification.', more };
|
|
366
|
+
}
|
|
367
|
+
if (m && (code === 'ERR_SSL_WRONG_VERSION_NUMBER' || code === 'EPROTO')) {
|
|
368
|
+
return { headline: `The ${svc} service at ${url} speaks plain http, but this profile's URL says https://.`,
|
|
369
|
+
next: `quox config set url ${url.replace(/^https:/, 'http:')}`, more };
|
|
370
|
+
}
|
|
371
|
+
if (m && code === 'ECONNREFUSED') {
|
|
372
|
+
return { headline: `Nothing is listening at ${url} for the ${svc} service: it is down, or this profile's URL is stale.`,
|
|
373
|
+
next: `run \`quox doctor\`; to point this shell elsewhere: export QUOX_${svc.toUpperCase()}_URL=<url>`, more };
|
|
374
|
+
}
|
|
375
|
+
const t = /^Request timed out after (\d+)ms$/.exec(message);
|
|
376
|
+
if (t) {
|
|
377
|
+
const secs = Math.round(Number(t[1]) / 1000);
|
|
378
|
+
return { headline: `The server did not answer within ${secs} second${secs === 1 ? '' : 's'}.`, next: 'check `quox service health`, then retry.', more };
|
|
379
|
+
}
|
|
380
|
+
return { headline: sentence(message.split('\n')[0]), next: 'check `quox service health`, then retry.', more };
|
|
381
|
+
}
|
|
382
|
+
if (err instanceof QuoxAuthError) {
|
|
383
|
+
const sp = splitInstruction(message);
|
|
384
|
+
if (!sp.next) {
|
|
385
|
+
sp.next = /QUOX_TOKEN_FILE/.test(message)
|
|
386
|
+
? 'this principal runs only on its issued token; ask the owner to re-issue the token file.'
|
|
387
|
+
: err.transient ? 'wait a moment and retry; your stored session is kept.' : 'run `quox login`, then retry.';
|
|
388
|
+
}
|
|
389
|
+
return sp;
|
|
390
|
+
}
|
|
391
|
+
if (err instanceof QuoxValidationError) return splitInstruction(message);
|
|
392
|
+
const first = message.split('\n')[0];
|
|
393
|
+
return { headline: sentence(first || 'Something went wrong'), next: null, more: message.split('\n').slice(1), stack: true };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function renderContract(err, ex, ctx) {
|
|
397
|
+
console.error(chalk.red(`✖ ${ex.headline}`));
|
|
398
|
+
if (ex.next) console.error(chalk.yellow(` Next: ${ex.next}`));
|
|
399
|
+
// #1000: a server that names the allowed values is telling the user the fix; keep it
|
|
400
|
+
if (err.body && typeof err.body === 'object') {
|
|
401
|
+
for (const [k, v] of Object.entries(err.body)) {
|
|
402
|
+
if (k.startsWith('valid_') && Array.isArray(v) && v.length) console.error(chalk.dim(` Valid ${k.slice(6).replace(/_/g, ' ')}: ${v.join(', ')}`));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (err.detail) console.error(chalk.dim(` Detail: ${renderDetail(err.detail)}`));
|
|
406
|
+
if (isVerbose(ctx)) {
|
|
407
|
+
if (err.status) console.error(chalk.dim(` Status: ${err.status} ${typeof err.code === 'object' && err.code ? renderDetail(err.code) : (err.code || '')}`));
|
|
408
|
+
if (err instanceof QuoxApiError && err.message && err.message !== ex.headline) console.error(chalk.dim(` Server said: ${err.message}`));
|
|
409
|
+
for (const line of ex.more || []) if (line) console.error(chalk.dim(` More: ${line}`));
|
|
410
|
+
if (ex.stack && err.stack) console.error(chalk.dim((err.stack || '').replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[JWT_REDACTED]')));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function handleError(err, outputFormat = 'table', ctx = null) {
|
|
97
415
|
if (outputFormat === 'json') {
|
|
98
416
|
const obj = {
|
|
99
417
|
error: true,
|
|
100
418
|
code: err.code || err.name,
|
|
101
|
-
message: err.message,
|
|
419
|
+
message: err.headline || err.message,
|
|
102
420
|
};
|
|
103
421
|
if (err.status) obj.status = err.status;
|
|
104
422
|
if (err.detail) obj.detail = err.detail;
|
|
105
423
|
// LL-3d: scripts parsing --json get the server's own body verbatim, not
|
|
106
424
|
// our prose rendering of it.
|
|
107
425
|
if (err.body && typeof err.body === 'object') obj.body = err.body;
|
|
426
|
+
const ex = explainError(err, ctx) || (err instanceof QuoxApiError ? fallbackExplain(err, ctx) : explainLocal(err));
|
|
427
|
+
if (ex) { obj.headline = ex.headline; if (ex.next) obj.next = ex.next; }
|
|
108
428
|
process.stderr.write(JSON.stringify(obj) + '\n');
|
|
109
429
|
return;
|
|
110
430
|
}
|
|
111
431
|
|
|
432
|
+
const ex = explainError(err, ctx);
|
|
433
|
+
if (ex) { renderContract(err, ex, ctx); return; }
|
|
434
|
+
|
|
112
435
|
if (err instanceof QuoxApiError) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
console.error(chalk.dim(` Status: ${err.status} ${err.code || ''}`));
|
|
116
|
-
const hint = STATUS_HINTS[err.status];
|
|
117
|
-
if (hint) console.error(chalk.yellow(` Hint: ${hint}`));
|
|
118
|
-
} else if (err instanceof QuoxAuthError) {
|
|
119
|
-
console.error(chalk.red(`Auth: ${err.message}`));
|
|
120
|
-
} else if (err instanceof QuoxNetworkError) {
|
|
121
|
-
console.error(chalk.red(`Network: ${err.message}`));
|
|
122
|
-
console.error(chalk.yellow(' Hint: Check that services are running with `quox service health`.'));
|
|
123
|
-
} else if (err instanceof QuoxValidationError) {
|
|
124
|
-
console.error(chalk.red(`Validation: ${err.message}`));
|
|
436
|
+
// CLI-ERRORS (PIN 01K55JRA11YHH73TN67X9ZDEGM): every API error renders in the contract now.
|
|
437
|
+
renderContract(err, fallbackExplain(err, ctx), ctx);
|
|
125
438
|
} else {
|
|
126
|
-
|
|
127
|
-
if (process.env.DEBUG || process.argv.includes('--debug')) {
|
|
128
|
-
const sanitized = (err.stack || '').replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[JWT_REDACTED]');
|
|
129
|
-
console.error(chalk.dim(sanitized));
|
|
130
|
-
}
|
|
439
|
+
renderContract(err, explainLocal(err), ctx);
|
|
131
440
|
}
|
|
132
441
|
}
|
package/lib/core/global-flags.js
CHANGED
|
@@ -39,15 +39,41 @@
|
|
|
39
39
|
export function mergeShadowedGlobals(rootCommand, actionCommand) {
|
|
40
40
|
if (!rootCommand || !actionCommand || actionCommand === rootCommand) return [];
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
// Nearest ancestor first, root last. quox#1077 follow-up: a PARENT group
|
|
43
|
+
// that declares the same option shadows its leaf exactly like the root does
|
|
44
|
+
// (`quox lucid since-last --no-advance` set the flag on `lucid`, and the
|
|
45
|
+
// leaf kept its default and advanced the cursor). Only the root used to be
|
|
46
|
+
// consulted.
|
|
47
|
+
const ancestors = [];
|
|
48
|
+
for (let c = actionCommand.parent; c; c = c.parent) {
|
|
49
|
+
ancestors.push(c);
|
|
50
|
+
if (c === rootCommand) break;
|
|
51
|
+
}
|
|
52
|
+
const typed = (src) => src === 'cli' || src === 'env';
|
|
43
53
|
const merged = [];
|
|
44
54
|
|
|
45
55
|
for (const option of actionCommand.options) {
|
|
46
56
|
const name = option.attributeName();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const localValue = actionCommand.getOptionValue(name);
|
|
58
|
+
const localSource = actionCommand.getOptionValueSource(name);
|
|
59
|
+
if (typed(localSource)) continue; // the user typed it on the leaf itself
|
|
60
|
+
|
|
61
|
+
// 1. A value the user TYPED on an ancestor wins over the leaf's default.
|
|
62
|
+
// A shadowed option can never carry a user value (commander gave it to
|
|
63
|
+
// the ancestor), so the leaf's value here is at most a default. This is
|
|
64
|
+
// what makes --no-* options work: their default is true, never undefined.
|
|
65
|
+
const typedOn = ancestors.find((a) => a.options.some((o) => o.attributeName() === name)
|
|
66
|
+
&& typed(a.getOptionValueSource(name)));
|
|
67
|
+
if (typedOn) {
|
|
68
|
+
actionCommand.setOptionValue(name, typedOn.getOptionValue(name));
|
|
69
|
+
merged.push(name);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Otherwise fill an empty leaf from the root (its default included),
|
|
74
|
+
// as before. A subcommand default is a real value and is left alone.
|
|
75
|
+
const rootOpts = rootCommand.opts();
|
|
76
|
+
if (localValue === undefined && rootOpts[name] !== undefined) {
|
|
51
77
|
actionCommand.setOptionValue(name, rootOpts[name]);
|
|
52
78
|
merged.push(name);
|
|
53
79
|
}
|
|
@@ -56,6 +82,80 @@ export function mergeShadowedGlobals(rootCommand, actionCommand) {
|
|
|
56
82
|
return merged;
|
|
57
83
|
}
|
|
58
84
|
|
|
85
|
+
const HONOURS_GLOBAL_DRY_RUN = Symbol.for('quox.forwardsRawArgs');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mark a leaf that honours a --dry-run WITHOUT declaring its own option,
|
|
89
|
+
* because it forwards every raw argv token after its verb word to a wrapped
|
|
90
|
+
* tool (claude, codex, gemini, wrap). Such a leaf honours the flag only when
|
|
91
|
+
* it is typed AFTER the verb (it then reaches the wrapped tool); typed before
|
|
92
|
+
* the verb it reaches nothing, so it is refused like any other.
|
|
93
|
+
*/
|
|
94
|
+
export function markForwardsRawArgs(command, reason) {
|
|
95
|
+
command[HONOURS_GLOBAL_DRY_RUN] = reason || true;
|
|
96
|
+
return command;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 'declared' | 'forwards' | false */
|
|
100
|
+
export function dryRunSupport(command) {
|
|
101
|
+
if (!command) return false;
|
|
102
|
+
if (command.options.some((o) => o.attributeName() === 'dryRun')) return 'declared';
|
|
103
|
+
if (command[HONOURS_GLOBAL_DRY_RUN]) return 'forwards';
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The raw argv tokens before the leaf's own verb word (commander's root rawArgs). */
|
|
108
|
+
function tokensBeforeVerb(rootCommand, actionCommand) {
|
|
109
|
+
const raw = Array.isArray(rootCommand.rawArgs) ? rootCommand.rawArgs : [];
|
|
110
|
+
const words = [];
|
|
111
|
+
for (let c = actionCommand; c && c !== rootCommand; c = c.parent) words.unshift(c.name());
|
|
112
|
+
let at = 0;
|
|
113
|
+
for (const w of words) {
|
|
114
|
+
const i = raw.indexOf(w, at);
|
|
115
|
+
if (i < 0) return raw; // cannot locate: treat everything as "before" (refuse)
|
|
116
|
+
at = i + 1;
|
|
117
|
+
}
|
|
118
|
+
return raw.slice(0, at - 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function honoursGlobalDryRun(command, rootCommand) {
|
|
122
|
+
const support = dryRunSupport(command);
|
|
123
|
+
if (support === 'declared') return true;
|
|
124
|
+
if (support === 'forwards') return !rootCommand || !tokensBeforeVerb(rootCommand, command).includes('--dry-run');
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Refuse a --dry-run the running leaf cannot honour (quox#1077 follow-up).
|
|
130
|
+
*
|
|
131
|
+
* `--dry-run` is a ROOT option, so commander accepts it after ANY verb. A leaf
|
|
132
|
+
* that never declared it then ignores it and runs for real: `quox offload npm
|
|
133
|
+
* publish --dry-run` published (the flag never reached npm), `quox discord bind
|
|
134
|
+
* ... --dry-run` bound (qdp bind has no dry run). A requested dry run must
|
|
135
|
+
* either be honoured or refused; it is never silently dropped.
|
|
136
|
+
*
|
|
137
|
+
* @throws {Error} exitCode 2, before the action runs
|
|
138
|
+
*/
|
|
139
|
+
export function assertDryRunHonoured(rootCommand, actionCommand) {
|
|
140
|
+
if (!rootCommand || !actionCommand || actionCommand === rootCommand) return;
|
|
141
|
+
const typed = (c) => ['cli', 'env'].includes(c.getOptionValueSource('dryRun')) && c.getOptionValue('dryRun') === true;
|
|
142
|
+
let requested = false;
|
|
143
|
+
for (let c = actionCommand; c; c = c.parent) {
|
|
144
|
+
if (typed(c)) { requested = true; break; }
|
|
145
|
+
if (c === rootCommand) break;
|
|
146
|
+
}
|
|
147
|
+
if (!requested || honoursGlobalDryRun(actionCommand, rootCommand)) return;
|
|
148
|
+
const path = [];
|
|
149
|
+
for (let c = actionCommand; c && c !== rootCommand; c = c.parent) path.unshift(c.name());
|
|
150
|
+
const err = new Error(
|
|
151
|
+
`\`quox ${path.join(' ')}\` has no dry run, so --dry-run cannot be honoured. Nothing was run. `
|
|
152
|
+
+ 'If --dry-run belongs to a command you are passing through, put that command after -- .',
|
|
153
|
+
);
|
|
154
|
+
err.code = 'quox.dryRunNotHonoured';
|
|
155
|
+
err.exitCode = 2;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
|
|
59
159
|
/**
|
|
60
160
|
* Walk a command tree and report every subcommand option that collides with a
|
|
61
161
|
* root option. Used by the regression test to keep the blast radius visible:
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HYPERION activity: the shared reader and plain-text renderers behind `quox hyperion`,
|
|
3
|
+
* `quox hyperion deck` and the QUOXON /hyperion command. Pure functions over the
|
|
4
|
+
* GET /api/v1/proxmox/activity payload; no colour (callers paint), no grey, labels not bare values.
|
|
5
|
+
*/
|
|
6
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
7
|
+
|
|
8
|
+
export function fetchActivity(client) {
|
|
9
|
+
const ep = resolveEndpoint('v1.proxmox.activity');
|
|
10
|
+
return client.get(ep.path, { service: ep.service });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ms = (v) => {
|
|
14
|
+
if (v == null || v === '') return null;
|
|
15
|
+
const n = typeof v === 'number' ? v : Date.parse(v);
|
|
16
|
+
return Number.isFinite(n) ? n : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function span(delta) {
|
|
20
|
+
const s = Math.max(0, Math.floor(delta / 1000));
|
|
21
|
+
if (s < 60) return `${s}s`;
|
|
22
|
+
const m = Math.floor(s / 60);
|
|
23
|
+
if (m < 60) return `${m}m`;
|
|
24
|
+
const h = Math.floor(m / 60);
|
|
25
|
+
if (h < 24) return `${h}h`;
|
|
26
|
+
return `${Math.floor(h / 24)}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Expiry wins ("expires in 3h" / "expired 1h ago"), else how long it has been held, else "-". */
|
|
30
|
+
export function whenText(held, now = Date.now()) {
|
|
31
|
+
const exp = ms(held && held.expires);
|
|
32
|
+
if (exp != null) return exp >= now ? `expires in ${span(exp - now)}` : `expired ${span(now - exp)} ago`;
|
|
33
|
+
const since = ms(held && held.since);
|
|
34
|
+
if (since != null) return `held ${span(now - since)}`;
|
|
35
|
+
return '-';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function holderText(held) {
|
|
39
|
+
const h = held || {};
|
|
40
|
+
if (h.source === 'pool') return h.lessee ? `${h.lessee} (pool)` : 'free (pool)';
|
|
41
|
+
if (h.source === 'lane') return `${h.lessee || 'unknown'} (lane)`;
|
|
42
|
+
return 'nobody recorded';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isHeld = (v) => Boolean(v.held_by && v.held_by.lessee);
|
|
46
|
+
const pct = (x) => (typeof x === 'number' ? `${Math.round(x * 100)}%` : '?');
|
|
47
|
+
const gb = (b) => (typeof b === 'number' ? (b / 1e9).toFixed(1) : '?');
|
|
48
|
+
const clipTo = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
49
|
+
|
|
50
|
+
/** Full one-shot view: VM table, node load, pool counts, last 5 ops. */
|
|
51
|
+
export function statusLines(data, { now = Date.now() } = {}) {
|
|
52
|
+
const d = data || {};
|
|
53
|
+
const out = [];
|
|
54
|
+
const vms = d.vms || [];
|
|
55
|
+
if (!vms.length) out.push('No VMs are held or pooled right now.');
|
|
56
|
+
else {
|
|
57
|
+
const rows = vms.map((v) => [String(v.vmid), v.name || '', v.node || '', v.status || '', holderText(v.held_by), (v.held_by && v.held_by.purpose) || '-', whenText(v.held_by, now)]);
|
|
58
|
+
const head = ['VMID', 'NAME', 'NODE', 'STATUS', 'HELD BY', 'PURPOSE', 'AGE / EXPIRY'];
|
|
59
|
+
const w = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
60
|
+
const fmt = (r) => r.map((c, i) => c.padEnd(w[i])).join(' ').trimEnd();
|
|
61
|
+
out.push(fmt(head), ...rows.map(fmt));
|
|
62
|
+
}
|
|
63
|
+
if (d.vms_truncated) out.push(`⚠ ${d.vms_truncated} more tagged VMs not shown`);
|
|
64
|
+
if (d.vm_config_errors) out.push(`⚠ ${d.vm_config_errors} VM description(s) could not be read, holder taken from tags`);
|
|
65
|
+
out.push('');
|
|
66
|
+
out.push('Node load');
|
|
67
|
+
if (d.nodes_error) out.push(` Node load unavailable: ${d.nodes_error}`);
|
|
68
|
+
else if (!(d.nodes || []).length) out.push(' no nodes reported');
|
|
69
|
+
else for (const n of d.nodes) out.push(` ${String(n.node).padEnd(10)} ${String(n.status).padEnd(8)} cpu ${pct(n.cpu)} mem ${gb(n.mem_used)}/${gb(n.mem_total)} GB`);
|
|
70
|
+
out.push('');
|
|
71
|
+
if (d.pool) {
|
|
72
|
+
const counts = Object.entries(d.pool.counts || {}).map(([k, v]) => `${k} ${v}`).join(', ') || 'no VMs';
|
|
73
|
+
out.push(`Pool: ${d.pool.enabled ? 'enabled' : 'disabled'}, ${counts}${d.pool.paused ? ' (paused, waiting for approval)' : ''}`);
|
|
74
|
+
} else out.push(`Pool unavailable: ${d.pool_error || 'unknown'}`);
|
|
75
|
+
out.push('');
|
|
76
|
+
if (d.ops_error) out.push(`Recent ops unavailable: ${d.ops_error}`);
|
|
77
|
+
else {
|
|
78
|
+
const ops = (d.recent_ops || []).slice(0, 5);
|
|
79
|
+
out.push(`Recent ops (${ops.length})`);
|
|
80
|
+
if (!ops.length) out.push(' none recorded');
|
|
81
|
+
for (const o of ops) out.push(` ${o.ts} ${o.intent} vmid ${o.vmid ?? '-'} by ${o.actor || '?'} outcome ${o.outcome ?? '-'}`);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Compact list for the TUI: a header, then one line per held VM. */
|
|
87
|
+
export function heldLines(data, { now = Date.now(), width = 100 } = {}) {
|
|
88
|
+
const held = ((data && data.vms) || []).filter(isHeld);
|
|
89
|
+
if (!held.length) return ['No VMs are held right now.'];
|
|
90
|
+
const lines = [`${held.length} held`];
|
|
91
|
+
for (const v of held) {
|
|
92
|
+
const purpose = v.held_by.purpose ? `, ${v.held_by.purpose}` : '';
|
|
93
|
+
lines.push(clipTo(`${v.vmid} ${v.name || ''} on ${v.node || '?'} ${v.status || ''}: held by ${holderText(v.held_by)}${purpose}, ${whenText(v.held_by, now)}`, width));
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Result text for the QuoxGlyph header line. */
|
|
99
|
+
export function summaryText(data) {
|
|
100
|
+
const vms = (data && data.vms) || [];
|
|
101
|
+
return `${vms.length} VMs · ${vms.filter(isHeld).length} held`;
|
|
102
|
+
}
|