@lenne.tech/cli 1.27.0 → 1.29.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.
Files changed (40) hide show
  1. package/build/cli.js +7 -1
  2. package/build/commands/dev/doctor.js +27 -1
  3. package/build/commands/dev/down.js +22 -10
  4. package/build/commands/dev/status.js +4 -3
  5. package/build/commands/dev/test.js +12 -4
  6. package/build/commands/dev/up.js +90 -50
  7. package/build/commands/frontend/angular.js +48 -46
  8. package/build/commands/frontend/convert-mode.js +41 -39
  9. package/build/commands/frontend/nuxt.js +49 -47
  10. package/build/commands/fullstack/add-api.js +34 -32
  11. package/build/commands/fullstack/add-app.js +25 -23
  12. package/build/commands/fullstack/convert-mode.js +85 -65
  13. package/build/commands/fullstack/init.js +12 -0
  14. package/build/commands/fullstack/update.js +24 -0
  15. package/build/commands/server/add-property.js +42 -40
  16. package/build/commands/server/convert-mode.js +41 -39
  17. package/build/commands/server/create.js +65 -63
  18. package/build/commands/server/module.js +56 -54
  19. package/build/commands/server/object.js +42 -40
  20. package/build/commands/server/permissions.js +60 -58
  21. package/build/commands/ticket/list.js +78 -0
  22. package/build/commands/ticket/start.js +141 -0
  23. package/build/commands/ticket/stop.js +166 -0
  24. package/build/commands/ticket/switch.js +70 -0
  25. package/build/commands/ticket/test.js +80 -0
  26. package/build/commands/ticket/ticket.js +36 -0
  27. package/build/commands/tools/crawl.js +92 -90
  28. package/build/extensions/frontend-helper.js +8 -37
  29. package/build/extensions/server.js +8 -38
  30. package/build/lib/command-help.js +161 -0
  31. package/build/lib/dev-identity.js +18 -0
  32. package/build/lib/dev-patches.js +1 -1
  33. package/build/lib/dev-project.js +14 -0
  34. package/build/lib/dev-state.js +96 -0
  35. package/build/lib/dev-test-session.js +55 -35
  36. package/build/lib/dev-ticket.js +343 -0
  37. package/build/lib/vendor-claude-md.js +227 -0
  38. package/docs/lt-dev-ticket-workflow.html +603 -0
  39. package/docs/lt-dev-ticket-workflow.pdf +0 -0
  40. package/package.json +32 -1
@@ -1,8 +1,18 @@
1
1
  "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
2
11
  Object.defineProperty(exports, "__esModule", { value: true });
3
12
  exports.paths = exports.TEST_SESSION_FILE = void 0;
4
13
  exports.allocateInternalPort = allocateInternalPort;
5
14
  exports.clearSession = clearSession;
15
+ exports.detectSlugConflict = detectSlugConflict;
6
16
  exports.isPidAlive = isPidAlive;
7
17
  exports.isValidPid = isValidPid;
8
18
  exports.loadRegistry = loadRegistry;
@@ -10,6 +20,7 @@ exports.loadSession = loadSession;
10
20
  exports.saveRegistry = saveRegistry;
11
21
  exports.saveSession = saveSession;
12
22
  exports.takenInternalPorts = takenInternalPorts;
23
+ exports.withRegistryLock = withRegistryLock;
13
24
  /**
14
25
  * State persistence for `lt dev`.
15
26
  *
@@ -57,6 +68,21 @@ function clearSession(root, sessionFile = SESSION_FILE) {
57
68
  }
58
69
  }
59
70
  }
71
+ /**
72
+ * Detect when `slug` is registered to a DIFFERENT checkout than `root`. Two
73
+ * clones of the same project share a package.json "name" → the same slug → the
74
+ * same Caddy block / internal ports / database, so running both via `lt dev`
75
+ * collides (and one's `lt dev down` can unroute the other). Returns null when
76
+ * there is no conflict (no registry entry, or the entry belongs to THIS checkout).
77
+ */
78
+ function detectSlugConflict(slug, root) {
79
+ const entry = loadRegistry().projects[slug];
80
+ if (!(entry === null || entry === void 0 ? void 0 : entry.path) || sameRealPath(entry.path, root))
81
+ return null;
82
+ const session = loadSession(entry.path);
83
+ const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p));
84
+ return { otherPath: entry.path, otherSessionAlive };
85
+ }
60
86
  /** Check whether a process with the given PID is currently alive. */
61
87
  function isPidAlive(pid) {
62
88
  if (!isValidPid(pid))
@@ -146,6 +172,76 @@ function takenInternalPorts(reg, excludeSlug) {
146
172
  }
147
173
  return ports;
148
174
  }
175
+ /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
176
+ function sameRealPath(a, b) {
177
+ try {
178
+ return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
179
+ }
180
+ catch (_a) {
181
+ return a === b;
182
+ }
183
+ }
184
+ const LOCK_PATH = `${REGISTRY_PATH}.lock`;
185
+ /**
186
+ * Run `fn` while holding an EXCLUSIVE lock on the registry, so concurrent
187
+ * `lt dev` invocations — e.g. two parallel `lt dev test` in different ticket
188
+ * worktrees — cannot read-modify-write the registry, or allocate the SAME
189
+ * internal ports, at the same time. (Without this, two simultaneous test runs
190
+ * both read the registry before either saves, both pick the same free ports,
191
+ * and the second server fails to bind — the port-allocation race.)
192
+ *
193
+ * The lock is a single atomically-created lock file (`openSync(..,'wx')`); a
194
+ * stale lock left by a crashed process (older than `staleMs`) is reclaimed.
195
+ * Keep `fn` SHORT — allocation + reservation only, NEVER across a build/spawn.
196
+ */
197
+ function withRegistryLock(fn_1) {
198
+ return __awaiter(this, arguments, void 0, function* (fn, opts = {}) {
199
+ var _a, _b;
200
+ const staleMs = (_a = opts.staleMs) !== null && _a !== void 0 ? _a : 30000;
201
+ const timeoutMs = (_b = opts.timeoutMs) !== null && _b !== void 0 ? _b : 20000;
202
+ const start = Date.now();
203
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(LOCK_PATH), { recursive: true });
204
+ let fd = null;
205
+ while (fd === null) {
206
+ try {
207
+ fd = (0, fs_1.openSync)(LOCK_PATH, 'wx'); // atomic exclusive create — throws if held
208
+ }
209
+ catch (_c) {
210
+ try {
211
+ if (Date.now() - (0, fs_1.statSync)(LOCK_PATH).mtimeMs > staleMs)
212
+ (0, fs_1.unlinkSync)(LOCK_PATH); // reclaim a crashed holder
213
+ }
214
+ catch (_d) {
215
+ /* lock vanished between calls — just retry */
216
+ }
217
+ if (Date.now() - start > timeoutMs)
218
+ throw new Error(`registry lock busy for >${timeoutMs}ms (${LOCK_PATH})`);
219
+ yield delay(40 + Math.floor(Math.random() * 60)); // jittered backoff
220
+ }
221
+ }
222
+ try {
223
+ return yield fn();
224
+ }
225
+ finally {
226
+ try {
227
+ (0, fs_1.closeSync)(fd);
228
+ }
229
+ catch (_e) {
230
+ /* ignore */
231
+ }
232
+ try {
233
+ (0, fs_1.unlinkSync)(LOCK_PATH);
234
+ }
235
+ catch (_f) {
236
+ /* ignore */
237
+ }
238
+ }
239
+ });
240
+ }
241
+ /** Promise-based delay for the lock retry loop. */
242
+ function delay(ms) {
243
+ return new Promise((resolve) => setTimeout(resolve, ms));
244
+ }
149
245
  /** Path constants exported for tests + status displays. */
150
246
  exports.paths = {
151
247
  registry: REGISTRY_PATH,
@@ -91,29 +91,50 @@ function autoShardCount() {
91
91
  */
92
92
  function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
93
93
  return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
94
- const { shardIndex, skipBuild } = opts;
94
+ const { devDbName, shardIndex, skipBuild } = opts;
95
95
  const names = testStackNames(shardIndex);
96
- const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex);
96
+ const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex, devDbName);
97
97
  // Always start from a clean slate — reclaim a stale/crashed test session.
98
98
  yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
99
- // Allocate internal ports (avoid every other registry entry incl. the dev
100
- // session AND already-running sibling shards, plus anything currently
101
- // listening). Sibling shards are registered before the next one allocates,
102
- // so each shard lands on its own port pair.
103
- const reg = (0, dev_state_1.loadRegistry)();
104
- const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
105
- const apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
106
- if (apiPort)
107
- taken.add(apiPort);
108
- const appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
109
- const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
110
- const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
111
- for (const p of portsToCheck) {
112
- const r = snap.get(p);
113
- if (r)
114
- throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
115
- }
116
- // Caddy block for the test URLs.
99
+ // Allocate internal ports AND reserve them in the registry ATOMICALLY, under a
100
+ // cross-process lock so two parallel `lt dev test` runs (different ticket
101
+ // worktrees) can never read the registry, pick the same free ports, and both
102
+ // try to bind them during the long build window (the port-allocation race).
103
+ // The lock is held ONLY for this fast section; the build below runs unlocked +
104
+ // fully in parallel. Allocation avoids every other registry entry (dev session
105
+ // + sibling shards) plus anything currently listening.
106
+ let apiPort;
107
+ let appPort;
108
+ yield (0, dev_state_1.withRegistryLock)(() => __awaiter(this, void 0, void 0, function* () {
109
+ const reg = (0, dev_state_1.loadRegistry)();
110
+ const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
111
+ apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
112
+ if (apiPort)
113
+ taken.add(apiPort);
114
+ appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
115
+ const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
116
+ const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
117
+ for (const p of portsToCheck) {
118
+ const r = snap.get(p);
119
+ if (r)
120
+ throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
121
+ }
122
+ // Reserve immediately (still under the lock) so a concurrent run sees these
123
+ // ports as taken. PIDs are written to the session file after spawn, below.
124
+ const subdomainMap = {};
125
+ for (const [k, v] of Object.entries(testIdentity.subdomains))
126
+ subdomainMap[k] = v.hostname;
127
+ reg.projects[testIdentity.slug] = {
128
+ dbName,
129
+ internalPorts: { api: apiPort, app: appPort },
130
+ lastUsedAt: new Date().toISOString(),
131
+ path: layout.root,
132
+ subdomains: subdomainMap,
133
+ };
134
+ (0, dev_state_1.saveRegistry)(reg);
135
+ }));
136
+ // Caddy block for the test URLs (slug-keyed → no cross-stack conflict; safe
137
+ // outside the lock).
117
138
  const routes = [];
118
139
  if (testIdentity.subdomains.api && apiPort)
119
140
  routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
@@ -211,19 +232,10 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
211
232
  if (appSpawn)
212
233
  pids.app = appSpawn.pid;
213
234
  }
214
- // Persist test session + registry entry (so ports are reserved + status sees it).
235
+ // Persist the session (PIDs are known now). The registry entry (ports) was
236
+ // already reserved BEFORE the build, above, to avoid a concurrent-allocation
237
+ // race between parallel ticket test runs.
215
238
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
216
- const subdomainMap = {};
217
- for (const [k, v] of Object.entries(testIdentity.subdomains))
218
- subdomainMap[k] = v.hostname;
219
- reg.projects[testIdentity.slug] = {
220
- dbName,
221
- internalPorts: { api: apiPort, app: appPort },
222
- lastUsedAt: new Date().toISOString(),
223
- path: layout.root,
224
- subdomains: subdomainMap,
225
- };
226
- (0, dev_state_1.saveRegistry)(reg);
227
239
  // ENV bridge for external tooling (kept separate from the dev `.env`).
228
240
  (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
229
241
  // Wait for the test App to answer (best-effort).
@@ -251,10 +263,14 @@ function hasTestSession(root) {
251
263
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
252
264
  }
253
265
  /** Build the dedicated test identity + test DB name for a project. */
254
- function resolveTestSession(layout, baseIdentity, shardIndex) {
266
+ function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
255
267
  const names = testStackNames(shardIndex);
256
268
  const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
257
- const dbName = (0, dev_project_1.deriveTestDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug)) + names.dbSuffix;
269
+ // For a ticket worktree the caller passes the ticket dev DB (e.g.
270
+ // `svl-sports-system-2200`), so each ticket's test DB is its own
271
+ // (`…-2200-test[-<shard>]`) and tickets never share a test database.
272
+ const baseDb = devDbName !== null && devDbName !== void 0 ? devDbName : (0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug);
273
+ const dbName = (0, dev_project_1.deriveTestDbName)(baseDb) + names.dbSuffix;
258
274
  return { dbName, testIdentity };
259
275
  }
260
276
  /**
@@ -280,7 +296,11 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
280
296
  for (let index = 1; index <= total; index++) {
281
297
  log.info('');
282
298
  log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
283
- const ctx = yield bringUpTestSession(layout, baseIdentity, log, { shardIndex: index, skipBuild: index > 1 });
299
+ const ctx = yield bringUpTestSession(layout, baseIdentity, log, {
300
+ devDbName: opts.devDbName,
301
+ shardIndex: index,
302
+ skipBuild: index > 1,
303
+ });
284
304
  contexts.push({ ctx, index });
285
305
  }
286
306
  // Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
@@ -0,0 +1,343 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkGlobalSetupTicketSafe = checkGlobalSetupTicketSafe;
4
+ exports.clearTicketMarker = clearTicketMarker;
5
+ exports.defaultTicketBranch = defaultTicketBranch;
6
+ exports.deriveTicketId = deriveTicketId;
7
+ exports.dropDatabase = dropDatabase;
8
+ exports.gitBranchExists = gitBranchExists;
9
+ exports.gitFetch = gitFetch;
10
+ exports.gitMainRepoRoot = gitMainRepoRoot;
11
+ exports.listWorktrees = listWorktrees;
12
+ exports.pnpmInstall = pnpmInstall;
13
+ exports.readTicketMarker = readTicketMarker;
14
+ exports.resolveDevIdentity = resolveDevIdentity;
15
+ exports.worktreeAdd = worktreeAdd;
16
+ exports.worktreeDirtyOnlyGenerated = worktreeDirtyOnlyGenerated;
17
+ exports.worktreePathFor = worktreePathFor;
18
+ exports.worktreeRemove = worktreeRemove;
19
+ exports.worktreeSafetyReport = worktreeSafetyReport;
20
+ exports.writeTicketMarker = writeTicketMarker;
21
+ /**
22
+ * Per-ticket parallel dev environments for `lt dev` (used by the `lt ticket`
23
+ * command group).
24
+ *
25
+ * The model: ONE git repo, N git worktrees — one per ticket/feature — each on
26
+ * its own branch (created fresh from `origin/dev` so tickets are independent),
27
+ * each running its own `lt dev` stack on a SUFFIXED identity:
28
+ *
29
+ * ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost
30
+ * worktree <parent>/svl-2200/ branch feat/DEV-2200
31
+ * DB svl-sports-system-2200 (empty at start, isolated)
32
+ *
33
+ * A worktree is "tagged" with its ticket by a `.lt-dev/ticket` marker file the
34
+ * moment `lt ticket start` creates it. From then on EVERY `lt dev *` command run
35
+ * inside that worktree (up / down / test / status) reads the marker via
36
+ * {@link resolveDevIdentity} and operates on the ticket's isolated stack — no
37
+ * flags needed. So `lt ticket` only has to orchestrate the worktree + marker and
38
+ * can delegate the actual bring-up/-down to the normal `lt dev` commands.
39
+ */
40
+ const child_process_1 = require("child_process");
41
+ const fs_1 = require("fs");
42
+ const path_1 = require("path");
43
+ const dev_identity_1 = require("./dev-identity");
44
+ const dev_project_1 = require("./dev-project");
45
+ const dev_state_1 = require("./dev-state");
46
+ /** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
47
+ const TICKET_MARKER = 'ticket';
48
+ /**
49
+ * Check whether a project's Playwright `global-setup` (if it wipes a DB) would
50
+ * ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
51
+ * create (`<base>-<id>-test[-<n>]`). Used by `lt dev doctor` to WARN (never
52
+ * auto-edit) when a bespoke allow-list is too narrow to reset a ticket's test DB.
53
+ *
54
+ * Precise, not heuristic: it extracts the real regex literals from the file and
55
+ * tests them against a SYNTHETIC ticket test-DB name — so an already-safe
56
+ * allow-list (e.g. svl's `…-(?:[a-z0-9-]+-)?test…`) is correctly recognised and
57
+ * a shard-only one (`…-test-\d+`) is correctly flagged.
58
+ */
59
+ function checkGlobalSetupTicketSafe(layout) {
60
+ var _a, _b;
61
+ const candidates = [
62
+ ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'tests', 'global-setup.ts')] : []),
63
+ (0, path_1.join)(layout.root, 'tests', 'global-setup.ts'),
64
+ (0, path_1.join)(layout.root, 'global-setup.ts'),
65
+ ];
66
+ const file = (_a = candidates.find((f) => (0, fs_1.existsSync)(f))) !== null && _a !== void 0 ? _a : null;
67
+ if (!file)
68
+ return { file: null, hasDbReset: false, ticketSafe: true };
69
+ let content = '';
70
+ try {
71
+ content = (0, fs_1.readFileSync)(file, 'utf8');
72
+ }
73
+ catch (_c) {
74
+ return { file, hasDbReset: false, ticketSafe: true };
75
+ }
76
+ const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
77
+ if (!hasDbReset)
78
+ return { file, hasDbReset: false, ticketSafe: true };
79
+ // Synthetic per-ticket test DB names derived from the project's dev DB base.
80
+ const base = (0, dev_project_1.deriveDbName)(layout.apiDir, (0, dev_identity_1.buildIdentity)(layout.root).slug).replace(/-(local|dev)$/i, '');
81
+ const samples = [`${base}-tkprobe-test`, `${base}-tkprobe-test-2`];
82
+ // Test every regex LITERAL in the file against the samples (char classes kept intact).
83
+ const literals = (_b = content.match(/\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n[])+\/[a-z]*/gi)) !== null && _b !== void 0 ? _b : [];
84
+ let ticketSafe = false;
85
+ for (const lit of literals) {
86
+ const lastSlash = lit.lastIndexOf('/');
87
+ try {
88
+ const re = new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
89
+ if (samples.some((s) => re.test(s))) {
90
+ ticketSafe = true;
91
+ break;
92
+ }
93
+ }
94
+ catch (_d) {
95
+ /* not a valid regex literal (e.g. a division) — ignore */
96
+ }
97
+ }
98
+ return { file, hasDbReset, ticketSafe };
99
+ }
100
+ /** Clear the ticket marker (called on teardown). */
101
+ function clearTicketMarker(root) {
102
+ const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
103
+ if ((0, fs_1.existsSync)(file)) {
104
+ try {
105
+ (0, fs_1.rmSync)(file);
106
+ }
107
+ catch (_a) {
108
+ /* best-effort */
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * Default branch name for a ticket/feature: `feat/<name>` with the human ticket
114
+ * id preserved (case + number), only sanitised for git-ref safety.
115
+ *
116
+ * "DEV-2200" → feat/DEV-2200
117
+ * "checkout refactor" → feat/checkout-refactor
118
+ */
119
+ function defaultTicketBranch(name) {
120
+ const safe = name
121
+ .trim()
122
+ .replace(/\s+/g, '-')
123
+ .replace(/[^\w.\-/]+/g, '')
124
+ .replace(/^-+|-+$/g, '');
125
+ return `feat/${safe}`;
126
+ }
127
+ /**
128
+ * Derive the short env id from a ticket id or free feature name.
129
+ *
130
+ * "DEV-2200" → "2200" (ticket pattern → short numeric part)
131
+ * "ABC-123" → "123"
132
+ * "checkout-refactor" → "checkout-refactor" (free name → slug)
133
+ * (asOverride "cof") → "cof" (explicit `--as` wins)
134
+ *
135
+ * The id flows into the slug / URLs / DB, so it is always a clean slug.
136
+ */
137
+ function deriveTicketId(name, asOverride) {
138
+ if (asOverride && asOverride.trim())
139
+ return (0, dev_identity_1.slugify)(asOverride);
140
+ const trimmed = name.trim();
141
+ const ticketMatch = trimmed.match(/^[A-Za-z][A-Za-z0-9]*-(\d+)$/);
142
+ if (ticketMatch)
143
+ return ticketMatch[1];
144
+ return (0, dev_identity_1.slugify)(trimmed);
145
+ }
146
+ /** Drop a MongoDB database (best-effort, via `mongosh`). Returns true on success. */
147
+ function dropDatabase(dbName, mongoBaseUri = 'mongodb://127.0.0.1:27017') {
148
+ try {
149
+ (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'], {
150
+ stdio: 'ignore',
151
+ });
152
+ return true;
153
+ }
154
+ catch (_a) {
155
+ return false;
156
+ }
157
+ }
158
+ /** True if a local branch with this name already exists. */
159
+ function gitBranchExists(repoDir, branch) {
160
+ try {
161
+ git(repoDir, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
162
+ return true;
163
+ }
164
+ catch (_a) {
165
+ return false;
166
+ }
167
+ }
168
+ /** `git fetch <remote>` (so worktrees branch from the freshest base). */
169
+ function gitFetch(repoDir, remote = 'origin') {
170
+ git(repoDir, ['fetch', remote, '--prune']);
171
+ }
172
+ /**
173
+ * Resolve the MAIN repository root even when invoked from inside a worktree, so
174
+ * sibling worktrees are always created next to the primary checkout (never
175
+ * nested inside another worktree).
176
+ */
177
+ function gitMainRepoRoot(cwd) {
178
+ // `--git-common-dir` is the SHARED `.git` (same for every worktree); its
179
+ // parent is the main repo root.
180
+ const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
181
+ return (0, path_1.dirname)(commonDir);
182
+ }
183
+ /** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
184
+ function listWorktrees(repoDir) {
185
+ let out = '';
186
+ try {
187
+ out = git(repoDir, ['worktree', 'list', '--porcelain']);
188
+ }
189
+ catch (_a) {
190
+ return [];
191
+ }
192
+ const result = [];
193
+ let current = null;
194
+ for (const line of out.split(/\r?\n/)) {
195
+ if (line.startsWith('worktree ')) {
196
+ if (current === null || current === void 0 ? void 0 : current.path)
197
+ result.push(finalizeWorktree(current));
198
+ current = { branch: null, path: line.slice('worktree '.length) };
199
+ }
200
+ else if (line.startsWith('branch ') && current) {
201
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
202
+ }
203
+ }
204
+ if (current === null || current === void 0 ? void 0 : current.path)
205
+ result.push(finalizeWorktree(current));
206
+ return result;
207
+ }
208
+ /** Install dependencies in a freshly-created worktree (pnpm hard-links from the shared store → fast). */
209
+ function pnpmInstall(dir) {
210
+ const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
211
+ (0, child_process_1.execFileSync)(pnpmBin, ['install'], { cwd: dir, stdio: 'inherit' });
212
+ }
213
+ /** Read the ticket id this worktree is tagged with, or null. */
214
+ function readTicketMarker(root) {
215
+ const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
216
+ if (!(0, fs_1.existsSync)(file))
217
+ return null;
218
+ try {
219
+ const id = (0, fs_1.readFileSync)(file, 'utf8').trim();
220
+ return id || null;
221
+ }
222
+ catch (_a) {
223
+ return null;
224
+ }
225
+ }
226
+ /**
227
+ * Resolve the dev identity + DB name for a project root, ticket-aware.
228
+ *
229
+ * Order of precedence for the ticket id:
230
+ * 1. an explicit `--ticket <name>` option (raw → {@link deriveTicketId}),
231
+ * 2. the `.lt-dev/ticket` marker in the worktree (already an id),
232
+ * 3. none → the plain project identity (base dev stack).
233
+ *
234
+ * Used by `lt dev up / down / test / status` so all of them treat a ticket
235
+ * worktree identically without duplicating the suffix logic.
236
+ */
237
+ function resolveDevIdentity(layout, options) {
238
+ const base = (0, dev_identity_1.buildIdentity)(layout.root);
239
+ const baseDb = (0, dev_project_1.deriveDbName)(layout.apiDir, base.slug);
240
+ const flag = typeof (options === null || options === void 0 ? void 0 : options.ticket) === 'string' && options.ticket.trim() ? deriveTicketId(options.ticket) : null;
241
+ const ticket = flag !== null && flag !== void 0 ? flag : readTicketMarker(layout.root);
242
+ if (!ticket)
243
+ return { dbName: baseDb, identity: base, ticket: null };
244
+ return {
245
+ dbName: (0, dev_project_1.deriveTicketDbName)(baseDb, ticket),
246
+ identity: (0, dev_identity_1.buildTicketIdentity)(base, ticket),
247
+ ticket,
248
+ };
249
+ }
250
+ /**
251
+ * Add a worktree for a ticket. Creates the branch from `baseRef` when it does
252
+ * not exist yet, otherwise checks the existing branch out into the worktree.
253
+ */
254
+ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
255
+ if (gitBranchExists(repoDir, branch)) {
256
+ git(repoDir, ['worktree', 'add', worktreePath, branch]);
257
+ }
258
+ else {
259
+ git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
260
+ }
261
+ }
262
+ /** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
263
+ const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
264
+ /**
265
+ * True ONLY when a worktree has uncommitted changes AND every one is a
266
+ * framework-generated / ephemeral file (`.nuxtrc`, `.nuxt`, `.output`, …).
267
+ * `nuxt dev` rewrites the tracked `.nuxtrc` on boot, which would otherwise block
268
+ * `git worktree remove`; this lets `lt ticket stop` auto-clean those safely.
269
+ */
270
+ function worktreeDirtyOnlyGenerated(worktreePath) {
271
+ let out = '';
272
+ try {
273
+ out = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']);
274
+ }
275
+ catch (_a) {
276
+ return false;
277
+ }
278
+ const lines = out.split(/\r?\n/).filter((l) => l.trim());
279
+ if (lines.length === 0)
280
+ return false; // clean → no force needed
281
+ return lines.every((line) => GENERATED_PATHS.test(porcelainPath(line)));
282
+ }
283
+ /**
284
+ * Compute the sibling worktree path for a ticket: `<parent-of-main-repo>/<slug>-<id>`,
285
+ * so it sits right next to the primary checkout and matches the URL (`<slug>-<id>.localhost`).
286
+ */
287
+ function worktreePathFor(mainRepoRoot, slug, id) {
288
+ return (0, path_1.join)((0, path_1.dirname)(mainRepoRoot), `${slug}-${id}`);
289
+ }
290
+ /** Remove a worktree (the branch is kept). */
291
+ function worktreeRemove(repoDir, worktreePath, force = false) {
292
+ const args = ['worktree', 'remove', worktreePath];
293
+ if (force)
294
+ args.push('--force');
295
+ git(repoDir, args);
296
+ }
297
+ /**
298
+ * Unsaved-work report for a worktree, so `lt ticket stop` can WARN + refuse to
299
+ * delete it before the user has committed AND pushed: `dirtySource` are
300
+ * uncommitted NON-generated changes (lost on removal), `unpushed` are commits on
301
+ * the branch not on any remote (the branch is kept on stop, but local-only).
302
+ */
303
+ function worktreeSafetyReport(worktreePath) {
304
+ let status = '';
305
+ try {
306
+ status = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']);
307
+ }
308
+ catch (_a) {
309
+ return { dirtySource: [], unpushed: 0 };
310
+ }
311
+ const dirtySource = status
312
+ .split(/\r?\n/)
313
+ .filter((l) => l.trim())
314
+ .filter((l) => !GENERATED_PATHS.test(porcelainPath(l)));
315
+ let unpushed = 0;
316
+ try {
317
+ unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
318
+ }
319
+ catch (_b) {
320
+ /* no remotes / detached HEAD → cannot determine; treat as 0 */
321
+ }
322
+ return { dirtySource, unpushed };
323
+ }
324
+ /** Write the ticket marker that tags a worktree (created by `lt ticket start`). */
325
+ function writeTicketMarker(root, id) {
326
+ const dir = (0, path_1.join)(root, dev_state_1.paths.sessionDir);
327
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
328
+ (0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
329
+ }
330
+ function finalizeWorktree(partial) {
331
+ var _a, _b;
332
+ const path = (_a = partial.path) !== null && _a !== void 0 ? _a : '';
333
+ return { branch: (_b = partial.branch) !== null && _b !== void 0 ? _b : null, path, ticket: path ? readTicketMarker(path) : null };
334
+ }
335
+ /** Run a git command in `cwd`, returning trimmed stdout. Throws on non-zero exit. */
336
+ function git(cwd, args) {
337
+ return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
338
+ }
339
+ /** Path from a `git status --porcelain` line ("XY <path>" / "XY <old> -> <new>"). */
340
+ function porcelainPath(line) {
341
+ var _a;
342
+ return (_a = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()) !== null && _a !== void 0 ? _a : '';
343
+ }