@lenne.tech/cli 1.36.0 → 1.37.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.
@@ -5,13 +5,19 @@ exports.clearTicketMarker = clearTicketMarker;
5
5
  exports.defaultTicketBranch = defaultTicketBranch;
6
6
  exports.deriveTicketId = deriveTicketId;
7
7
  exports.dropDatabase = dropDatabase;
8
+ exports.dropDatabases = dropDatabases;
8
9
  exports.gitBranchExists = gitBranchExists;
9
10
  exports.gitFetch = gitFetch;
10
11
  exports.gitMainRepoRoot = gitMainRepoRoot;
11
12
  exports.gitRefExists = gitRefExists;
12
13
  exports.installWorktreeDeps = installWorktreeDeps;
14
+ exports.isReservedTicketId = isReservedTicketId;
15
+ exports.isTicketScopedDb = isTicketScopedDb;
16
+ exports.keepDbFlag = keepDbFlag;
13
17
  exports.listBaseRefChoices = listBaseRefChoices;
18
+ exports.listDatabaseNames = listDatabaseNames;
14
19
  exports.listWorktrees = listWorktrees;
20
+ exports.planTicketDbDrop = planTicketDbDrop;
15
21
  exports.readTicketMarker = readTicketMarker;
16
22
  exports.resolveBaseRef = resolveBaseRef;
17
23
  exports.resolveDevIdentity = resolveDevIdentity;
@@ -53,6 +59,23 @@ const dev_project_1 = require("./dev-project");
53
59
  const dev_state_1 = require("./dev-state");
54
60
  /** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
55
61
  const TICKET_MARKER = 'ticket';
62
+ /** Where ticket databases live. Deliberately loopback-only — never a remote/prod host. */
63
+ const MONGO_BASE_URI = 'mongodb://127.0.0.1:27017';
64
+ /** Upper bound for a single `mongosh` call, so an unreachable Mongo cannot hang a teardown. */
65
+ const MONGOSH_TIMEOUT_MS = 10000;
66
+ /**
67
+ * Ticket ids that would make the ticket's derived database collide with a
68
+ * PROJECT-level database.
69
+ *
70
+ * {@link deriveTicketDbName} and {@link deriveTestDbName} both strip a trailing
71
+ * `-(local|dev)` before appending their own suffix, so these ids round-trip onto
72
+ * the project's own DBs — e.g. project db `imo-local` + ticket id `local` derives
73
+ * back to `imo-local`, and its test db to `imo-test`. Both are the DEVELOPER's
74
+ * databases, not the ticket's. Rejecting the ids at creation keeps the collision
75
+ * from ever existing; {@link isTicketScopedDb} is the second line of defence for
76
+ * environments created before this guard.
77
+ */
78
+ const RESERVED_TICKET_IDS = new Set(['ci', 'dev', 'e2e', 'local', 'prod', 'production', 'staging', 'test']);
56
79
  /**
57
80
  * Check whether a project's Playwright `global-setup` (if it wipes a DB) would
58
81
  * ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
@@ -81,6 +104,11 @@ function checkGlobalSetupTicketSafe(layout) {
81
104
  catch (_c) {
82
105
  return { file, hasDbReset: false, ticketSafe: true };
83
106
  }
107
+ // The API-style per-run scheme (db-lifecycle reporter) manages its own
108
+ // cleanup + naming — nothing for this Playwright-oriented check to judge.
109
+ if (content.includes('db-lifecycle.reporter')) {
110
+ return { file, hasDbReset: true, ticketSafe: true };
111
+ }
84
112
  const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
85
113
  if (!hasDbReset)
86
114
  return { file, hasDbReset: false, ticketSafe: true };
@@ -151,18 +179,64 @@ function deriveTicketId(name, asOverride) {
151
179
  return ticketMatch[1];
152
180
  return (0, dev_identity_1.slugify)(trimmed);
153
181
  }
154
- /** Drop a MongoDB database (best-effort, via `mongosh`). Returns true on success. */
155
- function dropDatabase(dbName, mongoBaseUri = 'mongodb://127.0.0.1:27017') {
182
+ /**
183
+ * Drop a MongoDB database via `mongosh`.
184
+ *
185
+ * SHAPE IS DELIBERATE — do NOT "optimise" it into a single multi-DB `--eval`:
186
+ * the database name travels in the URI PATH (percent-encoded, so it cannot
187
+ * escape into the host/query and hijack the connection), and `--eval` is a
188
+ * CONSTANT string, so the name is never interpolated into JavaScript. Together
189
+ * with `execFileSync`'s argv form (no shell), that is what makes an arbitrary
190
+ * db name un-injectable. Batching the drops would require building the eval
191
+ * from the names — trading that property away to save one process spawn.
192
+ *
193
+ * Returns WHY it failed, not just that it did: a missing `mongosh` binary and an
194
+ * unreachable Mongo need completely different fixes from the user, and collapsing
195
+ * both into `false` is how a "cleanup" feature ends up silently cleaning nothing.
196
+ */
197
+ function dropDatabase(dbName, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
156
198
  try {
157
- (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'], {
158
- stdio: 'ignore',
199
+ (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'],
200
+ // Without a timeout this blocks for as long as mongosh feels like: a Mongo that
201
+ // accepts TCP but never answers leaves the driver's 30s server-selection default
202
+ // to expire — silently, since stdio is ignored. On a default teardown path that
203
+ // is unacceptable, so we impose our own bound.
204
+ { killSignal: 'SIGKILL', stdio: 'ignore', timeout: MONGOSH_TIMEOUT_MS });
205
+ return 'dropped';
206
+ }
207
+ catch (e) {
208
+ if (e.code !== 'ENOENT')
209
+ return 'unreachable';
210
+ // mongosh is not installed — a REAL machine state that silently disabled every DB
211
+ // drop for months (ticket DBs piled up because `lt ticket stop` only warned).
212
+ // Fall back to the PROJECT's own `mongodb` driver when the caller provides
213
+ // resolution paths (the API project always depends on it via nest-server).
214
+ const res = runWithProjectDriver(driverPaths, 'await c.db(process.env.LT_MONGO_DB).dropDatabase();', {
215
+ LT_MONGO_DB: dbName,
216
+ LT_MONGO_URI: mongoBaseUri,
159
217
  });
160
- return true;
161
- }
162
- catch (_a) {
163
- return false;
218
+ if (res.outcome === 'no-driver')
219
+ return 'no-mongosh';
220
+ return res.outcome === 'ok' ? 'dropped' : 'unreachable';
164
221
  }
165
222
  }
223
+ /**
224
+ * Drop several databases, stopping at the first failure.
225
+ *
226
+ * A failure is never per-database: if `mongosh` is missing it is missing for all of
227
+ * them, and if Mongo is unreachable it is unreachable for all of them. Retrying each
228
+ * name would just multiply the timeout (2 names × 10s of hanging, for one diagnosis).
229
+ */
230
+ function dropDatabases(dbNames, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
231
+ const dropped = [];
232
+ for (const db of dbNames) {
233
+ const outcome = dropDatabase(db, mongoBaseUri, driverPaths);
234
+ if (outcome !== 'dropped')
235
+ return { dropped, reason: outcome };
236
+ dropped.push(db);
237
+ }
238
+ return { dropped, reason: null };
239
+ }
166
240
  /** True if a local branch with this name already exists. */
167
241
  function gitBranchExists(repoDir, branch) {
168
242
  try {
@@ -191,7 +265,9 @@ function gitMainRepoRoot(cwd) {
191
265
  /** True if `ref` resolves to a commit in the repo (any ref kind: local, remote, tag, sha). */
192
266
  function gitRefExists(repoDir, ref) {
193
267
  try {
194
- (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { stdio: 'ignore' });
268
+ (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
269
+ stdio: 'ignore',
270
+ });
195
271
  return true;
196
272
  }
197
273
  catch (_a) {
@@ -208,6 +284,73 @@ function installWorktreeDeps(dir) {
208
284
  const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
209
285
  (0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
210
286
  }
287
+ /**
288
+ * True for ticket ids whose derived database would collide with a project-level
289
+ * database (see {@link RESERVED_TICKET_IDS}). `lt ticket start` refuses them, so
290
+ * the collision cannot be created in the first place.
291
+ */
292
+ function isReservedTicketId(id) {
293
+ return RESERVED_TICKET_IDS.has(id.trim().toLowerCase());
294
+ }
295
+ /**
296
+ * True only when `dbName` provably belongs to ticket `ticketId` of the project whose
297
+ * own dev database is `projectDevDb`.
298
+ *
299
+ * This is the LAST gate before an irreversible drop, and it exists because the name
300
+ * being dropped is *derived* (or read from a slug-keyed global registry) rather than
301
+ * observed. Two things can therefore steer it at the wrong database:
302
+ *
303
+ * • a reserved ticket id (`local`/`dev`/`test`) derives back onto the project's own
304
+ * dev/test DB — the suffix check alone would happily accept that, because the name
305
+ * really does look ticket-shaped. Hence the explicit project-DB exclusion FIRST.
306
+ * • a registry entry under `<slug>-<id>` that in truth belongs to a different
307
+ * checkout (the registry is global and keyed by slug alone) carries THAT project's
308
+ * dbName — which will not match this ticket's shape and is rejected here.
309
+ *
310
+ * A derivation that drifts must fail closed: refuse, never guess.
311
+ */
312
+ function isTicketScopedDb(dbName, ticketId, projectDevDb) {
313
+ if (dbName === projectDevDb || dbName === (0, dev_project_1.deriveTestDbName)(projectDevDb))
314
+ return false;
315
+ const base = projectDevDb.replace(/-(local|dev)$/i, '');
316
+ if (dbName === `${base}-${ticketId}` || dbName === `${base}-${ticketId}-test`)
317
+ return true;
318
+ // Sharded Playwright stacks (`lt dev test --shard N`) derive one DB per shard:
319
+ // `<base>-<id>-test-<n>`. Without this arm they were invisible to the drop plan
320
+ // and orphaned on every sharded ticket test run.
321
+ return new RegExp(`^${escapeRegExpForDb(`${base}-${ticketId}-test-`)}\\d+$`).test(dbName);
322
+ }
323
+ /**
324
+ * Read the `--keep-db` opt-out. FAIL-CLOSED BY CONSTRUCTION.
325
+ *
326
+ * gluegun parses argv with yargs-parser and declares NO booleans, so the flag does not
327
+ * arrive as `true` in most of the spellings people actually type:
328
+ *
329
+ * --keep-db → true (boolean)
330
+ * --keep-db=true → 'true' (STRING)
331
+ * --keep-db true → 'true' (STRING)
332
+ * --keep-db 2200 → 2200 (NUMBER — and the ticket id is GONE from positionals)
333
+ * --no-keep-db → false (boolean)
334
+ *
335
+ * A strict `=== true` test reads three of those as "the user did not ask to keep" and
336
+ * destroys the very data they asked to keep. That shape is right for `--force`, where a
337
+ * parse quirk means "don't force" (safe); it is exactly backwards for a flag that
338
+ * PREVENTS destruction.
339
+ *
340
+ * So: the flag's PRESENCE means keep. Only an explicit negation still drops.
341
+ */
342
+ function keepDbFlag(options = {}) {
343
+ var _a;
344
+ const raw = (_a = options.keepDb) !== null && _a !== void 0 ? _a : options['keep-db'];
345
+ if (raw === undefined || raw === null)
346
+ return { keep: false, strayValue: null };
347
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0')
348
+ return { keep: false, strayValue: null };
349
+ // The flag takes no value, so anything that is not an affirmation is a positional
350
+ // yargs-parser swallowed. Hand it back rather than silently losing it.
351
+ const affirmative = raw === true || ['1', 'true', 'yes'].includes(String(raw).toLowerCase());
352
+ return { keep: true, strayValue: affirmative ? null : String(raw) };
353
+ }
211
354
  /**
212
355
  * Branches offered when the user has to pick a base ref interactively (no
213
356
  * candidate matched). Remote + local branches, most recently committed first,
@@ -233,6 +376,42 @@ function listBaseRefChoices(repoDir, limit = 25) {
233
376
  .filter((l) => l && !l.endsWith('/HEAD'));
234
377
  return [...new Set(refs)].slice(0, limit);
235
378
  }
379
+ /**
380
+ * List all database names on the local Mongo, or null when that is impossible
381
+ * (mongosh missing / Mongo unreachable).
382
+ *
383
+ * Used to OBSERVE what actually exists instead of deriving it: sharded test DBs
384
+ * (`<base>-<id>-test-<n>`) have an unbounded index, so no static candidate list
385
+ * can cover them. Callers must still gate every observed name through
386
+ * {@link isTicketScopedDb} before dropping — observation widens the candidate
387
+ * set, never the safety rules.
388
+ */
389
+ function listDatabaseNames(mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
390
+ try {
391
+ const out = (0, child_process_1.execFileSync)('mongosh', [
392
+ `${mongoBaseUri}/admin`,
393
+ '--quiet',
394
+ '--eval',
395
+ 'db.adminCommand({ listDatabases: 1, nameOnly: true }).databases.forEach(d => print(d.name))',
396
+ ], { killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'], timeout: MONGOSH_TIMEOUT_MS })
397
+ .toString()
398
+ .split('\n')
399
+ .map((line) => line.trim())
400
+ .filter(Boolean);
401
+ return out;
402
+ }
403
+ catch (_a) {
404
+ // Fall back to the project's own `mongodb` driver (see dropDatabase).
405
+ const res = runWithProjectDriver(driverPaths, "const { databases } = await c.db('admin').admin().listDatabases({ nameOnly: true });" +
406
+ ' databases.forEach((d) => console.log(d.name));', { LT_MONGO_URI: mongoBaseUri });
407
+ if (res.outcome !== 'ok')
408
+ return null;
409
+ return res.stdout
410
+ .split('\n')
411
+ .map((line) => line.trim())
412
+ .filter(Boolean);
413
+ }
414
+ }
236
415
  /** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
237
416
  function listWorktrees(repoDir) {
238
417
  let out = '';
@@ -258,6 +437,35 @@ function listWorktrees(repoDir) {
258
437
  result.push(finalizeWorktree(current));
259
438
  return result;
260
439
  }
440
+ /**
441
+ * Decide which databases `lt ticket stop` may drop — pure, so the decision guarding an
442
+ * irreversible action is unit-testable instead of buried in a gluegun `run()` closure.
443
+ *
444
+ * The name to drop is never observed; it is DERIVED, or read from the global registry.
445
+ * Both sources can point at a database the ticket never owned, so every candidate is
446
+ * validated against the ticket's shape and anything that does not match is refused:
447
+ *
448
+ * • App-only project → no MongoDB exists at all → nothing to drop.
449
+ * • Foreign registry → the registry is keyed by slug alone, so `<slug>-<id>` can be
450
+ * a genuinely different project (`myapp` + ticket `admin` vs. a
451
+ * real `myapp-admin`). Its `dbName` is that project's — ignore it.
452
+ * • Reserved ticket id → `local`/`dev`/`test` derive back onto the PROJECT's own dev or
453
+ * test DB. Refused by {@link isTicketScopedDb}.
454
+ */
455
+ function planTicketDbDrop(args) {
456
+ var _a, _b;
457
+ const { hasApi, observedDbNames, projectDevDb, registryEntry, ticketId, worktreePath } = args;
458
+ if (!hasApi)
459
+ return { foreignEntryPath: null, refused: [], targets: [] };
460
+ const trusted = (registryEntry === null || registryEntry === void 0 ? void 0 : registryEntry.path) && (0, dev_state_1.sameRealPath)(registryEntry.path, worktreePath) ? registryEntry : undefined;
461
+ const foreignEntryPath = registryEntry && !trusted ? ((_a = registryEntry.path) !== null && _a !== void 0 ? _a : null) : null;
462
+ const devDb = (_b = trusted === null || trusted === void 0 ? void 0 : trusted.dbName) !== null && _b !== void 0 ? _b : (0, dev_project_1.deriveTicketDbName)(projectDevDb, ticketId);
463
+ const shardCandidates = (observedDbNames !== null && observedDbNames !== void 0 ? observedDbNames : []).filter((name) => name.startsWith(`${devDb}-test-`));
464
+ // eslint-disable-next-line perfectionist/sort-sets -- dev DB first is the meaningful (and drop) order
465
+ const candidates = [...new Set([devDb, (0, dev_project_1.deriveTestDbName)(devDb), ...shardCandidates])];
466
+ const targets = candidates.filter((db) => isTicketScopedDb(db, ticketId, projectDevDb));
467
+ return { foreignEntryPath, refused: candidates.filter((db) => !targets.includes(db)), targets };
468
+ }
261
469
  /** Read the ticket id this worktree is tagged with, or null. */
262
470
  function readTicketMarker(root) {
263
471
  const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
@@ -289,10 +497,18 @@ function resolveBaseRef(repoDir, explicit) {
289
497
  var _a;
290
498
  const wanted = explicit === null || explicit === void 0 ? void 0 : explicit.trim();
291
499
  if (wanted) {
292
- return { candidates: [wanted], explicit: true, ref: gitRefExists(repoDir, wanted) ? wanted : null };
500
+ return {
501
+ candidates: [wanted],
502
+ explicit: true,
503
+ ref: gitRefExists(repoDir, wanted) ? wanted : null,
504
+ };
293
505
  }
294
506
  const candidates = baseRefCandidates(repoDir);
295
- return { candidates, explicit: false, ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null };
507
+ return {
508
+ candidates,
509
+ explicit: false,
510
+ ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null,
511
+ };
296
512
  }
297
513
  /**
298
514
  * Resolve the dev identity + DB name for a project root, ticket-aware.
@@ -330,6 +546,47 @@ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
330
546
  git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
331
547
  }
332
548
  }
549
+ /** Escape a literal database-name fragment for use inside a RegExp. */
550
+ function escapeRegExpForDb(value) {
551
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
552
+ }
553
+ /**
554
+ * Run a CONSTANT driver snippet via `node -e` using the project's own `mongodb`
555
+ * package (nest-server APIs always depend on it). SAME injection-safety doctrine
556
+ * as the mongosh path: the eval string is constant, every variable travels via
557
+ * env — never interpolated into code. `action` receives the connected client as
558
+ * `c` and must be a constant string from THIS module, never caller input.
559
+ */
560
+ function runWithProjectDriver(driverPaths, action, env) {
561
+ let driver = null;
562
+ for (const dir of driverPaths.filter(Boolean)) {
563
+ try {
564
+ driver = require.resolve('mongodb', { paths: [dir] });
565
+ break;
566
+ }
567
+ catch (_a) {
568
+ /* try next path */
569
+ }
570
+ }
571
+ if (!driver)
572
+ return { outcome: 'no-driver', stdout: '' };
573
+ const script = 'const { MongoClient } = require(process.env.LT_MONGO_DRIVER);' +
574
+ 'MongoClient.connect(process.env.LT_MONGO_URI, { serverSelectionTimeoutMS: 8000 })' +
575
+ `.then(async (c) => { ${action} await c.close(); process.exit(0); })` +
576
+ '.catch(() => process.exit(2));';
577
+ try {
578
+ const stdout = (0, child_process_1.execFileSync)(process.execPath, ['-e', script], {
579
+ env: Object.assign(Object.assign(Object.assign({}, process.env), env), { LT_MONGO_DRIVER: driver }),
580
+ killSignal: 'SIGKILL',
581
+ stdio: ['ignore', 'pipe', 'ignore'],
582
+ timeout: MONGOSH_TIMEOUT_MS,
583
+ }).toString();
584
+ return { outcome: 'ok', stdout };
585
+ }
586
+ catch (_b) {
587
+ return { outcome: 'failed', stdout: '' };
588
+ }
589
+ }
333
590
  /** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
334
591
  const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
335
592
  /** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
@@ -385,7 +642,13 @@ function worktreeSafetyReport(worktreePath) {
385
642
  const { realDirty } = classifyWorktreeDirt(worktreePath);
386
643
  let unpushed = 0;
387
644
  try {
388
- unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
645
+ // Only meaningful when a remote EXISTS: in a local-only repo (e.g. a fresh
646
+ // `lt fullstack init` without --git-link) "push first" is impossible, and the
647
+ // branch survives worktree removal anyway — counting every commit as
648
+ // "unpushed" would turn the gate into a permanent dead end.
649
+ if (git(worktreePath, ['remote'])) {
650
+ unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
651
+ }
389
652
  }
390
653
  catch (_a) {
391
654
  /* no remotes / detached HEAD → cannot determine; treat as 0 */
@@ -429,10 +692,14 @@ function classifyWorktreeDirt(worktreePath) {
429
692
  const realDirty = [];
430
693
  for (const line of gitStatusPorcelain(worktreePath)) {
431
694
  const p = porcelainPath(line);
432
- if (GENERATED_PATHS.test(p) || isPristineLtDevPatch(worktreePath, p))
695
+ if (GENERATED_PATHS.test(p) ||
696
+ isPristineLtDevPatch(worktreePath, p) ||
697
+ isPristineLtDevGitignoreAppend(worktreePath, p)) {
433
698
  autoDiscardable.push(p);
434
- else
699
+ }
700
+ else {
435
701
  realDirty.push(p);
702
+ }
436
703
  }
437
704
  return { autoDiscardable, realDirty };
438
705
  }
@@ -473,13 +740,53 @@ function gitRemoteHead(repoDir, remote = 'origin') {
473
740
  function gitStatusPorcelain(cwd) {
474
741
  let out = '';
475
742
  try {
476
- out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' });
743
+ out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], {
744
+ encoding: 'utf8',
745
+ });
477
746
  }
478
747
  catch (_a) {
479
748
  return [];
480
749
  }
481
750
  return out.split(/\r?\n/).filter((l) => l.trim() !== '');
482
751
  }
752
+ /**
753
+ * True when the dirty `.gitignore` differs from HEAD by EXACTLY the `.lt-dev/`
754
+ * line that `lt dev up` appends on every start (see `addToGitignore`). Older
755
+ * templates ship without that line, so EVERY ticket worktree's first `up` dirties
756
+ * `.gitignore` — and without this check every `lt ticket stop` then refuses over
757
+ * a machine-made change. Any other edit (removed lines, additional added lines)
758
+ * keeps it real work.
759
+ */
760
+ function isPristineLtDevGitignoreAppend(worktreePath, relPath) {
761
+ if (relPath !== '.gitignore')
762
+ return false;
763
+ let head = '';
764
+ try {
765
+ head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', 'HEAD:.gitignore'], {
766
+ encoding: 'utf8',
767
+ stdio: ['ignore', 'pipe', 'ignore'],
768
+ });
769
+ }
770
+ catch (_a) {
771
+ return false; // not tracked in HEAD → a NEW .gitignore is real work
772
+ }
773
+ let work = '';
774
+ try {
775
+ work = (0, fs_1.readFileSync)((0, path_1.join)(worktreePath, '.gitignore'), 'utf8');
776
+ }
777
+ catch (_b) {
778
+ return false; // deleted → real change
779
+ }
780
+ const norm = (s) => s
781
+ .split(/\r?\n/)
782
+ .map((l) => l.trim())
783
+ .filter(Boolean);
784
+ const headLines = norm(head);
785
+ const workLines = norm(work);
786
+ const removed = headLines.filter((l) => !workLines.includes(l));
787
+ const added = workLines.filter((l) => !headLines.includes(l));
788
+ return removed.length === 0 && added.length > 0 && added.every((l) => l === '.lt-dev/');
789
+ }
483
790
  /**
484
791
  * True when the dirty tracked file at `relPath` (relative to the worktree root)
485
792
  * differs from its committed (HEAD) version by EXACTLY the lt-dev self-heal
@@ -497,7 +804,9 @@ function isPristineLtDevPatch(worktreePath, relPath) {
497
804
  try {
498
805
  // NOT the trimming `git()` helper — the trailing newline must survive so the
499
806
  // comparison against the (untrimmed) working-tree content is exact.
500
- head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], { encoding: 'utf8' });
807
+ head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], {
808
+ encoding: 'utf8',
809
+ });
501
810
  }
502
811
  catch (_a) {
503
812
  return false; // not tracked at HEAD (e.g. a brand-new file) → never auto-discard
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.healCheckWrapper = healCheckWrapper;
4
+ const child_process_1 = require("child_process");
4
5
  const fs_1 = require("fs");
5
6
  const path_1 = require("path");
6
7
  /** Marker value for the report-driven check wrapper. */
@@ -39,12 +40,22 @@ function healCheckWrapper(projectRoot, assetPath) {
39
40
  return changed;
40
41
  }
41
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.
42
48
  const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
43
49
  const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
44
50
  if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
45
- (0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
46
- (0, fs_1.copyFileSync)(assetPath, targetScript);
47
- changed.push('scripts/check.mjs');
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');
58
+ }
48
59
  }
49
60
  // 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
50
61
  if (scripts.check !== WRAPPER) {
@@ -57,3 +68,22 @@ function healCheckWrapper(projectRoot, assetPath) {
57
68
  }
58
69
  return changed;
59
70
  }
71
+ /**
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.
77
+ */
78
+ function hasUncommittedChanges(projectRoot, relPath) {
79
+ try {
80
+ const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'status', '--porcelain', '--', relPath], {
81
+ encoding: 'utf8',
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ });
84
+ return out.trim().length > 0;
85
+ }
86
+ catch (_a) {
87
+ return false;
88
+ }
89
+ }
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hoistPackageManager = hoistPackageManager;
3
4
  exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
4
5
  const js_yaml_1 = require("js-yaml");
5
6
  const fs_utils_1 = require("./fs-utils");
@@ -26,14 +27,92 @@ const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
26
27
  const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
27
28
  const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
28
29
  const isArrayField = (field) => ARRAY_FIELDS.includes(field);
30
+ /**
31
+ * Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
32
+ * root `package.json`, keeping the highest version and stripping the pin from
33
+ * every sub-project.
34
+ *
35
+ * Unlike the fields above this is a TOP-LEVEL package.json field (not part of the
36
+ * `pnpm` block), and its destination is the root `package.json` — not
37
+ * `pnpm-workspace.yaml` — because Corepack, not pnpm, reads it. Hence its own pass.
38
+ *
39
+ * Why it must not stay in a sub-project: inside a workspace only the ROOT pin governs
40
+ * `pnpm install`. A pin left in `projects/app` is worse than inert — Corepack resolves
41
+ * the NEAREST package.json, so `cd projects/app && pnpm run build` (exactly what
42
+ * projects/app/Dockerfile does) provisions the sub-project's pnpm while the root
43
+ * install ran on another version. One build, two pnpm versions.
44
+ *
45
+ * Why the root needs a pin at all: without `packageManager`, Corepack silently
46
+ * downloads the LATEST pnpm from the registry (verified with an isolated cache, i.e.
47
+ * a fresh container). Together with the root `engines.pnpm: "^11.0.0"` shipped by
48
+ * lt-monorepo, that breaks the day pnpm 12 is released — pnpm enforces `engines.pnpm`
49
+ * hard (`ERR_PNPM_UNSUPPORTED_ENGINE`), so the Docker build dies without a single
50
+ * repo change. The starters carry an exact pin incl. integrity hash
51
+ * (`pnpm@11.13.1+sha512.…`, maintained via `corepack up`); hoisting it preserves both
52
+ * the determinism and the supply-chain check.
53
+ *
54
+ * Mixed package managers (e.g. api pinning yarn, app pinning pnpm) are left untouched
55
+ * rather than silently picking a winner — that is a template bug, not something to
56
+ * paper over.
57
+ *
58
+ * Idempotent: running twice has the same effect as running once.
59
+ *
60
+ * @param options.filesystem Gluegun filesystem tool
61
+ * @param options.projectDir Workspace root (contains the root package.json)
62
+ * @param options.subProjects Sub-project dirs relative to projectDir
63
+ */
64
+ function hoistPackageManager(options) {
65
+ const { filesystem, projectDir, subProjects } = options;
66
+ const rootPkgPath = `${projectDir}/package.json`;
67
+ const rootPkg = filesystem.exists(rootPkgPath) ? filesystem.read(rootPkgPath, 'json') : null;
68
+ if (!rootPkg)
69
+ return;
70
+ const candidates = [];
71
+ const strippedSubs = [];
72
+ for (const subDir of subProjects) {
73
+ const subPath = `${projectDir}/${subDir}`;
74
+ if (!filesystem.exists(subPath))
75
+ continue;
76
+ // Never mutate a symlinked sub-project — it points at the user's own checkout.
77
+ if ((0, fs_utils_1.isSymlink)(subPath))
78
+ continue;
79
+ const subPkgPath = `${subPath}/package.json`;
80
+ if (!filesystem.exists(subPkgPath))
81
+ continue;
82
+ const subPkg = filesystem.read(subPkgPath, 'json');
83
+ if (typeof (subPkg === null || subPkg === void 0 ? void 0 : subPkg.packageManager) !== 'string')
84
+ continue;
85
+ candidates.push(subPkg.packageManager);
86
+ strippedSubs.push({ path: subPkgPath, pkg: subPkg });
87
+ }
88
+ if (candidates.length === 0)
89
+ return;
90
+ const rootPin = typeof rootPkg.packageManager === 'string' ? rootPkg.packageManager : undefined;
91
+ const all = rootPin ? [rootPin, ...candidates] : candidates;
92
+ // Bail out on mixed managers instead of guessing which one is authoritative.
93
+ const names = new Set(all.map(pmName));
94
+ if (names.size > 1)
95
+ return;
96
+ const winner = all.reduce((best, pin) => (comparePmVersions(pin, best) > 0 ? pin : best));
97
+ if (rootPin !== winner) {
98
+ rootPkg.packageManager = winner;
99
+ filesystem.write(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
100
+ }
101
+ for (const { path, pkg } of strippedSubs) {
102
+ delete pkg.packageManager;
103
+ filesystem.write(path, `${JSON.stringify(pkg, null, 2)}\n`);
104
+ }
105
+ }
29
106
  /**
30
107
  * Hoist workspace-scoped pnpm config from sub-projects into the monorepo
31
108
  * root `pnpm-workspace.yaml`. After this runs, sub-project pnpm config
32
109
  * (package.json#pnpm or a settings-only pnpm-workspace.yaml) is gone, and
33
110
  * the root pnpm-workspace.yaml carries the merged union next to `packages:`.
34
111
  *
35
- * Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo root
36
- * pins pnpm 11 (via `packageManager`), and pnpm 11 SILENTLY IGNORES the
112
+ * Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo runs
113
+ * pnpm 11 (lt-monorepo ships `engines.pnpm: "^11.0.0"`; the exact version
114
+ * comes from the `packageManager` pin that `hoistPackageManager` lifts to
115
+ * the root), and pnpm 11 SILENTLY IGNORES the
37
116
  * `pnpm` block in package.json — overrides/build-allowlists/etc. declared
38
117
  * there never take effect, regressing `pnpm audit` and the minimum-release
39
118
  * -age exemptions. pnpm-workspace.yaml is the pnpm-recommended home and is
@@ -93,6 +172,26 @@ function hoistWorkspacePnpmConfig(options) {
93
172
  filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
94
173
  }
95
174
  }
175
+ /**
176
+ * Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
177
+ * Returns >0 if `a` is newer, <0 if older, 0 if equal. Numeric segment-wise
178
+ * comparison; the integrity hash and any pre-release suffix are ignored, which is
179
+ * enough for the exact pins Corepack writes (no ranges are legal here).
180
+ */
181
+ function comparePmVersions(a, b) {
182
+ var _a, _b;
183
+ const segments = (pin) => pmVersion(pin)
184
+ .split('.')
185
+ .map((s) => Number.parseInt(s, 10) || 0);
186
+ const av = segments(a);
187
+ const bv = segments(b);
188
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
189
+ const diff = ((_a = av[i]) !== null && _a !== void 0 ? _a : 0) - ((_b = bv[i]) !== null && _b !== void 0 ? _b : 0);
190
+ if (diff !== 0)
191
+ return diff;
192
+ }
193
+ return 0;
194
+ }
96
195
  /**
97
196
  * Move the workspace-scoped pnpm fields from `source` into `rootWs`,
98
197
  * deleting each moved field from `source`. Returns true if anything moved.
@@ -173,6 +272,15 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
173
272
  const merged = Object.assign(Object.assign({}, rootObj), subObj);
174
273
  return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
175
274
  }
275
+ /** Extract the manager name from a pin (`pnpm@11.13.1+sha512.…` -> `pnpm`). */
276
+ function pmName(pin) {
277
+ return pin.slice(0, Math.max(0, pin.lastIndexOf('@'))) || pin;
278
+ }
279
+ /** Extract the bare version from a pin (`pnpm@11.13.1+sha512.…` -> `11.13.1`). */
280
+ function pmVersion(pin) {
281
+ const afterAt = pin.slice(pin.lastIndexOf('@') + 1);
282
+ return afterAt.split('+')[0];
283
+ }
176
284
  /** Parse a YAML file into a plain object, or null on missing/malformed/non-object. */
177
285
  function readYaml(filesystem, path) {
178
286
  if (!filesystem.exists(path))