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,1424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault commands: credential management, health, export, migration
|
|
3
|
+
*
|
|
4
|
+
* quox vault <subcommand> [options]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import Table from 'cli-table3';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import { formatOutput, formatDetail, printSuccess, printWarning, printInfo } from '../core/output.js';
|
|
11
|
+
import { events, printEvent, line as glyphLine } from '../core/quoxglyph.js';
|
|
12
|
+
import { resolveOrgId } from './helpers.js';
|
|
13
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a credential's declared repository scope (quoxai/quox#481).
|
|
17
|
+
* Tolerant of absent or malformed metadata: a credential that cannot state its
|
|
18
|
+
* scope simply has none, rather than breaking a listing.
|
|
19
|
+
*/
|
|
20
|
+
export function readRepoScope(credential) {
|
|
21
|
+
const raw = credential?.metadata_json ?? credential?.metadata;
|
|
22
|
+
if (!raw) return [];
|
|
23
|
+
let meta;
|
|
24
|
+
try {
|
|
25
|
+
meta = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
26
|
+
} catch (_err) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return Array.isArray(meta?.repos) ? meta.repos.filter((r) => typeof r === 'string' && r.trim()) : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate one repository pattern (quoxai/quox#481).
|
|
34
|
+
*
|
|
35
|
+
* Validation matters more here than it looks: a typo'd pattern matches
|
|
36
|
+
* nothing, so resolution degrades silently to the org default and files the
|
|
37
|
+
* issue with the wrong token. That is the exact bug repo scoping exists to
|
|
38
|
+
* fix, only now with a config file that looks correct. Refuse at the point
|
|
39
|
+
* the human can still see what they typed.
|
|
40
|
+
*/
|
|
41
|
+
export function validateRepoPattern(pattern) {
|
|
42
|
+
if (pattern === '*' || pattern === '*/*') return pattern;
|
|
43
|
+
// owner/name, or owner/* — the wildcard is only ever the whole name segment.
|
|
44
|
+
if (/^[A-Za-z0-9._-]+\/([A-Za-z0-9._-]+|\*)$/.test(pattern)) return pattern;
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Invalid repository pattern "${pattern}". Use owner/name, owner/* for every repo under an owner, `
|
|
47
|
+
+ 'or * for everything.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* fleet #391 — resolve credential field values for a headless
|
|
53
|
+
* `vault create`, from a JSON object supplied via --credentials-file or
|
|
54
|
+
* --credentials-stdin.
|
|
55
|
+
*
|
|
56
|
+
* Secret hygiene is the whole point of this shape:
|
|
57
|
+
* - no `--value` flag, so nothing lands in argv, the process list, or
|
|
58
|
+
* shell history;
|
|
59
|
+
* - a file can be 0600 and shredded afterwards;
|
|
60
|
+
* - stdin never touches disk;
|
|
61
|
+
* - an "env:VAR_NAME" value is dereferenced here at runtime, so the
|
|
62
|
+
* secret itself need not be written down anywhere.
|
|
63
|
+
*
|
|
64
|
+
* VAULT-INTEG P6: also the pre-save `vault test-new` path, and any future
|
|
65
|
+
* headless credential-field entry point, share this exact routine so there
|
|
66
|
+
* is one place that decides how a secret gets off disk/stdin/env and never
|
|
67
|
+
* onto argv.
|
|
68
|
+
*
|
|
69
|
+
* @returns {Promise<Record<string,string>>} field -> value ({} if none supplied)
|
|
70
|
+
*/
|
|
71
|
+
export async function loadSuppliedCredentials(options) {
|
|
72
|
+
let raw = null;
|
|
73
|
+
|
|
74
|
+
if (options.credentialsStdin) {
|
|
75
|
+
raw = await new Promise((resolve, reject) => {
|
|
76
|
+
let buf = '';
|
|
77
|
+
process.stdin.setEncoding('utf8');
|
|
78
|
+
process.stdin.on('data', c => { buf += c; });
|
|
79
|
+
process.stdin.on('end', () => resolve(buf));
|
|
80
|
+
process.stdin.on('error', reject);
|
|
81
|
+
});
|
|
82
|
+
} else if (options.credentialsFile) {
|
|
83
|
+
const { readFile } = await import('node:fs/promises');
|
|
84
|
+
raw = await readFile(options.credentialsFile, 'utf8');
|
|
85
|
+
} else {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const source = options.credentialsStdin ? 'stdin' : options.credentialsFile;
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new Error(`Could not parse credentials JSON from ${source}: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
97
|
+
throw new Error(`Credentials from ${source} must be a JSON object of {field: value}.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
102
|
+
if (typeof v !== 'string') { out[k] = v; continue; }
|
|
103
|
+
if (v.startsWith('env:')) {
|
|
104
|
+
const varName = v.slice(4);
|
|
105
|
+
const fromEnv = process.env[varName];
|
|
106
|
+
if (fromEnv === undefined) {
|
|
107
|
+
throw new Error(`Credential field "${k}" references env:${varName}, which is not set.`);
|
|
108
|
+
}
|
|
109
|
+
out[k] = fromEnv;
|
|
110
|
+
} else {
|
|
111
|
+
out[k] = v;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Shared action factories ───────────────────────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// VAULT-INTEG P6: `quox integration ...` is a deprecated alias of `quox
|
|
120
|
+
// vault ...` (see lib/commands/integration.js). Rather than two copies of
|
|
121
|
+
// the same request/response handling, the six overlapping verbs (list, get,
|
|
122
|
+
// create, update, delete, test) live here as factories that both
|
|
123
|
+
// registerVaultCommands() below AND registerIntegrationCommands() wire up.
|
|
124
|
+
// One implementation, two command trees.
|
|
125
|
+
|
|
126
|
+
export function makeVaultListAction({ client, auth }, program) {
|
|
127
|
+
return async (options) => {
|
|
128
|
+
auth.requireAuth();
|
|
129
|
+
const orgId = resolveOrgId(auth, program);
|
|
130
|
+
const query = {};
|
|
131
|
+
if (options.type) query.type = options.type;
|
|
132
|
+
|
|
133
|
+
const data = await client.get(resolveEndpoint('vault.list', { orgId }).path, { service: resolveEndpoint('vault.list', { orgId }).service, query });
|
|
134
|
+
let rows = data.credentials || data;
|
|
135
|
+
|
|
136
|
+
// Client-side status filter (API only supports type filter)
|
|
137
|
+
if (options.status) {
|
|
138
|
+
rows = rows.filter(r => r.test_status === options.status);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// #481: repository scope decides WHICH credential a tool call gets, so
|
|
142
|
+
// leaving it out of the listing hides the thing most likely to explain a
|
|
143
|
+
// "wrong token" surprise. Shown only when at least one credential
|
|
144
|
+
// declares a scope, so orgs that never use it keep the narrower table.
|
|
145
|
+
const withRepos = rows.map((r) => ({ ...r, repos: readRepoScope(r).join(', ') || '' }));
|
|
146
|
+
const anyScoped = withRepos.some((r) => r.repos);
|
|
147
|
+
|
|
148
|
+
formatOutput({
|
|
149
|
+
columns: anyScoped
|
|
150
|
+
? ['id', 'integration_type', 'label', 'repos', 'test_status', 'is_default', 'use_count', 'last_used_at']
|
|
151
|
+
: ['id', 'integration_type', 'label', 'test_status', 'is_default', 'use_count', 'last_used_at'],
|
|
152
|
+
headers: anyScoped
|
|
153
|
+
? ['ID', 'Type', 'Label', 'Repos', 'Status', 'Default', 'Uses', 'Last Used']
|
|
154
|
+
: ['ID', 'Type', 'Label', 'Status', 'Default', 'Uses', 'Last Used'],
|
|
155
|
+
rows: withRepos,
|
|
156
|
+
}, { format: program.opts().output, emptyMessage: 'No credentials found.' });
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function makeVaultGetAction({ client, auth }, program) {
|
|
161
|
+
return async (id) => {
|
|
162
|
+
auth.requireAuth();
|
|
163
|
+
const orgId = resolveOrgId(auth, program);
|
|
164
|
+
const data = await client.get(resolveEndpoint('vault.get', { orgId, id }).path, { service: resolveEndpoint('vault.get', { orgId, id }).service });
|
|
165
|
+
const cred = data.integration || data;
|
|
166
|
+
formatDetail(cred, {
|
|
167
|
+
format: program.opts().output,
|
|
168
|
+
title: `Credential: ${cred.label || id}`,
|
|
169
|
+
fields: ['id', 'integration_type', 'label', 'test_status', 'is_default', 'use_count', 'last_used_at', 'created_at', 'endpoint_url', 'description'],
|
|
170
|
+
labels: {
|
|
171
|
+
id: 'ID', integration_type: 'Type', label: 'Label', test_status: 'Status',
|
|
172
|
+
is_default: 'Default', use_count: 'Uses', last_used_at: 'Last Used',
|
|
173
|
+
created_at: 'Created', endpoint_url: 'Endpoint', description: 'Description',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function makeVaultCreateAction({ client, auth }, program) {
|
|
180
|
+
return async (options) => {
|
|
181
|
+
auth.requireAuth();
|
|
182
|
+
const orgId = resolveOrgId(auth, program);
|
|
183
|
+
|
|
184
|
+
// Fetch available types
|
|
185
|
+
const typesData = await client.get(resolveEndpoint('vault.types.list', { orgId }).path, { service: resolveEndpoint('vault.types.list', { orgId }).service });
|
|
186
|
+
const types = typesData.types || typesData;
|
|
187
|
+
|
|
188
|
+
let integrationType = options.type;
|
|
189
|
+
let label = options.label;
|
|
190
|
+
|
|
191
|
+
// fleet #391: load any headless field values BEFORE prompting, so a
|
|
192
|
+
// fully-specified invocation never blocks on a TTY.
|
|
193
|
+
const supplied = await loadSuppliedCredentials(options);
|
|
194
|
+
const noInput = options.input === false || options.credentialsStdin;
|
|
195
|
+
|
|
196
|
+
if ((!integrationType || !label) && noInput) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Missing required option${!integrationType ? ' --type' : ''}${!label ? ' --label' : ''} `
|
|
199
|
+
+ '(running non-interactively, so it cannot be prompted for).'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Interactive prompts if needed
|
|
204
|
+
if (!integrationType || !label) {
|
|
205
|
+
const { default: inquirer } = await import('inquirer');
|
|
206
|
+
|
|
207
|
+
if (!integrationType) {
|
|
208
|
+
const choices = types.map(t => ({ name: `${t.name} — ${t.description}`, value: t.id }));
|
|
209
|
+
const ans = await inquirer.prompt([{
|
|
210
|
+
type: 'list',
|
|
211
|
+
name: 'type',
|
|
212
|
+
message: 'Integration type:',
|
|
213
|
+
choices,
|
|
214
|
+
}]);
|
|
215
|
+
integrationType = ans.type;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!label) {
|
|
219
|
+
const ans = await inquirer.prompt([{
|
|
220
|
+
type: 'input',
|
|
221
|
+
name: 'label',
|
|
222
|
+
message: 'Label:',
|
|
223
|
+
validate: v => v.trim().length >= 2 || 'Label must be at least 2 characters',
|
|
224
|
+
}]);
|
|
225
|
+
label = ans.label;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Get type definition for fields
|
|
230
|
+
const typeDef = types.find(t => t.id === integrationType);
|
|
231
|
+
if (!typeDef) throw new Error(`Unknown integration type: ${integrationType}`);
|
|
232
|
+
|
|
233
|
+
// Collect credential field values. fleet #391: anything already
|
|
234
|
+
// supplied via --credentials-file/--credentials-stdin is used as-is;
|
|
235
|
+
// only genuinely missing fields are prompted for, and under --no-input
|
|
236
|
+
// a missing required field is a clear error rather than a hang.
|
|
237
|
+
const credentials = {};
|
|
238
|
+
const missing = [];
|
|
239
|
+
// quox#564: `inquirer` used to be a `let ... = null` populated only
|
|
240
|
+
// inside this loop's missing-field branch. Any code path below that
|
|
241
|
+
// used `inquirer.prompt(...)` without that branch having run first
|
|
242
|
+
// (headless --no-input skipping this branch entirely, or an
|
|
243
|
+
// interactive run where --credentials-file already supplied every
|
|
244
|
+
// field so the branch never fired) crashed with "Cannot read
|
|
245
|
+
// properties of null (reading 'prompt')" instead of a clear error.
|
|
246
|
+
// A lazy per-use loader means inquirer is never accessed before it
|
|
247
|
+
// is loaded, on any path.
|
|
248
|
+
let inquirer = null;
|
|
249
|
+
async function getInquirer() {
|
|
250
|
+
if (!inquirer) ({ default: inquirer } = await import('inquirer'));
|
|
251
|
+
return inquirer;
|
|
252
|
+
}
|
|
253
|
+
for (const field of typeDef.fields) {
|
|
254
|
+
if (Object.prototype.hasOwnProperty.call(supplied, field.key)) {
|
|
255
|
+
credentials[field.key] = supplied[field.key];
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!(field.required || !field.default)) continue;
|
|
259
|
+
|
|
260
|
+
if (noInput) {
|
|
261
|
+
if (field.required) missing.push(field.key);
|
|
262
|
+
else if (field.default !== undefined) credentials[field.key] = field.default;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const inq = await getInquirer();
|
|
267
|
+
const ans = await inq.prompt([{
|
|
268
|
+
type: field.type === 'password' ? 'password' : 'input',
|
|
269
|
+
name: field.key,
|
|
270
|
+
message: `${field.label}:`,
|
|
271
|
+
default: field.default,
|
|
272
|
+
validate: v => field.required ? (v.trim().length > 0 || `${field.label} is required`) : true,
|
|
273
|
+
}]);
|
|
274
|
+
credentials[field.key] = ans[field.key];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// quox#577: `supplied` can carry keys the type definition never declared
|
|
278
|
+
// (e.g. an "smtp" type's fields miss imap_host/imap_port on an email
|
|
279
|
+
// credential that needs both). The loop above only ever copies declared
|
|
280
|
+
// fields across, so anything extra was silently dropped even though
|
|
281
|
+
// create still reported success. Union the leftovers in rather than
|
|
282
|
+
// discard them, and say so — a silent drop here is the exact bug.
|
|
283
|
+
const extraKeys = Object.keys(supplied).filter(
|
|
284
|
+
(k) => !Object.prototype.hasOwnProperty.call(credentials, k)
|
|
285
|
+
);
|
|
286
|
+
if (extraKeys.length > 0) {
|
|
287
|
+
for (const k of extraKeys) credentials[k] = supplied[k];
|
|
288
|
+
printInfo(
|
|
289
|
+
`note: including ${extraKeys.length} field(s) not declared by type "${integrationType}": `
|
|
290
|
+
+ extraKeys.join(', ')
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (missing.length > 0) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Missing required field(s) for type "${integrationType}": ${missing.join(', ')}. `
|
|
297
|
+
+ 'Provide them via --credentials-file <path> or --credentials-stdin as a JSON object, '
|
|
298
|
+
+ `e.g. {"${missing[0]}": "..."} (or "env:MY_VAR" to read from the environment).`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Ask about default
|
|
303
|
+
let isDefault = options.setDefault || false;
|
|
304
|
+
// fleet #391: `--yes` (and any non-interactive invocation) must not
|
|
305
|
+
// stop here either — the field prompts above used to run first, so
|
|
306
|
+
// this gate was never reached in a headless run anyway.
|
|
307
|
+
if (!options.setDefault && !program.opts().yes && !noInput) {
|
|
308
|
+
const inq = await getInquirer();
|
|
309
|
+
const ans = await inq.prompt([{
|
|
310
|
+
type: 'confirm',
|
|
311
|
+
name: 'isDefault',
|
|
312
|
+
message: 'Set as default for this type?',
|
|
313
|
+
default: false,
|
|
314
|
+
}]);
|
|
315
|
+
isDefault = ans.isDefault;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Optional non-secret metadata tags (e.g. {"purpose":"bootstrap"}).
|
|
319
|
+
let metadata;
|
|
320
|
+
if (options.metadata) {
|
|
321
|
+
try {
|
|
322
|
+
metadata = JSON.parse(options.metadata);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
throw new Error(`--metadata must be a JSON object: ${err.message}`);
|
|
325
|
+
}
|
|
326
|
+
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
327
|
+
throw new Error('--metadata must be a JSON object, e.g. \'{"purpose":"bootstrap"}\'');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// #481: --repos folds into the same metadata object, so the two flags
|
|
332
|
+
// compose rather than one silently winning.
|
|
333
|
+
if (options.repos) {
|
|
334
|
+
const patterns = options.repos.split(',').map((p) => p.trim()).filter(Boolean);
|
|
335
|
+
if (patterns.length === 0) throw new Error('--repos needs at least one pattern.');
|
|
336
|
+
for (const p of patterns) validateRepoPattern(p);
|
|
337
|
+
metadata = { ...(metadata || {}), repos: patterns };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create
|
|
341
|
+
const spinner = ora('Creating credential...').start();
|
|
342
|
+
const result = await client.post(resolveEndpoint('vault.create', { orgId }).path, {
|
|
343
|
+
service: resolveEndpoint('vault.create', { orgId }).service,
|
|
344
|
+
body: {
|
|
345
|
+
integration_type: integrationType,
|
|
346
|
+
label,
|
|
347
|
+
credentials,
|
|
348
|
+
is_default: isDefault,
|
|
349
|
+
...(metadata ? { metadata } : {}),
|
|
350
|
+
...(options.kind ? { credential_kind: options.kind } : {}),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
spinner.succeed('Credential created.');
|
|
354
|
+
|
|
355
|
+
// Optional test. quox#564: this confirm was missing the `!noInput`
|
|
356
|
+
// guard its sibling above (the "set as default?" confirm) already had,
|
|
357
|
+
// so a headless `--no-input` run (or `--yes`-less) fell through to
|
|
358
|
+
// `inquirer.prompt(...)` on whatever `inquirer` happened to be at that
|
|
359
|
+
// point -- null whenever no field prompt had run first. Headless has no
|
|
360
|
+
// TTY to prompt on, so it never runs the test unless asked explicitly
|
|
361
|
+
// via `--yes`.
|
|
362
|
+
if (options.test !== false) {
|
|
363
|
+
let runTest = program.opts().yes || false;
|
|
364
|
+
if (!runTest && !noInput) {
|
|
365
|
+
const inq = await getInquirer();
|
|
366
|
+
const ans = await inq.prompt([{
|
|
367
|
+
type: 'confirm',
|
|
368
|
+
name: 'test',
|
|
369
|
+
message: 'Test connection now?',
|
|
370
|
+
default: true,
|
|
371
|
+
}]);
|
|
372
|
+
runTest = ans.test;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (runTest) {
|
|
376
|
+
const testSpinner = ora('Testing connection...').start();
|
|
377
|
+
try {
|
|
378
|
+
const testResult = await client.post(resolveEndpoint('vault.test', { orgId, id: result.id }).path, { service: resolveEndpoint('vault.test', { orgId, id: result.id }).service });
|
|
379
|
+
if (testResult.success) {
|
|
380
|
+
testSpinner.succeed('Connection test passed.');
|
|
381
|
+
} else {
|
|
382
|
+
testSpinner.fail(`Connection test failed: ${testResult.error || testResult.message || 'unknown error'}`);
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
testSpinner.fail(`Connection test failed: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// fleet #397: creating the FIRST credential of a type gave no signal
|
|
391
|
+
// that it silently isn't used until it's also made default (a caller
|
|
392
|
+
// resolving by type only ever sees the default). Print-only, never
|
|
393
|
+
// auto-set-default — that would swap which credential live traffic
|
|
394
|
+
// uses as a side effect of a create. printInfo already no-ops for
|
|
395
|
+
// -o json / -q, so this can't pollute headless output.
|
|
396
|
+
if (!result.is_default) {
|
|
397
|
+
printInfo(`note: not set as default — run "quox vault set-default ${result.id}" to make it the active ${result.integration_type || integrationType} credential`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
formatDetail(result, { format: program.opts().output });
|
|
401
|
+
return result;
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function makeVaultUpdateAction({ client, auth }, program) {
|
|
406
|
+
return async (id, options) => {
|
|
407
|
+
auth.requireAuth();
|
|
408
|
+
const orgId = resolveOrgId(auth, program);
|
|
409
|
+
const body = {};
|
|
410
|
+
if (options.label) body.label = options.label;
|
|
411
|
+
if (options.description) body.description = options.description;
|
|
412
|
+
if (options.kind) body.credential_kind = options.kind;
|
|
413
|
+
|
|
414
|
+
// quox#580: `vault update` could only ever touch --label/--description —
|
|
415
|
+
// there was no way to change a credential's field VALUES (e.g. rotate an
|
|
416
|
+
// email credential's imap_host) short of `vault rotate`'s interactive
|
|
417
|
+
// prompt flow, and no way to send credential_kind at all. Reuses the
|
|
418
|
+
// same headless load path as `vault create` (loadSuppliedCredentials),
|
|
419
|
+
// so the same secret-hygiene rules apply: no --value flag, file/stdin/
|
|
420
|
+
// env:VAR only.
|
|
421
|
+
const supplied = await loadSuppliedCredentials(options);
|
|
422
|
+
if (Object.keys(supplied).length > 0) body.credentials = supplied;
|
|
423
|
+
|
|
424
|
+
if (Object.keys(body).length === 0) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
'Specify at least one field to update (--label, --description, --kind, '
|
|
427
|
+
+ '--credentials-file, or --credentials-stdin).'
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
await client.put(resolveEndpoint('vault.update', { orgId, id }).path, { service: resolveEndpoint('vault.update', { orgId, id }).service, body });
|
|
432
|
+
printEvent(events.updated('quox', 'vault credential', id));
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function makeVaultDeleteAction({ client, auth }, program) {
|
|
437
|
+
return async (id, options) => {
|
|
438
|
+
auth.requireAuth();
|
|
439
|
+
const orgId = resolveOrgId(auth, program);
|
|
440
|
+
|
|
441
|
+
if (!options.force && !program.opts().yes) {
|
|
442
|
+
const { default: inquirer } = await import('inquirer');
|
|
443
|
+
const ans = await inquirer.prompt([{
|
|
444
|
+
type: 'confirm',
|
|
445
|
+
name: 'confirm',
|
|
446
|
+
message: `Delete credential ${id}? This cannot be undone.`,
|
|
447
|
+
default: false,
|
|
448
|
+
}]);
|
|
449
|
+
if (!ans.confirm) {
|
|
450
|
+
printInfo('Cancelled.');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
await client.delete(resolveEndpoint('vault.delete', { orgId, id }).path, { service: resolveEndpoint('vault.delete', { orgId, id }).service });
|
|
456
|
+
printEvent(glyphLine('quox', 'vault credential deleted', { result: id, status: 'ok' }));
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function makeVaultTestAction({ client, auth }, program) {
|
|
461
|
+
return async (id) => {
|
|
462
|
+
auth.requireAuth();
|
|
463
|
+
const orgId = resolveOrgId(auth, program);
|
|
464
|
+
|
|
465
|
+
const spinner = ora('Testing connection...').start();
|
|
466
|
+
const start = Date.now();
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
const result = await client.post(resolveEndpoint('vault.test', { orgId, id }).path, { service: resolveEndpoint('vault.test', { orgId, id }).service });
|
|
470
|
+
const elapsed = Date.now() - start;
|
|
471
|
+
|
|
472
|
+
if (result.success) {
|
|
473
|
+
spinner.succeed(`Connection test passed (${elapsed}ms)`);
|
|
474
|
+
} else {
|
|
475
|
+
spinner.fail(`Connection test failed: ${result.error || result.message || 'unknown error'}`);
|
|
476
|
+
}
|
|
477
|
+
} catch (err) {
|
|
478
|
+
const elapsed = Date.now() - start;
|
|
479
|
+
spinner.fail(`Connection test failed (${elapsed}ms): ${err.message}`);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function registerVaultCommands(program, ctx) {
|
|
485
|
+
const { client, auth } = ctx;
|
|
486
|
+
|
|
487
|
+
const vault = program
|
|
488
|
+
.command('vault')
|
|
489
|
+
.description('Manage QuoxVault credentials');
|
|
490
|
+
|
|
491
|
+
// ── vault list ──────────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
vault
|
|
494
|
+
.command('list')
|
|
495
|
+
.description('List credentials (masked)')
|
|
496
|
+
.option('--type <type>', 'filter by integration type')
|
|
497
|
+
.option('--status <status>', 'filter by test status (success|failed|untested)')
|
|
498
|
+
.action(makeVaultListAction(ctx, program));
|
|
499
|
+
|
|
500
|
+
// ── vault get ───────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
vault
|
|
503
|
+
.command('get')
|
|
504
|
+
.description('Show credential details')
|
|
505
|
+
.argument('<id>', 'credential ID')
|
|
506
|
+
.action(makeVaultGetAction(ctx, program));
|
|
507
|
+
|
|
508
|
+
// ── vault create ────────────────────────────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
vault
|
|
511
|
+
.command('create')
|
|
512
|
+
.description('Create a credential (interactive, or headless via --credentials-file/--credentials-stdin)')
|
|
513
|
+
.option('--type <type>', 'integration type (skip prompt)')
|
|
514
|
+
.option('--label <label>', 'display label (skip prompt)')
|
|
515
|
+
.option('--set-default', 'set as default for this type')
|
|
516
|
+
.option('--no-test', 'skip connection test after creation')
|
|
517
|
+
// fleet #391: there was no non-interactive path for the field VALUES —
|
|
518
|
+
// including the secret — so a headless operator (CI, an agent, this
|
|
519
|
+
// very box) could not add so much as an LLM key. Deliberately NOT
|
|
520
|
+
// adding `--value <secret>`: that leaks the secret into argv, the
|
|
521
|
+
// process list and shell history. File and stdin both keep it out of
|
|
522
|
+
// all three. `env:VAR` is resolved at runtime so a value can also come
|
|
523
|
+
// from the environment without ever being written down.
|
|
524
|
+
.option('--credentials-file <path>', 'JSON object of {field: value} for the credential fields (use 0600; supports "env:VAR_NAME" values)')
|
|
525
|
+
.option('--credentials-stdin', 'read that same JSON object from stdin (never touches argv or disk)')
|
|
526
|
+
// BASTION-CRED: free-form metadata tags on the credential, e.g. a bootstrap
|
|
527
|
+
// SSH credential is tagged {"purpose":"bootstrap"} so the collector's
|
|
528
|
+
// auto-revoke path (DELETE /internal/integrations/:id) will accept it.
|
|
529
|
+
// Metadata is NOT secret and never contains field values.
|
|
530
|
+
.option('--metadata <json>', 'JSON object of non-secret metadata tags (e.g. \'{"purpose":"bootstrap"}\')')
|
|
531
|
+
// #481: sugar for the one metadata key that changes which credential gets
|
|
532
|
+
// used. Spelling it out as a flag is the difference between a scope people
|
|
533
|
+
// set and one they never discover inside a --metadata JSON blob.
|
|
534
|
+
.option('--repos <patterns>', 'repositories this credential covers: comma-separated owner/name, owner/*, or *')
|
|
535
|
+
.option('--kind <kind>', 'credential_kind to record on the credential, if the type distinguishes one')
|
|
536
|
+
.option('--no-input', 'never prompt; fail with a clear error if a required field is missing')
|
|
537
|
+
.action(makeVaultCreateAction(ctx, program));
|
|
538
|
+
|
|
539
|
+
// ── vault update ────────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
vault
|
|
542
|
+
.command('update')
|
|
543
|
+
.description('Update a credential (interactive label/description fields, or headless credential field values via --credentials-file/--credentials-stdin)')
|
|
544
|
+
.argument('<id>', 'credential ID')
|
|
545
|
+
.option('--label <label>', 'new label')
|
|
546
|
+
.option('--description <desc>', 'new description')
|
|
547
|
+
.option('--kind <kind>', 'credential_kind to record on the credential, if the type distinguishes one')
|
|
548
|
+
// quox#580: same secret-hygiene shape as `vault create` — no --value
|
|
549
|
+
// flag, so a field value never lands in argv, the process list, or
|
|
550
|
+
// shell history.
|
|
551
|
+
.option('--credentials-file <path>', 'JSON object of {field: value} for the credential fields to change (use 0600; supports "env:VAR_NAME" values)')
|
|
552
|
+
.option('--credentials-stdin', 'read that same JSON object from stdin (never touches argv or disk)')
|
|
553
|
+
.action(makeVaultUpdateAction(ctx, program));
|
|
554
|
+
|
|
555
|
+
// ── vault repos ─────────────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
vault
|
|
558
|
+
.command('repos')
|
|
559
|
+
.description('Show or set which repositories a credential covers (GitHub and similar)')
|
|
560
|
+
.argument('<id>', 'credential ID')
|
|
561
|
+
.option('--set <patterns>', 'comma-separated patterns: owner/name, owner/*, or * for everything')
|
|
562
|
+
.option('--clear', 'remove the repository scope, so only the org default reaches this credential')
|
|
563
|
+
.action(async (id, options) => {
|
|
564
|
+
auth.requireAuth();
|
|
565
|
+
const orgId = resolveOrgId(auth, program);
|
|
566
|
+
|
|
567
|
+
if (options.set && options.clear) {
|
|
568
|
+
throw new Error('Pass --set or --clear, not both.');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const getEp = resolveEndpoint('vault.get', { orgId, id });
|
|
572
|
+
const data = await client.get(getEp.path, { service: getEp.service });
|
|
573
|
+
const cred = data.integration || data.credential || data;
|
|
574
|
+
|
|
575
|
+
// Metadata is MERGED, never replaced. A credential can already carry
|
|
576
|
+
// {"purpose":"bootstrap"}, which the collector's auto-revoke path reads,
|
|
577
|
+
// and clobbering it would silently disarm that.
|
|
578
|
+
let metadata = {};
|
|
579
|
+
const raw = cred.metadata_json ?? cred.metadata;
|
|
580
|
+
if (raw) {
|
|
581
|
+
try { metadata = typeof raw === 'string' ? JSON.parse(raw) : { ...raw }; } catch (_err) { metadata = {}; }
|
|
582
|
+
}
|
|
583
|
+
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) metadata = {};
|
|
584
|
+
|
|
585
|
+
if (!options.set && !options.clear) {
|
|
586
|
+
const current = Array.isArray(metadata.repos) ? metadata.repos : [];
|
|
587
|
+
if (current.length === 0) {
|
|
588
|
+
// Say what the absence MEANS. "No scope" reads as "fine"; what it
|
|
589
|
+
// actually means is that only the org default ever reaches this
|
|
590
|
+
// credential, which is the confusion behind quoxai/quox#481.
|
|
591
|
+
printInfo(`${cred.label || id} declares no repositories, so it is only reachable as the org default.`);
|
|
592
|
+
} else {
|
|
593
|
+
console.log(`${cred.label || id} covers:`);
|
|
594
|
+
for (const p of current) console.log(` ${p}`);
|
|
595
|
+
}
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (options.clear) {
|
|
600
|
+
delete metadata.repos;
|
|
601
|
+
} else {
|
|
602
|
+
const patterns = options.set.split(',').map((p) => p.trim()).filter(Boolean);
|
|
603
|
+
if (patterns.length === 0) throw new Error('--set needs at least one pattern.');
|
|
604
|
+
for (const p of patterns) validateRepoPattern(p);
|
|
605
|
+
metadata.repos = patterns;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const ep = resolveEndpoint('vault.update', { orgId, id });
|
|
609
|
+
await client.put(ep.path, { service: ep.service, body: { metadata } });
|
|
610
|
+
|
|
611
|
+
if (options.clear) printEvent(events.updated('quox', 'vault credential repos', id, 'cleared'));
|
|
612
|
+
else printEvent(events.updated('quox', 'vault credential repos', id, metadata.repos.join(', ')));
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// ── vault delete ────────────────────────────────────────────────────────────
|
|
616
|
+
|
|
617
|
+
vault
|
|
618
|
+
.command('delete')
|
|
619
|
+
.description('Delete a credential')
|
|
620
|
+
.argument('<id>', 'credential ID')
|
|
621
|
+
.option('--force', 'skip confirmation')
|
|
622
|
+
.action(makeVaultDeleteAction(ctx, program));
|
|
623
|
+
|
|
624
|
+
// ── vault set-default ───────────────────────────────────────────────────────
|
|
625
|
+
|
|
626
|
+
vault
|
|
627
|
+
.command('set-default')
|
|
628
|
+
.description('Set a credential as the default for its type')
|
|
629
|
+
.argument('<id>', 'credential ID')
|
|
630
|
+
.action(async (id) => {
|
|
631
|
+
auth.requireAuth();
|
|
632
|
+
const orgId = resolveOrgId(auth, program);
|
|
633
|
+
const result = await client.post(resolveEndpoint('vault.default', { orgId, id }).path, { service: resolveEndpoint('vault.default', { orgId, id }).service });
|
|
634
|
+
printEvent(glyphLine('quox', 'vault credential default set', { result: `${id} · ${result.integration_type || 'its type'}`, status: 'ok' }));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ── vault test ──────────────────────────────────────────────────────────────
|
|
638
|
+
|
|
639
|
+
vault
|
|
640
|
+
.command('test')
|
|
641
|
+
.description('Test a credential connection')
|
|
642
|
+
.argument('<id>', 'credential ID')
|
|
643
|
+
.action(makeVaultTestAction(ctx, program));
|
|
644
|
+
|
|
645
|
+
// ── vault test-new ──────────────────────────────────────────────────────────
|
|
646
|
+
//
|
|
647
|
+
// VAULT-INTEG P6: pre-save test — verify a set of credential field values
|
|
648
|
+
// BEFORE creating anything, mirroring the auth service's
|
|
649
|
+
// POST /integrations/test-credentials. Nothing is persisted either way.
|
|
650
|
+
// Same secret-hygiene shape as `vault create`: no --value flag, field
|
|
651
|
+
// values only ever arrive via --credentials-file/--credentials-stdin.
|
|
652
|
+
|
|
653
|
+
vault
|
|
654
|
+
.command('test-new')
|
|
655
|
+
.description('Test credential field values before saving (nothing is persisted)')
|
|
656
|
+
.requiredOption('--type <type>', 'integration type to test')
|
|
657
|
+
.option('--endpoint-url <url>', 'endpoint URL to test against (custom/self-hosted types)')
|
|
658
|
+
.option('--credentials-file <path>', 'JSON object of {field: value} for the credential fields (use 0600; supports "env:VAR_NAME" values)')
|
|
659
|
+
.option('--credentials-stdin', 'read that same JSON object from stdin (never touches argv or disk)')
|
|
660
|
+
.action(async (options) => {
|
|
661
|
+
auth.requireAuth();
|
|
662
|
+
const orgId = resolveOrgId(auth, program);
|
|
663
|
+
|
|
664
|
+
const credentials = await loadSuppliedCredentials(options);
|
|
665
|
+
if (Object.keys(credentials).length === 0) {
|
|
666
|
+
throw new Error(
|
|
667
|
+
'Provide the credential field values to test via --credentials-file <path> or '
|
|
668
|
+
+ '--credentials-stdin (a JSON object of {field: value}).'
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const spinner = ora('Testing credentials (nothing will be saved)...').start();
|
|
673
|
+
try {
|
|
674
|
+
const ep = resolveEndpoint('vault.test-credentials', { orgId });
|
|
675
|
+
const result = await client.post(ep.path, {
|
|
676
|
+
service: ep.service,
|
|
677
|
+
body: {
|
|
678
|
+
integration_type: options.type,
|
|
679
|
+
credentials,
|
|
680
|
+
...(options.endpointUrl ? { endpoint_url: options.endpointUrl } : {}),
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
if (result.success) {
|
|
684
|
+
spinner.succeed('Connection test passed. Nothing was saved.');
|
|
685
|
+
} else {
|
|
686
|
+
spinner.fail(`Connection test failed: ${result.error || result.message || 'unknown error'}`);
|
|
687
|
+
}
|
|
688
|
+
} catch (err) {
|
|
689
|
+
spinner.fail(`Connection test failed: ${err.message}`);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ── vault test-all ──────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
vault
|
|
696
|
+
.command('test-all')
|
|
697
|
+
.description('Test all credentials sequentially')
|
|
698
|
+
.action(async () => {
|
|
699
|
+
auth.requireAuth();
|
|
700
|
+
const orgId = resolveOrgId(auth, program);
|
|
701
|
+
|
|
702
|
+
const data = await client.get(resolveEndpoint('vault.list', { orgId }).path, { service: resolveEndpoint('vault.list', { orgId }).service });
|
|
703
|
+
const creds = data.credentials || data;
|
|
704
|
+
|
|
705
|
+
if (!creds.length) {
|
|
706
|
+
printInfo('No credentials to test.');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const results = [];
|
|
711
|
+
for (const cred of creds) {
|
|
712
|
+
const spinner = ora(`Testing ${cred.label} (${cred.integration_type})...`).start();
|
|
713
|
+
const start = Date.now();
|
|
714
|
+
try {
|
|
715
|
+
const result = await client.post(resolveEndpoint('vault.test', { orgId, id: cred.id }).path, { service: resolveEndpoint('vault.test', { orgId, id: cred.id }).service });
|
|
716
|
+
const elapsed = Date.now() - start;
|
|
717
|
+
if (result.success) {
|
|
718
|
+
spinner.succeed(`${cred.label}: passed (${elapsed}ms)`);
|
|
719
|
+
results.push({ id: cred.id, label: cred.label, type: cred.integration_type, result: 'pass', time: `${elapsed}ms` });
|
|
720
|
+
} else {
|
|
721
|
+
spinner.fail(`${cred.label}: failed — ${result.error || result.message || 'unknown'}`);
|
|
722
|
+
results.push({ id: cred.id, label: cred.label, type: cred.integration_type, result: 'fail', time: `${elapsed}ms` });
|
|
723
|
+
}
|
|
724
|
+
} catch (err) {
|
|
725
|
+
const elapsed = Date.now() - start;
|
|
726
|
+
spinner.fail(`${cred.label}: error — ${err.message}`);
|
|
727
|
+
results.push({ id: cred.id, label: cred.label, type: cred.integration_type, result: 'error', time: `${elapsed}ms` });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const passed = results.filter(r => r.result === 'pass').length;
|
|
732
|
+
const failed = results.length - passed;
|
|
733
|
+
console.log('');
|
|
734
|
+
printInfo(`Results: ${passed} passed, ${failed} failed out of ${results.length} credentials`);
|
|
735
|
+
|
|
736
|
+
if (program.opts().output === 'json') {
|
|
737
|
+
console.log(JSON.stringify(results, null, 2));
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ── vault stats ─────────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
vault
|
|
744
|
+
.command('stats')
|
|
745
|
+
.description('Show vault health dashboard')
|
|
746
|
+
.action(async () => {
|
|
747
|
+
auth.requireAuth();
|
|
748
|
+
const orgId = resolveOrgId(auth, program);
|
|
749
|
+
const stats = await client.get(resolveEndpoint('vault.vault-stats.list', { orgId }).path, { service: resolveEndpoint('vault.vault-stats.list', { orgId }).service });
|
|
750
|
+
|
|
751
|
+
const format = program.opts().output;
|
|
752
|
+
if (format === 'json') {
|
|
753
|
+
console.log(JSON.stringify(stats, null, 2));
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Render health dashboard
|
|
758
|
+
const table = new Table({
|
|
759
|
+
chars: {
|
|
760
|
+
'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗',
|
|
761
|
+
'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝',
|
|
762
|
+
'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼',
|
|
763
|
+
'right': '║', 'right-mid': '╢', 'middle': '│',
|
|
764
|
+
},
|
|
765
|
+
style: { head: [], border: [] },
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
table.push(
|
|
769
|
+
[{ colSpan: 2, content: chalk.bold('QuoxVault Health'), hAlign: 'center' }],
|
|
770
|
+
['Credentials', String(stats.total || 0)],
|
|
771
|
+
[chalk.green('● Healthy'), String(stats.healthy || 0)],
|
|
772
|
+
[chalk.red('● Failed'), String(stats.failed || 0)],
|
|
773
|
+
[chalk.dim('○ Untested'), String(stats.untested || 0)],
|
|
774
|
+
[chalk.yellow('◐ Stale'), String(stats.stale || 0)],
|
|
775
|
+
['Total Uses', String(stats.totalUseCount || 0).replace(/\B(?=(\d{3})+(?!\d))/g, ',')],
|
|
776
|
+
['Encryption', stats.encryption || 'AES-256-GCM'],
|
|
777
|
+
);
|
|
778
|
+
|
|
779
|
+
console.log(table.toString());
|
|
780
|
+
|
|
781
|
+
// Type breakdown if available
|
|
782
|
+
if (stats.typeBreakdown && stats.typeBreakdown.length > 0) {
|
|
783
|
+
console.log('');
|
|
784
|
+
formatOutput({
|
|
785
|
+
columns: ['integration_type', 'count', 'healthy', 'failed'],
|
|
786
|
+
headers: ['Type', 'Count', 'Healthy', 'Failed'],
|
|
787
|
+
rows: stats.typeBreakdown,
|
|
788
|
+
}, { format });
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// ── vault scan ──────────────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
vault
|
|
795
|
+
.command('scan')
|
|
796
|
+
.description('Run a health scan and show issues')
|
|
797
|
+
.action(async () => {
|
|
798
|
+
auth.requireAuth();
|
|
799
|
+
const orgId = resolveOrgId(auth, program);
|
|
800
|
+
|
|
801
|
+
const spinner = ora('Running health scan...').start();
|
|
802
|
+
const result = await client.post(resolveEndpoint('vault.health-scan', { orgId }).path, { service: resolveEndpoint('vault.health-scan', { orgId }).service });
|
|
803
|
+
spinner.stop();
|
|
804
|
+
|
|
805
|
+
const format = program.opts().output;
|
|
806
|
+
if (format === 'json') {
|
|
807
|
+
console.log(JSON.stringify(result, null, 2));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (!result.issues || result.issues.length === 0) {
|
|
812
|
+
printSuccess('No issues found. All credentials are healthy.');
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
console.log(chalk.bold(`Found ${result.issueCount || result.issues.length} issue(s):\n`));
|
|
817
|
+
|
|
818
|
+
formatOutput({
|
|
819
|
+
columns: ['severity', 'type', 'label', 'issue', 'message'],
|
|
820
|
+
headers: ['Severity', 'Type', 'Label', 'Issue', 'Message'],
|
|
821
|
+
rows: result.issues,
|
|
822
|
+
}, { format });
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ── vault activity ──────────────────────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
vault
|
|
828
|
+
.command('activity')
|
|
829
|
+
.description('Show vault audit trail')
|
|
830
|
+
.option('--limit <n>', 'number of events', '20')
|
|
831
|
+
.action(async (options) => {
|
|
832
|
+
auth.requireAuth();
|
|
833
|
+
const orgId = resolveOrgId(auth, program);
|
|
834
|
+
const limit = parseInt(options.limit) || 20;
|
|
835
|
+
|
|
836
|
+
const data = await client.get(resolveEndpoint('vault.activity.list', { orgId }).path, {
|
|
837
|
+
service: resolveEndpoint('vault.activity.list', { orgId }).service,
|
|
838
|
+
query: { limit },
|
|
839
|
+
});
|
|
840
|
+
const events = data.events || data;
|
|
841
|
+
|
|
842
|
+
formatOutput({
|
|
843
|
+
columns: ['created_at', 'action', 'actor_type', 'status', 'target_id'],
|
|
844
|
+
headers: ['Time', 'Action', 'Actor', 'Status', 'Target'],
|
|
845
|
+
rows: events,
|
|
846
|
+
}, { format: program.opts().output, emptyMessage: 'No activity found.' });
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// ── vault types ─────────────────────────────────────────────────────────────
|
|
850
|
+
|
|
851
|
+
vault
|
|
852
|
+
.command('types')
|
|
853
|
+
.description('List available integration types')
|
|
854
|
+
.action(async () => {
|
|
855
|
+
auth.requireAuth();
|
|
856
|
+
const orgId = resolveOrgId(auth, program);
|
|
857
|
+
const data = await client.get(resolveEndpoint('vault.types.list', { orgId }).path, { service: resolveEndpoint('vault.types.list', { orgId }).service });
|
|
858
|
+
const types = data.types || data;
|
|
859
|
+
|
|
860
|
+
formatOutput({
|
|
861
|
+
columns: ['id', 'name', 'description'],
|
|
862
|
+
headers: ['ID', 'Name', 'Description'],
|
|
863
|
+
rows: types,
|
|
864
|
+
}, { format: program.opts().output });
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// ── vault rotate ────────────────────────────────────────────────────────────
|
|
868
|
+
|
|
869
|
+
vault
|
|
870
|
+
.command('rotate')
|
|
871
|
+
.description('Rotate a credential with new values')
|
|
872
|
+
.argument('<id>', 'credential ID')
|
|
873
|
+
.option('--no-test', 'skip connection test after rotation')
|
|
874
|
+
.option('--expires <date>', 'set expiration date (ISO 8601)')
|
|
875
|
+
.action(async (id, options) => {
|
|
876
|
+
auth.requireAuth();
|
|
877
|
+
const orgId = resolveOrgId(auth, program);
|
|
878
|
+
|
|
879
|
+
// Get current credential info
|
|
880
|
+
const data = await client.get(resolveEndpoint('vault.get', { orgId, id }).path, { service: resolveEndpoint('vault.get', { orgId, id }).service });
|
|
881
|
+
const cred = data.integration || data;
|
|
882
|
+
|
|
883
|
+
console.log(chalk.bold(`Rotating: ${cred.label} (${cred.integration_type})`));
|
|
884
|
+
console.log(chalk.dim(`Current key: ${cred.key_preview || '****'}`));
|
|
885
|
+
|
|
886
|
+
// Get type definition for fields
|
|
887
|
+
const typesData = await client.get(resolveEndpoint('vault.types.list', { orgId }).path, { service: resolveEndpoint('vault.types.list', { orgId }).service });
|
|
888
|
+
const types = typesData.types || typesData;
|
|
889
|
+
const typeDef = types.find(t => t.id === cred.integration_type);
|
|
890
|
+
|
|
891
|
+
if (!typeDef) throw new Error(`Unknown integration type: ${cred.integration_type}`);
|
|
892
|
+
|
|
893
|
+
// Prompt for new credential fields
|
|
894
|
+
const { default: inquirer } = await import('inquirer');
|
|
895
|
+
const credentials = {};
|
|
896
|
+
for (const field of typeDef.fields) {
|
|
897
|
+
if (field.required || !field.default) {
|
|
898
|
+
const ans = await inquirer.prompt([{
|
|
899
|
+
type: field.type === 'password' ? 'password' : 'input',
|
|
900
|
+
name: field.key,
|
|
901
|
+
message: `New ${field.label}:`,
|
|
902
|
+
validate: v => field.required ? (v.trim().length > 0 || `${field.label} is required`) : true,
|
|
903
|
+
}]);
|
|
904
|
+
credentials[field.key] = ans[field.key];
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const body = { credentials };
|
|
909
|
+
if (options.expires) body.expires_at = options.expires;
|
|
910
|
+
|
|
911
|
+
const testQuery = options.test !== false ? '?test=true' : '';
|
|
912
|
+
const spinner = ora('Rotating credential...').start();
|
|
913
|
+
|
|
914
|
+
const result = await client.post(resolveEndpoint('vault.rotate', { orgId, id, testQuery }).path, { service: resolveEndpoint('vault.rotate', { orgId, id, testQuery }).service, body });
|
|
915
|
+
|
|
916
|
+
spinner.succeed('Credential rotated.');
|
|
917
|
+
console.log(chalk.dim(`New key: ${result.key_preview || '****'}`));
|
|
918
|
+
|
|
919
|
+
if (result.test_status === 'success') {
|
|
920
|
+
printSuccess('Connection test passed.');
|
|
921
|
+
} else if (result.test_status === 'failed') {
|
|
922
|
+
printWarning('Connection test failed — credential was still rotated.');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
formatDetail(result, { format: program.opts().output });
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
// ── vault expiring ─────────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
vault
|
|
931
|
+
.command('expiring')
|
|
932
|
+
.description('List credentials expiring soon')
|
|
933
|
+
.option('--days <n>', 'expiration window in days', '7')
|
|
934
|
+
.action(async (options) => {
|
|
935
|
+
auth.requireAuth();
|
|
936
|
+
const orgId = resolveOrgId(auth, program);
|
|
937
|
+
const days = parseInt(options.days) || 7;
|
|
938
|
+
|
|
939
|
+
const data = await client.get(resolveEndpoint('vault.expiring.list', { orgId }).path, {
|
|
940
|
+
service: resolveEndpoint('vault.expiring.list', { orgId }).service,
|
|
941
|
+
query: { days },
|
|
942
|
+
});
|
|
943
|
+
const credentials = data.credentials || data;
|
|
944
|
+
|
|
945
|
+
if (credentials.length === 0) {
|
|
946
|
+
printInfo(`No credentials expiring within ${days} days.`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
console.log(chalk.bold(`Credentials expiring within ${days} days:\n`));
|
|
951
|
+
|
|
952
|
+
formatOutput({
|
|
953
|
+
columns: ['id', 'integration_type', 'label', 'expires_at', 'rotated_at', 'test_status'],
|
|
954
|
+
headers: ['ID', 'Type', 'Label', 'Expires At', 'Last Rotated', 'Status'],
|
|
955
|
+
rows: credentials,
|
|
956
|
+
}, { format: program.opts().output });
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// ── vault folder ────────────────────────────────────────────────────────────
|
|
960
|
+
//
|
|
961
|
+
// VAULT-INTEG P6: credential folders (organize vault entries into a tree,
|
|
962
|
+
// max nesting depth 3 — see services/auth/db/integrations.js createFolder).
|
|
963
|
+
|
|
964
|
+
const folder = vault
|
|
965
|
+
.command('folder')
|
|
966
|
+
.description('Organize credentials into folders');
|
|
967
|
+
|
|
968
|
+
folder
|
|
969
|
+
.command('list')
|
|
970
|
+
.description('List credential folders')
|
|
971
|
+
.action(async () => {
|
|
972
|
+
auth.requireAuth();
|
|
973
|
+
const orgId = resolveOrgId(auth, program);
|
|
974
|
+
const ep = resolveEndpoint('vault.folders.list', { orgId });
|
|
975
|
+
const data = await client.get(ep.path, { service: ep.service });
|
|
976
|
+
const folders = data.folders || data;
|
|
977
|
+
|
|
978
|
+
formatOutput({
|
|
979
|
+
columns: ['id', 'name', 'parent_id', 'color', 'icon', 'credential_count'],
|
|
980
|
+
headers: ['ID', 'Name', 'Parent', 'Color', 'Icon', 'Credentials'],
|
|
981
|
+
rows: folders,
|
|
982
|
+
}, { format: program.opts().output, emptyMessage: 'No credential folders found.' });
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
folder
|
|
986
|
+
.command('create')
|
|
987
|
+
.description('Create a credential folder')
|
|
988
|
+
.requiredOption('--name <name>', 'folder name')
|
|
989
|
+
.option('--parent <folderId>', 'parent folder ID (max nesting depth 3)')
|
|
990
|
+
.option('--color <color>', 'folder color tag')
|
|
991
|
+
.option('--icon <icon>', 'folder icon tag')
|
|
992
|
+
.action(async (options) => {
|
|
993
|
+
auth.requireAuth();
|
|
994
|
+
const orgId = resolveOrgId(auth, program);
|
|
995
|
+
const ep = resolveEndpoint('vault.folders.create', { orgId });
|
|
996
|
+
const result = await client.post(ep.path, {
|
|
997
|
+
service: ep.service,
|
|
998
|
+
body: { name: options.name, parent_id: options.parent || null, color: options.color, icon: options.icon },
|
|
999
|
+
});
|
|
1000
|
+
printEvent(events.created('quox', 'credential folder', result.id));
|
|
1001
|
+
formatDetail(result, { format: program.opts().output });
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
folder
|
|
1005
|
+
.command('rename')
|
|
1006
|
+
.description('Rename a credential folder')
|
|
1007
|
+
.argument('<folderId>', 'folder ID')
|
|
1008
|
+
.requiredOption('--name <name>', 'new folder name')
|
|
1009
|
+
.action(async (folderId, options) => {
|
|
1010
|
+
auth.requireAuth();
|
|
1011
|
+
const orgId = resolveOrgId(auth, program);
|
|
1012
|
+
const ep = resolveEndpoint('vault.folders.update', { orgId, folderId });
|
|
1013
|
+
const result = await client.put(ep.path, { service: ep.service, body: { name: options.name } });
|
|
1014
|
+
printEvent(events.updated('quox', 'credential folder', folderId, `renamed to "${options.name}"`));
|
|
1015
|
+
formatDetail(result, { format: program.opts().output });
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
folder
|
|
1019
|
+
.command('delete')
|
|
1020
|
+
.description('Delete a credential folder (its credentials move to no-folder)')
|
|
1021
|
+
.argument('<folderId>', 'folder ID')
|
|
1022
|
+
.option('--force', 'skip confirmation')
|
|
1023
|
+
.action(async (folderId, options) => {
|
|
1024
|
+
auth.requireAuth();
|
|
1025
|
+
const orgId = resolveOrgId(auth, program);
|
|
1026
|
+
|
|
1027
|
+
if (!options.force && !program.opts().yes) {
|
|
1028
|
+
const { default: inquirer } = await import('inquirer');
|
|
1029
|
+
const ans = await inquirer.prompt([{
|
|
1030
|
+
type: 'confirm',
|
|
1031
|
+
name: 'confirm',
|
|
1032
|
+
message: `Delete folder ${folderId}? Its credentials move to no-folder.`,
|
|
1033
|
+
default: false,
|
|
1034
|
+
}]);
|
|
1035
|
+
if (!ans.confirm) {
|
|
1036
|
+
printInfo('Cancelled.');
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const ep = resolveEndpoint('vault.folders.delete', { orgId, folderId });
|
|
1042
|
+
await client.delete(ep.path, { service: ep.service });
|
|
1043
|
+
printEvent(glyphLine('quox', 'credential folder deleted', { result: folderId, status: 'ok' }));
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// ── vault assign / unassign / assignments ──────────────────────────────────
|
|
1047
|
+
//
|
|
1048
|
+
// VAULT-INTEG P6: agent credential assignments (services/auth/routes/
|
|
1049
|
+
// agent-credentials.js) — which credential an agent uses for a given tool
|
|
1050
|
+
// type, ranked by priority. Not under the /vault/ namespace alias
|
|
1051
|
+
// server-side; these routes already live at their own path.
|
|
1052
|
+
|
|
1053
|
+
const isJsonOutput = () => program.opts().output === 'json' || program.opts().json === true;
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* BLAST-RADIUS-P5: grant-time reach delta. Every vault assign/unassign
|
|
1057
|
+
* names an agent, so once the grant/revoke has landed, re-fetch that
|
|
1058
|
+
* agent's reach — the exact GET .../reach call `quox reach agent` uses —
|
|
1059
|
+
* and surface the one line that just changed.
|
|
1060
|
+
*
|
|
1061
|
+
* A failed reach fetch must never fail the assign/unassign: it downgrades
|
|
1062
|
+
* to one plain warning line (never rendered as data — an error body is
|
|
1063
|
+
* not a reach result) and the command still exits 0. In --json mode no
|
|
1064
|
+
* glyph line is printed either way; on success the caller folds the
|
|
1065
|
+
* returned object into the JSON payload as `reach_delta`, on failure it's
|
|
1066
|
+
* simply absent.
|
|
1067
|
+
*/
|
|
1068
|
+
async function reachGrantDelta({ agentId, credentialRef, direction, unavailableReason }) {
|
|
1069
|
+
const json = isJsonOutput();
|
|
1070
|
+
|
|
1071
|
+
if (unavailableReason) {
|
|
1072
|
+
if (!json) {
|
|
1073
|
+
process.stdout.write(`\u{1F4A5} REACH │ grant delta unavailable (${unavailableReason})\n`);
|
|
1074
|
+
}
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
let result;
|
|
1079
|
+
try {
|
|
1080
|
+
const orgId = resolveOrgId(auth, program);
|
|
1081
|
+
const ep = resolveEndpoint('reach.agent.get', { orgId, agentId });
|
|
1082
|
+
result = await client.get(ep.path, { service: ep.service });
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
if (!json) {
|
|
1085
|
+
process.stdout.write(
|
|
1086
|
+
`\u{1F4A5} REACH │ grant delta unavailable (${err?.message || 'reach fetch failed'})\n`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const sources = result.sources || {};
|
|
1093
|
+
const badge = result.badge || { level: 'R0' };
|
|
1094
|
+
const credentials = sources.credentials?.count ?? 0;
|
|
1095
|
+
const matched = (result.may?.credentials || []).find((c) => c && c.id === credentialRef);
|
|
1096
|
+
const credRef = matched?.name || credentialRef;
|
|
1097
|
+
const verb = direction === 'assign' ? 'now in reach' : 'no longer in reach';
|
|
1098
|
+
|
|
1099
|
+
if (!json) {
|
|
1100
|
+
process.stdout.write(
|
|
1101
|
+
`\u{1F4A5} REACH │ grant delta ${agentId} │ credentials ${credentials} · ${badge.level} · ${credRef} ${verb}\n`
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return { agent_id: agentId, credentials, level: badge.level, credential: credRef, direction };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
vault
|
|
1109
|
+
.command('assign')
|
|
1110
|
+
.description('Assign a credential to an agent for a tool type')
|
|
1111
|
+
.requiredOption('--agent <agentId>', 'agent ID')
|
|
1112
|
+
.requiredOption('--credential <credentialId>', 'credential ID')
|
|
1113
|
+
.requiredOption('--tool-type <toolType>', 'tool type this assignment covers')
|
|
1114
|
+
.option('--priority <n>', 'priority when multiple assignments match (higher wins)', '0')
|
|
1115
|
+
.action(async (options) => {
|
|
1116
|
+
auth.requireAuth();
|
|
1117
|
+
const orgId = resolveOrgId(auth, program);
|
|
1118
|
+
const priority = parseInt(options.priority, 10);
|
|
1119
|
+
const ep = resolveEndpoint('vault.agent-assignments.create', { orgId });
|
|
1120
|
+
const result = await client.post(ep.path, {
|
|
1121
|
+
service: ep.service,
|
|
1122
|
+
body: {
|
|
1123
|
+
agent_id: options.agent,
|
|
1124
|
+
credential_id: options.credential,
|
|
1125
|
+
tool_type: options.toolType,
|
|
1126
|
+
priority: Number.isFinite(priority) ? priority : 0,
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
printEvent(events.assigned('quox', 'vault credential', `${options.agent} · ${options.toolType}`));
|
|
1130
|
+
|
|
1131
|
+
// BLAST-RADIUS-P5: assign always names an agent (--agent is required),
|
|
1132
|
+
// so the delta fires unconditionally here.
|
|
1133
|
+
const reachDelta = await reachGrantDelta({
|
|
1134
|
+
agentId: options.agent,
|
|
1135
|
+
credentialRef: options.credential,
|
|
1136
|
+
direction: 'assign',
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
const detail = isJsonOutput() && reachDelta ? { ...result, reach_delta: reachDelta } : result;
|
|
1140
|
+
formatDetail(detail, { format: program.opts().output });
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
vault
|
|
1144
|
+
.command('unassign')
|
|
1145
|
+
.description('Remove an agent credential assignment')
|
|
1146
|
+
.argument('<assignmentId>', 'assignment ID')
|
|
1147
|
+
.action(async (assignmentId) => {
|
|
1148
|
+
auth.requireAuth();
|
|
1149
|
+
const orgId = resolveOrgId(auth, program);
|
|
1150
|
+
|
|
1151
|
+
// BLAST-RADIUS-P5: unassign only takes the assignment ID, but the
|
|
1152
|
+
// reach delta needs to know WHICH agent lost the credential. Look the
|
|
1153
|
+
// assignment up (best effort, before it's deleted) via the same list
|
|
1154
|
+
// endpoint `quox vault assignments` already uses — no new endpoint.
|
|
1155
|
+
let targetAgentId = null;
|
|
1156
|
+
let credentialRef = assignmentId;
|
|
1157
|
+
let lookupFailure = null;
|
|
1158
|
+
try {
|
|
1159
|
+
const listEp = resolveEndpoint('vault.agent-assignments.list', { orgId });
|
|
1160
|
+
const data = await client.get(listEp.path, { service: listEp.service });
|
|
1161
|
+
const assignments = data.assignments || data;
|
|
1162
|
+
const match = Array.isArray(assignments) ? assignments.find((a) => a && a.id === assignmentId) : null;
|
|
1163
|
+
if (match) {
|
|
1164
|
+
targetAgentId = match.agent_id;
|
|
1165
|
+
credentialRef = match.label || match.credential_id || assignmentId;
|
|
1166
|
+
} else {
|
|
1167
|
+
lookupFailure = `could not resolve the agent for assignment ${assignmentId}`;
|
|
1168
|
+
}
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
lookupFailure = err?.message || `could not resolve the agent for assignment ${assignmentId}`;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const ep = resolveEndpoint('vault.agent-assignments.delete', { orgId, id: assignmentId });
|
|
1174
|
+
await client.delete(ep.path, { service: ep.service });
|
|
1175
|
+
printEvent(glyphLine('quox', 'agent credential assignment removed', { result: assignmentId, status: 'ok' }));
|
|
1176
|
+
|
|
1177
|
+
const reachDelta = await reachGrantDelta({
|
|
1178
|
+
agentId: targetAgentId,
|
|
1179
|
+
credentialRef,
|
|
1180
|
+
direction: 'unassign',
|
|
1181
|
+
unavailableReason: targetAgentId ? null : lookupFailure,
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
if (isJsonOutput() && reachDelta) {
|
|
1185
|
+
formatDetail({ deleted: true, id: assignmentId, reach_delta: reachDelta }, { format: program.opts().output });
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
vault
|
|
1190
|
+
.command('assignments')
|
|
1191
|
+
.description('List agent credential assignments')
|
|
1192
|
+
.option('--agent <agentId>', 'filter by agent ID')
|
|
1193
|
+
.option('--tool-type <toolType>', 'filter by tool type')
|
|
1194
|
+
.action(async (options) => {
|
|
1195
|
+
auth.requireAuth();
|
|
1196
|
+
const orgId = resolveOrgId(auth, program);
|
|
1197
|
+
const ep = resolveEndpoint('vault.agent-assignments.list', { orgId });
|
|
1198
|
+
const query = {};
|
|
1199
|
+
if (options.agent) query.agent_id = options.agent;
|
|
1200
|
+
if (options.toolType) query.tool_type = options.toolType;
|
|
1201
|
+
const data = await client.get(ep.path, { service: ep.service, query });
|
|
1202
|
+
const assignments = data.assignments || data;
|
|
1203
|
+
|
|
1204
|
+
formatOutput({
|
|
1205
|
+
columns: ['id', 'agent_id', 'label', 'integration_type', 'tool_type', 'priority'],
|
|
1206
|
+
headers: ['ID', 'Agent', 'Credential', 'Type', 'Tool Type', 'Priority'],
|
|
1207
|
+
rows: assignments,
|
|
1208
|
+
}, { format: program.opts().output, emptyMessage: 'No agent credential assignments found.' });
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// ── vault lease ─────────────────────────────────────────────────────────────
|
|
1212
|
+
//
|
|
1213
|
+
// VAULT-INTEG P6: just-in-time credential leases (services/auth/routes/
|
|
1214
|
+
// credential-leases.js) — an agent requests time-boxed access to a
|
|
1215
|
+
// lease-required credential; a human approves/denies via HITL inbox or
|
|
1216
|
+
// directly here.
|
|
1217
|
+
|
|
1218
|
+
const lease = vault
|
|
1219
|
+
.command('lease')
|
|
1220
|
+
.description('Just-in-time credential leases for agent access');
|
|
1221
|
+
|
|
1222
|
+
lease
|
|
1223
|
+
.command('request')
|
|
1224
|
+
.description('Request a lease on a credential (creates a pending HITL approval)')
|
|
1225
|
+
.requiredOption('--credential <credentialId>', 'credential ID')
|
|
1226
|
+
.requiredOption('--agent <agentId>', 'requesting agent ID')
|
|
1227
|
+
.option('--task <taskId>', 'associated task ID')
|
|
1228
|
+
.option('--justification <text>', 'why this lease is needed (plaintext, max 500 chars)')
|
|
1229
|
+
.option('--context <json>', 'JSON object of extra context (max 10KB)')
|
|
1230
|
+
.action(async (options) => {
|
|
1231
|
+
auth.requireAuth();
|
|
1232
|
+
const orgId = resolveOrgId(auth, program);
|
|
1233
|
+
|
|
1234
|
+
let context;
|
|
1235
|
+
if (options.context) {
|
|
1236
|
+
try {
|
|
1237
|
+
context = JSON.parse(options.context);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
throw new Error(`--context must be a JSON object: ${err.message}`);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const ep = resolveEndpoint('vault.leases.request', { orgId });
|
|
1244
|
+
const result = await client.post(ep.path, {
|
|
1245
|
+
service: ep.service,
|
|
1246
|
+
body: {
|
|
1247
|
+
credential_id: options.credential,
|
|
1248
|
+
agent_id: options.agent,
|
|
1249
|
+
task_id: options.task,
|
|
1250
|
+
justification: options.justification,
|
|
1251
|
+
context,
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
1254
|
+
printEvent(events.created('quox', 'credential lease request', result.id));
|
|
1255
|
+
formatDetail(result, { format: program.opts().output });
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
lease
|
|
1259
|
+
.command('list')
|
|
1260
|
+
.description('List credential leases')
|
|
1261
|
+
.option('--status <status>', 'filter by status (pending|active|denied|revoked|expired)')
|
|
1262
|
+
.option('--agent <agentId>', 'filter by agent ID')
|
|
1263
|
+
.option('--credential <credentialId>', 'filter by credential ID')
|
|
1264
|
+
.option('--limit <n>', 'max results', '50')
|
|
1265
|
+
.action(async (options) => {
|
|
1266
|
+
auth.requireAuth();
|
|
1267
|
+
const orgId = resolveOrgId(auth, program);
|
|
1268
|
+
const ep = resolveEndpoint('vault.leases.list', { orgId });
|
|
1269
|
+
const data = await client.get(ep.path, {
|
|
1270
|
+
service: ep.service,
|
|
1271
|
+
query: {
|
|
1272
|
+
status: options.status,
|
|
1273
|
+
agent_id: options.agent,
|
|
1274
|
+
credential_id: options.credential,
|
|
1275
|
+
limit: options.limit,
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
const leases = data.leases || data;
|
|
1279
|
+
|
|
1280
|
+
// VAULT-P5.M parity: surface the org's actual enforcement mode rather
|
|
1281
|
+
// than implying the backend gates every lease-required credential.
|
|
1282
|
+
if (data.enforcement_mode && program.opts().output !== 'json') {
|
|
1283
|
+
printInfo(`lease enforcement mode: ${data.enforcement_mode}`);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
formatOutput({
|
|
1287
|
+
columns: ['id', 'credential_label', 'requested_by_agent_id', 'status', 'expires_at'],
|
|
1288
|
+
headers: ['ID', 'Credential', 'Agent', 'Status', 'Expires'],
|
|
1289
|
+
rows: leases,
|
|
1290
|
+
}, { format: program.opts().output, emptyMessage: 'No leases found.' });
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
lease
|
|
1294
|
+
.command('approve')
|
|
1295
|
+
.description('Approve a pending lease with a duration')
|
|
1296
|
+
.argument('<leaseId>', 'lease ID')
|
|
1297
|
+
.requiredOption('--duration <minutes>', 'lease duration in minutes')
|
|
1298
|
+
.action(async (leaseId, options) => {
|
|
1299
|
+
auth.requireAuth();
|
|
1300
|
+
const orgId = resolveOrgId(auth, program);
|
|
1301
|
+
const duration = parseInt(options.duration, 10);
|
|
1302
|
+
if (!Number.isFinite(duration) || duration < 1) {
|
|
1303
|
+
throw new Error('--duration must be a positive number of minutes.');
|
|
1304
|
+
}
|
|
1305
|
+
const ep = resolveEndpoint('vault.leases.approve', { orgId, leaseId });
|
|
1306
|
+
const result = await client.post(ep.path, { service: ep.service, body: { duration_minutes: duration } });
|
|
1307
|
+
printEvent(events.updated('quox', 'credential lease', leaseId, `approved for ${duration}m`));
|
|
1308
|
+
formatDetail(result, { format: program.opts().output });
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
lease
|
|
1312
|
+
.command('deny')
|
|
1313
|
+
.description('Deny a pending lease')
|
|
1314
|
+
.argument('<leaseId>', 'lease ID')
|
|
1315
|
+
.action(async (leaseId) => {
|
|
1316
|
+
auth.requireAuth();
|
|
1317
|
+
const orgId = resolveOrgId(auth, program);
|
|
1318
|
+
const ep = resolveEndpoint('vault.leases.deny', { orgId, leaseId });
|
|
1319
|
+
const result = await client.post(ep.path, { service: ep.service });
|
|
1320
|
+
printEvent(events.updated('quox', 'credential lease', leaseId, 'denied'));
|
|
1321
|
+
formatDetail(result, { format: program.opts().output });
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
lease
|
|
1325
|
+
.command('revoke')
|
|
1326
|
+
.description('Revoke an active lease immediately')
|
|
1327
|
+
.argument('<leaseId>', 'lease ID')
|
|
1328
|
+
.action(async (leaseId) => {
|
|
1329
|
+
auth.requireAuth();
|
|
1330
|
+
const orgId = resolveOrgId(auth, program);
|
|
1331
|
+
const ep = resolveEndpoint('vault.leases.revoke', { orgId, leaseId });
|
|
1332
|
+
const result = await client.post(ep.path, { service: ep.service });
|
|
1333
|
+
if (result?.revoked === false) {
|
|
1334
|
+
printWarning(`Lease ${leaseId} was not revoked (already inactive?).`);
|
|
1335
|
+
} else {
|
|
1336
|
+
printEvent(glyphLine('quox', 'credential lease revoked', { result: leaseId, status: 'ok' }));
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
// ── vault cross-org ─────────────────────────────────────────────────────────
|
|
1341
|
+
//
|
|
1342
|
+
// VAULT-INTEG P6: GET /me/cross-org-credentials — find credentials of a
|
|
1343
|
+
// type across every org the caller belongs to. Metadata only, no secrets.
|
|
1344
|
+
|
|
1345
|
+
vault
|
|
1346
|
+
.command('cross-org')
|
|
1347
|
+
.description('Find credentials of a type across every org you belong to (no secrets returned)')
|
|
1348
|
+
.requiredOption('--type <type>', 'integration type to look up')
|
|
1349
|
+
.action(async (options) => {
|
|
1350
|
+
auth.requireAuth();
|
|
1351
|
+
const ep = resolveEndpoint('vault.cross-org.list', {});
|
|
1352
|
+
const data = await client.get(ep.path, { service: ep.service, query: { type: options.type } });
|
|
1353
|
+
const availableIn = data.available_in || [];
|
|
1354
|
+
|
|
1355
|
+
formatOutput({
|
|
1356
|
+
columns: ['org_id', 'org_name', 'credential_id', 'label', 'is_default', 'test_status'],
|
|
1357
|
+
headers: ['Org ID', 'Org Name', 'Credential ID', 'Label', 'Default', 'Status'],
|
|
1358
|
+
rows: availableIn,
|
|
1359
|
+
}, { format: program.opts().output, emptyMessage: `No ${options.type} credentials found in any of your orgs.` });
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
// ── vault health ────────────────────────────────────────────────────────────
|
|
1363
|
+
//
|
|
1364
|
+
// VAULT-INTEG P6: GET /internal/vault-health. This is a SERVICE-KEY-gated
|
|
1365
|
+
// route (checks the raw `x-service-key` header against INTERNAL_SERVICE_KEY,
|
|
1366
|
+
// not a user Bearer JWT) — unlike the removed `vault export` decrypt route,
|
|
1367
|
+
// the CLI client already has a legitimate service-key path (QUOX_SERVICE_KEY
|
|
1368
|
+
// env var, see lib/core/client.js), so this is wired rather than removed.
|
|
1369
|
+
// A normal logged-in user session sends a Bearer token instead and this
|
|
1370
|
+
// route will 401 it — that is not a bug, it is the route's design; run
|
|
1371
|
+
// with QUOX_SERVICE_KEY set to the instance's INTERNAL_SERVICE_KEY (or
|
|
1372
|
+
// QUOX_FORCE_SERVICE_KEY=1 if a user session is also stored) to use it.
|
|
1373
|
+
|
|
1374
|
+
vault
|
|
1375
|
+
.command('health')
|
|
1376
|
+
.description('Lightweight vault health summary (requires QUOX_SERVICE_KEY, not a user session)')
|
|
1377
|
+
.option('--org-id <orgId>', 'org ID to scope the summary to (server default: "default")')
|
|
1378
|
+
.action(async (options) => {
|
|
1379
|
+
const ep = resolveEndpoint('vault.health', {});
|
|
1380
|
+
const data = await client.get(ep.path, {
|
|
1381
|
+
service: ep.service,
|
|
1382
|
+
query: options.orgId ? { org_id: options.orgId } : undefined,
|
|
1383
|
+
});
|
|
1384
|
+
formatDetail(data, { format: program.opts().output, title: 'QuoxVault Health' });
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
// ── vault export (removed — WP0.2) ──────────────────────────────────────────
|
|
1388
|
+
//
|
|
1389
|
+
// REMOVED (WP0.2): this command decrypted credentials via
|
|
1390
|
+
// POST /internal/vault/:id/decrypt, which aliases to
|
|
1391
|
+
// /internal/integrations/:id/decrypt — a SERVICE-KEY-gated route
|
|
1392
|
+
// (authenticateApiKey(), expects `x-api-key: msk_...`). The CLI only
|
|
1393
|
+
// ever sends a user Bearer JWT, so this call would 401 in production.
|
|
1394
|
+
// There is no legitimate service-key mode wired into the CLI client,
|
|
1395
|
+
// so the command is removed rather than silently broken.
|
|
1396
|
+
|
|
1397
|
+
// ── vault migrate ───────────────────────────────────────────────────────────
|
|
1398
|
+
|
|
1399
|
+
vault
|
|
1400
|
+
.command('migrate')
|
|
1401
|
+
.description('Migrate legacy instance_config keys to QuoxVault')
|
|
1402
|
+
.option('--dry-run', 'show what would be migrated without making changes')
|
|
1403
|
+
.action(async (options) => {
|
|
1404
|
+
auth.requireAuth();
|
|
1405
|
+
|
|
1406
|
+
const spinner = ora('Running key migration...').start();
|
|
1407
|
+
|
|
1408
|
+
try {
|
|
1409
|
+
// Shell out to the migration script
|
|
1410
|
+
const { execSync } = await import('child_process');
|
|
1411
|
+
const scriptPath = new URL('../../../services/auth/scripts/migrate-keys-to-vault.js', import.meta.url).pathname;
|
|
1412
|
+
const args = options.dryRun ? '--dry-run' : '';
|
|
1413
|
+
const output = execSync(`node ${scriptPath} ${args}`, {
|
|
1414
|
+
encoding: 'utf-8',
|
|
1415
|
+
timeout: 30000,
|
|
1416
|
+
});
|
|
1417
|
+
spinner.stop();
|
|
1418
|
+
console.log(output);
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
spinner.fail('Migration failed.');
|
|
1421
|
+
throw new Error(err.stderr || err.message);
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
}
|