quoxcli 0.0.0-stage → 0.2.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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Setup commands: status, admin, config, license
3
+ */
4
+
5
+ import chalk from 'chalk';
6
+ import { formatDetail, printSuccess, printInfo } from '../core/output.js';
7
+ import { resolveEndpoint } from '../api/endpoints.js';
8
+ import { QuoxApiError, QuoxValidationError } from '../core/errors.js';
9
+ import { confirmDestructive } from '../core/confirm.js';
10
+
11
+ /**
12
+ * LICENSE-LAUNCH LL-8: read a license-key-v2 JSON object from a file or
13
+ * stdin, never from argv.
14
+ *
15
+ * Secret hygiene, same rule as `vault create` (fleet #391): argv is visible
16
+ * in the process list and lands in shell history, so there is deliberately no
17
+ * `--key <json>` flag. A file can be 0600 and shredded afterwards; stdin
18
+ * never touches disk.
19
+ *
20
+ * The RAW text is what gets posted, not a re-serialisation of the parsed
21
+ * object: the key is Ed25519-signed over its own bytes, and re-emitting it
22
+ * with different key order or spacing would invalidate the signature. The
23
+ * parse here is validation only.
24
+ *
25
+ * @returns {Promise<string>} the trimmed raw license key text
26
+ */
27
+ async function readLicenseKey(options) {
28
+ let raw;
29
+ let source;
30
+
31
+ if (options.stdin) {
32
+ source = 'stdin';
33
+ raw = await new Promise((resolve, reject) => {
34
+ let buf = '';
35
+ process.stdin.setEncoding('utf8');
36
+ process.stdin.on('data', c => { buf += c; });
37
+ process.stdin.on('end', () => resolve(buf));
38
+ process.stdin.on('error', reject);
39
+ });
40
+ } else if (options.keyFile) {
41
+ source = options.keyFile;
42
+ const { readFile } = await import('node:fs/promises');
43
+ try {
44
+ raw = await readFile(options.keyFile, 'utf8');
45
+ } catch (err) {
46
+ throw new QuoxValidationError(`Could not read license key from ${options.keyFile}: ${err.message}`);
47
+ }
48
+ } else {
49
+ // No prompt, ever. This runs from boot scripts on headless instances,
50
+ // where an inquirer prompt is a hang, not a question.
51
+ throw new QuoxValidationError(
52
+ 'No license key supplied. Pass --key-file <path>, or pipe the key and pass --stdin. '
53
+ + 'There is no --key flag: argv is visible in the process list and shell history.'
54
+ );
55
+ }
56
+
57
+ const text = (raw || '').trim();
58
+ if (!text) {
59
+ throw new QuoxValidationError(`License key from ${source} is empty.`);
60
+ }
61
+
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(text);
65
+ } catch (err) {
66
+ throw new QuoxValidationError(
67
+ `License key from ${source} is not valid JSON: ${err.message}. `
68
+ + 'Paste the whole license-key-v2 object from your purchase email.'
69
+ );
70
+ }
71
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
72
+ throw new QuoxValidationError(`License key from ${source} must be a JSON object.`);
73
+ }
74
+
75
+ return text;
76
+ }
77
+
78
+ /** Best available human name for one entry of a `bound` seat list. */
79
+ function boundEntryName(entry) {
80
+ if (typeof entry === 'string') return entry;
81
+ return entry?.instance_name
82
+ || entry?.bound_instance_name
83
+ || entry?.hostname
84
+ || entry?.instance_id
85
+ || 'unknown instance';
86
+ }
87
+
88
+ /**
89
+ * LICENSE-LAUNCH LL-3d: render the seat context the licensing endpoints send.
90
+ *
91
+ * The control plane's 409 carries `bound_instance_name`, and (after LL-2a)
92
+ * a `bound` list plus `max_servers`; the 402 carries the unlicensed `plugin`.
93
+ * core/client.js now hangs the whole body on `err.body`, so say WHICH box
94
+ * holds the seat and HOW MANY of the licensed servers are in use, instead of
95
+ * "Request failed with status 409". The raw body rides along on the returned
96
+ * error, so `--output json` still emits exactly what the server said.
97
+ *
98
+ * Returns the error unchanged when it carries no licensing context.
99
+ */
100
+ function enrichLicenseError(err) {
101
+ if (!(err instanceof QuoxApiError)) return err;
102
+
103
+ const body = err.body && typeof err.body === 'object' ? err.body : {};
104
+ const code = err.code || body.error;
105
+ const lines = [];
106
+
107
+ if (code === 'seat_taken' || code === 'seat_limit_reached') {
108
+ lines.push(code === 'seat_limit_reached'
109
+ ? 'This licence has no free seats left.'
110
+ : 'This licence seat is already bound to another instance.');
111
+
112
+ if (body.bound_instance_name) {
113
+ lines.push(` Seat held by: ${body.bound_instance_name}`);
114
+ }
115
+
116
+ const bound = Array.isArray(body.bound) ? body.bound : [];
117
+ const maxServers = Number.isFinite(Number(body.max_servers)) && body.max_servers != null
118
+ ? Number(body.max_servers)
119
+ : null;
120
+ if (bound.length) {
121
+ lines.push(maxServers != null
122
+ ? ` Seats: ${bound.length} of ${maxServers} servers in use`
123
+ : ` Seats: ${bound.length} servers in use`);
124
+ for (const entry of bound) lines.push(` - ${boundEntryName(entry)}`);
125
+ } else if (maxServers != null) {
126
+ lines.push(` Seats: ${maxServers} servers allowed by this licence`);
127
+ }
128
+
129
+ lines.push('Run `quox setup license deactivate` on the instance holding the seat '
130
+ + '(or release it in the quox.ai admin), then retry.');
131
+ } else if (code === 'LICENSE_REQUIRED') {
132
+ const plugin = body.plugin || body.requiredPlugin;
133
+ lines.push(plugin
134
+ ? `A licence entitlement is required for \`${plugin}\`.`
135
+ : 'A licence entitlement is required for this capability.');
136
+ lines.push('Activate a licence that covers it with `quox setup license activate --key-file <path>`, '
137
+ + 'then confirm with `quox setup license status`.');
138
+ } else {
139
+ return err;
140
+ }
141
+
142
+ return new QuoxApiError(err.status, err.code || code, lines.join('\n'), err.detail, err.body);
143
+ }
144
+
145
+ /** Pull whatever bound-server identity the activate response carries. */
146
+ function boundServerFields(lease, data) {
147
+ const out = {};
148
+ for (const key of ['instance_id', 'instance_name', 'bound_instance_name', 'server_id', 'hostname']) {
149
+ const value = lease?.[key] ?? data?.[key];
150
+ if (value != null && value !== '') out[key] = value;
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * LICENSE-LAUNCH LL-8-dash: how long a `link` run waits between polls when the
157
+ * server sends no interval. The control plane's own floor is 5s.
158
+ */
159
+ const DEFAULT_LINK_INTERVAL_SECONDS = 5;
160
+
161
+ export function registerSetupCommands(program, ctx) {
162
+ const { client, config, auth } = ctx;
163
+ // Injectable so tests drive the poll loop without waiting real seconds
164
+ // (same seam as devSupervisor.js).
165
+ const sleepFn = ctx.sleepFn || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
166
+
167
+ const setup = program
168
+ .command('setup')
169
+ .description('First-time setup and configuration');
170
+
171
+ setup
172
+ .command('status')
173
+ .description('Check setup status')
174
+ .action(async () => {
175
+ const data = await client.get(resolveEndpoint('setup.status.list').path, { service: resolveEndpoint('setup.status.list').service, noAuth: true });
176
+ formatDetail(data, {
177
+ format: program.opts().output,
178
+ title: 'Setup Status',
179
+ });
180
+ });
181
+
182
+ setup
183
+ .command('admin')
184
+ .description('Create the initial admin account')
185
+ .option('--email <email>', 'admin email')
186
+ .option('--password <password>', 'admin password')
187
+ .option('--name <name>', 'admin display name')
188
+ .action(async (options) => {
189
+ let { email, password, name } = options;
190
+
191
+ if (!email || !password || !name) {
192
+ const { default: inquirer } = await import('inquirer');
193
+
194
+ if (!email) {
195
+ const ans = await inquirer.prompt([{
196
+ type: 'input', name: 'email', message: 'Admin email:',
197
+ validate: v => v.includes('@') || 'Enter a valid email',
198
+ }]);
199
+ email = ans.email;
200
+ }
201
+ if (!name) {
202
+ const ans = await inquirer.prompt([{
203
+ type: 'input', name: 'name', message: 'Display name:',
204
+ }]);
205
+ name = ans.name;
206
+ }
207
+ if (!password) {
208
+ const ans = await inquirer.prompt([{
209
+ type: 'password', name: 'password', message: 'Password:', mask: '*',
210
+ }]);
211
+ password = ans.password;
212
+ }
213
+ }
214
+
215
+ // services/auth/routes/setup.js POST /admin destructures `name` (not
216
+ // `display_name`) from req.body — sending only display_name silently
217
+ // dropped the admin's name (fell back to 'Admin'). Send both: `name`
218
+ // is what the endpoint actually reads; `display_name` is kept for
219
+ // forward-compat / any other consumer that expects it.
220
+ const data = await client.post(resolveEndpoint('setup.admin').path, {
221
+ service: resolveEndpoint('setup.admin').service,
222
+ body: { email, password, name, display_name: name },
223
+ noAuth: true,
224
+ });
225
+ printSuccess(`Admin account created for ${email}.`);
226
+ printInfo('Run `quox login` to authenticate.');
227
+ });
228
+
229
+ setup
230
+ .command('config')
231
+ .description('Configure platform settings')
232
+ .option('--key <key>', 'config key')
233
+ .option('--value <value>', 'config value')
234
+ .action(async (options) => {
235
+ if (options.key && options.value) {
236
+ await client.post(resolveEndpoint('setup.config').path, {
237
+ service: resolveEndpoint('setup.config').service,
238
+ body: { [options.key]: options.value },
239
+ noAuth: true,
240
+ });
241
+ printSuccess(`Setup config ${options.key} set.`);
242
+ } else {
243
+ const data = await client.get(resolveEndpoint('setup.config.list').path, { service: resolveEndpoint('setup.config.list').service, noAuth: true });
244
+ formatDetail(data.config || data, { format: program.opts().output, title: 'Setup Config' });
245
+ }
246
+ });
247
+
248
+ // ── LICENSE-LAUNCH LL-8: license activation from the terminal ────────────
249
+ // Parity with the Settings > License surface in the dashboard, so a
250
+ // headless instance (the demo box redeploys every 15 minutes and comes
251
+ // back unactivated) can activate itself from a boot script.
252
+ const license = setup
253
+ .command('license')
254
+ .description('Activate, inspect and release this instance licence');
255
+
256
+ license
257
+ .command('activate')
258
+ .description('Activate a license-key-v2 key on this instance (instance admin)')
259
+ .option('--key-file <path>', 'read the license key JSON from a file (keep it 0600)')
260
+ .option('--stdin', 'read the license key JSON from stdin (never touches disk)')
261
+ .action(async (options) => {
262
+ auth.requireAuth();
263
+ const licenseKey = await readLicenseKey(options);
264
+
265
+ const ep = resolveEndpoint('setup.licensing.activate');
266
+ let data;
267
+ try {
268
+ data = await client.post(ep.path, { service: ep.service, body: { license_key: licenseKey } });
269
+ } catch (err) {
270
+ // The 409 body is { error: 'seat_taken'|'seat_limit_reached', ... }
271
+ // with no `message`. Say what the caller can DO, and name the box
272
+ // holding the seat, instead of "Request failed with status 409".
273
+ throw enrichLicenseError(err);
274
+ }
275
+
276
+ const lease = data.lease || {};
277
+ const summary = {
278
+ license_id: lease.license_id || null,
279
+ entitlements: lease.entitlements || [],
280
+ lease_expires_at: lease.expires_at || null,
281
+ ...boundServerFields(lease, data),
282
+ };
283
+ formatDetail(summary, { format: program.opts().output, title: 'Licence activated' });
284
+ });
285
+
286
+ // LICENSE-LAUNCH LL-8-dash: activation with nothing pasted into the box.
287
+ // The instance asks quox.ai for a code pair (through the auth service, so
288
+ // the device_code never leaves the server), prints the short code and the
289
+ // link, and polls until a logged-in customer approves it. Nothing prompts:
290
+ // this is meant to run in a boot script on a headless instance.
291
+ license
292
+ .command('link')
293
+ .description('Activate this instance by approving a short code on quox.ai (no key paste)')
294
+ .action(async () => {
295
+ auth.requireAuth();
296
+ const json = program.opts().output === 'json';
297
+ const emit = (event) => { if (json) console.log(JSON.stringify(event)); };
298
+
299
+ const startEp = resolveEndpoint('setup.licensing.device.start');
300
+ let started;
301
+ try {
302
+ started = await client.post(startEp.path, { service: startEp.service, body: {} });
303
+ } catch (err) {
304
+ throw enrichLicenseError(err);
305
+ }
306
+
307
+ const deviceCode = started.device_code;
308
+ if (!deviceCode || !started.user_code) {
309
+ throw new QuoxApiError(502, 'malformed_device_start',
310
+ 'The instance did not return an activation code pair. Check that it can reach the licence server.');
311
+ }
312
+
313
+ let interval = Number(started.interval) > 0 ? Number(started.interval) : DEFAULT_LINK_INTERVAL_SECONDS;
314
+ const expiresIn = Number(started.expires_in) > 0 ? Number(started.expires_in) : 900;
315
+ const deadline = Date.now() + expiresIn * 1000;
316
+
317
+ emit({
318
+ state: 'started',
319
+ user_code: started.user_code,
320
+ verification_url: started.verification_url || null,
321
+ expires_in: expiresIn,
322
+ interval,
323
+ });
324
+
325
+ if (!json) {
326
+ printInfo(`Open ${started.verification_url || 'https://quox.ai/link'} and enter this code:`);
327
+ console.log('');
328
+ console.log(chalk.bold.cyan(` ${started.user_code}`));
329
+ console.log('');
330
+ printInfo(`The code is good for ${Math.round(expiresIn / 60)} minutes. Waiting for approval...`);
331
+ }
332
+
333
+ const pollEp = resolveEndpoint('setup.licensing.device.poll');
334
+ let lastState = 'started';
335
+
336
+ while (Date.now() < deadline) {
337
+ await sleepFn(interval * 1000);
338
+
339
+ let result;
340
+ try {
341
+ result = await client.post(pollEp.path, {
342
+ service: pollEp.service,
343
+ body: { device_code: deviceCode, interval },
344
+ });
345
+ } catch (err) {
346
+ // 503 means quox.ai blipped, NOT that the activation died: the code
347
+ // is still live upstream and the customer may still be approving it.
348
+ // Anything else (409 seat_limit_reached, 403, 400) is terminal.
349
+ if (err instanceof QuoxApiError && err.status === 503) {
350
+ if (lastState !== 'unreachable') {
351
+ emit({ state: 'unreachable', message: err.message });
352
+ if (!json) printInfo('The licence server is unreachable right now. Still waiting...');
353
+ lastState = 'unreachable';
354
+ }
355
+ continue;
356
+ }
357
+ throw enrichLicenseError(err);
358
+ }
359
+
360
+ const state = result.state || 'pending';
361
+
362
+ if (state === 'slow_down') {
363
+ interval = Number(result.interval) > 0 ? Number(result.interval) : interval + 5;
364
+ } else if (Number(result.interval) > 0) {
365
+ interval = Number(result.interval);
366
+ }
367
+
368
+ if (state !== lastState) {
369
+ emit({ state, interval, ...(result.error ? { error: result.error } : {}) });
370
+ lastState = state;
371
+ }
372
+
373
+ if (state === 'approved') {
374
+ const lease = result.lease || {};
375
+ emit({ state: 'approved', lease });
376
+ if (!json) {
377
+ formatDetail({
378
+ license_id: lease.license_id || null,
379
+ entitlements: lease.entitlements || [],
380
+ lease_expires_at: lease.expires_at || null,
381
+ }, { format: program.opts().output, title: 'Licence activated' });
382
+ printSuccess('This instance is activated. No key was ever pasted into it.');
383
+ }
384
+ return;
385
+ }
386
+
387
+ if (state === 'denied') {
388
+ throw new QuoxApiError(403, 'access_denied',
389
+ 'The activation was denied on quox.ai. Nothing was activated on this instance.');
390
+ }
391
+ if (state === 'expired') {
392
+ throw new QuoxApiError(410, 'expired_token',
393
+ 'The code expired before it was approved. Run `quox setup license link` again for a fresh one.');
394
+ }
395
+ if (state === 'consumed') {
396
+ throw new QuoxApiError(409, 'consumed',
397
+ 'That code was already used. Run `quox setup license link` again for a fresh one.');
398
+ }
399
+ if (state === 'activation_failed') {
400
+ throw new QuoxApiError(502, result.error || 'activation_failed',
401
+ `The approval landed but the licence could not be stored on this instance: ${result.message || result.error || 'unknown error'}. `
402
+ + 'Run `quox setup license link` again.');
403
+ }
404
+ if (state === 'error') {
405
+ throw new QuoxApiError(502, result.error || 'device_poll_failed',
406
+ result.message || 'Device activation failed.');
407
+ }
408
+ }
409
+
410
+ throw new QuoxApiError(410, 'expired_token',
411
+ 'The code expired before it was approved. Run `quox setup license link` again for a fresh one.');
412
+ });
413
+
414
+ license
415
+ .command('status')
416
+ .description('Show this instance licence state: entitlements, lease expiry, freshness')
417
+ .action(async () => {
418
+ auth.requireAuth();
419
+ // Same read the dashboard licence gate uses
420
+ // (src/services/licenseValidator.js -> GET /api/setup/licensing/entitlements):
421
+ // verified, instance-bound, unexpired leases only.
422
+ const ep = resolveEndpoint('setup.licensing.entitlements.list');
423
+ let data;
424
+ try {
425
+ data = await client.get(ep.path, { service: ep.service });
426
+ } catch (err) {
427
+ throw enrichLicenseError(err);
428
+ }
429
+
430
+ const now = Date.now();
431
+ const licenses = (data.licenses || []).map((row) => {
432
+ const leaseExpiry = Date.parse(row.lease_expires_at);
433
+ let leaseStatus = 'unknown';
434
+ if (Number.isFinite(leaseExpiry)) {
435
+ const hours = Math.round((leaseExpiry - now) / 3600000);
436
+ leaseStatus = hours > 0 ? `fresh, renews within ${hours}h` : `stale, expired ${Math.abs(hours)}h ago`;
437
+ }
438
+ return {
439
+ license_id: row.license_id,
440
+ entitlements: row.entitlements || [],
441
+ expires_at: row.expires_at || null,
442
+ lease_expires_at: row.lease_expires_at || null,
443
+ lease_status: leaseStatus,
444
+ };
445
+ });
446
+
447
+ const summary = {
448
+ activated: licenses.length > 0,
449
+ instance_id: data.instance_id || null,
450
+ entitlements: data.entitlements || [],
451
+ licenses,
452
+ };
453
+
454
+ // LICENSE-LAUNCH LL-4c: the response carries the signed product catalogue
455
+ // (display metadata, present only once the owner installs the artifact
456
+ // generated on quox.ai) and the LL-3b org bands. Both are rendered as
457
+ // ADDITIVE fields: absent server-side means absent here, never a guessed
458
+ // tier or a fabricated band, and every pre-existing field is untouched so
459
+ // scripts parsing --json keep working.
460
+ const catalogTiers = new Map(
461
+ (Array.isArray(data.catalog) ? data.catalog : [])
462
+ .filter((row) => row && typeof row.id === 'string' && row.tier)
463
+ .map((row) => [row.id, row.tier])
464
+ );
465
+ if (catalogTiers.size > 0) {
466
+ summary.entitlement_tiers = summary.entitlements.map((id) => {
467
+ const tier = catalogTiers.get(id);
468
+ return tier ? `${id} (${tier})` : id;
469
+ });
470
+ }
471
+
472
+ const bands = (data.org_bands && typeof data.org_bands === 'object') ? data.org_bands : {};
473
+ const bandIds = Object.keys(bands);
474
+ if (bandIds.length > 0) {
475
+ if (data.org_id) summary.org_id = data.org_id;
476
+ summary.org_bands = bandIds.map((id) => {
477
+ const band = bands[id] || {};
478
+ // org_licensed is tri-state: true/false for a caller with org
479
+ // context, null for an instance-scoped session (see hasPlugin).
480
+ const state = band.org_licensed === true ? 'this org licensed'
481
+ : band.org_licensed === false ? 'this org NOT licensed'
482
+ : 'no org context';
483
+ return `${id}: ${band.assigned_count ?? 0} of ${band.allowance ?? 0} orgs (${state})`;
484
+ });
485
+ }
486
+
487
+ formatDetail(summary, { format: program.opts().output, title: 'Licence Status' });
488
+ });
489
+
490
+ license
491
+ .command('deactivate')
492
+ .description('Release this instance seat so the licence can be activated elsewhere')
493
+ .option('--license-id <id>', 'licence to release (required when several are held)')
494
+ .option('--yes', 'skip the confirmation prompt (for boot scripts and CI)')
495
+ .action(async (options) => {
496
+ auth.requireAuth();
497
+
498
+ let licenseId = options.licenseId;
499
+ if (!licenseId) {
500
+ const readEp = resolveEndpoint('setup.licensing.entitlements.list');
501
+ let state;
502
+ try {
503
+ state = await client.get(readEp.path, { service: readEp.service });
504
+ } catch (err) {
505
+ throw enrichLicenseError(err);
506
+ }
507
+ const held = state.licenses || [];
508
+ if (held.length === 0) {
509
+ throw new QuoxValidationError('No active license on this instance, so there is no seat to release.');
510
+ }
511
+ if (held.length > 1) {
512
+ throw new QuoxValidationError(
513
+ `This instance holds ${held.length} licences (${held.map(l => l.license_id).join(', ')}). `
514
+ + 'Pass --license-id <id> to say which seat to release.'
515
+ );
516
+ }
517
+ licenseId = held[0].license_id;
518
+ }
519
+
520
+ const confirmed = await confirmDestructive(
521
+ program,
522
+ { force: options.yes },
523
+ `Release the seat for ${licenseId} on this instance?`
524
+ );
525
+ if (!confirmed) {
526
+ printInfo('Aborted.');
527
+ return;
528
+ }
529
+
530
+ const ep = resolveEndpoint('setup.licensing.deactivate');
531
+ let data;
532
+ try {
533
+ data = await client.post(ep.path, { service: ep.service, body: { license_id: licenseId } });
534
+ } catch (err) {
535
+ throw enrichLicenseError(err);
536
+ }
537
+ printSuccess(
538
+ data.remote
539
+ ? `Seat released for ${licenseId} (control plane updated).`
540
+ : `Seat released locally for ${licenseId}. The control plane was not reachable, so the seat may still be held remotely.`
541
+ );
542
+ });
543
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * SKILLRIGHTS #478 Phase 10 — local SkillRights manifest construction for
3
+ * `quox skills protect`.
4
+ *
5
+ * The skill's CONTENT never leaves the machine: this builds the same
6
+ * `.skillrights.manifest.json` the open `skillrights` CLI produces (per-file
7
+ * SHA-256s plus a canonical-JSON hash of the core), and only that manifest
8
+ * is sent to the collector.
9
+ *
10
+ * CONSUMES THE OPEN FORMAT, NEVER FORKS IT: if the open CLI has already
11
+ * written a manifest in the directory, that file is used verbatim. The
12
+ * builder below exists only so a user without the open CLI installed can
13
+ * still protect a skill; its algorithm MUST stay byte-identical to
14
+ * skillrights/cli/lib/sign.js + lib/hash.js (canonical JSON = recursively
15
+ * sorted keys, no whitespace). The collector re-derives the hash from the
16
+ * manifest core and rejects a mismatch, so drift fails loudly rather than
17
+ * silently registering a hash nobody else can reproduce.
18
+ */
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { createHash } from 'node:crypto';
23
+
24
+ export const MANIFEST_NAME = '.skillrights.manifest.json';
25
+ export const SIG_NAME = `${MANIFEST_NAME}.sig`;
26
+ const RECEIPT_NAME = '.skillrights.receipt.json';
27
+ const EXCLUDE_DIRS = new Set(['.git', 'node_modules']);
28
+
29
+ function canonicalize(value) {
30
+ if (Array.isArray(value)) return value.map(canonicalize);
31
+ if (value && typeof value === 'object') {
32
+ const sorted = {};
33
+ for (const key of Object.keys(value).sort()) sorted[key] = canonicalize(value[key]);
34
+ return sorted;
35
+ }
36
+ return value;
37
+ }
38
+
39
+ export function canonicalJSON(value) {
40
+ return JSON.stringify(canonicalize(value));
41
+ }
42
+
43
+ export function sha256Hex(input) {
44
+ return createHash('sha256').update(input).digest('hex');
45
+ }
46
+
47
+ function walkFiles(rootDir, exclude) {
48
+ const results = [];
49
+ (function walk(dir) {
50
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
51
+ if (entry.isDirectory() && EXCLUDE_DIRS.has(entry.name)) continue;
52
+ const full = path.join(dir, entry.name);
53
+ const rel = path.relative(rootDir, full).split(path.sep).join('/');
54
+ if (exclude.has(rel)) continue;
55
+ if (entry.isDirectory()) walk(full);
56
+ else if (entry.isFile()) results.push(rel);
57
+ }
58
+ })(rootDir);
59
+ results.sort();
60
+ return results;
61
+ }
62
+
63
+ /**
64
+ * Reads YAML-ish frontmatter from SKILL.md, matching the open CLI's parser
65
+ * scope: a leading `---` block, `key: value` lines, optional quotes.
66
+ */
67
+ export function readSkillFrontmatter(dir) {
68
+ const file = path.join(dir, 'SKILL.md');
69
+ if (!fs.existsSync(file)) return {};
70
+ const text = fs.readFileSync(file, 'utf8');
71
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
72
+ if (!match) return {};
73
+ const fields = {};
74
+ for (const line of match[1].split(/\r?\n/)) {
75
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
76
+ if (!kv) continue;
77
+ let value = kv[2].trim();
78
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
79
+ value = value.slice(1, -1);
80
+ }
81
+ fields[kv[1]] = value;
82
+ }
83
+ return fields;
84
+ }
85
+
86
+ /**
87
+ * Returns { manifest, source } where source is 'existing' (written by the
88
+ * open skillrights CLI) or 'computed' (built here, not written to disk).
89
+ */
90
+ export function loadOrBuildManifest(dir) {
91
+ const manifestPath = path.join(dir, MANIFEST_NAME);
92
+ if (fs.existsSync(manifestPath)) {
93
+ return { manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')), source: 'existing' };
94
+ }
95
+
96
+ const exclude = new Set([MANIFEST_NAME, SIG_NAME, RECEIPT_NAME]);
97
+ const files = walkFiles(dir, exclude);
98
+ if (files.length === 0) throw new Error(`No files to protect in ${dir}`);
99
+
100
+ const fileHashes = {};
101
+ for (const rel of files) {
102
+ fileHashes[rel] = sha256Hex(fs.readFileSync(path.join(dir, rel)));
103
+ }
104
+ const front = readSkillFrontmatter(dir);
105
+ const core = {
106
+ version: 1,
107
+ created: new Date().toISOString(),
108
+ identifier: front.license || null,
109
+ author: front.author || null,
110
+ files: fileHashes,
111
+ };
112
+ return { manifest: { ...core, manifestSha256: sha256Hex(canonicalJSON(core)) }, source: 'computed' };
113
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * SKILLRIGHTS #478 Phase 11 — shared presentation helpers for SkillRights
3
+ * licence identifiers in CLI surfaces. One place, so every verb renders the
4
+ * same labels and accepts the same filter vocabulary.
5
+ */
6
+
7
+ const SHORT_LABELS = {
8
+ 'LicenseRef-SkillRights-Open-1.0': 'Open 1.0',
9
+ 'LicenseRef-SkillRights-NoTrain-1.0': 'NoTrain 1.0',
10
+ 'LicenseRef-SkillRights-Reserved-1.0': 'Reserved 1.0',
11
+ };
12
+
13
+ const FILTER_ALIASES = {
14
+ open: 'LicenseRef-SkillRights-Open-',
15
+ notrain: 'LicenseRef-SkillRights-NoTrain-',
16
+ reserved: 'LicenseRef-SkillRights-Reserved-',
17
+ };
18
+
19
+ /** Human label for a licence identifier; null/undefined -> '(undeclared)';
20
+ * unknown identifiers pass through untouched (never mislabel). */
21
+ export function shortLicenceLabel(id) {
22
+ if (!id) return '(undeclared)';
23
+ return SHORT_LABELS[id] || id;
24
+ }
25
+
26
+ /**
27
+ * Does a licence identifier match a user-supplied filter?
28
+ * Accepted filters (case-insensitive): open | notrain | reserved |
29
+ * undeclared, or any full identifier for an exact match. A null filter
30
+ * matches everything.
31
+ */
32
+ export function matchesLicenceFilter(license, filter) {
33
+ if (!filter) return true;
34
+ const f = String(filter).toLowerCase();
35
+ if (f === 'undeclared' || f === 'none') return !license;
36
+ if (FILTER_ALIASES[f]) return typeof license === 'string' && license.startsWith(FILTER_ALIASES[f]);
37
+ return license === filter;
38
+ }