@dzhechkov/harness-cli 0.3.227 → 0.3.228

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-cli",
3
- "version": "0.3.227",
3
+ "version": "0.3.228",
4
4
  "description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 35 commands, 13 presets, 6 platform targets.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "@dzhechkov/skills-reverse-engineering": "^0.1.0",
56
56
  "@dzhechkov/skills-presentation-storyteller": "^0.1.0",
57
57
  "@dzhechkov/skills-website-cloner": "^0.1.0",
58
- "@dzhechkov/harness-core": "0.3.120"
58
+ "@dzhechkov/harness-core": "0.3.121"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^25.6.0",
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
8
8
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { execSync } from 'node:child_process';
@@ -101,6 +101,7 @@ import {
101
101
  buildSbom,
102
102
  resolveTrustRoot,
103
103
  decideVerifyPolicy,
104
+ generateSigningKeypair,
104
105
  decideProvenance,
105
106
  isInsideTree,
106
107
  signManifest,
@@ -3230,10 +3231,73 @@ function reportPackVerification(
3230
3231
  }
3231
3232
 
3232
3233
  function cmdSign(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3234
+ // `dz sign --init` — one-time keygen. Writes the PRIVATE key OUTSIDE the repo (default ~/.dz/keys/dz.key,
3235
+ // mode 0600) and prints the PUBLIC key for the operator to commit as keys/dz.pub. The private key never
3236
+ // touches the repo or a tarball (the hard invariant — enforced by assertKeyOutsideTree).
3237
+ if (flags.has('init')) {
3238
+ const outPath = resolve(cwd, options.get('out') ?? join(homedir(), '.dz', 'keys', 'dz.key'));
3239
+ // Resolve symlinks BEFORE the containment check: an --out (or a parent dir) that is a symlink pointing
3240
+ // INTO the repo would otherwise slip the private key inside the tree past a string-only guard — including a
3241
+ // DANGLING symlink whose target does not exist yet (existsSync follows the link and reports false, so the
3242
+ // symlink itself must be resolved via lstat/readlink, not existsSync).
3243
+ const realTarget = ((): string => {
3244
+ // walk up to the deepest path that exists as ANY entry (file, dir, or even a dangling symlink).
3245
+ let anc = outPath;
3246
+ const tail: string[] = [];
3247
+ for (;;) {
3248
+ try { lstatSync(anc); break; } catch { /* not present */ }
3249
+ const parent = dirname(anc);
3250
+ if (parent === anc) return outPath; // reached root without an existing entry
3251
+ tail.unshift(basename(anc));
3252
+ anc = parent;
3253
+ }
3254
+ let base: string;
3255
+ try {
3256
+ base = realpathSync(anc); // resolves dirs/files and any RESOLVABLE symlink chain
3257
+ } catch {
3258
+ // `anc` exists but realpath threw → a dangling symlink: resolve its immediate target by hand.
3259
+ try { base = resolve(dirname(anc), readlinkSync(anc)); } catch { return outPath; }
3260
+ }
3261
+ return tail.length ? join(base, ...tail) : base;
3262
+ })();
3263
+ try {
3264
+ assertKeyOutsideTree(realTarget, cwd);
3265
+ } catch (err) {
3266
+ write(`dz sign --init: ${(err as Error).message}`);
3267
+ write(' choose an --out path (and parent) OUTSIDE the repository (e.g. ~/.dz/keys/dz.key)');
3268
+ return 1;
3269
+ }
3270
+ if (existsSync(outPath) && !flags.has('force')) {
3271
+ write(`dz sign --init: ${outPath} already exists — refusing to overwrite (pass --force to replace)`);
3272
+ write(' overwriting a signing key orphans every pack signed with the old one.');
3273
+ return 1;
3274
+ }
3275
+ const { privateKey, publicKey } = generateSigningKeypair();
3276
+ try {
3277
+ mkdirSync(dirname(outPath), { recursive: true });
3278
+ // Unlink an existing file first: writeFileSync's `mode` is ignored when the file already exists, so a
3279
+ // pre-existing 0644 key would stay world-readable after --force. Removing it forces a fresh 0600 create.
3280
+ if (existsSync(outPath)) rmSync(outPath, { force: true });
3281
+ writeFileSync(outPath, privateKey, { mode: 0o600 });
3282
+ chmodSync(outPath, 0o600); // belt-and-suspenders: guarantee 0600 regardless of umask/prior state
3283
+ } catch (err) {
3284
+ write(`dz sign --init: could not write the private key to ${outPath}: ${(err as Error).message}`);
3285
+ return 1;
3286
+ }
3287
+ write(`dz sign --init: Ed25519 keypair generated.`);
3288
+ write(` private key → ${outPath} (mode 0600, OUTSIDE the repo — never commit it)`);
3289
+ write(` public key → commit the block below as ${TRUST_ROOT_REL}:`);
3290
+ write('');
3291
+ write(publicKey.trimEnd());
3292
+ write('');
3293
+ write(` then: dz sign --pack <dir> --key ${outPath} (sign a pack)`);
3294
+ return 0;
3295
+ }
3296
+
3233
3297
  const pack = options.get('pack');
3234
3298
  const key = options.get('key');
3235
3299
  if (!pack || !key) {
3236
- write('dz sign: --pack <dir> and --key <path> are both required');
3300
+ write('dz sign: --pack <dir> and --key <path> are both required (or: dz sign --init to generate a keypair)');
3237
3301
  write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
3238
3302
  return 1;
3239
3303
  }
@@ -3285,6 +3349,37 @@ function cmdVerifyPack(options: Map<string, string>, flags: Set<string>, cwd: st
3285
3349
  return 1;
3286
3350
  }
3287
3351
 
3352
+ /**
3353
+ * `dz sbom` — emit a CycloneDX 1.5 SBOM (Software Bill of Materials) for a pack, standalone (no signing).
3354
+ * The same inventory `dz sign` writes as `sbom.json`, available on its own for audit/procurement. Components
3355
+ * are the pack's shipped files with their SHA-256 — a file-level bill of materials for the pack.
3356
+ * --pack <dir> the pack to inventory (required)
3357
+ * --out <file> write here (default: print to stdout)
3358
+ * --json (implied) SPDX/CycloneDX is already JSON
3359
+ */
3360
+ function cmdSbom(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3361
+ const pack = options.get('pack');
3362
+ if (!pack) { write('dz sbom: --pack <dir> is required'); return 1; }
3363
+ const packDir = resolve(cwd, pack);
3364
+ if (!existsSync(packDir)) { write(`dz sbom: no such pack: ${packDir}`); return 1; }
3365
+
3366
+ const files = packFiles(packDir);
3367
+ if (files.length === 0) { write('dz sbom: the pack contains no files'); return 1; }
3368
+ const manifest = buildManifest(packDir, basename(packDir), files);
3369
+ const sbom = buildSbom(manifest);
3370
+ const out = JSON.stringify(sbom, null, 2);
3371
+
3372
+ const outOpt = options.get('out');
3373
+ if (outOpt !== undefined) {
3374
+ const outPath = resolve(cwd, outOpt);
3375
+ try { writeFileSync(outPath, out + '\n'); } catch (err) { write(`dz sbom: could not write ${outPath}: ${(err as Error).message}`); return 1; }
3376
+ write(`dz sbom: ${sbom.components.length} component(s) → ${outPath} (CycloneDX ${sbom.specVersion})`);
3377
+ return 0;
3378
+ }
3379
+ write(out);
3380
+ return 0;
3381
+ }
3382
+
3288
3383
  function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3289
3384
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
3290
3385
  // silently swallowed and flip the command into live-publish mode.
@@ -4062,33 +4157,40 @@ function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: st
4062
4157
  write(`allowlisted (accepted drift): ${r.allowlisted.map((d) => d.name).join(', ')}`);
4063
4158
  }
4064
4159
  write(`DRIFTED (unexpected, byte-differences between copies): ${r.drifted.length}`);
4160
+ let driftExit = 0;
4065
4161
  if (r.drifted.length === 0) {
4066
4162
  write('✓ no unexpected intra-monorepo skill drift');
4067
- return 0;
4068
- }
4069
- write('');
4070
- write('skill'.padEnd(34) + 'copies drift/total');
4071
- for (const d of r.drifted) {
4072
- write(
4073
- d.name.padEnd(34) +
4074
- String(d.copies).padStart(4) +
4075
- ' ' +
4076
- `${d.driftFiles}/${d.totalFiles}` +
4077
- (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''),
4078
- );
4163
+ } else {
4164
+ write('');
4165
+ write('skill'.padEnd(34) + 'copies drift/total');
4166
+ for (const d of r.drifted) {
4167
+ write(
4168
+ d.name.padEnd(34) +
4169
+ String(d.copies).padStart(4) +
4170
+ ' ' +
4171
+ `${d.driftFiles}/${d.totalFiles}` +
4172
+ (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''),
4173
+ );
4174
+ }
4175
+ write('');
4176
+ write('→ fix each: heal drift, then commit. Remediation per skill:');
4177
+ for (const d of r.drifted) {
4178
+ const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
4179
+ write(
4180
+ hasMeta
4181
+ ? ` dz sync-canonical ${d.name}`
4182
+ : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
4183
+ );
4184
+ }
4185
+ write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
4186
+ driftExit = 1; // EXIT CODE 1 = the CI gate trips
4079
4187
  }
4188
+ // Supplementary CI check: installed-pack signatures. A TAMPERED pack trips the gate (fatal); an unsigned
4189
+ // pack or a missing trust root is reported, not fatal — the same warn/block posture as `dz doctor` (the
4190
+ // primary signature gate). drift-check surfaces it so the CI drift view also flags a tampered pack.
4080
4191
  write('');
4081
- write('→ fix each: heal drift, then commit. Remediation per skill:');
4082
- for (const d of r.drifted) {
4083
- const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
4084
- write(
4085
- hasMeta
4086
- ? ` dz sync-canonical ${d.name}`
4087
- : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
4088
- );
4089
- }
4090
- write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
4091
- return 1; // EXIT CODE 1 = the CI gate trips
4192
+ const sigFatal = reportPackVerification(root, options.get('pubkey'), flags.has('require-signing'), write);
4193
+ return driftExit || sigFatal;
4092
4194
  }
4093
4195
 
4094
4196
  /**
@@ -5065,6 +5167,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
5065
5167
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
5066
5168
  case 'sign':
5067
5169
  return cmdSign(options, flags, cwd, write);
5170
+ case 'sbom':
5171
+ return cmdSbom(options, flags, cwd, write);
5068
5172
  case 'verify-pack':
5069
5173
  return cmdVerifyPack(options, flags, cwd, write);
5070
5174
  case 'setup':