dw-kit 1.3.4 → 1.3.5

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,427 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { banner, log, info, warn, ok, err } from '../lib/ui.mjs';
5
+ import chalk from 'chalk';
6
+ import {
7
+ loadSnapshot,
8
+ snapshotInfo,
9
+ syncSnapshotForProject,
10
+ isStale,
11
+ isSchemaCompatible,
12
+ fetchOsvByName,
13
+ } from '../lib/sc-sync.mjs';
14
+ import {
15
+ scanProject,
16
+ severityRank,
17
+ worstSeverity,
18
+ parsePackageJson,
19
+ findPackageJson,
20
+ matchPackageByName,
21
+ matchNamespaceFixture,
22
+ } from '../lib/sc-scanner.mjs';
23
+ import { logEvent } from '../lib/telemetry.mjs';
24
+
25
+ const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
26
+ const NAMESPACE_FIXTURE_REL = '.dw/security/ioc-namespaces.json';
27
+
28
+ export async function securityScanCommand(opts) {
29
+ const rootDir = process.cwd();
30
+ const mode = pickMode(opts);
31
+
32
+ if (mode === 'pre-install') {
33
+ return runPreInstallMode(rootDir, opts);
34
+ }
35
+
36
+ if (opts.json) {
37
+ return runJsonMode(rootDir, mode, opts);
38
+ }
39
+
40
+ banner('dw-kit Supply-Chain Scan');
41
+
42
+ if (mode === 'update-db' || opts.updateDb) {
43
+ info('Fetching fresh advisory snapshot from OSV.dev...');
44
+ try {
45
+ const start = Date.now();
46
+ const res = await syncSnapshotForProject(rootDir);
47
+ const elapsed = Date.now() - start;
48
+ ok(`Snapshot updated — ${res.advisoryCount} advisories for ${res.packageCount} packages (${elapsed}ms)`);
49
+ logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount, latency_ms: elapsed }, rootDir);
50
+ } catch (e) {
51
+ err(`Sync failed: ${e.message}`);
52
+ if (e.code === 'NO_LOCKFILE') warn('Run `npm install` first to create package-lock.json.');
53
+ process.exit(2);
54
+ }
55
+ if (mode === 'update-db') {
56
+ log('');
57
+ info('Snapshot ready. Run `dw security-scan` to scan project.');
58
+ return;
59
+ }
60
+ log('');
61
+ }
62
+
63
+ const info_ = snapshotInfo(rootDir);
64
+ if (!info_.exists) {
65
+ warn('No advisory snapshot found.');
66
+ log('Run `dw security-scan --update-db` to fetch from OSV.dev.');
67
+ log('Quick mode requires an existing snapshot.');
68
+ process.exit(1);
69
+ }
70
+
71
+ const ageDays = info_.age_days;
72
+ const ageLabel = ageDays === Infinity ? 'unknown' : `${ageDays.toFixed(1)} days old`;
73
+ log(chalk.bold('Snapshot'));
74
+ log(` Source : ${info_.source} (${info_.ecosystem})`);
75
+ log(` Fetched : ${info_.fetched_at || '?'} (${ageLabel})`);
76
+ log(` Advisories : ${info_.advisory_count} Packages scanned previously: ${info_.package_count}`);
77
+ if (!info_.schema_compatible) {
78
+ err(` Schema mismatch — expected 1.0, got ${info_.schema_version || 'unknown'}`);
79
+ log(' Run `dw security-scan --update-db` to refresh.');
80
+ process.exit(2);
81
+ }
82
+ if (info_.stale) {
83
+ warn(` Snapshot is stale (>7 days). Run \`dw security-scan --update-db\` to refresh.`);
84
+ }
85
+
86
+ log('');
87
+ log(chalk.bold('Scanning project lockfile...'));
88
+ const snap = loadSnapshot(rootDir);
89
+ const start = Date.now();
90
+ const result = scanProject(rootDir, snap);
91
+ const elapsed = Date.now() - start;
92
+
93
+ if (result.error === 'no_lockfile') {
94
+ err('No lockfile found (expected package-lock.json).');
95
+ process.exit(1);
96
+ }
97
+ if (result.error === 'no_snapshot') {
98
+ err('Snapshot data unavailable.');
99
+ process.exit(1);
100
+ }
101
+
102
+ log(` Lockfile : ${result.lockfile}`);
103
+ log(` Packages scanned : ${result.packages_scanned}`);
104
+ log(` Elapsed : ${elapsed}ms`);
105
+ log('');
106
+
107
+ if (result.matches.length === 0) {
108
+ ok('No advisory matches — clean.');
109
+ logEvent({ event: 'sc_guard', action: 'scan_run', matches: 0, packages: result.packages_scanned, outcome: 'clean', latency_ms: elapsed }, rootDir);
110
+ log('');
111
+ advisoryFooter();
112
+ return;
113
+ }
114
+
115
+ log(chalk.bold(`Matches (${result.matches.length})`));
116
+ const sorted = result.matches.slice().sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
117
+ for (const m of sorted) {
118
+ const tag = severityTag(m.severity);
119
+ log(` ${tag} ${m.package}@${m.version}`);
120
+ if (m.summary) log(chalk.dim(` ${m.summary}`));
121
+ log(chalk.dim(` advisory: ${m.advisory_id}`));
122
+ if (m.fix_versions.length) log(chalk.dim(` fix: ${m.fix_versions.join(', ')}`));
123
+ }
124
+ log('');
125
+
126
+ const worst = worstSeverity(result.matches);
127
+ const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
128
+ const exitCode = blockCount > 0 ? 2 : 1;
129
+
130
+ logEvent(
131
+ {
132
+ event: 'sc_guard',
133
+ action: blockCount > 0 ? 'block' : 'allow',
134
+ matches: result.matches.length,
135
+ block_count: blockCount,
136
+ worst_severity: worst,
137
+ packages: result.packages_scanned,
138
+ latency_ms: elapsed,
139
+ snapshot_age_days: ageDays,
140
+ },
141
+ rootDir,
142
+ );
143
+
144
+ if (blockCount > 0) {
145
+ err(`${blockCount} HIGH+ severity match(es) — review before merging lockfile changes.`);
146
+ } else {
147
+ warn(`${result.matches.length} low/medium match(es) — review recommended.`);
148
+ }
149
+ log('');
150
+ advisoryFooter();
151
+ process.exit(exitCode);
152
+ }
153
+
154
+ function pickMode(opts) {
155
+ if (opts.preInstall) return 'pre-install';
156
+ if (opts.updateDb) return 'update-db';
157
+ return 'scan';
158
+ }
159
+
160
+ function loadNamespaceFixture(rootDir) {
161
+ // Prefer project-local fixture, fall back to toolkit-bundled
162
+ const candidates = [
163
+ join(rootDir, NAMESPACE_FIXTURE_REL),
164
+ join(TOOLKIT_ROOT, NAMESPACE_FIXTURE_REL),
165
+ ];
166
+ for (const p of candidates) {
167
+ if (existsSync(p)) {
168
+ try {
169
+ return { fixture: JSON.parse(readFileSync(p, 'utf-8')), path: p };
170
+ } catch {
171
+ // skip malformed
172
+ }
173
+ }
174
+ }
175
+ return { fixture: null, path: null };
176
+ }
177
+
178
+ async function runPreInstallMode(rootDir, opts) {
179
+ const useJson = !!opts.json;
180
+ const out = { mode: 'pre-install', ok: true, network_ok: false, packages: 0, osv_hits: [], namespace_hits: [] };
181
+
182
+ const pkgPath = findPackageJson(rootDir);
183
+ if (!pkgPath) {
184
+ if (useJson) {
185
+ out.ok = false;
186
+ out.error = { code: 'NO_PACKAGE_JSON', message: 'No package.json in current directory' };
187
+ process.stdout.write(JSON.stringify(out) + '\n');
188
+ process.exit(1);
189
+ }
190
+ err('No package.json in current directory.');
191
+ process.exit(1);
192
+ }
193
+
194
+ let packages;
195
+ try {
196
+ packages = parsePackageJson(pkgPath);
197
+ } catch (e) {
198
+ if (useJson) {
199
+ out.ok = false;
200
+ out.error = { code: 'PARSE_FAILED', message: e.message };
201
+ process.stdout.write(JSON.stringify(out) + '\n');
202
+ process.exit(1);
203
+ }
204
+ err(`Failed to parse package.json: ${e.message}`);
205
+ process.exit(1);
206
+ }
207
+
208
+ out.packages = packages.size;
209
+
210
+ if (!useJson) {
211
+ banner('dw-kit Pre-Install Scan');
212
+ log(chalk.bold('package.json'));
213
+ log(` Path : ${pkgPath}`);
214
+ log(` Declared : ${packages.size} packages (across deps/devDeps/peerDeps/optionalDeps)`);
215
+ log('');
216
+ }
217
+
218
+ // 1. Namespace fixture (offline, fast)
219
+ const fixtureBundle = loadNamespaceFixture(rootDir);
220
+ if (fixtureBundle.fixture) {
221
+ const hits = matchNamespaceFixture(packages, fixtureBundle.fixture);
222
+ out.namespace_hits = hits;
223
+ out.fixture_path = fixtureBundle.path;
224
+ if (!useJson) {
225
+ log(chalk.bold('Namespace IoC fixture'));
226
+ log(` Source : ${fixtureBundle.path}`);
227
+ log(` Entries : ${(fixtureBundle.fixture.namespaces || []).length} active namespace pattern(s)`);
228
+ if (hits.length === 0) {
229
+ ok(' No matches against active namespace patterns');
230
+ } else {
231
+ log('');
232
+ for (const h of hits) {
233
+ log(` ${severityTag(h.severity)} ${h.package} matches pattern "${h.namespace_pattern}"`);
234
+ if (h.reason) log(chalk.dim(` ${h.reason}`));
235
+ if (h.guidance) log(chalk.yellow(` guidance: ${h.guidance}`));
236
+ if (h.advisory_url) log(chalk.dim(` advisory: ${h.advisory_url}`));
237
+ }
238
+ }
239
+ log('');
240
+ }
241
+ }
242
+
243
+ // 2. OSV.dev name-only queries (network, optional)
244
+ if (!opts.offline) {
245
+ if (!useJson) log(chalk.bold('OSV.dev name-only query (per declared package)'));
246
+ const osvErrors = [];
247
+ let queried = 0;
248
+ for (const [name] of packages) {
249
+ try {
250
+ const result = await fetchOsvByName(name, 'npm', { timeoutMs: 5000 });
251
+ queried++;
252
+ const vulns = (result && result.vulns) || [];
253
+ if (vulns.length > 0) {
254
+ const hits = matchPackageByName(name, vulns);
255
+ for (const h of hits) {
256
+ out.osv_hits.push({ package: name, declared_range: packages.get(name), ...h });
257
+ }
258
+ }
259
+ } catch (e) {
260
+ osvErrors.push({ package: name, error: e.message });
261
+ }
262
+ }
263
+ out.network_ok = queried > 0;
264
+ out.osv_queried = queried;
265
+ out.osv_errors = osvErrors;
266
+
267
+ if (!useJson) {
268
+ log(` Queried : ${queried}/${packages.size} packages (${osvErrors.length} errors)`);
269
+ if (out.osv_hits.length === 0) {
270
+ ok(' No active advisories for declared packages');
271
+ } else {
272
+ const grouped = groupBy(out.osv_hits, 'package');
273
+ log('');
274
+ for (const [pkg, hits] of Object.entries(grouped)) {
275
+ const worst = hits.reduce(
276
+ (acc, h) => (severityRank(h.severity) > severityRank(acc) ? h.severity : acc),
277
+ 'unknown',
278
+ );
279
+ log(` ${severityTag(worst)} ${pkg}@${packages.get(pkg)} — ${hits.length} active advisory(s)`);
280
+ for (const h of hits.slice(0, 3)) {
281
+ log(chalk.dim(` ${h.advisory_id}: ${h.summary || '(no summary)'}`));
282
+ if (h.fix_versions && h.fix_versions.length) {
283
+ log(chalk.dim(` fix: ${h.fix_versions.slice(0, 3).join(', ')}`));
284
+ }
285
+ }
286
+ if (hits.length > 3) log(chalk.dim(` ... and ${hits.length - 3} more`));
287
+ }
288
+ }
289
+ log('');
290
+ }
291
+ } else if (!useJson) {
292
+ log(chalk.dim(' OSV.dev query: skipped (--offline)'));
293
+ log('');
294
+ }
295
+
296
+ // Summarize
297
+ const namespaceCrit = out.namespace_hits.length;
298
+ const osvHigh = out.osv_hits.filter((h) => severityRank(h.severity) >= severityRank('high')).length;
299
+ const exitCode = namespaceCrit > 0 ? 2 : (osvHigh > 0 ? 2 : (out.osv_hits.length > 0 ? 1 : 0));
300
+
301
+ logEvent(
302
+ {
303
+ event: 'sc_guard',
304
+ action: exitCode >= 2 ? 'block' : (exitCode === 1 ? 'allow' : 'scan_run'),
305
+ sub_mode: 'pre-install',
306
+ packages: packages.size,
307
+ namespace_hits: namespaceCrit,
308
+ osv_hits: out.osv_hits.length,
309
+ osv_high: osvHigh,
310
+ network_ok: out.network_ok,
311
+ },
312
+ rootDir,
313
+ );
314
+
315
+ if (useJson) {
316
+ out.summary = { namespace_hits: namespaceCrit, osv_hits: out.osv_hits.length, osv_high: osvHigh, exit_code: exitCode };
317
+ process.stdout.write(JSON.stringify(out) + '\n');
318
+ process.exit(exitCode);
319
+ }
320
+
321
+ log(chalk.bold('Summary'));
322
+ if (exitCode === 0) {
323
+ ok(`Clean — ${packages.size} packages scanned, no matches`);
324
+ } else if (exitCode === 1) {
325
+ warn(`${out.osv_hits.length} low/medium advisor(y/ies) found — review recommended`);
326
+ } else {
327
+ err(`${namespaceCrit} namespace match(es) + ${osvHigh} HIGH+ advisory(s) — rotate credentials if already installed`);
328
+ }
329
+ log('');
330
+ advisoryFooter();
331
+ process.exit(exitCode);
332
+ }
333
+
334
+ function groupBy(arr, key) {
335
+ const out = {};
336
+ for (const item of arr) {
337
+ const k = item[key];
338
+ if (!out[k]) out[k] = [];
339
+ out[k].push(item);
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function severityTag(label) {
345
+ switch (label) {
346
+ case 'critical':
347
+ return chalk.red.bold('[CRITICAL]'.padEnd(12));
348
+ case 'high':
349
+ return chalk.red('[HIGH] '.padEnd(12));
350
+ case 'medium':
351
+ return chalk.yellow('[MEDIUM] '.padEnd(12));
352
+ case 'low':
353
+ return chalk.blue('[LOW] '.padEnd(12));
354
+ default:
355
+ return chalk.dim('[?] '.padEnd(12));
356
+ }
357
+ }
358
+
359
+ function advisoryFooter() {
360
+ info('ADVISORY OUTPUT — NOT a decision rule.');
361
+ log(' Threshold matrix in v14-evaluation-protocol.md is authoritative.');
362
+ log(' Use this scan as evidence-supplement only.');
363
+ log(' Public sunset commitment: 2026-08-12 (see ADR-0005).');
364
+ }
365
+
366
+ async function runJsonMode(rootDir, mode, opts) {
367
+ let out = { mode, ok: true };
368
+
369
+ if (mode === 'update-db' || opts.updateDb) {
370
+ try {
371
+ const start = Date.now();
372
+ const res = await syncSnapshotForProject(rootDir);
373
+ out.sync = { advisory_count: res.advisoryCount, package_count: res.packageCount, latency_ms: Date.now() - start };
374
+ logEvent({ event: 'sc_guard', action: 'sync', advisories: res.advisoryCount, packages: res.packageCount }, rootDir);
375
+ } catch (e) {
376
+ out.ok = false;
377
+ out.error = { code: e.code || 'SYNC_FAILED', message: e.message };
378
+ process.stdout.write(JSON.stringify(out) + '\n');
379
+ process.exit(2);
380
+ }
381
+ if (mode === 'update-db') {
382
+ process.stdout.write(JSON.stringify(out) + '\n');
383
+ return;
384
+ }
385
+ }
386
+
387
+ const info_ = snapshotInfo(rootDir);
388
+ out.snapshot = info_;
389
+ if (!info_.exists) {
390
+ out.ok = false;
391
+ out.error = { code: 'NO_SNAPSHOT', message: 'Run `dw security-scan --update-db`' };
392
+ process.stdout.write(JSON.stringify(out) + '\n');
393
+ process.exit(1);
394
+ }
395
+
396
+ const snap = loadSnapshot(rootDir);
397
+ const start = Date.now();
398
+ const result = scanProject(rootDir, snap);
399
+ out.scan = { ...result, elapsed_ms: Date.now() - start };
400
+
401
+ if (result.error) {
402
+ out.ok = false;
403
+ out.error = { code: result.error.toUpperCase(), message: result.error };
404
+ process.stdout.write(JSON.stringify(out) + '\n');
405
+ process.exit(1);
406
+ }
407
+
408
+ const worst = worstSeverity(result.matches);
409
+ const blockCount = result.matches.filter((m) => severityRank(m.severity) >= severityRank('high')).length;
410
+ out.summary = { matches: result.matches.length, block_count: blockCount, worst_severity: worst };
411
+
412
+ logEvent(
413
+ {
414
+ event: 'sc_guard',
415
+ action: blockCount > 0 ? 'block' : (result.matches.length > 0 ? 'allow' : 'scan_run'),
416
+ matches: result.matches.length,
417
+ block_count: blockCount,
418
+ worst_severity: worst,
419
+ packages: result.packages_scanned,
420
+ snapshot_age_days: info_.age_days,
421
+ },
422
+ rootDir,
423
+ );
424
+
425
+ process.stdout.write(JSON.stringify(out) + '\n');
426
+ process.exit(blockCount > 0 ? 2 : result.matches.length > 0 ? 1 : 0);
427
+ }
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { header, ok, warn, err, info, log, dry } from '../lib/ui.mjs';
5
5
  import { loadConfig, writeConfig, getToolkitVersions } from '../lib/config.mjs';
6
6
  import { diffDirs, copyDir, copyFile } from '../lib/copy.mjs';
7
+ import { ensureDwGitignore, ensureClaudeGitignore } from '../lib/gitignore.mjs';
7
8
 
8
9
  const TOOLKIT_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..', '..');
9
10
 
@@ -57,6 +58,7 @@ export async function upgradeCommand(opts) {
57
58
 
58
59
  upgradeScripts(projectDir, opts);
59
60
  upgradeConfigSchema(projectDir, opts);
61
+ upgradeScopedGitignores(projectDir, opts);
60
62
 
61
63
  if (!opts.dryRun && totalChanges > 0) {
62
64
  updateVersionTracking(configPath, projectConfig, toolkitVersions);
@@ -169,6 +171,7 @@ function mergeSettingsJson(projectDir, opts) {
169
171
 
170
172
  if (opts.dryRun) {
171
173
  dry('merge .claude/settings.json');
174
+ dry('post-merge: wire supply-chain-scan.sh hook if missing (idempotent)');
172
175
  return;
173
176
  }
174
177
 
@@ -181,6 +184,57 @@ function mergeSettingsJson(projectDir, opts) {
181
184
  } catch (e) {
182
185
  warn(`settings.json merge failed: ${e.message}`);
183
186
  }
187
+
188
+ // Post-merge: explicitly install supply-chain-scan hook (ADR-0005, idempotent).
189
+ // deepMerge replaces arrays, so user's PostToolUse array may not contain the new
190
+ // hook entry. We must add it explicitly. Respects existing wiring.
191
+ installSupplyChainHookOnUpgrade(projectDir, opts);
192
+ }
193
+
194
+ function upgradeScopedGitignores(projectDir, opts) {
195
+ info('Scoped .gitignore (.dw/, .claude/)');
196
+ if (opts.dryRun) {
197
+ dry('refresh .dw/.gitignore + .claude/.gitignore managed blocks');
198
+ return;
199
+ }
200
+ try {
201
+ const dwR = ensureDwGitignore(projectDir);
202
+ if (dwR.action === 'noop') log(' .dw/.gitignore: up to date');
203
+ else ok(`.dw/.gitignore: ${dwR.action}`);
204
+
205
+ if (existsSync(join(projectDir, '.claude'))) {
206
+ const cR = ensureClaudeGitignore(projectDir);
207
+ if (cR.action === 'noop') log(' .claude/.gitignore: up to date');
208
+ else ok(`.claude/.gitignore: ${cR.action}`);
209
+ }
210
+ } catch (e) {
211
+ warn(`Scoped gitignore: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ function installSupplyChainHookOnUpgrade(projectDir, opts) {
216
+ if (opts.dryRun) return;
217
+ try {
218
+ const configPath = join(projectDir, '.dw', 'config', 'dw.config.yml');
219
+ if (existsSync(configPath)) {
220
+ const cfg = readFileSync(configPath, 'utf-8');
221
+ if (/depth:\s*quick/i.test(cfg) && !/roles:\s*\[?\s*dev\s*,/i.test(cfg)) {
222
+ // Heuristic: solo preset → skip per ADR-0005 TW5
223
+ log(' Supply-chain hook: skipped (solo-style preset detected)');
224
+ log(' Enable later: `dw security-scan --install-hook`');
225
+ return;
226
+ }
227
+ }
228
+ } catch { /* fall through */ }
229
+
230
+ // Defer import (avoid loading at module top) for clean test isolation
231
+ import('../lib/sc-install.mjs').then((m) => {
232
+ const result = m.installHookInProject(projectDir);
233
+ if (result.ok && result.action === 'added') {
234
+ ok('Supply-chain guard hook wired (ADR-0005 — opt-in available since v1.3.5)');
235
+ log(' First scan: `dw security-scan --update-db`');
236
+ }
237
+ }).catch(() => { /* silent — installation is best-effort */ });
184
238
  }
185
239
 
186
240
  function deepMerge(base, override) {
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+
4
+ const MARKER_START = '# >>> dw-kit managed >>>';
5
+ const MARKER_END = '# <<< dw-kit managed <<<';
6
+
7
+ const DW_GITIGNORE_BLOCK = [
8
+ MARKER_START,
9
+ '# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
10
+ '# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
11
+ 'adapters/',
12
+ 'core/',
13
+ 'security/',
14
+ '',
15
+ '# Config directory: ignore framework files, keep user dw.config.yml tracked',
16
+ 'config/*',
17
+ '!config/dw.config.yml',
18
+ '!config/.gitignore',
19
+ 'config/dw.config.local.yml',
20
+ '',
21
+ '# Local-only telemetry (machine-specific, has session hashes)',
22
+ 'metrics/',
23
+ MARKER_END,
24
+ ];
25
+
26
+ const CLAUDE_GITIGNORE_BLOCK = [
27
+ MARKER_START,
28
+ '# dw-kit framework files — regenerated by `dw init` / `dw upgrade`.',
29
+ '# Do NOT commit. Update dw-kit regularly via `dw upgrade`.',
30
+ 'agents/',
31
+ 'hooks/',
32
+ 'rules/',
33
+ 'skills/',
34
+ 'templates/',
35
+ '',
36
+ '# Local-only override (also in root .gitignore for safety)',
37
+ 'settings.local.json',
38
+ MARKER_END,
39
+ ];
40
+
41
+ function ensureDir(filepath) {
42
+ const dir = dirname(filepath);
43
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
44
+ }
45
+
46
+ function applyManagedBlock(content, blockLines) {
47
+ const block = blockLines.join('\n') + '\n';
48
+ if (!content) return block;
49
+
50
+ // If markers already present, replace block in-place (idempotent + upgrade-friendly)
51
+ const startIdx = content.indexOf(MARKER_START);
52
+ const endIdx = content.indexOf(MARKER_END);
53
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
54
+ const before = content.slice(0, startIdx);
55
+ const after = content.slice(endIdx + MARKER_END.length);
56
+ return (before + block + after).replace(/\n{3,}/g, '\n\n');
57
+ }
58
+
59
+ // Markers not present — append block
60
+ const sep = content.endsWith('\n') ? '\n' : '\n\n';
61
+ return content + sep + block;
62
+ }
63
+
64
+ function writeGitignore(targetPath, blockLines) {
65
+ ensureDir(targetPath);
66
+ const current = existsSync(targetPath) ? readFileSync(targetPath, 'utf-8') : '';
67
+ const updated = applyManagedBlock(current, blockLines);
68
+ if (updated === current) return { action: 'noop', path: targetPath };
69
+ writeFileSync(targetPath, updated, 'utf-8');
70
+ return { action: current ? 'updated' : 'created', path: targetPath };
71
+ }
72
+
73
+ export function ensureDwGitignore(projectDir = process.cwd()) {
74
+ return writeGitignore(join(projectDir, '.dw', '.gitignore'), DW_GITIGNORE_BLOCK);
75
+ }
76
+
77
+ export function ensureClaudeGitignore(projectDir = process.cwd()) {
78
+ return writeGitignore(join(projectDir, '.claude', '.gitignore'), CLAUDE_GITIGNORE_BLOCK);
79
+ }
80
+
81
+ export function ensureBothGitignores(projectDir = process.cwd()) {
82
+ return {
83
+ dw: ensureDwGitignore(projectDir),
84
+ claude: ensureClaudeGitignore(projectDir),
85
+ };
86
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ const HOOK_COMMAND = 'bash "$CLAUDE_PROJECT_DIR/.claude/hooks/supply-chain-scan.sh"';
5
+ const MATCHER = 'Write|Edit';
6
+
7
+ export function settingsPath(rootDir = process.cwd()) {
8
+ return join(rootDir, '.claude', 'settings.json');
9
+ }
10
+
11
+ export function isHookWired(settings) {
12
+ if (!settings?.hooks?.PostToolUse) return false;
13
+ for (const group of settings.hooks.PostToolUse) {
14
+ if (group.matcher !== MATCHER && !(group.matcher || '').split('|').includes('Write')) continue;
15
+ if (!Array.isArray(group.hooks)) continue;
16
+ for (const h of group.hooks) {
17
+ if (h.command && h.command.includes('supply-chain-scan.sh')) return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
22
+
23
+ export function wireHook(settings) {
24
+ settings.hooks = settings.hooks || {};
25
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
26
+
27
+ let group = settings.hooks.PostToolUse.find((g) => g.matcher === MATCHER);
28
+ if (!group) {
29
+ group = { matcher: MATCHER, hooks: [] };
30
+ settings.hooks.PostToolUse.push(group);
31
+ }
32
+ group.hooks = group.hooks || [];
33
+
34
+ for (const h of group.hooks) {
35
+ if (h.command && h.command.includes('supply-chain-scan.sh')) {
36
+ return { action: 'noop', reason: 'already wired' };
37
+ }
38
+ }
39
+
40
+ group.hooks.push({ type: 'command', command: HOOK_COMMAND });
41
+ return { action: 'added' };
42
+ }
43
+
44
+ export function unwireHook(settings) {
45
+ if (!settings?.hooks?.PostToolUse) return { action: 'noop', reason: 'no PostToolUse' };
46
+
47
+ let removed = 0;
48
+ for (const group of settings.hooks.PostToolUse) {
49
+ if (!Array.isArray(group.hooks)) continue;
50
+ const before = group.hooks.length;
51
+ group.hooks = group.hooks.filter((h) => !(h.command && h.command.includes('supply-chain-scan.sh')));
52
+ removed += before - group.hooks.length;
53
+ }
54
+ return removed > 0 ? { action: 'removed', count: removed } : { action: 'noop', reason: 'not wired' };
55
+ }
56
+
57
+ export function installHookInProject(rootDir = process.cwd()) {
58
+ const p = settingsPath(rootDir);
59
+ if (!existsSync(p)) {
60
+ return { ok: false, error: 'settings.json not found — run `dw init` first', path: p };
61
+ }
62
+ let settings;
63
+ try {
64
+ settings = JSON.parse(readFileSync(p, 'utf-8'));
65
+ } catch (e) {
66
+ return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
67
+ }
68
+
69
+ const result = wireHook(settings);
70
+ if (result.action === 'added') {
71
+ writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
72
+ }
73
+ return { ok: true, ...result, path: p };
74
+ }
75
+
76
+ export function uninstallHookFromProject(rootDir = process.cwd()) {
77
+ const p = settingsPath(rootDir);
78
+ if (!existsSync(p)) {
79
+ return { ok: false, error: 'settings.json not found', path: p };
80
+ }
81
+ let settings;
82
+ try {
83
+ settings = JSON.parse(readFileSync(p, 'utf-8'));
84
+ } catch (e) {
85
+ return { ok: false, error: `failed to parse settings.json: ${e.message}`, path: p };
86
+ }
87
+
88
+ const result = unwireHook(settings);
89
+ if (result.action === 'removed') {
90
+ writeFileSync(p, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
91
+ }
92
+ return { ok: true, ...result, path: p };
93
+ }