@lenne.tech/cli 1.26.1 → 1.28.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.
@@ -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
+ }