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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -2
  3. package/bin/openjs-cna.js +128 -0
  4. package/components/config.js +92 -0
  5. package/components/convert.js +81 -0
  6. package/components/get.js +35 -0
  7. package/components/health.js +30 -0
  8. package/components/list.js +36 -0
  9. package/components/llm-review-prompt.js +84 -0
  10. package/components/preview.js +42 -0
  11. package/components/publish.js +238 -0
  12. package/components/reserve.js +48 -0
  13. package/components/review.js +170 -0
  14. package/components/update.js +104 -0
  15. package/lib/auth.js +20 -0
  16. package/lib/batch.js +141 -0
  17. package/lib/cli.js +81 -0
  18. package/lib/client.js +244 -0
  19. package/lib/component-utils.js +74 -0
  20. package/lib/config.js +121 -0
  21. package/lib/convert/ghsa.js +450 -0
  22. package/lib/convert/version-range.js +251 -0
  23. package/lib/cvss.js +113 -0
  24. package/lib/errors.js +44 -0
  25. package/lib/index.js +9 -0
  26. package/lib/preview.js +145 -0
  27. package/lib/prompt.js +170 -0
  28. package/lib/record.js +123 -0
  29. package/lib/review/index.js +78 -0
  30. package/lib/review/rules.js +433 -0
  31. package/lib/review/schema.js +99 -0
  32. package/lib/review/schemas/cve-record-format.json +1362 -0
  33. package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
  34. package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
  35. package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
  36. package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
  37. package/lib/review/schemas/tags/adp-tags.json +7 -0
  38. package/lib/review/schemas/tags/cna-tags.json +7 -0
  39. package/lib/review/schemas/tags/reference-tags.json +27 -0
  40. package/package.json +55 -4
@@ -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/batch.js ADDED
@@ -0,0 +1,141 @@
1
+ // Review and publish several CVE Records in one go.
2
+ //
3
+ // This is the "all or none" flow a release pipeline wants: every record is
4
+ // reviewed first and nothing is sent unless all of them pass. Publication
5
+ // itself is best effort per record, since MITRE has no batch operation and
6
+ // the records are independent; a failed id is reported and the rest still
7
+ // go out. `--skip-published` style reruns are supported by asking the CNA
8
+ // whether an id already has a published container before sending it.
9
+ //
10
+ // Both functions take `entries`: an array of `{ record, file? }` where
11
+ // `record` is a full v5.2 CVE Record (`containers.cna` plus `cveMetadata`)
12
+ // and `file` is only used to label output. The CLI `review` and `publish`
13
+ // commands are thin wrappers over them.
14
+
15
+ import { reviewRecord } from './review/index.js'
16
+ import { CnaOperationError } from './errors.js'
17
+
18
+ function labelOf (entry) {
19
+ return entry.record?.cveMetadata?.cveId ?? entry.file ?? '<record>'
20
+ }
21
+
22
+ /**
23
+ * Review every entry. Never stops early, so the caller sees all problems at
24
+ * once. A record without `cveMetadata.cveId` gets an extra blocker: in a
25
+ * batch there is no CLI argument to fall back on for the id.
26
+ *
27
+ * Returns `[{ file, cveId, ready, summary, issues }]` in input order.
28
+ */
29
+ export function reviewRecords (entries, { strict = true } = {}) {
30
+ return entries.map((entry) => {
31
+ const report = reviewRecord(entry.record, { strict })
32
+ const cveId = entry.record?.cveMetadata?.cveId
33
+ const issues = [...report.issues]
34
+ const summary = { ...report.summary }
35
+ if (!cveId) {
36
+ issues.unshift({
37
+ rule: 'cve-id-present',
38
+ tier: 'mitre-common',
39
+ severity: 'blocker',
40
+ path: 'cveMetadata.cveId',
41
+ message: 'Record carries no cveMetadata.cveId; batch publish needs one per record'
42
+ })
43
+ summary.blockers += 1
44
+ summary.rating = 'blocked'
45
+ }
46
+ return {
47
+ file: entry.file,
48
+ cveId,
49
+ ready: summary.blockers === 0,
50
+ summary,
51
+ issues
52
+ }
53
+ })
54
+ }
55
+
56
+ // `getCve` resolves with the container when MITRE has a published record.
57
+ // For a RESERVED id there is nothing to fetch and the CNA either fails the
58
+ // run or completes it with an empty result; neither is pinned down, so
59
+ // only positive evidence counts: an operational failure, or a result that
60
+ // does not look like a record, means "not published" and the id is sent.
61
+ // Erring towards publishing is the safe side, since the CNA rejects a
62
+ // second publish loudly while a wrong skip would exit 0 having sent
63
+ // nothing. Auth and timeout errors propagate so a broken setup does not
64
+ // look like a clean slate.
65
+ async function isPublished (client, cveId) {
66
+ let result
67
+ try {
68
+ result = await client.getCve(cveId)
69
+ } catch (error) {
70
+ if (error instanceof CnaOperationError) return false
71
+ throw error
72
+ }
73
+ const cna = result?.containers?.cna ?? result
74
+ return result?.cveMetadata?.state === 'PUBLISHED' ||
75
+ Array.isArray(cna?.descriptions) ||
76
+ Array.isArray(cna?.affected)
77
+ }
78
+
79
+ /**
80
+ * Review every entry, then publish each one through `client.publishCve`.
81
+ *
82
+ * Options:
83
+ * client — a CnaClient (required unless every record is skipped)
84
+ * strict — reviewer strictness (default true, warnings block)
85
+ * skipReview — publish without reviewing (discouraged)
86
+ * skipPublished — ask the CNA first and skip ids already published
87
+ * onProgress — called with `{ phase, cveId, file, status, ... }` as
88
+ * each step completes, for CLI progress lines
89
+ *
90
+ * Returns `{ blocked, review, results }`. When `blocked` is true the review
91
+ * found blockers, `results` is empty and nothing was sent. Otherwise
92
+ * `results` has one `{ file, cveId, status, ... }` per entry, with status
93
+ * `published`, `skipped` or `failed`, plus `correlationId` / `runId` when
94
+ * the CNA returned them and `error` for failures.
95
+ */
96
+ export async function publishRecords (entries, {
97
+ client,
98
+ strict = true,
99
+ skipReview = false,
100
+ skipPublished = false,
101
+ onProgress = () => {}
102
+ } = {}) {
103
+ const review = skipReview ? [] : reviewRecords(entries, { strict })
104
+ if (review.some((item) => !item.ready)) {
105
+ return { blocked: true, review, results: [] }
106
+ }
107
+ if (skipReview && entries.some((entry) => !entry.record?.cveMetadata?.cveId)) {
108
+ throw new Error('Every record needs cveMetadata.cveId to be published in a batch')
109
+ }
110
+
111
+ const results = []
112
+ for (const entry of entries) {
113
+ const cveId = labelOf(entry)
114
+ const base = { file: entry.file, cveId }
115
+ let result
116
+ if (skipPublished && await isPublished(client, cveId)) {
117
+ result = { ...base, status: 'skipped', reason: 'already published' }
118
+ } else {
119
+ try {
120
+ const response = await client.publishCve(cveId, entry.record.containers.cna)
121
+ result = {
122
+ ...base,
123
+ status: 'published',
124
+ correlationId: response.correlationId,
125
+ runId: response.runId
126
+ }
127
+ } catch (error) {
128
+ result = {
129
+ ...base,
130
+ status: 'failed',
131
+ error: error.message,
132
+ correlationId: error.correlationId,
133
+ runId: error.runId
134
+ }
135
+ }
136
+ }
137
+ results.push(result)
138
+ onProgress({ phase: 'publish', ...result })
139
+ }
140
+ return { blocked: false, review, results }
141
+ }
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
+ }
package/lib/client.js ADDED
@@ -0,0 +1,244 @@
1
+ // CnaClient — the transport layer for the OpenJS Foundation CNA API.
2
+ //
3
+ // The CNA API is a thin edge in front of a `workflow_dispatch`-backed GitHub
4
+ // Actions workflow. POST /dispatch returns a Worker-minted correlation_id;
5
+ // GET /runs/{id} polls the backing run and, once it completes, returns the
6
+ // operation's structured result extracted from the job log.
7
+ //
8
+ // Two layers:
9
+ //
10
+ // * High-level methods (`reserveCve`, `publishCve`, `getCve`, `listCves`,
11
+ // `updateCve`, `health`) dispatch, wait for completion, throw on failure,
12
+ // and return the operation result.
13
+ //
14
+ // * Low-level methods (`dispatch`, `poll`, `waitForCompletion`) expose the
15
+ // three primitives for callers that want to bridge polling externally
16
+ // (UIs, queues, retries, custom progress reporting).
17
+ //
18
+ // The constructor is non-allocating — no network, no fs, no subprocess. Token
19
+ // and base-url validation is deferred until the first operation (the
20
+ // self-replacing-getter pattern from node-core-utils).
21
+
22
+ import { createRequire } from 'node:module'
23
+ import {
24
+ CnaAuthError,
25
+ CnaTimeoutError,
26
+ CnaOperationError
27
+ } from './errors.js'
28
+
29
+ const require = createRequire(import.meta.url)
30
+ const pkg = require('../package.json')
31
+
32
+ // Single identity string used as the User-Agent on every outbound HTTP
33
+ // request AND as `cna.x_generator.engine` on every container we publish.
34
+ // Pinning both to the same string means a downstream reader of a CVE
35
+ // record can find the exact `openjs-cna-tools <version>` that produced
36
+ // the call.
37
+ export const USER_AGENT = `${pkg.name} ${pkg.version}`
38
+
39
+ const DEFAULT_TIMEOUT_MS = 600_000 // 10 minutes; matches NCU's choice
40
+ const DEFAULT_POLL_INTERVAL_MS = 5_000
41
+
42
+ export class CnaClient {
43
+ constructor ({ token, baseUrl, timeoutMs, pollIntervalMs, fetch: fetchImpl } = {}) {
44
+ this._token = token
45
+ this._baseUrl = baseUrl
46
+ this._timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS
47
+ this._pollIntervalMs = pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
48
+ // Injectable for tests; defaults to the runtime's global fetch.
49
+ this._fetch = fetchImpl ?? globalThis.fetch
50
+ }
51
+
52
+ // Lazy validation. Reading `client.token` once is the only time we check
53
+ // the configuration. Re-reads pay no cost; missing-token failures throw at
54
+ // the exact moment the caller is about to make a network call, which is
55
+ // also the moment the operator can act on the error.
56
+ get token () {
57
+ if (!this._token) {
58
+ throw new CnaAuthError(
59
+ 'OPENJS_CNA_TOKEN is not configured. Run:\n' +
60
+ ' openjs-cna-experimental config set -x openjs_cna_token "<bucket>:<hex-secret>"\n' +
61
+ 'or pass --token / set OPENJS_CNA_TOKEN in the environment.'
62
+ )
63
+ }
64
+ return this._token
65
+ }
66
+
67
+ get baseUrl () {
68
+ if (!this._baseUrl) {
69
+ throw new CnaAuthError(
70
+ 'OPENJS_CNA_BASE_URL is not configured. Run:\n' +
71
+ ' openjs-cna-experimental config set openjs_cna_base_url "https://cna.openjsf.org"\n' +
72
+ 'or pass --base-url / set OPENJS_CNA_BASE_URL in the environment.'
73
+ )
74
+ }
75
+ return this._baseUrl.replace(/\/$/, '')
76
+ }
77
+
78
+ _authHeaders () {
79
+ return {
80
+ Authorization: `Bearer ${this.token}`,
81
+ Accept: 'application/json',
82
+ 'User-Agent': USER_AGENT
83
+ }
84
+ }
85
+
86
+ // Unauthenticated liveness probe. Useful for `openjs-cna-experimental health` and for
87
+ // catching base-url misconfiguration without burning a workflow dispatch.
88
+ async health () {
89
+ const res = await this._fetch(`${this.baseUrl}/health`, {
90
+ headers: { 'User-Agent': USER_AGENT }
91
+ })
92
+ if (!res.ok) {
93
+ throw new CnaOperationError(`Health check failed: ${res.status} ${res.statusText}`)
94
+ }
95
+ return res.json()
96
+ }
97
+
98
+ // Low-level: POST /dispatch. Returns { correlation_id, status } from the
99
+ // Worker. Throws CnaAuthError on 401 so the CLI can map to exit 2.
100
+ async dispatch (operation, inputs = {}) {
101
+ const res = await this._fetch(`${this.baseUrl}/dispatch`, {
102
+ method: 'POST',
103
+ headers: { ...this._authHeaders(), 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ operation, inputs })
105
+ })
106
+ if (res.status === 401) {
107
+ throw new CnaAuthError(
108
+ 'The CNA API rejected the bearer token (401). Check that ' +
109
+ '`openjs_cna_token` matches the bucket your CNA coordinator provisioned.',
110
+ { status: 401 }
111
+ )
112
+ }
113
+ let body
114
+ try {
115
+ body = await res.json()
116
+ } catch (e) {
117
+ throw new CnaOperationError(
118
+ `Dispatch returned non-JSON response (${res.status}): ${e.message}`
119
+ )
120
+ }
121
+ if (body?.error) {
122
+ throw new CnaOperationError(`Dispatch failed (${operation}): ${body.error}`)
123
+ }
124
+ return body
125
+ }
126
+
127
+ // Low-level: GET /runs/{id}. Returns the run object, possibly with a
128
+ // `result` field when the workflow has completed successfully.
129
+ async poll (correlationId) {
130
+ const res = await this._fetch(`${this.baseUrl}/runs/${correlationId}`, {
131
+ headers: this._authHeaders()
132
+ })
133
+ if (res.status === 401) {
134
+ throw new CnaAuthError('CNA API rejected the bearer token (401)', { status: 401 })
135
+ }
136
+ // 502 run_result_unavailable: completed+success but the Worker could not
137
+ // extract the marker block. Surface as an operation error with the run
138
+ // metadata still attached so the CLI can suggest `openjs-cna-experimental runs <id>`.
139
+ if (res.status === 502) {
140
+ const body = await res.json().catch(() => ({}))
141
+ throw new CnaOperationError(
142
+ body?.message ?? `CNA API returned 502 for run ${correlationId}`,
143
+ {
144
+ correlationId,
145
+ runId: body?.run_id
146
+ }
147
+ )
148
+ }
149
+ return res.json()
150
+ }
151
+
152
+ // Poll until the run completes. Throws on timeout, on a non-success
153
+ // conclusion, or on AbortSignal cancellation. `onPoll` lets callers update
154
+ // a progress UI without owning the polling loop.
155
+ async waitForCompletion (correlationId, opts = {}) {
156
+ const {
157
+ timeoutMs = this._timeoutMs,
158
+ intervalMs = this._pollIntervalMs,
159
+ signal,
160
+ onPoll
161
+ } = opts
162
+ const deadline = Date.now() + timeoutMs
163
+ while (Date.now() < deadline) {
164
+ if (signal?.aborted) throw new DOMException('aborted', 'AbortError')
165
+ const run = await this.poll(correlationId)
166
+ if (typeof onPoll === 'function') onPoll(run)
167
+ if (run.status === 'completed') {
168
+ if (run.conclusion !== 'success') {
169
+ throw new CnaOperationError(
170
+ `Run ${correlationId} concluded with '${run.conclusion}'. ` +
171
+ 'Forward correlation_id and run_id to your CNA coordinator for inspection.',
172
+ {
173
+ correlationId,
174
+ runId: run.run_id,
175
+ conclusion: run.conclusion,
176
+ result: run.result
177
+ }
178
+ )
179
+ }
180
+ return run
181
+ }
182
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
183
+ }
184
+ throw new CnaTimeoutError(
185
+ `Run ${correlationId} did not complete within ${timeoutMs}ms`,
186
+ { correlationId, timeoutMs }
187
+ )
188
+ }
189
+
190
+ // Internal: dispatch + wait. Every high-level method funnels through here.
191
+ async _runAndWait (operation, inputs, opts) {
192
+ const dispatched = await this.dispatch(operation, inputs)
193
+ const run = await this.waitForCompletion(dispatched.correlation_id, opts)
194
+ return { ...run, correlationId: dispatched.correlation_id }
195
+ }
196
+
197
+ // ----- High-level operations -----
198
+
199
+ async reserveCve (opts = {}) {
200
+ const run = await this._runAndWait('reserve-cve', {}, opts)
201
+ const cveId = run.result?.cve_id
202
+ if (!cveId) {
203
+ throw new CnaOperationError(
204
+ 'reserve-cve completed but no cve_id in the result. ' +
205
+ 'Forward correlation_id and run_id to your CNA coordinator.',
206
+ {
207
+ correlationId: run.correlationId,
208
+ runId: run.run_id,
209
+ result: run.result
210
+ }
211
+ )
212
+ }
213
+ return { cveId, correlationId: run.correlationId, runId: run.run_id, run }
214
+ }
215
+
216
+ async publishCve (cveId, container, opts = {}) {
217
+ const run = await this._runAndWait('publish-cve', {
218
+ cve_id: cveId,
219
+ cnaContainer: container
220
+ }, opts)
221
+ return { correlationId: run.correlationId, runId: run.run_id, run, result: run.result }
222
+ }
223
+
224
+ async updateCve (cveId, container, opts = {}) {
225
+ const run = await this._runAndWait('update-cve', {
226
+ cve_id: cveId,
227
+ cnaContainer: container
228
+ }, opts)
229
+ return { correlationId: run.correlationId, runId: run.run_id, run, result: run.result }
230
+ }
231
+
232
+ async getCve (cveId, opts = {}) {
233
+ const run = await this._runAndWait('get-cve', { cve_id: cveId }, opts)
234
+ return run.result
235
+ }
236
+
237
+ async listCves (opts = {}) {
238
+ const run = await this._runAndWait('list-cve', {}, opts)
239
+ // Two acceptable result shapes:
240
+ // * { cve_ids: [...] } — what the workflow currently emits
241
+ // * [ ... ] — direct array, in case the API evolves
242
+ return run.result?.cve_ids ?? (Array.isArray(run.result) ? run.result : [])
243
+ }
244
+ }
@@ -0,0 +1,74 @@
1
+ // Shared bootstrap for CLI components.
2
+ //
3
+ // Every component runs the same opening sequence: parse argv (mixing global
4
+ // flags with its own), resolve credentials, build a CLI + CnaClient. This
5
+ // module factors that out so each component file in components/ can stay
6
+ // at ~30 lines.
7
+
8
+ import { parseArgs } from 'node:util'
9
+ import { CLI } from './cli.js'
10
+ import { CnaClient } from './client.js'
11
+ import { resolveCredentials } from './auth.js'
12
+
13
+ const GLOBAL_OPTIONS = {
14
+ token: { type: 'string' },
15
+ 'base-url': { type: 'string' },
16
+ timeout: { type: 'string' },
17
+ 'poll-interval': { type: 'string' },
18
+ json: { type: 'boolean' },
19
+ quiet: { type: 'boolean' },
20
+ yes: { type: 'boolean', short: 'y' },
21
+ 'no-color': { type: 'boolean' },
22
+ help: { type: 'boolean', short: 'h' }
23
+ }
24
+
25
+ export function parseComponentArgs (argv, componentOptions = {}, { allowPositionals = false } = {}) {
26
+ const options = { ...GLOBAL_OPTIONS, ...componentOptions }
27
+ let parsed
28
+ try {
29
+ parsed = parseArgs({ args: argv, options, allowPositionals })
30
+ } catch (e) {
31
+ process.stderr.write(`✖ ${e.message}\n`)
32
+ process.exit(64) // EX_USAGE
33
+ }
34
+ return parsed
35
+ }
36
+
37
+ export function buildCli ({ values } = { values: {} }) {
38
+ return new CLI(process.stderr, {
39
+ color: values['no-color'] ? false : undefined,
40
+ assumeYes: values.yes || process.env.OPENJS_CNA_YES === '1'
41
+ })
42
+ }
43
+
44
+ export function buildClient ({ values } = { values: {} }) {
45
+ const creds = resolveCredentials({
46
+ overrides: {
47
+ token: values.token,
48
+ baseUrl: values['base-url']
49
+ }
50
+ })
51
+ return new CnaClient({
52
+ token: creds.token,
53
+ baseUrl: creds.baseUrl,
54
+ timeoutMs: values.timeout ? Number(values.timeout) * 1000 : undefined,
55
+ pollIntervalMs: values['poll-interval'] ? Number(values['poll-interval']) * 1000 : undefined
56
+ })
57
+ }
58
+
59
+ // Convenience: read a file or stdin to text. Used by publish/update/review/
60
+ // convert when -f is omitted.
61
+ export async function readFileOrStdin (filePath) {
62
+ const fs = await import('node:fs/promises')
63
+ if (filePath && filePath !== '-') {
64
+ return fs.readFile(filePath, 'utf8')
65
+ }
66
+ // Read all of stdin.
67
+ return new Promise((resolve, reject) => {
68
+ let data = ''
69
+ process.stdin.setEncoding('utf8')
70
+ process.stdin.on('data', (chunk) => { data += chunk })
71
+ process.stdin.on('end', () => resolve(data))
72
+ process.stdin.on('error', reject)
73
+ })
74
+ }