harness-everything 0.19.1 → 0.20.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,239 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Generate a CycloneDX SBOM from package.json and package-lock.json.
6
+ *
7
+ * The file is intentionally generated during semantic-release rather than
8
+ * committed on every change. npm packs the generated root-level file, so the
9
+ * published package carries the exact release version and dependency graph.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const crypto = require('crypto');
14
+ const path = require('path');
15
+
16
+ const ROOT = path.resolve(__dirname, '..');
17
+ const DEFAULT_OUTPUT = path.join(ROOT, 'sbom.cdx.json');
18
+ const PACKAGE_NAME = 'harness-everything';
19
+ const CYCLONEDX_VERSION = '1.7';
20
+ const CYCLONEDX_SCHEMA_URL = `https://cyclonedx.org/schema/bom-${CYCLONEDX_VERSION}.schema.json`;
21
+
22
+ function readJson(file) {
23
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ }
25
+
26
+ function npmPurl(name, version) {
27
+ const purlName = String(name).startsWith('@') ? `%40${String(name).slice(1)}` : String(name);
28
+ return `pkg:npm/${purlName}@${encodeURIComponent(version)}`;
29
+ }
30
+
31
+ function serialNumberFor(name, version) {
32
+ // UUID v5-shaped, deterministic serial number: release SBOMs remain
33
+ // comparable without introducing a random value into the generated file.
34
+ const namespace = Buffer.from('6ba7b8109dad11d180b400c04fd430c8', 'hex');
35
+ const digest = crypto.createHash('sha1').update(namespace).update(`${name}@${version}`).digest();
36
+ digest[6] = (digest[6] & 0x0f) | 0x50;
37
+ digest[8] = (digest[8] & 0x3f) | 0x80;
38
+ const hex = digest.toString('hex').slice(0, 32);
39
+ return `urn:uuid:${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
40
+ }
41
+
42
+ function integrityHash(integrity) {
43
+ if (typeof integrity !== 'string') return null;
44
+ const match = integrity.match(/^(sha\d+)-(.+)$/i);
45
+ if (!match) return null;
46
+ const algorithm = match[1].toUpperCase().replace(/^SHA(\d+)$/, 'SHA-$1');
47
+ return { alg: algorithm, content: match[2] };
48
+ }
49
+
50
+ function packageNameFromKey(key, record) {
51
+ if (record && record.name) return record.name;
52
+ const marker = '/node_modules/';
53
+ const last = key.lastIndexOf(marker);
54
+ const name = last === -1 ? key.replace(/^node_modules\//, '') : key.slice(last + marker.length);
55
+ if (name.startsWith('@')) return name;
56
+ return name.split('/node_modules/').pop();
57
+ }
58
+
59
+ function componentFor(key, record) {
60
+ const name = packageNameFromKey(key, record);
61
+ if (!record || !record.version || !name) return null;
62
+ const purl = npmPurl(name, record.version);
63
+ const component = {
64
+ type: 'library',
65
+ 'bom-ref': purl,
66
+ name,
67
+ version: record.version,
68
+ scope: record.dev ? 'excluded' : (record.optional ? 'optional' : 'required'),
69
+ purl
70
+ };
71
+ const hash = integrityHash(record.integrity);
72
+ if (hash) component.hashes = [hash];
73
+ return component;
74
+ }
75
+
76
+ function resolveDependencyKey(packages, parentKey, dependencyName) {
77
+ let current = parentKey;
78
+ while (true) {
79
+ const candidate = current
80
+ ? `${current}/node_modules/${dependencyName}`
81
+ : `node_modules/${dependencyName}`;
82
+ if (packages[candidate] && packages[candidate].version) return candidate;
83
+ const marker = '/node_modules/';
84
+ const markerIndex = current.lastIndexOf(marker);
85
+ if (markerIndex !== -1) current = current.slice(0, markerIndex);
86
+ else if (current.startsWith('node_modules/')) current = '';
87
+ else break;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function directDependencyNames(packageJson) {
93
+ return [...new Set([
94
+ ...Object.keys(packageJson.dependencies || {}),
95
+ ...Object.keys(packageJson.optionalDependencies || {}),
96
+ ...Object.keys(packageJson.devDependencies || {})
97
+ ])].sort();
98
+ }
99
+
100
+ function buildBom(root = ROOT, options = {}) {
101
+ const packageJson = readJson(path.join(root, 'package.json'));
102
+ const lock = readJson(path.join(root, 'package-lock.json'));
103
+ const version = options.version || packageJson.version;
104
+ const name = packageJson.name || PACKAGE_NAME;
105
+ const rootPurl = npmPurl(name, version);
106
+ const packages = lock.packages || {};
107
+ const components = [];
108
+ const componentByKey = new Map();
109
+
110
+ for (const [key, record] of Object.entries(packages)) {
111
+ if (!key) continue;
112
+ const component = componentFor(key, record);
113
+ if (!component) continue;
114
+ components.push(component);
115
+ componentByKey.set(key, component);
116
+ }
117
+
118
+ components.sort((left, right) => left.purl.localeCompare(right.purl));
119
+ const dependencyRecords = [];
120
+ const dependencySources = [['', packages[''] || {}]];
121
+ for (const [key, record] of Object.entries(packages)) {
122
+ if (key) dependencySources.push([key, record]);
123
+ }
124
+ for (const [key, record] of dependencySources) {
125
+ const sourceRef = key ? componentByKey.get(key)?.['bom-ref'] : rootPurl;
126
+ if (!sourceRef) continue;
127
+ const dependencyNames = key ? Object.keys(record.dependencies || {}) : directDependencyNames(packageJson);
128
+ const dependsOn = dependencyNames
129
+ .map(dependencyName => resolveDependencyKey(packages, key, dependencyName))
130
+ .map(dependencyKey => dependencyKey && componentByKey.get(dependencyKey)?.['bom-ref'])
131
+ .filter(Boolean)
132
+ .sort();
133
+ if (dependsOn.length) dependencyRecords.push({ ref: sourceRef, dependsOn: [...new Set(dependsOn)] });
134
+ }
135
+ dependencyRecords.sort((left, right) => left.ref.localeCompare(right.ref));
136
+
137
+ const bom = {
138
+ $schema: CYCLONEDX_SCHEMA_URL,
139
+ bomFormat: 'CycloneDX',
140
+ specVersion: CYCLONEDX_VERSION,
141
+ serialNumber: options.serialNumber || serialNumberFor(name, version),
142
+ version: 1,
143
+ metadata: {
144
+ timestamp: options.timestamp || new Date().toISOString(),
145
+ tools: [{ vendor: 'Harness Everything', name: 'generate-sbom.js', version }],
146
+ component: {
147
+ type: 'application',
148
+ 'bom-ref': rootPurl,
149
+ name,
150
+ version,
151
+ purl: rootPurl
152
+ }
153
+ },
154
+ components,
155
+ dependencies: dependencyRecords
156
+ };
157
+ return bom;
158
+ }
159
+
160
+ function validateBom(bom, expectedVersion) {
161
+ if (!bom || bom.bomFormat !== 'CycloneDX') throw new Error('SBOM must use the CycloneDX format');
162
+ if (bom.$schema !== CYCLONEDX_SCHEMA_URL) throw new Error(`SBOM schema must be ${CYCLONEDX_SCHEMA_URL}`);
163
+ if (bom.specVersion !== CYCLONEDX_VERSION) throw new Error(`SBOM specVersion must be ${CYCLONEDX_VERSION}, got ${bom.specVersion}`);
164
+ if (!Number.isInteger(bom.version) || bom.version < 1) throw new Error('SBOM document version is missing');
165
+ if (!/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(bom.serialNumber || ''))) {
166
+ throw new Error('SBOM serialNumber must be a UUID URN');
167
+ }
168
+ const component = bom.metadata && bom.metadata.component;
169
+ if (!component || component.name !== PACKAGE_NAME) throw new Error('SBOM metadata component must be harness-everything');
170
+ if (!component.version || (expectedVersion && component.version !== expectedVersion)) {
171
+ throw new Error(`SBOM metadata version drift: expected ${expectedVersion || 'a version'}, got ${component.version || 'missing'}`);
172
+ }
173
+ if (!Array.isArray(bom.components)) throw new Error('SBOM components array is missing');
174
+ for (const entry of bom.components) {
175
+ if (!entry.name || !entry.version || !entry.purl || !entry['bom-ref']) throw new Error('SBOM component is incomplete');
176
+ }
177
+ if (!Array.isArray(bom.dependencies)) throw new Error('SBOM dependencies array is missing');
178
+ return true;
179
+ }
180
+
181
+ function writeBom(output, root = ROOT, options = {}) {
182
+ const bom = buildBom(root, options);
183
+ validateBom(bom, options.version);
184
+ fs.writeFileSync(output, `${JSON.stringify(bom, null, 2)}\n`, 'utf8');
185
+ return bom;
186
+ }
187
+
188
+ function parseArgs(argv) {
189
+ const options = { output: DEFAULT_OUTPUT, validate: null, version: null };
190
+ for (let index = 0; index < argv.length; index++) {
191
+ const arg = argv[index];
192
+ if (arg === '--output') options.output = path.resolve(argv[++index] || '');
193
+ else if (arg === '--validate') options.validate = path.resolve(argv[++index] || '');
194
+ else if (arg === '--version') options.version = argv[++index] || null;
195
+ else if (arg === '--help' || arg === '-h') options.help = true;
196
+ else throw new Error(`Unknown option: ${arg}`);
197
+ }
198
+ return options;
199
+ }
200
+
201
+ function main(argv = process.argv.slice(2)) {
202
+ const options = parseArgs(argv);
203
+ if (options.help) {
204
+ console.log('Usage: node scripts/generate-sbom.js [--output <file>] [--version <x.y.z>]');
205
+ console.log(' node scripts/generate-sbom.js --validate <file> [--version <x.y.z>]');
206
+ return 0;
207
+ }
208
+ if (options.validate) {
209
+ const bom = readJson(options.validate);
210
+ validateBom(bom, options.version);
211
+ console.log(`Validated CycloneDX SBOM ${options.validate} (${bom.components.length} components).`);
212
+ return 0;
213
+ }
214
+ const bom = writeBom(options.output, ROOT, { version: options.version });
215
+ console.log(`Generated CycloneDX SBOM ${options.output} (${bom.components.length} components).`);
216
+ return 0;
217
+ }
218
+
219
+ if (require.main === module) {
220
+ try {
221
+ process.exitCode = main();
222
+ } catch (error) {
223
+ console.error(`[Error] ${error.message}`);
224
+ process.exitCode = 1;
225
+ }
226
+ }
227
+
228
+ module.exports = {
229
+ buildBom,
230
+ componentFor,
231
+ CYCLONEDX_SCHEMA_URL,
232
+ CYCLONEDX_VERSION,
233
+ directDependencyNames,
234
+ integrityHash,
235
+ main,
236
+ npmPurl,
237
+ validateBom,
238
+ writeBom
239
+ };
@@ -162,14 +162,14 @@ function createContext(options = {}) {
162
162
  return context;
163
163
  }
164
164
 
165
- function invoke(context, command, args, { mutate = false, label = command } = {}) {
165
+ function invoke(context, command, args, { mutate = false, label = command, warnOnFailure = true } = {}) {
166
166
  const rendered = commandLine(command, args);
167
167
  if (mutate && context.dryRun) {
168
168
  context.log(`[dry-run] ${rendered}`);
169
169
  return { status: 0, stdout: '', stderr: '', dryRun: true };
170
170
  }
171
171
  const result = context.runner(command, args, { label });
172
- if (!result || result.status !== 0) {
172
+ if (warnOnFailure && (!result || result.status !== 0)) {
173
173
  context.warn(`[failed] ${rendered}\n ${resultError(result)}`);
174
174
  }
175
175
  return result || { status: 1, stdout: '', stderr: 'runner returned no result' };
@@ -179,6 +179,39 @@ function query(context, command, args) {
179
179
  return context.runner(command, args, { query: true }) || { status: 1, stdout: '', stderr: 'runner returned no result' };
180
180
  }
181
181
 
182
+ function hasUnknownOption(result, option) {
183
+ const output = [result && result.stderr, result && result.stdout, result && result.error && result.error.message]
184
+ .map(value => String(value || ''))
185
+ .join('\n');
186
+ return new RegExp(`unknown option\\s+['"]?${escapeRegExp(option)}['"]?`, 'i').test(output);
187
+ }
188
+
189
+ function runClaudePluginMutation(context, action, pluginId) {
190
+ const baseArgs = [
191
+ 'plugin', action, pluginId,
192
+ '--scope', 'user'
193
+ ];
194
+ const currentArgs = [...baseArgs, '--yes', '--json'];
195
+ const compatibleArgs = [...baseArgs, '--json'];
196
+ const current = invoke(context, 'claude', currentArgs, {
197
+ mutate: true,
198
+ label: `claude plugin ${action}`,
199
+ warnOnFailure: false
200
+ });
201
+ if (current.status === 0) return current;
202
+
203
+ if (!hasUnknownOption(current, '--yes')) {
204
+ context.warn(`[failed] ${commandLine('claude', currentArgs)}\n ${resultError(current)}`);
205
+ return current;
206
+ }
207
+
208
+ context.warn(`[notice] Claude CLI does not support --yes; retrying ${action} without that option.`);
209
+ return invoke(context, 'claude', compatibleArgs, {
210
+ mutate: true,
211
+ label: `claude plugin ${action} compatibility retry`
212
+ });
213
+ }
214
+
182
215
  function listState(context, { command, jsonArgs, plainArgs, detectJson, detectText }) {
183
216
  const jsonResult = query(context, command, jsonArgs);
184
217
  if (jsonResult.status === 0) {
@@ -314,10 +347,11 @@ function syncClaude(context) {
314
347
  if (!refreshed.ok) return { host: 'claude', status: 'failed', error: refreshed.error };
315
348
 
316
349
  const action = installed.value ? 'update' : 'install';
317
- const result = invoke(context, 'claude', [
318
- 'plugin', action, `${context.plugin}@${context.marketplace}`,
319
- '--scope', 'user', '--yes', '--json'
320
- ], { mutate: true, label: `claude plugin ${action}` });
350
+ const result = runClaudePluginMutation(
351
+ context,
352
+ action,
353
+ `${context.plugin}@${context.marketplace}`
354
+ );
321
355
  if (result.status !== 0) return { host: 'claude', status: 'failed', error: resultError(result) };
322
356
 
323
357
  const verification = verifyPlugin(context, 'Claude', queryClaudePlugin);