@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.
- package/build/commands/dev/doctor.js +27 -1
- package/build/commands/dev/down.js +34 -10
- package/build/commands/dev/status.js +23 -4
- package/build/commands/dev/test.js +206 -119
- package/build/commands/dev/up.js +90 -50
- package/build/commands/ticket/list.js +78 -0
- package/build/commands/ticket/start.js +141 -0
- package/build/commands/ticket/stop.js +166 -0
- package/build/commands/ticket/switch.js +70 -0
- package/build/commands/ticket/test.js +80 -0
- package/build/commands/ticket/ticket.js +36 -0
- package/build/lib/dev-env-bridge.js +6 -6
- package/build/lib/dev-identity.js +36 -0
- package/build/lib/dev-migrate-helper.js +16 -6
- package/build/lib/dev-patches.js +71 -0
- package/build/lib/dev-process.js +154 -0
- package/build/lib/dev-project.js +34 -1
- package/build/lib/dev-state.js +105 -7
- package/build/lib/dev-test-session.js +430 -0
- package/build/lib/dev-ticket.js +343 -0
- package/docs/lt-dev-ticket-workflow.html +603 -0
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +32 -1
|
@@ -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
|
+
}
|