@cellajs/cli 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,24 +1,38 @@
1
1
  #!/usr/bin/env tsx
2
2
 
3
3
  // src/cella-cli.ts
4
- import { existsSync as existsSync11, statSync as statSync2 } from "fs";
5
- import { join as join16, resolve as resolve6 } from "path";
6
- import process8 from "process";
4
+ import { existsSync as existsSync10, statSync as statSync2 } from "fs";
5
+ import { join as join17, resolve as resolve5 } from "path";
6
+ import process9 from "process";
7
7
 
8
8
  // src/cli.ts
9
9
  import { readFileSync } from "fs";
10
- import { join as join4 } from "path";
11
- import process4 from "process";
10
+ import { join as join5 } from "path";
11
+ import process5 from "process";
12
12
  import { select } from "@inquirer/prompts";
13
13
  import { Command } from "commander";
14
14
 
15
15
  // src/utils/colors.ts
16
- import process2 from "process";
16
+ import process3 from "process";
17
17
  import { styleText } from "util";
18
+
19
+ // src/utils/env.ts
20
+ import process2 from "process";
21
+ function getEnv(name) {
22
+ return process2.env[name];
23
+ }
24
+ function hasEnv(name) {
25
+ return name in process2.env;
26
+ }
27
+ function getEnvSnapshot() {
28
+ return { ...process2.env };
29
+ }
30
+
31
+ // src/utils/colors.ts
18
32
  function colorsEnabled() {
19
- if ("NO_COLOR" in process2.env) return false;
20
- if ("FORCE_COLOR" in process2.env) return true;
21
- return !!process2.stdout.isTTY;
33
+ if (hasEnv("NO_COLOR")) return false;
34
+ if (hasEnv("FORCE_COLOR")) return true;
35
+ return !!process3.stdout.isTTY;
22
36
  }
23
37
  function applyStyle(style, text) {
24
38
  if (!colorsEnabled()) return text;
@@ -39,8 +53,8 @@ var pc = {
39
53
  var colors_default = pc;
40
54
 
41
55
  // src/utils/config.ts
42
- import { existsSync, realpathSync } from "fs";
43
- import { join, resolve } from "path";
56
+ import { existsSync as existsSync2, realpathSync } from "fs";
57
+ import { join as join2, resolve } from "path";
44
58
  import { z as z2 } from "zod";
45
59
 
46
60
  // src/config/types.ts
@@ -82,9 +96,11 @@ var cellaConfigSchema = z.object({
82
96
  }).strict();
83
97
 
84
98
  // src/utils/module-territory.ts
85
- import { relative, sep } from "path";
99
+ import { existsSync } from "fs";
100
+ import { basename, join, relative, sep } from "path";
86
101
  import { Project, SyntaxKind } from "ts-morph";
87
102
  var MODULE_FILE_GLOBS = ["backend/src/modules/*/*-module.ts", "frontend/src/modules/*/*-module.ts"];
103
+ var FRONTEND_STATIC_ROOT = "frontend/public/static";
88
104
  function readOwner(call) {
89
105
  const arg = call.getArguments()[0]?.asKind(SyntaxKind.ObjectLiteralExpression);
90
106
  const initializer = arg?.getProperty("owner")?.asKind(SyntaxKind.PropertyAssignment)?.getInitializer();
@@ -98,11 +114,19 @@ function resolveAppModuleFolders(repoPath) {
98
114
  });
99
115
  const sourceFiles = project.addSourceFilesAtPaths(MODULE_FILE_GLOBS.map((glob) => `${repoPath}/${glob}`));
100
116
  const folders = /* @__PURE__ */ new Set();
117
+ const moduleNames = /* @__PURE__ */ new Set();
101
118
  for (const sourceFile of sourceFiles) {
102
119
  const ownsApp = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).some((call) => call.getExpression().getText() === "registerModule" && readOwner(call) === "app");
103
120
  if (!ownsApp) continue;
104
121
  const folder = relative(repoPath, sourceFile.getDirectoryPath()).split(sep).join("/");
105
122
  folders.add(folder);
123
+ moduleNames.add(basename(folder));
124
+ }
125
+ for (const moduleName of moduleNames) {
126
+ const staticFolder = `${FRONTEND_STATIC_ROOT}/${moduleName}`;
127
+ if (existsSync(join(repoPath, staticFolder))) {
128
+ folders.add(staticFolder);
129
+ }
106
130
  }
107
131
  return [...folders];
108
132
  }
@@ -131,8 +155,8 @@ function resolveUpstream(settings) {
131
155
  return { track, branchRef };
132
156
  }
133
157
  async function loadConfig(forkPath) {
134
- const configPath = join(forkPath, "cella.config.ts");
135
- if (!existsSync(configPath)) {
158
+ const configPath = join2(forkPath, "cella.config.ts");
159
+ if (!existsSync2(configPath)) {
136
160
  throw new Error(`config file not found: ${configPath}`);
137
161
  }
138
162
  const realConfigPath = realpathSync(configPath);
@@ -165,18 +189,17 @@ ${z2.prettifyError(result.error)}`);
165
189
  }
166
190
 
167
191
  // src/utils/display.ts
168
- import { spawnSync } from "child_process";
169
- import { existsSync as existsSync2, writeFileSync } from "fs";
170
- import { join as join2, resolve as resolve2 } from "path";
171
- import process3 from "process";
192
+ import { writeFileSync } from "fs";
193
+ import { join as join3, resolve as resolve2 } from "path";
194
+ import process4 from "process";
172
195
 
173
196
  // package.json
174
197
  var package_default = {
175
198
  name: "@cellajs/cli",
176
- version: "0.0.1",
199
+ version: "0.0.4",
177
200
  private: false,
178
201
  license: "MIT",
179
- description: "CLI for syncing and managing apps with upstream Cella.",
202
+ description: "CLI for managing your monorepo and syncing with upstream Cella.",
180
203
  repository: {
181
204
  type: "git",
182
205
  url: "https://github.com/cellajs/cella-cli"
@@ -225,6 +248,7 @@ var package_default = {
225
248
  dependencies: {
226
249
  "@inquirer/core": "^11.2.1",
227
250
  "@inquirer/prompts": "^8.5.2",
251
+ "@pierre/diffs": "^1.2.12",
228
252
  commander: "^15.0.0",
229
253
  "ts-morph": "^28.0.0",
230
254
  zod: "^4.4.3"
@@ -311,7 +335,7 @@ var checkMark = colors_default.green("\u2713");
311
335
  var TerminalSpinner = class {
312
336
  constructor(text, isSilent) {
313
337
  this.text = text;
314
- this.enabled = !isSilent && !!process3.stdout.isTTY;
338
+ this.enabled = !isSilent && !!process4.stdout.isTTY;
315
339
  }
316
340
  text;
317
341
  frames = ["-", "\\", "|", "/"];
@@ -333,10 +357,10 @@ var TerminalSpinner = class {
333
357
  this.timer = null;
334
358
  }
335
359
  if (!this.enabled) return;
336
- process3.stdout.write("\r\x1B[2K");
360
+ process4.stdout.write("\r\x1B[2K");
337
361
  }
338
362
  render() {
339
- process3.stdout.write(`\r${colors_default.cyan(this.frames[this.frameIndex])} ${this.text}`);
363
+ process4.stdout.write(`\r${colors_default.cyan(this.frames[this.frameIndex])} ${this.text}`);
340
364
  }
341
365
  };
342
366
  var activeSpinner = null;
@@ -345,7 +369,7 @@ function setJsonMode(enabled) {
345
369
  jsonMode = enabled;
346
370
  if (!enabled) return;
347
371
  const toStderr = (...args) => {
348
- process3.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
372
+ process4.stderr.write(`${args.map((a) => typeof a === "string" ? a : String(a)).join(" ")}
349
373
  `);
350
374
  };
351
375
  console.info = toStderr;
@@ -353,7 +377,7 @@ function setJsonMode(enabled) {
353
377
  console.warn = toStderr;
354
378
  }
355
379
  function writeStdout(text) {
356
- process3.stdout.write(text.endsWith("\n") ? text : `${text}
380
+ process4.stdout.write(text.endsWith("\n") ? text : `${text}
357
381
  `);
358
382
  }
359
383
  function getHeader() {
@@ -376,7 +400,7 @@ function printStep(label, detail) {
376
400
  }
377
401
  }
378
402
  function createSpinner(text) {
379
- const isTestEnv = !!process3.env.VITEST || process3.env.NODE_ENV === "test";
403
+ const isTestEnv = !!getEnv("VITEST") || getEnv("NODE_ENV") === "test";
380
404
  activeSpinner = new TerminalSpinner(text, isTestEnv || jsonMode);
381
405
  activeSpinner.start();
382
406
  return activeSpinner;
@@ -402,23 +426,6 @@ function stopSpinner() {
402
426
  activeSpinner = null;
403
427
  }
404
428
  }
405
- function showDiffInPager(diffOutput) {
406
- if (diffOutput.length === 0) return;
407
- const hasBat = spawnSync("which", ["bat"], { stdio: "pipe" }).status === 0;
408
- process3.stdout.write("\x1B[?25h");
409
- if (hasBat) {
410
- spawnSync("bat", ["--language", "diff", "--paging", "always", "--style", "plain"], {
411
- input: diffOutput,
412
- stdio: ["pipe", "inherit", "inherit"]
413
- });
414
- } else {
415
- spawnSync("less", ["-R"], {
416
- input: diffOutput,
417
- stdio: ["pipe", "inherit", "inherit"]
418
- });
419
- }
420
- process3.stdout.write("\x1B[2J\x1B[0;0H");
421
- }
422
429
  function hyperlink(label, url) {
423
430
  if (!url) return label;
424
431
  const safeUrl = url.replace(/[\x00-\x1f\x7f]/g, "");
@@ -447,18 +454,12 @@ function formatFetchedUpstreamDetail(commitCount, commits, upstreamGitHubUrl, ma
447
454
  return lines.join("\n");
448
455
  }
449
456
  function getFileLink(filePath, commitHash, options) {
450
- const { upstreamGitHubUrl, upstreamBranch, fileLinkMode = "commit", upstreamViewPath, forkPath } = options;
451
- if (fileLinkMode === "local" && upstreamViewPath && forkPath) {
452
- const absolutePath = resolve2(upstreamViewPath, filePath);
453
- if (existsSync2(absolutePath)) {
454
- const url = `vscode://file${absolutePath}`;
455
- return { label: filePath.split("/").pop() || filePath, url };
456
- }
457
- }
457
+ const { upstreamGitHubUrl, upstreamBranch, fileLinkMode = "commit" } = options;
458
+ const mode = fileLinkMode === "local" ? "file" : fileLinkMode;
458
459
  if (!upstreamGitHubUrl) {
459
460
  return { label: commitHash?.slice(0, 9) || "" };
460
461
  }
461
- if (fileLinkMode === "file" && upstreamBranch) {
462
+ if (mode === "file" && upstreamBranch) {
462
463
  const url = `${upstreamGitHubUrl}/blob/${upstreamBranch}/${filePath}`;
463
464
  return { label: filePath.split("/").pop() || filePath, url };
464
465
  }
@@ -514,7 +515,7 @@ var summaryStatusConfig = {
514
515
  icon: colors_default.cyan("\u25C7"),
515
516
  label: "managed",
516
517
  color: colors_default.cyan,
517
- description: "package/config files handled separately"
518
+ description: "package.jsons & cella.config.ts handled separately"
518
519
  },
519
520
  ignored: { ...statusConfig.ignored, description: "protected by ignored config" }
520
521
  };
@@ -602,7 +603,7 @@ function printAnalysisFileGroups(files, linkOptions) {
602
603
  });
603
604
  }
604
605
  function writeLogFile(forkPath, files) {
605
- const logPath = join2(forkPath, "cella-sync.log");
606
+ const logPath = join3(forkPath, "cella-sync.log");
606
607
  const lines = [
607
608
  `cella sync analysis - ${(/* @__PURE__ */ new Date()).toISOString()}`,
608
609
  DIVIDER,
@@ -625,14 +626,23 @@ function writeLogFile(forkPath, files) {
625
626
  writeFileSync(logPath, lines.join("\n"), "utf-8");
626
627
  return logPath;
627
628
  }
628
- function printSyncComplete(result) {
629
+ function printSyncComplete(result, options = {}) {
629
630
  const updated = result.files.filter((file) => file.status === "behind" || file.status === "diverged").length;
630
631
  const diverged = result.files.filter((file) => file.status === "diverged").length;
631
632
  const merged = result.autoMergedFiles?.length ?? Math.max(0, diverged - result.conflicts.length);
632
633
  const conflicts = result.conflicts.length;
633
634
  console.info();
634
- console.info(`${colors_default.green("\u2713")} sync complete`);
635
- console.info(colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`));
635
+ if (options.stagedBranch) {
636
+ console.info(`${colors_default.green("\u2713")} Sync merge staged on '${options.stagedBranch}'`);
637
+ console.info(
638
+ colors_default.dim(
639
+ ` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts. Review, then rerun \`pnpm cella sync\` to finish.`
640
+ )
641
+ );
642
+ } else {
643
+ console.info(`${colors_default.green("\u2713")} sync complete`);
644
+ console.info(colors_default.dim(` ${updated} files updated, ${merged} auto-merged, ${conflicts} conflicts`));
645
+ }
636
646
  console.info();
637
647
  }
638
648
  function printFlagWarnings(options) {
@@ -652,7 +662,7 @@ function printFlagWarnings(options) {
652
662
 
653
663
  // src/utils/overrides.ts
654
664
  import { existsSync as existsSync3 } from "fs";
655
- import { join as join3 } from "path";
665
+ import { join as join4 } from "path";
656
666
  function isUnderAnyFolder(filePath, folders) {
657
667
  return folders.some((folder) => {
658
668
  const entry = folder.replace(/\/+$/, "");
@@ -684,7 +694,7 @@ function validateOverrides(config, forkPath) {
684
694
  pattern: entry,
685
695
  message: `pin uses glob (not supported, use a path or folder): ${entry}`
686
696
  });
687
- } else if (!existsSync3(join3(forkPath, entry))) {
697
+ } else if (!existsSync3(join4(forkPath, entry))) {
688
698
  warnings.push({
689
699
  type: "pinned-not-found",
690
700
  pattern: entry,
@@ -700,7 +710,7 @@ function validateOverrides(config, forkPath) {
700
710
  pattern: entry,
701
711
  message: `ignored entry uses glob (not supported, use a path or folder): ${entry}`
702
712
  });
703
- } else if (!existsSync3(join3(forkPath, entry.replace(/\/+$/, "")))) {
713
+ } else if (!existsSync3(join4(forkPath, entry.replace(/\/+$/, "")))) {
704
714
  warnings.push({
705
715
  type: "ignored-not-found",
706
716
  pattern: entry,
@@ -751,7 +761,7 @@ var serviceDefinitions = [
751
761
  { flags: "--scope <scope>", description: "analyze scope for --list/--json: all|risk|protected" },
752
762
  { flags: "--track <mode>", description: "override upstream tracking for this run: release|branch" },
753
763
  { flags: "--diff <path>", description: "print unified diff for one file, then exit" },
754
- { flags: "--open-diff <path>", description: "open VS Code side-by-side diff for one file, then exit" }
764
+ { flags: "--open-diff <path>", description: "open a browser diff for one file, then exit" }
755
765
  ],
756
766
  includeInMenu: (context) => !context.isUpstreamRepo
757
767
  },
@@ -810,7 +820,7 @@ var serviceDefinitions = [
810
820
  async function getMenuContext(userConfig, forkPath) {
811
821
  return {
812
822
  hasForks: (userConfig.forks?.length ?? 0) > 0,
813
- isUpstreamRepo: JSON.parse(readFileSync(join4(forkPath, "package.json"), "utf8")).name === "cella"
823
+ isUpstreamRepo: JSON.parse(readFileSync(join5(forkPath, "package.json"), "utf8")).name === "cella"
814
824
  };
815
825
  }
816
826
  function buildServiceChoices(context) {
@@ -878,7 +888,7 @@ async function promptForService(userConfig, forkPath) {
878
888
  if (selected === "exit") {
879
889
  console.info(colors_default.dim("exiting..."));
880
890
  console.info();
881
- process4.exit(0);
891
+ process5.exit(0);
882
892
  }
883
893
  console.info();
884
894
  return selected;
@@ -894,7 +904,7 @@ function buildRuntimeConfig(userConfig, forkPath, selection) {
894
904
  };
895
905
  }
896
906
  async function parseCli(userConfig, forkPath) {
897
- const selection = parseCommandLine(process4.argv);
907
+ const selection = parseCommandLine(process5.argv);
898
908
  if (selection.options.json || selection.options.diff) setJsonMode(true);
899
909
  printHeader();
900
910
  const warnings = validateOverrides(userConfig, forkPath);
@@ -909,29 +919,134 @@ async function parseCli(userConfig, forkPath) {
909
919
  }
910
920
 
911
921
  // src/services/analyze.ts
912
- import { basename as basename2 } from "path";
922
+ import { basename as basename4 } from "path";
913
923
 
914
- // src/utils/cleanup.ts
915
- import { existsSync as existsSync5, rmSync } from "fs";
924
+ // src/utils/diff.ts
925
+ import { execFileSync } from "child_process";
926
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
916
927
  import { tmpdir } from "os";
917
- import { basename, join as join7 } from "path";
928
+ import { basename as basename2, join as join6 } from "path";
918
929
  import process6 from "process";
930
+ function gitDiffFile(cwd, range, filePath, options = {}) {
931
+ const args = ["diff", "--no-color"];
932
+ if (options.dstPrefix) args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`);
933
+ args.push(range, "--", filePath);
934
+ try {
935
+ return execFileSync("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
936
+ } catch (error) {
937
+ const stderr = error.stderr?.toString().trim();
938
+ throw new Error(stderr || `failed to diff ${filePath}`);
939
+ }
940
+ }
941
+ function escapeHtml(text) {
942
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
943
+ }
944
+ async function renderDiffPage(patch, meta) {
945
+ const { preloadPatchDiff } = await import("@pierre/diffs/ssr");
946
+ const { prerenderedHTML } = await preloadPatchDiff({
947
+ patch,
948
+ options: {
949
+ diffStyle: "split",
950
+ themeType: "system",
951
+ lineDiffType: "word-alt",
952
+ hunkSeparators: "line-info",
953
+ overflow: "wrap",
954
+ disableFileHeader: true
955
+ }
956
+ });
957
+ const title = `${escapeHtml(meta.filePath)} \xB7 ${escapeHtml(meta.srcLabel)} vs ${escapeHtml(meta.dstLabel)}`;
958
+ const note = meta.note ? `<span class="note">\xB7 ${escapeHtml(meta.note)}</span>` : "";
959
+ return `<!doctype html>
960
+ <html lang="en">
961
+ <head>
962
+ <meta charset="utf-8">
963
+ <meta name="viewport" content="width=device-width, initial-scale=1">
964
+ <title>${title}</title>
965
+ <style>
966
+ :root { color-scheme: light dark; }
967
+ * { box-sizing: border-box; }
968
+ body {
969
+ margin: 0;
970
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
971
+ background: light-dark(#fafafa, #111);
972
+ color: light-dark(#111, #eee);
973
+ }
974
+ header {
975
+ display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
976
+ padding: 0.9rem 1.25rem;
977
+ border-bottom: 1px solid light-dark(#e2e2e2, #2a2a2a);
978
+ position: sticky; top: 0;
979
+ background: inherit;
980
+ }
981
+ header .brand { font-weight: 600; color: light-dark(#0891b2, #22d3ee); }
982
+ header code { font-family: ui-monospace, monospace; font-size: 0.9rem; }
983
+ header .sides { margin-left: auto; font-size: 0.8rem; opacity: 0.7; }
984
+ main { max-width: 1400px; margin: 1rem auto; padding: 0 1rem 2rem; }
985
+ .diff-shell {
986
+ border: 1px solid light-dark(#e2e2e2, #2a2a2a);
987
+ border-radius: 8px; overflow: hidden;
988
+ background: light-dark(#fff, #161616);
989
+ }
990
+ </style>
991
+ </head>
992
+ <body>
993
+ <header>
994
+ <span class="brand">\u29C8 cella diff</span>
995
+ <code>${escapeHtml(meta.filePath)}</code>
996
+ <span class="sides">${escapeHtml(meta.srcLabel)} \u2190 \u2192 ${escapeHtml(meta.dstLabel)} ${note}</span>
997
+ </header>
998
+ <main>
999
+ <!-- Declarative shadow DOM: the library's CSS targets :host, which only
1000
+ matches inside a shadow root (its browser component renders into one). -->
1001
+ <div class="diff-shell">
1002
+ <template shadowrootmode="open">
1003
+ ${prerenderedHTML}
1004
+ </template>
1005
+ </div>
1006
+ </main>
1007
+ </body>
1008
+ </html>`;
1009
+ }
1010
+ function openWithDefaultApp(target) {
1011
+ try {
1012
+ if (process6.platform === "win32") {
1013
+ execFileSync("rundll32", ["url.dll,FileProtocolHandler", target], { stdio: "ignore" });
1014
+ } else {
1015
+ execFileSync(process6.platform === "darwin" ? "open" : "xdg-open", [target], { stdio: "ignore" });
1016
+ }
1017
+ } catch {
1018
+ }
1019
+ }
1020
+ async function openDiffInBrowser(patch, meta, repoPath) {
1021
+ const html = await renderDiffPage(patch, meta);
1022
+ const dir = join6(tmpdir(), `cella-diff-${basename2(repoPath)}`);
1023
+ mkdirSync(dir, { recursive: true });
1024
+ const outPath = join6(dir, `${meta.filePath.replace(/[^\w.-]+/g, "-")}.html`);
1025
+ writeFileSync2(outPath, html);
1026
+ openWithDefaultApp(outPath);
1027
+ return outPath;
1028
+ }
1029
+
1030
+ // src/utils/cleanup.ts
1031
+ import { existsSync as existsSync5, rmSync } from "fs";
1032
+ import { tmpdir as tmpdir2 } from "os";
1033
+ import { basename as basename3, join as join9 } from "path";
1034
+ import process7 from "process";
919
1035
 
920
1036
  // src/utils/git.ts
921
1037
  import { execFile } from "child_process";
922
1038
  import { existsSync as existsSync4 } from "fs";
923
1039
  import { mkdir, readdir, rmdir, unlink } from "fs/promises";
924
- import { dirname, join as join6 } from "path";
925
- import process5 from "process";
1040
+ import { dirname, join as join8 } from "path";
926
1041
  import { promisify } from "util";
927
1042
 
928
1043
  // src/utils/manifest.ts
929
1044
  import { readFile, writeFile } from "fs/promises";
930
- import { join as join5 } from "path";
1045
+ import { join as join7 } from "path";
931
1046
  var MANIFEST_FILE = "cella.manifest.json";
932
1047
  async function readSyncManifest(cwd) {
933
1048
  try {
934
- const parsed = JSON.parse(await readFile(join5(cwd, MANIFEST_FILE), "utf8"));
1049
+ const parsed = JSON.parse(await readFile(join7(cwd, MANIFEST_FILE), "utf8"));
935
1050
  const commit = parsed?.upstream?.commit;
936
1051
  if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
937
1052
  return parsed;
@@ -944,7 +1059,7 @@ async function readManifestBase(cwd) {
944
1059
  return manifest ? manifest.upstream.commit.toLowerCase() : null;
945
1060
  }
946
1061
  async function writeSyncManifest(cwd, manifest) {
947
- await writeFile(join5(cwd, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
1062
+ await writeFile(join7(cwd, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
948
1063
  `, "utf8");
949
1064
  }
950
1065
 
@@ -958,7 +1073,7 @@ function sanitizeCredentials(text) {
958
1073
  }
959
1074
  async function git(args, cwd, options = {}) {
960
1075
  const env = {
961
- ...process5.env,
1076
+ ...getEnvSnapshot(),
962
1077
  ...options.skipEditor ? { GIT_EDITOR: "true" } : {},
963
1078
  ...options.env
964
1079
  };
@@ -1000,12 +1115,22 @@ async function getUpstreamStatus(cwd) {
1000
1115
  const [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
1001
1116
  return { upstream, ahead: ahead ?? 0, behind: behind ?? 0 };
1002
1117
  }
1003
- async function pushBranch(cwd, remote, branch) {
1004
- await git(["push", "-u", remote, branch], cwd);
1118
+ async function pushBranch(cwd, remote, branch, options = {}) {
1119
+ await git(["push", "-u", ...options.forceWithLease ? ["--force-with-lease"] : [], remote, branch], cwd);
1120
+ }
1121
+ async function listBranchMergeCommits(cwd, baseRef) {
1122
+ const out = await git(["rev-list", "--merges", "--first-parent", `${baseRef}..HEAD`], cwd, { ignoreErrors: true });
1123
+ return out ? out.split("\n").filter(Boolean) : [];
1124
+ }
1125
+ async function flattenBranch(cwd, baseRef, message) {
1126
+ const parent = await getMergeBase(cwd, baseRef, "HEAD");
1127
+ const tree = await git(["rev-parse", "HEAD^{tree}"], cwd);
1128
+ const commit = await git(["commit-tree", tree, "-p", parent, "-m", message], cwd);
1129
+ await git(["reset", "--hard", commit], cwd);
1005
1130
  }
1006
1131
  async function commitSquash(cwd, message) {
1007
1132
  for (const file of ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"]) {
1008
- const path4 = join6(cwd, ".git", file);
1133
+ const path4 = join8(cwd, ".git", file);
1009
1134
  if (existsSync4(path4)) await unlink(path4);
1010
1135
  }
1011
1136
  await git(["commit", "-m", message], cwd, { skipEditor: true });
@@ -1014,7 +1139,7 @@ async function stageAll(cwd) {
1014
1139
  await git(["add", "-A"], cwd);
1015
1140
  }
1016
1141
  function mergeInProgress(cwd) {
1017
- return existsSync4(join6(cwd, ".git", "MERGE_HEAD"));
1142
+ return existsSync4(join8(cwd, ".git", "MERGE_HEAD"));
1018
1143
  }
1019
1144
  async function getShortSha(cwd, ref) {
1020
1145
  return git(["rev-parse", "--short", ref], cwd);
@@ -1244,15 +1369,15 @@ async function batchUnstageFiles(cwd, filePaths) {
1244
1369
  await git(["rm", "--cached", "-f", "--", ...filePaths], cwd, { ignoreErrors: true });
1245
1370
  }
1246
1371
  async function gitMv(cwd, oldPath, newPath) {
1247
- const newDir = dirname(join6(cwd, newPath));
1372
+ const newDir = dirname(join8(cwd, newPath));
1248
1373
  await mkdir(newDir, { recursive: true });
1249
1374
  await git(["mv", "-f", oldPath, newPath], cwd);
1250
1375
  }
1251
1376
  async function fileExistsInWorktree(cwd, filePath) {
1252
- return existsSync4(join6(cwd, filePath));
1377
+ return existsSync4(join8(cwd, filePath));
1253
1378
  }
1254
1379
  async function removeFileFromWorktree(cwd, filePath) {
1255
- const fullPath = join6(cwd, filePath);
1380
+ const fullPath = join8(cwd, filePath);
1256
1381
  try {
1257
1382
  await unlink(fullPath);
1258
1383
  await cleanupEmptyParentDirs(cwd, dirname(filePath));
@@ -1265,7 +1390,7 @@ async function removeFileFully(cwd, filePath) {
1265
1390
  }
1266
1391
  async function cleanupEmptyParentDirs(cwd, relativePath) {
1267
1392
  if (!relativePath || relativePath === ".") return;
1268
- const fullPath = join6(cwd, relativePath);
1393
+ const fullPath = join8(cwd, relativePath);
1269
1394
  try {
1270
1395
  const entries = await readdir(fullPath);
1271
1396
  if (entries.length === 0) {
@@ -1293,14 +1418,28 @@ async function getStoredSyncHead(cwd) {
1293
1418
  const ref = await git(["rev-parse", "refs/cella/last-sync-head"], cwd, { ignoreErrors: true });
1294
1419
  return ref || null;
1295
1420
  }
1296
- async function readManifestBaseAtRef(cwd, ref) {
1421
+ async function readManifestAtRef(cwd, ref) {
1297
1422
  const raw = await git(["show", `${ref}:${MANIFEST_FILE}`], cwd, { ignoreErrors: true });
1298
1423
  if (!raw) return null;
1299
1424
  try {
1300
1425
  const parsed = JSON.parse(raw);
1301
1426
  const commit = parsed?.upstream?.commit;
1302
1427
  if (typeof commit !== "string" || !/^[0-9a-f]{40}$/i.test(commit)) return null;
1303
- return commit.toLowerCase();
1428
+ return parsed;
1429
+ } catch {
1430
+ return null;
1431
+ }
1432
+ }
1433
+ async function readManifestBaseAtRef(cwd, ref) {
1434
+ const manifest = await readManifestAtRef(cwd, ref);
1435
+ return manifest ? manifest.upstream.commit.toLowerCase() : null;
1436
+ }
1437
+ async function readPackageVersionAtRef(cwd, ref) {
1438
+ const raw = await git(["show", `${ref}:package.json`], cwd, { ignoreErrors: true });
1439
+ if (!raw) return null;
1440
+ try {
1441
+ const version = JSON.parse(raw).version;
1442
+ return typeof version === "string" && version ? version : null;
1304
1443
  } catch {
1305
1444
  return null;
1306
1445
  }
@@ -1318,28 +1457,82 @@ async function getRootCommit(cwd, ref) {
1318
1457
  const roots = out.split("\n").filter(Boolean);
1319
1458
  return roots.length > 0 ? roots[roots.length - 1] : null;
1320
1459
  }
1460
+ var SCAFFOLD_CANDIDATE_LIMIT = 400;
1461
+ var SCAFFOLD_SCORE_CONCURRENCY = 10;
1462
+ var SCAFFOLD_MATCH_MAX_DIFF_RATIO = 0.3;
1463
+ async function inferScaffoldBase(cwd, headRef, upstreamRef) {
1464
+ const root = await getRootCommit(cwd, headRef);
1465
+ if (!root) return null;
1466
+ const rootDate = await git(["show", "-s", "--format=%cI", root], cwd, { ignoreErrors: true });
1467
+ if (!rootDate) return null;
1468
+ const before = new Date(new Date(rootDate).getTime() + 24 * 60 * 60 * 1e3).toISOString();
1469
+ const revList = await git(
1470
+ ["rev-list", `--max-count=${SCAFFOLD_CANDIDATE_LIMIT}`, `--before=${before}`, upstreamRef],
1471
+ cwd,
1472
+ { ignoreErrors: true }
1473
+ );
1474
+ const candidates = revList.split("\n").filter(Boolean);
1475
+ if (candidates.length === 0) return null;
1476
+ const totalPaths = (await git(["ls-tree", "-r", "--name-only", root], cwd)).split("\n").filter(Boolean).length;
1477
+ if (totalPaths === 0) return null;
1478
+ let best = null;
1479
+ outer: for (let i = 0; i < candidates.length; i += SCAFFOLD_SCORE_CONCURRENCY) {
1480
+ const chunk = candidates.slice(i, i + SCAFFOLD_SCORE_CONCURRENCY);
1481
+ const scores = await Promise.all(
1482
+ chunk.map(async (sha) => {
1483
+ try {
1484
+ const out = await git(["diff-tree", "-r", "--name-only", sha, root], cwd);
1485
+ return { sha, diff: out ? out.split("\n").length : 0 };
1486
+ } catch {
1487
+ return { sha, diff: Number.POSITIVE_INFINITY };
1488
+ }
1489
+ })
1490
+ );
1491
+ for (const score of scores) {
1492
+ if (!best || score.diff < best.diff) best = score;
1493
+ if (best.diff === 0) break outer;
1494
+ }
1495
+ }
1496
+ if (!best || best.diff / totalPaths > SCAFFOLD_MATCH_MAX_DIFF_RATIO) return null;
1497
+ return best.sha;
1498
+ }
1499
+ async function readRootTrailerBase(cwd, headRef) {
1500
+ const root = await getRootCommit(cwd, headRef);
1501
+ if (!root) return null;
1502
+ const message = await git(["show", "-s", "--format=%B", root], cwd, { ignoreErrors: true });
1503
+ const match = message.match(/^cella-base:\s*([0-9a-f]{40})$/im);
1504
+ return match ? match[1].toLowerCase() : null;
1505
+ }
1321
1506
  async function ensureSyncBase(cwd, headRef, upstreamRef) {
1322
1507
  const nativeBase = await git(["merge-base", headRef, upstreamRef], cwd, { ignoreErrors: true });
1323
1508
  if (nativeBase) return;
1324
- const baseSha = await getStoredSyncRef(cwd) ?? await readManifestBaseAtRef(cwd, headRef) ?? await readManifestBase(cwd);
1509
+ const sources = [
1510
+ () => getStoredSyncRef(cwd),
1511
+ () => readManifestBaseAtRef(cwd, headRef),
1512
+ () => readManifestBase(cwd),
1513
+ () => readRootTrailerBase(cwd, headRef),
1514
+ () => inferScaffoldBase(cwd, headRef, upstreamRef)
1515
+ ];
1516
+ let baseSha = null;
1517
+ for (const source of sources) {
1518
+ const sha = await source();
1519
+ if (sha && await commitObjectExists(cwd, sha)) {
1520
+ baseSha = sha;
1521
+ break;
1522
+ }
1523
+ }
1325
1524
  if (!baseSha) {
1326
1525
  throw new Error(
1327
- `no common ancestor between the fork and '${upstreamRef}', and no sync base recorded.
1526
+ `no common ancestor between the fork and '${upstreamRef}', and no sync base could be determined.
1328
1527
 
1329
1528
  This fork has unrelated history with upstream \u2014 typical for a create-cella scaffold, or after
1330
- upstream squashed its history. Seed the base with the upstream commit the fork was created from,
1331
- then re-run sync:
1529
+ upstream squashed its history \u2014 and no recorded or inferable base commit exists after fetching
1530
+ upstream. Seed it manually with the upstream commit the fork was created from, then re-run sync:
1332
1531
 
1333
1532
  git update-ref refs/cella/last-sync <upstream-sha>
1334
1533
  `
1335
1534
  );
1336
1535
  }
1337
- if (!await commitObjectExists(cwd, baseSha)) {
1338
- throw new Error(
1339
- `recorded sync base ${baseSha.slice(0, 12)} is not present after fetching '${upstreamRef}'.
1340
- It may belong to a different upstream, or have been dropped by an upstream history rewrite.`
1341
- );
1342
- }
1343
1536
  const root = await getRootCommit(cwd, headRef);
1344
1537
  if (!root) throw new Error("could not determine the fork's root commit to bootstrap sync ancestry");
1345
1538
  await git(["replace", "-f", "--graft", root, baseSha], cwd);
@@ -1382,23 +1575,11 @@ var WORKTREE_PREFIXES = {
1382
1575
  view: "cella-view-"
1383
1576
  };
1384
1577
  function buildWorktreePath(kind, repoPath) {
1385
- return join7(tmpdir(), `${WORKTREE_PREFIXES[kind]}${basename(repoPath)}`);
1578
+ return join9(tmpdir2(), `${WORKTREE_PREFIXES[kind]}${basename3(repoPath)}`);
1386
1579
  }
1387
1580
  function getWorktreePath(repoPath) {
1388
1581
  return buildWorktreePath("sync", repoPath);
1389
1582
  }
1390
- function getViewWorktreePath(repoPath) {
1391
- return buildWorktreePath("view", repoPath);
1392
- }
1393
- async function refreshViewWorktree(repoPath, upstreamRef) {
1394
- const viewPath = getViewWorktreePath(repoPath);
1395
- await removeWorktree(repoPath, viewPath);
1396
- if (existsSync5(viewPath)) {
1397
- rmSync(viewPath, { recursive: true, force: true });
1398
- }
1399
- await createWorktree(repoPath, viewPath, upstreamRef);
1400
- return viewPath;
1401
- }
1402
1583
  var cleanupRegistered = false;
1403
1584
  var currentWorktreePath = null;
1404
1585
  var currentRepoPath = null;
@@ -1418,9 +1599,11 @@ async function cleanupWorktree(repoPath, worktreePath) {
1418
1599
  unregisterWorktree();
1419
1600
  }
1420
1601
  async function cleanupLeftoverWorktrees(repoPath) {
1421
- const worktreePath = getWorktreePath(repoPath);
1422
- if (existsSync5(worktreePath)) {
1423
- await cleanupWorktree(repoPath, worktreePath);
1602
+ for (const kind of Object.keys(WORKTREE_PREFIXES)) {
1603
+ const worktreePath = buildWorktreePath(kind, repoPath);
1604
+ if (existsSync5(worktreePath)) {
1605
+ await cleanupWorktree(repoPath, worktreePath);
1606
+ }
1424
1607
  }
1425
1608
  const prefixes = Object.values(WORKTREE_PREFIXES);
1426
1609
  const worktrees = await listWorktrees(repoPath);
@@ -1447,56 +1630,15 @@ async function handleAbort(signal) {
1447
1630
  );
1448
1631
  }
1449
1632
  }
1450
- process6.exit(1);
1633
+ process7.exit(1);
1451
1634
  }
1452
1635
  function registerSignalHandlers() {
1453
1636
  if (cleanupRegistered) return;
1454
- process6.on("SIGINT", () => handleAbort("SIGINT"));
1455
- process6.on("SIGTERM", () => handleAbort("SIGTERM"));
1637
+ process7.on("SIGINT", () => handleAbort("SIGINT"));
1638
+ process7.on("SIGTERM", () => handleAbort("SIGTERM"));
1456
1639
  cleanupRegistered = true;
1457
1640
  }
1458
1641
 
1459
- // src/utils/diff.ts
1460
- import { spawnSync as spawnSync2 } from "child_process";
1461
- import { existsSync as existsSync6, mkdtempSync, writeFileSync as writeFileSync2 } from "fs";
1462
- import { tmpdir as tmpdir2 } from "os";
1463
- import { join as join8, resolve as resolve3 } from "path";
1464
- function gitDiffFile(cwd, range, filePath, options) {
1465
- const args = ["diff"];
1466
- if (options.color === "always") args.push("--color=always");
1467
- if (options.color === "never") args.push("--no-color");
1468
- args.push("--src-prefix=cella/", `--dst-prefix=${options.dstPrefix}/`, range, "--", filePath);
1469
- const result = spawnSync2("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 });
1470
- if (result.status !== 0) {
1471
- const stderr = result.stderr?.toString().trim();
1472
- throw new Error(stderr || `failed to diff ${filePath}`);
1473
- }
1474
- return result.stdout;
1475
- }
1476
- function openVsCodeDiff(forkPath, upstreamRef, viewPath, filePath) {
1477
- const forkFile = resolve3(forkPath, filePath);
1478
- if (viewPath) {
1479
- const upstreamFile = resolve3(viewPath, filePath);
1480
- if (existsSync6(upstreamFile)) {
1481
- spawnSync2("code", ["--diff", upstreamFile, forkFile], { stdio: "ignore" });
1482
- return;
1483
- }
1484
- }
1485
- const tmpDir = mkdtempSync(join8(tmpdir2(), "cella-analyze-diff-"));
1486
- const fileName = filePath.split("/").pop() || "file";
1487
- const tmpFile = join8(tmpDir, `upstream-${fileName}`);
1488
- const result = spawnSync2("git", ["show", `${upstreamRef}:${filePath}`], {
1489
- cwd: forkPath,
1490
- stdio: ["pipe", "pipe", "pipe"]
1491
- });
1492
- if (result.status !== 0) {
1493
- const stderr = result.stderr.toString().trim();
1494
- throw new Error(stderr || `failed to read upstream version for ${filePath}`);
1495
- }
1496
- writeFileSync2(tmpFile, result.stdout);
1497
- spawnSync2("code", ["--diff", tmpFile, forkFile], { stdio: "ignore" });
1498
- }
1499
-
1500
1642
  // src/services/analyze-core.ts
1501
1643
  async function analyzeRefs(repoPath, localRef, incomingRef, mergeBaseRef, predicates, onProgress) {
1502
1644
  onProgress?.("collecting file hashes (batch)...");
@@ -2009,8 +2151,7 @@ function findTargetFile(files, targetPath) {
2009
2151
  }
2010
2152
  function printUnifiedDiff(config, filePath) {
2011
2153
  const diff = gitDiffFile(config.forkPath, `${config.upstreamRef}..HEAD`, filePath, {
2012
- dstPrefix: basename2(config.forkPath),
2013
- color: "never"
2154
+ dstPrefix: basename4(config.forkPath)
2014
2155
  });
2015
2156
  writeStdout(diff.toString());
2016
2157
  }
@@ -2052,19 +2193,32 @@ async function runAnalyze(config) {
2052
2193
  }
2053
2194
  return result;
2054
2195
  }
2055
- const upstreamViewPath = await refreshViewWorktree(config.forkPath, config.upstreamRef);
2056
2196
  if (config.openDiff) {
2057
2197
  const file = findTargetFile(scopedFiles, config.openDiff) ?? findTargetFile(result.files, config.openDiff);
2058
2198
  if (!file) throw new Error(`file not found in analysis results: ${config.openDiff}`);
2059
- openVsCodeDiff(config.forkPath, config.upstreamRef, upstreamViewPath, file.path);
2060
- console.info(`${colors_default.green("\u2713")} opened VS Code diff for ${file.path}`);
2199
+ const patch = gitDiffFile(config.forkPath, config.upstreamRef, file.path);
2200
+ if (patch.length === 0) {
2201
+ console.info(`${colors_default.green("\u2713")} ${file.path} is identical to upstream`);
2202
+ return result;
2203
+ }
2204
+ const pagePath = await openDiffInBrowser(
2205
+ patch.toString(),
2206
+ {
2207
+ filePath: file.path,
2208
+ srcLabel: "cella",
2209
+ dstLabel: basename4(config.forkPath),
2210
+ note: "local working tree"
2211
+ },
2212
+ config.forkPath
2213
+ );
2214
+ console.info(`${colors_default.green("\u2713")} opened browser diff for ${file.path}`);
2215
+ console.info(colors_default.dim(` ${pagePath}`));
2061
2216
  return result;
2062
2217
  }
2063
2218
  const linkOptions = {
2064
2219
  upstreamGitHubUrl: result.upstreamGitHubUrl,
2065
2220
  upstreamBranch: result.upstreamBranch,
2066
2221
  fileLinkMode: config.settings.fileLinkMode,
2067
- upstreamViewPath,
2068
2222
  forkPath: config.forkPath
2069
2223
  };
2070
2224
  printAnalysisFileGroups(result.files, linkOptions);
@@ -2079,7 +2233,7 @@ async function runAnalyze(config) {
2079
2233
  }
2080
2234
 
2081
2235
  // src/services/audit.ts
2082
- import { spawnSync as spawnSync3 } from "child_process";
2236
+ import { spawnSync } from "child_process";
2083
2237
  import * as fs2 from "fs";
2084
2238
  import * as path2 from "path";
2085
2239
  import { checkbox, confirm, Separator } from "@inquirer/prompts";
@@ -2370,7 +2524,7 @@ async function runAudit(config, options = {}) {
2370
2524
  if (options.force) {
2371
2525
  spinnerText("clearing pnpm metadata cache...");
2372
2526
  clearCache();
2373
- spawnSync3("pnpm", ["cache", "delete", "*"], {
2527
+ spawnSync("pnpm", ["cache", "delete", "*"], {
2374
2528
  cwd: forkPath,
2375
2529
  stdio: "ignore"
2376
2530
  });
@@ -2660,7 +2814,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
2660
2814
  }
2661
2815
  if (runAuditFix) {
2662
2816
  console.info(colors_default.dim("running pnpm audit --fix override..."));
2663
- const auditFixResult = spawnSync3("pnpm", ["audit", "--fix", "override"], {
2817
+ const auditFixResult = spawnSync("pnpm", ["audit", "--fix", "override"], {
2664
2818
  cwd: forkPath,
2665
2819
  stdio: "inherit"
2666
2820
  });
@@ -2680,7 +2834,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
2680
2834
  const selectedCatalogPackages = selectedPackages.filter((pkgName) => catalogPackages.has(pkgName));
2681
2835
  if (selectedCatalogPackages.length > 0) {
2682
2836
  console.info(colors_default.dim(`normalizing catalog consumers: ${selectedCatalogPackages.join(", ")}`));
2683
- const normalizeResult = spawnSync3("pnpm", ["-r", "up", ...selectedCatalogPackages], {
2837
+ const normalizeResult = spawnSync("pnpm", ["-r", "up", ...selectedCatalogPackages], {
2684
2838
  cwd: forkPath,
2685
2839
  stdio: "inherit"
2686
2840
  });
@@ -2690,7 +2844,7 @@ async function promptForUpdates(packages, forkPath, auditResult) {
2690
2844
  return;
2691
2845
  }
2692
2846
  }
2693
- const result = spawnSync3("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
2847
+ const result = spawnSync("pnpm", ["-r", "up", "--latest", ...selectedPackages], {
2694
2848
  cwd: forkPath,
2695
2849
  stdio: "inherit"
2696
2850
  });
@@ -2847,7 +3001,7 @@ async function checkOverrides(forkPath) {
2847
3001
 
2848
3002
  // src/services/contributions.ts
2849
3003
  import { readFileSync as readFileSync4 } from "fs";
2850
- import { join as join12 } from "path";
3004
+ import { join as join13 } from "path";
2851
3005
  import {
2852
3006
  createPrompt,
2853
3007
  isDownKey,
@@ -2862,7 +3016,7 @@ import {
2862
3016
  import { select as select2 } from "@inquirer/prompts";
2863
3017
 
2864
3018
  // src/services/contrib-core.ts
2865
- import { dirname as dirname2, join as join11 } from "path";
3019
+ import { dirname as dirname2, join as join12 } from "path";
2866
3020
  async function listFilesAtRef(repoPath, ref) {
2867
3021
  const raw = await git(["ls-tree", "-r", "--name-only", ref], repoPath);
2868
3022
  return new Set(raw.split("\n").filter(Boolean));
@@ -2924,7 +3078,7 @@ async function readBlobInfo(repoPath, ref, filePath) {
2924
3078
  }
2925
3079
  async function buildContribBranch(repoPath, baseRef, forkRef, detection, forkName) {
2926
3080
  const branch = `contrib/${forkName}`;
2927
- const tmpIndex = join11(repoPath, ".git", `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
3081
+ const tmpIndex = join12(repoPath, ".git", `tmp-contrib-index-${forkName.replace(/[^\w.-]/g, "_")}`);
2928
3082
  const indexEnv = { GIT_INDEX_FILE: tmpIndex };
2929
3083
  try {
2930
3084
  await git(["read-tree", baseRef], repoPath, { env: indexEnv });
@@ -2967,16 +3121,16 @@ ${commitBody}`;
2967
3121
  }
2968
3122
 
2969
3123
  // src/services/fork-utils.ts
2970
- import { existsSync as existsSync8 } from "fs";
2971
- import { resolve as resolve4 } from "path";
3124
+ import { existsSync as existsSync7 } from "fs";
3125
+ import { resolve as resolve3 } from "path";
2972
3126
  function validateForkPath(fork, basePath, requireConfig = false) {
2973
- const resolvedPath = resolve4(basePath, fork.localPath);
2974
- if (!resolvedPath.startsWith(resolve4(basePath, ".."))) {
3127
+ const resolvedPath = resolve3(basePath, fork.localPath);
3128
+ if (!resolvedPath.startsWith(resolve3(basePath, ".."))) {
2975
3129
  return { fork, resolvedPath, valid: false, error: "path resolves outside parent directory" };
2976
3130
  }
2977
- if (!existsSync8(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
2978
- if (!existsSync8(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
2979
- if (requireConfig && !existsSync8(`${resolvedPath}/cella.config.ts`)) {
3131
+ if (!existsSync7(resolvedPath)) return { fork, resolvedPath, valid: false, error: "path does not exist" };
3132
+ if (!existsSync7(`${resolvedPath}/.git`)) return { fork, resolvedPath, valid: false, error: "not a git repository" };
3133
+ if (requireConfig && !existsSync7(`${resolvedPath}/cella.config.ts`)) {
2980
3134
  return { fork, resolvedPath, valid: false, error: "missing cella.config.ts" };
2981
3135
  }
2982
3136
  return { fork, resolvedPath, valid: true };
@@ -2988,12 +3142,13 @@ function printNoForksHint(action) {
2988
3142
  }
2989
3143
 
2990
3144
  // src/services/contributions.ts
2991
- function showContribDiff(item, baseRef, cwd, forkName) {
2992
- const diff = gitDiffFile(cwd, `${baseRef}..${item.ref}`, item.path, { dstPrefix: forkName, color: "always" });
2993
- showDiffInPager(diff);
3145
+ async function showContribDiff(item, baseRef, cwd, forkName) {
3146
+ const patch = gitDiffFile(cwd, `${baseRef}..${item.ref}`, item.path);
3147
+ if (patch.length === 0) return null;
3148
+ return openDiffInBrowser(patch.toString(), { filePath: item.path, srcLabel: "cella", dstLabel: forkName }, cwd);
2994
3149
  }
2995
3150
  function shouldApplyContributionsUnstaged(repoPath) {
2996
- return JSON.parse(readFileSync4(join12(repoPath, "package.json"), "utf8")).name === "cella";
3151
+ return JSON.parse(readFileSync4(join13(repoPath, "package.json"), "utf8")).name === "cella";
2997
3152
  }
2998
3153
  var contribPrompt = createPrompt((config, done) => {
2999
3154
  const { pageSize = 20, baseRef, cwd, forkName } = config;
@@ -3053,8 +3208,12 @@ var contribPrompt = createPrompt((config, done) => {
3053
3208
  return;
3054
3209
  }
3055
3210
  if (key.name === "d") {
3056
- showContribDiff(items[active], baseRef, cwd, forkName);
3057
- setStatusMsg(`viewed ${items[active].path}`);
3211
+ try {
3212
+ const pagePath = await showContribDiff(items[active], baseRef, cwd, forkName);
3213
+ setStatusMsg(pagePath ? `opened ${items[active].path} in browser` : `no changes for ${items[active].path}`);
3214
+ } catch (error) {
3215
+ setStatusMsg(`diff failed: ${error instanceof Error ? error.message : "unknown error"}`);
3216
+ }
3058
3217
  return;
3059
3218
  }
3060
3219
  if (key.name === "a") {
@@ -3095,7 +3254,7 @@ var contribPrompt = createPrompt((config, done) => {
3095
3254
  const header = `${colors_default.cyan("\u2B07")} ${config.message} ${colors_default.dim(`(${countInfo})`)}`;
3096
3255
  const keys = [
3097
3256
  ["\u2191\u2193", "navigate"],
3098
- ["d", "diff"],
3257
+ ["d", "diff in browser"],
3099
3258
  ["a", "(de)select all"],
3100
3259
  ["space", "select"],
3101
3260
  ["\u23CE", "accept"],
@@ -3336,11 +3495,6 @@ async function runContributions(config) {
3336
3495
  spinnerSuccess(`applied ${applied} files ${colors_default.dim(applyUnstaged ? "(unstaged)" : "(staged)")}`);
3337
3496
  }
3338
3497
  console.info();
3339
- console.info(` ${colors_default.cyan(forkName)} ${colors_default.dim(`(${selected.length} files)`)}`);
3340
- for (const item of selected) {
3341
- console.info(` ${colors_default.dim("\u2192")} ${item.deleted ? `(deleted) ${item.path}` : item.path}`);
3342
- }
3343
- console.info();
3344
3498
  console.info(
3345
3499
  colors_default.dim(
3346
3500
  applyUnstaged ? " files are unstaged \u2014 review, stage what you want, and commit when ready" : " files are staged \u2014 review and commit when ready"
@@ -3350,16 +3504,16 @@ async function runContributions(config) {
3350
3504
  }
3351
3505
 
3352
3506
  // src/services/forks.ts
3353
- import { resolve as resolve5 } from "path";
3354
- import process7 from "process";
3507
+ import { resolve as resolve4 } from "path";
3508
+ import process8 from "process";
3355
3509
  import { Separator as Separator2, select as select3 } from "@inquirer/prompts";
3356
3510
 
3357
3511
  // src/services/sync.ts
3358
- import { spawnSync as spawnSync4 } from "child_process";
3512
+ import { spawnSync as spawnSync2 } from "child_process";
3359
3513
 
3360
3514
  // src/services/packages.ts
3361
- import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3362
- import { join as join13 } from "path";
3515
+ import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3516
+ import { join as join14 } from "path";
3363
3517
  function parseComparableVersion(version) {
3364
3518
  const trimmed = version.trim();
3365
3519
  if (trimmed === "" || trimmed === "*" || trimmed.includes("workspace:") || trimmed.includes("catalog:") || trimmed.includes("file:") || trimmed.includes("link:") || trimmed.includes("git+") || trimmed.includes("github:") || trimmed.includes("http://") || trimmed.includes("https://") || trimmed.includes("||")) {
@@ -3463,7 +3617,7 @@ function safeMergePnpm(forkPnpm, upstreamPnpm) {
3463
3617
  return { merged, changed };
3464
3618
  }
3465
3619
  function readPackageJson(filePath) {
3466
- if (!existsSync9(filePath)) return null;
3620
+ if (!existsSync8(filePath)) return null;
3467
3621
  try {
3468
3622
  return JSON.parse(readFileSync5(filePath, "utf-8"));
3469
3623
  } catch {
@@ -3485,11 +3639,11 @@ async function getUpstreamPackageJson(forkPath, upstreamRef, relativePath) {
3485
3639
  async function discoverPackageLocations(forkPath, upstreamRef) {
3486
3640
  const stdout = await git(["ls-tree", "-r", "--name-only", upstreamRef], forkPath, { ignoreErrors: true });
3487
3641
  const upstreamPaths = stdout.split("\n").filter((p) => p.endsWith("/package.json") || p === "package.json").map((p) => p === "package.json" ? "" : p.replace("/package.json", ""));
3488
- return upstreamPaths.filter((loc) => existsSync9(join13(forkPath, loc, "package.json")));
3642
+ return upstreamPaths.filter((loc) => existsSync8(join14(forkPath, loc, "package.json")));
3489
3643
  }
3490
3644
  async function syncPackageJson(forkPath, upstreamRef, relativePath, keysToSync) {
3491
3645
  const changes = [];
3492
- const pkgPath = join13(forkPath, relativePath, "package.json");
3646
+ const pkgPath = join14(forkPath, relativePath, "package.json");
3493
3647
  const forkPkg = readPackageJson(pkgPath);
3494
3648
  const upstreamPkg = await getUpstreamPackageJson(forkPath, upstreamRef, relativePath);
3495
3649
  if (!forkPkg || !upstreamPkg) {
@@ -3544,7 +3698,7 @@ async function runPackages(config, options = {}) {
3544
3698
  const keysToSync = config.settings.packageJsonSync || ["dependencies", "devDependencies"];
3545
3699
  const conflictedSet = new Set(options.conflictedFiles ?? []);
3546
3700
  const locations = await discoverPackageLocations(config.forkPath, config.upstreamRef);
3547
- const results = [];
3701
+ let changedCount = 0;
3548
3702
  const skipped = [];
3549
3703
  for (const location of locations) {
3550
3704
  const pkgRelPath = location ? `${location}/package.json` : "package.json";
@@ -3553,22 +3707,15 @@ async function runPackages(config, options = {}) {
3553
3707
  continue;
3554
3708
  }
3555
3709
  spinnerText(`syncing ${location || "root"}/package.json...`);
3556
- const { updated, changes } = await syncPackageJson(config.forkPath, config.upstreamRef, location, keysToSync);
3710
+ const { updated } = await syncPackageJson(config.forkPath, config.upstreamRef, location, keysToSync);
3557
3711
  if (updated) {
3558
- results.push({ location: location || "root", changes });
3559
- }
3560
- }
3561
- spinnerSuccess("package sync complete");
3562
- if (results.length === 0) {
3563
- console.info(colors_default.dim(" no package.json changes needed"));
3564
- } else {
3565
- for (const { location, changes } of results) {
3566
- console.info(` ${colors_default.green("\u2713")} ${location}/package.json`);
3567
- for (const change of changes) {
3568
- console.info(` ${colors_default.dim("\u2192")} ${change}`);
3569
- }
3712
+ changedCount += 1;
3570
3713
  }
3571
3714
  }
3715
+ spinnerSuccess(
3716
+ "package sync complete",
3717
+ `${changedCount} package.json${changedCount === 1 ? " was" : "s were"} changed`
3718
+ );
3572
3719
  if (skipped.length > 0) {
3573
3720
  console.info();
3574
3721
  console.warn(
@@ -3633,23 +3780,68 @@ async function runSync(config, options) {
3633
3780
  spinnerFail("sync completed with conflicts");
3634
3781
  }
3635
3782
  printSummary(result.summary, "merge summary");
3636
- options?.onAfterSummary?.(result);
3637
3783
  if (config.logFile) {
3638
3784
  const logPath = writeLogFile(config.forkPath, result.files);
3639
3785
  console.info();
3640
3786
  console.info(colors_default.dim(`full file list written to: ${logPath}`));
3641
3787
  }
3642
- printSyncComplete(result);
3788
+ const stagedBranch = options?.stagedBranch && result.conflicts.length === 0 && hasStagedSyncChanges(result) ? options.stagedBranch : void 0;
3789
+ printSyncComplete(result, { stagedBranch });
3643
3790
  printFlagWarnings({ hard: config.hard, unpinned: config.unpinned });
3644
3791
  return result;
3645
3792
  }
3646
3793
  var SYNC_PR_TITLE = "chore: sync upstream cella";
3647
- function printPrCreateStep(branch, base) {
3648
- console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${SYNC_PR_TITLE}" --fill`));
3794
+ var PR_BODY_COMMIT_MAX = 50;
3795
+ async function buildSyncCommitMessage(forkPath, upstreamRef) {
3796
+ const shortSha = await getShortSha(forkPath, upstreamRef).catch(() => "");
3797
+ if (!shortSha) return SYNC_PR_TITLE;
3798
+ const manifest = await readSyncManifest(forkPath);
3799
+ const version = manifest?.upstream.release ?? await readPackageVersionAtRef(forkPath, upstreamRef).catch(() => null);
3800
+ return version ? `${SYNC_PR_TITLE} v${version.replace(/^v/, "")} (${shortSha})` : `${SYNC_PR_TITLE} ${shortSha}`;
3801
+ }
3802
+ function qualifyPrRefs(subject, repoSlug) {
3803
+ if (!repoSlug) return subject;
3804
+ return subject.replace(/(^|[^\w/])#(\d+)\b/g, `$1${repoSlug}#$2`);
3805
+ }
3806
+ function buildSyncPrBody(input) {
3807
+ const { repoSlug, version, fromSha, toSha, commits, totalCount } = input;
3808
+ const githubUrl = repoSlug ? `https://github.com/${repoSlug}` : void 0;
3809
+ const short = (sha) => sha.slice(0, 7);
3810
+ const commitRef = (sha) => githubUrl ? `[\`${short(sha)}\`](${githubUrl}/commit/${sha})` : `\`${short(sha)}\``;
3811
+ const upstreamName = githubUrl ? `[${repoSlug}](${githubUrl})` : "cella";
3812
+ const versionSuffix = version ? ` (v${version.replace(/^v/, "")})` : "";
3813
+ const lines = [`Syncs upstream ${upstreamName} to ${commitRef(toSha)}${versionSuffix}.`];
3814
+ if (totalCount > 0 && fromSha) {
3815
+ const compare = githubUrl ? ` ([compare](${githubUrl}/compare/${short(fromSha)}...${short(toSha)}))` : "";
3816
+ lines.push("", `**${totalCount} upstream commit${totalCount === 1 ? "" : "s"} since last sync**${compare}:`, "");
3817
+ if (totalCount > commits.length) lines.push(`- \u2026${totalCount - commits.length} earlier commit(s) not shown`);
3818
+ for (const commit of commits) {
3819
+ lines.push(`- ${commitRef(commit.hash)} ${qualifyPrRefs(commit.message, repoSlug)}`);
3820
+ }
3821
+ }
3822
+ return `${lines.join("\n")}
3823
+ `;
3824
+ }
3825
+ async function buildSyncPrBodyForBranch(forkPath, base) {
3826
+ const manifest = await readManifestAtRef(forkPath, "HEAD");
3827
+ if (!manifest) return void 0;
3828
+ const toSha = manifest.upstream.commit.toLowerCase();
3829
+ const fromSha = await readManifestBaseAtRef(forkPath, base) ?? await getMergeBase(forkPath, base, toSha).catch(() => null);
3830
+ const version = manifest.upstream.release ?? await readPackageVersionAtRef(forkPath, toSha).catch(() => null);
3831
+ const totalCount = fromSha ? await countCommitsBetween(forkPath, fromSha, toSha).catch(() => 0) : 0;
3832
+ const commits = fromSha && totalCount > 0 ? await listCommitsBetween(forkPath, fromSha, toSha, {
3833
+ oldestFirst: true,
3834
+ skip: totalCount > PR_BODY_COMMIT_MAX ? totalCount - PR_BODY_COMMIT_MAX : 0,
3835
+ limit: PR_BODY_COMMIT_MAX
3836
+ }) : [];
3837
+ return buildSyncPrBody({ repoSlug: manifest.upstream.repo, version, fromSha, toSha, commits, totalCount });
3838
+ }
3839
+ function printPrCreateStep(branch, base, title = SYNC_PR_TITLE) {
3840
+ console.info(colors_default.dim(` gh pr create --base ${base} --head ${branch} --title "${title}" --fill`));
3649
3841
  }
3650
- function printShipSteps(temporaryBranch, base) {
3842
+ function printShipSteps(temporaryBranch, base, title) {
3651
3843
  console.info(colors_default.dim(` git push -u origin ${temporaryBranch}`));
3652
- printPrCreateStep(temporaryBranch, base);
3844
+ printPrCreateStep(temporaryBranch, base, title);
3653
3845
  }
3654
3846
  function printFinishSteps() {
3655
3847
  console.info(colors_default.dim(" pnpm cella sync"));
@@ -3657,51 +3849,80 @@ function printFinishSteps() {
3657
3849
  function hasStagedSyncChanges(result) {
3658
3850
  return result.files.some((file) => ["behind", "diverged", "renamed", "ignored", "pinned"].includes(file.status));
3659
3851
  }
3660
- function printCleanSyncNextStep(branch) {
3661
- console.info();
3662
- console.info(`${checkMark} ${colors_default.green(`Sync merge staged on '${branch}'. No conflicts. Review, then finish with:`)}`);
3663
- printFinishSteps();
3664
- console.info(colors_default.dim(" rerun commits the sync, pushes the branch, and opens a PR."));
3665
- }
3666
3852
  function ghAvailable() {
3667
- return spawnSync4("gh", ["--version"], { stdio: "ignore" }).status === 0;
3853
+ return spawnSync2("gh", ["--version"], { stdio: "ignore" }).status === 0;
3854
+ }
3855
+ function extractFirstUrl(output) {
3856
+ return output.match(/https?:\/\/\S+/)?.[0];
3857
+ }
3858
+ async function flattenSyncBranch(forkPath, branch, base) {
3859
+ const mergeCommits = await listBranchMergeCommits(forkPath, base);
3860
+ if (mergeCommits.length === 0) return false;
3861
+ const message = await buildSyncCommitMessage(forkPath, `${mergeCommits[0]}^2`);
3862
+ console.info(
3863
+ colors_default.yellow(
3864
+ `'${branch}' contains ${mergeCommits.length} merge commit(s) \u2014 the PR would list the entire upstream history.`
3865
+ )
3866
+ );
3867
+ console.info(colors_default.dim(`flattening '${branch}' to a single commit (same content)...`));
3868
+ await flattenBranch(forkPath, base, message);
3869
+ return true;
3668
3870
  }
3669
3871
  async function shipSyncBranch(config, branch) {
3670
3872
  const { forkPath, settings } = config;
3671
3873
  const base = resolveReleaseBase(settings);
3874
+ const flattened = await flattenSyncBranch(forkPath, branch, base);
3875
+ let prUrl;
3876
+ let prOpened = false;
3877
+ const headSubject = (await getCommitInfo(forkPath, "HEAD").catch(() => null))?.message;
3878
+ const prTitle = headSubject?.startsWith(SYNC_PR_TITLE) ? headSubject : SYNC_PR_TITLE;
3672
3879
  console.info(colors_default.dim(`pushing '${branch}' to origin...`));
3673
3880
  try {
3674
- await pushBranch(forkPath, "origin", branch);
3881
+ await pushBranch(forkPath, "origin", branch, { forceWithLease: flattened });
3675
3882
  } catch (error) {
3676
3883
  const detail = error instanceof Error ? error.message : String(error);
3677
3884
  console.info(colors_default.yellow(`push failed (${detail.split("\n")[0]}). finish manually:`));
3678
- printShipSteps(branch, base);
3885
+ printShipSteps(branch, base, prTitle);
3679
3886
  return;
3680
3887
  }
3681
3888
  if (ghAvailable()) {
3682
3889
  console.info(colors_default.dim("opening a pull request..."));
3683
- const pr = spawnSync4("gh", ["pr", "create", "--base", base, "--head", branch, "--title", SYNC_PR_TITLE, "--fill"], {
3890
+ const prBody = await buildSyncPrBodyForBranch(forkPath, base);
3891
+ const bodyArgs = prBody ? ["--body", prBody] : ["--fill"];
3892
+ const pr = spawnSync2("gh", ["pr", "create", "--base", base, "--head", branch, "--title", prTitle, ...bodyArgs], {
3684
3893
  cwd: forkPath,
3685
- stdio: "inherit"
3894
+ encoding: "utf-8",
3895
+ stdio: ["ignore", "pipe", "pipe"]
3686
3896
  });
3687
- if (pr.status !== 0) {
3897
+ prUrl = extractFirstUrl(`${pr.stdout ?? ""}
3898
+ ${pr.stderr ?? ""}`);
3899
+ prOpened = pr.status === 0;
3900
+ if (!prOpened) {
3688
3901
  console.info(colors_default.yellow("could not open the PR automatically (it may already exist). open it with:"));
3689
- printPrCreateStep(branch, base);
3902
+ if (prUrl) console.info(colors_default.dim(` ${prUrl}`));
3903
+ printPrCreateStep(branch, base, prTitle);
3690
3904
  }
3691
3905
  } else {
3692
3906
  console.info(colors_default.yellow("`gh` not found \u2014 open the PR manually:"));
3693
- printPrCreateStep(branch, base);
3907
+ printPrCreateStep(branch, base, prTitle);
3694
3908
  }
3695
3909
  console.info(colors_default.dim(`switching back to '${base}'...`));
3696
3910
  await switchBranch(forkPath, base);
3697
3911
  await pullFastForward(forkPath).catch(() => {
3698
3912
  });
3699
- console.info(colors_default.green(`done \u2014 '${branch}' is pushed and you're back on '${base}'.`));
3913
+ console.info();
3914
+ if (prUrl) {
3915
+ console.info(`${colors_default.green("\u2713")} Sync pull request ${prOpened ? "opened" : "ready"}`);
3916
+ console.info(colors_default.dim(` ${prUrl} \xB7 branch pushed, back on '${base}'`));
3917
+ } else {
3918
+ console.info(`${colors_default.green("\u2713")} Sync branch pushed`);
3919
+ console.info(colors_default.dim(` '${branch}' is on origin, back on '${base}'`));
3920
+ }
3700
3921
  }
3701
3922
  function finalizeWorkspace(forkPath) {
3702
3923
  for (const args of [["install"], ["check"]]) {
3703
3924
  console.info(colors_default.dim(`running pnpm ${args.join(" ")}...`));
3704
- const result = spawnSync4("pnpm", args, { cwd: forkPath, stdio: "inherit" });
3925
+ const result = spawnSync2("pnpm", args, { cwd: forkPath, stdio: "inherit" });
3705
3926
  if (result.status !== 0) return false;
3706
3927
  }
3707
3928
  return true;
@@ -3709,13 +3930,7 @@ function finalizeWorkspace(forkPath) {
3709
3930
  async function runSyncCycle(config) {
3710
3931
  const { forkPath } = config;
3711
3932
  const branch = await setupTemporarySyncBranch(config);
3712
- const result = await runSync(config, {
3713
- onAfterSummary: (syncResult) => {
3714
- if (syncResult.conflicts.length === 0 && hasStagedSyncChanges(syncResult)) {
3715
- printCleanSyncNextStep(branch.temporaryBranch);
3716
- }
3717
- }
3718
- });
3933
+ const result = await runSync(config, { stagedBranch: branch.temporaryBranch });
3719
3934
  if (config.settings.syncWithPackages !== false) {
3720
3935
  await runPackages(config, { conflictedFiles: result.conflicts });
3721
3936
  }
@@ -3734,7 +3949,7 @@ async function resumeSyncMerge(config, branch) {
3734
3949
  if (conflicts.length > 0) {
3735
3950
  console.info(colors_default.yellow(`${conflicts.length} file(s) still conflict on '${branch}':`));
3736
3951
  for (const file of conflicts) console.info(colors_default.dim(` ${file}`));
3737
- console.info(colors_default.dim("resolve and stage them, then re-run `pnpm cella sync` to finish."));
3952
+ console.info(colors_default.dim("resolve and stage them, then re-run `pnpm cella sync` to finish (it commits for you)."));
3738
3953
  return;
3739
3954
  }
3740
3955
  await stageAll(forkPath);
@@ -3745,9 +3960,8 @@ async function resumeSyncMerge(config, branch) {
3745
3960
  );
3746
3961
  return;
3747
3962
  }
3748
- const upstreamSha = await getShortSha(forkPath, "MERGE_HEAD").catch(() => "");
3963
+ const message = await buildSyncCommitMessage(forkPath, "MERGE_HEAD");
3749
3964
  await stageAll(forkPath);
3750
- const message = upstreamSha ? `${SYNC_PR_TITLE} ${upstreamSha}` : SYNC_PR_TITLE;
3751
3965
  await commitSquash(forkPath, message);
3752
3966
  console.info();
3753
3967
  console.info(colors_default.green(`committed the sync on '${branch}' as '${message}'.`));
@@ -3766,14 +3980,12 @@ async function runSyncCommand(config) {
3766
3980
  console.info();
3767
3981
  console.info(
3768
3982
  colors_default.yellow(
3769
- `sync branch '${currentBranch}' has uncommitted changes that would not be shipped.
3770
- commit them (\`git commit --amend --no-edit\` or a new commit), then re-run \`pnpm cella sync\`.`
3983
+ `sync branch '${currentBranch}' has uncommitted changes.
3984
+ commit them first (\`git commit --amend --no-edit\` or a new commit).`
3771
3985
  )
3772
3986
  );
3773
3987
  return;
3774
3988
  }
3775
- console.info();
3776
- console.info(colors_default.green(`sync branch '${currentBranch}' is already committed.`));
3777
3989
  await shipSyncBranch(config, currentBranch);
3778
3990
  return;
3779
3991
  }
@@ -3786,8 +3998,10 @@ commit them (\`git commit --amend --no-edit\` or a new commit), then re-run \`pn
3786
3998
  }
3787
3999
  const { temporaryBranch } = outcome.branch;
3788
4000
  if (outcome.status === "conflicts") {
3789
- console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve them and then:`)}`);
4001
+ console.info(`${warningMark} ${colors_default.yellow(`conflicts on '${temporaryBranch}'. Resolve and stage them, then:`)}`);
3790
4002
  printFinishSteps();
4003
+ console.info(colors_default.dim(" rerun commits the sync, pushes the branch, and opens a PR."));
4004
+ console.info(colors_default.dim(" let the rerun commit \u2014 a manual `git commit` records a merge commit that bloats the PR."));
3791
4005
  }
3792
4006
  }
3793
4007
 
@@ -3877,7 +4091,7 @@ async function runForks(config) {
3877
4091
  console.error(colors_default.red(`fork '${config.fork}' not found in config`));
3878
4092
  return;
3879
4093
  }
3880
- const resolvedPath = resolve5(config.forkPath, match.localPath);
4094
+ const resolvedPath = resolve4(config.forkPath, match.localPath);
3881
4095
  await syncFork(config, match, resolvedPath);
3882
4096
  return;
3883
4097
  }
@@ -3890,9 +4104,9 @@ async function runForks(config) {
3890
4104
  loop: false
3891
4105
  });
3892
4106
  if (selectedPath === "_exit") {
3893
- process7.exit(0);
4107
+ process8.exit(0);
3894
4108
  }
3895
- const resolvedForkPath = resolve5(config.forkPath, selectedPath);
4109
+ const resolvedForkPath = resolve4(config.forkPath, selectedPath);
3896
4110
  const selectedFork = forks.find((f) => f.localPath === selectedPath);
3897
4111
  if (!selectedFork) {
3898
4112
  console.error(colors_default.red(`fork '${selectedPath}' not found in config`));
@@ -3905,10 +4119,10 @@ async function runForks(config) {
3905
4119
 
3906
4120
  // src/services/stats.ts
3907
4121
  import { readFile as readFile2 } from "fs/promises";
3908
- import { join as join15 } from "path";
4122
+ import { join as join16 } from "path";
3909
4123
 
3910
4124
  // src/utils/coverage-utils.ts
3911
- import { spawnSync as spawnSync5 } from "child_process";
4125
+ import { spawnSync as spawnSync3 } from "child_process";
3912
4126
  import * as fs3 from "fs";
3913
4127
  import * as path3 from "path";
3914
4128
  var STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1e3;
@@ -3931,7 +4145,7 @@ function formatAge(ms) {
3931
4145
  }
3932
4146
  function refreshCoverage(forkPath) {
3933
4147
  console.info(colors_default.dim("running `pnpm test` (this spins up Docker and runs the full suite)..."));
3934
- const result = spawnSync5("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
4148
+ const result = spawnSync3("pnpm", ["test"], { cwd: forkPath, stdio: "inherit" });
3935
4149
  return result.status === 0;
3936
4150
  }
3937
4151
  function printCoverageSummary(forkPath, options = {}) {
@@ -3993,7 +4207,7 @@ function printCoverageSummary(forkPath, options = {}) {
3993
4207
  // src/services/stats.ts
3994
4208
  var sourceExtensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "json", "jsonc", "css", "html"]);
3995
4209
  async function getWorkspacePackages(forkPath) {
3996
- const content = await readFile2(join15(forkPath, "pnpm-workspace.yaml"), "utf-8");
4210
+ const content = await readFile2(join16(forkPath, "pnpm-workspace.yaml"), "utf-8");
3997
4211
  return parseYamlBlockList(content, "packages").map((pattern) => {
3998
4212
  if (pattern.endsWith("/*")) {
3999
4213
  const base = pattern.slice(0, -2);
@@ -4148,27 +4362,27 @@ async function runStats(forkPath, options = {}) {
4148
4362
 
4149
4363
  // src/cella-cli.ts
4150
4364
  function getForkPath() {
4151
- const envPath = process8.env.CELLA_FORK_PATH;
4365
+ const envPath = getEnv("CELLA_FORK_PATH");
4152
4366
  if (envPath) {
4153
- const resolved = resolve6(envPath);
4154
- if (!existsSync11(resolved) || !statSync2(resolved).isDirectory()) {
4367
+ const resolved = resolve5(envPath);
4368
+ if (!existsSync10(resolved) || !statSync2(resolved).isDirectory()) {
4155
4369
  throw new Error(`CELLA_FORK_PATH is not a valid directory: ${resolved}`);
4156
4370
  }
4157
4371
  return resolved;
4158
4372
  }
4159
- let cwd = process8.cwd();
4373
+ let cwd = process9.cwd();
4160
4374
  if (cwd.endsWith("/cli/cella") || cwd.endsWith("\\cli\\cella")) {
4161
- cwd = resolve6(cwd, "../..");
4375
+ cwd = resolve5(cwd, "../..");
4162
4376
  }
4163
4377
  return cwd;
4164
4378
  }
4165
4379
  async function preflight(forkPath) {
4166
- if (!existsSync11(join16(forkPath, ".git"))) {
4380
+ if (!existsSync10(join17(forkPath, ".git"))) {
4167
4381
  throw new Error(`not a git repository: ${forkPath}`);
4168
4382
  }
4169
4383
  }
4170
4384
  function isHelpOrVersionRequest() {
4171
- return process8.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process8.argv[2] === "help";
4385
+ return process9.argv.slice(2).some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v") || process9.argv[2] === "help";
4172
4386
  }
4173
4387
  async function main() {
4174
4388
  registerSignalHandlers();
@@ -4209,7 +4423,7 @@ async function main() {
4209
4423
  } catch (error) {
4210
4424
  console.error();
4211
4425
  console.error(`${colors_default.red("\u2717")} ${error instanceof Error ? error.message : "unknown error"}`);
4212
- process8.exit(1);
4426
+ process9.exit(1);
4213
4427
  }
4214
4428
  }
4215
4429
  main();