@cellajs/cli 0.4.0 → 0.4.2

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/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // src/cella-cli.ts
4
- import { existsSync as existsSync12, statSync as statSync3 } from "fs";
5
- import { join as join17, resolve as resolve7 } from "path";
6
- import process8 from "process";
4
+ import { existsSync as existsSync13, statSync as statSync3 } from "fs";
5
+ import { join as join19, resolve as resolve7 } from "path";
6
+ import process9 from "process";
7
7
 
8
8
  // src/cli.ts
9
9
  import process5 from "process";
@@ -219,7 +219,7 @@ import process4 from "process";
219
219
  // package.json
220
220
  var package_default = {
221
221
  name: "@cellajs/cli",
222
- version: "0.4.0",
222
+ version: "0.4.2",
223
223
  private: false,
224
224
  license: "MIT",
225
225
  description: "CLI for managing your monorepo and syncing with upstream Cella.",
@@ -300,7 +300,7 @@ var package_default = {
300
300
  "sync",
301
301
  "template"
302
302
  ],
303
- packageManager: "pnpm@11.8.0",
303
+ packageManager: "pnpm@11.28.7",
304
304
  commitlint: {
305
305
  extends: [
306
306
  "@commitlint/config-conventional"
@@ -536,16 +536,18 @@ function printFileSection(files, title, linkOptions, options) {
536
536
  }
537
537
  }
538
538
  }
539
+ function filesWithStatus(files, status) {
540
+ return files.filter((f) => f.status === status && !isManagedFile(f.path));
541
+ }
539
542
  function printFileGroup(files, status, linkOptions, options) {
540
- const filtered = files.filter((f) => f.status === status && !isManagedFile(f.path));
541
543
  const config = statusConfig[status];
542
544
  const title = options?.title ?? `${config.icon} ${config.label}`;
543
- printFileSection(filtered, title, linkOptions, { ...options, icon: config.icon });
545
+ printFileSection(filesWithStatus(files, status), title, linkOptions, { ...options, icon: config.icon });
544
546
  }
545
547
  function findProtectedBehind(files) {
546
548
  return files.filter((file) => file.upstreamChanged && !isManagedFile(file.path));
547
549
  }
548
- var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so diff each against upstream and adopt what you need.";
550
+ var PROTECTED_BEHIND_HINT = "pinned/ignored files where upstream also changed since the last sync; the fork side wins on conflict, so review what upstream changed and adopt what you need.";
549
551
  function protectionIcon(file) {
550
552
  return file.isIgnored ? statusConfig.ignored.icon : statusConfig.pinned.icon;
551
553
  }
@@ -553,22 +555,11 @@ function formatUpstreamChangedLines(file) {
553
555
  if (file.upstreamChangedLines === void 0) return "";
554
556
  return colors_default.yellow(` \xB7 ${plural(file.upstreamChangedLines, "line")} changed upstream`);
555
557
  }
556
- function protectedDiffHints(source, paths) {
557
- const range = source?.upstreamDiffRange;
558
- if (!range) return [];
559
- const listed = new Set(paths);
560
- const groups = (source?.protectedUpstreamChanges ?? []).filter((group) => group.paths.some((p) => listed.has(p)));
561
- if (groups.length === 0) return [];
562
- return [
563
- "what upstream changed since the last sync, per pinned/ignored entry:",
564
- ...groups.map((group) => ` git diff ${range} -- ${shellPath(group.entry)}`)
565
- ];
566
- }
567
558
  function formatUpstreamLinesAbsent(file) {
568
559
  const n = file.upstreamLinesAbsent ?? 0;
569
560
  return n > 0 ? colors_default.yellow(` \xB7 ${plural(n, "upstream line")} absent`) : "";
570
561
  }
571
- function printAnalysisFileGroups(files, linkOptions, diffSource) {
562
+ function printAnalysisFileGroups(files, linkOptions) {
572
563
  const protectedBehind = findProtectedBehind(files);
573
564
  printFileGroup(files, "behind", linkOptions, {
574
565
  title: colors_default.cyan("\u2193 behind on upstream")
@@ -585,13 +576,7 @@ function printAnalysisFileGroups(files, linkOptions, diffSource) {
585
576
  icon: protectionIcon,
586
577
  suffix: formatUpstreamChangedLines,
587
578
  dateSource: "upstream",
588
- hint: [
589
- PROTECTED_BEHIND_HINT,
590
- ...protectedDiffHints(
591
- diffSource,
592
- protectedBehind.map((file) => file.path)
593
- )
594
- ]
579
+ hint: PROTECTED_BEHIND_HINT
595
580
  });
596
581
  printFileGroup(files, "drifted", linkOptions, {
597
582
  title: `${warningMark} ${colors_default.yellow("drifted from upstream")}`,
@@ -636,18 +621,15 @@ function printProtectedConflicts(result) {
636
621
  const icon = file ? protectionIcon(file) : statusConfig.pinned.icon;
637
622
  console.info(` ${icon} ${path}${file ? formatUpstreamChangedLines(file) : ""}`);
638
623
  }
639
- for (const line of [PROTECTED_BEHIND_HINT, ...protectedDiffHints(result, paths)]) console.info(colors_default.dim(` ${line}`));
640
- }
641
- function plural(count2, singular, pluralForm = `${singular}s`) {
642
- return `${count2} ${count2 === 1 ? singular : pluralForm}`;
624
+ console.info(colors_default.dim(` ${PROTECTED_BEHIND_HINT}`));
643
625
  }
644
- function shellPath(path) {
645
- return /^[\w./@+-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`;
626
+ function plural(count3, singular, pluralForm = `${singular}s`) {
627
+ return `${count3} ${count3 === 1 ? singular : pluralForm}`;
646
628
  }
647
629
  function printIgnoredUpstreamChanges(result) {
648
630
  const groups = result.ignoredUpstreamChanges ?? [];
649
631
  if (groups.length === 0) return;
650
- const total = groups.reduce((count2, group) => count2 + group.paths.length, 0);
632
+ const total = groups.reduce((count3, group) => count3 + group.paths.length, 0);
651
633
  console.info();
652
634
  console.info(
653
635
  `${warningMark} ${colors_default.yellow(`ignored paths changed upstream \xB7 ${plural(total, "file")} under ${plural(groups.length, "entry", "entries")}`)}`
@@ -657,11 +639,8 @@ function printIgnoredUpstreamChanges(result) {
657
639
  if (group.added > 0) counts.push(`${group.added} new`);
658
640
  if (group.deleted > 0) counts.push(`${group.deleted} deleted`);
659
641
  console.info(` ${statusConfig.ignored.icon} ${group.entry} ${colors_default.dim(`\xB7 ${counts.join(", ")}`)}`);
660
- if (result.upstreamDiffRange) {
661
- console.info(colors_default.dim(` git diff ${result.upstreamDiffRange} -- ${shellPath(group.entry)}`));
662
- }
663
642
  }
664
- console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: diff and adopt what you need."));
643
+ console.info(colors_default.dim(" the fork left these untouched and ignored paths never sync: review and adopt what you need."));
665
644
  }
666
645
  function printUpstreamOverrideChanges(result) {
667
646
  const report = result.upstreamOverrides;
@@ -1393,18 +1372,6 @@ function groupIgnoredUpstreamChanges(files, ignored) {
1393
1372
  for (const group of groups.values()) group.paths.sort();
1394
1373
  return entries.flatMap((entry) => groups.get(entry) ?? []);
1395
1374
  }
1396
- function groupProtectedUpstreamChanges(paths, config) {
1397
- const [, findEntry] = entryMatcher([...config.overrides?.pinned ?? [], ...config.overrides?.ignored ?? []]);
1398
- const groups = /* @__PURE__ */ new Map();
1399
- for (const path of paths) {
1400
- const entry = findEntry(path) ?? path;
1401
- const group = groups.get(entry) ?? { entry, paths: [] };
1402
- group.paths.push(path);
1403
- groups.set(entry, group);
1404
- }
1405
- for (const group of groups.values()) group.paths.sort();
1406
- return [...groups.values()];
1407
- }
1408
1375
  async function validateOverrides(config, forkPath, upstreamRef) {
1409
1376
  const warnings = [];
1410
1377
  const ignored = config.overrides?.ignored ?? [];
@@ -1524,7 +1491,7 @@ var serviceDefinitions = [
1524
1491
  },
1525
1492
  {
1526
1493
  name: "audit",
1527
- description: "update outdated packages, then rebuild the security overrides from a fresh audit",
1494
+ description: "update outdated packages and runtime pins, then rebuild the security overrides from a fresh audit",
1528
1495
  options: [["--list", "report only: no prompts, and nothing is written"]]
1529
1496
  },
1530
1497
  {
@@ -1845,8 +1812,8 @@ async function compareUpstreamOverrides(repoPath, mergeBaseRef, incomingRef, for
1845
1812
  packageJsonSync: forkConfig.settings.packageJsonSync ?? DEFAULT_PACKAGE_JSON_SYNC
1846
1813
  };
1847
1814
  const changes = diffOverrideLists(base, incoming, fork);
1848
- const count2 = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
1849
- return count2 > 0 ? { kind: "changes", ...changes } : void 0;
1815
+ const count3 = Object.values(changes).reduce((n, list) => n + list.added.length + list.removed.length, 0);
1816
+ return count3 > 0 ? { kind: "changes", ...changes } : void 0;
1850
1817
  }
1851
1818
 
1852
1819
  // src/services/analyze-core.ts
@@ -2281,7 +2248,7 @@ function resultMeta(config, ctx) {
2281
2248
  upstreamCommit: ctx.upstreamCommit
2282
2249
  };
2283
2250
  }
2284
- async function upstreamOnlyReports(config, ctx, files, protectedConflicts) {
2251
+ async function upstreamOnlyReports(config, ctx, files) {
2285
2252
  const { forkPath } = config;
2286
2253
  try {
2287
2254
  const [baseSha, upstreamSha, upstreamOverrides] = await Promise.all([
@@ -2293,8 +2260,7 @@ async function upstreamOnlyReports(config, ctx, files, protectedConflicts) {
2293
2260
  return {
2294
2261
  upstreamDiffRange: `${baseSha}..${upstreamSha}`,
2295
2262
  upstreamOverrides,
2296
- ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? []),
2297
- protectedUpstreamChanges: groupProtectedUpstreamChanges(protectedConflicts, config)
2263
+ ignoredUpstreamChanges: groupIgnoredUpstreamChanges(files, config.overrides?.ignored ?? [])
2298
2264
  };
2299
2265
  } catch {
2300
2266
  return {};
@@ -2379,7 +2345,7 @@ async function runSyncMerge(config, ctx, onProgress, onStep) {
2379
2345
  autoMergedFiles,
2380
2346
  protectedConflicts,
2381
2347
  ...resultMeta(config, ctx),
2382
- ...await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts),
2348
+ ...await upstreamOnlyReports(config, ctx, analyzedFiles),
2383
2349
  ...notesResult(notes)
2384
2350
  };
2385
2351
  }
@@ -2397,7 +2363,7 @@ async function runAnalyzePreview(config, ctx, onProgress, onStep) {
2397
2363
  );
2398
2364
  await enrichChangeInfo(forkPath, analyzedFiles, ctx.mergeBase, "HEAD", ctx.upstreamRef);
2399
2365
  const protectedConflicts = analyzedFiles.filter((f) => f.upstreamChanged).map((f) => f.path);
2400
- const reports = await upstreamOnlyReports(config, ctx, analyzedFiles, protectedConflicts);
2366
+ const reports = await upstreamOnlyReports(config, ctx, analyzedFiles);
2401
2367
  onStep?.("analysis complete", `${analyzedFiles.length} files analyzed, dry run, no changes applied`);
2402
2368
  return {
2403
2369
  success: true,
@@ -2618,6 +2584,234 @@ async function runMigrate(config) {
2618
2584
  }
2619
2585
  }
2620
2586
 
2587
+ // src/services/review-page.ts
2588
+ import { basename as basename2 } from "path";
2589
+ import process7 from "process";
2590
+ import { pathToFileURL } from "url";
2591
+
2592
+ // src/utils/diff.ts
2593
+ import { execFileSync as execFileSync2 } from "child_process";
2594
+ import { mkdirSync, writeFileSync } from "fs";
2595
+ import { join as join9 } from "path";
2596
+ import process6 from "process";
2597
+ function gitDiffFile(cwd, range, filePath) {
2598
+ try {
2599
+ return execFileSync2("git", ["diff", "--no-color", range, "--", filePath], { cwd, maxBuffer: MAX_BUFFER });
2600
+ } catch (error) {
2601
+ const stderr = error.stderr?.toString().trim();
2602
+ throw new Error(stderr || `failed to diff ${filePath}`);
2603
+ }
2604
+ }
2605
+ function escapeHtml(text) {
2606
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2607
+ }
2608
+ async function renderDiffPage(sections, meta) {
2609
+ const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
2610
+ const renderFile = async (file) => {
2611
+ const { prerenderedHTML } = await preloadPatchDiff({
2612
+ patch: file.patch,
2613
+ options: {
2614
+ diffStyle: "split",
2615
+ themeType: "system",
2616
+ lineDiffType: "word-alt",
2617
+ hunkSeparators: "line-info",
2618
+ overflow: "wrap"
2619
+ }
2620
+ });
2621
+ return `<div class="diff-shell"><template shadowrootmode="open">
2622
+ ${prerenderedHTML}
2623
+ </template></div>`;
2624
+ };
2625
+ const blocks = [];
2626
+ for (const [index2, section] of sections.entries()) {
2627
+ const files = [];
2628
+ for (const file of section.files) files.push(await renderFile(file));
2629
+ const count3 = section.files.length + (section.omitted?.length ?? 0);
2630
+ const title = section.title ? `<h2>${escapeHtml(section.title)} <span class="count">\xB7 ${count3} ${count3 === 1 ? "file" : "files"}</span></h2>` : "";
2631
+ const hint = section.hint ? `<p class="hint">${escapeHtml(section.hint)}</p>` : "";
2632
+ const omitted = section.omitted?.length ? `<p class="hint">Not shown here (binary, too large, or past the page limit):</p><ul class="omitted">${section.omitted.map((path) => `<li><code>${escapeHtml(path)}</code></li>`).join("")}</ul>` : "";
2633
+ blocks.push(`<section id="section-${index2 + 1}">${title}${hint}${files.join("\n")}${omitted}</section>`);
2634
+ }
2635
+ const titled = sections.filter((section) => section.title);
2636
+ const index = titled.length > 1 ? `<nav><ol>${sections.map(
2637
+ (section, i) => section.title ? `<li><a href="#section-${i + 1}">${escapeHtml(section.title)}</a> <span class="count">\xB7 ${section.files.length + (section.omitted?.length ?? 0)}</span></li>` : ""
2638
+ ).join("")}</ol></nav>` : "";
2639
+ const note = meta.note ? `<span class="note">${escapeHtml(meta.note)}</span>` : "";
2640
+ return `<!doctype html>
2641
+ <html lang="en">
2642
+ <head>
2643
+ <meta charset="utf-8">
2644
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2645
+ <title>${escapeHtml(meta.title)}</title>
2646
+ <style>
2647
+ :root { color-scheme: light dark; }
2648
+ * { box-sizing: border-box; }
2649
+ body {
2650
+ margin: 0;
2651
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
2652
+ background: light-dark(#fafafa, #111);
2653
+ color: light-dark(#111, #eee);
2654
+ }
2655
+ header {
2656
+ display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
2657
+ padding: 0.9rem 1.25rem;
2658
+ border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
2659
+ position: sticky; top: 0; z-index: 1;
2660
+ background: inherit;
2661
+ }
2662
+ header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
2663
+ header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
2664
+ header .note { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
2665
+ main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
2666
+ nav ol { margin: 0 0 1.5rem; padding-left: 1.25rem; line-height: 1.7; }
2667
+ nav a { color: inherit; }
2668
+ section { scroll-margin-top: 4rem; }
2669
+ h2 { font-size: 1.05rem; margin: 2rem 0 0.25rem; }
2670
+ .count { font-weight: normal; opacity: 0.6; }
2671
+ .hint { margin: 0 0 1rem; font-size: 0.9rem; opacity: 0.75; max-width: 80ch; }
2672
+ .diff-shell {
2673
+ border: 1px solid light-dark(#e2e2e2, #2a2a2a);
2674
+ border-radius: 8px; overflow: hidden;
2675
+ background: light-dark(#fff, #161616);
2676
+ margin-bottom: 1rem;
2677
+ }
2678
+ .omitted { font-size: 0.85rem; opacity: 0.8; }
2679
+ .omitted code { font-family: ui-monospace, monospace; }
2680
+ </style>
2681
+ </head>
2682
+ <body>
2683
+ <header>
2684
+ <span class="brand">\u29C8 cella diff</span>
2685
+ <code>${escapeHtml(meta.title)}</code>
2686
+ ${note}
2687
+ </header>
2688
+ <main>
2689
+ ${index}
2690
+ ${blocks.join("\n")}
2691
+ </main>
2692
+ </body>
2693
+ </html>`;
2694
+ }
2695
+ async function writeDiffPage(sections, meta, repoPath, name) {
2696
+ const html = await renderDiffPage(sections, meta);
2697
+ const gitDir = execFileSync2("git", ["rev-parse", "--absolute-git-dir"], { cwd: repoPath, encoding: "utf8" }).trim();
2698
+ const dir = join9(gitDir, "cella-diff");
2699
+ mkdirSync(dir, { recursive: true });
2700
+ const outPath = join9(dir, `${name.replace(/[^\w.-]+/g, "-")}.html`);
2701
+ writeFileSync(outPath, html);
2702
+ return outPath;
2703
+ }
2704
+ function openInBrowser(target) {
2705
+ try {
2706
+ execFileSync2(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
2707
+ return true;
2708
+ } catch {
2709
+ return false;
2710
+ }
2711
+ }
2712
+
2713
+ // src/services/review-page.ts
2714
+ var MAX_PAGE_FILES = 50;
2715
+ var MAX_PATCH_BYTES = 512 * 1024;
2716
+ function reviewSections(result, command) {
2717
+ const [base, upstream] = result.upstreamDiffRange?.split("..") ?? [];
2718
+ if (!base || !upstream) return [];
2719
+ const sinceSync = `${base}..${upstream}`;
2720
+ const dropped = command === "sync" ? {
2721
+ title: "protected files kept the fork version",
2722
+ hint: "What upstream changed in these files since the last sync. The fork kept its own version, so none of it arrived: adopt what you need.",
2723
+ range: sinceSync,
2724
+ paths: result.protectedConflicts ?? []
2725
+ } : {
2726
+ title: "protected but behind upstream",
2727
+ hint: "What upstream changed in these files since the last sync. They are pinned or ignored, so the sync keeps the fork version and none of it arrives: adopt what you need.",
2728
+ range: sinceSync,
2729
+ paths: findProtectedBehind(result.files).map((file) => file.path)
2730
+ };
2731
+ const sections = [
2732
+ dropped,
2733
+ {
2734
+ title: "ignored paths changed upstream",
2735
+ hint: "What upstream changed under ignored paths since the last sync. The fork left these files untouched, and ignored paths never sync.",
2736
+ range: sinceSync,
2737
+ paths: (result.ignoredUpstreamChanges ?? []).flatMap((group) => group.paths)
2738
+ },
2739
+ {
2740
+ title: "pinned files that match the previous upstream",
2741
+ hint: "What upstream changed in these files since the last sync. The fork copy still equals the previous upstream, so the pin only drops these changes: unpin to take them.",
2742
+ range: sinceSync,
2743
+ paths: findMaskingPins(result.files).map((file) => file.path)
2744
+ }
2745
+ ];
2746
+ if (command === "analyze") {
2747
+ sections.push(
2748
+ {
2749
+ title: "protected in fork, upstream lines absent",
2750
+ hint: "The fork's version against upstream's, both as they are now: added lines are upstream content the fork does not have, dropped by an earlier sync or removed on purpose.",
2751
+ range: `HEAD..${upstream}`,
2752
+ paths: filesWithStatus(result.files, "ahead").filter((file) => (file.upstreamLinesAbsent ?? 0) > 0).map((file) => file.path)
2753
+ },
2754
+ {
2755
+ title: "drifted from upstream",
2756
+ hint: "What the fork changed in these files since the last sync. They are not pinned or ignored, so upstream changes still merge into them.",
2757
+ range: `${base}..HEAD`,
2758
+ paths: filesWithStatus(result.files, "drifted").map((file) => file.path)
2759
+ }
2760
+ );
2761
+ }
2762
+ return sections.filter((section) => section.paths.length > 0);
2763
+ }
2764
+ async function writeReviewPage(forkPath, sections, meta) {
2765
+ const [base, upstream] = meta.range.split("..");
2766
+ let diffs = 0;
2767
+ const pageSections = [];
2768
+ for (const section of sections) {
2769
+ const pageSection = { title: section.title, hint: section.hint, files: [], omitted: [] };
2770
+ for (const path of section.paths) {
2771
+ if (diffs >= MAX_PAGE_FILES) {
2772
+ pageSection.omitted?.push(path);
2773
+ continue;
2774
+ }
2775
+ const patch = gitDiffFile(forkPath, section.range, path);
2776
+ if (patch.length === 0) continue;
2777
+ if (patch.length > MAX_PATCH_BYTES || !patch.includes("\n@@ ")) {
2778
+ pageSection.omitted?.push(path);
2779
+ continue;
2780
+ }
2781
+ pageSection.files.push({ path, patch: patch.toString() });
2782
+ diffs++;
2783
+ }
2784
+ if (pageSection.files.length > 0 || pageSection.omitted?.length) pageSections.push(pageSection);
2785
+ }
2786
+ if (diffs === 0) return void 0;
2787
+ const page = await writeDiffPage(
2788
+ pageSections,
2789
+ { title: `${meta.command} review`, note: `${basename2(forkPath)} \xB7 last sync ${base} \u2192 upstream ${upstream}` },
2790
+ forkPath,
2791
+ `${meta.command}-review`
2792
+ );
2793
+ return { path: page, diffs };
2794
+ }
2795
+ async function reportReviewPage(forkPath, result, command) {
2796
+ const sections = reviewSections(result, command);
2797
+ if (sections.length === 0 || !result.upstreamDiffRange) return;
2798
+ createSpinner("writing review page...");
2799
+ let page;
2800
+ try {
2801
+ page = await writeReviewPage(forkPath, sections, { command, range: result.upstreamDiffRange });
2802
+ } catch {
2803
+ page = void 0;
2804
+ }
2805
+ spinnerSuccess();
2806
+ if (!page) return;
2807
+ const opened = command === "sync" && process7.stdout.isTTY === true && openInBrowser(page.path);
2808
+ console.info();
2809
+ console.info(
2810
+ `${colors_default.cyan("\u29C8")} review page with ${plural(page.diffs, "diff")}${opened ? ", opened in the browser" : ""}:`
2811
+ );
2812
+ console.info(colors_default.dim(` ${hyperlink(page.path, pathToFileURL(page.path).href)}`));
2813
+ }
2814
+
2621
2815
  // src/services/analyze.ts
2622
2816
  var scopeStatuses = {
2623
2817
  all: /* @__PURE__ */ new Set(["ahead", "drifted", "diverged"]),
@@ -2668,23 +2862,28 @@ async function runAnalyze(config) {
2668
2862
  fileLinkMode: config.settings.fileLinkMode,
2669
2863
  forkPath: config.forkPath
2670
2864
  };
2671
- printAnalysisFileGroups(result.files, linkOptions, result);
2865
+ printAnalysisFileGroups(result.files, linkOptions);
2672
2866
  printEngineReports(result, "analysis summary");
2867
+ await reportReviewPage(config.forkPath, result, "analyze");
2673
2868
  await printMigrationNotesLine(config, result);
2674
2869
  console.info();
2675
2870
  return result;
2676
2871
  }
2677
2872
 
2678
2873
  // src/services/audit.ts
2679
- import { spawnSync as spawnSync3 } from "child_process";
2680
- import { readFileSync as readFileSync3 } from "fs";
2681
- import { basename as basename2, join as join10, relative as relative3 } from "path";
2682
- import { checkbox, confirm as confirm2, Separator } from "@inquirer/prompts";
2874
+ import { readFileSync as readFileSync4 } from "fs";
2875
+ import { basename as basename4, join as join13, relative as relative3 } from "path";
2876
+ import { checkbox, Separator } from "@inquirer/prompts";
2683
2877
 
2684
2878
  // src/utils/audit-utils.ts
2685
2879
  import { execFile as execFile2 } from "child_process";
2686
2880
  import { promisify as promisify2 } from "util";
2687
2881
  var execFileAsync2 = promisify2(execFile2);
2882
+ function outdatedPackageName(key) {
2883
+ const name = key.split(" ")[0];
2884
+ const at = name.indexOf("@", 1);
2885
+ return at === -1 ? name : name.slice(0, at);
2886
+ }
2688
2887
  function getRepoUrl(repositoryUrl) {
2689
2888
  if (!repositoryUrl) return null;
2690
2889
  let url = repositoryUrl;
@@ -2712,11 +2911,6 @@ function getReleasesUrl(repoUrl) {
2712
2911
  if (!isGitHubRepoUrl(repoUrl)) return null;
2713
2912
  return `${repoUrl}/releases`;
2714
2913
  }
2715
- function isMajorVersionChange(current, latest) {
2716
- const from = parseComparableVersion(current);
2717
- const to = parseComparableVersion(latest);
2718
- return !!from && !!to && to.major > from.major;
2719
- }
2720
2914
  async function runPnpm(args, cwd) {
2721
2915
  try {
2722
2916
  const { stdout, stderr } = await execFileAsync2("pnpm", args, {
@@ -2742,9 +2936,23 @@ async function runPnpmAudit(cwd) {
2742
2936
  if (!result || typeof result.advisories !== "object" || result.advisories === null) return null;
2743
2937
  return result;
2744
2938
  }
2939
+ async function readReleaseAgeMinutes(cwd) {
2940
+ const minutes = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], cwd)).stdout, 10);
2941
+ return Number.isNaN(minutes) ? 0 : minutes;
2942
+ }
2745
2943
  async function fetchPublishTimes(name, cwd) {
2746
2944
  const times = await runPnpmJson(["view", name, "time", "--json"], cwd);
2747
- return times && typeof times === "object" ? times : null;
2945
+ return times && typeof times === "object" && !("error" in times) ? times : null;
2946
+ }
2947
+ function editJsonText(content, change) {
2948
+ const json = JSON.parse(content);
2949
+ change(json);
2950
+ const indent = content.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
2951
+ return `${JSON.stringify(json, null, indent)}${content.endsWith("\n") ? "\n" : ""}`;
2952
+ }
2953
+ function rewriteSpecifier(spec, latest) {
2954
+ const match = spec.match(/^([\^~]?)\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?$/);
2955
+ return match ? `${match[1]}${latest}` : null;
2748
2956
  }
2749
2957
  var vulnIcons = {
2750
2958
  critical: colors_default.red("\u25CF"),
@@ -2770,6 +2978,265 @@ function formatDependents(dependents) {
2770
2978
  return `${dependents[0]} +${dependents.length - 1}`;
2771
2979
  }
2772
2980
 
2981
+ // src/utils/local-tools.ts
2982
+ import { execFile as execFile3 } from "child_process";
2983
+ import { existsSync as existsSync7, realpathSync as realpathSync2 } from "fs";
2984
+ import { tmpdir } from "os";
2985
+ import { delimiter, dirname as dirname6, join as join11, sep as sep2 } from "path";
2986
+ import { promisify as promisify3 } from "util";
2987
+
2988
+ // src/utils/runtimes.ts
2989
+ import { readFileSync as readFileSync2 } from "fs";
2990
+ import { basename as basename3, dirname as dirname5, join as join10 } from "path";
2991
+ var DAY = 24 * 60 * 60 * 1e3;
2992
+ var FROZEN_AFTER = 180 * DAY;
2993
+ var RECENT_PUSH = 30 * DAY;
2994
+ var EXACT_VERSION = /^\d+\.\d+\.\d+$/;
2995
+ var FROM_LINE = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i;
2996
+ var NODE_ARG_TAG = /^(?:docker\.io\/)?(?:library\/)?node:\$\{?\w+\}?([\w.-]*)$/;
2997
+ var MINOR_TAG = /^(\d+(?:\.\d+){1,2})(-[\w.-]+)?$/;
2998
+ var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename3(path));
2999
+ function parseDockerfile(file, content) {
3000
+ const images = [];
3001
+ const nodeImageSuffixes = [];
3002
+ const stages = /* @__PURE__ */ new Set();
3003
+ content.split("\n").forEach((text, line) => {
3004
+ const match = text.match(FROM_LINE);
3005
+ if (!match) return;
3006
+ const [, ref, stage] = match;
3007
+ const fromStage = stages.has(ref.toLowerCase());
3008
+ if (stage) stages.add(stage.toLowerCase());
3009
+ if (fromStage || ref.includes("@")) return;
3010
+ const nodeArg = ref.match(NODE_ARG_TAG);
3011
+ if (nodeArg) {
3012
+ nodeImageSuffixes.push(nodeArg[1]);
3013
+ return;
3014
+ }
3015
+ if (ref.includes("$")) return;
3016
+ const slash = ref.lastIndexOf("/");
3017
+ const colon = ref.lastIndexOf(":");
3018
+ if (colon <= slash) return;
3019
+ let image = ref.slice(0, colon);
3020
+ const tag = ref.slice(colon + 1);
3021
+ const host = image.includes("/") ? image.split("/")[0] : "";
3022
+ if (host === "docker.io") image = image.slice(host.length + 1).replace(/^library\//, "");
3023
+ else if (host.includes(".") || host.includes(":") || host === "localhost") return;
3024
+ if (image === "scratch") return;
3025
+ images.push({ file, line, image, tag });
3026
+ });
3027
+ return { images, nodeImageSuffixes };
3028
+ }
3029
+ async function readRuntimePins(forkPath) {
3030
+ let manifest = {};
3031
+ try {
3032
+ manifest = JSON.parse(readFileSync2(join10(forkPath, "package.json"), "utf8"));
3033
+ } catch {
3034
+ }
3035
+ const node = manifest.engines?.node?.trim() ?? "";
3036
+ const pnpm = manifest.packageManager?.match(/^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/)?.[1] ?? null;
3037
+ const tracked = (await git(["ls-files", "-z"], forkPath, { ignoreErrors: true })).split("\0").filter(isDockerfile);
3038
+ const images = [];
3039
+ const nodeImageSuffixes = /* @__PURE__ */ new Set();
3040
+ for (const file of tracked) {
3041
+ let content;
3042
+ try {
3043
+ content = readFileSync2(join10(forkPath, file), "utf8");
3044
+ } catch {
3045
+ continue;
3046
+ }
3047
+ const parsed = parseDockerfile(file, content);
3048
+ images.push(...parsed.images);
3049
+ for (const suffix of parsed.nodeImageSuffixes) nodeImageSuffixes.add(suffix);
3050
+ }
3051
+ return {
3052
+ node: EXACT_VERSION.test(node) ? node : null,
3053
+ pnpm,
3054
+ images,
3055
+ nodeImageSuffixes: [...nodeImageSuffixes]
3056
+ };
3057
+ }
3058
+ async function fetchJson(url, init) {
3059
+ try {
3060
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(1e4) });
3061
+ return response.ok ? await response.json() : null;
3062
+ } catch {
3063
+ return null;
3064
+ }
3065
+ }
3066
+ var hubRepository = (image) => image.includes("/") ? image : `library/${image}`;
3067
+ var hubTag = (entry) => {
3068
+ const { name, last_updated } = entry ?? {};
3069
+ const pushedAt = Date.parse(last_updated ?? "");
3070
+ return name && !Number.isNaN(pushedAt) ? { name, pushedAt } : null;
3071
+ };
3072
+ async function fetchNodeReleases(major, now) {
3073
+ const index = await fetchJson("https://nodejs.org/dist/index.json");
3074
+ if (!Array.isArray(index)) return null;
3075
+ const releases = index.map((entry) => entry).filter((entry) => entry.version && parseComparableVersion(entry.version)?.major === major).map((entry) => ({
3076
+ version: String(entry.version).replace(/^v/, ""),
3077
+ publishedAt: Date.parse(entry.date ?? ""),
3078
+ security: entry.security === true
3079
+ })).filter((release) => !Number.isNaN(release.publishedAt));
3080
+ await Promise.all(
3081
+ releases.filter((release) => now - release.publishedAt < 2 * DAY).map(async (release) => {
3082
+ try {
3083
+ const response = await fetch(`https://nodejs.org/dist/v${release.version}/SHASUMS256.txt`, {
3084
+ method: "HEAD",
3085
+ signal: AbortSignal.timeout(1e4)
3086
+ });
3087
+ const modified = Date.parse(response.headers.get("last-modified") ?? "");
3088
+ release.publishedAt = Number.isNaN(modified) ? release.publishedAt + DAY : modified;
3089
+ } catch {
3090
+ release.publishedAt += DAY;
3091
+ }
3092
+ })
3093
+ );
3094
+ return releases;
3095
+ }
3096
+ async function fetchImageTags(pin) {
3097
+ const shape = pin.tag.match(MINOR_TAG);
3098
+ if (!shape) return null;
3099
+ const base = `https://hub.docker.com/v2/repositories/${hubRepository(pin.image)}/tags`;
3100
+ const filter = shape[2] ?? `${shape[1].split(".")[0]}.`;
3101
+ const [list, pinned] = await Promise.all([
3102
+ fetchJson(`${base}?page_size=100&ordering=last_updated&name=${encodeURIComponent(filter)}`),
3103
+ fetchJson(`${base}/${encodeURIComponent(pin.tag)}`)
3104
+ ]);
3105
+ const results = list?.results;
3106
+ if (!Array.isArray(results)) return null;
3107
+ return { pinned: hubTag(pinned), tags: results.flatMap((entry) => hubTag(entry) ?? []) };
3108
+ }
3109
+ async function fetchPnpmAdvisories(version) {
3110
+ const answer = await fetchJson("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
3111
+ method: "POST",
3112
+ headers: { "content-type": "application/json" },
3113
+ body: JSON.stringify({ pnpm: [version] })
3114
+ });
3115
+ return answer ? answer.pnpm ?? [] : null;
3116
+ }
3117
+ async function fetchRuntimeFeeds(pins, cwd, now) {
3118
+ const nodeMajor = pins.node ? parseComparableVersion(pins.node)?.major : void 0;
3119
+ const [node, published, advisories, images] = await Promise.all([
3120
+ nodeMajor === void 0 ? null : fetchNodeReleases(nodeMajor, now),
3121
+ pins.pnpm ? fetchPublishTimes("pnpm", cwd) : null,
3122
+ pins.pnpm ? fetchPnpmAdvisories(pins.pnpm) : null,
3123
+ Promise.all(pins.images.map(fetchImageTags))
3124
+ ]);
3125
+ return { node, pnpm: published && advisories ? { published, advisories } : null, images };
3126
+ }
3127
+ var count = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3128
+ var monthYear = (time) => new Date(time).toLocaleString("en", { month: "short", year: "numeric", timeZone: "UTC" }).toLowerCase();
3129
+ function newestAged(pinned, versions, cutoff) {
3130
+ const major = parseComparableVersion(pinned)?.major;
3131
+ return versions.filter(
3132
+ (entry) => !isPrerelease(entry.version) && parseComparableVersion(entry.version)?.major === major && compareVersionStrings(entry.version, pinned) > 0 && entry.publishedAt <= cutoff
3133
+ ).sort((a, b) => compareVersionStrings(b.version, a.version))[0];
3134
+ }
3135
+ function planRuntimeUpdates(pins, feeds, options) {
3136
+ const cutoff = options.now - options.releaseAgeMinutes * 60 * 1e3;
3137
+ const updates = [];
3138
+ const unchecked = [];
3139
+ if (pins.node) {
3140
+ if (!feeds.node) unchecked.push("node");
3141
+ const target = feeds.node && newestAged(pins.node, feeds.node, cutoff);
3142
+ if (target) {
3143
+ const node = pins.node;
3144
+ const security = (feeds.node ?? []).some(
3145
+ (release) => release.security && compareVersionStrings(release.version, node) > 0 && compareVersionStrings(release.version, target.version) <= 0
3146
+ );
3147
+ updates.push({
3148
+ name: "node",
3149
+ current: node,
3150
+ target: target.version,
3151
+ file: "package.json",
3152
+ severity: security ? "high" : null,
3153
+ note: security ? "security release" : "",
3154
+ releasesUrl: `https://nodejs.org/en/blog/release/v${target.version}`,
3155
+ pin: { kind: "node" }
3156
+ });
3157
+ }
3158
+ }
3159
+ if (pins.pnpm) {
3160
+ if (!feeds.pnpm) unchecked.push("pnpm");
3161
+ const published = Object.entries(feeds.pnpm?.published ?? {}).map(([version, time]) => ({
3162
+ version,
3163
+ publishedAt: Date.parse(time)
3164
+ }));
3165
+ const target = feeds.pnpm && newestAged(pins.pnpm, published, cutoff);
3166
+ if (target) {
3167
+ const pnpm = pins.pnpm;
3168
+ const fixed = (feeds.pnpm?.advisories ?? []).filter(
3169
+ (advisory) => satisfiesComparators(pnpm, advisory.vulnerable_versions) && !satisfiesComparators(target.version, advisory.vulnerable_versions)
3170
+ ).length;
3171
+ updates.push({
3172
+ name: "pnpm",
3173
+ current: pnpm,
3174
+ target: target.version,
3175
+ file: "package.json",
3176
+ severity: fixed > 0 ? "high" : null,
3177
+ note: fixed > 0 ? count(fixed, "advisory", "advisories") : "",
3178
+ releasesUrl: `https://github.com/pnpm/pnpm/releases/tag/v${target.version}`,
3179
+ pin: { kind: "pnpm" }
3180
+ });
3181
+ }
3182
+ }
3183
+ pins.images.forEach((pin, i) => {
3184
+ const shape = pin.tag.match(MINOR_TAG);
3185
+ if (!shape) return;
3186
+ const feed = feeds.images[i];
3187
+ if (!feed) {
3188
+ unchecked.push(pin.image);
3189
+ return;
3190
+ }
3191
+ const [, version, suffix = ""] = shape;
3192
+ const parts = version.split(".").length;
3193
+ const major = version.split(".")[0];
3194
+ const target = feed.tags.map((tag) => ({ tag, match: tag.name.match(MINOR_TAG) })).filter(
3195
+ ({ match }) => match && (match[2] ?? "") === suffix && match[1].split(".").length === parts && match[1].split(".")[0] === major && compareVersionStrings(match[1], version) > 0
3196
+ ).sort((a, b) => compareVersionStrings(b.match?.[1] ?? "", a.match?.[1] ?? ""))[0];
3197
+ if (!target?.match) return;
3198
+ const frozen = !!feed.pinned && options.now - feed.pinned.pushedAt > FROZEN_AFTER && options.now - target.tag.pushedAt < RECENT_PUSH;
3199
+ const repository = hubRepository(pin.image);
3200
+ updates.push({
3201
+ name: pin.image,
3202
+ current: version,
3203
+ target: target.match[1],
3204
+ file: pin.file,
3205
+ severity: frozen ? "moderate" : null,
3206
+ note: frozen && feed.pinned ? `tag frozen since ${monthYear(feed.pinned.pushedAt)}` : "",
3207
+ releasesUrl: repository.startsWith("library/") ? `https://hub.docker.com/_/${pin.image}/tags` : `https://hub.docker.com/r/${repository}/tags`,
3208
+ pin: { kind: "image", line: pin.line, ref: `${pin.image}:${pin.tag}`, to: `${pin.image}:${target.tag.name}` }
3209
+ });
3210
+ });
3211
+ return { updates, unchecked };
3212
+ }
3213
+ function applyRuntimeUpdate(content, update) {
3214
+ const { pin } = update;
3215
+ if (pin.kind === "image") {
3216
+ const lines = content.split("\n");
3217
+ lines[pin.line] = lines[pin.line].replace(pin.ref, pin.to);
3218
+ return lines.join("\n");
3219
+ }
3220
+ return editJsonText(content, (json) => {
3221
+ if (pin.kind === "node") json.engines.node = update.target;
3222
+ else json.packageManager = `pnpm@${update.target}`;
3223
+ });
3224
+ }
3225
+ async function missingNodeImages(version, suffixes) {
3226
+ const missing = await Promise.all(
3227
+ suffixes.map(async (suffix) => {
3228
+ const tag = `${version}${suffix}`;
3229
+ const found = await fetchJson(`https://hub.docker.com/v2/repositories/library/node/tags/${tag}`);
3230
+ return found ? [] : [`node:${tag}`];
3231
+ })
3232
+ );
3233
+ return missing.flat();
3234
+ }
3235
+ function pinLocation(file) {
3236
+ const dir = dirname5(file);
3237
+ return dir === "." ? file : dir;
3238
+ }
3239
+
2773
3240
  // src/utils/security-plan.ts
2774
3241
  var SEVERITIES = ["critical", "high", "moderate", "low", "info"];
2775
3242
  function isAuditOverride(key) {
@@ -2885,6 +3352,130 @@ function liveExcludes(input) {
2885
3352
  });
2886
3353
  }
2887
3354
 
3355
+ // src/utils/local-tools.ts
3356
+ var execFileAsync3 = promisify3(execFile3);
3357
+ function findOnPath(name, skip = []) {
3358
+ for (const dir of (getEnv("PATH") ?? "").split(delimiter)) {
3359
+ if (!dir || skip.includes(dir) || dir.endsWith(`node_modules${sep2}.bin`)) continue;
3360
+ if (existsSync7(join11(dir, name))) return join11(dir, name);
3361
+ }
3362
+ return null;
3363
+ }
3364
+ function installerOf(file) {
3365
+ let real = file;
3366
+ try {
3367
+ real = realpathSync2(file);
3368
+ } catch {
3369
+ }
3370
+ if (real.includes(`${sep2}.volta${sep2}`)) return "volta";
3371
+ return /[\\/](Cellar|homebrew|linuxbrew)[\\/]/.test(real) ? "brew" : null;
3372
+ }
3373
+ async function versionOf(file, cwd) {
3374
+ try {
3375
+ const { stdout } = await execFileAsync3(file, ["--version"], { encoding: "utf8", timeout: 1e4, cwd });
3376
+ return stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
3377
+ } catch {
3378
+ return null;
3379
+ }
3380
+ }
3381
+ async function readNode(now) {
3382
+ const version = process.version.slice(1);
3383
+ const major = parseComparableVersion(version)?.major ?? 0;
3384
+ return { version, installer: installerOf(process.execPath), releases: await fetchNodeReleases(major, now) };
3385
+ }
3386
+ async function readPnpm(cwd) {
3387
+ const running = getEnv("npm_execpath");
3388
+ const file = findOnPath("pnpm", running ? [dirname6(running)] : []);
3389
+ const version = file && await versionOf(file, tmpdir());
3390
+ if (!file || !version) return null;
3391
+ const [published, advisories] = await Promise.all([fetchPublishTimes("pnpm", cwd), fetchPnpmAdvisories(version)]);
3392
+ return { version, installer: installerOf(file), published, advisories };
3393
+ }
3394
+ async function fetchOsvSeverities(module, version) {
3395
+ const answer = await fetchJson("https://api.osv.dev/v1/query", {
3396
+ method: "POST",
3397
+ body: JSON.stringify({ package: { name: module, ecosystem: "Go" }, version })
3398
+ });
3399
+ if (!answer) return null;
3400
+ const byGhsa = /* @__PURE__ */ new Map();
3401
+ for (const vuln of answer.vulns ?? []) {
3402
+ const id = [vuln.id, ...vuln.aliases ?? []].find((alias) => alias.startsWith("GHSA-")) ?? vuln.id;
3403
+ const severity = SEVERITIES.find((level) => level === vuln.database_specific?.severity?.toLowerCase());
3404
+ if (severity || !byGhsa.has(id)) byGhsa.set(id, severity ?? "moderate");
3405
+ }
3406
+ return [...byGhsa.values()];
3407
+ }
3408
+ async function readGh() {
3409
+ const file = findOnPath("gh");
3410
+ const version = file && await versionOf(file);
3411
+ if (!file || !version) return null;
3412
+ const [list, advisories] = await Promise.all([
3413
+ fetchJson("https://api.github.com/repos/cli/cli/releases?per_page=30"),
3414
+ fetchOsvSeverities("github.com/cli/cli/v2", version)
3415
+ ]);
3416
+ const releases = Array.isArray(list) ? list.flatMap((entry) => {
3417
+ const { tag_name, published_at } = entry;
3418
+ const publishedAt = Date.parse(published_at ?? "");
3419
+ return tag_name && !Number.isNaN(publishedAt) ? [{ version: tag_name.replace(/^v/, ""), publishedAt }] : [];
3420
+ }) : null;
3421
+ return { version, installer: installerOf(file), releases, advisories };
3422
+ }
3423
+ async function readLocalTools(cwd, now) {
3424
+ const [node, pnpm, gh] = await Promise.all([readNode(now), readPnpm(cwd), readGh()]);
3425
+ return { node, pnpm, gh };
3426
+ }
3427
+ function upgradeCommand(name, installer, target) {
3428
+ if (installer === "brew") return `brew upgrade ${name}`;
3429
+ if (!target) return "no fixed release older than the release age yet";
3430
+ if (installer === "volta") return `volta install ${name}@${target}`;
3431
+ return name === "pnpm" ? `pnpm self-update ${target}` : `update to ${target}`;
3432
+ }
3433
+ function toCheck(name, tool, missed, target, noun) {
3434
+ if (!missed)
3435
+ return { name, version: tool.version, severity: null, note: "not checked, no answer from the feed", fix: "" };
3436
+ if (missed.length === 0) return { name, version: tool.version, severity: null, note: "", fix: "" };
3437
+ const high = missed.filter((severity) => severity === "critical" || severity === "high").length;
3438
+ return {
3439
+ name,
3440
+ version: tool.version,
3441
+ severity: SEVERITIES.find((severity) => missed.includes(severity)) ?? null,
3442
+ note: `${plural(missed.length, ...noun)}${high > 0 && high < missed.length ? `, ${high} high` : ""}`,
3443
+ fix: upgradeCommand(name, tool.installer, target)
3444
+ };
3445
+ }
3446
+ function planLocalTools(tools, options) {
3447
+ const cutoff = options.now - options.releaseAgeMinutes * 60 * 1e3;
3448
+ const { node, pnpm, gh } = tools;
3449
+ const nodeTarget = node.releases ? newestAged(node.version, node.releases, cutoff) : void 0;
3450
+ const securityReleases = (node.releases ?? []).filter(
3451
+ (release) => release.security && nodeTarget && compareVersionStrings(release.version, node.version) > 0 && compareVersionStrings(release.version, nodeTarget.version) <= 0
3452
+ );
3453
+ const checks = [
3454
+ toCheck(
3455
+ "node",
3456
+ node,
3457
+ node.releases && securityReleases.map(() => "high"),
3458
+ nodeTarget?.version,
3459
+ ["security release", "security releases"]
3460
+ )
3461
+ ];
3462
+ if (pnpm) {
3463
+ const affecting = (version) => (pnpm.advisories ?? []).filter((advisory) => satisfiesComparators(version, advisory.vulnerable_versions));
3464
+ const fixed = Object.entries(pnpm.published ?? {}).map(([version, time]) => ({ version, publishedAt: Date.parse(time) })).filter(
3465
+ (release) => parseComparableVersion(release.version) && !isPrerelease(release.version) && release.publishedAt <= cutoff && compareVersionStrings(release.version, pnpm.version) > 0 && affecting(release.version).length === 0
3466
+ ).sort((a, b) => compareVersionStrings(b.version, a.version));
3467
+ const major = parseComparableVersion(pnpm.version)?.major;
3468
+ const target = fixed.find((release) => parseComparableVersion(release.version)?.major === major) ?? fixed[0];
3469
+ const missed = pnpm.advisories && pnpm.published && affecting(pnpm.version).map((advisory) => advisory.severity);
3470
+ checks.push(toCheck("pnpm", pnpm, missed, target?.version, ["advisory", "advisories"]));
3471
+ }
3472
+ if (gh) {
3473
+ const target = gh.releases ? newestAged(gh.version, gh.releases, cutoff) : void 0;
3474
+ checks.push(toCheck("gh", gh, gh.advisories, target?.version, ["advisory", "advisories"]));
3475
+ }
3476
+ return checks;
3477
+ }
3478
+
2888
3479
  // src/utils/workspace-yaml.ts
2889
3480
  import { isMap, isScalar, isSeq, parseDocument } from "yaml";
2890
3481
  var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -2965,37 +3556,37 @@ function writeAuditSettings(content, next) {
2965
3556
 
2966
3557
  // src/services/audit-security.ts
2967
3558
  import { spawnSync as spawnSync2 } from "child_process";
2968
- import { cpSync, existsSync as existsSync7, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
2969
- import { tmpdir } from "os";
2970
- import { dirname as dirname5, join as join9, relative as relative2 } from "path";
3559
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
3560
+ import { tmpdir as tmpdir2 } from "os";
3561
+ import { dirname as dirname7, join as join12, relative as relative2 } from "path";
2971
3562
  import { confirm, select as select2 } from "@inquirer/prompts";
2972
3563
  var WORKSPACE_FILE = "pnpm-workspace.yaml";
2973
3564
  var LOCK_FILE = "pnpm-lock.yaml";
2974
3565
  var RESOLUTION_FILES = ["package.json", LOCK_FILE, ".npmrc", ".pnpmfile.cjs"];
2975
- var readOr = (path, fallback = "") => existsSync7(path) ? readFileSync2(path, "utf8") : fallback;
2976
- var count = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3566
+ var readOr = (path, fallback = "") => existsSync8(path) ? readFileSync3(path, "utf8") : fallback;
3567
+ var count2 = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
2977
3568
  var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
2978
3569
  var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
2979
3570
  var sameEntries = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
2980
3571
  async function createStaging(forkPath, workspaceYaml) {
2981
- const staging = mkdtempSync(join9(tmpdir(), "cella-audit-"));
3572
+ const staging = mkdtempSync(join12(tmpdir2(), "cella-audit-"));
2982
3573
  const projects = await runPnpmJson(["ls", "-r", "--depth", "-1", "--json"], forkPath) ?? [];
2983
3574
  const files = [
2984
3575
  ...RESOLUTION_FILES,
2985
3576
  ...readPatchFiles(workspaceYaml),
2986
- ...projects.map((project) => join9(relative2(forkPath, project.path), "package.json"))
3577
+ ...projects.map((project) => join12(relative2(forkPath, project.path), "package.json"))
2987
3578
  ];
2988
3579
  for (const file of new Set(files)) {
2989
- if (file.startsWith("..") || !existsSync7(join9(forkPath, file))) continue;
2990
- mkdirSync(dirname5(join9(staging, file)), { recursive: true });
2991
- cpSync(join9(forkPath, file), join9(staging, file));
3580
+ if (file.startsWith("..") || !existsSync8(join12(forkPath, file))) continue;
3581
+ mkdirSync2(dirname7(join12(staging, file)), { recursive: true });
3582
+ cpSync(join12(forkPath, file), join12(staging, file));
2992
3583
  }
2993
3584
  return staging;
2994
3585
  }
2995
3586
  async function resolveStaging(staging, workspaceYaml, settings) {
2996
- writeFileSync(join9(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
3587
+ writeFileSync2(join12(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
2997
3588
  const result = await runPnpm(["install", "--lockfile-only", "--no-frozen-lockfile", "--ignore-scripts"], staging);
2998
- return result.ok ? { lock: readOr(join9(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
3589
+ return result.ok ? { lock: readOr(join12(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
2999
3590
  }
3000
3591
  function lockedVersions(lock, name) {
3001
3592
  const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -3096,9 +3687,11 @@ function changeLine(label, before, after) {
3096
3687
  const counts = moved ? `${before.length} \u2192 ${after.length}` : String(after.length);
3097
3688
  return ` ${label.padEnd(9)} ${counts.padEnd(7)} ${colors_default.dim(moved || "unchanged")}`;
3098
3689
  }
3690
+ var NO_EDITS = { manifests: {}, files: {} };
3099
3691
  async function runSecurityPass(forkPath, options) {
3100
- const workspaceYaml = readOr(join9(forkPath, WORKSPACE_FILE));
3101
- const workspaceLock = readOr(join9(forkPath, LOCK_FILE));
3692
+ const { releaseAgeMinutes, edits = NO_EDITS } = options;
3693
+ const workspaceYaml = readOr(join12(forkPath, WORKSPACE_FILE));
3694
+ const workspaceLock = readOr(join12(forkPath, LOCK_FILE));
3102
3695
  let previous;
3103
3696
  try {
3104
3697
  previous = readAuditSettings(workspaceYaml);
@@ -3106,12 +3699,13 @@ async function runSecurityPass(forkPath, options) {
3106
3699
  console.error(
3107
3700
  `${colors_default.red("\u2717")} ${WORKSPACE_FILE} could not be read: ${error instanceof Error ? error.message : error}`
3108
3701
  );
3109
- return;
3702
+ return false;
3110
3703
  }
3111
3704
  const auditKeys = Object.keys(previous.overrides).filter(isAuditOverride);
3112
3705
  const lasting = Object.fromEntries(Object.entries(previous.overrides).filter(([key]) => !isAuditOverride(key)));
3706
+ const updatesManifests = Object.keys(edits.manifests).length > 0;
3113
3707
  const spinner = createSpinner(
3114
- auditKeys.length > 0 ? `resolving without the ${count(auditKeys.length, "audit override")}...` : "resolving the workspace..."
3708
+ auditKeys.length > 0 ? `resolving without the ${count2(auditKeys.length, "audit override")}...` : "resolving the workspace..."
3115
3709
  );
3116
3710
  const fail = (message, detail = "") => {
3117
3711
  spinner.stop();
@@ -3119,16 +3713,21 @@ async function runSecurityPass(forkPath, options) {
3119
3713
  if (detail) console.error(colors_default.dim(detail.split("\n").slice(-12).join("\n")));
3120
3714
  console.info(colors_default.dim(" the workspace is unchanged"));
3121
3715
  console.info();
3716
+ return false;
3122
3717
  };
3123
3718
  const staging = await createStaging(forkPath, workspaceYaml);
3124
3719
  try {
3720
+ for (const [file, content] of Object.entries(edits.manifests)) writeFileSync2(join12(staging, file), content);
3125
3721
  const bare = await resolveStaging(staging, workspaceYaml, { ...previous, overrides: lasting, ignoredGhsas: [] });
3126
- if ("error" in bare) return fail("could not resolve the workspace without its audit overrides", bare.error);
3722
+ if ("error" in bare) {
3723
+ const without = updatesManifests ? "with the selected updates" : "without its audit overrides";
3724
+ return fail(`could not resolve the workspace ${without}`, bare.error);
3725
+ }
3127
3726
  spinnerText("checking for vulnerabilities...");
3128
3727
  const audit = await runPnpmAudit(staging);
3129
3728
  if (!audit) return fail("pnpm audit gave no answer, so nothing can be decided");
3130
3729
  const advisories = advisoriesFromAudit(audit);
3131
- spinnerText(`checking ${count(advisories.length, "advisory", "advisories")} against the registry...`);
3730
+ spinnerText(`checking ${count2(advisories.length, "advisory", "advisories")} against the registry...`);
3132
3731
  const versionedExcludes = previous.releaseAgeExcludes.flatMap((entry) => parseVersionedExclude(entry)?.name ?? []);
3133
3732
  const names = [.../* @__PURE__ */ new Set([...advisories.map((advisory) => advisory.module), ...versionedExcludes])];
3134
3733
  const times = await Promise.all(names.map((name) => fetchPublishTimes(name, forkPath)));
@@ -3137,8 +3736,6 @@ async function runSecurityPass(forkPath, options) {
3137
3736
  );
3138
3737
  const unanswered = advisories.map((advisory) => advisory.module).filter((name) => !published[name]);
3139
3738
  if (unanswered.length > 0) return fail(`the registry did not answer for ${[...new Set(unanswered)].join(", ")}`);
3140
- const releaseAge = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], forkPath)).stdout, 10);
3141
- const releaseAgeMinutes = Number.isNaN(releaseAge) ? 0 : releaseAge;
3142
3739
  const releaseAgeHours = Math.round(releaseAgeMinutes / 60);
3143
3740
  const planInput = { advisories, published, previous, releaseAgeMinutes, now: Date.now() };
3144
3741
  const items = planSecurity(planInput);
@@ -3156,60 +3753,72 @@ async function runSecurityPass(forkPath, options) {
3156
3753
  if (!options.apply) {
3157
3754
  if (changes) console.info(colors_default.dim("run without --list to apply"));
3158
3755
  console.info();
3159
- return;
3756
+ return false;
3160
3757
  }
3161
3758
  let decisions;
3162
3759
  try {
3163
3760
  decisions = await decide(items, releaseAgeHours);
3164
3761
  } catch {
3165
3762
  console.info(colors_default.dim(" cancelled, the workspace is unchanged"));
3166
- return;
3763
+ return false;
3167
3764
  }
3168
3765
  const open = decisions.filter((decision) => decision.action === "open").map((decision) => decision.item.module);
3169
3766
  const kept = Object.assign({}, lasting, ...open.map((module) => standingOverrides(module, planInput)));
3170
3767
  const next = settingsFor(decisions, kept, excludes);
3171
3768
  const nextYaml = writeAuditSettings(workspaceYaml, next);
3172
- if (nextYaml === workspaceYaml) {
3173
- const state = `${count(auditKeys.length, "override")} \xB7 ${next.ignoredGhsas.length} ignored`;
3769
+ const settingsChanged = nextYaml !== workspaceYaml;
3770
+ const otherFiles = Object.keys(edits.files);
3771
+ const writeFiles = () => {
3772
+ for (const [file, content] of Object.entries(edits.files)) writeFileSync2(join12(forkPath, file), content);
3773
+ if (otherFiles.length > 0) console.info(`${checkMark} ${otherFiles.join(", ")} updated`);
3774
+ };
3775
+ if (!settingsChanged && !updatesManifests) {
3776
+ writeFiles();
3777
+ const state = `${count2(auditKeys.length, "override")} \xB7 ${next.ignoredGhsas.length} ignored`;
3174
3778
  console.info(`${checkMark} security settings are current ${colors_default.dim(`\xB7 ${state}`)}`);
3175
3779
  printOpen(open);
3176
- return;
3780
+ return true;
3177
3781
  }
3178
3782
  const summarize = () => {
3783
+ if (!settingsChanged) return;
3179
3784
  console.info(colors_default.bold(WORKSPACE_FILE));
3180
3785
  console.info(changeLine("overrides", auditKeys, Object.keys(next.overrides).filter(isAuditOverride)));
3181
3786
  console.info(changeLine("excludes", previous.releaseAgeExcludes, next.releaseAgeExcludes));
3182
3787
  console.info(changeLine("ignored", previous.ignoredGhsas, next.ignoredGhsas));
3183
3788
  console.info();
3184
3789
  };
3185
- if (sameEntries(next.overrides, previous.overrides)) {
3186
- writeFileSync(join9(forkPath, WORKSPACE_FILE), nextYaml);
3790
+ if (!updatesManifests && sameEntries(next.overrides, previous.overrides)) {
3791
+ writeFiles();
3792
+ writeFileSync2(join12(forkPath, WORKSPACE_FILE), nextYaml);
3187
3793
  console.info(`${checkMark} ${WORKSPACE_FILE} updated ${colors_default.dim("\xB7 nothing to install")}`);
3188
3794
  printOpen(open);
3189
3795
  summarize();
3190
- return;
3796
+ return true;
3191
3797
  }
3192
3798
  const stagingSpinner = createSpinner("resolving with the new settings...");
3193
3799
  const staged = await resolveStaging(staging, workspaceYaml, next);
3194
3800
  stagingSpinner.stop();
3195
3801
  if ("error" in staged) return fail("could not resolve the workspace with the new settings", staged.error);
3196
- writeFileSync(join9(forkPath, WORKSPACE_FILE), nextYaml);
3197
- writeFileSync(join9(forkPath, LOCK_FILE), staged.lock);
3802
+ const written = { ...edits.files, ...edits.manifests, [WORKSPACE_FILE]: nextYaml, [LOCK_FILE]: staged.lock };
3803
+ const before = Object.fromEntries(Object.keys(written).map((file) => [file, readOr(join12(forkPath, file))]));
3804
+ before[WORKSPACE_FILE] = workspaceYaml;
3805
+ before[LOCK_FILE] = workspaceLock;
3806
+ for (const [file, content] of Object.entries(written)) writeFileSync2(join12(forkPath, file), content);
3198
3807
  const install = spawnSync2("pnpm", ["install"], { cwd: forkPath, stdio: "inherit" });
3199
3808
  console.info();
3200
3809
  if (install.status !== 0) {
3201
- writeFileSync(join9(forkPath, WORKSPACE_FILE), workspaceYaml);
3202
- writeFileSync(join9(forkPath, LOCK_FILE), workspaceLock);
3810
+ for (const [file, content] of Object.entries(before)) writeFileSync2(join12(forkPath, file), content);
3203
3811
  console.error(`${colors_default.red("\u2717")} pnpm install failed (exit code ${install.status})`);
3204
- console.info(colors_default.dim(` ${WORKSPACE_FILE} and ${LOCK_FILE} are back as they were; run pnpm install to match`));
3812
+ console.info(colors_default.dim(` ${Object.keys(before).join(", ")} are back as they were; run pnpm install to match`));
3205
3813
  console.info();
3206
- return;
3814
+ return false;
3207
3815
  }
3208
3816
  const verified = await runPnpmAudit(forkPath);
3209
3817
  const reported = verified ? [...new Set(advisoriesFromAudit(verified).map((advisory) => advisory.module))] : open;
3210
3818
  console.info(`${checkMark} installed${reported.length === 0 ? ` ${colors_default.dim("\xB7")} audit clean` : ""}`);
3211
3819
  printOpen(reported);
3212
3820
  summarize();
3821
+ return true;
3213
3822
  } finally {
3214
3823
  spinner.stop();
3215
3824
  rmSync(staging, { recursive: true, force: true });
@@ -3217,7 +3826,7 @@ async function runSecurityPass(forkPath, options) {
3217
3826
  }
3218
3827
  function printOpen(modules) {
3219
3828
  if (modules.length > 0) {
3220
- console.info(`${warningMark} ${count(modules.length, "package")} still reported: ${modules.join(", ")}`);
3829
+ console.info(`${warningMark} ${count2(modules.length, "package")} still reported: ${modules.join(", ")}`);
3221
3830
  }
3222
3831
  console.info();
3223
3832
  }
@@ -3250,7 +3859,7 @@ function printPlan(plan) {
3250
3859
  printDropped("ignore dropped", droppedIgnores);
3251
3860
  const lastingCount = Object.keys(plan.lasting).length;
3252
3861
  if (lastingCount > 0) {
3253
- console.info(colors_default.dim(`${count(lastingCount, "override")} without a version selector left as written`));
3862
+ console.info(colors_default.dim(`${count2(lastingCount, "override")} without a version selector left as written`));
3254
3863
  }
3255
3864
  if (items.some(isBreaking)) {
3256
3865
  console.info(colors_default.dim("\u26A0 a breaking jump: another major, or another minor below 1.0"));
@@ -3260,13 +3869,14 @@ function printPlan(plan) {
3260
3869
  }
3261
3870
 
3262
3871
  // src/services/audit.ts
3872
+ var DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
3263
3873
  function createDependentPkgReader() {
3264
3874
  const cache = /* @__PURE__ */ new Map();
3265
3875
  return (location) => {
3266
3876
  let cached = cache.get(location);
3267
3877
  if (cached === void 0) {
3268
3878
  try {
3269
- cached = JSON.parse(readFileSync3(join10(location, "package.json"), "utf8"));
3879
+ cached = JSON.parse(readFileSync4(join13(location, "package.json"), "utf8"));
3270
3880
  } catch {
3271
3881
  cached = null;
3272
3882
  }
@@ -3277,85 +3887,111 @@ function createDependentPkgReader() {
3277
3887
  }
3278
3888
  async function runAudit(config) {
3279
3889
  const { forkPath } = config;
3280
- const spinner = createSpinner("checking for outdated packages...");
3890
+ const spinner = createSpinner("checking packages and runtimes...");
3281
3891
  try {
3282
- const [outdated, auditResult] = await Promise.all([
3892
+ const pins = await readRuntimePins(forkPath);
3893
+ const now = Date.now();
3894
+ const [outdated, auditResult, releaseAgeMinutes, feeds, machine] = await Promise.all([
3283
3895
  runPnpmJson(["-r", "outdated", "--json", "--long"], forkPath),
3284
- runPnpmAudit(forkPath)
3896
+ runPnpmAudit(forkPath),
3897
+ readReleaseAgeMinutes(forkPath),
3898
+ fetchRuntimeFeeds(pins, forkPath, now),
3899
+ readLocalTools(forkPath, now)
3285
3900
  ]);
3286
3901
  if (!outdated) {
3287
3902
  spinnerFail("pnpm outdated gave no answer: run pnpm -r outdated to see why");
3288
3903
  console.info(`${warningMark} security pass skipped: it runs after the update check`);
3289
3904
  return;
3290
3905
  }
3291
- const outdatedPackages = outdated;
3292
- const packageNames = Object.keys(outdatedPackages);
3293
- if (packageNames.length === 0) {
3294
- spinnerSuccess("all packages are up to date");
3906
+ const runtimes = planRuntimeUpdates(pins, feeds, { releaseAgeMinutes, now });
3907
+ const localTools = planLocalTools(machine, { releaseAgeMinutes, now });
3908
+ const rows = [
3909
+ ...packageRows(outdated, auditResult ? advisoriesFromAudit(auditResult) : []),
3910
+ ...runtimes.updates.map(runtimeRow)
3911
+ ];
3912
+ if (rows.length === 0) {
3913
+ spinnerSuccess("all packages and runtimes are up to date");
3914
+ printNotes(forkPath, runtimes.unchecked);
3295
3915
  console.info();
3296
- await runSecurityPass(forkPath, { apply: !config.list });
3916
+ printLocalTools(localTools);
3917
+ await runSecurityPass(forkPath, { apply: !config.list, releaseAgeMinutes });
3297
3918
  return;
3298
3919
  }
3299
- const advisories = auditResult ? advisoriesFromAudit(auditResult) : [];
3300
- const severityOf = (name) => SEVERITIES.find((severity) => advisories.some((a) => a.module === name && a.severity === severity)) ?? null;
3301
- const readDependentPkg = createDependentPkgReader();
3302
- const enhancedPackages = packageNames.map((name) => {
3303
- const pkg = outdatedPackages[name];
3304
- const repoUrl = getRepoUrl(pkg.latestManifest?.repository?.url);
3305
- const pinnedIn = [];
3306
- for (const d of pkg.dependentPackages) {
3307
- const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
3308
- if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
3309
- pinnedIn.push(d.name);
3310
- }
3311
- }
3312
- return {
3313
- name,
3314
- current: pkg.current,
3315
- latest: pkg.latest,
3316
- dependentPackages: pkg.dependentPackages,
3317
- isDev: pkg.dependencyType === "devDependencies",
3318
- isMajorUpdate: isMajorVersionChange(pkg.current, pkg.latest),
3319
- pinnedIn,
3320
- repoUrl,
3321
- releasesUrl: getReleasesUrl(repoUrl),
3322
- severity: severityOf(name)
3323
- };
3324
- });
3325
- enhancedPackages.sort((a, b) => {
3326
- const depCompare = dependentsText(a).localeCompare(dependentsText(b));
3327
- if (depCompare !== 0) return depCompare;
3328
- return a.name.localeCompare(b.name);
3329
- });
3330
3920
  spinner.stop();
3331
- printOutdatedResults(enhancedPackages, forkPath);
3921
+ printOutdatedResults(rows, forkPath);
3922
+ printNotes(forkPath, runtimes.unchecked);
3332
3923
  console.info();
3333
- const outcome = config.list ? "skipped" : await promptForUpdates(enhancedPackages, forkPath);
3334
- if (outcome === "cancelled") return;
3335
- if (outcome === "failed") {
3336
- console.info(`${warningMark} security pass skipped: it needs a workspace that installs`);
3924
+ printLocalTools(localTools);
3925
+ if (config.list) {
3926
+ await runSecurityPass(forkPath, { apply: false, releaseAgeMinutes });
3337
3927
  return;
3338
3928
  }
3339
- await runSecurityPass(forkPath, { apply: !config.list });
3929
+ const selected = await promptForUpdates(rows);
3930
+ if (!selected) return;
3931
+ const { edits, bumped } = await planEdits(forkPath, rows, selected, pins.nodeImageSuffixes);
3932
+ if (await runSecurityPass(forkPath, { apply: true, releaseAgeMinutes, edits })) printNext(bumped);
3340
3933
  } finally {
3341
3934
  spinner.stop();
3342
3935
  }
3343
3936
  }
3344
- var dependentsText = (pkg) => formatDependents(pkg.dependentPackages.map((d) => d.name));
3345
- function printOutdatedResults(enhancedPackages, forkPath) {
3937
+ function packageRows(outdated, advisories) {
3938
+ const severityOf = (name) => SEVERITIES.find((severity) => advisories.some((a) => a.module === name && a.severity === severity)) ?? null;
3939
+ const readDependentPkg = createDependentPkgReader();
3940
+ const rows = Object.entries(outdated).map(([key, pkg]) => {
3941
+ const name = outdatedPackageName(key);
3942
+ const repoUrl = getRepoUrl(pkg.latestManifest?.repository?.url);
3943
+ const current = pkg.current ?? "missing";
3944
+ const pinnedIn = [];
3945
+ for (const d of pkg.dependentPackages) {
3946
+ const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
3947
+ if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
3948
+ pinnedIn.push(d.name);
3949
+ }
3950
+ }
3951
+ return {
3952
+ key,
3953
+ name,
3954
+ current,
3955
+ latest: pkg.latest,
3956
+ dependentPackages: pkg.dependentPackages,
3957
+ isDev: pkg.dependencyType === "devDependencies",
3958
+ breaking: isBreakingJump(current, pkg.latest),
3959
+ pinnedIn,
3960
+ repoUrl,
3961
+ releasesUrl: getReleasesUrl(repoUrl),
3962
+ severity: severityOf(name)
3963
+ };
3964
+ });
3965
+ return rows.sort((a, b) => inText(a).localeCompare(inText(b)) || a.name.localeCompare(b.name));
3966
+ }
3967
+ function runtimeRow(update) {
3968
+ return {
3969
+ key: `runtime:${update.file}:${update.name}`,
3970
+ name: update.name,
3971
+ current: update.current,
3972
+ latest: update.target,
3973
+ dependentPackages: [],
3974
+ isDev: false,
3975
+ breaking: isBreakingJump(update.current, update.target),
3976
+ pinnedIn: [],
3977
+ repoUrl: null,
3978
+ releasesUrl: update.releasesUrl,
3979
+ severity: update.severity,
3980
+ runtime: update
3981
+ };
3982
+ }
3983
+ var isRed = (row) => row.severity === "critical" || row.severity === "high";
3984
+ var inText = (row) => row.runtime ? pinLocation(row.runtime.file) : formatDependents(row.dependentPackages.map((d) => d.name));
3985
+ function printOutdatedResults(rows, forkPath) {
3346
3986
  const DEV_TAG = " (dev)";
3987
+ const RUNTIME_TAG = " (runtime)";
3347
3988
  const MAX_NAME_LEN = 35;
3348
3989
  const MAX_DEPENDENTS_LEN = 15;
3349
- const maxNameLen = Math.min(
3350
- MAX_NAME_LEN,
3351
- Math.max(7, ...enhancedPackages.map((p) => p.name.length + (p.isDev ? DEV_TAG.length : 0)))
3352
- );
3353
- const maxCurrentLen = Math.max(7, ...enhancedPackages.map((p) => p.current.length));
3354
- const maxLatestLen = Math.max(6, ...enhancedPackages.map((p) => p.latest.length));
3355
- const maxDependentsLen = Math.min(
3356
- MAX_DEPENDENTS_LEN,
3357
- Math.max(10, ...enhancedPackages.map((p) => dependentsText(p).length))
3358
- );
3990
+ const tag = (row) => row.runtime ? RUNTIME_TAG : row.isDev ? DEV_TAG : "";
3991
+ const maxNameLen = Math.min(MAX_NAME_LEN, Math.max(7, ...rows.map((p) => p.name.length + tag(p).length)));
3992
+ const maxCurrentLen = Math.max(7, ...rows.map((p) => p.current.length));
3993
+ const maxLatestLen = Math.max(6, ...rows.map((p) => p.latest.length));
3994
+ const maxDependentsLen = Math.min(MAX_DEPENDENTS_LEN, Math.max(10, ...rows.map((p) => inText(p).length)));
3359
3995
  const header = [
3360
3996
  " ",
3361
3997
  colors_default.bold("package".padEnd(maxNameLen)),
@@ -3365,15 +4001,14 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3365
4001
  colors_default.bold("links")
3366
4002
  ].join(" ");
3367
4003
  console.info(header);
3368
- for (const pkg of enhancedPackages) {
4004
+ for (const pkg of rows) {
3369
4005
  const vulnIndicator = pkg.severity ? getVulnIcon(pkg.severity) : " ";
3370
- const fullName = pkg.isDev ? `${pkg.name}${DEV_TAG}` : pkg.name;
3371
- const displayName = middleTruncate(fullName, maxNameLen);
4006
+ const displayName = middleTruncate(`${pkg.name}${tag(pkg)}`, maxNameLen);
3372
4007
  const name = colors_default.white(displayName.padEnd(maxNameLen));
3373
4008
  const current = colors_default.red(pkg.current.padEnd(maxCurrentLen));
3374
4009
  const latestText = pkg.latest.padEnd(maxLatestLen);
3375
- const latest = pkg.isMajorUpdate ? colors_default.bold(colors_default.green(latestText)) : colors_default.green(latestText);
3376
- const dependents = colors_default.dim(middleTruncate(dependentsText(pkg), maxDependentsLen).padEnd(maxDependentsLen));
4010
+ const latest = pkg.breaking ? colors_default.bold(colors_default.green(latestText)) : colors_default.green(latestText);
4011
+ const dependents = colors_default.dim(middleTruncate(inText(pkg), maxDependentsLen).padEnd(maxDependentsLen));
3377
4012
  const links = [];
3378
4013
  if (pkg.releasesUrl) {
3379
4014
  links.push(hyperlink(colors_default.blue("rel"), pkg.releasesUrl));
@@ -3386,9 +4021,9 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3386
4021
  }
3387
4022
  console.info();
3388
4023
  const dependentMap = /* @__PURE__ */ new Map();
3389
- for (const pkg of enhancedPackages) {
4024
+ for (const pkg of rows) {
3390
4025
  for (const { name, location } of pkg.dependentPackages) {
3391
- const dependentName = name || basename2(location);
4026
+ const dependentName = name || basename4(location);
3392
4027
  const existing = dependentMap.get(dependentName);
3393
4028
  if (existing) {
3394
4029
  existing.count++;
@@ -3399,55 +4034,76 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3399
4034
  }
3400
4035
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3401
4036
  const maxDependentNameLen = Math.max(0, ...sortedDependents.map(([name]) => name.length));
3402
- for (const [dependentName, { location, count: count2 }] of sortedDependents) {
3403
- const packageJsonPath2 = join10(location, "package.json");
4037
+ for (const [dependentName, { location, count: count3 }] of sortedDependents) {
4038
+ const packageJsonPath2 = join13(location, "package.json");
3404
4039
  const relativePath = relative3(forkPath, packageJsonPath2);
3405
4040
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3406
4041
  console.info(
3407
- ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count2}`)}`
4042
+ ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count3}`)}`
3408
4043
  );
3409
4044
  }
3410
4045
  }
3411
- function buildUpdateChoices(packages) {
3412
- const makeChoice = (pkg) => {
3413
- const devTag = pkg.isDev ? colors_default.dim(" (dev)") : "";
3414
- const version = `${colors_default.red(pkg.current)} \u2192 ${colors_default.green(pkg.latest)}`;
3415
- const pinnedWarning = pkg.pinnedIn.length > 0 ? ` ${warningMark} ${colors_default.dim(`pinned in ${pkg.pinnedIn.join(", ")}`)}` : "";
4046
+ function printNotes(forkPath, unchecked) {
4047
+ let held = [];
4048
+ try {
4049
+ const updateConfig = readTopLevel(readFileSync4(join13(forkPath, "pnpm-workspace.yaml"), "utf8"), "updateConfig");
4050
+ const ignored = updateConfig?.ignoreDependencies;
4051
+ held = Array.isArray(ignored) ? ignored.map(String) : [];
4052
+ } catch {
4053
+ }
4054
+ if (held.length > 0) console.info(` ${colors_default.dim(`held: ${held.join(", ")} \xB7 pnpm-workspace.yaml updateConfig`)}`);
4055
+ if (unchecked.length > 0)
4056
+ console.info(` ${colors_default.dim(`not checked, no answer from the feed: ${unchecked.join(", ")}`)}`);
4057
+ }
4058
+ function printLocalTools(checks) {
4059
+ const width = Math.max(...checks.map((tool) => `${tool.name} ${tool.version}`.length));
4060
+ console.info(colors_default.bold("this machine"));
4061
+ for (const tool of checks) {
4062
+ const label = `${tool.name} ${tool.version}`.padEnd(width);
4063
+ const detail = tool.severity ? `${tool.note} ${colors_default.dim("\xB7")} ${tool.fix}` : colors_default.dim(tool.note || "no missing security fixes");
4064
+ console.info(`${tool.severity ? getVulnIcon(tool.severity) : " "} ${label} ${detail}`);
4065
+ }
4066
+ console.info();
4067
+ }
4068
+ function buildUpdateChoices(rows) {
4069
+ const makeChoice = (row) => {
4070
+ const devTag = row.isDev ? colors_default.dim(" (dev)") : "";
4071
+ const version = `${colors_default.red(row.current)} \u2192 ${colors_default.green(row.latest)}`;
4072
+ const notes = [
4073
+ row.breaking ? colors_default.bold("breaking") : "",
4074
+ row.runtime?.note ? colors_default.dim(row.runtime.note) : "",
4075
+ row.pinnedIn.length > 0 ? `${warningMark} ${colors_default.dim(`pinned in ${row.pinnedIn.join(", ")}`)}` : ""
4076
+ ].filter(Boolean);
3416
4077
  return {
3417
- name: `${pkg.name}${devTag} ${version}${pinnedWarning}`,
3418
- value: pkg.name,
3419
- checked: false
4078
+ name: `${row.name}${devTag} ${version}${notes.length > 0 ? ` ${notes.join(" ")}` : ""}`,
4079
+ value: row.key,
4080
+ checked: isRed(row)
3420
4081
  };
3421
4082
  };
3422
- const pinned = packages.filter((p) => p.pinnedIn.length > 0);
3423
- const regular = packages.filter((p) => p.pinnedIn.length === 0);
4083
+ const runtimes = rows.filter((p) => p.runtime);
4084
+ const pinned = rows.filter((p) => !p.runtime && p.pinnedIn.length > 0);
4085
+ const regular = rows.filter((p) => !p.runtime && p.pinnedIn.length === 0);
3424
4086
  const choices = [];
4087
+ if (runtimes.length > 0) {
4088
+ choices.push(new Separator(colors_default.yellow(`\u2500\u2500 runtimes (${runtimes.length}) \u2500\u2500`)));
4089
+ choices.push(...runtimes.map(makeChoice));
4090
+ }
3425
4091
  if (pinned.length > 0) {
3426
4092
  choices.push(new Separator(colors_default.yellow(`\u2500\u2500 pinned (${pinned.length}) \u2500\u2500`)));
3427
4093
  choices.push(...pinned.map(makeChoice));
3428
4094
  }
3429
4095
  if (regular.length > 0) {
3430
- if (pinned.length > 0) choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
4096
+ if (choices.length > 0) choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
3431
4097
  choices.push(...regular.map(makeChoice));
3432
4098
  }
3433
4099
  return choices;
3434
4100
  }
3435
- async function promptForUpdates(packages, forkPath) {
3436
- let wantsUpdate;
4101
+ async function promptForUpdates(rows) {
3437
4102
  try {
3438
- wantsUpdate = await confirm2({ message: "update packages?", default: true });
3439
- } catch {
3440
- return "cancelled";
3441
- }
3442
- console.info();
3443
- if (!wantsUpdate) return "skipped";
3444
- const choices = buildUpdateChoices(packages);
3445
- let selected;
3446
- try {
3447
- selected = await checkbox({
3448
- message: "toggle packages to update",
3449
- choices,
3450
- required: true,
4103
+ const selected = await checkbox({
4104
+ message: `toggle what to update ${colors_default.dim("\xB7 enter with nothing ticked skips")}`,
4105
+ choices: buildUpdateChoices(rows),
4106
+ required: false,
3451
4107
  pageSize: 20,
3452
4108
  loop: false,
3453
4109
  theme: {
@@ -3460,83 +4116,93 @@ async function promptForUpdates(packages, forkPath) {
3460
4116
  const tips = keys.map(([key, action]) => `${colors_default.dim(key)} ${action}`);
3461
4117
  return tips.join(" ");
3462
4118
  },
3463
- renderSelectedChoices: (selectedChoices) => colors_default.dim(`${selectedChoices.length} package${selectedChoices.length !== 1 ? "s" : ""} selected`)
4119
+ renderSelectedChoices: (selectedChoices) => colors_default.dim(`${selectedChoices.length} selected`)
3464
4120
  }
3465
4121
  }
3466
4122
  });
4123
+ console.info();
4124
+ return selected;
3467
4125
  } catch {
3468
- return "cancelled";
4126
+ return null;
3469
4127
  }
3470
- return executeUpdates(packages, forkPath, selected);
3471
4128
  }
3472
- async function executeUpdates(packages, forkPath, selectedPackages) {
3473
- console.info();
3474
- console.info(colors_default.green(`\u2713 updating ${selectedPackages.length} package${selectedPackages.length !== 1 ? "s" : ""}`));
3475
- console.info();
3476
- const selectedMajor = packages.filter((p) => selectedPackages.includes(p.name) && p.isMajorUpdate);
3477
- if (selectedMajor.length > 0) {
3478
- const majorNames = selectedMajor.map((p) => colors_default.bold(p.name)).join(", ");
3479
- try {
3480
- const proceed = await confirm2({
3481
- message: `${selectedMajor.length} major update${selectedMajor.length > 1 ? "s" : ""} selected (${majorNames}). proceed?`,
3482
- default: true
3483
- });
3484
- if (!proceed) {
3485
- console.info(colors_default.dim(" update cancelled"));
3486
- console.info();
3487
- return "skipped";
3488
- }
3489
- } catch {
3490
- return "cancelled";
4129
+ function bumpText(update) {
4130
+ if (update.pin.kind === "node") return `engines.node ${update.target}`;
4131
+ if (update.pin.kind === "pnpm") return `packageManager pnpm@${update.target}`;
4132
+ return update.pin.to;
4133
+ }
4134
+ async function planEdits(forkPath, rows, selected, nodeImageSuffixes) {
4135
+ const contents = /* @__PURE__ */ new Map();
4136
+ const summary = /* @__PURE__ */ new Map();
4137
+ const bumped = [];
4138
+ const edit = (file, label, item, resolves, change) => {
4139
+ const entry = contents.get(file) ?? { content: readFileSync4(join13(forkPath, file), "utf8"), resolves: false };
4140
+ const next = change(entry.content);
4141
+ if (next === entry.content) return;
4142
+ contents.set(file, { content: next, resolves: entry.resolves || resolves });
4143
+ summary.set(label, [...summary.get(label) ?? [], item]);
4144
+ };
4145
+ const chosen = rows.filter((row) => selected.includes(row.key));
4146
+ for (const row of chosen) {
4147
+ if (row.runtime) continue;
4148
+ for (const { name: dependent, location } of row.dependentPackages) {
4149
+ const file = relative3(forkPath, join13(location, "package.json"));
4150
+ edit(
4151
+ file,
4152
+ dependent || file,
4153
+ row.name,
4154
+ true,
4155
+ (content) => editJsonText(content, (json) => {
4156
+ for (const section of DEPENDENCY_SECTIONS) {
4157
+ const deps = json[section];
4158
+ const spec = deps?.[row.name];
4159
+ if (!deps || spec === void 0) continue;
4160
+ const next = rewriteSpecifier(spec, row.latest);
4161
+ if (next) deps[row.name] = next;
4162
+ else console.info(`${warningMark} ${row.name} in ${dependent || file} left as written: ${spec}`);
4163
+ }
4164
+ })
4165
+ );
3491
4166
  }
3492
4167
  }
3493
- const catalogPackages = readCatalogPackageNames(forkPath);
3494
- const workspacePackages = /* @__PURE__ */ new Map();
3495
- for (const pkgName of selectedPackages) {
3496
- const pkg = packages.find((p) => p.name === pkgName);
3497
- if (!pkg) continue;
3498
- for (const { name: dependent } of pkg.dependentPackages) {
3499
- const group = workspacePackages.get(dependent);
3500
- if (group) group.push(pkgName);
3501
- else workspacePackages.set(dependent, [pkgName]);
4168
+ for (const row of chosen) {
4169
+ const update = row.runtime;
4170
+ if (!update) continue;
4171
+ if (update.pin.kind === "node") {
4172
+ const missing = await missingNodeImages(update.target, nodeImageSuffixes);
4173
+ if (missing.length > 0) {
4174
+ console.info(`${warningMark} node ${update.target} skipped: ${missing.join(", ")} is not on docker hub yet`);
4175
+ continue;
4176
+ }
3502
4177
  }
4178
+ edit(update.file, update.file, bumpText(update), false, (content) => applyRuntimeUpdate(content, update));
4179
+ bumped.push(update);
3503
4180
  }
3504
- for (const [workspace, pkgs] of workspacePackages) {
3505
- const pkgList = pkgs.join(", ");
3506
- console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(workspace)}: ${colors_default.dim(pkgList)}`);
4181
+ for (const [label, items] of summary) {
4182
+ console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(label)}: ${colors_default.dim(items.join(", "))}`);
3507
4183
  }
3508
- console.info();
3509
- const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
3510
- if (selectedCatalogPackages.length > 0) {
3511
- console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
3512
- const normalizeResult = spawnSync3("pnpm", ["-r", "up", ...selectedCatalogPackages], {
3513
- cwd: forkPath,
3514
- stdio: "inherit"
3515
- });
3516
- console.info();
3517
- if (normalizeResult.status !== 0) {
3518
- console.error(colors_default.red(`\u2717 catalog normalization failed (exit code ${normalizeResult.status})`));
3519
- return "failed";
3520
- }
4184
+ if (summary.size > 0) console.info();
4185
+ const edits = { manifests: {}, files: {} };
4186
+ for (const [file, { content, resolves }] of contents) {
4187
+ (resolves ? edits.manifests : edits.files)[file] = content;
3521
4188
  }
3522
- const result = spawnSync3("pnpm", ["-r", "up", "--latest", ...selectedPackages], { cwd: forkPath, stdio: "inherit" });
3523
- console.info();
3524
- if (result.status !== 0) {
3525
- console.error(colors_default.red(`\u2717 update failed (exit code ${result.status})`));
3526
- return "failed";
3527
- }
3528
- console.info(colors_default.green(`\u2713 ${selectedPackages.length} package${selectedPackages.length > 1 ? "s" : ""} updated`));
3529
- console.info();
3530
- return "updated";
4189
+ return { edits, bumped };
3531
4190
  }
3532
- function readCatalogPackageNames(forkPath) {
3533
- const workspacePath = join10(forkPath, "pnpm-workspace.yaml");
3534
- try {
3535
- const content = readFileSync3(workspacePath, "utf8");
3536
- return new Set(Object.keys(readTopLevel(content, "catalog") ?? {}));
3537
- } catch {
3538
- return /* @__PURE__ */ new Set();
4191
+ function printNext(bumped) {
4192
+ const steps = [];
4193
+ const node = bumped.find((update) => update.pin.kind === "node");
4194
+ if (node && process.version !== `v${node.target}`) {
4195
+ const command = getEnv("VOLTA_HOME") ? `volta install node@${node.target}` : `switch to node ${node.target}`;
4196
+ steps.push([command, `this machine runs ${process.version.slice(1)}`]);
4197
+ }
4198
+ for (const update of bumped) {
4199
+ if (update.pin.kind === "image") steps.push([`docker build -f ${update.file} .`, "check that the image builds"]);
3539
4200
  }
4201
+ if (steps.length === 0) return;
4202
+ const width = Math.max(...steps.map(([command]) => command.length));
4203
+ console.info(colors_default.bold("next"));
4204
+ for (const [command, why] of steps) console.info(` ${colors_default.dim("\u2022")} ${command.padEnd(width)} ${colors_default.dim(why)}`);
4205
+ console.info();
3540
4206
  }
3541
4207
 
3542
4208
  // src/services/contributions.ts
@@ -3552,111 +4218,9 @@ import {
3552
4218
  } from "@inquirer/core";
3553
4219
  import { select as select3 } from "@inquirer/prompts";
3554
4220
 
3555
- // src/utils/diff.ts
3556
- import { execFileSync as execFileSync2 } from "child_process";
3557
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
3558
- import { tmpdir as tmpdir2 } from "os";
3559
- import { basename as basename3, join as join11 } from "path";
3560
- import process6 from "process";
3561
- function gitDiffFile(cwd, range, filePath, options = {}) {
3562
- const args = ["diff", "--no-color"];
3563
- if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
3564
- args.push(range, "--", filePath);
3565
- try {
3566
- return execFileSync2("git", args, { cwd, maxBuffer: MAX_BUFFER });
3567
- } catch (error) {
3568
- const stderr = error.stderr?.toString().trim();
3569
- throw new Error(stderr || `failed to diff ${filePath}`);
3570
- }
3571
- }
3572
- function escapeHtml(text) {
3573
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3574
- }
3575
- async function renderDiffPage(patch, meta) {
3576
- const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
3577
- const { prerenderedHTML } = await preloadPatchDiff({
3578
- patch,
3579
- options: {
3580
- diffStyle: "split",
3581
- themeType: "system",
3582
- lineDiffType: "word-alt",
3583
- hunkSeparators: "line-info",
3584
- overflow: "wrap",
3585
- disableFileHeader: true
3586
- }
3587
- });
3588
- const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
3589
- const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
3590
- return `<!doctype html>
3591
- <html lang="en">
3592
- <head>
3593
- <meta charset="utf-8">
3594
- <meta name="viewport" content="width=device-width, initial-scale=1">
3595
- <title>${title}</title>
3596
- <style>
3597
- :root { color-scheme: light dark; }
3598
- * { box-sizing: border-box; }
3599
- body {
3600
- margin: 0;
3601
- font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
3602
- background: light-dark(#fafafa, #111);
3603
- color: light-dark(#111, #eee);
3604
- }
3605
- header {
3606
- display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
3607
- padding: 0.9rem 1.25rem;
3608
- border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
3609
- position: sticky; top: 0;
3610
- background: inherit;
3611
- }
3612
- header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
3613
- header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
3614
- header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
3615
- main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
3616
- .diff-shell {
3617
- border: 1px solid light-dark(#e2e2e2, #2a2a2a);
3618
- border-radius: 8px; overflow: hidden;
3619
- background: light-dark(#fff, #161616);
3620
- }
3621
- </style>
3622
- </head>
3623
- <body>
3624
- <header>
3625
- <span class="brand">\u29C8 cella diff</span>
3626
- <code>${escapeHtml(meta.filePath)}</code>
3627
- <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
3628
- </header>
3629
- <main>
3630
- <!-- Declarative shadow DOM: the library's CSS targets :host, which only
3631
- matches inside a shadow root (its browser component renders into one). -->
3632
- <div class="diff-shell">
3633
- <template shadowrootmode="open">
3634
- ${prerenderedHTML}
3635
- </template>
3636
- </div>
3637
- </main>
3638
- </body>
3639
- </html>`;
3640
- }
3641
- function openWithDefaultApp(target) {
3642
- try {
3643
- execFileSync2(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
3644
- } catch {
3645
- }
3646
- }
3647
- async function openDiffInBrowser(patch, meta, repoPath) {
3648
- const html = await renderDiffPage(patch, meta);
3649
- const dir = join11(tmpdir2(), `cella-diff-${basename3(repoPath)}`);
3650
- mkdirSync2(dir, { recursive: true });
3651
- const outPath = join11(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
3652
- writeFileSync2(outPath, html);
3653
- openWithDefaultApp(outPath);
3654
- return outPath;
3655
- }
3656
-
3657
4221
  // src/services/contrib-core.ts
3658
4222
  import { rmSync as rmSync2 } from "fs";
3659
- import { dirname as dirname6, join as join12, resolve as resolve4 } from "path";
4223
+ import { dirname as dirname8, join as join14, resolve as resolve4 } from "path";
3660
4224
  async function listFilesAtRef(repoPath, ref) {
3661
4225
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3662
4226
  return new Set(raw.split("\n").filter(Boolean));
@@ -3672,10 +4236,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3672
4236
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3673
4237
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3674
4238
  for (const f of baseFiles) {
3675
- let dir = dirname6(f);
4239
+ let dir = dirname8(f);
3676
4240
  while (dir !== ".") {
3677
4241
  baseDirs.add(dir);
3678
- dir = dirname6(dir);
4242
+ dir = dirname8(dir);
3679
4243
  }
3680
4244
  }
3681
4245
  const files = [];
@@ -3688,7 +4252,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3688
4252
  if (inCella && !inFork) {
3689
4253
  kind = "deleted";
3690
4254
  } else if (!inCella && inFork) {
3691
- if (!baseDirs.has(dirname6(file.path))) continue;
4255
+ if (!baseDirs.has(dirname8(file.path))) continue;
3692
4256
  kind = "created";
3693
4257
  } else {
3694
4258
  kind = "modified";
@@ -3712,7 +4276,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
3712
4276
  async function buildContribBranch(repoPath, baseRef, forkRef, files, forkName) {
3713
4277
  const branch = `contrib/${forkName}`;
3714
4278
  const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3715
- const tmpIndex = join12(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4279
+ const tmpIndex = join14(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3716
4280
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3717
4281
  try {
3718
4282
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3747,20 +4311,20 @@ ${commitBody}`;
3747
4311
  }
3748
4312
 
3749
4313
  // src/services/fork-utils.ts
3750
- import { existsSync as existsSync8 } from "fs";
3751
- import { dirname as dirname7, resolve as resolve5 } from "path";
4314
+ import { existsSync as existsSync9 } from "fs";
4315
+ import { dirname as dirname9, resolve as resolve5 } from "path";
3752
4316
  async function resolveForkBasePath(repoPath) {
3753
4317
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3754
- return commonDir ? dirname7(resolve5(repoPath, commonDir)) : repoPath;
4318
+ return commonDir ? dirname9(resolve5(repoPath, commonDir)) : repoPath;
3755
4319
  }
3756
4320
  function validateForkPath(fork, basePath, requireConfig = false) {
3757
4321
  const resolvedPath = resolve5(basePath, fork.localPath);
3758
4322
  if (!resolvedPath.startsWith(resolve5(basePath, ".."))) {
3759
4323
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
3760
4324
  }
3761
- if (!existsSync8(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3762
- if (!existsSync8(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3763
- if (requireConfig && !existsSync8(`${resolvedPath}/${CONFIG_FILE}`)) {
4325
+ if (!existsSync9(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
4326
+ if (!existsSync9(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
4327
+ if (requireConfig && !existsSync9(`${resolvedPath}/${CONFIG_FILE}`)) {
3764
4328
  return { fork, resolvedPath, valid: false, error: `missing ${CONFIG_FILE}` };
3765
4329
  }
3766
4330
  return { fork, resolvedPath, valid: true };
@@ -3831,8 +4395,13 @@ var contribPrompt = createPrompt((config, done) => {
3831
4395
  try {
3832
4396
  const patch = gitDiffFile(cwd, `${baseRef}..${branch}`, path);
3833
4397
  if (patch.length === 0) return setStatusMsg(`no changes for ${path}`);
3834
- await openDiffInBrowser(patch.toString(), { filePath: path, srcLabel: "cella", dstLabel: forkName }, cwd);
3835
- setStatusMsg(`opened ${path} in browser`);
4398
+ const page2 = await writeDiffPage(
4399
+ [{ files: [{ path, patch: patch.toString() }] }],
4400
+ { title: path, note: `cella \u2190 \u2192 ${forkName}` },
4401
+ cwd,
4402
+ path
4403
+ );
4404
+ setStatusMsg(openInBrowser(page2) ? `opened ${path} in browser` : `diff page: ${page2}`);
3836
4405
  } catch (error) {
3837
4406
  setStatusMsg(`diff failed: ${errorMessage(error)}`);
3838
4407
  }
@@ -4061,20 +4630,20 @@ async function runContributions(config) {
4061
4630
 
4062
4631
  // src/services/forks.ts
4063
4632
  import { resolve as resolve6 } from "path";
4064
- import process7 from "process";
4633
+ import process8 from "process";
4065
4634
  import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4066
4635
 
4067
4636
  // src/services/sync.ts
4068
- import { spawnSync as spawnSync5 } from "child_process";
4637
+ import { spawnSync as spawnSync4 } from "child_process";
4069
4638
  import { select as select4 } from "@inquirer/prompts";
4070
4639
 
4071
4640
  // src/utils/gh.ts
4072
- import { spawnSync as spawnSync4 } from "child_process";
4641
+ import { spawnSync as spawnSync3 } from "child_process";
4073
4642
  function ghAvailable() {
4074
- return spawnSync4("gh", ["--version"], { stdio: "ignore" }).status === 0;
4643
+ return spawnSync3("gh", ["--version"], { stdio: "ignore" }).status === 0;
4075
4644
  }
4076
4645
  function runGh(cwd, args) {
4077
- const result = spawnSync4("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4646
+ const result = spawnSync3("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4078
4647
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4079
4648
  return { ok: result.status === 0, output };
4080
4649
  }
@@ -4106,12 +4675,12 @@ function closePr(cwd, ref) {
4106
4675
  }
4107
4676
 
4108
4677
  // src/services/packages.ts
4109
- import { existsSync as existsSync10, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4110
- import { join as join14 } from "path";
4678
+ import { existsSync as existsSync11, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4679
+ import { join as join16 } from "path";
4111
4680
 
4112
4681
  // src/services/package-usage.ts
4113
- import { existsSync as existsSync9, readFileSync as readFileSync4, statSync } from "fs";
4114
- import { join as join13 } from "path";
4682
+ import { existsSync as existsSync10, readFileSync as readFileSync5, statSync } from "fs";
4683
+ import { join as join15 } from "path";
4115
4684
  var MAX_FILE_BYTES = 1024 * 1024;
4116
4685
  function escapeRegExp(value) {
4117
4686
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4119,7 +4688,7 @@ function escapeRegExp(value) {
4119
4688
  function readSearchable(filePath) {
4120
4689
  try {
4121
4690
  if (statSync(filePath).size > MAX_FILE_BYTES) return "";
4122
- const text = readFileSync4(filePath, "utf8");
4691
+ const text = readFileSync5(filePath, "utf8");
4123
4692
  return text.includes("\0") ? "" : text;
4124
4693
  } catch {
4125
4694
  return "";
@@ -4127,7 +4696,7 @@ function readSearchable(filePath) {
4127
4696
  }
4128
4697
  function fileSource(forkPath, path) {
4129
4698
  let text;
4130
- return { path, label: path, text: () => text ??= readSearchable(join13(forkPath, path)) };
4699
+ return { path, label: path, text: () => text ??= readSearchable(join15(forkPath, path)) };
4131
4700
  }
4132
4701
  async function readScriptsAtRef(forkPath, ref, path) {
4133
4702
  try {
@@ -4139,7 +4708,7 @@ async function readScriptsAtRef(forkPath, ref, path) {
4139
4708
  async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4140
4709
  let forkScripts;
4141
4710
  try {
4142
- forkScripts = JSON.parse(readFileSync4(join13(forkPath, path), "utf8")).scripts ?? {};
4711
+ forkScripts = JSON.parse(readFileSync5(join15(forkPath, path), "utf8")).scripts ?? {};
4143
4712
  } catch {
4144
4713
  return [];
4145
4714
  }
@@ -4149,11 +4718,11 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4149
4718
  }
4150
4719
  function binNames(forkPath, location, name) {
4151
4720
  const bareName = name.split("/").pop();
4152
- for (const dir of [join13(forkPath, location), forkPath]) {
4153
- const manifest = join13(dir, "node_modules", name, "package.json");
4154
- if (!existsSync9(manifest)) continue;
4721
+ for (const dir of [join15(forkPath, location), forkPath]) {
4722
+ const manifest = join15(dir, "node_modules", name, "package.json");
4723
+ if (!existsSync10(manifest)) continue;
4155
4724
  try {
4156
- const { bin } = JSON.parse(readFileSync4(manifest, "utf8"));
4725
+ const { bin } = JSON.parse(readFileSync5(manifest, "utf8"));
4157
4726
  if (typeof bin === "string") return [bareName];
4158
4727
  return bin && typeof bin === "object" ? Object.keys(bin) : [];
4159
4728
  } catch {
@@ -4332,9 +4901,9 @@ function setTopLevelKey(pkg, key, value) {
4332
4901
  Object.assign(pkg, Object.fromEntries(entries));
4333
4902
  }
4334
4903
  function readPackageJson(filePath) {
4335
- if (!existsSync10(filePath)) return null;
4904
+ if (!existsSync11(filePath)) return null;
4336
4905
  try {
4337
- return JSON.parse(readFileSync5(filePath, "utf8"));
4906
+ return JSON.parse(readFileSync6(filePath, "utf8"));
4338
4907
  } catch (error) {
4339
4908
  throw new Error(`cannot parse ${filePath}: ${errorMessage(error)}`);
4340
4909
  }
@@ -4366,13 +4935,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4366
4935
  const changes = [];
4367
4936
  const kept = [];
4368
4937
  const pkgRelPath = packageJsonPath(relativePath);
4369
- const pkgPath = join14(forkPath, pkgRelPath);
4938
+ const pkgPath = join16(forkPath, pkgRelPath);
4370
4939
  const forkPkg = readPackageJson(pkgPath);
4371
4940
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4372
4941
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4373
4942
  if (!upstream) return { updated: false, changes, kept };
4374
4943
  if (!forkPkg) {
4375
- const workspaceArrived = relativePath !== "" && existsSync10(join14(forkPath, relativePath));
4944
+ const workspaceArrived = relativePath !== "" && existsSync11(join16(forkPath, relativePath));
4376
4945
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4377
4946
  writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4378
4947
  `, "utf8");
@@ -4578,6 +5147,7 @@ async function runSync(config, options) {
4578
5147
  const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
4579
5148
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
4580
5149
  printSyncComplete(result, { stagedBranch });
5150
+ await reportReviewPage(config.forkPath, result, "sync");
4581
5151
  await printMigrationNotesLine(config, result);
4582
5152
  return result;
4583
5153
  }
@@ -4686,7 +5256,7 @@ async function shipSyncBranch(config, branch) {
4686
5256
  console.info(colors_default.dim("opening a pull request..."));
4687
5257
  const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
4688
5258
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
4689
- const pr = spawnSync5("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5259
+ const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4690
5260
  cwd: forkPath,
4691
5261
  encoding: "utf8",
4692
5262
  stdio: ["ignore", "pipe", "pipe"]
@@ -4717,7 +5287,7 @@ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
4717
5287
  function finalizeWorkspace(forkPath) {
4718
5288
  for (const args of [["install"], ["check"]]) {
4719
5289
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
4720
- const result = spawnSync5("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5290
+ const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4721
5291
  if (result.status !== 0) return false;
4722
5292
  }
4723
5293
  return true;
@@ -4983,7 +5553,7 @@ async function runForks(config) {
4983
5553
  loop: false
4984
5554
  });
4985
5555
  if (selectedPath === "_exit") {
4986
- process7.exit(0);
5556
+ process8.exit(0);
4987
5557
  }
4988
5558
  const resolvedForkPath = resolve6(forkBasePath, selectedPath);
4989
5559
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
@@ -4998,11 +5568,11 @@ async function runForks(config) {
4998
5568
 
4999
5569
  // src/services/stats.ts
5000
5570
  import { readFile as readFile3 } from "fs/promises";
5001
- import { join as join16 } from "path";
5571
+ import { join as join18 } from "path";
5002
5572
 
5003
5573
  // src/utils/coverage-utils.ts
5004
- import { existsSync as existsSync11, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
5005
- import { join as join15, relative as relative4, sep as sep2 } from "path";
5574
+ import { existsSync as existsSync12, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
5575
+ import { join as join17, relative as relative4, sep as sep3 } from "path";
5006
5576
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5007
5577
  function colorPct(pct) {
5008
5578
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
@@ -5020,14 +5590,14 @@ function formatAge(ms) {
5020
5590
  return `${mins} minute${mins === 1 ? "" : "s"} ago`;
5021
5591
  }
5022
5592
  function printCoverageSummary(forkPath) {
5023
- const file = join15(forkPath, ".coverage", "coverage-summary.json");
5593
+ const file = join17(forkPath, ".coverage", "coverage-summary.json");
5024
5594
  let summary;
5025
5595
  let mtimeMs = 0;
5026
5596
  let problem = "no coverage data found";
5027
- if (existsSync11(file)) {
5597
+ if (existsSync12(file)) {
5028
5598
  try {
5029
5599
  mtimeMs = statSync2(file).mtimeMs;
5030
- summary = JSON.parse(readFileSync6(file, "utf8"));
5600
+ summary = JSON.parse(readFileSync7(file, "utf8"));
5031
5601
  } catch {
5032
5602
  problem = "could not read coverage-summary.json";
5033
5603
  }
@@ -5045,7 +5615,7 @@ function printCoverageSummary(forkPath) {
5045
5615
  if (key === "total") continue;
5046
5616
  const rel = relative4(forkPath, key);
5047
5617
  if (rel.startsWith("..")) continue;
5048
- const pkg = rel.split(sep2)[0];
5618
+ const pkg = rel.split(sep3)[0];
5049
5619
  const acc = perPackage.get(pkg) ?? { covered: 0, total: 0 };
5050
5620
  acc.covered += entry.lines.covered;
5051
5621
  acc.total += entry.lines.total;
@@ -5071,7 +5641,7 @@ function printCoverageSummary(forkPath) {
5071
5641
  // src/services/stats.ts
5072
5642
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5073
5643
  async function getWorkspacePackages(forkPath) {
5074
- const content = await readFile3(join16(forkPath, "pnpm-workspace.yaml"), "utf8");
5644
+ const content = await readFile3(join18(forkPath, "pnpm-workspace.yaml"), "utf8");
5075
5645
  const patterns = readTopLevel(content, "packages");
5076
5646
  return (Array.isArray(patterns) ? patterns.map(String) : []).map((pattern) => {
5077
5647
  if (pattern.endsWith("/*")) {
@@ -5119,9 +5689,9 @@ async function collectStats(forkPath) {
5119
5689
  const entries = [];
5120
5690
  for (const line of output.split("\n")) {
5121
5691
  if (!line) continue;
5122
- const sep3 = line.lastIndexOf(":");
5123
- if (sep3 === -1) continue;
5124
- entries.push({ path: line.slice(0, sep3), loc: Number.parseInt(line.slice(sep3 + 1), 10) });
5692
+ const sep4 = line.lastIndexOf(":");
5693
+ if (sep4 === -1) continue;
5694
+ entries.push({ path: line.slice(0, sep4), loc: Number.parseInt(line.slice(sep4 + 1), 10) });
5125
5695
  }
5126
5696
  const emptyCat = () => ({
5127
5697
  test: { files: 0, loc: 0 },
@@ -5424,19 +5994,19 @@ function getForkPath() {
5424
5994
  const envPath = getEnv("CELLA_FORK_PATH");
5425
5995
  if (envPath) {
5426
5996
  const resolved = resolve7(envPath);
5427
- if (!existsSync12(resolved) || !statSync3(resolved).isDirectory()) {
5997
+ if (!existsSync13(resolved) || !statSync3(resolved).isDirectory()) {
5428
5998
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
5429
5999
  }
5430
6000
  return resolved;
5431
6001
  }
5432
- let cwd = process8.cwd();
6002
+ let cwd = process9.cwd();
5433
6003
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5434
6004
  cwd = resolve7(cwd, "../..");
5435
6005
  }
5436
6006
  return cwd;
5437
6007
  }
5438
6008
  function preflight(forkPath) {
5439
- if (!existsSync12(join17(forkPath, ".git"))) {
6009
+ if (!existsSync13(join19(forkPath, ".git"))) {
5440
6010
  throw new Error(`not a git repository: ${forkPath}`);
5441
6011
  }
5442
6012
  }
@@ -5454,7 +6024,7 @@ var services = {
5454
6024
  }
5455
6025
  };
5456
6026
  function isHelpOrVersionRequest() {
5457
- return process8.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process8.argv[2] === "help";
6027
+ return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
5458
6028
  }
5459
6029
  async function main() {
5460
6030
  try {
@@ -5473,7 +6043,7 @@ async function main() {
5473
6043
  } catch (error) {
5474
6044
  console.error();
5475
6045
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
5476
- process8.exit(1);
6046
+ process9.exit(1);
5477
6047
  }
5478
6048
  }
5479
6049
  main();