@ulisesgascon/openjs-cna-tools 0.0.0-stage → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ // openjs-cna-experimental llm-review-prompt <record.json>
2
+ //
3
+ // Print a prompt for an LLM-driven editorial review of the record's
4
+ // natural-language fields (title + description). The tool does not call
5
+ // an LLM itself. Paste the output into the LLM of your choice. Pure
6
+ // read; no network.
7
+
8
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
9
+ import { buildAuditPrompt } from '../lib/prompt.js'
10
+
11
+ export const command = 'llm-review-prompt'
12
+ export const describe = 'Print a prompt for an LLM editorial review of the record\'s title + description.'
13
+
14
+ export async function handler (argv) {
15
+ const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
16
+ if (parsed.values.help) return printHelp()
17
+
18
+ // Keep buildCli for help/diagnostic plumbing even though we don't
19
+ // print status. Keeps the component shape consistent with peers.
20
+ buildCli(parsed)
21
+
22
+ const file = parsed.positionals[0]
23
+ const raw = await readFileOrStdin(file)
24
+ let record
25
+ try {
26
+ record = JSON.parse(raw)
27
+ } catch (e) {
28
+ process.stderr.write(`✖ Record is not valid JSON: ${e.message}\n`)
29
+ process.exit(64)
30
+ }
31
+
32
+ // Banner goes to stderr so a pipe like `| pbcopy` or `| llm` only
33
+ // captures the prompt itself, not the human-facing how-to.
34
+ process.stderr.write(buildBanner())
35
+ process.stdout.write(buildAuditPrompt(record))
36
+ }
37
+
38
+ function buildBanner () {
39
+ const lines = [
40
+ 'How to use this prompt',
41
+ '',
42
+ '1. Paste everything that follows into an LLM (Claude, ChatGPT,',
43
+ ' Gemini, etc.).',
44
+ '2. Also attach the full CVE Record JSON so the LLM can cross-check',
45
+ ' the natural-language fields against the rest of the record.',
46
+ '3. The LLM returns a Markdown review you can act on.'
47
+ ]
48
+ const width = Math.max(...lines.map(l => l.length))
49
+ const top = '┌' + '─'.repeat(width + 2) + '┐'
50
+ const bottom = '└' + '─'.repeat(width + 2) + '┘'
51
+ const body = lines.map(l => '│ ' + l.padEnd(width) + ' │').join('\n')
52
+ return top + '\n' + body + '\n' + bottom + '\n'
53
+ }
54
+
55
+ function printHelp () {
56
+ process.stderr.write(
57
+ `openjs-cna-experimental llm-review-prompt <record.json>
58
+
59
+ Build a prompt that asks an LLM for an editorial review of the record's
60
+ title and description. The prompt includes the OpenJS naming and tone
61
+ guidelines, the texts to audit, and a short facts block (affected,
62
+ CVSS, CWE) so the LLM can ground its suggestions.
63
+
64
+ The tool does not call any LLM. Paste the output into the LLM of your
65
+ choice and read the reply. Attach the full CVE Record JSON in the same
66
+ turn so the LLM can cross-check the prose against the structured data.
67
+
68
+ Reads the record from a file (positional) or from stdin if the
69
+ positional is '-' or absent.
70
+
71
+ A how-to banner is written to stderr so a pipe like \`| pbcopy\` only
72
+ captures the prompt itself.
73
+
74
+ Pipeline use (audit a freshly converted draft before publish):
75
+
76
+ openjs-cna-experimental convert https://github.com/<owner>/<repo>/security/advisories/<GHSA> \\
77
+ | tee draft.json \\
78
+ | openjs-cna-experimental llm-review-prompt - | pbcopy
79
+
80
+ Open the LLM, paste, attach \`draft.json\`, then iterate before
81
+ \`openjs-cna-experimental publish\`.
82
+ `
83
+ )
84
+ }
@@ -0,0 +1,42 @@
1
+ // openjs-cna-experimental preview <container.json>
2
+ // Compact terminal view of a v5.2 CNA Container. Pure read; no network.
3
+
4
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
5
+ import { renderPreview } from '../lib/preview.js'
6
+
7
+ export const command = 'preview'
8
+ export const describe = 'Print a compact preview of a CNA Container.'
9
+
10
+ export async function handler (argv) {
11
+ const parsed = parseComponentArgs(argv, {}, { allowPositionals: true })
12
+ if (parsed.values.help) return printHelp()
13
+
14
+ const cli = buildCli(parsed)
15
+ const file = parsed.positionals[0]
16
+ const raw = await readFileOrStdin(file)
17
+ let container
18
+ try {
19
+ container = JSON.parse(raw)
20
+ } catch (e) {
21
+ process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
22
+ process.exit(64)
23
+ }
24
+
25
+ renderPreview(container, cli)
26
+ }
27
+
28
+ function printHelp () {
29
+ process.stderr.write(
30
+ `openjs-cna-experimental preview <container.json>
31
+
32
+ Print a compact summary of the container — title, description, affected
33
+ products, CVSS, CWE, credits, references, and the x_generator engine.
34
+
35
+ Useful as a quick sanity check before publish, or as a step in a pipeline
36
+ ('openjs-cna-experimental get CVE-... | openjs-cna-experimental preview -').
37
+
38
+ Reads the container from a file (positional) or from stdin if the
39
+ positional is '-' or absent.
40
+ `
41
+ )
42
+ }
@@ -0,0 +1,133 @@
1
+ // openjs-cna-experimental publish <cve-id> [-f container.json] [--skip-review] [--no-strict]
2
+ //
3
+ // Cross-checks the CLI <cve-id> against `container.cveMetadata.cveId` if
4
+ // present (hard-error on mismatch); fills cveMetadata in when absent.
5
+ // Then runs the reviewer (strict by default — warnings block too). On a
6
+ // clean review (or `--skip-review`), prompts for confirmation and
7
+ // publishes. Surfaces `correlation_id` + `run_id` after publish so the
8
+ // operator can forward to a CNA coordinator if needed.
9
+
10
+ import {
11
+ parseComponentArgs,
12
+ buildClient,
13
+ buildCli,
14
+ readFileOrStdin
15
+ } from '../lib/component-utils.js'
16
+ import { reviewRecord } from '../lib/review/index.js'
17
+ import { CnaReviewBlockedError } from '../lib/errors.js'
18
+
19
+ export const command = 'publish'
20
+ export const describe = 'Publish a v5.2 CNA Container for a reserved CVE.'
21
+
22
+ export async function handler (argv) {
23
+ const parsed = parseComponentArgs(argv, {
24
+ file: { type: 'string', short: 'f' },
25
+ 'skip-review': { type: 'boolean' },
26
+ 'no-strict': { type: 'boolean' }
27
+ }, { allowPositionals: true })
28
+ if (parsed.values.help) return printHelp()
29
+
30
+ const cveId = parsed.positionals[0]
31
+ if (!cveId) {
32
+ process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental publish <CVE-...> -f container.json\n')
33
+ process.exit(64)
34
+ }
35
+
36
+ const cli = buildCli(parsed)
37
+ const client = buildClient(parsed)
38
+
39
+ const raw = await readFileOrStdin(parsed.values.file)
40
+ let record
41
+ try {
42
+ record = JSON.parse(raw)
43
+ } catch (e) {
44
+ process.stderr.write(`✖ File is not valid JSON: ${e.message}\n`)
45
+ process.exit(64)
46
+ }
47
+
48
+ // CVE id consistency check. The Record's cveMetadata.cveId must match
49
+ // the CLI arg; mismatch is a hard error (operator was probably about to
50
+ // publish the wrong record). If cveMetadata is missing, the CLI arg is
51
+ // the sole source of truth.
52
+ const recordCveId = record?.cveMetadata?.cveId
53
+ if (recordCveId && recordCveId !== cveId) {
54
+ process.stderr.write(
55
+ `✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
56
+ ' Aborting before publish to prevent updating the wrong record.\n'
57
+ )
58
+ process.exit(64)
59
+ }
60
+
61
+ // Extract the inner cna container — the workflow expects just that,
62
+ // not the full Record (it builds the Record server-side with the
63
+ // assignerOrgId and dateUpdated MITRE injects).
64
+ const container = record.containers.cna
65
+
66
+ if (!parsed.values['skip-review']) {
67
+ const strict = !parsed.values['no-strict']
68
+ const report = reviewRecord(record, { strict })
69
+ // Strict mode promotes warnings to blockers inside reviewRecord, so
70
+ // the `summary.blockers` count already reflects strictness. We just
71
+ // need to check that single number.
72
+ if (report.summary.blockers > 0) {
73
+ cli.error(
74
+ `Review blocked publish: ${report.summary.blockers} blocker(s)`
75
+ )
76
+ for (const issue of report.issues) {
77
+ const tag = issue.severity === 'blocker' ? '✖' : issue.severity === 'warning' ? '⚠' : '·'
78
+ cli.log(` ${tag} [${issue.rule}] ${issue.message}`)
79
+ }
80
+ cli.log('')
81
+ cli.log(' Re-run with --skip-review to bypass.')
82
+ throw new CnaReviewBlockedError(
83
+ `Review blocked publish for ${cveId}`,
84
+ { report }
85
+ )
86
+ }
87
+ if (report.summary.warnings) {
88
+ cli.warn(`Review surfaced ${report.summary.warnings} warning(s); proceeding.`)
89
+ } else {
90
+ cli.ok(`Review clean (${report.summary.suggestions} suggestion(s)).`)
91
+ }
92
+ }
93
+
94
+ // Confirmation prompt. Defaults to "yes" only when the review was clean
95
+ // (and we got here without --skip-review). Honors --yes / OPENJS_CNA_YES.
96
+ const confirmed = await cli.prompt(
97
+ `Publish ${cveId}? This is irreversible.`,
98
+ { defaultAnswer: !parsed.values['skip-review'] }
99
+ )
100
+ if (!confirmed) {
101
+ cli.info('Aborted.')
102
+ return
103
+ }
104
+
105
+ cli.info(`Publishing ${cveId}…`)
106
+ const { correlationId, runId, result } = await client.publishCve(cveId, container, {
107
+ onPoll: (run) => {
108
+ if (run.status && run.status !== 'completed') cli.info(`run status: ${run.status}`)
109
+ }
110
+ })
111
+ cli.ok(`Published ${cveId}`)
112
+ if (correlationId || runId) {
113
+ cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
114
+ }
115
+ if (result) process.stdout.write(JSON.stringify(result, null, 2) + '\n')
116
+ }
117
+
118
+ function printHelp () {
119
+ process.stderr.write(
120
+ `openjs-cna-experimental publish <cve-id> [-f container.json]
121
+
122
+ Publish a v5.2 CNA Container against an already-reserved CVE id. Default
123
+ behavior runs the reviewer first (strict — warnings block too) and aborts
124
+ on any issue.
125
+
126
+ Options:
127
+ -f, --file <path> read the container from a file (default: stdin)
128
+ --skip-review do not run the reviewer
129
+ --no-strict let warnings pass through (only true blockers abort)
130
+ [global flags]
131
+ `
132
+ )
133
+ }
@@ -0,0 +1,48 @@
1
+ // openjs-cna-experimental reserve
2
+ // Reserves a CVE id via the CNA API and prints it on stdout.
3
+
4
+ import { parseComponentArgs, buildClient, buildCli } from '../lib/component-utils.js'
5
+
6
+ export const command = 'reserve'
7
+ export const describe = 'Reserve a CVE id via the OpenJS Foundation CNA.'
8
+
9
+ export async function handler (argv) {
10
+ const parsed = parseComponentArgs(argv)
11
+ if (parsed.values.help) return printHelp()
12
+ const cli = buildCli(parsed)
13
+ const client = buildClient(parsed)
14
+
15
+ cli.info('Reserving CVE…')
16
+ const { cveId, correlationId, runId } = await client.reserveCve({
17
+ onPoll: (run) => {
18
+ if (run.status && run.status !== 'completed') {
19
+ // Honest progress — one line per poll cycle, no animation.
20
+ cli.info(`run status: ${run.status}`)
21
+ }
22
+ }
23
+ })
24
+ cli.ok(`Reserved ${cveId}`)
25
+ if (correlationId || runId) {
26
+ cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
27
+ }
28
+
29
+ // Data on stdout, no decoration — `CVE=$(openjs-cna-experimental reserve)` Just Works.
30
+ process.stdout.write(`${cveId}\n`)
31
+ }
32
+
33
+ function printHelp () {
34
+ process.stderr.write(
35
+ `openjs-cna-experimental reserve
36
+
37
+ Reserve a CVE id from the configured bucket. Prints the CVE id on stdout
38
+ once the workflow completes.
39
+
40
+ Usage:
41
+ openjs-cna-experimental reserve [global flags]
42
+
43
+ Example:
44
+ CVE=$(openjs-cna-experimental reserve)
45
+ openjs-cna-experimental publish "$CVE" -f container.json
46
+ `
47
+ )
48
+ }
@@ -0,0 +1,123 @@
1
+ // openjs-cna-experimental review <container.json> [--no-strict] [--no-preview] [--tier <name>] [--json]
2
+ //
3
+ // Runs the offline reviewer against a CNA Container. Strict by default
4
+ // (warnings block); --no-strict surfaces them as warnings. Renders the
5
+ // preview before the issues list unless --no-preview. --tier filters the
6
+ // printed issues to a single severity tier (mitre-common / openjs-extra)
7
+ // without changing the exit code, which always reflects the full review.
8
+
9
+ import { parseComponentArgs, buildCli, readFileOrStdin } from '../lib/component-utils.js'
10
+ import { reviewRecord } from '../lib/review/index.js'
11
+ import { renderPreview } from '../lib/preview.js'
12
+
13
+ export const command = 'review'
14
+ export const describe = 'Review a v5.2 CNA Container against best-practice rules.'
15
+
16
+ const VALID_TIERS = new Set(['mitre-common', 'openjs-extra'])
17
+
18
+ export async function handler (argv) {
19
+ const parsed = parseComponentArgs(argv, {
20
+ 'no-strict': { type: 'boolean' },
21
+ 'no-preview': { type: 'boolean' },
22
+ tier: { type: 'string' }
23
+ }, { allowPositionals: true })
24
+ if (parsed.values.help) return printHelp()
25
+
26
+ const cli = buildCli(parsed)
27
+ const file = parsed.positionals[0]
28
+
29
+ // Validate the --tier filter early so a typo doesn't silently match
30
+ // nothing and look like a clean review.
31
+ const tierFilter = parsed.values.tier
32
+ if (tierFilter && !VALID_TIERS.has(tierFilter)) {
33
+ process.stderr.write(
34
+ `✖ Unknown --tier '${tierFilter}'. Expected one of: ${[...VALID_TIERS].join(', ')}\n`
35
+ )
36
+ process.exit(64)
37
+ }
38
+
39
+ const raw = await readFileOrStdin(file)
40
+ let container
41
+ try {
42
+ container = JSON.parse(raw)
43
+ } catch (e) {
44
+ process.stderr.write(`✖ Container is not valid JSON: ${e.message}\n`)
45
+ process.exit(64)
46
+ }
47
+
48
+ const report = reviewRecord(container, { strict: !parsed.values['no-strict'] })
49
+
50
+ if (parsed.values.json) {
51
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
52
+ } else {
53
+ // Preview goes first so the operator sees what they're about to ship
54
+ // alongside what the reviewer caught. --no-preview drops it for
55
+ // CI / pipeline use.
56
+ if (!parsed.values['no-preview']) renderPreview(container, cli)
57
+ printHumanReport(cli, report, { tierFilter })
58
+ }
59
+
60
+ // Exit code: 0 if ready, 1 if blockers (or strict + warnings).
61
+ if (report.summary.blockers > 0) process.exit(1)
62
+ }
63
+
64
+ function printHumanReport (cli, report, { tierFilter } = {}) {
65
+ const { summary, issues } = report
66
+ const ratingLabel = {
67
+ ready: '✔ ready',
68
+ 'needs-work': '⚠ needs work',
69
+ blocked: '✖ blocked'
70
+ }[summary.rating]
71
+ cli.log('')
72
+ cli.log(`Review: ${ratingLabel}`)
73
+ cli.log(` ${summary.blockers} blocker(s) · ${summary.warnings} warning(s) · ${summary.suggestions} suggestion(s)`)
74
+ if (tierFilter) {
75
+ cli.log(` (filtered to tier: ${tierFilter})`)
76
+ }
77
+ cli.log('')
78
+ // Filter at print time so the summary counts above still reflect the full
79
+ // review. This keeps `--tier mitre-common` from hiding the broader picture
80
+ // while still letting operators focus on the must-fix items.
81
+ const shown = tierFilter
82
+ ? issues.filter(i => i.tier === tierFilter)
83
+ : issues
84
+ if (shown.length === 0) {
85
+ if (tierFilter && issues.length > 0) {
86
+ cli.log(` No ${tierFilter} issues found.`)
87
+ } else {
88
+ cli.log(' No issues found.')
89
+ }
90
+ return
91
+ }
92
+ for (const issue of shown) {
93
+ const tag = issue.severity === 'blocker'
94
+ ? '✖'
95
+ : issue.severity === 'warning'
96
+ ? '⚠'
97
+ : '·'
98
+ // Tier rides inline with the rule id so a glance at the report tells
99
+ // the operator whether MITRE would have caught the issue too.
100
+ const tierLabel = issue.tier ? `${issue.tier}: ` : ''
101
+ cli.log(` ${tag} [${tierLabel}${issue.rule}] ${issue.path}`)
102
+ cli.log(` ${issue.message}`)
103
+ }
104
+ }
105
+
106
+ function printHelp () {
107
+ process.stderr.write(
108
+ `openjs-cna-experimental review <container.json>
109
+
110
+ Run the offline reviewer against a CNA Container. Strict by default —
111
+ warnings are treated as blockers because publishing to MITRE is
112
+ irreversible. Pass --no-strict to surface warnings as warnings.
113
+
114
+ Options:
115
+ --no-strict surface warnings as warnings (default treats them as blockers)
116
+ --json emit a structured JSON report instead of the human view
117
+ --no-preview skip the container preview (for CI / pipelines)
118
+ --tier <tier> filter the printed issue list to a single tier
119
+ (mitre-common | openjs-extra). Summary counts still
120
+ reflect the full review; exit code is unaffected.
121
+ `
122
+ )
123
+ }
@@ -0,0 +1,104 @@
1
+ // openjs-cna-experimental update <cve-id> [-f container.json] [--skip-review] [--no-strict]
2
+ //
3
+ // Same shape as `publish`, but issues the update-cve operation. Same
4
+ // cveMetadata.cveId cross-check, same strict-by-default reviewer, same
5
+ // correlation_id + run_id surfacing on success.
6
+
7
+ import {
8
+ parseComponentArgs,
9
+ buildClient,
10
+ buildCli,
11
+ readFileOrStdin
12
+ } from '../lib/component-utils.js'
13
+ import { reviewRecord } from '../lib/review/index.js'
14
+ import { CnaReviewBlockedError } from '../lib/errors.js'
15
+
16
+ export const command = 'update'
17
+ export const describe = 'Update an already-published CVE record.'
18
+
19
+ export async function handler (argv) {
20
+ const parsed = parseComponentArgs(argv, {
21
+ file: { type: 'string', short: 'f' },
22
+ 'skip-review': { type: 'boolean' },
23
+ 'no-strict': { type: 'boolean' }
24
+ }, { allowPositionals: true })
25
+ if (parsed.values.help) return printHelp()
26
+
27
+ const cveId = parsed.positionals[0]
28
+ if (!cveId) {
29
+ process.stderr.write('✖ Missing CVE id. Usage: openjs-cna-experimental update <CVE-...> -f container.json\n')
30
+ process.exit(64)
31
+ }
32
+
33
+ const cli = buildCli(parsed)
34
+ const client = buildClient(parsed)
35
+
36
+ const raw = await readFileOrStdin(parsed.values.file)
37
+ let record
38
+ try {
39
+ record = JSON.parse(raw)
40
+ } catch (e) {
41
+ process.stderr.write(`✖ File is not valid JSON: ${e.message}\n`)
42
+ process.exit(64)
43
+ }
44
+
45
+ // CVE id consistency check (same logic as publish).
46
+ const recordCveId = record?.cveMetadata?.cveId
47
+ if (recordCveId && recordCveId !== cveId) {
48
+ process.stderr.write(
49
+ `✖ CVE id mismatch: CLI argument is ${cveId}, record.cveMetadata.cveId is ${recordCveId}.\n` +
50
+ ' Aborting before update to prevent overwriting the wrong record.\n'
51
+ )
52
+ process.exit(64)
53
+ }
54
+
55
+ // Workflow expects just the inner cna container.
56
+ const container = record.containers.cna
57
+
58
+ if (!parsed.values['skip-review']) {
59
+ const strict = !parsed.values['no-strict']
60
+ const report = reviewRecord(record, { strict })
61
+ if (report.summary.blockers > 0) {
62
+ cli.error(`Review blocked update: ${report.summary.blockers} blocker(s)`)
63
+ for (const issue of report.issues) {
64
+ const tag = issue.severity === 'blocker' ? '✖' : '⚠'
65
+ cli.log(` ${tag} [${issue.rule}] ${issue.message}`)
66
+ }
67
+ throw new CnaReviewBlockedError(`Review blocked update for ${cveId}`, { report })
68
+ }
69
+ cli.ok('Review clean.')
70
+ }
71
+
72
+ const confirmed = await cli.prompt(
73
+ `Update ${cveId}? This will replace the public MITRE record.`,
74
+ { defaultAnswer: !parsed.values['skip-review'] }
75
+ )
76
+ if (!confirmed) {
77
+ cli.info('Aborted.')
78
+ return
79
+ }
80
+
81
+ cli.info(`Updating ${cveId}…`)
82
+ const { correlationId, runId } = await client.updateCve(cveId, container, {
83
+ onPoll: (run) => { if (run.status !== 'completed') cli.info(`run status: ${run.status}`) }
84
+ })
85
+ cli.ok(`Updated ${cveId}`)
86
+ if (correlationId || runId) {
87
+ cli.info(`Forward to coordinator if needed: correlation_id=${correlationId ?? '?'}, run_id=${runId ?? '?'}`)
88
+ }
89
+ }
90
+
91
+ function printHelp () {
92
+ process.stderr.write(
93
+ `openjs-cna-experimental update <cve-id> [-f container.json]
94
+
95
+ Update a published CVE record. Same review semantics as publish (strict
96
+ by default — warnings block too).
97
+
98
+ Options:
99
+ -f, --file <path> read the container from a file (default: stdin)
100
+ --skip-review do not run the reviewer
101
+ --no-strict let warnings pass through (only true blockers abort)
102
+ `
103
+ )
104
+ }
package/lib/auth.js ADDED
@@ -0,0 +1,20 @@
1
+ // Resolve token + base URL from the precedence chain (flags > env > project >
2
+ // global) and feed them to a CnaClient.
3
+ //
4
+ // The CLI passes any explicit --token / --base-url flags as `overrides`.
5
+ // Everything else comes from the merged config (see ./config.js).
6
+ //
7
+ // Validation here is permissive: we surface what we have, the CnaClient
8
+ // throws lazy CnaAuthError on the first operation that actually needs a
9
+ // credential. That keeps `openjs-cna-experimental --help` and `openjs-cna-experimental config list`
10
+ // usable without any auth at all.
11
+
12
+ import { getMergedConfig } from './config.js'
13
+
14
+ export function resolveCredentials ({ overrides = {}, cwd } = {}) {
15
+ const config = getMergedConfig({ cwd })
16
+ return {
17
+ token: overrides.token ?? config.openjs_cna_token,
18
+ baseUrl: overrides.baseUrl ?? config.openjs_cna_base_url
19
+ }
20
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ // Minimal CLI: a stream wrapper, four log levels, a non-animated spinner,
2
+ // and a confirm/input prompt. Zero deps — ANSI escapes inline, gated by
3
+ // stream.isTTY so piped output stays clean.
4
+ //
5
+ // Output discipline: this class always writes to its `stream` (defaulting
6
+ // to stderr). The CLI verbs are responsible for writing data (CVE ids,
7
+ // JSON records) to stdout directly. That keeps `CVE=$(openjs-cna-experimental reserve)`
8
+ // pipeline-safe by construction.
9
+
10
+ const ANSI = {
11
+ reset: '\x1b[0m',
12
+ bold: '\x1b[1m',
13
+ red: '\x1b[31m',
14
+ yellow: '\x1b[33m',
15
+ green: '\x1b[32m',
16
+ blue: '\x1b[34m',
17
+ gray: '\x1b[90m'
18
+ }
19
+
20
+ export class CLI {
21
+ constructor (stream = process.stderr, { color, assumeYes } = {}) {
22
+ this.stream = stream
23
+ // Default to color when stderr is a TTY and the user didn't opt out.
24
+ this.color = color ?? (stream.isTTY === true && !process.env.NO_COLOR)
25
+ this.assumeYes = assumeYes ?? false
26
+ this._exitCode = 0
27
+ }
28
+
29
+ setAssumeYes (value = true) { this.assumeYes = value }
30
+ setExitCode (code) { this._exitCode = code }
31
+ exitCode () { return this._exitCode }
32
+
33
+ _paint (code, text) { return this.color ? `${code}${text}${ANSI.reset}` : text }
34
+ _write (line) { this.stream.write(line + '\n') }
35
+
36
+ // Severity-leveled log lines. Match NCU's vocabulary (ok / info / warn /
37
+ // error) so a developer moving between the two codebases doesn't have to
38
+ // re-learn the lexicon.
39
+ ok (msg) { this._write(this._paint(ANSI.green, '✔ ') + msg) }
40
+ info (msg) { this._write(this._paint(ANSI.blue, 'ℹ ') + msg) }
41
+ warn (msg) { this._write(this._paint(ANSI.yellow, '⚠ ') + msg) }
42
+ error (msg) { this._write(this._paint(ANSI.red, '✖ ') + msg) }
43
+ log (msg) { this._write(msg) }
44
+ separator () { this._write('') }
45
+
46
+ // Non-animated spinner replacement. Prints a "starting…" line on start
47
+ // and a "done" line on stop. Honest about progress without leaning on
48
+ // ora's TTY animation. Future: rewrite-in-place when stream.isTTY, fall
49
+ // back to discrete lines when piped.
50
+ startSpinner (text) {
51
+ this._write(this._paint(ANSI.gray, '… ') + text)
52
+ return { stop: () => {} }
53
+ }
54
+
55
+ stopSpinner (text) {
56
+ if (text) this._write(this._paint(ANSI.green, '✔ ') + text)
57
+ }
58
+
59
+ // Confirm or input prompt. Honors --yes (or assumeYes flag) by returning
60
+ // the defaultAnswer without rendering. Non-TTY stdin also defaults to
61
+ // the default so the CLI is safe to drive from CI without explicit --yes.
62
+ async prompt (question, { defaultAnswer = false, questionType = 'confirm' } = {}) {
63
+ if (this.assumeYes) return defaultAnswer
64
+ if (!process.stdin.isTTY) return defaultAnswer
65
+ const { createInterface } = await import('node:readline/promises')
66
+ const rl = createInterface({ input: process.stdin, output: this.stream })
67
+ try {
68
+ const hint = questionType === 'confirm'
69
+ ? (defaultAnswer ? '[Y/n]' : '[y/N]')
70
+ : ''
71
+ const raw = (await rl.question(`${question} ${hint} `.trimEnd() + ' ')).trim()
72
+ if (questionType === 'confirm') {
73
+ if (!raw) return defaultAnswer
74
+ return /^y(es)?$/i.test(raw)
75
+ }
76
+ return raw || defaultAnswer
77
+ } finally {
78
+ rl.close()
79
+ }
80
+ }
81
+ }