@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/LICENSE +21 -0
- package/README.md +203 -2
- package/bin/openjs-cna.js +128 -0
- package/components/config.js +92 -0
- package/components/convert.js +81 -0
- package/components/get.js +35 -0
- package/components/health.js +30 -0
- package/components/list.js +36 -0
- package/components/llm-review-prompt.js +84 -0
- package/components/preview.js +42 -0
- package/components/publish.js +133 -0
- package/components/reserve.js +48 -0
- package/components/review.js +123 -0
- package/components/update.js +104 -0
- package/lib/auth.js +20 -0
- package/lib/cli.js +81 -0
- package/lib/client.js +244 -0
- package/lib/component-utils.js +74 -0
- package/lib/config.js +121 -0
- package/lib/convert/ghsa.js +493 -0
- package/lib/convert/version-range.js +251 -0
- package/lib/errors.js +44 -0
- package/lib/index.js +9 -0
- package/lib/preview.js +144 -0
- package/lib/prompt.js +165 -0
- package/lib/review/index.js +78 -0
- package/lib/review/rules.js +382 -0
- package/lib/review/schema.js +99 -0
- package/lib/review/schemas/cve-record-format.json +1362 -0
- package/lib/review/schemas/imports/cvss/cvss-v2.0.json +195 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.0.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v3.1.json +577 -0
- package/lib/review/schemas/imports/cvss/cvss-v4.0.json +784 -0
- package/lib/review/schemas/tags/adp-tags.json +7 -0
- package/lib/review/schemas/tags/cna-tags.json +7 -0
- package/lib/review/schemas/tags/reference-tags.json +27 -0
- package/package.json +52 -4
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
|
+
}
|