@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.
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
+ }
package/lib/config.js ADDED
@@ -0,0 +1,121 @@
1
+ // Config layering for openjs-cna-tools.
2
+ //
3
+ // Precedence (highest → lowest):
4
+ // 1. CLI flag (resolved by each component; not in scope here)
5
+ // 2. Environment (OPENJS_CNA_TOKEN, OPENJS_CNA_BASE_URL, …)
6
+ // 3. Project config: ./.openjs-cna.json in CWD
7
+ // 4. Global config: ~/.config/openjs-cna/config.json (XDG-friendly)
8
+ //
9
+ // Encryption-at-rest for secrets is on the roadmap (matches NCU's `-x` flag
10
+ // shape via a `gpg` subprocess — no JS dep). Until that lands, secrets are
11
+ // stored as plaintext and `set -x` is a no-op with a warning. The merged
12
+ // config still uses property-descriptor merging so the encrypted-getter
13
+ // pattern can drop in later without a breaking change to callers.
14
+
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import os from 'node:os'
18
+
19
+ const ENV_PREFIX = 'OPENJS_CNA_'
20
+
21
+ const ENV_TO_KEY = {
22
+ OPENJS_CNA_TOKEN: 'openjs_cna_token',
23
+ OPENJS_CNA_BASE_URL: 'openjs_cna_base_url'
24
+ }
25
+
26
+ export function globalConfigPath () {
27
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
28
+ return path.join(xdg, 'openjs-cna-experimental', 'config.json')
29
+ }
30
+
31
+ export function projectConfigPath (cwd = process.cwd()) {
32
+ return path.join(cwd, '.openjs-cna.json')
33
+ }
34
+
35
+ function safeReadJson (filePath) {
36
+ try {
37
+ const raw = fs.readFileSync(filePath, 'utf8')
38
+ return JSON.parse(raw)
39
+ } catch {
40
+ return {}
41
+ }
42
+ }
43
+
44
+ function envOverrides () {
45
+ const out = {}
46
+ for (const [envName, key] of Object.entries(ENV_TO_KEY)) {
47
+ if (process.env[envName] != null) out[key] = process.env[envName]
48
+ }
49
+ // Pass-through for any future OPENJS_CNA_* env vars we haven't mapped yet.
50
+ for (const [name, value] of Object.entries(process.env)) {
51
+ if (!name.startsWith(ENV_PREFIX)) continue
52
+ if (ENV_TO_KEY[name]) continue
53
+ const key = name.slice(ENV_PREFIX.length).toLowerCase()
54
+ out[key] = value
55
+ }
56
+ return out
57
+ }
58
+
59
+ // Merge configs using property descriptors so future encrypted-getter
60
+ // properties survive the merge without being prematurely evaluated. Today
61
+ // every value is a plain string; this is a no-op cost paid for future-
62
+ // proofing.
63
+ function mergeWithDescriptors (...sources) {
64
+ const out = Object.create(null)
65
+ for (const source of sources) {
66
+ if (!source) continue
67
+ Object.defineProperties(out, Object.getOwnPropertyDescriptors(source))
68
+ }
69
+ return out
70
+ }
71
+
72
+ export function getMergedConfig ({ cwd } = {}) {
73
+ const global = safeReadJson(globalConfigPath())
74
+ const project = safeReadJson(projectConfigPath(cwd))
75
+ const env = envOverrides()
76
+ return mergeWithDescriptors(global, project, env)
77
+ }
78
+
79
+ function ensureDir (filePath) {
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
81
+ }
82
+
83
+ export function setConfigValue (key, value, { scope = 'global', cwd } = {}) {
84
+ const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
85
+ const current = safeReadJson(target)
86
+ current[key] = value
87
+ ensureDir(target)
88
+ fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
89
+ return target
90
+ }
91
+
92
+ export function getConfigValue (key, opts = {}) {
93
+ const merged = getMergedConfig(opts)
94
+ return merged[key]
95
+ }
96
+
97
+ export function deleteConfigValue (key, { scope = 'global', cwd } = {}) {
98
+ const target = scope === 'project' ? projectConfigPath(cwd) : globalConfigPath()
99
+ const current = safeReadJson(target)
100
+ if (!(key in current)) return false
101
+ delete current[key]
102
+ ensureDir(target)
103
+ fs.writeFileSync(target, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 })
104
+ return true
105
+ }
106
+
107
+ // Returns a redacted view of the merged config: secret-looking keys
108
+ // (anything containing 'token' or 'secret') are replaced with a fixed
109
+ // placeholder so `openjs-cna-experimental config list` can be piped without leaking.
110
+ export function listConfig (opts = {}) {
111
+ const merged = getMergedConfig(opts)
112
+ const out = {}
113
+ for (const [key, value] of Object.entries(merged)) {
114
+ if (/token|secret|password/i.test(key)) {
115
+ out[key] = '<redacted>'
116
+ } else {
117
+ out[key] = value
118
+ }
119
+ }
120
+ return out
121
+ }