@cellajs/cli 0.4.0 → 0.4.1

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
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cella-cli.ts
4
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";
5
+ import { join as join18, 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.1",
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,18 +2862,18 @@ 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 join12, 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";
@@ -2712,11 +2906,6 @@ function getReleasesUrl(repoUrl) {
2712
2906
  if (!isGitHubRepoUrl(repoUrl)) return null;
2713
2907
  return `${repoUrl}/releases`;
2714
2908
  }
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
2909
  async function runPnpm(args, cwd) {
2721
2910
  try {
2722
2911
  const { stdout, stderr } = await execFileAsync2("pnpm", args, {
@@ -2742,9 +2931,23 @@ async function runPnpmAudit(cwd) {
2742
2931
  if (!result || typeof result.advisories !== "object" || result.advisories === null) return null;
2743
2932
  return result;
2744
2933
  }
2934
+ async function readReleaseAgeMinutes(cwd) {
2935
+ const minutes = Number.parseInt((await runPnpm(["config", "get", "minimumReleaseAge"], cwd)).stdout, 10);
2936
+ return Number.isNaN(minutes) ? 0 : minutes;
2937
+ }
2745
2938
  async function fetchPublishTimes(name, cwd) {
2746
2939
  const times = await runPnpmJson(["view", name, "time", "--json"], cwd);
2747
- return times && typeof times === "object" ? times : null;
2940
+ return times && typeof times === "object" && !("error" in times) ? times : null;
2941
+ }
2942
+ function editJsonText(content, change) {
2943
+ const json = JSON.parse(content);
2944
+ change(json);
2945
+ const indent = content.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
2946
+ return `${JSON.stringify(json, null, indent)}${content.endsWith("\n") ? "\n" : ""}`;
2947
+ }
2948
+ function rewriteSpecifier(spec, latest) {
2949
+ const match = spec.match(/^([\^~]?)\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?$/);
2950
+ return match ? `${match[1]}${latest}` : null;
2748
2951
  }
2749
2952
  var vulnIcons = {
2750
2953
  critical: colors_default.red("\u25CF"),
@@ -2770,6 +2973,259 @@ function formatDependents(dependents) {
2770
2973
  return `${dependents[0]} +${dependents.length - 1}`;
2771
2974
  }
2772
2975
 
2976
+ // src/utils/runtimes.ts
2977
+ import { readFileSync as readFileSync2 } from "fs";
2978
+ import { basename as basename3, dirname as dirname5, join as join10 } from "path";
2979
+ var DAY = 24 * 60 * 60 * 1e3;
2980
+ var FROZEN_AFTER = 180 * DAY;
2981
+ var RECENT_PUSH = 30 * DAY;
2982
+ var EXACT_VERSION = /^\d+\.\d+\.\d+$/;
2983
+ var FROM_LINE = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)(?:\s+AS\s+(\S+))?\s*$/i;
2984
+ var NODE_ARG_TAG = /^(?:docker\.io\/)?(?:library\/)?node:\$\{?\w+\}?([\w.-]*)$/;
2985
+ var MINOR_TAG = /^(\d+(?:\.\d+){1,2})(-[\w.-]+)?$/;
2986
+ var isDockerfile = (path) => /(^|\.)Dockerfile$|^Dockerfile\./i.test(basename3(path));
2987
+ function parseDockerfile(file, content) {
2988
+ const images = [];
2989
+ const nodeImageSuffixes = [];
2990
+ const stages = /* @__PURE__ */ new Set();
2991
+ content.split("\n").forEach((text, line) => {
2992
+ const match = text.match(FROM_LINE);
2993
+ if (!match) return;
2994
+ const [, ref, stage] = match;
2995
+ const fromStage = stages.has(ref.toLowerCase());
2996
+ if (stage) stages.add(stage.toLowerCase());
2997
+ if (fromStage || ref.includes("@")) return;
2998
+ const nodeArg = ref.match(NODE_ARG_TAG);
2999
+ if (nodeArg) {
3000
+ nodeImageSuffixes.push(nodeArg[1]);
3001
+ return;
3002
+ }
3003
+ if (ref.includes("$")) return;
3004
+ const slash = ref.lastIndexOf("/");
3005
+ const colon = ref.lastIndexOf(":");
3006
+ if (colon <= slash) return;
3007
+ let image = ref.slice(0, colon);
3008
+ const tag = ref.slice(colon + 1);
3009
+ const host = image.includes("/") ? image.split("/")[0] : "";
3010
+ if (host === "docker.io") image = image.slice(host.length + 1).replace(/^library\//, "");
3011
+ else if (host.includes(".") || host.includes(":") || host === "localhost") return;
3012
+ if (image === "scratch") return;
3013
+ images.push({ file, line, image, tag });
3014
+ });
3015
+ return { images, nodeImageSuffixes };
3016
+ }
3017
+ async function readRuntimePins(forkPath) {
3018
+ let manifest = {};
3019
+ try {
3020
+ manifest = JSON.parse(readFileSync2(join10(forkPath, "package.json"), "utf8"));
3021
+ } catch {
3022
+ }
3023
+ const node = manifest.engines?.node?.trim() ?? "";
3024
+ const pnpm = manifest.packageManager?.match(/^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/)?.[1] ?? null;
3025
+ const tracked = (await git(["ls-files", "-z"], forkPath, { ignoreErrors: true })).split("\0").filter(isDockerfile);
3026
+ const images = [];
3027
+ const nodeImageSuffixes = /* @__PURE__ */ new Set();
3028
+ for (const file of tracked) {
3029
+ let content;
3030
+ try {
3031
+ content = readFileSync2(join10(forkPath, file), "utf8");
3032
+ } catch {
3033
+ continue;
3034
+ }
3035
+ const parsed = parseDockerfile(file, content);
3036
+ images.push(...parsed.images);
3037
+ for (const suffix of parsed.nodeImageSuffixes) nodeImageSuffixes.add(suffix);
3038
+ }
3039
+ return {
3040
+ node: EXACT_VERSION.test(node) ? node : null,
3041
+ pnpm,
3042
+ images,
3043
+ nodeImageSuffixes: [...nodeImageSuffixes]
3044
+ };
3045
+ }
3046
+ async function fetchJson(url, init) {
3047
+ try {
3048
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(1e4) });
3049
+ return response.ok ? await response.json() : null;
3050
+ } catch {
3051
+ return null;
3052
+ }
3053
+ }
3054
+ var hubRepository = (image) => image.includes("/") ? image : `library/${image}`;
3055
+ var hubTag = (entry) => {
3056
+ const { name, last_updated } = entry ?? {};
3057
+ const pushedAt = Date.parse(last_updated ?? "");
3058
+ return name && !Number.isNaN(pushedAt) ? { name, pushedAt } : null;
3059
+ };
3060
+ async function fetchNodeReleases(major, now) {
3061
+ const index = await fetchJson("https://nodejs.org/dist/index.json");
3062
+ if (!Array.isArray(index)) return null;
3063
+ const releases = index.map((entry) => entry).filter((entry) => entry.version && parseComparableVersion(entry.version)?.major === major).map((entry) => ({
3064
+ version: String(entry.version).replace(/^v/, ""),
3065
+ publishedAt: Date.parse(entry.date ?? ""),
3066
+ security: entry.security === true
3067
+ })).filter((release) => !Number.isNaN(release.publishedAt));
3068
+ await Promise.all(
3069
+ releases.filter((release) => now - release.publishedAt < 2 * DAY).map(async (release) => {
3070
+ try {
3071
+ const response = await fetch(`https://nodejs.org/dist/v${release.version}/SHASUMS256.txt`, {
3072
+ method: "HEAD",
3073
+ signal: AbortSignal.timeout(1e4)
3074
+ });
3075
+ const modified = Date.parse(response.headers.get("last-modified") ?? "");
3076
+ release.publishedAt = Number.isNaN(modified) ? release.publishedAt + DAY : modified;
3077
+ } catch {
3078
+ release.publishedAt += DAY;
3079
+ }
3080
+ })
3081
+ );
3082
+ return releases;
3083
+ }
3084
+ async function fetchImageTags(pin) {
3085
+ const shape = pin.tag.match(MINOR_TAG);
3086
+ if (!shape) return null;
3087
+ const base = `https://hub.docker.com/v2/repositories/${hubRepository(pin.image)}/tags`;
3088
+ const filter = shape[2] ?? `${shape[1].split(".")[0]}.`;
3089
+ const [list, pinned] = await Promise.all([
3090
+ fetchJson(`${base}?page_size=100&ordering=last_updated&name=${encodeURIComponent(filter)}`),
3091
+ fetchJson(`${base}/${encodeURIComponent(pin.tag)}`)
3092
+ ]);
3093
+ const results = list?.results;
3094
+ if (!Array.isArray(results)) return null;
3095
+ return { pinned: hubTag(pinned), tags: results.flatMap((entry) => hubTag(entry) ?? []) };
3096
+ }
3097
+ async function fetchRuntimeFeeds(pins, cwd, now) {
3098
+ const nodeMajor = pins.node ? parseComparableVersion(pins.node)?.major : void 0;
3099
+ const [node, published, advisories, images] = await Promise.all([
3100
+ nodeMajor === void 0 ? null : fetchNodeReleases(nodeMajor, now),
3101
+ pins.pnpm ? fetchPublishTimes("pnpm", cwd) : null,
3102
+ pins.pnpm ? fetchJson("https://registry.npmjs.org/-/npm/v1/security/advisories/bulk", {
3103
+ method: "POST",
3104
+ headers: { "content-type": "application/json" },
3105
+ body: JSON.stringify({ pnpm: [pins.pnpm] })
3106
+ }) : null,
3107
+ Promise.all(pins.images.map(fetchImageTags))
3108
+ ]);
3109
+ const pnpmAdvisories = advisories?.pnpm;
3110
+ return {
3111
+ node,
3112
+ pnpm: published && advisories ? { published, advisories: pnpmAdvisories ?? [] } : null,
3113
+ images
3114
+ };
3115
+ }
3116
+ var count = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
3117
+ var monthYear = (time) => new Date(time).toLocaleString("en", { month: "short", year: "numeric", timeZone: "UTC" }).toLowerCase();
3118
+ function newestAged(pinned, versions, cutoff) {
3119
+ const major = parseComparableVersion(pinned)?.major;
3120
+ return versions.filter(
3121
+ (entry) => !isPrerelease(entry.version) && parseComparableVersion(entry.version)?.major === major && compareVersionStrings(entry.version, pinned) > 0 && entry.publishedAt <= cutoff
3122
+ ).sort((a, b) => compareVersionStrings(b.version, a.version))[0];
3123
+ }
3124
+ function planRuntimeUpdates(pins, feeds, options) {
3125
+ const cutoff = options.now - options.releaseAgeMinutes * 60 * 1e3;
3126
+ const updates = [];
3127
+ const unchecked = [];
3128
+ if (pins.node) {
3129
+ if (!feeds.node) unchecked.push("node");
3130
+ const target = feeds.node && newestAged(pins.node, feeds.node, cutoff);
3131
+ if (target) {
3132
+ const node = pins.node;
3133
+ const security = (feeds.node ?? []).some(
3134
+ (release) => release.security && compareVersionStrings(release.version, node) > 0 && compareVersionStrings(release.version, target.version) <= 0
3135
+ );
3136
+ updates.push({
3137
+ name: "node",
3138
+ current: node,
3139
+ target: target.version,
3140
+ file: "package.json",
3141
+ severity: security ? "high" : null,
3142
+ note: security ? "security release" : "",
3143
+ releasesUrl: `https://nodejs.org/en/blog/release/v${target.version}`,
3144
+ pin: { kind: "node" }
3145
+ });
3146
+ }
3147
+ }
3148
+ if (pins.pnpm) {
3149
+ if (!feeds.pnpm) unchecked.push("pnpm");
3150
+ const published = Object.entries(feeds.pnpm?.published ?? {}).map(([version, time]) => ({
3151
+ version,
3152
+ publishedAt: Date.parse(time)
3153
+ }));
3154
+ const target = feeds.pnpm && newestAged(pins.pnpm, published, cutoff);
3155
+ if (target) {
3156
+ const pnpm = pins.pnpm;
3157
+ const fixed = (feeds.pnpm?.advisories ?? []).filter(
3158
+ (advisory) => satisfiesComparators(pnpm, advisory.vulnerable_versions) && !satisfiesComparators(target.version, advisory.vulnerable_versions)
3159
+ ).length;
3160
+ updates.push({
3161
+ name: "pnpm",
3162
+ current: pnpm,
3163
+ target: target.version,
3164
+ file: "package.json",
3165
+ severity: fixed > 0 ? "high" : null,
3166
+ note: fixed > 0 ? count(fixed, "advisory", "advisories") : "",
3167
+ releasesUrl: `https://github.com/pnpm/pnpm/releases/tag/v${target.version}`,
3168
+ pin: { kind: "pnpm" }
3169
+ });
3170
+ }
3171
+ }
3172
+ pins.images.forEach((pin, i) => {
3173
+ const shape = pin.tag.match(MINOR_TAG);
3174
+ if (!shape) return;
3175
+ const feed = feeds.images[i];
3176
+ if (!feed) {
3177
+ unchecked.push(pin.image);
3178
+ return;
3179
+ }
3180
+ const [, version, suffix = ""] = shape;
3181
+ const parts = version.split(".").length;
3182
+ const major = version.split(".")[0];
3183
+ const target = feed.tags.map((tag) => ({ tag, match: tag.name.match(MINOR_TAG) })).filter(
3184
+ ({ match }) => match && (match[2] ?? "") === suffix && match[1].split(".").length === parts && match[1].split(".")[0] === major && compareVersionStrings(match[1], version) > 0
3185
+ ).sort((a, b) => compareVersionStrings(b.match?.[1] ?? "", a.match?.[1] ?? ""))[0];
3186
+ if (!target?.match) return;
3187
+ const frozen = !!feed.pinned && options.now - feed.pinned.pushedAt > FROZEN_AFTER && options.now - target.tag.pushedAt < RECENT_PUSH;
3188
+ const repository = hubRepository(pin.image);
3189
+ updates.push({
3190
+ name: pin.image,
3191
+ current: version,
3192
+ target: target.match[1],
3193
+ file: pin.file,
3194
+ severity: frozen ? "moderate" : null,
3195
+ note: frozen && feed.pinned ? `tag frozen since ${monthYear(feed.pinned.pushedAt)}` : "",
3196
+ releasesUrl: repository.startsWith("library/") ? `https://hub.docker.com/_/${pin.image}/tags` : `https://hub.docker.com/r/${repository}/tags`,
3197
+ pin: { kind: "image", line: pin.line, ref: `${pin.image}:${pin.tag}`, to: `${pin.image}:${target.tag.name}` }
3198
+ });
3199
+ });
3200
+ return { updates, unchecked };
3201
+ }
3202
+ function applyRuntimeUpdate(content, update) {
3203
+ const { pin } = update;
3204
+ if (pin.kind === "image") {
3205
+ const lines = content.split("\n");
3206
+ lines[pin.line] = lines[pin.line].replace(pin.ref, pin.to);
3207
+ return lines.join("\n");
3208
+ }
3209
+ return editJsonText(content, (json) => {
3210
+ if (pin.kind === "node") json.engines.node = update.target;
3211
+ else json.packageManager = `pnpm@${update.target}`;
3212
+ });
3213
+ }
3214
+ async function missingNodeImages(version, suffixes) {
3215
+ const missing = await Promise.all(
3216
+ suffixes.map(async (suffix) => {
3217
+ const tag = `${version}${suffix}`;
3218
+ const found = await fetchJson(`https://hub.docker.com/v2/repositories/library/node/tags/${tag}`);
3219
+ return found ? [] : [`node:${tag}`];
3220
+ })
3221
+ );
3222
+ return missing.flat();
3223
+ }
3224
+ function pinLocation(file) {
3225
+ const dir = dirname5(file);
3226
+ return dir === "." ? file : dir;
3227
+ }
3228
+
2773
3229
  // src/utils/security-plan.ts
2774
3230
  var SEVERITIES = ["critical", "high", "moderate", "low", "info"];
2775
3231
  function isAuditOverride(key) {
@@ -2965,37 +3421,37 @@ function writeAuditSettings(content, next) {
2965
3421
 
2966
3422
  // src/services/audit-security.ts
2967
3423
  import { spawnSync as spawnSync2 } from "child_process";
2968
- import { cpSync, existsSync as existsSync7, mkdirSync, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
3424
+ import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync2, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
2969
3425
  import { tmpdir } from "os";
2970
- import { dirname as dirname5, join as join9, relative as relative2 } from "path";
3426
+ import { dirname as dirname6, join as join11, relative as relative2 } from "path";
2971
3427
  import { confirm, select as select2 } from "@inquirer/prompts";
2972
3428
  var WORKSPACE_FILE = "pnpm-workspace.yaml";
2973
3429
  var LOCK_FILE = "pnpm-lock.yaml";
2974
3430
  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}`;
3431
+ var readOr = (path, fallback = "") => existsSync7(path) ? readFileSync3(path, "utf8") : fallback;
3432
+ var count2 = (n, noun, plural2 = `${noun}s`) => `${n} ${n === 1 ? noun : plural2}`;
2977
3433
  var isBreaking = (item) => item.remedy.kind !== "none" && item.remedy.breaking;
2978
3434
  var needsDecision = (item) => item.standing === "new" && (item.remedy.kind !== "override" || item.remedy.breaking || item.direct);
2979
3435
  var sameEntries = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([key, value]) => b[key] === value);
2980
3436
  async function createStaging(forkPath, workspaceYaml) {
2981
- const staging = mkdtempSync(join9(tmpdir(), "cella-audit-"));
3437
+ const staging = mkdtempSync(join11(tmpdir(), "cella-audit-"));
2982
3438
  const projects = await runPnpmJson(["ls", "-r", "--depth", "-1", "--json"], forkPath) ?? [];
2983
3439
  const files = [
2984
3440
  ...RESOLUTION_FILES,
2985
3441
  ...readPatchFiles(workspaceYaml),
2986
- ...projects.map((project) => join9(relative2(forkPath, project.path), "package.json"))
3442
+ ...projects.map((project) => join11(relative2(forkPath, project.path), "package.json"))
2987
3443
  ];
2988
3444
  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));
3445
+ if (file.startsWith("..") || !existsSync7(join11(forkPath, file))) continue;
3446
+ mkdirSync2(dirname6(join11(staging, file)), { recursive: true });
3447
+ cpSync(join11(forkPath, file), join11(staging, file));
2992
3448
  }
2993
3449
  return staging;
2994
3450
  }
2995
3451
  async function resolveStaging(staging, workspaceYaml, settings) {
2996
- writeFileSync(join9(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
3452
+ writeFileSync2(join11(staging, WORKSPACE_FILE), writeAuditSettings(workspaceYaml, settings));
2997
3453
  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() };
3454
+ return result.ok ? { lock: readOr(join11(staging, LOCK_FILE)) } : { error: `${result.stdout}${result.stderr}`.trim() };
2999
3455
  }
3000
3456
  function lockedVersions(lock, name) {
3001
3457
  const escaped = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
@@ -3096,9 +3552,11 @@ function changeLine(label, before, after) {
3096
3552
  const counts = moved ? `${before.length} \u2192 ${after.length}` : String(after.length);
3097
3553
  return ` ${label.padEnd(9)} ${counts.padEnd(7)} ${colors_default.dim(moved || "unchanged")}`;
3098
3554
  }
3555
+ var NO_EDITS = { manifests: {}, files: {} };
3099
3556
  async function runSecurityPass(forkPath, options) {
3100
- const workspaceYaml = readOr(join9(forkPath, WORKSPACE_FILE));
3101
- const workspaceLock = readOr(join9(forkPath, LOCK_FILE));
3557
+ const { releaseAgeMinutes, edits = NO_EDITS } = options;
3558
+ const workspaceYaml = readOr(join11(forkPath, WORKSPACE_FILE));
3559
+ const workspaceLock = readOr(join11(forkPath, LOCK_FILE));
3102
3560
  let previous;
3103
3561
  try {
3104
3562
  previous = readAuditSettings(workspaceYaml);
@@ -3106,12 +3564,13 @@ async function runSecurityPass(forkPath, options) {
3106
3564
  console.error(
3107
3565
  `${colors_default.red("\u2717")} ${WORKSPACE_FILE} could not be read: ${error instanceof Error ? error.message : error}`
3108
3566
  );
3109
- return;
3567
+ return false;
3110
3568
  }
3111
3569
  const auditKeys = Object.keys(previous.overrides).filter(isAuditOverride);
3112
3570
  const lasting = Object.fromEntries(Object.entries(previous.overrides).filter(([key]) => !isAuditOverride(key)));
3571
+ const updatesManifests = Object.keys(edits.manifests).length > 0;
3113
3572
  const spinner = createSpinner(
3114
- auditKeys.length > 0 ? `resolving without the ${count(auditKeys.length, "audit override")}...` : "resolving the workspace..."
3573
+ auditKeys.length > 0 ? `resolving without the ${count2(auditKeys.length, "audit override")}...` : "resolving the workspace..."
3115
3574
  );
3116
3575
  const fail = (message, detail = "") => {
3117
3576
  spinner.stop();
@@ -3119,16 +3578,21 @@ async function runSecurityPass(forkPath, options) {
3119
3578
  if (detail) console.error(colors_default.dim(detail.split("\n").slice(-12).join("\n")));
3120
3579
  console.info(colors_default.dim(" the workspace is unchanged"));
3121
3580
  console.info();
3581
+ return false;
3122
3582
  };
3123
3583
  const staging = await createStaging(forkPath, workspaceYaml);
3124
3584
  try {
3585
+ for (const [file, content] of Object.entries(edits.manifests)) writeFileSync2(join11(staging, file), content);
3125
3586
  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);
3587
+ if ("error" in bare) {
3588
+ const without = updatesManifests ? "with the selected updates" : "without its audit overrides";
3589
+ return fail(`could not resolve the workspace ${without}`, bare.error);
3590
+ }
3127
3591
  spinnerText("checking for vulnerabilities...");
3128
3592
  const audit = await runPnpmAudit(staging);
3129
3593
  if (!audit) return fail("pnpm audit gave no answer, so nothing can be decided");
3130
3594
  const advisories = advisoriesFromAudit(audit);
3131
- spinnerText(`checking ${count(advisories.length, "advisory", "advisories")} against the registry...`);
3595
+ spinnerText(`checking ${count2(advisories.length, "advisory", "advisories")} against the registry...`);
3132
3596
  const versionedExcludes = previous.releaseAgeExcludes.flatMap((entry) => parseVersionedExclude(entry)?.name ?? []);
3133
3597
  const names = [.../* @__PURE__ */ new Set([...advisories.map((advisory) => advisory.module), ...versionedExcludes])];
3134
3598
  const times = await Promise.all(names.map((name) => fetchPublishTimes(name, forkPath)));
@@ -3137,8 +3601,6 @@ async function runSecurityPass(forkPath, options) {
3137
3601
  );
3138
3602
  const unanswered = advisories.map((advisory) => advisory.module).filter((name) => !published[name]);
3139
3603
  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
3604
  const releaseAgeHours = Math.round(releaseAgeMinutes / 60);
3143
3605
  const planInput = { advisories, published, previous, releaseAgeMinutes, now: Date.now() };
3144
3606
  const items = planSecurity(planInput);
@@ -3156,60 +3618,72 @@ async function runSecurityPass(forkPath, options) {
3156
3618
  if (!options.apply) {
3157
3619
  if (changes) console.info(colors_default.dim("run without --list to apply"));
3158
3620
  console.info();
3159
- return;
3621
+ return false;
3160
3622
  }
3161
3623
  let decisions;
3162
3624
  try {
3163
3625
  decisions = await decide(items, releaseAgeHours);
3164
3626
  } catch {
3165
3627
  console.info(colors_default.dim(" cancelled, the workspace is unchanged"));
3166
- return;
3628
+ return false;
3167
3629
  }
3168
3630
  const open = decisions.filter((decision) => decision.action === "open").map((decision) => decision.item.module);
3169
3631
  const kept = Object.assign({}, lasting, ...open.map((module) => standingOverrides(module, planInput)));
3170
3632
  const next = settingsFor(decisions, kept, excludes);
3171
3633
  const nextYaml = writeAuditSettings(workspaceYaml, next);
3172
- if (nextYaml === workspaceYaml) {
3173
- const state = `${count(auditKeys.length, "override")} \xB7 ${next.ignoredGhsas.length} ignored`;
3634
+ const settingsChanged = nextYaml !== workspaceYaml;
3635
+ const otherFiles = Object.keys(edits.files);
3636
+ const writeFiles = () => {
3637
+ for (const [file, content] of Object.entries(edits.files)) writeFileSync2(join11(forkPath, file), content);
3638
+ if (otherFiles.length > 0) console.info(`${checkMark} ${otherFiles.join(", ")} updated`);
3639
+ };
3640
+ if (!settingsChanged && !updatesManifests) {
3641
+ writeFiles();
3642
+ const state = `${count2(auditKeys.length, "override")} \xB7 ${next.ignoredGhsas.length} ignored`;
3174
3643
  console.info(`${checkMark} security settings are current ${colors_default.dim(`\xB7 ${state}`)}`);
3175
3644
  printOpen(open);
3176
- return;
3645
+ return true;
3177
3646
  }
3178
3647
  const summarize = () => {
3648
+ if (!settingsChanged) return;
3179
3649
  console.info(colors_default.bold(WORKSPACE_FILE));
3180
3650
  console.info(changeLine("overrides", auditKeys, Object.keys(next.overrides).filter(isAuditOverride)));
3181
3651
  console.info(changeLine("excludes", previous.releaseAgeExcludes, next.releaseAgeExcludes));
3182
3652
  console.info(changeLine("ignored", previous.ignoredGhsas, next.ignoredGhsas));
3183
3653
  console.info();
3184
3654
  };
3185
- if (sameEntries(next.overrides, previous.overrides)) {
3186
- writeFileSync(join9(forkPath, WORKSPACE_FILE), nextYaml);
3655
+ if (!updatesManifests && sameEntries(next.overrides, previous.overrides)) {
3656
+ writeFiles();
3657
+ writeFileSync2(join11(forkPath, WORKSPACE_FILE), nextYaml);
3187
3658
  console.info(`${checkMark} ${WORKSPACE_FILE} updated ${colors_default.dim("\xB7 nothing to install")}`);
3188
3659
  printOpen(open);
3189
3660
  summarize();
3190
- return;
3661
+ return true;
3191
3662
  }
3192
3663
  const stagingSpinner = createSpinner("resolving with the new settings...");
3193
3664
  const staged = await resolveStaging(staging, workspaceYaml, next);
3194
3665
  stagingSpinner.stop();
3195
3666
  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);
3667
+ const written = { ...edits.files, ...edits.manifests, [WORKSPACE_FILE]: nextYaml, [LOCK_FILE]: staged.lock };
3668
+ const before = Object.fromEntries(Object.keys(written).map((file) => [file, readOr(join11(forkPath, file))]));
3669
+ before[WORKSPACE_FILE] = workspaceYaml;
3670
+ before[LOCK_FILE] = workspaceLock;
3671
+ for (const [file, content] of Object.entries(written)) writeFileSync2(join11(forkPath, file), content);
3198
3672
  const install = spawnSync2("pnpm", ["install"], { cwd: forkPath, stdio: "inherit" });
3199
3673
  console.info();
3200
3674
  if (install.status !== 0) {
3201
- writeFileSync(join9(forkPath, WORKSPACE_FILE), workspaceYaml);
3202
- writeFileSync(join9(forkPath, LOCK_FILE), workspaceLock);
3675
+ for (const [file, content] of Object.entries(before)) writeFileSync2(join11(forkPath, file), content);
3203
3676
  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`));
3677
+ console.info(colors_default.dim(` ${Object.keys(before).join(", ")} are back as they were; run pnpm install to match`));
3205
3678
  console.info();
3206
- return;
3679
+ return false;
3207
3680
  }
3208
3681
  const verified = await runPnpmAudit(forkPath);
3209
3682
  const reported = verified ? [...new Set(advisoriesFromAudit(verified).map((advisory) => advisory.module))] : open;
3210
3683
  console.info(`${checkMark} installed${reported.length === 0 ? ` ${colors_default.dim("\xB7")} audit clean` : ""}`);
3211
3684
  printOpen(reported);
3212
3685
  summarize();
3686
+ return true;
3213
3687
  } finally {
3214
3688
  spinner.stop();
3215
3689
  rmSync(staging, { recursive: true, force: true });
@@ -3217,7 +3691,7 @@ async function runSecurityPass(forkPath, options) {
3217
3691
  }
3218
3692
  function printOpen(modules) {
3219
3693
  if (modules.length > 0) {
3220
- console.info(`${warningMark} ${count(modules.length, "package")} still reported: ${modules.join(", ")}`);
3694
+ console.info(`${warningMark} ${count2(modules.length, "package")} still reported: ${modules.join(", ")}`);
3221
3695
  }
3222
3696
  console.info();
3223
3697
  }
@@ -3250,7 +3724,7 @@ function printPlan(plan) {
3250
3724
  printDropped("ignore dropped", droppedIgnores);
3251
3725
  const lastingCount = Object.keys(plan.lasting).length;
3252
3726
  if (lastingCount > 0) {
3253
- console.info(colors_default.dim(`${count(lastingCount, "override")} without a version selector left as written`));
3727
+ console.info(colors_default.dim(`${count2(lastingCount, "override")} without a version selector left as written`));
3254
3728
  }
3255
3729
  if (items.some(isBreaking)) {
3256
3730
  console.info(colors_default.dim("\u26A0 a breaking jump: another major, or another minor below 1.0"));
@@ -3260,13 +3734,14 @@ function printPlan(plan) {
3260
3734
  }
3261
3735
 
3262
3736
  // src/services/audit.ts
3737
+ var DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
3263
3738
  function createDependentPkgReader() {
3264
3739
  const cache = /* @__PURE__ */ new Map();
3265
3740
  return (location) => {
3266
3741
  let cached = cache.get(location);
3267
3742
  if (cached === void 0) {
3268
3743
  try {
3269
- cached = JSON.parse(readFileSync3(join10(location, "package.json"), "utf8"));
3744
+ cached = JSON.parse(readFileSync4(join12(location, "package.json"), "utf8"));
3270
3745
  } catch {
3271
3746
  cached = null;
3272
3747
  }
@@ -3277,85 +3752,105 @@ function createDependentPkgReader() {
3277
3752
  }
3278
3753
  async function runAudit(config) {
3279
3754
  const { forkPath } = config;
3280
- const spinner = createSpinner("checking for outdated packages...");
3755
+ const spinner = createSpinner("checking packages and runtimes...");
3281
3756
  try {
3282
- const [outdated, auditResult] = await Promise.all([
3757
+ const pins = await readRuntimePins(forkPath);
3758
+ const now = Date.now();
3759
+ const [outdated, auditResult, releaseAgeMinutes, feeds] = await Promise.all([
3283
3760
  runPnpmJson(["-r", "outdated", "--json", "--long"], forkPath),
3284
- runPnpmAudit(forkPath)
3761
+ runPnpmAudit(forkPath),
3762
+ readReleaseAgeMinutes(forkPath),
3763
+ fetchRuntimeFeeds(pins, forkPath, now)
3285
3764
  ]);
3286
3765
  if (!outdated) {
3287
3766
  spinnerFail("pnpm outdated gave no answer: run pnpm -r outdated to see why");
3288
3767
  console.info(`${warningMark} security pass skipped: it runs after the update check`);
3289
3768
  return;
3290
3769
  }
3291
- const outdatedPackages = outdated;
3292
- const packageNames = Object.keys(outdatedPackages);
3293
- if (packageNames.length === 0) {
3294
- spinnerSuccess("all packages are up to date");
3770
+ const runtimes = planRuntimeUpdates(pins, feeds, { releaseAgeMinutes, now });
3771
+ const rows = [
3772
+ ...packageRows(outdated, auditResult ? advisoriesFromAudit(auditResult) : []),
3773
+ ...runtimes.updates.map(runtimeRow)
3774
+ ];
3775
+ if (rows.length === 0) {
3776
+ spinnerSuccess("all packages and runtimes are up to date");
3777
+ printNotes(forkPath, runtimes.unchecked);
3295
3778
  console.info();
3296
- await runSecurityPass(forkPath, { apply: !config.list });
3779
+ await runSecurityPass(forkPath, { apply: !config.list, releaseAgeMinutes });
3297
3780
  return;
3298
3781
  }
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
3782
  spinner.stop();
3331
- printOutdatedResults(enhancedPackages, forkPath);
3783
+ printOutdatedResults(rows, forkPath);
3784
+ printNotes(forkPath, runtimes.unchecked);
3332
3785
  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`);
3786
+ if (config.list) {
3787
+ await runSecurityPass(forkPath, { apply: false, releaseAgeMinutes });
3337
3788
  return;
3338
3789
  }
3339
- await runSecurityPass(forkPath, { apply: !config.list });
3790
+ const selected = await promptForUpdates(rows);
3791
+ if (!selected) return;
3792
+ const { edits, bumped } = await planEdits(forkPath, rows, selected, pins.nodeImageSuffixes);
3793
+ if (await runSecurityPass(forkPath, { apply: true, releaseAgeMinutes, edits })) printNext(bumped);
3340
3794
  } finally {
3341
3795
  spinner.stop();
3342
3796
  }
3343
3797
  }
3344
- var dependentsText = (pkg) => formatDependents(pkg.dependentPackages.map((d) => d.name));
3345
- function printOutdatedResults(enhancedPackages, forkPath) {
3798
+ function packageRows(outdated, advisories) {
3799
+ const severityOf = (name) => SEVERITIES.find((severity) => advisories.some((a) => a.module === name && a.severity === severity)) ?? null;
3800
+ const readDependentPkg = createDependentPkgReader();
3801
+ const rows = Object.entries(outdated).map(([name, pkg]) => {
3802
+ const repoUrl = getRepoUrl(pkg.latestManifest?.repository?.url);
3803
+ const current = pkg.current ?? "missing";
3804
+ const pinnedIn = [];
3805
+ for (const d of pkg.dependentPackages) {
3806
+ const spec = readDependentPkg(d.location)?.[pkg.dependencyType]?.[name] || "";
3807
+ if (spec && !spec.startsWith("^") && !spec.startsWith("~") && !spec.startsWith(">")) {
3808
+ pinnedIn.push(d.name);
3809
+ }
3810
+ }
3811
+ return {
3812
+ name,
3813
+ current,
3814
+ latest: pkg.latest,
3815
+ dependentPackages: pkg.dependentPackages,
3816
+ isDev: pkg.dependencyType === "devDependencies",
3817
+ breaking: isBreakingJump(current, pkg.latest),
3818
+ pinnedIn,
3819
+ repoUrl,
3820
+ releasesUrl: getReleasesUrl(repoUrl),
3821
+ severity: severityOf(name)
3822
+ };
3823
+ });
3824
+ return rows.sort((a, b) => inText(a).localeCompare(inText(b)) || a.name.localeCompare(b.name));
3825
+ }
3826
+ function runtimeRow(update) {
3827
+ return {
3828
+ name: update.name,
3829
+ current: update.current,
3830
+ latest: update.target,
3831
+ dependentPackages: [],
3832
+ isDev: false,
3833
+ breaking: isBreakingJump(update.current, update.target),
3834
+ pinnedIn: [],
3835
+ repoUrl: null,
3836
+ releasesUrl: update.releasesUrl,
3837
+ severity: update.severity,
3838
+ runtime: update
3839
+ };
3840
+ }
3841
+ var rowKey = (row) => row.runtime ? `runtime:${row.runtime.file}:${row.name}` : row.name;
3842
+ var isRed = (row) => row.severity === "critical" || row.severity === "high";
3843
+ var inText = (row) => row.runtime ? pinLocation(row.runtime.file) : formatDependents(row.dependentPackages.map((d) => d.name));
3844
+ function printOutdatedResults(rows, forkPath) {
3346
3845
  const DEV_TAG = " (dev)";
3846
+ const RUNTIME_TAG = " (runtime)";
3347
3847
  const MAX_NAME_LEN = 35;
3348
3848
  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
- );
3849
+ const tag = (row) => row.runtime ? RUNTIME_TAG : row.isDev ? DEV_TAG : "";
3850
+ const maxNameLen = Math.min(MAX_NAME_LEN, Math.max(7, ...rows.map((p) => p.name.length + tag(p).length)));
3851
+ const maxCurrentLen = Math.max(7, ...rows.map((p) => p.current.length));
3852
+ const maxLatestLen = Math.max(6, ...rows.map((p) => p.latest.length));
3853
+ const maxDependentsLen = Math.min(MAX_DEPENDENTS_LEN, Math.max(10, ...rows.map((p) => inText(p).length)));
3359
3854
  const header = [
3360
3855
  " ",
3361
3856
  colors_default.bold("package".padEnd(maxNameLen)),
@@ -3365,15 +3860,14 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3365
3860
  colors_default.bold("links")
3366
3861
  ].join(" ");
3367
3862
  console.info(header);
3368
- for (const pkg of enhancedPackages) {
3863
+ for (const pkg of rows) {
3369
3864
  const vulnIndicator = pkg.severity ? getVulnIcon(pkg.severity) : " ";
3370
- const fullName = pkg.isDev ? `${pkg.name}${DEV_TAG}` : pkg.name;
3371
- const displayName = middleTruncate(fullName, maxNameLen);
3865
+ const displayName = middleTruncate(`${pkg.name}${tag(pkg)}`, maxNameLen);
3372
3866
  const name = colors_default.white(displayName.padEnd(maxNameLen));
3373
3867
  const current = colors_default.red(pkg.current.padEnd(maxCurrentLen));
3374
3868
  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));
3869
+ const latest = pkg.breaking ? colors_default.bold(colors_default.green(latestText)) : colors_default.green(latestText);
3870
+ const dependents = colors_default.dim(middleTruncate(inText(pkg), maxDependentsLen).padEnd(maxDependentsLen));
3377
3871
  const links = [];
3378
3872
  if (pkg.releasesUrl) {
3379
3873
  links.push(hyperlink(colors_default.blue("rel"), pkg.releasesUrl));
@@ -3386,9 +3880,9 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3386
3880
  }
3387
3881
  console.info();
3388
3882
  const dependentMap = /* @__PURE__ */ new Map();
3389
- for (const pkg of enhancedPackages) {
3883
+ for (const pkg of rows) {
3390
3884
  for (const { name, location } of pkg.dependentPackages) {
3391
- const dependentName = name || basename2(location);
3885
+ const dependentName = name || basename4(location);
3392
3886
  const existing = dependentMap.get(dependentName);
3393
3887
  if (existing) {
3394
3888
  existing.count++;
@@ -3399,55 +3893,66 @@ function printOutdatedResults(enhancedPackages, forkPath) {
3399
3893
  }
3400
3894
  const sortedDependents = [...dependentMap.entries()].sort((a, b) => a[0].localeCompare(b[0]));
3401
3895
  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");
3896
+ for (const [dependentName, { location, count: count3 }] of sortedDependents) {
3897
+ const packageJsonPath2 = join12(location, "package.json");
3404
3898
  const relativePath = relative3(forkPath, packageJsonPath2);
3405
3899
  const paddedName = dependentName.padEnd(maxDependentNameLen);
3406
3900
  console.info(
3407
- ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count2}`)}`
3901
+ ` ${colors_default.dim("\u2022")} ${paddedName} ${hyperlink(relativePath, `file://${packageJsonPath2}`)} ${colors_default.dim(`${count3}`)}`
3408
3902
  );
3409
3903
  }
3410
3904
  }
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(", ")}`)}` : "";
3905
+ function printNotes(forkPath, unchecked) {
3906
+ let held = [];
3907
+ try {
3908
+ const updateConfig = readTopLevel(readFileSync4(join12(forkPath, "pnpm-workspace.yaml"), "utf8"), "updateConfig");
3909
+ const ignored = updateConfig?.ignoreDependencies;
3910
+ held = Array.isArray(ignored) ? ignored.map(String) : [];
3911
+ } catch {
3912
+ }
3913
+ if (held.length > 0) console.info(` ${colors_default.dim(`held: ${held.join(", ")} \xB7 pnpm-workspace.yaml updateConfig`)}`);
3914
+ if (unchecked.length > 0)
3915
+ console.info(` ${colors_default.dim(`not checked, no answer from the feed: ${unchecked.join(", ")}`)}`);
3916
+ }
3917
+ function buildUpdateChoices(rows) {
3918
+ const makeChoice = (row) => {
3919
+ const devTag = row.isDev ? colors_default.dim(" (dev)") : "";
3920
+ const version = `${colors_default.red(row.current)} \u2192 ${colors_default.green(row.latest)}`;
3921
+ const notes = [
3922
+ row.breaking ? colors_default.bold("breaking") : "",
3923
+ row.runtime?.note ? colors_default.dim(row.runtime.note) : "",
3924
+ row.pinnedIn.length > 0 ? `${warningMark} ${colors_default.dim(`pinned in ${row.pinnedIn.join(", ")}`)}` : ""
3925
+ ].filter(Boolean);
3416
3926
  return {
3417
- name: `${pkg.name}${devTag} ${version}${pinnedWarning}`,
3418
- value: pkg.name,
3419
- checked: false
3927
+ name: `${row.name}${devTag} ${version}${notes.length > 0 ? ` ${notes.join(" ")}` : ""}`,
3928
+ value: rowKey(row),
3929
+ checked: isRed(row)
3420
3930
  };
3421
3931
  };
3422
- const pinned = packages.filter((p) => p.pinnedIn.length > 0);
3423
- const regular = packages.filter((p) => p.pinnedIn.length === 0);
3932
+ const runtimes = rows.filter((p) => p.runtime);
3933
+ const pinned = rows.filter((p) => !p.runtime && p.pinnedIn.length > 0);
3934
+ const regular = rows.filter((p) => !p.runtime && p.pinnedIn.length === 0);
3424
3935
  const choices = [];
3936
+ if (runtimes.length > 0) {
3937
+ choices.push(new Separator(colors_default.yellow(`\u2500\u2500 runtimes (${runtimes.length}) \u2500\u2500`)));
3938
+ choices.push(...runtimes.map(makeChoice));
3939
+ }
3425
3940
  if (pinned.length > 0) {
3426
3941
  choices.push(new Separator(colors_default.yellow(`\u2500\u2500 pinned (${pinned.length}) \u2500\u2500`)));
3427
3942
  choices.push(...pinned.map(makeChoice));
3428
3943
  }
3429
3944
  if (regular.length > 0) {
3430
- if (pinned.length > 0) choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
3945
+ if (choices.length > 0) choices.push(new Separator(colors_default.dim(`\u2500\u2500 packages (${regular.length}) \u2500\u2500`)));
3431
3946
  choices.push(...regular.map(makeChoice));
3432
3947
  }
3433
3948
  return choices;
3434
3949
  }
3435
- async function promptForUpdates(packages, forkPath) {
3436
- let wantsUpdate;
3950
+ async function promptForUpdates(rows) {
3437
3951
  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,
3952
+ const selected = await checkbox({
3953
+ message: `toggle what to update ${colors_default.dim("\xB7 enter with nothing ticked skips")}`,
3954
+ choices: buildUpdateChoices(rows),
3955
+ required: false,
3451
3956
  pageSize: 20,
3452
3957
  loop: false,
3453
3958
  theme: {
@@ -3460,83 +3965,93 @@ async function promptForUpdates(packages, forkPath) {
3460
3965
  const tips = keys.map(([key, action]) => `${colors_default.dim(key)} ${action}`);
3461
3966
  return tips.join(" ");
3462
3967
  },
3463
- renderSelectedChoices: (selectedChoices) => colors_default.dim(`${selectedChoices.length} package${selectedChoices.length !== 1 ? "s" : ""} selected`)
3968
+ renderSelectedChoices: (selectedChoices) => colors_default.dim(`${selectedChoices.length} selected`)
3464
3969
  }
3465
3970
  }
3466
3971
  });
3972
+ console.info();
3973
+ return selected;
3467
3974
  } catch {
3468
- return "cancelled";
3975
+ return null;
3469
3976
  }
3470
- return executeUpdates(packages, forkPath, selected);
3471
3977
  }
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";
3978
+ function bumpText(update) {
3979
+ if (update.pin.kind === "node") return `engines.node ${update.target}`;
3980
+ if (update.pin.kind === "pnpm") return `packageManager pnpm@${update.target}`;
3981
+ return update.pin.to;
3982
+ }
3983
+ async function planEdits(forkPath, rows, selected, nodeImageSuffixes) {
3984
+ const contents = /* @__PURE__ */ new Map();
3985
+ const summary = /* @__PURE__ */ new Map();
3986
+ const bumped = [];
3987
+ const edit = (file, label, item, resolves, change) => {
3988
+ const entry = contents.get(file) ?? { content: readFileSync4(join12(forkPath, file), "utf8"), resolves: false };
3989
+ const next = change(entry.content);
3990
+ if (next === entry.content) return;
3991
+ contents.set(file, { content: next, resolves: entry.resolves || resolves });
3992
+ summary.set(label, [...summary.get(label) ?? [], item]);
3993
+ };
3994
+ const chosen = rows.filter((row) => selected.includes(rowKey(row)));
3995
+ for (const row of chosen) {
3996
+ if (row.runtime) continue;
3997
+ for (const { name: dependent, location } of row.dependentPackages) {
3998
+ const file = relative3(forkPath, join12(location, "package.json"));
3999
+ edit(
4000
+ file,
4001
+ dependent || file,
4002
+ row.name,
4003
+ true,
4004
+ (content) => editJsonText(content, (json) => {
4005
+ for (const section of DEPENDENCY_SECTIONS) {
4006
+ const deps = json[section];
4007
+ const spec = deps?.[row.name];
4008
+ if (!deps || spec === void 0) continue;
4009
+ const next = rewriteSpecifier(spec, row.latest);
4010
+ if (next) deps[row.name] = next;
4011
+ else console.info(`${warningMark} ${row.name} in ${dependent || file} left as written: ${spec}`);
4012
+ }
4013
+ })
4014
+ );
3491
4015
  }
3492
4016
  }
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]);
4017
+ for (const row of chosen) {
4018
+ const update = row.runtime;
4019
+ if (!update) continue;
4020
+ if (update.pin.kind === "node") {
4021
+ const missing = await missingNodeImages(update.target, nodeImageSuffixes);
4022
+ if (missing.length > 0) {
4023
+ console.info(`${warningMark} node ${update.target} skipped: ${missing.join(", ")} is not on docker hub yet`);
4024
+ continue;
4025
+ }
3502
4026
  }
4027
+ edit(update.file, update.file, bumpText(update), false, (content) => applyRuntimeUpdate(content, update));
4028
+ bumped.push(update);
3503
4029
  }
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)}`);
4030
+ for (const [label, items] of summary) {
4031
+ console.info(`${colors_default.cyan("\u2191")} ${colors_default.bold(label)}: ${colors_default.dim(items.join(", "))}`);
3507
4032
  }
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
- }
3521
- }
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";
4033
+ if (summary.size > 0) console.info();
4034
+ const edits = { manifests: {}, files: {} };
4035
+ for (const [file, { content, resolves }] of contents) {
4036
+ (resolves ? edits.manifests : edits.files)[file] = content;
3527
4037
  }
3528
- console.info(colors_default.green(`\u2713 ${selectedPackages.length} package${selectedPackages.length > 1 ? "s" : ""} updated`));
3529
- console.info();
3530
- return "updated";
4038
+ return { edits, bumped };
3531
4039
  }
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();
4040
+ function printNext(bumped) {
4041
+ const steps = [];
4042
+ const node = bumped.find((update) => update.pin.kind === "node");
4043
+ if (node && process.version !== `v${node.target}`) {
4044
+ const command = getEnv("VOLTA_HOME") ? `volta install node@${node.target}` : `switch to node ${node.target}`;
4045
+ steps.push([command, `this machine runs ${process.version.slice(1)}`]);
3539
4046
  }
4047
+ for (const update of bumped) {
4048
+ if (update.pin.kind === "image") steps.push([`docker build -f ${update.file} .`, "check that the image builds"]);
4049
+ }
4050
+ if (steps.length === 0) return;
4051
+ const width = Math.max(...steps.map(([command]) => command.length));
4052
+ console.info(colors_default.bold("next"));
4053
+ for (const [command, why] of steps) console.info(` ${colors_default.dim("\u2022")} ${command.padEnd(width)} ${colors_default.dim(why)}`);
4054
+ console.info();
3540
4055
  }
3541
4056
 
3542
4057
  // src/services/contributions.ts
@@ -3552,111 +4067,9 @@ import {
3552
4067
  } from "@inquirer/core";
3553
4068
  import { select as select3 } from "@inquirer/prompts";
3554
4069
 
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
4070
  // src/services/contrib-core.ts
3658
4071
  import { rmSync as rmSync2 } from "fs";
3659
- import { dirname as dirname6, join as join12, resolve as resolve4 } from "path";
4072
+ import { dirname as dirname7, join as join13, resolve as resolve4 } from "path";
3660
4073
  async function listFilesAtRef(repoPath, ref) {
3661
4074
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
3662
4075
  return new Set(raw.split("\n").filter(Boolean));
@@ -3672,10 +4085,10 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3672
4085
  const baseFiles = await listFilesAtRef(repoPath, baseRef);
3673
4086
  const baseDirs = /* @__PURE__ */ new Set(["."]);
3674
4087
  for (const f of baseFiles) {
3675
- let dir = dirname6(f);
4088
+ let dir = dirname7(f);
3676
4089
  while (dir !== ".") {
3677
4090
  baseDirs.add(dir);
3678
- dir = dirname6(dir);
4091
+ dir = dirname7(dir);
3679
4092
  }
3680
4093
  }
3681
4094
  const files = [];
@@ -3688,7 +4101,7 @@ async function detectContributableFiles(repoPath, baseRef, forkRef, config, fork
3688
4101
  if (inCella && !inFork) {
3689
4102
  kind = "deleted";
3690
4103
  } else if (!inCella && inFork) {
3691
- if (!baseDirs.has(dirname6(file.path))) continue;
4104
+ if (!baseDirs.has(dirname7(file.path))) continue;
3692
4105
  kind = "created";
3693
4106
  } else {
3694
4107
  kind = "modified";
@@ -3712,7 +4125,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
3712
4125
  async function buildContribBranch(repoPath, baseRef, forkRef, files, forkName) {
3713
4126
  const branch = `contrib/${forkName}`;
3714
4127
  const gitDir = resolve4(repoPath, await git(["rev-parse", "--git-dir"], repoPath));
3715
- const tmpIndex = join12(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
4128
+ const tmpIndex = join13(gitDir, `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3716
4129
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
3717
4130
  try {
3718
4131
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -3748,10 +4161,10 @@ ${commitBody}`;
3748
4161
 
3749
4162
  // src/services/fork-utils.ts
3750
4163
  import { existsSync as existsSync8 } from "fs";
3751
- import { dirname as dirname7, resolve as resolve5 } from "path";
4164
+ import { dirname as dirname8, resolve as resolve5 } from "path";
3752
4165
  async function resolveForkBasePath(repoPath) {
3753
4166
  const commonDir = await git(["rev-parse", "--git-common-dir"], repoPath, { ignoreErrors: true });
3754
- return commonDir ? dirname7(resolve5(repoPath, commonDir)) : repoPath;
4167
+ return commonDir ? dirname8(resolve5(repoPath, commonDir)) : repoPath;
3755
4168
  }
3756
4169
  function validateForkPath(fork, basePath, requireConfig = false) {
3757
4170
  const resolvedPath = resolve5(basePath, fork.localPath);
@@ -3831,8 +4244,13 @@ var contribPrompt = createPrompt((config, done) => {
3831
4244
  try {
3832
4245
  const patch = gitDiffFile(cwd, `${baseRef}..${branch}`, path);
3833
4246
  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`);
4247
+ const page2 = await writeDiffPage(
4248
+ [{ files: [{ path, patch: patch.toString() }] }],
4249
+ { title: path, note: `cella \u2190 \u2192 ${forkName}` },
4250
+ cwd,
4251
+ path
4252
+ );
4253
+ setStatusMsg(openInBrowser(page2) ? `opened ${path} in browser` : `diff page: ${page2}`);
3836
4254
  } catch (error) {
3837
4255
  setStatusMsg(`diff failed: ${errorMessage(error)}`);
3838
4256
  }
@@ -4061,20 +4479,20 @@ async function runContributions(config) {
4061
4479
 
4062
4480
  // src/services/forks.ts
4063
4481
  import { resolve as resolve6 } from "path";
4064
- import process7 from "process";
4482
+ import process8 from "process";
4065
4483
  import { Separator as Separator2, select as select5 } from "@inquirer/prompts";
4066
4484
 
4067
4485
  // src/services/sync.ts
4068
- import { spawnSync as spawnSync5 } from "child_process";
4486
+ import { spawnSync as spawnSync4 } from "child_process";
4069
4487
  import { select as select4 } from "@inquirer/prompts";
4070
4488
 
4071
4489
  // src/utils/gh.ts
4072
- import { spawnSync as spawnSync4 } from "child_process";
4490
+ import { spawnSync as spawnSync3 } from "child_process";
4073
4491
  function ghAvailable() {
4074
- return spawnSync4("gh", ["--version"], { stdio: "ignore" }).status === 0;
4492
+ return spawnSync3("gh", ["--version"], { stdio: "ignore" }).status === 0;
4075
4493
  }
4076
4494
  function runGh(cwd, args) {
4077
- const result = spawnSync4("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4495
+ const result = spawnSync3("gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
4078
4496
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
4079
4497
  return { ok: result.status === 0, output };
4080
4498
  }
@@ -4106,12 +4524,12 @@ function closePr(cwd, ref) {
4106
4524
  }
4107
4525
 
4108
4526
  // src/services/packages.ts
4109
- import { existsSync as existsSync10, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
4110
- import { join as join14 } from "path";
4527
+ import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
4528
+ import { join as join15 } from "path";
4111
4529
 
4112
4530
  // src/services/package-usage.ts
4113
- import { existsSync as existsSync9, readFileSync as readFileSync4, statSync } from "fs";
4114
- import { join as join13 } from "path";
4531
+ import { existsSync as existsSync9, readFileSync as readFileSync5, statSync } from "fs";
4532
+ import { join as join14 } from "path";
4115
4533
  var MAX_FILE_BYTES = 1024 * 1024;
4116
4534
  function escapeRegExp(value) {
4117
4535
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -4119,7 +4537,7 @@ function escapeRegExp(value) {
4119
4537
  function readSearchable(filePath) {
4120
4538
  try {
4121
4539
  if (statSync(filePath).size > MAX_FILE_BYTES) return "";
4122
- const text = readFileSync4(filePath, "utf8");
4540
+ const text = readFileSync5(filePath, "utf8");
4123
4541
  return text.includes("\0") ? "" : text;
4124
4542
  } catch {
4125
4543
  return "";
@@ -4127,7 +4545,7 @@ function readSearchable(filePath) {
4127
4545
  }
4128
4546
  function fileSource(forkPath, path) {
4129
4547
  let text;
4130
- return { path, label: path, text: () => text ??= readSearchable(join13(forkPath, path)) };
4548
+ return { path, label: path, text: () => text ??= readSearchable(join14(forkPath, path)) };
4131
4549
  }
4132
4550
  async function readScriptsAtRef(forkPath, ref, path) {
4133
4551
  try {
@@ -4139,7 +4557,7 @@ async function readScriptsAtRef(forkPath, ref, path) {
4139
4557
  async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4140
4558
  let forkScripts;
4141
4559
  try {
4142
- forkScripts = JSON.parse(readFileSync4(join13(forkPath, path), "utf8")).scripts ?? {};
4560
+ forkScripts = JSON.parse(readFileSync5(join14(forkPath, path), "utf8")).scripts ?? {};
4143
4561
  } catch {
4144
4562
  return [];
4145
4563
  }
@@ -4149,11 +4567,11 @@ async function scriptSources(forkPath, upstreamRef, baseRef, path) {
4149
4567
  }
4150
4568
  function binNames(forkPath, location, name) {
4151
4569
  const bareName = name.split("/").pop();
4152
- for (const dir of [join13(forkPath, location), forkPath]) {
4153
- const manifest = join13(dir, "node_modules", name, "package.json");
4570
+ for (const dir of [join14(forkPath, location), forkPath]) {
4571
+ const manifest = join14(dir, "node_modules", name, "package.json");
4154
4572
  if (!existsSync9(manifest)) continue;
4155
4573
  try {
4156
- const { bin } = JSON.parse(readFileSync4(manifest, "utf8"));
4574
+ const { bin } = JSON.parse(readFileSync5(manifest, "utf8"));
4157
4575
  if (typeof bin === "string") return [bareName];
4158
4576
  return bin && typeof bin === "object" ? Object.keys(bin) : [];
4159
4577
  } catch {
@@ -4334,7 +4752,7 @@ function setTopLevelKey(pkg, key, value) {
4334
4752
  function readPackageJson(filePath) {
4335
4753
  if (!existsSync10(filePath)) return null;
4336
4754
  try {
4337
- return JSON.parse(readFileSync5(filePath, "utf8"));
4755
+ return JSON.parse(readFileSync6(filePath, "utf8"));
4338
4756
  } catch (error) {
4339
4757
  throw new Error(`cannot parse ${filePath}: ${errorMessage(error)}`);
4340
4758
  }
@@ -4366,13 +4784,13 @@ async function syncPackageJson(config, baseRef, usage, relativePath, keysToSync)
4366
4784
  const changes = [];
4367
4785
  const kept = [];
4368
4786
  const pkgRelPath = packageJsonPath(relativePath);
4369
- const pkgPath = join14(forkPath, pkgRelPath);
4787
+ const pkgPath = join15(forkPath, pkgRelPath);
4370
4788
  const forkPkg = readPackageJson(pkgPath);
4371
4789
  const upstream = await readPackageJsonAtRef(forkPath, upstreamRef, relativePath);
4372
4790
  const basePkg = baseRef ? (await readPackageJsonAtRef(forkPath, baseRef, relativePath))?.data : void 0;
4373
4791
  if (!upstream) return { updated: false, changes, kept };
4374
4792
  if (!forkPkg) {
4375
- const workspaceArrived = relativePath !== "" && existsSync10(join14(forkPath, relativePath));
4793
+ const workspaceArrived = relativePath !== "" && existsSync10(join15(forkPath, relativePath));
4376
4794
  if (basePkg || !workspaceArrived || isIgnored(pkgRelPath, config)) return { updated: false, changes, kept };
4377
4795
  writeFileSync3(pkgPath, upstream.raw.endsWith("\n") ? upstream.raw : `${upstream.raw}
4378
4796
  `, "utf8");
@@ -4578,6 +4996,7 @@ async function runSync(config, options) {
4578
4996
  const staged = result.files.some((file) => SYNC_APPLIED_STATUSES.includes(file.status));
4579
4997
  const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && staged ? options.stagedBranch : void 0;
4580
4998
  printSyncComplete(result, { stagedBranch });
4999
+ await reportReviewPage(config.forkPath, result, "sync");
4581
5000
  await printMigrationNotesLine(config, result);
4582
5001
  return result;
4583
5002
  }
@@ -4686,7 +5105,7 @@ async function shipSyncBranch(config, branch) {
4686
5105
  console.info(colors_default.dim("opening a pull request..."));
4687
5106
  const prBody = await buildSyncPrBodyForBranch(forkPath, cutBase);
4688
5107
  const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
4689
- const pr = spawnSync5("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
5108
+ const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
4690
5109
  cwd: forkPath,
4691
5110
  encoding: "utf8",
4692
5111
  stdio: ["ignore", "pipe", "pipe"]
@@ -4717,7 +5136,7 @@ ${pr.stderr ?? ""}`.match(/https?:\/\/\S+/)?.[0];
4717
5136
  function finalizeWorkspace(forkPath) {
4718
5137
  for (const args of [["install"], ["check"]]) {
4719
5138
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
4720
- const result = spawnSync5("pnpm", args, { cwd: forkPath, stdio: "inherit" });
5139
+ const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
4721
5140
  if (result.status !== 0) return false;
4722
5141
  }
4723
5142
  return true;
@@ -4983,7 +5402,7 @@ async function runForks(config) {
4983
5402
  loop: false
4984
5403
  });
4985
5404
  if (selectedPath === "_exit") {
4986
- process7.exit(0);
5405
+ process8.exit(0);
4987
5406
  }
4988
5407
  const resolvedForkPath = resolve6(forkBasePath, selectedPath);
4989
5408
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
@@ -4998,11 +5417,11 @@ async function runForks(config) {
4998
5417
 
4999
5418
  // src/services/stats.ts
5000
5419
  import { readFile as readFile3 } from "fs/promises";
5001
- import { join as join16 } from "path";
5420
+ import { join as join17 } from "path";
5002
5421
 
5003
5422
  // 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";
5423
+ import { existsSync as existsSync11, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
5424
+ import { join as join16, relative as relative4, sep as sep2 } from "path";
5006
5425
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
5007
5426
  function colorPct(pct) {
5008
5427
  if (typeof pct !== "number" || !Number.isFinite(pct)) return colors_default.dim("n/a");
@@ -5020,14 +5439,14 @@ function formatAge(ms) {
5020
5439
  return `${mins} minute${mins === 1 ? "" : "s"} ago`;
5021
5440
  }
5022
5441
  function printCoverageSummary(forkPath) {
5023
- const file = join15(forkPath, ".coverage", "coverage-summary.json");
5442
+ const file = join16(forkPath, ".coverage", "coverage-summary.json");
5024
5443
  let summary;
5025
5444
  let mtimeMs = 0;
5026
5445
  let problem = "no coverage data found";
5027
5446
  if (existsSync11(file)) {
5028
5447
  try {
5029
5448
  mtimeMs = statSync2(file).mtimeMs;
5030
- summary = JSON.parse(readFileSync6(file, "utf8"));
5449
+ summary = JSON.parse(readFileSync7(file, "utf8"));
5031
5450
  } catch {
5032
5451
  problem = "could not read coverage-summary.json";
5033
5452
  }
@@ -5071,7 +5490,7 @@ function printCoverageSummary(forkPath) {
5071
5490
  // src/services/stats.ts
5072
5491
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
5073
5492
  async function getWorkspacePackages(forkPath) {
5074
- const content = await readFile3(join16(forkPath, "pnpm-workspace.yaml"), "utf8");
5493
+ const content = await readFile3(join17(forkPath, "pnpm-workspace.yaml"), "utf8");
5075
5494
  const patterns = readTopLevel(content, "packages");
5076
5495
  return (Array.isArray(patterns) ? patterns.map(String) : []).map((pattern) => {
5077
5496
  if (pattern.endsWith("/*")) {
@@ -5429,14 +5848,14 @@ function getForkPath() {
5429
5848
  }
5430
5849
  return resolved;
5431
5850
  }
5432
- let cwd = process8.cwd();
5851
+ let cwd = process9.cwd();
5433
5852
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
5434
5853
  cwd = resolve7(cwd, "../..");
5435
5854
  }
5436
5855
  return cwd;
5437
5856
  }
5438
5857
  function preflight(forkPath) {
5439
- if (!existsSync12(join17(forkPath, ".git"))) {
5858
+ if (!existsSync12(join18(forkPath, ".git"))) {
5440
5859
  throw new Error(`not a git repository: ${forkPath}`);
5441
5860
  }
5442
5861
  }
@@ -5454,7 +5873,7 @@ var services = {
5454
5873
  }
5455
5874
  };
5456
5875
  function isHelpOrVersionRequest() {
5457
- return process8.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process8.argv[2] === "help";
5876
+ return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
5458
5877
  }
5459
5878
  async function main() {
5460
5879
  try {
@@ -5473,7 +5892,7 @@ async function main() {
5473
5892
  } catch (error) {
5474
5893
  console.error();
5475
5894
  console.error(`${colors_default.red("\u2717")} ${errorMessage(error)}`);
5476
- process8.exit(1);
5895
+ process9.exit(1);
5477
5896
  }
5478
5897
  }
5479
5898
  main();