forged-cli 0.5.0 → 0.6.0

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/README.md CHANGED
@@ -79,6 +79,15 @@ One strong signal, or a medium plus a weak one, verifies the change. Weak signal
79
79
  | `--verbose`, `-v` | Show suppressed maintainer rotations and verified publishers |
80
80
  | `--report` | Save findings to `forged-report.json` |
81
81
  | `--report-md` | Save findings to `forged-report.md` |
82
+ | `--changed` | Skip if `package.json` + lockfile match a scan from the last 7 days (rescans weekly anyway — new malware advisories land even when your lockfile doesn't change) |
83
+ | `--quiet`, `-q` | Print nothing unless something is flagged, then one line — for shell hooks |
84
+
85
+ **Scan on `cd`:** `--changed --quiet` is cheap enough to run every time you enter a project (≈50ms when unchanged). A zsh example:
86
+
87
+ ```zsh
88
+ _forged_autoscan() { [[ -f package-lock.json ]] && { forged scan --changed --quiet & } 2>/dev/null }
89
+ chpwd_functions+=(_forged_autoscan)
90
+ ```
82
91
 
83
92
  **In CI:** `forged scan` exits with code `1` when it finds errors (malware, integrity mismatches, dangerous scripts), so it can fail a build:
84
93
 
@@ -115,11 +124,11 @@ These are advertised in `forged help` and currently print "coming soon":
115
124
 
116
125
  | Command | Plan |
117
126
  |---------|------|
118
- | `forged init` | Set up a modular zsh environment — plugins, hooks, project auto-detection, and a GitHub dashboard |
127
+ | `forged init` | Guided setup of a modular zsh environment — plugins, hooks, project auto-detection, and the GitHub workflow (Ctrl+G: issue → branch → commit → PR, project boards, label and template pickers) |
119
128
  | `forged new` | Create a new project with GitHub setup |
120
129
  | `forged install` | Add Forged to an existing shell config |
121
130
 
122
- Want them today? They're running in my [dotfiles](https://github.com/bkness/dotfiles).
131
+ Want them today? Everything above, including the full GitHub workflow, already runs in my [dotfiles](https://github.com/bkness/dotfiles) — `forged init` will package it.
123
132
 
124
133
  ---
125
134
 
package/bin/forged.js CHANGED
@@ -38,6 +38,9 @@ ${Object.entries(planned).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`).
38
38
 
39
39
  Scan options:
40
40
  forged scan [path] [--verbose|-v] [--report|--report-md]
41
+ [--changed] [--quiet|-q]
42
+ --changed skip if package.json + lockfile match a scan from the last 7 days
43
+ --quiet print nothing unless something is flagged (for shell hooks)
41
44
  Exits 1 when errors are found, so it can fail a CI job.
42
45
  `);
43
46
  process.exit(0);
@@ -66,11 +69,14 @@ if (command === 'scan') {
66
69
  const report = args.includes('--report') || args.includes('--report-md');
67
70
  const reportFormat = args.includes('--report-md') ? 'markdown' : 'json';
68
71
  const verbose = args.includes('--verbose') || args.includes('-v');
72
+ const changed = args.includes('--changed');
73
+ const quiet = args.includes('--quiet') || args.includes('-q');
69
74
  // Any dash-prefixed arg is a flag, not the path (so `-v` isn't scanned as a dir)
70
75
  const pathArg = args.find(a => !a.startsWith('-'));
71
76
  const targetPath = pathArg ? resolve(pathArg) : process.cwd();
72
- const findings = await scanCommand(targetPath, { report, reportFormat, verbose });
73
- process.exit(findings?.errors.length ? 1 : 0);
77
+ const findings = await scanCommand(targetPath, { report, reportFormat, verbose, changed, quiet });
78
+ const errorCount = findings?.skipped ? findings.errorCount : findings?.errors.length;
79
+ process.exit(errorCount ? 1 : 0);
74
80
  }
75
81
 
76
82
  if (planned[command]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forged-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A complete dev environment toolkit — shell, workflows, and project scaffolding in one install.",
5
5
  "keywords": [
6
6
  "cli",
@@ -5,6 +5,7 @@ import { isSuspiciousName } from '../utils/levenshtein.js';
5
5
  import { POPULAR_PACKAGES } from '../utils/popularPackages.js';
6
6
  import { verifyTarballIntegrity } from '../utils/verifyIntegrity.js';
7
7
  import { queryOsv } from '../utils/osv.js';
8
+ import { previousResult, recordResult } from '../utils/scanState.js';
8
9
 
9
10
  const green = '\x1b[32m';
10
11
  const yellow = '\x1b[33m';
@@ -111,10 +112,14 @@ function saveMarkdownReport(reportPath, data) {
111
112
  }
112
113
 
113
114
  export async function scanCommand(cwd = process.cwd(), opts = {}) {
114
- const { report, reportFormat = 'json', verbose = false } = opts;
115
+ const { report, reportFormat = 'json', verbose = false, changed = false, quiet = false } = opts;
115
116
 
116
- console.log(`\n${bold}⚒ Forged Scanner${reset}`);
117
- console.log(` Scanning: ${cwd}\n`);
117
+ // --quiet: no output unless something is flagged (for shell hooks)
118
+ const log = quiet ? () => {} : console.log;
119
+ const write = quiet ? () => {} : (text) => process.stdout.write(text);
120
+
121
+ log(`\n${bold}⚒ Forged Scanner${reset}`);
122
+ log(` Scanning: ${cwd}\n`);
118
123
 
119
124
  const findings = { warnings: [], errors: [], info: [], suppressed: [] };
120
125
  let packagesVerified = 0;
@@ -125,9 +130,20 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
125
130
  // Not a Node project: nothing to scan. Return before writing the scan
126
131
  // cache, so the dotfiles badge keeps the last real result.
127
132
  if (!existsSync(join(cwd, 'package.json'))) {
128
- console.log(`${yellow}ℹ No package.json here — not a Node project, nothing to scan.${reset}\n`);
133
+ log(`${yellow}ℹ No package.json here — not a Node project, nothing to scan.${reset}\n`);
129
134
  return null;
130
135
  }
136
+
137
+ // --changed: skip when package.json + lockfile match a recent scan
138
+ if (changed) {
139
+ const prev = previousResult(cwd);
140
+ if (prev) {
141
+ log(`${green}✔ Unchanged since the scan on ${prev.scannedAt.slice(0, 10)} — skipped ` +
142
+ `(${prev.errors} error(s), ${prev.warnings} warning(s)).${reset}\n`);
143
+ return { skipped: true, errorCount: prev.errors, warningCount: prev.warnings };
144
+ }
145
+ }
146
+
131
147
  if (error) findings.errors.push({ message: error });
132
148
 
133
149
  // 2. Dangerous scripts
@@ -156,16 +172,16 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
156
172
  }
157
173
 
158
174
  // 4. Tarball integrity verification
159
- process.stdout.write(' Verifying tarball integrity');
175
+ write(' Verifying tarball integrity');
160
176
  const { error: integrityError, findings: integrityFindings, total, packages } = await verifyTarballIntegrity(
161
177
  cwd,
162
178
  (checked, t) => {
163
179
  if (checked % 10 === 0 || checked === t) {
164
- process.stdout.write(`\r Verifying tarball integrity (${checked}/${t})...`);
180
+ write(`\r Verifying tarball integrity (${checked}/${t})...`);
165
181
  }
166
182
  }
167
183
  );
168
- process.stdout.write('\n');
184
+ write('\n');
169
185
 
170
186
  if (integrityError) {
171
187
  findings.warnings.push({ message: integrityError });
@@ -211,50 +227,58 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
211
227
 
212
228
  // Print results
213
229
  if (findings.errors.length === 0 && findings.warnings.length === 0) {
214
- console.log(`${green}✔ No issues found. Looks clean.${reset}\n`);
230
+ log(`${green}✔ No issues found. Looks clean.${reset}\n`);
215
231
  } else {
216
232
  if (findings.errors.length > 0) {
217
- console.log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
233
+ log(`${red}${bold}ERRORS (${findings.errors.length}):${reset}`);
218
234
  for (const e of findings.errors) {
219
235
  const label = e.package ? `${e.package}@${e.version}: ` : '';
220
- console.log(` ${red}✖${reset} ${label}${e.message}`);
236
+ log(` ${red}✖${reset} ${label}${e.message}`);
221
237
  if (e.detail) {
222
- console.log(` locked: ${e.detail.locked}`);
223
- console.log(` registry: ${e.detail.registry}`);
238
+ log(` locked: ${e.detail.locked}`);
239
+ log(` registry: ${e.detail.registry}`);
224
240
  }
225
241
  }
226
- console.log();
242
+ log();
227
243
  }
228
244
 
229
245
  if (findings.warnings.length > 0) {
230
- console.log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
246
+ log(`${yellow}${bold}WARNINGS (${findings.warnings.length}):${reset}`);
231
247
  for (const w of findings.warnings) {
232
248
  const label = w.package ? `${w.package}@${w.version}: ` : '';
233
- console.log(` ${yellow}⚠${reset} ${label}${w.message}`);
249
+ log(` ${yellow}⚠${reset} ${label}${w.message}`);
234
250
  }
235
- console.log();
251
+ log();
236
252
  }
237
253
  }
238
254
 
239
255
  // Verbose: show suppressed trusted rotations
240
256
  if (verbose && findings.suppressed.length > 0) {
241
- console.log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
257
+ log(`${bold}SUPPRESSED — trusted, verified, returning, or trusted-publishing publishers:${reset}`);
242
258
  for (const s of findings.suppressed) {
243
- console.log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
259
+ log(` ${green}~${reset} ${s.package}@${s.version}: ${s.message}`);
244
260
  }
245
- console.log();
261
+ log();
246
262
  }
247
263
 
248
264
  if (findings.info.length > 0) {
249
- for (const i of findings.info) console.log(` ${green}ℹ${reset} ${i}`);
265
+ for (const i of findings.info) log(` ${green}ℹ${reset} ${i}`);
250
266
  }
251
267
  if (findings.suppressed.length > 0) {
252
268
  const note = verbose ? '' : ' (run with --verbose to see them)';
253
- console.log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
269
+ log(` ${green}ℹ${reset} ${findings.suppressed.length} publisher change(s) suppressed${note}`);
254
270
  }
255
- console.log();
271
+ log();
272
+
273
+ log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
256
274
 
257
- console.log(`${bold}Summary:${reset} ${findings.errors.length} error(s), ${findings.warnings.length} warning(s), ${findings.suppressed.length} suppressed\n`);
275
+ recordResult(cwd, findings);
276
+
277
+ if (quiet && (findings.errors.length || findings.warnings.length)) {
278
+ const color = findings.errors.length ? red : yellow;
279
+ console.log(`${color}⚒ forged: ${findings.errors.length} error(s), ${findings.warnings.length} warning(s) ` +
280
+ `in ${basename(cwd)} — run \`forged scan\` for details${reset}`);
281
+ }
258
282
 
259
283
  // Write local scan cache — dotfiles reads this to push telemetry
260
284
  const flagged = [
@@ -302,7 +326,7 @@ export async function scanCommand(cwd = process.cwd(), opts = {}) {
302
326
  saveJsonReport(reportFile, reportData);
303
327
  }
304
328
 
305
- console.log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
329
+ log(`${green}✔ Report saved: ${reportFile}${reset}\n`);
306
330
  }
307
331
 
308
332
  return findings;
@@ -0,0 +1,51 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+
6
+ // An unchanged project still gets rescanned after this long: new malware
7
+ // advisories land on OSV even when the lockfile doesn't move.
8
+ export const RESCAN_DAYS = 7;
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+
11
+ const statePath = () => join(homedir(), '.forged-scan-state.json');
12
+
13
+ // package.json is part of the fingerprint too — the install-script check reads it
14
+ export function fingerprint(cwd) {
15
+ const hash = createHash('sha256');
16
+ for (const file of ['package.json', 'package-lock.json']) {
17
+ const path = join(cwd, file);
18
+ hash.update(`${file}\0`);
19
+ if (existsSync(path)) hash.update(readFileSync(path));
20
+ hash.update('\0');
21
+ }
22
+ return hash.digest('hex');
23
+ }
24
+
25
+ function readState() {
26
+ try {
27
+ return JSON.parse(readFileSync(statePath(), 'utf8'));
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ // The last result for this project if nothing changed since and it's recent
34
+ // enough to trust, else null (→ scan again)
35
+ export function previousResult(cwd, now = Date.now()) {
36
+ const entry = readState()[cwd];
37
+ if (!entry || entry.fingerprint !== fingerprint(cwd)) return null;
38
+ if (now - Date.parse(entry.scannedAt) > RESCAN_DAYS * DAY_MS) return null;
39
+ return entry;
40
+ }
41
+
42
+ export function recordResult(cwd, findings, now = Date.now()) {
43
+ const state = readState();
44
+ state[cwd] = {
45
+ fingerprint: fingerprint(cwd),
46
+ scannedAt: new Date(now).toISOString(),
47
+ errors: findings.errors.length,
48
+ warnings: findings.warnings.length,
49
+ };
50
+ writeFileSync(statePath(), JSON.stringify(state, null, 2), 'utf8');
51
+ }