@lenne.tech/cli 1.36.0 → 1.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,52 +9,128 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- const fs_1 = require("fs");
12
+ exports.help = void 0;
13
13
  const dev_identity_1 = require("../../lib/dev-identity");
14
14
  const dev_process_1 = require("../../lib/dev-process");
15
15
  const dev_project_1 = require("../../lib/dev-project");
16
16
  const dev_state_1 = require("../../lib/dev-state");
17
17
  const dev_ticket_1 = require("../../lib/dev-ticket");
18
+ const workspace_integration_1 = require("../../lib/workspace-integration");
18
19
  /**
19
20
  * `lt ticket stop [<id>]` — tear a ticket env down + remove its worktree.
20
21
  *
21
22
  * 1. `lt dev down` inside the worktree (stops the ticket stack + any test
22
23
  * stacks, removes the Caddy block — residue-free),
23
24
  * 2. `git worktree remove` (the BRANCH is kept, so nothing is lost),
24
- * 3. `--drop-db` also drops the ticket's empty dev + test databases.
25
+ * 3. drops the ticket's dev + test databases (`--keep-db` opts out).
26
+ *
27
+ * The env is gone entirely afterwards — worktree AND registry entry — so the
28
+ * databases would be orphans: nothing references them, nothing lists them, and
29
+ * nothing reuses them. They are therefore dropped by default; keeping them was
30
+ * how machines ended up with hundreds of dead databases from deleted tickets.
31
+ *
32
+ * ORDER MATTERS: the drop is irreversible and `git worktree remove` can fail
33
+ * (locked worktree, modified submodule, permissions). Dropping first would leave
34
+ * the user with a surviving env whose data is gone — while the error message
35
+ * tells them to commit and retry. So the fallible step runs first and the
36
+ * irreversible one only after it succeeded.
25
37
  *
26
38
  * Run with NO id from INSIDE a ticket worktree to clean up THIS environment
27
39
  * (the current folder is removed; the process steps out to the main repo first).
28
40
  */
41
+ exports.help = {
42
+ description: 'Stop a ticket env: remove its worktree (branch kept) and drop its databases',
43
+ examples: [
44
+ 'ticket stop 2200',
45
+ 'ticket stop 2200 --keep-db',
46
+ 'ticket stop # from inside a ticket worktree',
47
+ ],
48
+ features: [
49
+ 'Runs `lt dev down` inside the worktree (stack + Caddy block, residue-free)',
50
+ 'Removes the git worktree — the BRANCH is kept, so committed work survives',
51
+ 'Drops the ticket dev + test databases (they are orphans once the env is gone)',
52
+ 'Refuses to remove a worktree with uncommitted changes or unpushed commits',
53
+ ],
54
+ name: 'stop',
55
+ options: [
56
+ {
57
+ default: false,
58
+ description: 'Keep the ticket databases instead of dropping them (they are orphans — drop them manually later)',
59
+ flag: '--keep-db',
60
+ required: false,
61
+ type: 'boolean',
62
+ },
63
+ {
64
+ default: false,
65
+ description: 'Remove the worktree even with uncommitted changes / unpushed commits (the branch is kept)',
66
+ flag: '--force',
67
+ required: false,
68
+ type: 'boolean',
69
+ },
70
+ {
71
+ default: false,
72
+ description: 'Skip the confirmation prompt for dropping the databases',
73
+ flag: '--noConfirm',
74
+ required: false,
75
+ type: 'boolean',
76
+ },
77
+ {
78
+ default: false,
79
+ description: 'Deprecated no-op — dropping is the default now. Use --keep-db to opt out.',
80
+ flag: '--drop-db',
81
+ required: false,
82
+ type: 'boolean',
83
+ },
84
+ ],
85
+ };
29
86
  const StopCommand = {
30
87
  alias: ['rm'],
31
- description: 'Stop a ticket env + remove its worktree (branch kept); no id = the current worktree',
88
+ // Discloses what is DESTROYED, not just what is preserved this line is the most-read
89
+ // documentation the command has, and dropping databases is its irreversible part.
90
+ description: 'Stop ticket env + drop its DBs',
32
91
  name: 'stop',
33
92
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
34
- var _a, _b, _c, _d, _e;
35
- const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
93
+ var _a, _b, _c, _d, _e, _f;
94
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, prompt: { confirm }, } = toolbox;
36
95
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
37
96
  let mainRepoRoot;
38
97
  try {
39
98
  mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
40
99
  }
41
- catch (_f) {
100
+ catch (_g) {
42
101
  error('Not inside a git repository.');
43
102
  if (!parameters.options.fromGluegunMenu)
44
103
  process.exit(1);
45
104
  return 'ticket stop: not a git repo';
46
105
  }
106
+ // `--keep-db` PREVENTS an irreversible drop, so it is read fail-closed: presence
107
+ // means keep, only an explicit negation still drops. (A strict `=== true` would
108
+ // destroy the data on `--keep-db=true`, which yargs-parser hands over as a STRING.)
109
+ const { keep: keepDb, strayValue } = (0, dev_ticket_1.keepDbFlag)(parameters.options);
110
+ // `--keep-db` takes no value, so yargs-parser swallows the next positional as one:
111
+ // `lt ticket stop --keep-db 2200` loses the id. Recover it rather than silently
112
+ // falling back to the marker and stopping a DIFFERENT ticket than the user named.
113
+ if (strayValue !== null && parameters.first != null) {
114
+ error(`Ambiguous: ticket "${parameters.first}" given, but --keep-db also carries "${strayValue}".`);
115
+ info(colors.dim(' --keep-db takes no value. Use: lt ticket stop <id> --keep-db'));
116
+ if (!parameters.options.fromGluegunMenu)
117
+ process.exit(1);
118
+ return 'ticket stop: ambiguous --keep-db';
119
+ }
47
120
  // Id from the argument — or, when invoked with NO id from INSIDE a ticket
48
121
  // worktree, the current worktree's own ticket (so a bare `lt ticket stop`
49
122
  // cleans up "this" environment and removes this very folder).
50
- const fromMarker = parameters.first == null ? (0, dev_ticket_1.readTicketMarker)(layout.root) : null;
51
- const id = String((_b = (_a = parameters.first) !== null && _a !== void 0 ? _a : fromMarker) !== null && _b !== void 0 ? _b : '').trim();
123
+ const fromMarker = parameters.first == null && strayValue === null ? (0, dev_ticket_1.readTicketMarker)(layout.root) : null;
124
+ const id = String((_c = (_b = (_a = parameters.first) !== null && _a !== void 0 ? _a : strayValue) !== null && _b !== void 0 ? _b : fromMarker) !== null && _c !== void 0 ? _c : '').trim();
52
125
  if (!id) {
53
- error('Usage: lt ticket stop <id> [--drop-db] [--force] — or run with no id from INSIDE a ticket worktree.');
126
+ error('Usage: lt ticket stop <id> [--keep-db] [--force] — or run with no id from INSIDE a ticket worktree.');
54
127
  if (!parameters.options.fromGluegunMenu)
55
128
  process.exit(1);
56
129
  return 'ticket stop: missing id';
57
130
  }
131
+ if (strayValue !== null) {
132
+ info(colors.dim(` (read ticket "${id}" from --keep-db — the flag takes no value)`));
133
+ }
58
134
  const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
59
135
  if (!wt) {
60
136
  error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
@@ -62,6 +138,16 @@ const StopCommand = {
62
138
  process.exit(1);
63
139
  return 'ticket stop: not found';
64
140
  }
141
+ // `git worktree list` reports the MAIN checkout as its first entry, so a stray
142
+ // `.lt-dev/ticket` marker there would make us "stop" the main repo: drop its
143
+ // databases, then fail to remove it. Never treat the main checkout as a ticket.
144
+ if ((0, dev_state_1.sameRealPath)(wt.path, mainRepoRoot)) {
145
+ error(`"${id}" resolves to the MAIN checkout, not a ticket worktree — refusing.`);
146
+ info(colors.dim(' A stale .lt-dev/ticket marker in the main repo? Delete it and retry.'));
147
+ if (!parameters.options.fromGluegunMenu)
148
+ process.exit(1);
149
+ return 'ticket stop: refused (main worktree)';
150
+ }
65
151
  // SAFETY: never silently delete unsaved work. Warn + REFUSE (unless --force)
66
152
  // when the worktree has uncommitted changes OR unpushed commits, so the user
67
153
  // commits + pushes first. (`--force` removes anyway; the branch is kept, so
@@ -77,7 +163,7 @@ const StopCommand = {
77
163
  info(colors.dim(` … and ${safety.dirtySource.length - 12} more`));
78
164
  }
79
165
  if (safety.unpushed > 0) {
80
- warning(` • ${safety.unpushed} commit(s) on "${(_c = wt.branch) !== null && _c !== void 0 ? _c : '-'}" not pushed to any remote`);
166
+ warning(` • ${safety.unpushed} commit(s) on "${(_d = wt.branch) !== null && _d !== void 0 ? _d : '-'}" not pushed to any remote`);
81
167
  }
82
168
  info('');
83
169
  info(colors.dim(' Commit + push first (the branch is kept), or re-run with --force to remove anyway.'));
@@ -85,37 +171,70 @@ const StopCommand = {
85
171
  process.exit(1);
86
172
  return 'ticket stop: unsaved work (use --force)';
87
173
  }
174
+ // Resolve the drop targets BEFORE anything is torn down (the registry entry is
175
+ // deleted at the end, and the layout is read from the main repo) — but execute
176
+ // the drop LAST, after the worktree is provably gone.
177
+ const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
178
+ const ticketSlug = `${base.slug}-${id}`;
179
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
180
+ // The plan is built even with --keep-db: its targets are then RECORDED as kept
181
+ // (registry `keptDbs`) so the orphan sweep (`lt dev prune` / `lt dev up`) never
182
+ // collects what the user explicitly asked to keep.
183
+ const plan = (0, dev_ticket_1.planTicketDbDrop)({
184
+ hasApi: Boolean(mainLayout.apiDir),
185
+ // Observe what exists so sharded test DBs (`…-test-<n>`) are included —
186
+ // their shard index cannot be derived. Listing failure (no mongosh /
187
+ // Mongo down) degrades to the derived candidates.
188
+ observedDbNames: (0, dev_ticket_1.listDatabaseNames)(undefined, [mainLayout.apiDir, mainRepoRoot]),
189
+ projectDevDb: (0, dev_project_1.deriveDbName)(mainLayout.apiDir, base.slug),
190
+ registryEntry: (0, dev_state_1.loadRegistry)().projects[ticketSlug],
191
+ ticketId: id,
192
+ worktreePath: wt.path,
193
+ });
194
+ let dropTargets = keepDb ? [] : plan.targets;
195
+ if (!keepDb) {
196
+ if (plan.foreignEntryPath) {
197
+ warning(` registry slug "${ticketSlug}" belongs to another checkout (${plan.foreignEntryPath}) —`);
198
+ warning(" ignoring its database name (it is not this ticket's).");
199
+ }
200
+ for (const db of plan.refused) {
201
+ warning(` refusing to drop "${db}" — it is not a database of ticket "${id}".`);
202
+ info(colors.dim(' (drop it manually if you are sure it is safe.)'));
203
+ }
204
+ }
88
205
  // If we are removing the worktree we are standing in, step the process out
89
206
  // to the main repo first so git can remove the folder cleanly.
90
- const removingCwd = fromMarker !== null || samePath(wt.path, layout.root);
207
+ const removingCwd = fromMarker !== null || (0, dev_state_1.sameRealPath)(wt.path, layout.root);
91
208
  if (removingCwd) {
92
209
  try {
93
210
  process.chdir(mainRepoRoot);
94
211
  }
95
- catch (_g) {
212
+ catch (_h) {
96
213
  /* best-effort */
97
214
  }
98
215
  }
216
+ // Dropping a database is irreversible and — unlike the source work the safety gate
217
+ // above protects — it can never be recovered from a branch. So it is the one step we
218
+ // confirm. Non-interactive callers (CI, AI agents, `--noConfirm`) get the documented
219
+ // default without a prompt; the guards above are what make that safe, not the prompt.
220
+ if (dropTargets.length > 0 && !(0, workspace_integration_1.isNonInteractive)(parameters.options.noConfirm === true)) {
221
+ info('');
222
+ warning(' These databases will be DROPPED (irreversible):');
223
+ dropTargets.forEach((db) => info(colors.dim(` • ${db}`)));
224
+ if (!(yield confirm('Drop them?', true))) {
225
+ info(colors.dim(' keeping the databases (same as --keep-db).'));
226
+ dropTargets = [];
227
+ }
228
+ }
99
229
  info('');
100
230
  info(colors.bold(`Stopping ticket "${id}"`));
101
231
  // 1. Tear the isolated stack down from inside the worktree (marker-aware).
102
232
  info(colors.dim(' lt dev down …'));
103
- yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'down'], { cwd: wt.path, env: process.env });
104
- // 2. Optionally drop the ticket databases (they are otherwise just left empty).
105
- if (parameters.options.dropDb === true || parameters.options['drop-db'] === true) {
106
- const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
107
- const entry = (0, dev_state_1.loadRegistry)().projects[`${base.slug}-${id}`];
108
- const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
109
- const devDb = (_d = entry === null || entry === void 0 ? void 0 : entry.dbName) !== null && _d !== void 0 ? _d : (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(mainLayout.apiDir, base.slug), id);
110
- const testDb = (0, dev_project_1.deriveTestDbName)(devDb);
111
- for (const db of [devDb, testDb]) {
112
- if ((0, dev_ticket_1.dropDatabase)(db))
113
- info(colors.dim(` dropped db ${db}`));
114
- else
115
- warning(` could not drop db ${db} (mongosh missing or DB not reachable) — drop it manually if needed.`);
116
- }
117
- }
118
- // 3. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
233
+ yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], 'dev', 'down'], {
234
+ cwd: wt.path,
235
+ env: process.env,
236
+ });
237
+ // 2. Remove the worktree (branch is kept). Auto-force when the ONLY dirty
119
238
  // files are auto-discardable — framework-generated (e.g. `nuxt dev`
120
239
  // rewrites the tracked `.nuxtrc` on boot) OR pristine lt-dev self-heal
121
240
  // patches (config.env.ts/nuxt.config.ts/playwright.config.ts that
@@ -132,23 +251,51 @@ const StopCommand = {
132
251
  catch (e) {
133
252
  error(`git worktree remove failed: ${e.message}`);
134
253
  info(colors.dim(' The worktree has uncommitted SOURCE changes — commit/stash them, or pass --force to discard.'));
254
+ info(colors.dim(' Nothing was dropped — the databases are untouched.'));
135
255
  if (!parameters.options.fromGluegunMenu)
136
256
  process.exit(1);
137
257
  return 'ticket stop: worktree remove failed';
138
258
  }
259
+ // 3. The env is provably gone now — so its databases are orphans and it is safe to
260
+ // do the one thing we cannot undo.
261
+ if (keepDb) {
262
+ info(colors.dim(' --keep-db → databases kept (nothing references them anymore; drop them manually when done).'));
263
+ // Record the kept names on the MAIN project's entry — the ticket's own entry is
264
+ // deleted below, and without this record the orphan sweep would collect them.
265
+ if (plan.targets.length > 0) {
266
+ const reg = (0, dev_state_1.loadRegistry)();
267
+ const main = reg.projects[base.slug];
268
+ if (main) {
269
+ main.keptDbs = [...new Set([...((_e = main.keptDbs) !== null && _e !== void 0 ? _e : []), ...plan.targets])];
270
+ (0, dev_state_1.saveRegistry)(reg);
271
+ info(colors.dim(` recorded as kept in the registry: ${plan.targets.join(', ')}`));
272
+ }
273
+ }
274
+ }
275
+ else if (dropTargets.length > 0) {
276
+ const { dropped, reason } = (0, dev_ticket_1.dropDatabases)(dropTargets, undefined, [mainLayout.apiDir, mainRepoRoot]);
277
+ dropped.forEach((db) => info(colors.dim(` dropped db ${db}`)));
278
+ if (reason === 'no-mongosh') {
279
+ warning(' mongosh is not installed — the ticket databases were NOT dropped.');
280
+ info(colors.dim(` install it (brew install mongosh) and drop them manually: ${dropTargets.join(', ')}`));
281
+ }
282
+ else if (reason === 'unreachable') {
283
+ warning(' MongoDB is not reachable — the ticket databases were NOT dropped.');
284
+ info(colors.dim(` start it and drop them manually: ${dropTargets.join(', ')}`));
285
+ }
286
+ }
139
287
  // The whole env is gone now — drop the ticket's registry entry so its slug +
140
288
  // reserved ports are reclaimed (`lt dev down` only ends the session, keeping
141
289
  // the entry for a restart; `lt ticket stop` removes the env entirely).
142
290
  {
143
291
  const reg = (0, dev_state_1.loadRegistry)();
144
- const ticketSlug = `${(0, dev_identity_1.buildIdentity)(mainRepoRoot).slug}-${id}`;
145
292
  if (reg.projects[ticketSlug]) {
146
293
  delete reg.projects[ticketSlug];
147
294
  (0, dev_state_1.saveRegistry)(reg);
148
295
  }
149
296
  }
150
297
  info('');
151
- success(`Ticket "${id}" stopped — worktree removed, branch "${(_e = wt.branch) !== null && _e !== void 0 ? _e : '-'}" kept.`);
298
+ success(`Ticket "${id}" stopped — worktree removed, branch "${(_f = wt.branch) !== null && _f !== void 0 ? _f : '-'}" kept.`);
152
299
  if (removingCwd)
153
300
  info(colors.dim(` This folder is gone — your shell is still in it. Run: cd ${mainRepoRoot}`));
154
301
  if (!parameters.options.fromGluegunMenu)
@@ -156,13 +303,4 @@ const StopCommand = {
156
303
  return `ticket stop: ${id}`;
157
304
  }),
158
305
  };
159
- /** True if two paths point at the same location (resolving symlinks, e.g. /tmp → /private/tmp). */
160
- function samePath(a, b) {
161
- try {
162
- return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
163
- }
164
- catch (_a) {
165
- return a === b;
166
- }
167
- }
168
306
  module.exports = StopCommand;
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectDevPrunePlan = collectDevPrunePlan;
4
+ exports.listLiveTicketIds = listLiveTicketIds;
5
+ exports.listPastTicketIds = listPastTicketIds;
6
+ exports.planOrphanTicketDbSweep = planOrphanTicketDbSweep;
7
+ exports.planRegistryPrune = planRegistryPrune;
8
+ exports.planStaleShardDbSweep = planStaleShardDbSweep;
9
+ /**
10
+ * Garbage collection for `lt dev` / `lt ticket` leftovers.
11
+ *
12
+ * Two classes of orphans accumulate on a busy machine and nothing collected them:
13
+ *
14
+ * 1. TICKET DATABASES whose environment is gone. `lt ticket stop` is the only
15
+ * path that drops `<base>-<id>`(+`-test`/`-test-<n>`) — when a worktree is
16
+ * deleted manually, the ticket predates the drop feature, `--keep-db` was
17
+ * not intended, or mongosh/Mongo was unavailable at stop time, the DBs stay
18
+ * forever (observed: 19 orphaned per-ticket DBs for a single project).
19
+ *
20
+ * 2. REGISTRY ENTRIES whose `path` no longer exists. They hold reserved
21
+ * internal ports and slugs indefinitely; `lastUsedAt` is recorded but was
22
+ * never used for cleanup.
23
+ *
24
+ * Safety model (fail closed, like `lt ticket stop`):
25
+ * - Ticket ids are taken from THIS project's own `feat/*` branches — the one
26
+ * durable record of every ticket ever started here (branches are kept on
27
+ * stop). A name that merely LOOKS ticket-shaped is never enough: prefix
28
+ * collisions between sibling projects (`nest-server` vs `nest-server-starter`)
29
+ * make shape-based sweeps destructive.
30
+ * - An id is only orphaned when it has NO live worktree and NO live registry
31
+ * entry.
32
+ * - Every candidate must pass {@link isTicketScopedDb} — the same last gate
33
+ * `lt ticket stop` uses.
34
+ * - Databases recorded in any registry entry (dbName or keptDbs — the
35
+ * `lt ticket stop --keep-db` record) are NEVER touched.
36
+ * - Registry pruning removes ENTRIES only, never a `-local` dev database:
37
+ * a deleted folder is not consent to destroy data (the project may be
38
+ * re-cloned).
39
+ */
40
+ const child_process_1 = require("child_process");
41
+ const fs_1 = require("fs");
42
+ const path_1 = require("path");
43
+ const dev_project_1 = require("./dev-project");
44
+ const dev_ticket_1 = require("./dev-ticket");
45
+ /** Branch prefix `lt ticket start` creates worktrees on. */
46
+ const TICKET_BRANCH_PREFIX = 'feat/';
47
+ /**
48
+ * Collect the full prune plan for one project. Reads the registry and the Mongo
49
+ * database listing; performs NO destructive action.
50
+ */
51
+ function collectDevPrunePlan(args) {
52
+ const { loadRegistry, mainRepoRoot, observedDbNames, projectDevDb, slug } = args;
53
+ const registry = loadRegistry();
54
+ return {
55
+ observed: observedDbNames,
56
+ orphan: planOrphanTicketDbSweep({
57
+ mainRepoRoot,
58
+ observedDbNames,
59
+ projectDevDb,
60
+ registry,
61
+ slug,
62
+ }),
63
+ registryPrune: planRegistryPrune(registry),
64
+ shardTargets: planStaleShardDbSweep({
65
+ observedDbNames,
66
+ projectDevDb,
67
+ projectRoot: mainRepoRoot,
68
+ }),
69
+ };
70
+ }
71
+ /** Ticket ids that still have a live environment (worktree and/or live registry entry). */
72
+ function listLiveTicketIds(mainRepoRoot, registry, slug) {
73
+ var _a;
74
+ const live = new Set();
75
+ try {
76
+ for (const wt of (0, dev_ticket_1.listWorktrees)(mainRepoRoot)) {
77
+ if (wt.ticket)
78
+ live.add(wt.ticket);
79
+ if ((_a = wt.branch) === null || _a === void 0 ? void 0 : _a.startsWith(TICKET_BRANCH_PREFIX)) {
80
+ live.add((0, dev_ticket_1.deriveTicketId)(wt.branch.slice(TICKET_BRANCH_PREFIX.length)));
81
+ }
82
+ }
83
+ }
84
+ catch (_b) {
85
+ /* fail closed below: without worktree info we do not sweep at all */
86
+ return [];
87
+ }
88
+ for (const [key, entry] of Object.entries(registry.projects)) {
89
+ if (key.startsWith(`${slug}-`) && entry.path && (0, fs_1.existsSync)(entry.path)) {
90
+ live.add(key.slice(slug.length + 1));
91
+ }
92
+ }
93
+ return [...live];
94
+ }
95
+ /**
96
+ * Ticket ids of every ticket EVER started in this repo, derived from its local
97
+ * `feat/*` branches (kept by `lt ticket stop` on purpose). Best-effort: an
98
+ * unreadable repo yields an empty list, which makes the sweep a no-op.
99
+ */
100
+ function listPastTicketIds(mainRepoRoot) {
101
+ let branches;
102
+ try {
103
+ branches = (0, child_process_1.execFileSync)('git', ['-C', mainRepoRoot, 'branch', '--list', `${TICKET_BRANCH_PREFIX}*`, '--format=%(refname:short)'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000 })
104
+ .toString()
105
+ .split('\n')
106
+ .map((line) => line.trim())
107
+ .filter(Boolean);
108
+ }
109
+ catch (_a) {
110
+ return [];
111
+ }
112
+ const ids = branches.map((branch) => (0, dev_ticket_1.deriveTicketId)(branch.slice(TICKET_BRANCH_PREFIX.length)));
113
+ return [...new Set(ids)].filter((id) => id && !(0, dev_ticket_1.isReservedTicketId)(id));
114
+ }
115
+ /**
116
+ * Plan the orphan-ticket-database sweep for ONE project. Pure — no side effects.
117
+ *
118
+ * `observedDbNames` must come from an actual server listing; null (listing
119
+ * failed) yields an empty plan. Observation selects candidates, the safety
120
+ * gates decide.
121
+ */
122
+ function planOrphanTicketDbSweep(args) {
123
+ var _a;
124
+ const { mainRepoRoot, observedDbNames, projectDevDb, registry, slug } = args;
125
+ if (!observedDbNames || observedDbNames.length === 0) {
126
+ return { orphanIds: [], protected: [], targets: [] };
127
+ }
128
+ const pastIds = listPastTicketIds(mainRepoRoot);
129
+ if (pastIds.length === 0) {
130
+ return { orphanIds: [], protected: [], targets: [] };
131
+ }
132
+ const liveIds = new Set(listLiveTicketIds(mainRepoRoot, registry, slug));
133
+ const orphanIds = pastIds.filter((id) => !liveIds.has(id));
134
+ // Never touch anything a registry entry still references or explicitly kept.
135
+ const protectedNames = new Set();
136
+ for (const entry of Object.values(registry.projects)) {
137
+ if (entry.dbName) {
138
+ protectedNames.add(entry.dbName);
139
+ protectedNames.add((0, dev_project_1.deriveTestDbName)(entry.dbName));
140
+ }
141
+ for (const kept of (_a = entry.keptDbs) !== null && _a !== void 0 ? _a : []) {
142
+ protectedNames.add(kept);
143
+ }
144
+ }
145
+ // Sibling shield: when another REGISTERED project's DB base extends ours
146
+ // (`nest-server` vs `nest-server-starter`), every name under the longer base
147
+ // belongs to the sibling — even if one of OUR branch-derived ids happens to
148
+ // make it parse as ticket-scoped for us (branch `feat/starter-2205` +
149
+ // sibling DB `nest-server-starter-2205`). The longer base wins, always.
150
+ const base = projectDevDb.replace(/-(local|dev)$/i, '');
151
+ const siblingBases = Object.values(registry.projects)
152
+ .map((entry) => { var _a; return (_a = entry.dbName) === null || _a === void 0 ? void 0 : _a.replace(/-(local|dev)$/i, ''); })
153
+ .filter((b) => Boolean(b) && b !== base && b.startsWith(`${base}-`));
154
+ const targets = [];
155
+ const shielded = [];
156
+ for (const id of orphanIds) {
157
+ for (const name of observedDbNames) {
158
+ if (!(0, dev_ticket_1.isTicketScopedDb)(name, id, projectDevDb))
159
+ continue;
160
+ if (protectedNames.has(name) || siblingBases.some((sb) => name === sb || name.startsWith(`${sb}-`))) {
161
+ shielded.push(name);
162
+ }
163
+ else {
164
+ targets.push(name);
165
+ }
166
+ }
167
+ }
168
+ return {
169
+ orphanIds: orphanIds.filter((id) => targets.some((t) => (0, dev_ticket_1.isTicketScopedDb)(t, id, projectDevDb))),
170
+ protected: shielded,
171
+ targets: [...new Set(targets)],
172
+ };
173
+ }
174
+ /**
175
+ * Registry entries whose recorded path no longer exists. Their slug + reserved
176
+ * internal ports are reclaimed by deleting the ENTRY; their databases are left
177
+ * alone here (ticket DBs are handled by the orphan sweep; `-local` dev DBs are
178
+ * never auto-dropped).
179
+ */
180
+ function planRegistryPrune(registry) {
181
+ return Object.entries(registry.projects)
182
+ .filter(([, entry]) => entry.path && !(0, fs_1.existsSync)(entry.path))
183
+ .map(([key]) => key);
184
+ }
185
+ /**
186
+ * Stale sharded-test databases of the project itself (`<base>-test-<n>` from
187
+ * `lt dev test --shard`). They are ephemeral by definition — every shard run
188
+ * resets its DB on boot — so with no test session alive they are pure leftovers.
189
+ * The UNSHARDED `<base>-test` DB is kept: it is the project's steady-state test
190
+ * database (dev DB + test DB is the intended per-project footprint).
191
+ */
192
+ function planStaleShardDbSweep(args) {
193
+ const { observedDbNames, projectDevDb, projectRoot } = args;
194
+ if (!observedDbNames)
195
+ return [];
196
+ try {
197
+ const sessionDir = (0, path_1.join)(projectRoot, '.lt-dev');
198
+ if ((0, fs_1.existsSync)(sessionDir) && (0, fs_1.readdirSync)(sessionDir).some((f) => /^state\.test.*\.json$/.test(f))) {
199
+ // A test session (or shard) is live — its DBs are in use.
200
+ return [];
201
+ }
202
+ }
203
+ catch (_a) {
204
+ return [];
205
+ }
206
+ const shardPattern = new RegExp(`^${escapeRegExp(`${(0, dev_project_1.deriveTestDbName)(projectDevDb)}-`)}\\d+$`);
207
+ return observedDbNames.filter((name) => shardPattern.test(name));
208
+ }
209
+ function escapeRegExp(value) {
210
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
211
+ }
@@ -18,6 +18,7 @@ exports.isPidAlive = isPidAlive;
18
18
  exports.isValidPid = isValidPid;
19
19
  exports.loadRegistry = loadRegistry;
20
20
  exports.loadSession = loadSession;
21
+ exports.sameRealPath = sameRealPath;
21
22
  exports.saveRegistry = saveRegistry;
22
23
  exports.saveSession = saveSession;
23
24
  exports.takenInternalPorts = takenInternalPorts;
@@ -151,6 +152,15 @@ function loadSession(root, sessionFile = SESSION_FILE) {
151
152
  }
152
153
  return null;
153
154
  }
155
+ /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
156
+ function sameRealPath(a, b) {
157
+ try {
158
+ return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
159
+ }
160
+ catch (_a) {
161
+ return a === b;
162
+ }
163
+ }
154
164
  /** Atomically persist the registry. */
155
165
  function saveRegistry(reg) {
156
166
  (0, fs_1.mkdirSync)((0, path_1.dirname)(REGISTRY_PATH), { recursive: true });
@@ -200,15 +210,6 @@ function normalizeRegistry(reg) {
200
210
  }
201
211
  return reg;
202
212
  }
203
- /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
204
- function sameRealPath(a, b) {
205
- try {
206
- return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
207
- }
208
- catch (_a) {
209
- return a === b;
210
- }
211
- }
212
213
  const LOCK_PATH = `${REGISTRY_PATH}.lock`;
213
214
  /**
214
215
  * Run `fn` while holding an EXCLUSIVE lock on the registry, so concurrent
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.TEST_INITIAL_ADMIN_ENV = void 0;
13
+ exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
13
14
  exports.autoShardCount = autoShardCount;
14
15
  exports.bringUpTestSession = bringUpTestSession;
15
16
  exports.hasTestSession = hasTestSession;
@@ -41,6 +42,7 @@ exports.tearDownTestSession = tearDownTestSession;
41
42
  * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
42
43
  * session file, registry entry), so a stale session is always safely reclaimed.
43
44
  */
45
+ const child_process_1 = require("child_process");
44
46
  const fs_1 = require("fs");
45
47
  const os_1 = require("os");
46
48
  const path_1 = require("path");
@@ -53,6 +55,27 @@ const dev_patches_1 = require("./dev-patches");
53
55
  const dev_process_1 = require("./dev-process");
54
56
  const dev_project_1 = require("./dev-project");
55
57
  const dev_state_1 = require("./dev-state");
58
+ /**
59
+ * Make sure the Playwright browser build the app's OWN playwright version needs
60
+ * is installed. A freshly created (or freshly updated) project ships a
61
+ * playwright.config, but the per-version browser build
62
+ * (`chromium_headless_shell-<rev>`) may be missing from the machine cache —
63
+ * every spec then fails with "Executable doesn't exist" (observed: 16/16
64
+ * failures on a brand-new project, easily misread as a broken stack).
65
+ * `playwright install chromium` is idempotent and returns in about a second
66
+ * when the build is already cached, so running it up front costs nothing and
67
+ * removes a whole class of first-run failures. Best-effort: if the install
68
+ * fails (offline), the suite still runs and Playwright's own error explains it.
69
+ */
70
+ function ensurePlaywrightBrowsers(appDir, pm, logInfo) {
71
+ try {
72
+ const args = pm.exec('playwright', ['install', 'chromium']);
73
+ (0, child_process_1.execFileSync)(pm.bin, args, { cwd: appDir, stdio: 'ignore', timeout: 300000 });
74
+ }
75
+ catch (_a) {
76
+ logInfo === null || logInfo === void 0 ? void 0 : logInfo('playwright install chromium failed — continuing (the suite reports missing browsers itself).');
77
+ }
78
+ }
56
79
  const TEST_API_LOG = 'api.test.log';
57
80
  const TEST_APP_LOG = 'app.test.log';
58
81
  const TEST_BRIDGE_FILE = '.env.test';
@@ -351,6 +374,7 @@ function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
351
374
  */
352
375
  function runShardedTestSession(layout, baseIdentity, log, opts) {
353
376
  return __awaiter(this, void 0, void 0, function* () {
377
+ ensurePlaywrightBrowsers(layout.appDir, opts.pm, log.info);
354
378
  const total = Math.max(2, Math.floor(opts.total));
355
379
  const contexts = [];
356
380
  // Bring up the N isolated stacks sequentially (shard 1 builds; 2..N reuse).