@lenne.tech/cli 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -154,23 +154,46 @@ const DoctorCommand = {
154
154
  else if (gs.file && gs.hasDbReset) {
155
155
  line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
156
156
  }
157
- // 7.5 check.mjs drift: the root wrapper is canonical (bundled with the
157
+ // 7.5 check wrapper drift: the root wrapper is canonical (bundled with the
158
158
  // CLI, synced by `lt fullstack update`). A diverged copy silently
159
159
  // misses fixes (idle-watchdog, install hoisting, summed test
160
160
  // metrics) — surface it instead of letting copies drift apart.
161
+ //
162
+ // Checked over the WHOLE copy set, not just `check.mjs`: the wrapper
163
+ // imports siblings, so a missing or stale one makes `check` die with
164
+ // ERR_MODULE_NOT_FOUND (or an import mismatch) before running a single
165
+ // step. Reporting only on `check.mjs` meant doctor printed a green
166
+ // "matches the canonical CLI version" for a project whose `check` was
167
+ // completely broken — and doctor is the tool people reach for exactly
168
+ // then.
161
169
  try {
162
170
  const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
163
171
  const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
164
- const projectCheck = j(layout.root, 'scripts', 'check.mjs');
172
+ const { resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
165
173
  const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
166
- if (filesystem.exists(projectCheck) && filesystem.exists(bundledCheck)) {
167
- if (read(projectCheck, 'utf8') === read(bundledCheck, 'utf8')) {
168
- line('OK', colors.green, 'scripts/check.mjs matches the canonical CLI version');
174
+ if (filesystem.exists(bundledCheck)) {
175
+ const missing = [];
176
+ const drifted = [];
177
+ for (const { rel, source } of resolveCopySet(bundledCheck)) {
178
+ const target = j(layout.root, rel);
179
+ if (!filesystem.exists(target)) {
180
+ missing.push(rel);
181
+ }
182
+ else if (read(target, 'utf8') !== read(source, 'utf8')) {
183
+ drifted.push(rel);
184
+ }
169
185
  }
170
- else {
171
- line('WARN', colors.yellow, 'scripts/check.mjs differs from the canonical CLI version');
186
+ if (missing.length > 0) {
187
+ line('ERROR', colors.red, `check wrapper incomplete — missing ${missing.join(', ')}`);
188
+ line('ERROR', colors.red, ' `pnpm run check` cannot start; run `lt fullstack update` to install it');
189
+ }
190
+ else if (drifted.length > 0) {
191
+ line('WARN', colors.yellow, `${drifted.join(', ')} differs from the canonical CLI version`);
172
192
  line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
173
193
  }
194
+ else if (filesystem.exists(j(layout.root, 'scripts', 'check.mjs'))) {
195
+ line('OK', colors.green, 'check wrapper matches the canonical CLI version');
196
+ }
174
197
  }
175
198
  }
176
199
  catch (_a) {
@@ -330,7 +330,11 @@ const NewCommand = {
330
330
  // non-root level pnpm ignores), drop nested lockfiles the root supersedes,
331
331
  // and guarantee a root `.dockerignore` (Docker never reads a sub-project's
332
332
  // own when building from the root context).
333
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
333
+ // A disagreement between the two repos is not something the merge may decide
334
+ // silently. Reported here, and repeated in the closing block — this call site
335
+ // sits directly above the install output that would otherwise bury it.
336
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
337
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
334
338
  // Run install + format unless explicitly skipped (CI/agents may
335
339
  // want to chain multiple add-* calls before installing once).
336
340
  if (!skipInstall && !experimental) {
@@ -357,6 +361,7 @@ const NewCommand = {
357
361
  info('');
358
362
  success(`API integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
359
363
  info('');
364
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
360
365
  info('Next:');
361
366
  if (experimental) {
362
367
  info(` $ cd ${workspaceDir}/projects/api && bun install`);
@@ -259,7 +259,11 @@ const NewCommand = {
259
259
  // carry pnpm.overrides too), drop the frontend starter's standalone
260
260
  // pnpm-lock.yaml the root supersedes, and guarantee a root `.dockerignore`
261
261
  // (else the image inherits the host's node_modules, .output and .env).
262
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
262
+ // A disagreement between the two repos is not something the merge may decide
263
+ // silently. Reported here, and repeated in the closing block — this call site
264
+ // sits directly above the install output that would otherwise bury it.
265
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
266
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
263
267
  if (!skipInstall) {
264
268
  const installSpinner = spin('Install workspace packages');
265
269
  try {
@@ -278,6 +282,7 @@ const NewCommand = {
278
282
  info('');
279
283
  success(`App integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
280
284
  info('');
285
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
281
286
  info('Next:');
282
287
  info(` $ cd ${workspaceDir}`);
283
288
  info(` $ ${toolbox.pm.run('start')}`);
@@ -653,7 +653,12 @@ const NewCommand = {
653
653
  // guarantees a root `.dockerignore` (else the images carry the host's
654
654
  // node_modules, a stale .output, and a local .env that `nuxt build`
655
655
  // bakes in).
656
- (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
656
+ // Two sources setting the same pnpm key to different values is not a merge
657
+ // to resolve — it is a disagreement between two repos, and the merge would
658
+ // pick one silently. Say so here, where both halves are still visible, and
659
+ // again at the end (the install output below buries this one).
660
+ const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
661
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
657
662
  // Install all packages
658
663
  if (!experimental) {
659
664
  const installSpinner = spin('Install all packages');
@@ -734,6 +739,7 @@ const NewCommand = {
734
739
  info('');
735
740
  success(`Generated fullstack workspace with ${frontend} in ${projectDir} with ${name} app in ${helper.msToMinutesAndSeconds(timer())}m.`);
736
741
  info('');
742
+ (0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
737
743
  info('Next:');
738
744
  if (experimental) {
739
745
  info(` $ cd ${projectDir}`);
@@ -198,7 +198,20 @@ const NewCommand = {
198
198
  const changedCheck = (0, heal_check_wrapper_1.healCheckWrapper)(cwd, checkAsset);
199
199
  if (changedCheck.length > 0) {
200
200
  info('');
201
- success(` Installed/updated the check wrapper: ${changedCheck.join(', ')}`);
201
+ // A skip entry is NOT a success — it means the wrapper stayed on its old
202
+ // version. Reporting the whole list through `success()` painted a refusal
203
+ // green.
204
+ const skipped = changedCheck.filter((entry) => entry.includes('skipped'));
205
+ const applied = changedCheck.filter((entry) => !entry.includes('skipped'));
206
+ if (applied.length > 0) {
207
+ success(` Installed/updated the check wrapper: ${applied.join(', ')}`);
208
+ info(' `check` now serialises build/typecheck against the test suites,');
209
+ info(' so it takes longer in wall-clock but no longer destabilises API e2e runs.');
210
+ info(' The wrapper imports its siblings — keep them together, or `check` will not start.');
211
+ }
212
+ for (const entry of skipped) {
213
+ warning(` Check wrapper NOT updated: ${entry}`);
214
+ }
202
215
  }
203
216
  // ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
204
217
  //
@@ -209,6 +222,16 @@ const NewCommand = {
209
222
  info('');
210
223
  success(' Added `.lt-dev/` to .gitignore');
211
224
  }
225
+ // ── Self-heal: keep the check's isolated Nuxt build dir out of git ──────
226
+ //
227
+ // The check wrapper pins `NUXT_BUILD_DIR=.nuxt-check` so it never writes the
228
+ // `.nuxt/` a parked `nuxt dev` reads. Current starters already ignore it
229
+ // (via their `.nuxt-*` glob); projects scaffolded before that glob do not,
230
+ // and a build dir is a plausible place for a resolved runtimeConfig to be
231
+ // committed by accident. Idempotent.
232
+ if ((0, dev_patches_1.addToGitignore)(cwd, '.nuxt-check')) {
233
+ success(' Added `.nuxt-check` to .gitignore');
234
+ }
212
235
  // ── Self-heal: repair the vendor-mode migration store ──────────────────
213
236
  //
214
237
  // `migrations-utils/migrate.js` is written ONCE, at conversion time. Projects
@@ -1,5 +1,4 @@
1
1
  {
2
- "$schema": "./vendor-runtime-deps.schema.json",
3
- "description": "Upstream @lenne.tech/nest-server devDependencies that are actually needed at runtime in a consumer project. When vendoring, these entries are promoted from upstream devDependencies into the project's dependencies so production runtime has them available.",
4
- "runtimeHelpers": ["find-file-up"]
2
+ "$comment": "Upstream @lenne.tech/nest-server DEVDEPENDENCIES that must land in a vendored project's `dependencies`. Vendoring copies upstream `dependencies` verbatim and does not read `peerDependencies` at all, so a package the framework imports at runtime while keeping it in devDependencies would never reach the production runtime. Entries here are matched against upstream `devDependencies` only — see `isVendorRuntimeDep`, consulted in the `upstreamDevDeps` loop in `extensions/server.ts` — and promoted into `dependencies`, preferring the upstream peer RANGE over the exact devDep pin where one exists, so a generated project still receives patch-level security fixes. A package that appears ONLY in upstream `peerDependencies` cannot be promoted by this list: listing it here is a silent no-op. Two live cases: `find-file-up` (imported by the config loader, devDependency only), and the better-auth trio (declared as peerDependencies AND mirrored into devDependencies upstream; the mirror is what makes the promotion fire, so this list is the fallback if the starter ever stops declaring them itself).",
3
+ "runtimeHelpers": ["@better-auth/core", "@better-auth/passkey", "better-auth", "find-file-up"]
5
4
  }
@@ -951,6 +951,7 @@ class Server {
951
951
  */
952
952
  convertCloneToVendored(options) {
953
953
  return __awaiter(this, void 0, void 0, function* () {
954
+ var _a;
954
955
  const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nest-server.git' } = options;
955
956
  const filesystem = this.filesystem;
956
957
  const { system } = this.toolbox;
@@ -995,16 +996,22 @@ class Server {
995
996
  // transitive deps into the project's package.json (step 5 below).
996
997
  let upstreamDeps = {};
997
998
  let upstreamDevDeps = {};
999
+ let upstreamPeerDeps = {};
998
1000
  let upstreamVersion = '';
999
1001
  try {
1000
1002
  const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
1001
1003
  if (upstreamPkg && typeof upstreamPkg === 'object') {
1002
1004
  upstreamDeps = upstreamPkg.dependencies || {};
1003
1005
  upstreamDevDeps = upstreamPkg.devDependencies || {};
1006
+ // Read for the version RANGE only, never as a source of packages: the
1007
+ // promotion below still keys off devDependencies. A peer range is the
1008
+ // spec upstream actually supports, whereas the devDep entry beside it is
1009
+ // an exact pin that exists for the framework's own CI.
1010
+ upstreamPeerDeps = upstreamPkg.peerDependencies || {};
1004
1011
  upstreamVersion = upstreamPkg.version || '';
1005
1012
  }
1006
1013
  }
1007
- catch (_a) {
1014
+ catch (_b) {
1008
1015
  // Best-effort — if we can't read upstream pkg, the starter's own
1009
1016
  // deps should still cover most of the framework's needs.
1010
1017
  }
@@ -1018,7 +1025,7 @@ class Server {
1018
1025
  try {
1019
1026
  upstreamLockRaw = filesystem.read(`${tmpClone}/pnpm-lock.yaml`) || '';
1020
1027
  }
1021
- catch (_b) {
1028
+ catch (_c) {
1022
1029
  // Best-effort — without the lockfile, the closure step falls back to
1023
1030
  // the upstream package.json ranges (still better than nothing).
1024
1031
  }
@@ -1034,7 +1041,7 @@ class Server {
1034
1041
  try {
1035
1042
  upstreamWorkspaceYaml = filesystem.read(`${tmpClone}/pnpm-workspace.yaml`) || '';
1036
1043
  }
1037
- catch (_c) {
1044
+ catch (_d) {
1038
1045
  // Best-effort: an older framework revision may predate the file. The project
1039
1046
  // then keeps exactly the allowlist its own template shipped.
1040
1047
  }
@@ -1050,7 +1057,7 @@ class Server {
1050
1057
  upstreamClaudeMd = claudeMdContent;
1051
1058
  }
1052
1059
  }
1053
- catch (_d) {
1060
+ catch (_e) {
1054
1061
  // Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
1055
1062
  }
1056
1063
  // Snapshot the upstream commit SHA for traceability in VENDOR.md.
@@ -1059,7 +1066,7 @@ class Server {
1059
1066
  const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
1060
1067
  upstreamCommit = (sha || '').trim();
1061
1068
  }
1062
- catch (_e) {
1069
+ catch (_f) {
1063
1070
  // Non-fatal — VENDOR.md will just show an empty SHA.
1064
1071
  }
1065
1072
  try {
@@ -1342,7 +1349,7 @@ class Server {
1342
1349
  }
1343
1350
  }
1344
1351
  }
1345
- catch (_f) {
1352
+ catch (_g) {
1346
1353
  // skip unreadable file
1347
1354
  }
1348
1355
  }
@@ -1484,9 +1491,13 @@ class Server {
1484
1491
  pkg.devDependencies = {};
1485
1492
  const devDeps = pkg.devDependencies;
1486
1493
  for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
1487
- // Runtime-needed devDeps → promote to dependencies
1494
+ // Runtime-needed devDeps → promote to dependencies. Prefer the peer
1495
+ // RANGE when upstream declares one: the devDep entry beside it is an
1496
+ // exact pin for the framework's own CI, and baking that into a
1497
+ // generated project means `pnpm update` never delivers a patch-level
1498
+ // fix — the wrong default for an auth library like better-auth.
1488
1499
  if (this.isVendorRuntimeDep(depName) && !(depName in deps)) {
1489
- deps[depName] = depVersion;
1500
+ deps[depName] = (_a = upstreamPeerDeps[depName]) !== null && _a !== void 0 ? _a : depVersion;
1490
1501
  continue;
1491
1502
  }
1492
1503
  // @types/<pkg> where <pkg> is a runtime dep → accept as devDep
@@ -1,24 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.healCheckWrapper = healCheckWrapper;
4
+ exports.resolveCopySet = resolveCopySet;
4
5
  const child_process_1 = require("child_process");
5
6
  const fs_1 = require("fs");
6
7
  const path_1 = require("path");
7
8
  /** Marker value for the report-driven check wrapper. */
8
9
  const WRAPPER = 'node scripts/check.mjs';
10
+ /** Where the wrapper and its imports live inside a project. */
11
+ const SCRIPTS_DIR = 'scripts';
9
12
  /**
10
- * Idempotently install the report-driven `check.mjs` wrapper into a project.
13
+ * Idempotently install the report-driven check wrapper — and every module it
14
+ * imports — into a project.
11
15
  *
12
- * Copies the bundled canonical wrapper to `<root>/scripts/check.mjs` and rewrites
13
- * the root `package.json` so that `check` runs the wrapper while the original
14
- * chain is preserved as `check:raw`. A no-op once already wired (so it is safe to
15
- * run on every `lt fullstack update`).
16
+ * Copies the bundled wrapper to `<root>/scripts/check.mjs`, copies its whole
17
+ * relative-import closure alongside it under the names the wrapper imports them
18
+ * by, and rewrites the root `package.json` so that `check` runs the wrapper
19
+ * while the original chain is preserved as `check:raw`. A no-op once already
20
+ * wired (so it is safe to run on every `lt fullstack update`).
16
21
  *
17
22
  * `lt fullstack init` already ships the wrapper via the template clone; this is
18
23
  * the MIGRATION path that brings it into pre-existing projects.
19
24
  *
25
+ * The copy set moves ATOMICALLY: if any member must be skipped, none are
26
+ * written. A partial update would leave `check.mjs` and a sibling on different
27
+ * versions, and the project's `check` then dies on an import mismatch before
28
+ * running a single step.
29
+ *
20
30
  * @param projectRoot Absolute path to the (workspace) project root.
21
- * @param assetPath Absolute path to the bundled canonical `check.mjs`.
31
+ * @param assetPath Absolute path to the bundled wrapper. Its DIRECTORY is
32
+ * also probed: every module the wrapper imports relatively
33
+ * (transitively) is shipped from there. The asset always
34
+ * lands as `scripts/check.mjs` regardless of its own name.
22
35
  * @returns The list of changed file paths (relative to `projectRoot`); empty when nothing changed.
23
36
  */
24
37
  function healCheckWrapper(projectRoot, assetPath) {
@@ -39,22 +52,35 @@ function healCheckWrapper(projectRoot, assetPath) {
39
52
  if (!scripts || typeof scripts.check !== 'string') {
40
53
  return changed;
41
54
  }
42
- // 1. Ensure scripts/check.mjs exists and matches the bundled canonical version.
43
- // GUARD: never overwrite a check.mjs with UNCOMMITTED local modifications —
44
- // that would silently destroy work that exists nowhere else. A committed
45
- // divergence is overwritten (recoverable via `git diff`/history, and the
46
- // canonical wrapper is the supported version); an uncommitted one is kept and
47
- // reported via the 'scripts/check.mjs (skipped: uncommitted changes)' entry.
48
- const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
49
- const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
50
- if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
51
- if ((0, fs_1.existsSync)(targetScript) && hasUncommittedChanges(projectRoot, 'scripts/check.mjs')) {
52
- changed.push('scripts/check.mjs (skipped: uncommitted changes — commit or discard them, then re-run)');
53
- }
54
- else {
55
- (0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
56
- (0, fs_1.copyFileSync)(assetPath, targetScript);
57
- changed.push('scripts/check.mjs');
55
+ // 1. Ensure the wrapper — and everything it imports — exists in the project
56
+ // and matches the canonical version.
57
+ //
58
+ // The set is derived from the wrapper's own import statements rather than a
59
+ // hard-coded name: the wrapper grew a sibling (`build-test-gate.mjs`, which
60
+ // serialises the CPU-heavy build against the API e2e suite), and copying only
61
+ // `check.mjs` installs a file whose very first import resolves to nothing —
62
+ // the project's `check` then dies with ERR_MODULE_NOT_FOUND before running a
63
+ // single step. Deriving it from the imports (not from "every .mjs in the
64
+ // directory") keeps the next sibling free of changes here while making sure a
65
+ // stray file in the asset dir never claims a path in the project's scripts/.
66
+ const copies = resolveCopySet(assetPath);
67
+ // Decide EVERY member before writing ANY of them — see the atomicity note in
68
+ // the doc block above.
69
+ const plans = copies.map((copy) => planCopy(projectRoot, copy));
70
+ const blocked = plans.filter((p) => p.action === 'skip');
71
+ if (blocked.length > 0) {
72
+ // One entry for the whole set: the set is what could not be updated, and
73
+ // naming only the blocking member would suggest the others did land.
74
+ const names = blocked.map((p) => p.rel).join(', ');
75
+ changed.push(`${copies.map((c) => c.rel).join(' + ')} (skipped: uncommitted changes in ${names} — commit or discard them, then re-run)`);
76
+ }
77
+ else {
78
+ for (const plan of plans) {
79
+ if (plan.action === 'up-to-date') {
80
+ continue;
81
+ }
82
+ writeCopy(projectRoot, plan);
83
+ changed.push(plan.rel);
58
84
  }
59
85
  }
60
86
  // 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
@@ -69,11 +95,57 @@ function healCheckWrapper(projectRoot, assetPath) {
69
95
  return changed;
70
96
  }
71
97
  /**
72
- * True when `relPath` has UNCOMMITTED modifications in the project's git tree.
73
- * Overwriting such a file would destroy work that exists nowhere else — a
74
- * committed file is recoverable via git, an uncommitted edit is not. Non-git
75
- * projects (or git errors) return false: there the overwrite is the only way
76
- * to distribute fixes, and `git` cannot protect what it does not track.
98
+ * The wrapper plus the transitive closure of its relative imports.
99
+ *
100
+ * Matches both quote styles: the bundled `.mjs` files are formatted by the
101
+ * consuming project's formatter, not by this repo's, so their quote style is
102
+ * not ours to assume.
103
+ */
104
+ function resolveCopySet(assetPath) {
105
+ const assetDir = (0, path_1.dirname)(assetPath);
106
+ // Regular files only — a directory named `*.mjs` would otherwise reach
107
+ // copyFileSync and abort the whole migration with EISDIR.
108
+ const available = new Set((0, fs_1.readdirSync)(assetDir, { withFileTypes: true })
109
+ .filter((entry) => entry.isFile())
110
+ .map((entry) => entry.name));
111
+ const copies = [{ rel: `${SCRIPTS_DIR}/check.mjs`, source: assetPath }];
112
+ // Keyed by target rel, NOT by source basename: the asset lands as
113
+ // `scripts/check.mjs` whatever it is called, so a `check.mjs` sitting beside a
114
+ // differently-named asset must not claim that same path a second time.
115
+ const claimed = new Set(copies.map((c) => c.rel));
116
+ const queue = [assetPath];
117
+ const visited = new Set();
118
+ while (queue.length > 0) {
119
+ const file = queue.shift();
120
+ if (visited.has(file)) {
121
+ continue;
122
+ }
123
+ visited.add(file);
124
+ let source;
125
+ try {
126
+ source = (0, fs_1.readFileSync)(file, 'utf8');
127
+ }
128
+ catch (_a) {
129
+ continue;
130
+ }
131
+ for (const match of source.matchAll(/\bfrom\s+['"]\.\/([^'"/]+)['"]/g)) {
132
+ const name = match[1];
133
+ const rel = `${SCRIPTS_DIR}/${name}`;
134
+ if (claimed.has(rel) || !available.has(name)) {
135
+ continue;
136
+ }
137
+ claimed.add(rel);
138
+ const resolved = (0, path_1.join)(assetDir, name);
139
+ copies.push({ rel, source: resolved });
140
+ queue.push(resolved);
141
+ }
142
+ }
143
+ return copies;
144
+ }
145
+ /**
146
+ * True when the TRACKED `relPath` has uncommitted modifications. Overwriting
147
+ * such a file would destroy work that exists nowhere else. Only meaningful for
148
+ * a tracked path — see `isTracked`.
77
149
  */
78
150
  function hasUncommittedChanges(projectRoot, relPath) {
79
151
  try {
@@ -87,3 +159,93 @@ function hasUncommittedChanges(projectRoot, relPath) {
87
159
  return false;
88
160
  }
89
161
  }
162
+ /** True when `target` is a symlink (checked without following it). */
163
+ function isSymlink(target) {
164
+ try {
165
+ return (0, fs_1.lstatSync)(target).isSymbolicLink();
166
+ }
167
+ catch (_a) {
168
+ return false;
169
+ }
170
+ }
171
+ /**
172
+ * True when git tracks `relPath`, i.e. it holds a recoverable copy.
173
+ *
174
+ * An empty `git status --porcelain` alone does NOT establish that: it is also
175
+ * empty for an ignored file, and for a path in a directory git knows nothing
176
+ * about. Those are precisely the cases where an overwrite is unrecoverable.
177
+ */
178
+ function isTracked(projectRoot, relPath) {
179
+ try {
180
+ (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
181
+ stdio: 'ignore',
182
+ });
183
+ return true;
184
+ }
185
+ catch (_a) {
186
+ return false;
187
+ }
188
+ }
189
+ /** Decide what should happen to one copy target, without touching the disk. */
190
+ function planCopy(projectRoot, copy) {
191
+ const target = (0, path_1.join)(projectRoot, copy.rel);
192
+ const plan = Object.assign(Object.assign({}, copy), { action: 'write', backup: false });
193
+ if (!(0, fs_1.existsSync)(target)) {
194
+ return plan;
195
+ }
196
+ // A symlink here resolves OUTSIDE the project, and git reports the (unchanged)
197
+ // link blob as clean, so the guard below cannot see it. Writing would silently
198
+ // modify a file somewhere else entirely.
199
+ if (isSymlink(target)) {
200
+ plan.action = 'skip';
201
+ return plan;
202
+ }
203
+ if ((0, fs_1.readFileSync)(target, 'utf8') === (0, fs_1.readFileSync)(copy.source, 'utf8')) {
204
+ plan.action = 'up-to-date';
205
+ return plan;
206
+ }
207
+ // A TRACKED file whose working copy diverges carries edits that exist nowhere
208
+ // else — never overwrite it. A tracked-and-clean file is safe to replace
209
+ // (git can restore it). Anything git does not track is not recoverable at
210
+ // all, so it gets a `.bak` instead of a refusal: refusing would be the worse
211
+ // outcome, because the wrapper's OWN previous output is untracked until the
212
+ // user commits it, and a refusal there permanently blocks the update.
213
+ if (isTracked(projectRoot, copy.rel)) {
214
+ if (hasUncommittedChanges(projectRoot, copy.rel)) {
215
+ plan.action = 'skip';
216
+ }
217
+ return plan;
218
+ }
219
+ plan.backup = true;
220
+ return plan;
221
+ }
222
+ /** Write one planned copy, backing up an unversioned target first. */
223
+ function writeCopy(projectRoot, plan) {
224
+ const target = (0, path_1.join)(projectRoot, plan.rel);
225
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
226
+ if (plan.backup && (0, fs_1.existsSync)(target)) {
227
+ const backup = `${target}.bak`;
228
+ // Keep the FIRST backup — a later run must not overwrite the original with
229
+ // an already-generated copy.
230
+ if (!(0, fs_1.existsSync)(backup)) {
231
+ (0, fs_1.copyFileSync)(target, backup);
232
+ }
233
+ }
234
+ // temp + rename so an interrupted run can never leave a half-written wrapper.
235
+ const tmp = `${target}.lt-tmp-${process.pid}`;
236
+ try {
237
+ (0, fs_1.copyFileSync)(plan.source, tmp);
238
+ (0, fs_1.renameSync)(tmp, target);
239
+ }
240
+ catch (error) {
241
+ try {
242
+ if ((0, fs_1.existsSync)(tmp)) {
243
+ (0, fs_1.unlinkSync)(tmp);
244
+ }
245
+ }
246
+ catch (_a) {
247
+ /* best effort */
248
+ }
249
+ throw error;
250
+ }
251
+ }