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.
- package/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- 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
|
+
}
|