quoxcli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/actions.js +15 -0
  6. package/lib/api/endpoints/admin.js +27 -0
  7. package/lib/api/endpoints/agent-attach.js +7 -0
  8. package/lib/api/endpoints/blog.js +16 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/compliance.js +25 -0
  11. package/lib/api/endpoints/contactsq.js +4 -0
  12. package/lib/api/endpoints/devServerRegistry.js +25 -0
  13. package/lib/api/endpoints/engagement.js +26 -0
  14. package/lib/api/endpoints/evidence.js +20 -0
  15. package/lib/api/endpoints/hat.js +14 -0
  16. package/lib/api/endpoints/inbox.js +6 -0
  17. package/lib/api/endpoints/inboxq.js +19 -0
  18. package/lib/api/endpoints/index.js +14 -0
  19. package/lib/api/endpoints/loops.js +26 -0
  20. package/lib/api/endpoints/mon.js +295 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/quoxseo.js +6 -0
  23. package/lib/api/endpoints/server.js +21 -0
  24. package/lib/api/endpoints/setup.js +6 -0
  25. package/lib/api/endpoints/tools.js +13 -0
  26. package/lib/api/endpoints/v1.js +47 -0
  27. package/lib/commands/actions.js +81 -11
  28. package/lib/commands/admin.js +35 -0
  29. package/lib/commands/agent.js +11 -4
  30. package/lib/commands/agentAttach.js +61 -7
  31. package/lib/commands/approvals.js +198 -3
  32. package/lib/commands/ask.js +4 -2
  33. package/lib/commands/auth.js +64 -11
  34. package/lib/commands/backup.js +11 -4
  35. package/lib/commands/blog.js +110 -0
  36. package/lib/commands/capabilities.js +3 -2
  37. package/lib/commands/chat.js +142 -28
  38. package/lib/commands/chatCompanion.js +27 -0
  39. package/lib/commands/chatState.js +45 -0
  40. package/lib/commands/claude.js +7 -33
  41. package/lib/commands/compliance.js +109 -0
  42. package/lib/commands/connect.js +69 -0
  43. package/lib/commands/contacts.js +85 -4
  44. package/lib/commands/contactsSendAt.js +21 -0
  45. package/lib/commands/dev.js +6 -8
  46. package/lib/commands/doctor.js +22 -3
  47. package/lib/commands/engagement.js +90 -0
  48. package/lib/commands/evidence.js +155 -0
  49. package/lib/commands/ext.js +23 -0
  50. package/lib/commands/fleet.js +25 -0
  51. package/lib/commands/gripe.js +24 -13
  52. package/lib/commands/hat.js +76 -0
  53. package/lib/commands/image.js +68 -0
  54. package/lib/commands/inbox.js +37 -3
  55. package/lib/commands/inboxq.js +147 -0
  56. package/lib/commands/kuma.js +1123 -0
  57. package/lib/commands/kumaSla.js +174 -0
  58. package/lib/commands/licence.js +39 -1
  59. package/lib/commands/licenceLinkView.js +311 -0
  60. package/lib/commands/linkedin.js +19 -0
  61. package/lib/commands/loop.js +318 -0
  62. package/lib/commands/lucid.js +1 -0
  63. package/lib/commands/mail.js +161 -20
  64. package/lib/commands/memory.js +68 -4
  65. package/lib/commands/memorySync.js +158 -0
  66. package/lib/commands/mon.js +55 -144
  67. package/lib/commands/notes.js +29 -7
  68. package/lib/commands/notesSeal.js +259 -0
  69. package/lib/commands/objective.js +53 -3
  70. package/lib/commands/offload.js +2 -3
  71. package/lib/commands/org.js +21 -4
  72. package/lib/commands/plan.js +17 -0
  73. package/lib/commands/provider.js +87 -8
  74. package/lib/commands/qlarity.js +32 -1
  75. package/lib/commands/quoxseo.js +31 -0
  76. package/lib/commands/research.js +79 -0
  77. package/lib/commands/rows.js +9 -0
  78. package/lib/commands/run.js +20 -2
  79. package/lib/commands/scope.js +2 -2
  80. package/lib/commands/server.js +150 -0
  81. package/lib/commands/setup.js +127 -75
  82. package/lib/commands/shortcut.js +41 -0
  83. package/lib/commands/skills.js +24 -5
  84. package/lib/commands/slot.js +49 -9
  85. package/lib/commands/status.js +92 -44
  86. package/lib/commands/tool.js +30 -0
  87. package/lib/commands/vault.js +6 -3
  88. package/lib/commands/vm.js +2 -2
  89. package/lib/commands/walkthrough.js +14 -2
  90. package/lib/commands/watch.js +11 -2
  91. package/lib/commands/wazuh.js +824 -0
  92. package/lib/commands/wazuhIntake.js +317 -0
  93. package/lib/commands/wazuhReport.js +192 -0
  94. package/lib/commands/workflow.js +47 -4
  95. package/lib/commands/x.js +50 -0
  96. package/lib/commands/youtube.js +172 -0
  97. package/lib/core/actionItems.js +115 -0
  98. package/lib/core/agentAlias.js +2 -0
  99. package/lib/core/agentFamilies.js +7 -0
  100. package/lib/core/announce.js +86 -0
  101. package/lib/core/auth.js +20 -2
  102. package/lib/core/callMe.js +118 -0
  103. package/lib/core/client.js +37 -3
  104. package/lib/core/commandRegistrars.js +17 -0
  105. package/lib/core/config.js +29 -3
  106. package/lib/core/confirm.js +68 -7
  107. package/lib/core/crossOrgApprovals.js +69 -0
  108. package/lib/core/errors.js +52 -1
  109. package/lib/core/findingReconcile.js +90 -0
  110. package/lib/core/global-flags.js +1 -1
  111. package/lib/core/governedSession.js +136 -8
  112. package/lib/core/inboxResolution.js +3 -2
  113. package/lib/core/liveSessionsReport.js +1 -1
  114. package/lib/core/loginGuard.js +73 -0
  115. package/lib/core/loopGlyph.js +97 -0
  116. package/lib/core/offload.js +73 -7
  117. package/lib/core/org-override.js +5 -1
  118. package/lib/core/output.js +8 -8
  119. package/lib/core/profileTrust.js +182 -0
  120. package/lib/core/quoxglyph.js +22 -1
  121. package/lib/core/router.js +14 -0
  122. package/lib/core/sessionHeartbeat.js +12 -8
  123. package/lib/core/shortcut.js +83 -0
  124. package/lib/core/stepUpPrompt.js +99 -0
  125. package/lib/core/version.js +1 -1
  126. package/lib/core/vm.js +5 -0
  127. package/lib/core/walkthrough.js +9 -5
  128. package/lib/dragon/companionView.js +34 -0
  129. package/lib/dragon/frames.js +118 -0
  130. package/lib/dragon/jobs.js +123 -0
  131. package/lib/dragon/renderWorker.js +15 -0
  132. package/lib/dragon/select.js +72 -0
  133. package/lib/evidence/bundleExport.js +47 -0
  134. package/lib/evidence/bundleVerify.js +77 -0
  135. package/lib/quoxon/EXTENSIONS.md +146 -0
  136. package/lib/quoxon/api.js +364 -27
  137. package/lib/quoxon/app.js +1629 -183
  138. package/lib/quoxon/approvalBatch.js +117 -0
  139. package/lib/quoxon/approvalFacts.js +64 -8
  140. package/lib/quoxon/approvalSentence.js +48 -0
  141. package/lib/quoxon/approvalsView.js +124 -7
  142. package/lib/quoxon/autoOpen.js +94 -0
  143. package/lib/quoxon/backgroundWork.js +59 -0
  144. package/lib/quoxon/bang.js +1 -1
  145. package/lib/quoxon/capabilities.js +7 -2
  146. package/lib/quoxon/commandShort.js +22 -3
  147. package/lib/quoxon/commands.js +110 -15
  148. package/lib/quoxon/connect.js +5 -3
  149. package/lib/quoxon/countdownLine.js +10 -1
  150. package/lib/quoxon/deck.js +30 -0
  151. package/lib/quoxon/deckLazy.js +58 -0
  152. package/lib/quoxon/demo.autoplay.js +1 -1
  153. package/lib/quoxon/demo.js +3 -1
  154. package/lib/quoxon/destructive.js +35 -0
  155. package/lib/quoxon/dividerAocl.js +4 -3
  156. package/lib/quoxon/dividerRule.js +3 -3
  157. package/lib/quoxon/dnd.js +71 -0
  158. package/lib/quoxon/dragonCompanion.js +285 -0
  159. package/lib/quoxon/dragonModes.js +1 -0
  160. package/lib/quoxon/dragonMoments.js +160 -0
  161. package/lib/quoxon/dragonReads.js +68 -0
  162. package/lib/quoxon/dragonView.js +2 -2
  163. package/lib/quoxon/echo.js +1 -0
  164. package/lib/quoxon/events.js +67 -10
  165. package/lib/quoxon/extensions.js +636 -0
  166. package/lib/quoxon/gripeQueue.js +135 -0
  167. package/lib/quoxon/inboxView.js +182 -0
  168. package/lib/quoxon/installCli.js +79 -0
  169. package/lib/quoxon/keys.js +4 -4
  170. package/lib/quoxon/loopCard.js +460 -0
  171. package/lib/quoxon/loopCards.js +189 -0
  172. package/lib/quoxon/loopStream.js +98 -0
  173. package/lib/quoxon/mailView.js +71 -0
  174. package/lib/quoxon/main.js +29 -5
  175. package/lib/quoxon/markdown.js +1 -1
  176. package/lib/quoxon/model.js +15 -0
  177. package/lib/quoxon/mouse.js +12 -1
  178. package/lib/quoxon/neverAnswered.js +20 -0
  179. package/lib/quoxon/objectiveOffer.js +82 -0
  180. package/lib/quoxon/objects/away.js +43 -5
  181. package/lib/quoxon/objects/index.js +22 -4
  182. package/lib/quoxon/objects/model.js +33 -0
  183. package/lib/quoxon/objects/proof.js +12 -4
  184. package/lib/quoxon/onboarding/connectServer.js +162 -0
  185. package/lib/quoxon/onboarding/copy.js +6 -0
  186. package/lib/quoxon/onboarding/index.js +5 -43
  187. package/lib/quoxon/onboarding/probe.js +20 -1
  188. package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
  189. package/lib/quoxon/onboarding/view.js +16 -3
  190. package/lib/quoxon/orgSwitch.js +49 -1
  191. package/lib/quoxon/planSteps.js +51 -0
  192. package/lib/quoxon/print.js +8 -3
  193. package/lib/quoxon/profileSwitch.js +7 -0
  194. package/lib/quoxon/promptHistory.js +3 -1
  195. package/lib/quoxon/qdragonFly.js +7 -6
  196. package/lib/quoxon/qmotion.js +17 -0
  197. package/lib/quoxon/render.js +187 -41
  198. package/lib/quoxon/roster.js +36 -17
  199. package/lib/quoxon/rosterSessions.js +44 -0
  200. package/lib/quoxon/rows.js +244 -1
  201. package/lib/quoxon/screenContext.js +1 -1
  202. package/lib/quoxon/secretShape.js +86 -0
  203. package/lib/quoxon/serverChip.js +42 -0
  204. package/lib/quoxon/session.js +10 -0
  205. package/lib/quoxon/slash/agents.js +2 -1
  206. package/lib/quoxon/slash/argSources.js +9 -2
  207. package/lib/quoxon/slash/args.js +4 -2
  208. package/lib/quoxon/slash/broadcast.js +18 -5
  209. package/lib/quoxon/slash/broadcastRun.js +13 -2
  210. package/lib/quoxon/slash/cliCommand.js +42 -1
  211. package/lib/quoxon/slash/cliLeaves.json +139 -2
  212. package/lib/quoxon/slash/evidenceCmd.js +34 -0
  213. package/lib/quoxon/slash/hatWalk.js +1 -1
  214. package/lib/quoxon/slash/palette.js +23 -2
  215. package/lib/quoxon/slash/paletteController.js +10 -4
  216. package/lib/quoxon/slash/qpeCmd.js +35 -0
  217. package/lib/quoxon/slash/restart.js +31 -4
  218. package/lib/quoxon/slash/resume.js +18 -1
  219. package/lib/quoxon/slash/stepUp.js +5 -5
  220. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  221. package/lib/quoxon/slash/todoCmd.js +122 -0
  222. package/lib/quoxon/slashSpinner.js +35 -0
  223. package/lib/quoxon/slotAttach.js +8 -0
  224. package/lib/quoxon/slotChat.js +17 -0
  225. package/lib/quoxon/slotFormat.js +1 -1
  226. package/lib/quoxon/slotOutbox.js +113 -0
  227. package/lib/quoxon/suggestCommand.js +34 -3
  228. package/lib/quoxon/term.js +25 -1
  229. package/lib/quoxon/toolCards.js +30 -0
  230. package/lib/quoxon/transcript.js +2 -1
  231. package/lib/quoxon/welcome.js +1 -1
  232. package/lib/quoxon/whatsnew.js +1 -1
  233. package/lib/quoxon/whatsnew.jsonl +81 -0
  234. package/lib/quoxon/windowOrg.js +11 -1
  235. package/lib/server/devServers.js +137 -0
  236. package/lib/server/installFlow.js +168 -0
  237. package/lib/server/status.js +15 -1
  238. package/lib/toolOutputShape.js +55 -0
  239. package/package.json +4 -2
  240. package/vendor/dragonlab/SOURCE +4 -0
  241. package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
  242. package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
  243. package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
  244. package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
  245. package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
  246. package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
  247. package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
  248. package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
  249. package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
  250. package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
  251. package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
  252. package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
  253. package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
  254. package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
  255. package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
  256. package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
  257. package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
  258. package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
  259. package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
  260. package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
  261. package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
  262. package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
  263. package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
  264. package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
  265. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  266. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  267. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  268. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  269. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  270. package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
  271. package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
  272. package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
  273. package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
  274. package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
  275. package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
  276. package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
  277. package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
  278. package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
  279. package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
  280. package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
  281. package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
  282. package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
  283. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  284. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  285. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  286. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  287. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  288. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  289. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  290. package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
  291. package/vendor/dragonlab/integration/companions-v1/characters.mjs +108 -0
  292. package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
  293. package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
  294. package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
  295. package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
  296. package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
  297. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  298. package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
  299. package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
  300. package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
  301. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
  302. package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
  303. package/vendor/dragonlab/lib/power-effects.js +294 -0
  304. package/vendor/dragonlab/lib/power-registry.js +74 -0
  305. package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
  306. package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * quox kuma sla ... : signed, verifiable uptime reports (NQ-KUMA-7).
3
+ *
4
+ * quox kuma sla [--month YYYY-MM | --from <iso> --to <iso>] [--out <file>] [--json] generate + sign + store
5
+ * quox kuma sla list this org's stored reports
6
+ * quox kuma sla verify <id|file> [--public-key <base64>] recompute the hash, check the signature
7
+ *
8
+ * `verify <id>` asks the collector, which checks against its pinned WARD key (trust anchored).
9
+ * `verify <file>` is fully OFFLINE: it recomputes sha256 over the canonical report and checks the Ed25519
10
+ * signature with node:crypto. Against the key embedded in the file this proves integrity only (anyone can
11
+ * mint a keypair); pass --public-key <raw 32 byte base64 WARD key> to pin the signer yourself.
12
+ *
13
+ * Exit codes: 0 ok / verified, 1 error, 2 verification FAILED.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
18
+ import { resolveEndpoint } from '../api/endpoints.js';
19
+ import { line as glyphLine } from '../core/quoxglyph.js';
20
+
21
+ // Same canonical form as services/collector/lib/bundleSigner.js canonicalize (sorted keys, no whitespace).
22
+ export function canonicalize(val) {
23
+ if (val === null || val === undefined) return JSON.stringify(val);
24
+ if (typeof val !== 'object') return JSON.stringify(val);
25
+ if (Array.isArray(val)) return `[${val.map((v) => canonicalize(v)).join(',')}]`;
26
+ const keys = Object.keys(val).filter((k) => val[k] !== undefined).sort();
27
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(val[k])}`).join(',')}}`;
28
+ }
29
+
30
+ const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
31
+ const keyFromRaw = (b64) => {
32
+ try {
33
+ const raw = Buffer.from(String(b64 || ''), 'base64');
34
+ if (raw.length !== 32) return null;
35
+ return createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
36
+ } catch { return null; }
37
+ };
38
+
39
+ /** Offline verification of an export bundle. Mirrors kumaSlaReports.verifyBundle. */
40
+ export function verifyBundleOffline(bundle, { publicKey } = {}) {
41
+ const out = { valid: false, hash_ok: false, binding_ok: false, signature_ok: false, key_source: 'none', trust_anchored: false };
42
+ if (!bundle || typeof bundle !== 'object' || !bundle.report || typeof bundle.report !== 'object') return { ...out, reason: 'malformed_bundle' };
43
+ const sig = bundle.signature;
44
+ if (!sig || !sig.signature || !sig.message) return { ...out, reason: 'missing_signature' };
45
+ const recomputed = createHash('sha256').update(canonicalize(bundle.report), 'utf-8').digest('hex');
46
+ out.recomputed_sha256 = recomputed;
47
+ out.hash_ok = recomputed === bundle.report_sha256;
48
+ if (!out.hash_ok) return { ...out, reason: 'report_hash_mismatch' };
49
+ const r = bundle.report;
50
+ const expected = r.period ? {
51
+ kind: 'quox.kuma.sla.report', report_id: bundle.report_id, org_id: r.org_id,
52
+ period_from: r.period.from, period_to: r.period.to, generated_at: r.generated_at, report_sha256: recomputed,
53
+ } : null;
54
+ out.binding_ok = !!expected && canonicalize(expected) === canonicalize(sig.message);
55
+ if (!out.binding_ok) return { ...out, reason: 'signature_not_bound_to_report' };
56
+ if (sig.sig_type !== 'ed25519') return { ...out, reason: `unverifiable_sig_type:${sig.sig_type}` };
57
+ let key = null; let source = 'none';
58
+ if (publicKey) { key = keyFromRaw(publicKey); source = 'pinned'; if (!key) return { ...out, reason: 'bad_public_key' }; }
59
+ else { key = keyFromRaw(sig.public_key); source = 'embedded'; }
60
+ if (!key) return { ...out, reason: 'no_verification_key' };
61
+ out.key_source = source;
62
+ try {
63
+ out.signature_ok = cryptoVerify(null, Buffer.from(canonicalize(sig.message), 'utf-8'), key, Buffer.from(String(sig.signature), 'base64'));
64
+ } catch (err) { return { ...out, reason: `verify_error:${err.message}` }; }
65
+ if (!out.signature_ok) return { ...out, reason: 'signature_mismatch' };
66
+ out.valid = true;
67
+ out.trust_anchored = source === 'pinned' && !sig.warning;
68
+ if (sig.warning) out.warning = sig.warning;
69
+ return out;
70
+ }
71
+
72
+ const dur = (s) => {
73
+ if (s === null || s === undefined) return '-';
74
+ if (s < 60) return `${s}s`;
75
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
76
+ return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
77
+ };
78
+
79
+ export function registerKumaSla(kuma, { client, auth, jsonOut, guard, KumaFail, pad }) {
80
+ const slaFail = (verb, ep, err) => {
81
+ const status = err && (err.status ?? err.statusCode);
82
+ const msg = (err && err.message) || String(err);
83
+ const head = `quox kuma ${verb}: ${ep.method} ${ep.path} failed${status ? ` with HTTP ${status}` : ''}`;
84
+ if (status === 422) return new KumaFail(`${head}: Uptime Kuma is not configured for this org (add the uptime_kuma credential in the vault).`);
85
+ if (status === 404) return new KumaFail(`${head}: not found in this org.`);
86
+ return new KumaFail(`${head}: ${msg}`);
87
+ };
88
+
89
+ const sla = kuma
90
+ .command('sla')
91
+ .description('Signed, verifiable uptime (SLA) report from Kuma heartbeats; `sla verify <id|file>` checks one')
92
+ .option('--month <YYYY-MM>', 'calendar month (UTC), e.g. 2026-09')
93
+ .option('--from <iso>', 'range start (with --to)')
94
+ .option('--to <iso>', 'range end, exclusive')
95
+ .option('--out <file>', 'also write the signed bundle here for offline verification')
96
+ .addHelpText('after', '\nExit codes: 0 ok, 1 error, 2 `sla verify` found the report does NOT verify.')
97
+ .action(guard('sla', async (options) => {
98
+ auth.requireAuth();
99
+ if (!options.month && !(options.from && options.to)) throw new KumaFail('quox kuma sla: give --month YYYY-MM, or both --from and --to.');
100
+ const ep = resolveEndpoint('mon.kuma.sla.create');
101
+ const body = options.month ? { month: options.month } : { from: options.from, to: options.to };
102
+ let data;
103
+ try { data = await client.post(ep.path, { service: ep.service, body }); } catch (err) { throw slaFail('sla', ep, err); }
104
+ if (!data || data.success === false || data.error || !data.report || !data.signature) {
105
+ throw new KumaFail(`quox kuma sla: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no report'}`);
106
+ }
107
+ if (options.out) {
108
+ writeFileSync(options.out, `${JSON.stringify({ report_id: data.report_id, report: data.report, report_sha256: data.report_sha256, signature: data.signature }, null, 2)}\n`);
109
+ }
110
+ if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
111
+ const r = data.report; const ro = r.rollup;
112
+ console.log(glyphLine('kuma', 'sla', { result: `${r.period.label} · ${ro.uptime_pct === null ? 'no data' : `${ro.uptime_pct}%`}`, status: ro.uptime_pct === null ? 'warn' : 'ok' }));
113
+ console.log(`Report ${data.report_id} sha256 ${data.report_sha256}`);
114
+ console.log(`Period ${r.period.from} to ${r.period.effective_to}${r.period.partial ? ' (partial: the period has not ended)' : ''}`);
115
+ const rows = [['MONITOR', 'UPTIME', 'OUTAGES', 'DOWNTIME', 'LONGEST', 'MTTR', 'MAINT', 'GAPS']];
116
+ for (const m of r.monitors) {
117
+ rows.push([m.name || m.monitor_id, m.status === 'no_data' ? 'no data' : `${m.uptime_pct}%`, m.outages.count, dur(m.outages.total_downtime_seconds), dur(m.outages.longest_seconds), dur(m.outages.mttr_seconds), m.beats.maintenance, m.coverage.gaps.length]);
118
+ }
119
+ console.log(`\n${pad(rows).join('\n')}`);
120
+ console.log(`\nOrg: ${ro.uptime_pct === null ? 'no data' : `${ro.uptime_pct}%`} over ${ro.monitors_with_data}/${ro.monitors_total} monitors with data; ${ro.outages.count} ${ro.outages.count === 1 ? 'outage' : 'outages'}, ${dur(ro.outages.total_downtime_seconds)} down, MTTR ${dur(ro.outages.mttr_seconds)}.`);
121
+ if (ro.monitors_no_data) console.log(`${ro.monitors_no_data} ${ro.monitors_no_data === 1 ? 'monitor has' : 'monitors have'} no heartbeats in the period: reported as no data, not as 100%.`);
122
+ console.log(data.trust_anchored ? 'Signed with the WARD key and witnessed.' : 'WARNING: signed with an ephemeral key (no persistent WARD key on this collector): integrity only, not trust anchored.');
123
+ console.log(`Verify: quox kuma sla verify ${options.out || data.report_id}`);
124
+ }));
125
+
126
+ sla
127
+ .command('list')
128
+ .description('This org\'s stored SLA reports')
129
+ .action(guard('sla list', async () => {
130
+ auth.requireAuth();
131
+ const ep = resolveEndpoint('mon.kuma.sla.list');
132
+ let data;
133
+ try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw slaFail('sla list', ep, err); }
134
+ if (!data || data.success === false || data.error || !Array.isArray(data.reports)) {
135
+ throw new KumaFail(`quox kuma sla list: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no reports array'}`);
136
+ }
137
+ if (jsonOut()) { console.log(JSON.stringify(data, null, 2)); return; }
138
+ console.log(glyphLine('kuma', 'sla list', { result: `${data.reports.length} ${data.reports.length === 1 ? 'report' : 'reports'}`, status: 'ok' }));
139
+ if (!data.reports.length) { console.log('No reports yet. Generate one: quox kuma sla --month YYYY-MM'); return; }
140
+ const rows = [['REPORT', 'PERIOD', 'CREATED', 'SHA256']];
141
+ for (const r of data.reports) rows.push([r.report_id, r.period, r.created_at, String(r.report_sha256).slice(0, 16)]);
142
+ console.log(pad(rows).join('\n'));
143
+ }));
144
+
145
+ sla
146
+ .command('verify')
147
+ .description('Recompute the report hash and check the signature (id: via the collector, pinned WARD key; file: fully offline)')
148
+ .argument('<id|file>', 'a report id from `quox kuma sla list`, or a bundle file written by `sla --out`')
149
+ .option('--public-key <base64>', 'offline: pin this raw 32 byte Ed25519 WARD public key instead of trusting the key in the file')
150
+ .action(guard('sla verify', async (target, options) => {
151
+ let result;
152
+ if (existsSync(target)) {
153
+ let bundle;
154
+ try { bundle = JSON.parse(readFileSync(target, 'utf-8')); } catch (err) { throw new KumaFail(`quox kuma sla verify: ${target} is not valid JSON: ${err.message}`); }
155
+ result = { report_id: bundle && bundle.report_id, mode: 'offline', ...verifyBundleOffline(bundle, { publicKey: options.publicKey }) };
156
+ } else {
157
+ auth.requireAuth();
158
+ const ep = resolveEndpoint('mon.kuma.sla.verify', { id: target });
159
+ let data;
160
+ try { data = await client.get(ep.path, { service: ep.service }); } catch (err) { throw slaFail('sla verify', ep, err); }
161
+ if (!data || data.success === false || data.error || typeof data.valid !== 'boolean') {
162
+ throw new KumaFail(`quox kuma sla verify: ${ep.method} ${ep.path} returned an error body: ${(data && data.error) || 'response had no verdict'}`);
163
+ }
164
+ result = { mode: 'collector', ...data };
165
+ }
166
+ if (jsonOut()) { console.log(JSON.stringify(result, null, 2)); } else {
167
+ console.log(glyphLine('kuma', 'sla verify', { result: result.valid ? `verified (${result.mode})` : `NOT verified: ${result.reason}`, status: result.valid ? 'ok' : 'error' }));
168
+ console.log(`hash ${result.hash_ok ? 'matches' : 'DOES NOT match'} binding ${result.binding_ok ? 'ok' : 'FAILED'} signature ${result.signature_ok ? 'valid' : 'INVALID'} key ${result.key_source}${result.trust_anchored ? ' (trust anchored)' : ' (not trust anchored)'}`);
169
+ if (result.valid && !result.trust_anchored) console.log('Integrity holds, but the signer was not checked against the WARD key. Pin it with --public-key, or verify by id through the collector.');
170
+ if (result.warning) console.log(`Warning: ${result.warning}`);
171
+ }
172
+ if (!result.valid) process.exitCode = 2;
173
+ }));
174
+ }
@@ -109,9 +109,21 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
109
109
  .description('List a plugin band holders; --release-dead frees non-live holders (dry run unless --yes)')
110
110
  .argument('<pluginId>', 'plugin id, e.g. notesq')
111
111
  .option('--release-dead', 'release holders that are not live orgs (deleted orgs, user: ids)')
112
+ .option('--release <orgId>', 'release a LIVE org seat (its install of the plugin is disabled); dry run unless --yes')
113
+ .option('--to <orgId>', 'with --release: hand the freed seat to this org in the same call')
112
114
  .option('--yes', 'actually release (otherwise dry run)')
113
115
  .action(async (pluginId, opts) => {
114
116
  auth.requireAuth();
117
+ if (opts.to && !opts.release) {
118
+ const e = new Error('--to only works together with --release <orgId>');
119
+ e.exitCode = 2;
120
+ throw e;
121
+ }
122
+ if (opts.release && opts.releaseDead) {
123
+ const e = new Error('use either --release or --release-dead, not both');
124
+ e.exitCode = 2;
125
+ throw e;
126
+ }
115
127
  const lep = resolveEndpoint('setup.licensing.seats.list', { pluginId });
116
128
  const list = await client.get(lep.path, { service: lep.service });
117
129
  let rel = null;
@@ -119,7 +131,15 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
119
131
  const rep = resolveEndpoint('setup.licensing.seats.releaseDead', { pluginId });
120
132
  rel = await client.post(rep.path, { service: rep.service, body: { dry_run: !opts.yes } });
121
133
  }
122
- if (json()) { console.log(JSON.stringify({ ...list, release: rel }, null, 2)); return; }
134
+ let live = null;
135
+ if (opts.release) {
136
+ const rp = resolveEndpoint('setup.licensing.seats.release', { pluginId });
137
+ live = await client.post(rp.path, {
138
+ service: rp.service,
139
+ body: { org_id: opts.release, ...(opts.to ? { to_org_id: opts.to } : {}), dry_run: !opts.yes },
140
+ });
141
+ }
142
+ if (json()) { console.log(JSON.stringify({ ...list, release: rel, live_release: live }, null, 2)); return; }
123
143
  printInfo(`${pluginId}: ${list.holders.length} holder(s), cap ${list.allowance ?? '(no band)'}`);
124
144
  formatOutput({
125
145
  columns: ['id', 'name', 'status'],
@@ -133,6 +153,24 @@ export function registerLicenceCommands(program, ctx, { owner = VENDOR_OWNER } =
133
153
  printSuccess(`Released ${rel.released.length} non-live holder(s): ${rel.released.join(', ') || '(none)'}`);
134
154
  }
135
155
  }
156
+ if (live) {
157
+ const who = `${live.org_name || live.org_id} (${live.org_id})`;
158
+ const loses = live.will_disable_install
159
+ ? `its install of ${pluginId} goes disabled and the plugin stops working there`
160
+ : `it loses access to ${pluginId} (no enabled install to disable)`;
161
+ if (live.dry_run) {
162
+ printInfo(`DRY RUN: would release the ${pluginId} seat held by ${who}: ${loses}.`);
163
+ if (live.would_assign_to) printInfo(`DRY RUN: the freed seat would go to ${live.would_assign_to}.`);
164
+ else printInfo(`Seats are first-come: add --to <orgId> to hand the freed seat to a specific org, otherwise the first org to use ${pluginId} takes it.`);
165
+ printInfo(`Re-run with --yes to release.`);
166
+ } else if (live.released) {
167
+ printSuccess(`Released the ${pluginId} seat held by ${who}: ${loses}.`);
168
+ if (live.assigned_to) printSuccess(`Seat now held by ${live.assigned_to}.`);
169
+ else if (opts.to) printWarning(`The seat was freed but ${opts.to} could not take it (see the band with: quox licence seats ${pluginId}).`);
170
+ } else {
171
+ printWarning(`Nothing released: ${who} no longer holds a ${pluginId} seat.`);
172
+ }
173
+ }
136
174
  });
137
175
 
138
176
  if (owner) owner.registerLicenceOwnerCommands(licence, { json, fetchImpl });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * QUOXON (owner 2026-11-05): the render layer for `quox setup license link`.
3
+ *
4
+ * The owner linked the wrong licence twice in one day (one granting 0
5
+ * plugins) because neither the web page nor the CLI said what was being
6
+ * picked. This view frames the code and link, counts the code down live,
7
+ * and on approval names WHAT was linked: licence id, plan, plugins, seats.
8
+ *
9
+ * Everything here is pure (strings in, strings out) so it is testable without
10
+ * a terminal. Colour comes from the house QuoxGlyph module (lib/core/
11
+ * quoxglyph.js: line grammar, brand gradient, status colours) and the spinner
12
+ * frames from the TUI's house spinner; nothing here invents a parallel style.
13
+ * With colour off every function returns plain lines with no escape codes.
14
+ */
15
+
16
+ import { line, setColor, GRAD, STATUS, celebrate } from '../core/quoxglyph.js';
17
+ import { SPIN_FRAMES } from '../quoxon/slashSpinner.js';
18
+ import { motionAllowed } from '../quoxon/motion/clock.js';
19
+
20
+ const R = '\x1b[0m';
21
+ const BOLD = '\x1b[1m';
22
+ const DIM = '\x1b[2m';
23
+ const UNDERLINE = '\x1b[4m';
24
+ const fg = ([r, g, b]) => `\x1b[38;2;${r};${g};${b}m`;
25
+ const AMBER = STATUS.warn.rgb;
26
+ const GREEN = STATUS.ok.rgb;
27
+
28
+ // eslint-disable-next-line no-control-regex
29
+ const ESC = /\x1b\]8;;[^\x07]*\x07|\x1b\[[0-9;]*[A-Za-z]/g;
30
+ /** Visible width: escape codes stripped, one column per code point. */
31
+ const vis = (s) => [...String(s).replace(ESC, '')].length;
32
+ const padTo = (s, w) => s + ' '.repeat(Math.max(0, w - vis(s)));
33
+
34
+ /** Run a QuoxGlyph render with colour forced on or off, then hand control back to auto. */
35
+ function withColor(color, fn) {
36
+ setColor(color);
37
+ try { return fn(); } finally { setColor(null); }
38
+ }
39
+
40
+ /** Should this terminal get colour? Same rule as quoxglyph, decided for an injected stream. */
41
+ export function colorFor(term, env = process.env) {
42
+ if (env.QUOX_COLOR === '0') return false;
43
+ if (env.QUOX_COLOR === '1' || env.QUOX_COLOR === '2') return true;
44
+ return Boolean(term && term.isTTY) && !env.NO_COLOR;
45
+ }
46
+
47
+ /** Should the waiting line animate? TTY only, and never under reduced motion. */
48
+ export const animateFor = (term, env = process.env) => motionAllowed(Boolean(term && term.isTTY), env);
49
+
50
+ /** m:ss, floored at 0:00. */
51
+ export function fmtCountdown(ms) {
52
+ const total = Math.max(0, Math.floor(Number(ms) / 1000) || 0);
53
+ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
54
+ }
55
+
56
+ /** WQXP-7T4K -> "W Q X P - 7 T 4 K". */
57
+ export const spacedCode = (code) => [...String(code || '')].join(' ');
58
+
59
+ /** Paint text in the brand gradient (per visible character), bold. */
60
+ function gradient(text) {
61
+ const chars = [...text];
62
+ const last = Math.max(1, chars.length - 1);
63
+ return BOLD + chars.map((ch, i) => (ch === ' ' ? ch
64
+ : fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + ch)).join('') + R;
65
+ }
66
+
67
+ /** ▰▰▰▱▱ in the brand gradient, for a 0..1 ratio. */
68
+ function bar(ratio, width = 10) {
69
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
70
+ const last = Math.max(1, width - 1);
71
+ let out = '';
72
+ for (let i = 0; i < filled; i++) out += fg(GRAD[0].map((v, k) => Math.round(v + (GRAD[1][k] - v) * (i / last)))) + '▰';
73
+ return out + (filled ? R : '') + DIM + '▱'.repeat(width - filled) + R;
74
+ }
75
+
76
+ /** OSC 8 hyperlink whose visible text is the full URL, so it is clickable either way. */
77
+ const hyperlink = (url) => `\x1b]8;;${url}\x07${UNDERLINE}${fg(GRAD[0])}${url}${R}\x1b]8;;\x07`;
78
+
79
+ /**
80
+ * A rounded frame around rows (already styled). The title sits in the top
81
+ * border. The frame grows to fit its widest row (a URL is never clipped).
82
+ */
83
+ function frame(rows, { title = '', cols = 80, tint = null } = {}) {
84
+ const b = (s) => (tint ? fg(tint) + s + R : DIM + s + R);
85
+ const titleText = title ? ` ${title} ` : '';
86
+ const inner = Math.max(44, Math.min(Math.max(44, cols - 6), 76), ...rows.map((r) => vis(r) + 4), vis(titleText) + 4);
87
+ const top = b('╭─') + titleText + b('─'.repeat(Math.max(0, inner - 1 - vis(titleText))) + '╮');
88
+ const body = rows.map((r) => b('│') + padTo(` ${r}`, inner) + b('│'));
89
+ const bottom = b('╰' + '─'.repeat(inner) + '╯');
90
+ return [top, ...body, bottom].map((l) => ` ${l}`).join('\n');
91
+ }
92
+
93
+ /** Greedy wrap of items into lines no wider than `width`, joined by `sep`. */
94
+ function wrapList(items, width, sep = ' · ') {
95
+ const lines = [];
96
+ let cur = '';
97
+ for (const item of items) {
98
+ const next = cur ? cur + sep + item : item;
99
+ if (cur && vis(next) > width) { lines.push(cur); cur = item; } else cur = next;
100
+ }
101
+ if (cur) lines.push(cur);
102
+ return lines;
103
+ }
104
+
105
+ const minutes = (ms) => Math.max(1, Math.round(ms / 60000));
106
+
107
+ /**
108
+ * The card shown while the code waits for approval.
109
+ * @param {{userCode:string,url:string,remainingMs:number,color:boolean,cols?:number}} o
110
+ */
111
+ export function renderLinkCard({ userCode, url, remainingMs, color, cols = 80 }) {
112
+ const header = withColor(color, () => line('quox', 'licence link', { result: 'approve on quox.ai', status: 'busy' }));
113
+ if (!color) {
114
+ return [
115
+ header,
116
+ `Open ${url} and enter this code:`,
117
+ '',
118
+ ` ${userCode}`,
119
+ '',
120
+ `The code is good for ${minutes(remainingMs)} minutes.`,
121
+ 'Pick the licence you want on quox.ai: this instance gets exactly what you approve.',
122
+ 'Waiting for approval...',
123
+ ].join('\n');
124
+ }
125
+ const label = (s) => `${DIM}${s.padEnd(11)}${R}`;
126
+ const rows = [
127
+ '',
128
+ `${label('Your code')}${gradient(spacedCode(userCode))}`,
129
+ '',
130
+ `${label('Open')}${hyperlink(url)}`,
131
+ `${label('Expires in')}${bar(1)} ${BOLD}${fmtCountdown(remainingMs)}${R}`,
132
+ '',
133
+ `${fg(AMBER)}Pick the licence you want on quox.ai.${R}`,
134
+ `${DIM}This instance gets exactly what you approve.${R}`,
135
+ ];
136
+ return [header, frame(rows, { title: `${BOLD}Link this instance${R}`, cols })].join('\n');
137
+ }
138
+
139
+ /**
140
+ * One frame of the live waiting line (TTY only): spinner, label, countdown.
141
+ * @param {{frame:number,remainingMs:number,totalMs?:number,color:boolean,note?:string}} o
142
+ */
143
+ export function renderWaitingLine({ frame: i, remainingMs, totalMs = 600000, color, note = '' }) {
144
+ const spin = SPIN_FRAMES[i % SPIN_FRAMES.length];
145
+ const left = `${fmtCountdown(remainingMs)} left`;
146
+ const extra = note ? ` · ${note}` : '';
147
+ if (!color) return `${spin} Waiting for approval on quox.ai · ${left}${extra}`;
148
+ const ratio = totalMs > 0 ? remainingMs / totalMs : 0;
149
+ const tone = ratio < 0.2 ? fg(AMBER) : '';
150
+ return ` ${fg(GRAD[0])}${spin}${R} Waiting for approval on quox.ai ${DIM}│${R} ${bar(ratio)} ${tone}${BOLD}${left}${R}${note ? `${DIM}${extra}${R}` : ''}`;
151
+ }
152
+
153
+ const bareId = (raw) => String(raw).split('@')[0];
154
+ const orgAllowance = (raw) => {
155
+ const m = /@orgs=(\d+)/.exec(String(raw));
156
+ return m ? Number(m[1]) : null;
157
+ };
158
+
159
+ /**
160
+ * What was linked, from the approved lease plus the read-back of
161
+ * GET /api/setup/licensing/entitlements (the route `quox licence status`
162
+ * reads). The read-back wins where it has the licence; the lease is the
163
+ * fallback, so a failed read never hides what was activated.
164
+ *
165
+ * @returns {{license_id, plans:string[], entitlements:string[], seats:Array<{plugin,used,total}>, warnings:string[], servers?:{used,total}}}
166
+ */
167
+ export function linkedDetails(lease = {}, status = null, poll = {}) {
168
+ const id = lease.license_id || null;
169
+ const row = (status && Array.isArray(status.licenses))
170
+ ? status.licenses.find((l) => l && l.license_id === id) : null;
171
+
172
+ let rawEntitlements;
173
+ let plans;
174
+ if (row) {
175
+ rawEntitlements = row.entitlements || [];
176
+ plans = row.plans || [];
177
+ } else {
178
+ const raw = lease.entitlements || [];
179
+ plans = raw.filter((e) => String(e).toLowerCase().startsWith('plan.')).map((e) => bareId(e).slice(5).toLowerCase()).filter(Boolean);
180
+ rawEntitlements = raw.filter((e) => !String(e).toLowerCase().startsWith('plan.'));
181
+ }
182
+
183
+ const bands = (status && status.org_bands && typeof status.org_bands === 'object') ? status.org_bands : {};
184
+ const entitlements = [...new Set(rawEntitlements.map(bareId))];
185
+ const seats = [];
186
+ for (const raw of rawEntitlements) {
187
+ const plugin = bareId(raw);
188
+ const band = bands[plugin];
189
+ if (band) seats.push({ plugin, used: Number(band.assigned_count ?? 0), total: Number(band.allowance ?? 0) });
190
+ else if (orgAllowance(raw) != null) seats.push({ plugin, used: null, total: orgAllowance(raw) });
191
+ }
192
+
193
+ const out = { license_id: id, plans, entitlements, seats, warnings: entitlements.length === 0 ? ['zero_entitlements'] : [] };
194
+ // Server seats travel only when the poll response carries them (the
195
+ // control plane sends max_servers and bound; additive, never guessed).
196
+ if (poll && poll.max_servers != null) {
197
+ out.servers = { used: Array.isArray(poll.bound) ? poll.bound.length : null, total: Number(poll.max_servers) };
198
+ }
199
+ return out;
200
+ }
201
+
202
+ const seatText = (s) => (s.used == null ? `${s.plugin} up to ${s.total} orgs` : `${s.plugin} ${s.used} of ${s.total} orgs`);
203
+ const leaseText = (iso) => {
204
+ const t = Date.parse(iso);
205
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 16).replace('T', ' ') + ' UTC' : null;
206
+ };
207
+
208
+ /** The exact command that unlinks this licence and starts a fresh link. */
209
+ export function relinkCommand(licenseId, profile) {
210
+ const q = profile ? `quox -p ${profile}` : 'quox';
211
+ return `${q} setup license deactivate --license-id ${licenseId} --yes && ${q} setup license link`;
212
+ }
213
+
214
+ /**
215
+ * The result after approval: compact, celebratory when the licence grants
216
+ * something, amber with the one fix command when it grants nothing.
217
+ * @param {ReturnType<typeof linkedDetails>} d
218
+ * @param {{color:boolean, cols?:number, leaseExpiresAt?:string, profile?:string, readBackError?:string}} o
219
+ */
220
+ export function renderLinkedCard(d, { color, cols = 80, leaseExpiresAt = null, profile = null, readBackError = null } = {}) {
221
+ const zero = d.warnings.includes('zero_entitlements');
222
+ const header = withColor(color, () => (zero
223
+ ? line('quox', 'licence linked', { result: 'grants 0 plugins', status: 'warn' })
224
+ : line('quox', 'licence linked', { result: color ? celebrate('instance activated') : 'instance activated', status: 'ok', max: 400 })));
225
+ const plan = d.plans.length ? d.plans.join(', ') : '(none on this licence)';
226
+ const renews = leaseText(leaseExpiresAt);
227
+ const fix = relinkCommand(d.license_id, profile);
228
+ const warnText = 'This licence grants 0 plugins, so nothing unlocks. You may have picked the wrong licence on quox.ai.';
229
+ const readBack = readBackError
230
+ ? `Could not read the licence details back (${readBackError}). Showing what the lease carried; run \`quox licence status\` to check.`
231
+ : null;
232
+
233
+ if (!color) {
234
+ const out = [header];
235
+ if (readBack) out.push(readBack);
236
+ out.push(`Licence: ${d.license_id}`);
237
+ out.push(`Plan: ${plan}`);
238
+ out.push(`Plugins (${d.entitlements.length}): ${d.entitlements.join(', ') || 'none'}`);
239
+ if (d.seats.length) out.push(`Seats: ${d.seats.map(seatText).join(', ')}`);
240
+ if (d.servers) out.push(`Servers: ${d.servers.used ?? '?'} of ${d.servers.total} in use`);
241
+ if (renews) out.push(`Lease renews by: ${renews}`);
242
+ if (zero) {
243
+ out.push(`⚠ ${warnText}`);
244
+ out.push('Unlink it and link the right one:');
245
+ out.push(` ${fix}`);
246
+ } else {
247
+ out.push('This instance is activated. No key was ever pasted into it.');
248
+ }
249
+ return out.join('\n');
250
+ }
251
+
252
+ const label = (s) => `${DIM}${s.padEnd(10)}${R}`;
253
+ const width = Math.min(Math.max(44, cols - 6), 76) - 14;
254
+ const rows = [
255
+ `${label('Licence')}${BOLD}${d.license_id}${R}`,
256
+ `${label('Plan')}${d.plans.length ? gradient(plan) : `${DIM}${plan}${R}`}`,
257
+ ];
258
+ if (zero) {
259
+ rows.push(`${label('Plugins')}${fg(AMBER)}${BOLD}0${R}${fg(AMBER)} entitled${R}`);
260
+ } else {
261
+ const list = wrapList(d.entitlements, width);
262
+ rows.push(`${label('Plugins')}${BOLD}${d.entitlements.length}${R} ${DIM}entitled${R}`);
263
+ for (const l of list) rows.push(`${' '.repeat(10)}${fg(GREEN)}${l}${R}`);
264
+ }
265
+ for (const [n, s] of d.seats.entries()) {
266
+ const ratio = s.used == null || !s.total ? 0 : s.used / s.total;
267
+ const text = s.used == null ? `up to ${s.total} orgs` : `${s.used} of ${s.total} orgs`;
268
+ rows.push(`${label(n === 0 ? 'Seats' : '')}${s.plugin} ${bar(ratio, 6)} ${text}`);
269
+ }
270
+ if (d.servers) rows.push(`${label('Servers')}${d.servers.used ?? '?'} of ${d.servers.total} in use`);
271
+ if (renews) rows.push(`${label('Lease')}${DIM}renews by ${renews}${R}`);
272
+
273
+ const title = zero
274
+ ? `${fg(AMBER)}${BOLD}${STATUS.warn.glyph} Linked, but it grants 0 plugins${R}`
275
+ : `${fg(GREEN)}${BOLD}${STATUS.ok.glyph} Licence linked${R}`;
276
+ const out = [header];
277
+ if (readBack) out.push(` ${fg(AMBER)}${readBack}${R}`);
278
+ out.push(frame(rows, { title, cols, tint: zero ? AMBER : GREEN }));
279
+ if (zero) {
280
+ out.push(` ${fg(AMBER)}${BOLD}${STATUS.warn.glyph}${R} ${fg(AMBER)}${warnText}${R}`);
281
+ out.push(` ${DIM}Unlink it and link the right one:${R}`);
282
+ out.push(` ${BOLD}${fix}${R}`);
283
+ } else {
284
+ out.push(` ${DIM}No key was ever pasted into this instance.${R}`);
285
+ }
286
+ return out.join('\n');
287
+ }
288
+
289
+ /**
290
+ * The live waiting indicator. On an animating TTY it redraws one line in
291
+ * place every 80 ms (house spinner pace) with the countdown; with motion off
292
+ * it draws once. `note()` prints a line above it without tearing the spinner.
293
+ * `stop()` clears the line. Never used when the stream is not a TTY.
294
+ */
295
+ export function startWaiting({ term, deadline, totalMs, color, animate, now = () => Date.now(), frameMs = 80 }) {
296
+ let i = 0;
297
+ let note = '';
298
+ const draw = () => {
299
+ term.write('\r\x1b[2K' + renderWaitingLine({ frame: i++, remainingMs: deadline - now(), totalMs, color, note }));
300
+ };
301
+ draw();
302
+ const timer = animate ? setInterval(draw, frameMs) : null;
303
+ if (timer && typeof timer.unref === 'function') timer.unref();
304
+ return {
305
+ note(text) { note = text; draw(); },
306
+ stop() {
307
+ if (timer) clearInterval(timer);
308
+ term.write('\r\x1b[2K');
309
+ },
310
+ };
311
+ }
@@ -4,6 +4,7 @@
4
4
  * quox linkedin post "<text>" --as <author_urn> [--visibility PUBLIC|CONNECTIONS] [--link <url>] [--image-url <https url>]
5
5
  * quox linkedin comment <post_urn> "<text>" --as <actor_urn>
6
6
  * quox linkedin posts <author_urn> [--count <n>]
7
+ * quox linkedin whoami
7
8
  * quox linkedin org <org_id>
8
9
  *
9
10
  * Both write verbs execute the linkedin connector tools through the governed
@@ -154,6 +155,24 @@ export function registerLinkedInCommands(program, ctx) {
154
155
  }
155
156
  });
156
157
 
158
+ // ── linkedin me (read) ───────────────────────────────────────────────────
159
+
160
+ linkedin
161
+ .command('whoami')
162
+ .description('Show the connected member identity and the member URN to use with --as on posts (read-only). Needs the openid and profile scopes on the token.')
163
+ .action(async () => {
164
+ const data = await executeLinkedInTool({ client, auth, program }, 'linkedin_get_me', {});
165
+
166
+ if (program.opts().output === 'json') {
167
+ formatDetail(data, { format: 'json' });
168
+ return;
169
+ }
170
+ const me = data.result || {};
171
+ printSuccess(`${me.name || 'Unknown'} (${me.member_urn || 'no member URN returned'})`);
172
+ if (me.email) printInfo(`Email: ${me.email}`);
173
+ if (me.member_urn) printInfo(`Use as author: --as ${me.member_urn}`);
174
+ });
175
+
157
176
  // ── linkedin org (read) ──────────────────────────────────────────────────
158
177
 
159
178
  linkedin