bun-ready 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +139 -0
  2. package/dist/cli.js +921 -135
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,6 +39,145 @@ bun-ready scan . --no-install --no-test
39
39
  - 2 → YELLOW
40
40
  - 3 → RED
41
41
 
42
+ ## Monorepo / Workspaces Support
43
+
44
+ bun-ready now supports scanning monorepo projects with multiple workspace packages.
45
+
46
+ ### Workspace Discovery
47
+
48
+ If your `package.json` contains a `workspaces` field (array or object), bun-ready will:
49
+ - Discover all workspace packages automatically
50
+ - Analyze each package individually
51
+ - Aggregate results into a single report
52
+
53
+ ### Configuration
54
+
55
+ You can create a `bun-ready.config.json` file in your repository root to customize the scan:
56
+
57
+ ```json
58
+ {
59
+ "ignorePackages": ["packages/legacy"],
60
+ "ignoreFindings": ["scripts.pm_assumptions"],
61
+ "nativeAddonAllowlist": ["fsevents"],
62
+ "failOn": "yellow"
63
+ }
64
+ ```
65
+
66
+ ### Options
67
+
68
+ | Option | Description | Default |
69
+ |---------|-------------|----------|
70
+ | `ignorePackages` | Array of package paths to ignore | `[]` |
71
+ | `ignoreFindings` | Array of finding IDs to ignore | `[]` |
72
+ | `nativeAddonAllowlist` | Packages to exclude from native addon checks | `[]` |
73
+ | `failOn` | When to return non-zero exit code | `"red"` |
74
+
75
+ ### New CLI Flags
76
+
77
+ `--scope root|packages|all`
78
+ - `root`: Scan only the root package.json
79
+ - `packages`: Scan only workspace packages
80
+ - `all`: Scan root and all workspace packages (default)
81
+
82
+ `--fail-on green|yellow|red`
83
+ - Controls when bun-ready exits with a failure code
84
+ - `green`: Fail on anything not green (exit 3)
85
+ - `yellow`: Fail on red only (exit 3), yellow passes (exit 0)
86
+ - `red`: Default behavior - green=0, yellow=2, red=3
87
+
88
+ ## How Scoring Works
89
+
90
+ bun-ready uses a combination of heuristics to determine migration readiness:
91
+
92
+ ### Severity Levels
93
+
94
+ **🟢 GREEN** - Ready to migrate
95
+ - No critical issues detected
96
+ - Bun install succeeds
97
+ - Tests pass (if compatible)
98
+ - No native addon risks or properly handled
99
+
100
+ **🟡 YELLOW** - Migration possible with manual fixes
101
+ - Lifecycle scripts present (verify npm compatibility)
102
+ - Native addons detected (may need updates)
103
+ - Package manager assumptions in scripts
104
+ - Node version < 18
105
+ - Dev tools that may need configuration
106
+
107
+ **🔴 RED** - Migration blocked
108
+ - Bun install fails
109
+ - Native build tools (node-gyp, node-sass)
110
+ - Critical missing dependencies
111
+ - Tests fail
112
+
113
+ ### Findings Categories
114
+
115
+ - `scripts.lifecycle` - Lifecycle scripts in root or dependencies
116
+ - `scripts.npm_specific` - npm/yarn/pnpm-specific commands
117
+ - `scripts.pm_assumptions` - Package manager assumptions
118
+ - `deps.native_addons` - Native addon dependencies
119
+ - `runtime.node_version` - Node.js version requirements
120
+ - `runtime.dev_tools` - Testing frameworks (jest, vitest, etc.)
121
+ - `runtime.build_tools` - Build tools (webpack, babel, etc.)
122
+ - `runtime.ts_execution` - TypeScript runtime execution
123
+ - `lockfile.missing` - No lockfile detected
124
+ - `lockfile.migration` - Non-Bun lockfile present
125
+ - `install.blocked_scripts` - Scripts blocked by Bun
126
+ - `install.trusted_deps` - Trusted dependencies mentioned
127
+
128
+ ## FAQ
129
+
130
+ ### Why yellow when there's a postinstall script?
131
+
132
+ Bun runs your project's lifecycle scripts during install, but **does not run** lifecycle scripts of dependencies unless they're in `trustedDependencies`. The yellow warning reminds you to verify these scripts work correctly with Bun.
133
+
134
+ ### What are trustedDependencies?
135
+
136
+ Bun's `trustedDependencies` configuration controls which packages are allowed to run their lifecycle scripts. You can add trusted packages to this field in your `package.json`:
137
+
138
+ ```json
139
+ {
140
+ "trustedDependencies": ["some-package"]
141
+ }
142
+ ```
143
+
144
+ ### How do I handle monorepo scanning?
145
+
146
+ For monorepos, bun-ready automatically detects workspaces and scans all packages. Use `--scope` to control what's scanned:
147
+
148
+ ```bash
149
+ # Scan everything (default)
150
+ bun-ready scan .
151
+
152
+ # Scan only root package
153
+ bun-ready scan . --scope root
154
+
155
+ # Scan only workspace packages
156
+ bun-ready scan . --scope packages
157
+ ```
158
+
159
+ ### Can I ignore certain findings?
160
+
161
+ Yes! Create a `bun-ready.config.json` file:
162
+
163
+ ```json
164
+ {
165
+ "ignoreFindings": ["scripts.pm_assumptions"]
166
+ }
167
+ ```
168
+
169
+ ### What if a package is in the native addon list but works with Bun?
170
+
171
+ Add it to the allowlist:
172
+
173
+ ```json
174
+ {
175
+ "nativeAddonAllowlist": ["fsevents"]
176
+ }
177
+ ```
178
+
179
+ Some packages have optional native modules that can be disabled or work fine with Bun.
180
+
42
181
  ## What it checks (MVP)
43
182
  - package.json presence & shape
44
183
  - lockfiles (npm/yarn/pnpm/bun)
package/dist/cli.js CHANGED
@@ -1,11 +1,9 @@
1
- #!/usr/bin/env node
2
-
3
1
  // src/cli.ts
4
2
  import { promises as fs3 } from "node:fs";
5
- import path3 from "node:path";
3
+ import path5 from "node:path";
6
4
 
7
5
  // src/analyze.ts
8
- import path2 from "node:path";
6
+ import path4 from "node:path";
9
7
  import os from "node:os";
10
8
  import { promises as fs2 } from "node:fs";
11
9
 
@@ -81,7 +79,7 @@ var truncateLines = (lines, max) => {
81
79
  };
82
80
 
83
81
  // src/heuristics.ts
84
- var NATIVE_SUSPECTS = [
82
+ var NATIVE_SUSPECTS_V2 = [
85
83
  "node-gyp",
86
84
  "node-pre-gyp",
87
85
  "prebuild-install",
@@ -97,7 +95,112 @@ var NATIVE_SUSPECTS = [
97
95
  "argon2",
98
96
  "bufferutil",
99
97
  "utf-8-validate",
100
- "fsevents"
98
+ "fsevents",
99
+ "grpc",
100
+ "@grpc/grpc-js",
101
+ "grpc-js",
102
+ "bcryptjs",
103
+ "bcrypt",
104
+ "sodium",
105
+ "libsodium",
106
+ "leveldb",
107
+ "level",
108
+ "rocksdb",
109
+ "mysql2",
110
+ "pg",
111
+ "oracledb",
112
+ "nodegit",
113
+ "ffi-napi",
114
+ "node-ffi",
115
+ "ref-napi",
116
+ "skia-canvas",
117
+ "jimp",
118
+ "pdfkit",
119
+ "sharp",
120
+ "pixelmatch",
121
+ "cheerio",
122
+ "node-wav",
123
+ "lamejs",
124
+ "flac-bindings",
125
+ "opus-recorder",
126
+ "silk-wasm",
127
+ "zeromq",
128
+ "zeromq.js",
129
+ "mongodb",
130
+ "redis",
131
+ "ioredis",
132
+ "elasticsearch",
133
+ "snappy",
134
+ "snappyjs",
135
+ "iltorb",
136
+ "brotli",
137
+ "node-sha3",
138
+ "ursa",
139
+ "node-forge",
140
+ "jsonwebtoken",
141
+ "node-cron",
142
+ "bull",
143
+ "bullmq"
144
+ ];
145
+ var DEV_TOOL_SUSPECTS = [
146
+ "jest",
147
+ "vitest",
148
+ "mocha",
149
+ "chai",
150
+ "ava",
151
+ "tap",
152
+ "jasmine",
153
+ "karma",
154
+ "cypress",
155
+ "playwright",
156
+ "puppeteer",
157
+ "selenium-webdriver",
158
+ "webdriverio",
159
+ "nightwatch",
160
+ "testcafe",
161
+ "protractor"
162
+ ];
163
+ var RUNTIME_TOOL_SUSPECTS = [
164
+ "ts-node",
165
+ "tsx",
166
+ "ts-node-dev",
167
+ "nodemon",
168
+ "babel",
169
+ "@babel/core",
170
+ "@babel/node",
171
+ "babel-cli",
172
+ "babel-register",
173
+ "babel-preset-env",
174
+ "webpack",
175
+ "webpack-cli",
176
+ "rollup",
177
+ "@rollup/plugin",
178
+ "esbuild",
179
+ "vite",
180
+ "@vitejs/plugin",
181
+ "swc",
182
+ "@swc/core",
183
+ "@swc/register",
184
+ "turbopack",
185
+ "snowpack",
186
+ "parcel",
187
+ "browserify"
188
+ ];
189
+ var PM_SPECIFIC_COMMANDS = [
190
+ "npm ci",
191
+ "npm run ci",
192
+ "pnpm -r",
193
+ "pnpm recursive",
194
+ "pnpm workspaces",
195
+ "yarn workspaces",
196
+ "yarn workspace",
197
+ "yarn -w",
198
+ "lerna run",
199
+ "nx run",
200
+ "npx lerna",
201
+ "npx nx",
202
+ "turbo run",
203
+ "rushx"
101
204
  ];
102
205
  var includesAny = (s, needles) => {
103
206
  const lower = s.toLowerCase();
@@ -144,31 +247,6 @@ var detectScriptRisks = (repo) => {
144
247
  }
145
248
  return findings;
146
249
  };
147
- var detectNativeAddonRisk = (repo) => {
148
- const allDeps = {
149
- ...repo.dependencies,
150
- ...repo.devDependencies,
151
- ...repo.optionalDependencies
152
- };
153
- const names = Object.keys(allDeps);
154
- const suspects = stableSort(names.filter((n) => NATIVE_SUSPECTS.includes(n) || includesAny(n, ["napi", "node-gyp", "prebuild", "ffi"])), (x) => x);
155
- if (suspects.length === 0)
156
- return [];
157
- const hardRed = suspects.some((n) => n === "node-gyp" || n === "node-sass");
158
- const severity = hardRed ? "red" : "yellow";
159
- return [
160
- {
161
- id: "deps.native_addons",
162
- title: "Potential native addons / node-gyp toolchain risk",
163
- severity,
164
- details: suspects.map((n) => `${n}@${allDeps[n] ?? ""}`.trim()),
165
- hints: [
166
- "Native addons often require toolchains and can be sensitive to runtime differences.",
167
- "If you see install/build failures, try upgrading these packages or switching to pure-JS alternatives."
168
- ]
169
- }
170
- ];
171
- };
172
250
  var detectLockfileSignals = (repo) => {
173
251
  const { bunLock, bunLockb, npmLock, yarnLock, pnpmLock } = repo.lockfiles;
174
252
  if (bunLock || bunLockb)
@@ -208,6 +286,145 @@ var detectLockfileSignals = (repo) => {
208
286
  }
209
287
  ];
210
288
  };
289
+ var detectRuntimeApiRisks = (repo) => {
290
+ const findings = [];
291
+ if (repo.packageJson?.engines?.node) {
292
+ const nodeVersion = repo.packageJson.engines.node;
293
+ const match = nodeVersion.match(/>=?(\d+)/);
294
+ if (match && match[1]) {
295
+ const minVersion = parseInt(match[1], 10);
296
+ if (minVersion < 18) {
297
+ findings.push({
298
+ id: "runtime.node_version",
299
+ title: "Node.js version requirement is below Bun's baseline (v18+)",
300
+ severity: "yellow",
301
+ details: [`engines.node: ${nodeVersion}`],
302
+ hints: [
303
+ "Bun targets Node 18+ compatibility. Packages requiring older Node versions may need updates.",
304
+ "Check if packages have updates or if version constraints can be relaxed."
305
+ ]
306
+ });
307
+ }
308
+ }
309
+ }
310
+ const allDeps = { ...repo.dependencies, ...repo.devDependencies, ...repo.optionalDependencies };
311
+ const deps = Object.keys(allDeps);
312
+ const devToolHits = deps.filter((d) => DEV_TOOL_SUSPECTS.includes(d) || deps.some((x) => x.startsWith(`${d}/`)));
313
+ const relevantDevTools = devToolHits.filter((d) => allDeps[d]);
314
+ if (relevantDevTools.length > 0) {
315
+ findings.push({
316
+ id: "runtime.dev_tools",
317
+ title: "Dev tools that may need Bun compatibility checks",
318
+ severity: "yellow",
319
+ details: relevantDevTools.map((d) => `${d}@${allDeps[d]}`),
320
+ hints: [
321
+ "Testing frameworks like jest/vitest may work, but consider migrating to bun:test for optimal performance.",
322
+ "Build tools like webpack/esbuild/vite typically work with Bun, but verify your build pipeline.",
323
+ "Check documentation for each tool's Bun compatibility status."
324
+ ]
325
+ });
326
+ }
327
+ const runtimeToolHits = deps.filter((d) => RUNTIME_TOOL_SUSPECTS.includes(d) || deps.some((x) => x.startsWith(`${d}/`)));
328
+ const relevantRuntimeTools = runtimeToolHits.filter((d) => allDeps[d]);
329
+ if (relevantRuntimeTools.length > 0) {
330
+ findings.push({
331
+ id: "runtime.build_tools",
332
+ title: "Runtime/build tools that may need Bun compatibility verification",
333
+ severity: "yellow",
334
+ details: relevantRuntimeTools.map((d) => `${d}@${allDeps[d]}`),
335
+ hints: [
336
+ "Tools like ts-node/tsx work with Bun, but verify your TypeScript configuration.",
337
+ "Bundlers like webpack/vite/esbuild typically work with Bun, but verify your build scripts.",
338
+ "Consider switching to Bun's native build capabilities for improved performance."
339
+ ]
340
+ });
341
+ }
342
+ const scriptNames = Object.keys(repo.scripts);
343
+ const tsRuntimeScripts = scriptNames.filter((k) => {
344
+ const script = repo.scripts[k]?.toLowerCase() || "";
345
+ return script.includes("ts-node") || script.includes("tsx") || script.includes("babel-node");
346
+ });
347
+ if (tsRuntimeScripts.length > 0) {
348
+ findings.push({
349
+ id: "runtime.ts_execution",
350
+ title: "Scripts use TypeScript runtime execution (ts-node/tsx/babel-node)",
351
+ severity: "yellow",
352
+ details: tsRuntimeScripts.map((k) => `${k}: ${repo.scripts[k]}`),
353
+ hints: [
354
+ "Bun supports TypeScript natively, but verify tsconfig compatibility.",
355
+ "Consider migrating scripts to use `bun run` directly with TypeScript files.",
356
+ "Test execution of affected scripts with Bun before full migration."
357
+ ]
358
+ });
359
+ }
360
+ return findings;
361
+ };
362
+ var detectPmAssumptions = (repo) => {
363
+ const findings = [];
364
+ const scriptNames = Object.keys(repo.scripts);
365
+ const pmSpecificHits = [];
366
+ for (const scriptName of scriptNames) {
367
+ const script = repo.scripts[scriptName] || "";
368
+ const lowerScript = script.toLowerCase();
369
+ for (const cmd of PM_SPECIFIC_COMMANDS) {
370
+ if (lowerScript.includes(cmd.toLowerCase())) {
371
+ pmSpecificHits.push({ name: scriptName, command: cmd });
372
+ break;
373
+ }
374
+ }
375
+ }
376
+ if (pmSpecificHits.length > 0) {
377
+ findings.push({
378
+ id: "scripts.pm_assumptions",
379
+ title: "Scripts contain package-manager-specific commands",
380
+ severity: "yellow",
381
+ details: pmSpecificHits.map((h) => `${h.name}: uses "${h.command}"`),
382
+ hints: [
383
+ "Package-manager-specific commands may need adaptation for Bun.",
384
+ "Test each affected script with Bun to ensure compatibility.",
385
+ "Consider rewriting scripts to be package-manager agnostic where possible."
386
+ ]
387
+ });
388
+ }
389
+ return findings;
390
+ };
391
+ var detectNativeAddonRiskV2 = (repo, config) => {
392
+ const allDeps = {
393
+ ...repo.dependencies,
394
+ ...repo.devDependencies,
395
+ ...repo.optionalDependencies
396
+ };
397
+ const names = Object.keys(allDeps);
398
+ const allowlist = config?.nativeAddonAllowlist || [];
399
+ const suspects = names.filter((n) => {
400
+ if (allowlist.includes(n))
401
+ return false;
402
+ return NATIVE_SUSPECTS_V2.includes(n) || includesAny(n, ["napi", "node-gyp", "prebuild", "ffi", "bindings", "native", "native-module"]);
403
+ });
404
+ const scriptNames = Object.keys(repo.scripts);
405
+ const hasNodeGypRebuild = scriptNames.some((k) => {
406
+ const script = repo.scripts[k]?.toLowerCase() || "";
407
+ return script.includes("node-gyp") || script.includes("node-gyp rebuild");
408
+ });
409
+ if (suspects.length === 0 && !hasNodeGypRebuild)
410
+ return [];
411
+ const hardRed = suspects.some((n) => n === "node-gyp" || n === "node-sass") || hasNodeGypRebuild;
412
+ const severity = hardRed ? "red" : "yellow";
413
+ return [
414
+ {
415
+ id: "deps.native_addons",
416
+ title: "Potential native addons / node-gyp toolchain risk",
417
+ severity,
418
+ details: suspects.map((n) => `${n}@${allDeps[n]}`),
419
+ hints: [
420
+ "Native addons often require toolchains and can be sensitive to runtime differences.",
421
+ "If you see install/build failures, try upgrading these packages or switching to pure-JS alternatives.",
422
+ "Some packages offer optional native modules that can be disabled via configuration.",
423
+ "Check if native modules are in use or just installed for optional features."
424
+ ]
425
+ }
426
+ ];
427
+ };
211
428
  var summarizeSeverity = (findings, installOk, testOk) => {
212
429
  let sev = "green";
213
430
  for (const f of findings)
@@ -219,33 +436,264 @@ var summarizeSeverity = (findings, installOk, testOk) => {
219
436
  return sev;
220
437
  };
221
438
 
439
+ // src/bun_logs.ts
440
+ function parseInstallLogs(logs) {
441
+ const blockedDeps = [];
442
+ const trustedDepsMentioned = [];
443
+ const notes = [];
444
+ const blockedKeywords = ["blocked", "not allowed", "lifecycle script", "postinstall blocked", "prepare blocked"];
445
+ const trustedKeywords = ["trustedDependencies", "trusted", "trust"];
446
+ for (const log of logs) {
447
+ const lowerLog = log.toLowerCase();
448
+ for (const keyword of blockedKeywords) {
449
+ if (lowerLog.includes(keyword)) {
450
+ let pkgMatch = log.match(/blocked:\s+(?:lifecycle\s+script\s+(?:for\s+)?)?(@?[a-z0-9-]+\/[a-z0-9-]+|@?[a-z0-9-]+)@[\d.^]+/i);
451
+ if (pkgMatch && pkgMatch[1] && !blockedDeps.includes(pkgMatch[1])) {
452
+ blockedDeps.push(pkgMatch[1]);
453
+ } else {
454
+ const fallbackMatch = log.match(/blocked:\s+(?:lifecycle\s+script\s+(?:for\s+)?)?(@?[a-z0-9-]+\/[a-z0-9-]+|@?[a-z0-9-]+)/i);
455
+ if (fallbackMatch && fallbackMatch[1] && !blockedDeps.includes(fallbackMatch[1]) && !blockedKeywords.includes(fallbackMatch[1].toLowerCase())) {
456
+ blockedDeps.push(fallbackMatch[1]);
457
+ }
458
+ }
459
+ break;
460
+ }
461
+ }
462
+ for (const keyword of trustedKeywords) {
463
+ if (lowerLog.includes(keyword)) {
464
+ if (!notes.some((n) => n.toLowerCase().includes(keyword))) {
465
+ notes.push(log.trim());
466
+ }
467
+ const pkgMatch = log.match(/@?[a-z0-9-]+\/[a-z0-9-]+|@?[a-z0-9-]+/gi);
468
+ if (pkgMatch) {
469
+ for (const pkg of pkgMatch) {
470
+ if (pkg.toLowerCase() !== "trusteddependencies" && !trustedDepsMentioned.includes(pkg)) {
471
+ trustedDepsMentioned.push(pkg);
472
+ }
473
+ }
474
+ }
475
+ break;
476
+ }
477
+ }
478
+ if (lowerLog.includes("warning") || lowerLog.includes("warn")) {
479
+ notes.push(log.trim());
480
+ }
481
+ }
482
+ blockedDeps.sort((a, b) => a.localeCompare(b));
483
+ trustedDepsMentioned.sort((a, b) => a.localeCompare(b));
484
+ notes.sort();
485
+ return {
486
+ blockedDeps,
487
+ trustedDepsMentioned,
488
+ notes
489
+ };
490
+ }
491
+
492
+ // src/workspaces.ts
493
+ import path2 from "node:path";
494
+ import fsSync from "node:fs";
495
+ function globMatch(pattern, path3) {
496
+ const patternParts = pattern.split("/");
497
+ const pathParts = path3.split("/");
498
+ let patternIdx = 0;
499
+ let pathIdx = 0;
500
+ while (patternIdx < patternParts.length && pathIdx < pathParts.length) {
501
+ const patternPart = patternParts[patternIdx];
502
+ const pathPart = pathParts[pathIdx];
503
+ if (patternPart === "**") {
504
+ if (patternIdx === patternParts.length - 1) {
505
+ return true;
506
+ }
507
+ const nextPatternPart = patternParts[patternIdx + 1];
508
+ while (pathIdx < pathParts.length && pathParts[pathIdx] !== nextPatternPart) {
509
+ pathIdx++;
510
+ }
511
+ patternIdx++;
512
+ } else if (patternPart === "*") {
513
+ patternIdx++;
514
+ pathIdx++;
515
+ } else {
516
+ if (patternPart !== pathPart) {
517
+ return false;
518
+ }
519
+ patternIdx++;
520
+ pathIdx++;
521
+ }
522
+ }
523
+ if (patternIdx < patternParts.length) {
524
+ const remaining = patternParts.slice(patternIdx);
525
+ if (remaining.length === 1 && remaining[0] === "**") {
526
+ return true;
527
+ }
528
+ }
529
+ return patternIdx === patternParts.length && pathIdx === pathParts.length;
530
+ }
531
+ function discoverFromWorkspaces(rootPath, workspaces) {
532
+ const patterns = Array.isArray(workspaces) ? workspaces : workspaces.packages;
533
+ const packages = [];
534
+ for (const pattern of patterns) {
535
+ const patternPath = path2.resolve(rootPath, pattern);
536
+ if (pattern.includes("/*") && !pattern.includes("/**")) {
537
+ try {
538
+ const baseDir = path2.dirname(patternPath);
539
+ const patternName = path2.basename(patternPath);
540
+ const entries = fsSync.readdirSync(baseDir, { withFileTypes: true });
541
+ for (const entry of entries) {
542
+ if (entry.isDirectory() && globMatch(patternName, entry.name)) {
543
+ const packagePath = path2.join(baseDir, entry.name);
544
+ const pkgJsonPath = path2.join(packagePath, "package.json");
545
+ if (fileExistsSync(pkgJsonPath)) {
546
+ packages.push(packagePath);
547
+ }
548
+ }
549
+ }
550
+ } catch {}
551
+ }
552
+ }
553
+ return packages;
554
+ }
555
+ function fileExistsSync(filePath) {
556
+ try {
557
+ fsSync.accessSync(filePath);
558
+ return true;
559
+ } catch {
560
+ return false;
561
+ }
562
+ }
563
+ async function discoverWorkspaces(rootPath) {
564
+ const packages = [];
565
+ const rootPkgJson = path2.join(rootPath, "package.json");
566
+ if (!await fileExists(rootPkgJson)) {
567
+ return packages;
568
+ }
569
+ const rootPkg = await readJsonFile(rootPkgJson);
570
+ if (!rootPkg.workspaces && !rootPkg.packages) {
571
+ return packages;
572
+ }
573
+ const workspaces = rootPkg.workspaces || rootPkg.packages;
574
+ if (!workspaces) {
575
+ return packages;
576
+ }
577
+ const packagePaths = discoverFromWorkspaces(rootPath, workspaces);
578
+ for (const pkgPath of packagePaths) {
579
+ const pkgJsonPath = path2.join(pkgPath, "package.json");
580
+ try {
581
+ const pkg = await readJsonFile(pkgJsonPath);
582
+ if (pkg.name) {
583
+ packages.push({
584
+ name: pkg.name,
585
+ path: pkgPath,
586
+ packageJsonPath: pkgJsonPath
587
+ });
588
+ }
589
+ } catch {
590
+ continue;
591
+ }
592
+ }
593
+ packages.sort((a, b) => a.name.localeCompare(b.name));
594
+ return packages;
595
+ }
596
+ async function hasWorkspaces(rootPath) {
597
+ const rootPkgJson = path2.join(rootPath, "package.json");
598
+ if (!await fileExists(rootPkgJson)) {
599
+ return false;
600
+ }
601
+ const rootPkg = await readJsonFile(rootPkgJson);
602
+ return Boolean(rootPkg.workspaces || rootPkg.packages);
603
+ }
604
+
605
+ // src/config.ts
606
+ import path3 from "node:path";
607
+ var CONFIG_FILE_NAME = "bun-ready.config.json";
608
+ async function readConfig(rootPath) {
609
+ const configPath = path3.join(rootPath, CONFIG_FILE_NAME);
610
+ if (!await fileExists(configPath)) {
611
+ return null;
612
+ }
613
+ try {
614
+ const config = await readJsonFile(configPath);
615
+ return validateConfig(config);
616
+ } catch (error) {
617
+ const msg = error instanceof Error ? error.message : String(error);
618
+ process.stderr.write(`Warning: Invalid ${CONFIG_FILE_NAME}: ${msg}
619
+ `);
620
+ return null;
621
+ }
622
+ }
623
+ function validateConfig(config) {
624
+ if (!config || typeof config !== "object") {
625
+ return null;
626
+ }
627
+ const cfg = config;
628
+ const result = {};
629
+ if (Array.isArray(cfg.ignorePackages)) {
630
+ const validIgnore = cfg.ignorePackages.filter((p) => typeof p === "string");
631
+ if (validIgnore.length > 0) {
632
+ result.ignorePackages = validIgnore;
633
+ }
634
+ }
635
+ if (Array.isArray(cfg.ignoreFindings)) {
636
+ const validIgnore = cfg.ignoreFindings.filter((f) => typeof f === "string");
637
+ if (validIgnore.length > 0) {
638
+ result.ignoreFindings = validIgnore;
639
+ }
640
+ }
641
+ if (Array.isArray(cfg.nativeAddonAllowlist)) {
642
+ const validAllowlist = cfg.nativeAddonAllowlist.filter((p) => typeof p === "string");
643
+ if (validAllowlist.length > 0) {
644
+ result.nativeAddonAllowlist = validAllowlist;
645
+ }
646
+ }
647
+ if (typeof cfg.failOn === "string") {
648
+ const validFailOn = ["green", "yellow", "red"].includes(cfg.failOn);
649
+ if (validFailOn) {
650
+ result.failOn = cfg.failOn;
651
+ }
652
+ }
653
+ if (Object.keys(result).length === 0) {
654
+ return null;
655
+ }
656
+ return result;
657
+ }
658
+ function mergeConfigWithOpts(config, opts) {
659
+ if (!config && !opts.failOn) {
660
+ return null;
661
+ }
662
+ const result = {
663
+ ...config || {}
664
+ };
665
+ if (opts.failOn) {
666
+ result.failOn = opts.failOn;
667
+ }
668
+ return Object.keys(result).length > 0 ? result : null;
669
+ }
670
+
222
671
  // src/analyze.ts
223
- var readRepoInfo = async (repoPath) => {
224
- const packageJsonPath = path2.join(repoPath, "package.json");
672
+ async function readRepoInfo(packagePath) {
673
+ const packageJsonPath = path4.join(packagePath, "package.json");
225
674
  const pkg = await readJsonFile(packageJsonPath);
226
675
  const scripts = pkg.scripts ?? {};
227
676
  const dependencies = pkg.dependencies ?? {};
228
677
  const devDependencies = pkg.devDependencies ?? {};
229
678
  const optionalDependencies = pkg.optionalDependencies ?? {};
230
- const hasWorkspaces = Boolean(pkg.workspaces);
231
679
  const lockfiles = {
232
- bunLock: await fileExists(path2.join(repoPath, "bun.lock")),
233
- bunLockb: await fileExists(path2.join(repoPath, "bun.lockb")),
234
- npmLock: await fileExists(path2.join(repoPath, "package-lock.json")),
235
- yarnLock: await fileExists(path2.join(repoPath, "yarn.lock")),
236
- pnpmLock: await fileExists(path2.join(repoPath, "pnpm-lock.yaml"))
680
+ bunLock: await fileExists(path4.join(packagePath, "bun.lock")),
681
+ bunLockb: await fileExists(path4.join(packagePath, "bun.lockb")),
682
+ npmLock: await fileExists(path4.join(packagePath, "package-lock.json")),
683
+ yarnLock: await fileExists(path4.join(packagePath, "yarn.lock")),
684
+ pnpmLock: await fileExists(path4.join(packagePath, "pnpm-lock.yaml"))
237
685
  };
238
- return { packageJsonPath, lockfiles, scripts, dependencies, devDependencies, optionalDependencies, hasWorkspaces };
239
- };
240
- var copyIfExists = async (from, to) => {
686
+ return { pkg, scripts, dependencies, devDependencies, optionalDependencies, lockfiles };
687
+ }
688
+ async function copyIfExists(from, to) {
241
689
  try {
242
690
  await fs2.copyFile(from, to);
243
691
  } catch {
244
692
  return;
245
693
  }
246
- };
247
- var runBunInstallDryRun = async (repoPath) => {
248
- const base = await fs2.mkdtemp(path2.join(os.tmpdir(), "bun-ready-"));
694
+ }
695
+ async function runBunInstallDryRun(packagePath) {
696
+ const base = await fs2.mkdtemp(path4.join(os.tmpdir(), "bun-ready-"));
249
697
  const cleanup = async () => {
250
698
  try {
251
699
  await fs2.rm(base, { recursive: true, force: true });
@@ -254,42 +702,147 @@ var runBunInstallDryRun = async (repoPath) => {
254
702
  }
255
703
  };
256
704
  try {
257
- await copyIfExists(path2.join(repoPath, "package.json"), path2.join(base, "package.json"));
258
- await copyIfExists(path2.join(repoPath, "bun.lock"), path2.join(base, "bun.lock"));
259
- await copyIfExists(path2.join(repoPath, "bun.lockb"), path2.join(base, "bun.lockb"));
260
- await copyIfExists(path2.join(repoPath, "package-lock.json"), path2.join(base, "package-lock.json"));
261
- await copyIfExists(path2.join(repoPath, "yarn.lock"), path2.join(base, "yarn.lock"));
262
- await copyIfExists(path2.join(repoPath, "pnpm-lock.yaml"), path2.join(base, "pnpm-lock.yaml"));
705
+ await copyIfExists(path4.join(packagePath, "package.json"), path4.join(base, "package.json"));
706
+ await copyIfExists(path4.join(packagePath, "bun.lock"), path4.join(base, "bun.lock"));
707
+ await copyIfExists(path4.join(packagePath, "bun.lockb"), path4.join(base, "bun.lockb"));
708
+ await copyIfExists(path4.join(packagePath, "package-lock.json"), path4.join(base, "package-lock.json"));
709
+ await copyIfExists(path4.join(packagePath, "yarn.lock"), path4.join(base, "yarn.lock"));
710
+ await copyIfExists(path4.join(packagePath, "pnpm-lock.yaml"), path4.join(base, "pnpm-lock.yaml"));
263
711
  const res = await exec("bun", ["install", "--dry-run"], base);
264
712
  const combined = [...res.stdout ? res.stdout.split(`
265
713
  `) : [], ...res.stderr ? res.stderr.split(`
266
714
  `) : []].filter((l) => l.trim().length > 0);
267
715
  const logs = truncateLines(combined, 60);
268
- return res.code === 0 ? { ok: true, summary: "bun install --dry-run succeeded", logs } : { ok: false, summary: `bun install --dry-run failed (exit ${res.code})`, logs };
716
+ const installAnalysis = parseInstallLogs(logs);
717
+ return res.code === 0 ? { ok: true, summary: "bun install --dry-run succeeded", logs, installAnalysis } : { ok: false, summary: `bun install --dry-run failed (exit ${res.code})`, logs, installAnalysis };
269
718
  } finally {
270
719
  await cleanup();
271
720
  }
272
- };
273
- var shouldRunBunTest = (repo) => {
274
- const t = repo.scripts["test"];
721
+ }
722
+ function shouldRunBunTest(scripts) {
723
+ const t = scripts["test"];
275
724
  if (!t)
276
725
  return false;
277
726
  return t.toLowerCase().includes("bun test") || t.toLowerCase().trim() === "bun test";
278
- };
279
- var runBunTest = async (repoPath) => {
280
- const res = await exec("bun", ["test"], repoPath);
727
+ }
728
+ async function runBunTest(packagePath) {
729
+ const res = await exec("bun", ["test"], packagePath);
281
730
  const combined = [...res.stdout ? res.stdout.split(`
282
731
  `) : [], ...res.stderr ? res.stderr.split(`
283
732
  `) : []].filter((l) => l.trim().length > 0);
284
733
  const logs = truncateLines(combined, 120);
285
734
  return res.code === 0 ? { ok: true, summary: "bun test succeeded", logs } : { ok: false, summary: `bun test failed (exit ${res.code})`, logs };
286
- };
287
- var analyzeRepo = async (opts) => {
735
+ }
736
+ function filterFindings(findings, config) {
737
+ const ignoreList = config?.ignoreFindings;
738
+ if (!ignoreList || ignoreList.length === 0) {
739
+ return findings;
740
+ }
741
+ return findings.filter((f) => !ignoreList.includes(f.id));
742
+ }
743
+ async function analyzeSinglePackage(packagePath, opts, config, pkgName) {
744
+ const info = await readRepoInfo(packagePath);
745
+ const name = pkgName || info.pkg.name || path4.basename(packagePath);
746
+ let findings = [
747
+ ...detectLockfileSignals({ packageJsonPath: packagePath, lockfiles: info.lockfiles, scripts: info.scripts, dependencies: info.dependencies, devDependencies: info.devDependencies, optionalDependencies: info.optionalDependencies, hasWorkspaces: false, packageJson: info.pkg }),
748
+ ...detectScriptRisks({ packageJsonPath: packagePath, lockfiles: info.lockfiles, scripts: info.scripts, dependencies: info.dependencies, devDependencies: info.devDependencies, optionalDependencies: info.optionalDependencies, hasWorkspaces: false, packageJson: info.pkg }),
749
+ ...detectNativeAddonRiskV2({ packageJsonPath: packagePath, lockfiles: info.lockfiles, scripts: info.scripts, dependencies: info.dependencies, devDependencies: info.devDependencies, optionalDependencies: info.optionalDependencies, hasWorkspaces: false, packageJson: info.pkg }, config || undefined),
750
+ ...detectRuntimeApiRisks({ packageJsonPath: packagePath, lockfiles: info.lockfiles, scripts: info.scripts, dependencies: info.dependencies, devDependencies: info.devDependencies, optionalDependencies: info.optionalDependencies, hasWorkspaces: false, packageJson: info.pkg }),
751
+ ...detectPmAssumptions({ packageJsonPath: packagePath, lockfiles: info.lockfiles, scripts: info.scripts, dependencies: info.dependencies, devDependencies: info.devDependencies, optionalDependencies: info.optionalDependencies, hasWorkspaces: false, packageJson: info.pkg })
752
+ ];
753
+ findings = filterFindings(findings, config);
754
+ let install = null;
755
+ let installOk = null;
756
+ if (opts.runInstall) {
757
+ const installResult = await runBunInstallDryRun(packagePath);
758
+ install = {
759
+ ok: installResult.ok,
760
+ summary: installResult.summary,
761
+ logs: installResult.logs
762
+ };
763
+ installOk = installResult.ok;
764
+ if (installResult.installAnalysis.blockedDeps.length > 0) {
765
+ findings.push({
766
+ id: "install.blocked_scripts",
767
+ title: "Lifecycle scripts blocked by Bun",
768
+ severity: "red",
769
+ details: installResult.installAnalysis.blockedDeps,
770
+ hints: [
771
+ "Bun blocks lifecycle scripts of dependencies unless they are in trustedDependencies.",
772
+ "Add the blocked packages to trustedDependencies in root package.json.",
773
+ "Review if these packages are necessary or can be replaced with alternatives."
774
+ ]
775
+ });
776
+ }
777
+ if (installResult.installAnalysis.trustedDepsMentioned.length > 0) {
778
+ findings.push({
779
+ id: "install.trusted_deps",
780
+ title: "Trusted dependencies mentioned in install output",
781
+ severity: "yellow",
782
+ details: installResult.installAnalysis.trustedDepsMentioned,
783
+ hints: [
784
+ "Review the trustedDependencies configuration in your package.json.",
785
+ "Consider adding packages to trustedDependencies if you trust them."
786
+ ]
787
+ });
788
+ }
789
+ }
790
+ let test = null;
791
+ let testOk = null;
792
+ if (opts.runTest && shouldRunBunTest(info.scripts)) {
793
+ const testResult = await runBunTest(packagePath);
794
+ test = {
795
+ ok: testResult.ok,
796
+ summary: testResult.summary,
797
+ logs: testResult.logs
798
+ };
799
+ testOk = testResult.ok;
800
+ }
801
+ const severity = summarizeSeverity(findings, installOk, testOk);
802
+ const summaryLines = [];
803
+ summaryLines.push(`Lockfiles: ${info.lockfiles.bunLock || info.lockfiles.bunLockb ? "bun" : "non-bun or missing"}`);
804
+ summaryLines.push(`Lifecycle scripts: ${Object.keys(info.scripts).some((k) => ["postinstall", "prepare", "preinstall", "install"].includes(k)) ? "present" : "none"}`);
805
+ summaryLines.push(`Native addon risk: ${findings.some((f) => f.id === "deps.native_addons") ? "yes" : "no"}`);
806
+ summaryLines.push(`bun install dry-run: ${install ? install.ok ? "ok" : "failed" : "skipped"}`);
807
+ summaryLines.push(`bun test: ${test ? test.ok ? "ok" : "failed" : "skipped"}`);
808
+ return {
809
+ name,
810
+ path: packagePath,
811
+ severity,
812
+ summaryLines,
813
+ findings,
814
+ install,
815
+ test,
816
+ scripts: info.scripts,
817
+ dependencies: info.dependencies,
818
+ devDependencies: info.devDependencies,
819
+ optionalDependencies: info.optionalDependencies,
820
+ lockfiles: info.lockfiles
821
+ };
822
+ }
823
+ function aggregateSeverity(packages, overallSeverity) {
824
+ if (overallSeverity === "red")
825
+ return "red";
826
+ if (overallSeverity === "yellow")
827
+ return "yellow";
828
+ for (const pkg of packages) {
829
+ if (pkg.severity === "red")
830
+ return "red";
831
+ }
832
+ for (const pkg of packages) {
833
+ if (pkg.severity === "yellow")
834
+ return "yellow";
835
+ }
836
+ return "green";
837
+ }
838
+ async function analyzeRepoOverall(opts) {
288
839
  const repoPath = normalizeRepoPath(opts.repoPath);
289
- const packageJsonPath = path2.join(repoPath, "package.json");
840
+ const packageJsonPath = path4.join(repoPath, "package.json");
290
841
  const hasPkg = await fileExists(packageJsonPath);
842
+ const config = await readConfig(repoPath);
291
843
  if (!hasPkg) {
292
844
  return {
845
+ version: "0.2",
293
846
  severity: "red",
294
847
  summaryLines: ["package.json not found"],
295
848
  findings: [
@@ -305,35 +858,90 @@ var analyzeRepo = async (opts) => {
305
858
  test: null,
306
859
  repo: {
307
860
  packageJsonPath,
861
+ hasWorkspaces: false,
862
+ rootPackage: { name: "", version: "" },
308
863
  lockfiles: { bunLock: false, bunLockb: false, npmLock: false, yarnLock: false, pnpmLock: false },
309
864
  scripts: {},
310
865
  dependencies: {},
311
866
  devDependencies: {},
312
- optionalDependencies: {},
313
- hasWorkspaces: false
314
- }
867
+ optionalDependencies: {}
868
+ },
869
+ packages: [],
870
+ config
315
871
  };
316
872
  }
317
- const repo = await readRepoInfo(repoPath);
318
- const findings = [
319
- ...detectLockfileSignals(repo),
320
- ...detectScriptRisks(repo),
321
- ...detectNativeAddonRisk(repo)
322
- ];
323
- const install = opts.runInstall ? await runBunInstallDryRun(repoPath) : null;
324
- const allowTest = opts.runTest && shouldRunBunTest(repo);
325
- const test = allowTest ? await runBunTest(repoPath) : null;
326
- const installOk = install ? install.ok : null;
327
- const testOk = test ? test.ok : null;
328
- const severity = summarizeSeverity(findings, installOk, testOk);
329
- const summaryLines = [];
330
- summaryLines.push(`Lockfiles: ${repo.lockfiles.bunLock || repo.lockfiles.bunLockb ? "bun" : "non-bun or missing"}`);
331
- summaryLines.push(`Lifecycle scripts: ${Object.keys(repo.scripts).some((k) => ["postinstall", "prepare", "preinstall", "install"].includes(k)) ? "present" : "none"}`);
332
- summaryLines.push(`Native addon risk: ${findings.some((f) => f.id === "deps.native_addons") ? "yes" : "no"}`);
333
- summaryLines.push(`bun install dry-run: ${install ? install.ok ? "ok" : "failed" : "skipped"}`);
334
- summaryLines.push(`bun test: ${test ? test.ok ? "ok" : "failed" : allowTest ? "running" : "skipped"}`);
335
- return { severity, summaryLines, findings, install, test, repo };
336
- };
873
+ const rootInfo = await readRepoInfo(repoPath);
874
+ const rootHasWorkspaces = await hasWorkspaces(repoPath);
875
+ const workspacePackages = [];
876
+ if (rootHasWorkspaces) {
877
+ workspacePackages.push(...await discoverWorkspaces(repoPath));
878
+ }
879
+ let packagesToAnalyze = [];
880
+ let packagesToReport = [];
881
+ if (opts.scope === "root") {
882
+ packagesToAnalyze = [repoPath];
883
+ packagesToReport = [repoPath];
884
+ } else if (opts.scope === "packages") {
885
+ packagesToAnalyze = workspacePackages.map((wp) => wp.path);
886
+ packagesToReport = workspacePackages.map((wp) => wp.path);
887
+ } else {
888
+ packagesToAnalyze = [repoPath, ...workspacePackages.map((wp) => wp.path)];
889
+ packagesToReport = [repoPath, ...workspacePackages.map((wp) => wp.path)];
890
+ }
891
+ const ignorePackages = config?.ignorePackages;
892
+ if (ignorePackages && ignorePackages.length > 0) {
893
+ packagesToAnalyze = packagesToAnalyze.filter((p) => {
894
+ return !ignorePackages.some((ignore) => p.includes(ignore));
895
+ });
896
+ packagesToReport = packagesToReport.filter((p) => {
897
+ return !ignorePackages.some((ignore) => p.includes(ignore));
898
+ });
899
+ }
900
+ const packages = [];
901
+ for (const packagePath of packagesToAnalyze) {
902
+ const wp = workspacePackages.find((w) => w.path === packagePath);
903
+ const analysis = await analyzeSinglePackage(packagePath, opts, config, wp?.name);
904
+ packages.push(analysis);
905
+ }
906
+ const rootAnalysis = packages.find((p) => p.path === repoPath);
907
+ let overallSeverity = "green";
908
+ if (rootAnalysis) {
909
+ overallSeverity = rootAnalysis.severity;
910
+ }
911
+ overallSeverity = aggregateSeverity(packages, overallSeverity);
912
+ const overallFindings = rootAnalysis ? rootAnalysis.findings : [];
913
+ const overallSummaryLines = [];
914
+ overallSummaryLines.push(`Total packages analyzed: ${packages.length}`);
915
+ overallSummaryLines.push(`Workspaces detected: ${rootHasWorkspaces ? "yes" : "no"}`);
916
+ if (rootHasWorkspaces) {
917
+ overallSummaryLines.push(`Workspace packages: ${workspacePackages.length}`);
918
+ }
919
+ overallSummaryLines.push(`Root package severity: ${rootAnalysis ? rootAnalysis.severity : "unknown"}`);
920
+ overallSummaryLines.push(`Overall severity: ${overallSeverity}`);
921
+ return {
922
+ version: "0.2",
923
+ severity: overallSeverity,
924
+ summaryLines: overallSummaryLines,
925
+ findings: overallFindings,
926
+ install: rootAnalysis ? rootAnalysis.install : null,
927
+ test: rootAnalysis ? rootAnalysis.test : null,
928
+ repo: {
929
+ packageJsonPath,
930
+ hasWorkspaces: rootHasWorkspaces,
931
+ rootPackage: {
932
+ name: rootInfo.pkg.name || "",
933
+ version: rootInfo.pkg.version || ""
934
+ },
935
+ lockfiles: rootInfo.lockfiles,
936
+ scripts: rootInfo.scripts,
937
+ dependencies: rootInfo.dependencies,
938
+ devDependencies: rootInfo.devDependencies,
939
+ optionalDependencies: rootInfo.optionalDependencies
940
+ },
941
+ packages,
942
+ config
943
+ };
944
+ }
337
945
 
338
946
  // src/report_md.ts
339
947
  var badge = (s) => {
@@ -343,7 +951,24 @@ var badge = (s) => {
343
951
  return "\uD83D\uDFE1 YELLOW";
344
952
  return "\uD83D\uDD34 RED";
345
953
  };
346
- var renderMarkdown = (r) => {
954
+ var getTopFindings = (pkg, count = 3) => {
955
+ const sorted = [...pkg.findings].sort((a, b) => {
956
+ const severityOrder = { red: 0, yellow: 1, green: 2 };
957
+ if (severityOrder[a.severity] !== severityOrder[b.severity]) {
958
+ return severityOrder[a.severity] - severityOrder[b.severity];
959
+ }
960
+ return a.id.localeCompare(b.id);
961
+ });
962
+ return sorted.slice(0, count).map((f) => `${badge(f.severity)} ${f.title}`);
963
+ };
964
+ var packageRow = (pkg) => {
965
+ const name = pkg.name;
966
+ const path5 = pkg.path.replace(/\\/g, "/");
967
+ const severity = badge(pkg.severity);
968
+ const topFindings = getTopFindings(pkg, 2).join(", ") || "No issues";
969
+ return `| ${name} | \`${path5}\` | ${severity} | ${topFindings} |`;
970
+ };
971
+ function renderMarkdown(r) {
347
972
  const lines = [];
348
973
  lines.push(`# bun-ready report`);
349
974
  lines.push(``);
@@ -353,77 +978,157 @@ var renderMarkdown = (r) => {
353
978
  for (const l of r.summaryLines)
354
979
  lines.push(`- ${l}`);
355
980
  lines.push(``);
356
- lines.push(`## Repo`);
981
+ if (r.version) {
982
+ lines.push(`**Report version:** ${r.version}`);
983
+ lines.push(``);
984
+ }
985
+ if (r.config) {
986
+ lines.push(`**Configuration:**`);
987
+ const configInfo = [];
988
+ if (r.config.ignorePackages && r.config.ignorePackages.length > 0) {
989
+ configInfo.push(`Ignored packages: ${r.config.ignorePackages.join(", ")}`);
990
+ }
991
+ if (r.config.ignoreFindings && r.config.ignoreFindings.length > 0) {
992
+ configInfo.push(`Ignored findings: ${r.config.ignoreFindings.join(", ")}`);
993
+ }
994
+ if (r.config.nativeAddonAllowlist && r.config.nativeAddonAllowlist.length > 0) {
995
+ configInfo.push(`Native addon allowlist: ${r.config.nativeAddonAllowlist.join(", ")}`);
996
+ }
997
+ if (r.config.failOn) {
998
+ configInfo.push(`Fail on: ${r.config.failOn}`);
999
+ }
1000
+ if (configInfo.length > 0) {
1001
+ for (const info of configInfo) {
1002
+ lines.push(`- ${info}`);
1003
+ }
1004
+ } else {
1005
+ lines.push(`- Using default configuration`);
1006
+ }
1007
+ lines.push(``);
1008
+ }
1009
+ lines.push(`## Root Package`);
357
1010
  lines.push(`- Path: \`${r.repo.packageJsonPath.replace(/\\/g, "/")}\``);
358
1011
  lines.push(`- Workspaces: ${r.repo.hasWorkspaces ? "yes" : "no"}`);
359
- const lock = [];
360
- if (r.repo.lockfiles.bunLock)
361
- lock.push("bun.lock");
362
- if (r.repo.lockfiles.bunLockb)
363
- lock.push("bun.lockb");
364
- if (r.repo.lockfiles.npmLock)
365
- lock.push("package-lock.json");
366
- if (r.repo.lockfiles.yarnLock)
367
- lock.push("yarn.lock");
368
- if (r.repo.lockfiles.pnpmLock)
369
- lock.push("pnpm-lock.yaml");
370
- lines.push(`- Lockfiles: ${lock.length === 0 ? "none" : lock.join(", ")}`);
1012
+ lines.push(`- Name: ${r.repo.rootPackage?.name || "unknown"}`);
1013
+ lines.push(`- Version: ${r.repo.rootPackage?.version || "unknown"}`);
371
1014
  lines.push(``);
372
- if (r.install) {
1015
+ if (r.packages && (r.packages.length > 1 || r.packages.length === 1 && r.repo.hasWorkspaces)) {
1016
+ lines.push(`## Packages Overview`);
1017
+ lines.push(`| Package | Path | Status | Key Findings |`);
1018
+ lines.push(`|---------|------|--------|--------------|`);
1019
+ const sortedPackages = stableSort(r.packages, (p) => p.name);
1020
+ for (const pkg of sortedPackages) {
1021
+ lines.push(packageRow(pkg));
1022
+ }
1023
+ lines.push(``);
1024
+ }
1025
+ const rootPkg = r.packages?.find((p) => p.path === r.repo.packageJsonPath);
1026
+ if (rootPkg?.install) {
373
1027
  lines.push(`## bun install (dry-run)`);
374
- lines.push(`- Result: ${r.install.ok ? "ok" : "failed"}`);
375
- lines.push(`- Summary: ${r.install.summary}`);
376
- if (r.install.logs.length > 0) {
1028
+ lines.push(`- Result: ${rootPkg.install.ok ? "ok" : "failed"}`);
1029
+ lines.push(`- Summary: ${rootPkg.install.summary}`);
1030
+ if (rootPkg.install.logs.length > 0) {
377
1031
  lines.push(``);
378
1032
  lines.push("```text");
379
- for (const l of r.install.logs)
1033
+ for (const l of rootPkg.install.logs)
380
1034
  lines.push(l);
381
1035
  lines.push("```");
382
1036
  }
383
1037
  lines.push(``);
384
1038
  }
385
- if (r.test) {
1039
+ if (rootPkg?.test) {
386
1040
  lines.push(`## bun test`);
387
- lines.push(`- Result: ${r.test.ok ? "ok" : "failed"}`);
388
- lines.push(`- Summary: ${r.test.summary}`);
389
- if (r.test.logs.length > 0) {
1041
+ lines.push(`- Result: ${rootPkg.test.ok ? "ok" : "failed"}`);
1042
+ lines.push(`- Summary: ${rootPkg.test.summary}`);
1043
+ if (rootPkg.test.logs.length > 0) {
390
1044
  lines.push(``);
391
1045
  lines.push("```text");
392
- for (const l of r.test.logs)
1046
+ for (const l of rootPkg.test.logs)
393
1047
  lines.push(l);
394
1048
  lines.push("```");
395
1049
  }
396
1050
  lines.push(``);
397
1051
  }
398
- lines.push(`## Findings`);
1052
+ lines.push(`## Root Findings`);
399
1053
  if (r.findings.length === 0) {
400
- lines.push(`No findings. Looks good.`);
401
- lines.push(``);
402
- return lines.join(`
403
- `);
1054
+ lines.push(`No findings for root package.`);
1055
+ } else {
1056
+ const findings = stableSort(r.findings, (f) => `${f.severity}:${f.id}`);
1057
+ for (const f of findings) {
1058
+ lines.push(`### ${f.title} (${badge(f.severity)})`);
1059
+ lines.push(``);
1060
+ for (const d of f.details)
1061
+ lines.push(`- ${d}`);
1062
+ if (f.hints.length > 0) {
1063
+ lines.push(``);
1064
+ lines.push(`**Hints:**`);
1065
+ for (const h of f.hints)
1066
+ lines.push(`- ${h}`);
1067
+ }
1068
+ lines.push(``);
1069
+ }
404
1070
  }
405
- const findings = stableSort(r.findings, (f) => `${f.severity}:${f.id}`);
406
- for (const f of findings) {
407
- lines.push(`### ${f.title} (${badge(f.severity)})`);
408
- lines.push(``);
409
- for (const d of f.details)
410
- lines.push(`- ${d}`);
411
- if (f.hints.length > 0) {
1071
+ lines.push(``);
1072
+ if (r.packages && r.packages.length > 0) {
1073
+ const sortedPackages = stableSort(r.packages, (p) => p.name);
1074
+ for (const pkg of sortedPackages) {
1075
+ lines.push(`## Package: ${pkg.name} (${badge(pkg.severity)})`);
1076
+ lines.push(``);
1077
+ lines.push(`**Path:** \`${pkg.path.replace(/\\/g, "/")}\``);
1078
+ lines.push(``);
1079
+ for (const l of pkg.summaryLines)
1080
+ lines.push(`- ${l}`);
1081
+ lines.push(``);
1082
+ if (pkg.install) {
1083
+ lines.push(`**bun install (dry-run):** ${pkg.install.ok ? "ok" : "failed"}`);
1084
+ if (pkg.install.logs.length > 0 && pkg.install.logs.length < 10) {
1085
+ lines.push(``);
1086
+ lines.push("```text");
1087
+ for (const l of pkg.install.logs)
1088
+ lines.push(l);
1089
+ lines.push("```");
1090
+ }
1091
+ lines.push(``);
1092
+ }
1093
+ if (pkg.test) {
1094
+ lines.push(`**bun test:** ${pkg.test.ok ? "ok" : "failed"}`);
1095
+ if (pkg.test.logs.length > 0 && pkg.test.logs.length < 10) {
1096
+ lines.push(``);
1097
+ lines.push("```text");
1098
+ for (const l of pkg.test.logs)
1099
+ lines.push(l);
1100
+ lines.push("```");
1101
+ }
1102
+ lines.push(``);
1103
+ }
1104
+ lines.push(`**Findings:**`);
1105
+ if (pkg.findings.length === 0) {
1106
+ lines.push(`No findings for this package.`);
1107
+ } else {
1108
+ const findings = stableSort(pkg.findings, (f) => `${f.severity}:${f.id}`);
1109
+ for (const f of findings) {
1110
+ lines.push(``);
1111
+ lines.push(`### ${f.title} (${badge(f.severity)})`);
1112
+ for (const d of f.details)
1113
+ lines.push(`- ${d}`);
1114
+ if (f.hints.length > 0) {
1115
+ lines.push(`**Hints:**`);
1116
+ for (const h of f.hints)
1117
+ lines.push(`- ${h}`);
1118
+ }
1119
+ }
1120
+ }
412
1121
  lines.push(``);
413
- lines.push(`**Hints:**`);
414
- for (const h of f.hints)
415
- lines.push(`- ${h}`);
416
1122
  }
417
- lines.push(``);
418
1123
  }
419
1124
  return lines.join(`
420
1125
  `);
421
- };
1126
+ }
422
1127
 
423
1128
  // src/report_json.ts
424
- var renderJson = (r) => {
1129
+ function renderJson(r) {
425
1130
  return JSON.stringify(r, null, 2);
426
- };
1131
+ }
427
1132
 
428
1133
  // src/cli.ts
429
1134
  var usage = () => {
@@ -431,10 +1136,22 @@ var usage = () => {
431
1136
  "bun-ready",
432
1137
  "",
433
1138
  "Usage:",
434
- " bun-ready scan <path> [--format md|json] [--out <file>] [--no-install] [--no-test] [--verbose]",
1139
+ " bun-ready scan <path> [--format md|json] [--out <file>] [--no-install] [--no-test] [--verbose] [--scope root|packages|all] [--fail-on green|yellow|red]",
1140
+ "",
1141
+ "Options:",
1142
+ " --format md|json Output format (default: md)",
1143
+ " --out <file> Output file path (default: bun-ready.md or bun-ready.json)",
1144
+ " --no-install Skip bun install --dry-run",
1145
+ " --no-test Skip bun test",
1146
+ " --verbose Show detailed output",
1147
+ " --scope root|packages|all Scan scope for monorepos (default: all)",
1148
+ " --fail-on green|yellow|red Fail policy (default: red)",
435
1149
  "",
436
1150
  "Exit codes:",
437
- " 0 green, 2 yellow, 3 red"
1151
+ " 0 green",
1152
+ " 2 yellow",
1153
+ " 3 red",
1154
+ " 1 invalid command"
438
1155
  ].join(`
439
1156
  `);
440
1157
  };
@@ -444,7 +1161,15 @@ var parseArgs = (argv) => {
444
1161
  if (cmd !== "scan") {
445
1162
  return {
446
1163
  cmd,
447
- opts: { repoPath: ".", format: "md", outFile: null, runInstall: true, runTest: true, verbose: false }
1164
+ opts: {
1165
+ repoPath: ".",
1166
+ format: "md",
1167
+ outFile: null,
1168
+ runInstall: true,
1169
+ runTest: true,
1170
+ verbose: false,
1171
+ scope: "all"
1172
+ }
448
1173
  };
449
1174
  }
450
1175
  const repoPath = args[1] && !args[1].startsWith("-") ? args[1] : ".";
@@ -453,6 +1178,8 @@ var parseArgs = (argv) => {
453
1178
  let runInstall = true;
454
1179
  let runTest = true;
455
1180
  let verbose = false;
1181
+ let scope = "all";
1182
+ let failOn;
456
1183
  for (let i = 2;i < args.length; i++) {
457
1184
  const a = args[i] ?? "";
458
1185
  if (a === "--format") {
@@ -479,10 +1206,56 @@ var parseArgs = (argv) => {
479
1206
  verbose = true;
480
1207
  continue;
481
1208
  }
1209
+ if (a === "--scope") {
1210
+ const v = args[i + 1] ?? "";
1211
+ if (v === "root" || v === "packages" || v === "all")
1212
+ scope = v;
1213
+ i++;
1214
+ continue;
1215
+ }
1216
+ if (a === "--fail-on") {
1217
+ const v = args[i + 1] ?? "";
1218
+ if (v === "green" || v === "yellow" || v === "red")
1219
+ failOn = v;
1220
+ i++;
1221
+ continue;
1222
+ }
1223
+ }
1224
+ const baseOpts = {
1225
+ repoPath,
1226
+ format,
1227
+ outFile,
1228
+ runInstall,
1229
+ runTest,
1230
+ verbose,
1231
+ scope
1232
+ };
1233
+ if (failOn !== undefined) {
1234
+ baseOpts.failOn = failOn;
482
1235
  }
483
- return { cmd, opts: { repoPath, format, outFile, runInstall, runTest, verbose } };
1236
+ return {
1237
+ cmd,
1238
+ opts: baseOpts
1239
+ };
484
1240
  };
485
- var exitCode = (sev) => {
1241
+ var exitCode = (sev, failOn) => {
1242
+ if (!failOn) {
1243
+ if (sev === "green")
1244
+ return 0;
1245
+ if (sev === "yellow")
1246
+ return 2;
1247
+ return 3;
1248
+ }
1249
+ if (failOn === "green") {
1250
+ if (sev === "green")
1251
+ return 0;
1252
+ return 3;
1253
+ }
1254
+ if (failOn === "yellow") {
1255
+ if (sev === "red")
1256
+ return 3;
1257
+ return 0;
1258
+ }
486
1259
  if (sev === "green")
487
1260
  return 0;
488
1261
  if (sev === "yellow")
@@ -496,14 +1269,27 @@ var main = async () => {
496
1269
  `);
497
1270
  process.exit(1);
498
1271
  }
499
- const res = await analyzeRepo(opts);
1272
+ const config = await mergeConfigWithOpts(null, opts);
1273
+ const scanOpts = {
1274
+ repoPath: opts.repoPath,
1275
+ format: opts.format,
1276
+ outFile: opts.outFile,
1277
+ runInstall: opts.runInstall,
1278
+ runTest: opts.runTest,
1279
+ verbose: opts.verbose,
1280
+ scope: opts.scope
1281
+ };
1282
+ if (opts.failOn !== undefined) {
1283
+ scanOpts.failOn = opts.failOn;
1284
+ }
1285
+ const res = await analyzeRepoOverall(scanOpts);
500
1286
  const out = opts.format === "json" ? renderJson(res) : renderMarkdown(res);
501
1287
  const target = opts.outFile ?? (opts.format === "json" ? "bun-ready.json" : "bun-ready.md");
502
- const resolved = path3.resolve(process.cwd(), target);
1288
+ const resolved = path5.resolve(process.cwd(), target);
503
1289
  await fs3.writeFile(resolved, out, "utf8");
504
1290
  process.stdout.write(`Wrote ${opts.format.toUpperCase()} report to ${resolved}
505
1291
  `);
506
- process.exit(exitCode(res.severity));
1292
+ process.exit(exitCode(res.severity, config?.failOn || opts.failOn));
507
1293
  };
508
1294
  main().catch((e) => {
509
1295
  const msg = e instanceof Error ? e.message : String(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-ready",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI that estimates how painful migrating a Node.js repo to Bun might be. Generates a green/yellow/red Markdown report with reasons.",
5
5
  "author": "Pas7 Studio",
6
6
  "license": "Apache-2.0",