@lenne.tech/cli 1.35.1 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/dev/doctor.js +55 -0
- package/build/commands/dev/prune.js +152 -0
- package/build/commands/dev/test.js +3 -0
- package/build/commands/dev/up.js +63 -0
- package/build/commands/fullstack/update.js +10 -0
- package/build/commands/ticket/start.js +108 -11
- package/build/commands/ticket/stop.js +177 -39
- package/build/lib/dev-prune.js +211 -0
- package/build/lib/dev-state.js +10 -9
- package/build/lib/dev-test-session.js +24 -0
- package/build/lib/dev-ticket.js +422 -14
- package/build/lib/heal-check-wrapper.js +33 -3
- package/build/lib/hoist-workspace-pnpm-config.js +110 -2
- package/build/lib/workspace-integration.js +15 -5
- package/build/templates/check/check.mjs +208 -23
- package/docs/commands.md +130 -0
- package/docs/lt-dev-ticket-workflow.html +8 -8
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +1 -1
package/build/lib/dev-ticket.js
CHANGED
|
@@ -5,12 +5,21 @@ exports.clearTicketMarker = clearTicketMarker;
|
|
|
5
5
|
exports.defaultTicketBranch = defaultTicketBranch;
|
|
6
6
|
exports.deriveTicketId = deriveTicketId;
|
|
7
7
|
exports.dropDatabase = dropDatabase;
|
|
8
|
+
exports.dropDatabases = dropDatabases;
|
|
8
9
|
exports.gitBranchExists = gitBranchExists;
|
|
9
10
|
exports.gitFetch = gitFetch;
|
|
10
11
|
exports.gitMainRepoRoot = gitMainRepoRoot;
|
|
12
|
+
exports.gitRefExists = gitRefExists;
|
|
11
13
|
exports.installWorktreeDeps = installWorktreeDeps;
|
|
14
|
+
exports.isReservedTicketId = isReservedTicketId;
|
|
15
|
+
exports.isTicketScopedDb = isTicketScopedDb;
|
|
16
|
+
exports.keepDbFlag = keepDbFlag;
|
|
17
|
+
exports.listBaseRefChoices = listBaseRefChoices;
|
|
18
|
+
exports.listDatabaseNames = listDatabaseNames;
|
|
12
19
|
exports.listWorktrees = listWorktrees;
|
|
20
|
+
exports.planTicketDbDrop = planTicketDbDrop;
|
|
13
21
|
exports.readTicketMarker = readTicketMarker;
|
|
22
|
+
exports.resolveBaseRef = resolveBaseRef;
|
|
14
23
|
exports.resolveDevIdentity = resolveDevIdentity;
|
|
15
24
|
exports.worktreeAdd = worktreeAdd;
|
|
16
25
|
exports.worktreeDirtyOnlyAutoDiscardable = worktreeDirtyOnlyAutoDiscardable;
|
|
@@ -24,8 +33,9 @@ exports.writeTicketMarker = writeTicketMarker;
|
|
|
24
33
|
* command group).
|
|
25
34
|
*
|
|
26
35
|
* The model: ONE git repo, N git worktrees — one per ticket/feature — each on
|
|
27
|
-
* its own branch (created fresh from
|
|
28
|
-
*
|
|
36
|
+
* its own branch (created fresh from the repo's base branch — see
|
|
37
|
+
* {@link resolveBaseRef} — so tickets are independent), each running its own
|
|
38
|
+
* `lt dev` stack on a SUFFIXED identity:
|
|
29
39
|
*
|
|
30
40
|
* ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost
|
|
31
41
|
* worktree <parent>/svl-2200/ branch feat/DEV-2200
|
|
@@ -49,6 +59,23 @@ const dev_project_1 = require("./dev-project");
|
|
|
49
59
|
const dev_state_1 = require("./dev-state");
|
|
50
60
|
/** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
|
|
51
61
|
const TICKET_MARKER = 'ticket';
|
|
62
|
+
/** Where ticket databases live. Deliberately loopback-only — never a remote/prod host. */
|
|
63
|
+
const MONGO_BASE_URI = 'mongodb://127.0.0.1:27017';
|
|
64
|
+
/** Upper bound for a single `mongosh` call, so an unreachable Mongo cannot hang a teardown. */
|
|
65
|
+
const MONGOSH_TIMEOUT_MS = 10000;
|
|
66
|
+
/**
|
|
67
|
+
* Ticket ids that would make the ticket's derived database collide with a
|
|
68
|
+
* PROJECT-level database.
|
|
69
|
+
*
|
|
70
|
+
* {@link deriveTicketDbName} and {@link deriveTestDbName} both strip a trailing
|
|
71
|
+
* `-(local|dev)` before appending their own suffix, so these ids round-trip onto
|
|
72
|
+
* the project's own DBs — e.g. project db `imo-local` + ticket id `local` derives
|
|
73
|
+
* back to `imo-local`, and its test db to `imo-test`. Both are the DEVELOPER's
|
|
74
|
+
* databases, not the ticket's. Rejecting the ids at creation keeps the collision
|
|
75
|
+
* from ever existing; {@link isTicketScopedDb} is the second line of defence for
|
|
76
|
+
* environments created before this guard.
|
|
77
|
+
*/
|
|
78
|
+
const RESERVED_TICKET_IDS = new Set(['ci', 'dev', 'e2e', 'local', 'prod', 'production', 'staging', 'test']);
|
|
52
79
|
/**
|
|
53
80
|
* Check whether a project's Playwright `global-setup` (if it wipes a DB) would
|
|
54
81
|
* ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
|
|
@@ -77,6 +104,11 @@ function checkGlobalSetupTicketSafe(layout) {
|
|
|
77
104
|
catch (_c) {
|
|
78
105
|
return { file, hasDbReset: false, ticketSafe: true };
|
|
79
106
|
}
|
|
107
|
+
// The API-style per-run scheme (db-lifecycle reporter) manages its own
|
|
108
|
+
// cleanup + naming — nothing for this Playwright-oriented check to judge.
|
|
109
|
+
if (content.includes('db-lifecycle.reporter')) {
|
|
110
|
+
return { file, hasDbReset: true, ticketSafe: true };
|
|
111
|
+
}
|
|
80
112
|
const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
|
|
81
113
|
if (!hasDbReset)
|
|
82
114
|
return { file, hasDbReset: false, ticketSafe: true };
|
|
@@ -147,17 +179,63 @@ function deriveTicketId(name, asOverride) {
|
|
|
147
179
|
return ticketMatch[1];
|
|
148
180
|
return (0, dev_identity_1.slugify)(trimmed);
|
|
149
181
|
}
|
|
150
|
-
/**
|
|
151
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Drop a MongoDB database via `mongosh`.
|
|
184
|
+
*
|
|
185
|
+
* SHAPE IS DELIBERATE — do NOT "optimise" it into a single multi-DB `--eval`:
|
|
186
|
+
* the database name travels in the URI PATH (percent-encoded, so it cannot
|
|
187
|
+
* escape into the host/query and hijack the connection), and `--eval` is a
|
|
188
|
+
* CONSTANT string, so the name is never interpolated into JavaScript. Together
|
|
189
|
+
* with `execFileSync`'s argv form (no shell), that is what makes an arbitrary
|
|
190
|
+
* db name un-injectable. Batching the drops would require building the eval
|
|
191
|
+
* from the names — trading that property away to save one process spawn.
|
|
192
|
+
*
|
|
193
|
+
* Returns WHY it failed, not just that it did: a missing `mongosh` binary and an
|
|
194
|
+
* unreachable Mongo need completely different fixes from the user, and collapsing
|
|
195
|
+
* both into `false` is how a "cleanup" feature ends up silently cleaning nothing.
|
|
196
|
+
*/
|
|
197
|
+
function dropDatabase(dbName, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
|
|
152
198
|
try {
|
|
153
|
-
(0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'],
|
|
154
|
-
|
|
199
|
+
(0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'],
|
|
200
|
+
// Without a timeout this blocks for as long as mongosh feels like: a Mongo that
|
|
201
|
+
// accepts TCP but never answers leaves the driver's 30s server-selection default
|
|
202
|
+
// to expire — silently, since stdio is ignored. On a default teardown path that
|
|
203
|
+
// is unacceptable, so we impose our own bound.
|
|
204
|
+
{ killSignal: 'SIGKILL', stdio: 'ignore', timeout: MONGOSH_TIMEOUT_MS });
|
|
205
|
+
return 'dropped';
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
if (e.code !== 'ENOENT')
|
|
209
|
+
return 'unreachable';
|
|
210
|
+
// mongosh is not installed — a REAL machine state that silently disabled every DB
|
|
211
|
+
// drop for months (ticket DBs piled up because `lt ticket stop` only warned).
|
|
212
|
+
// Fall back to the PROJECT's own `mongodb` driver when the caller provides
|
|
213
|
+
// resolution paths (the API project always depends on it via nest-server).
|
|
214
|
+
const res = runWithProjectDriver(driverPaths, 'await c.db(process.env.LT_MONGO_DB).dropDatabase();', {
|
|
215
|
+
LT_MONGO_DB: dbName,
|
|
216
|
+
LT_MONGO_URI: mongoBaseUri,
|
|
155
217
|
});
|
|
156
|
-
|
|
218
|
+
if (res.outcome === 'no-driver')
|
|
219
|
+
return 'no-mongosh';
|
|
220
|
+
return res.outcome === 'ok' ? 'dropped' : 'unreachable';
|
|
157
221
|
}
|
|
158
|
-
|
|
159
|
-
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Drop several databases, stopping at the first failure.
|
|
225
|
+
*
|
|
226
|
+
* A failure is never per-database: if `mongosh` is missing it is missing for all of
|
|
227
|
+
* them, and if Mongo is unreachable it is unreachable for all of them. Retrying each
|
|
228
|
+
* name would just multiply the timeout (2 names × 10s of hanging, for one diagnosis).
|
|
229
|
+
*/
|
|
230
|
+
function dropDatabases(dbNames, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
|
|
231
|
+
const dropped = [];
|
|
232
|
+
for (const db of dbNames) {
|
|
233
|
+
const outcome = dropDatabase(db, mongoBaseUri, driverPaths);
|
|
234
|
+
if (outcome !== 'dropped')
|
|
235
|
+
return { dropped, reason: outcome };
|
|
236
|
+
dropped.push(db);
|
|
160
237
|
}
|
|
238
|
+
return { dropped, reason: null };
|
|
161
239
|
}
|
|
162
240
|
/** True if a local branch with this name already exists. */
|
|
163
241
|
function gitBranchExists(repoDir, branch) {
|
|
@@ -184,6 +262,18 @@ function gitMainRepoRoot(cwd) {
|
|
|
184
262
|
const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
185
263
|
return (0, path_1.dirname)(commonDir);
|
|
186
264
|
}
|
|
265
|
+
/** True if `ref` resolves to a commit in the repo (any ref kind: local, remote, tag, sha). */
|
|
266
|
+
function gitRefExists(repoDir, ref) {
|
|
267
|
+
try {
|
|
268
|
+
(0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
269
|
+
stdio: 'ignore',
|
|
270
|
+
});
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
catch (_a) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
187
277
|
/**
|
|
188
278
|
* Install dependencies in a freshly-created worktree. Auto-detects the
|
|
189
279
|
* project's package manager from its lockfile (pnpm hard-links from the
|
|
@@ -194,6 +284,134 @@ function installWorktreeDeps(dir) {
|
|
|
194
284
|
const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
|
|
195
285
|
(0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
|
|
196
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* True for ticket ids whose derived database would collide with a project-level
|
|
289
|
+
* database (see {@link RESERVED_TICKET_IDS}). `lt ticket start` refuses them, so
|
|
290
|
+
* the collision cannot be created in the first place.
|
|
291
|
+
*/
|
|
292
|
+
function isReservedTicketId(id) {
|
|
293
|
+
return RESERVED_TICKET_IDS.has(id.trim().toLowerCase());
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* True only when `dbName` provably belongs to ticket `ticketId` of the project whose
|
|
297
|
+
* own dev database is `projectDevDb`.
|
|
298
|
+
*
|
|
299
|
+
* This is the LAST gate before an irreversible drop, and it exists because the name
|
|
300
|
+
* being dropped is *derived* (or read from a slug-keyed global registry) rather than
|
|
301
|
+
* observed. Two things can therefore steer it at the wrong database:
|
|
302
|
+
*
|
|
303
|
+
* • a reserved ticket id (`local`/`dev`/`test`) derives back onto the project's own
|
|
304
|
+
* dev/test DB — the suffix check alone would happily accept that, because the name
|
|
305
|
+
* really does look ticket-shaped. Hence the explicit project-DB exclusion FIRST.
|
|
306
|
+
* • a registry entry under `<slug>-<id>` that in truth belongs to a different
|
|
307
|
+
* checkout (the registry is global and keyed by slug alone) carries THAT project's
|
|
308
|
+
* dbName — which will not match this ticket's shape and is rejected here.
|
|
309
|
+
*
|
|
310
|
+
* A derivation that drifts must fail closed: refuse, never guess.
|
|
311
|
+
*/
|
|
312
|
+
function isTicketScopedDb(dbName, ticketId, projectDevDb) {
|
|
313
|
+
if (dbName === projectDevDb || dbName === (0, dev_project_1.deriveTestDbName)(projectDevDb))
|
|
314
|
+
return false;
|
|
315
|
+
const base = projectDevDb.replace(/-(local|dev)$/i, '');
|
|
316
|
+
if (dbName === `${base}-${ticketId}` || dbName === `${base}-${ticketId}-test`)
|
|
317
|
+
return true;
|
|
318
|
+
// Sharded Playwright stacks (`lt dev test --shard N`) derive one DB per shard:
|
|
319
|
+
// `<base>-<id>-test-<n>`. Without this arm they were invisible to the drop plan
|
|
320
|
+
// and orphaned on every sharded ticket test run.
|
|
321
|
+
return new RegExp(`^${escapeRegExpForDb(`${base}-${ticketId}-test-`)}\\d+$`).test(dbName);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Read the `--keep-db` opt-out. FAIL-CLOSED BY CONSTRUCTION.
|
|
325
|
+
*
|
|
326
|
+
* gluegun parses argv with yargs-parser and declares NO booleans, so the flag does not
|
|
327
|
+
* arrive as `true` in most of the spellings people actually type:
|
|
328
|
+
*
|
|
329
|
+
* --keep-db → true (boolean)
|
|
330
|
+
* --keep-db=true → 'true' (STRING)
|
|
331
|
+
* --keep-db true → 'true' (STRING)
|
|
332
|
+
* --keep-db 2200 → 2200 (NUMBER — and the ticket id is GONE from positionals)
|
|
333
|
+
* --no-keep-db → false (boolean)
|
|
334
|
+
*
|
|
335
|
+
* A strict `=== true` test reads three of those as "the user did not ask to keep" and
|
|
336
|
+
* destroys the very data they asked to keep. That shape is right for `--force`, where a
|
|
337
|
+
* parse quirk means "don't force" (safe); it is exactly backwards for a flag that
|
|
338
|
+
* PREVENTS destruction.
|
|
339
|
+
*
|
|
340
|
+
* So: the flag's PRESENCE means keep. Only an explicit negation still drops.
|
|
341
|
+
*/
|
|
342
|
+
function keepDbFlag(options = {}) {
|
|
343
|
+
var _a;
|
|
344
|
+
const raw = (_a = options.keepDb) !== null && _a !== void 0 ? _a : options['keep-db'];
|
|
345
|
+
if (raw === undefined || raw === null)
|
|
346
|
+
return { keep: false, strayValue: null };
|
|
347
|
+
if (raw === false || raw === 'false' || raw === 0 || raw === '0')
|
|
348
|
+
return { keep: false, strayValue: null };
|
|
349
|
+
// The flag takes no value, so anything that is not an affirmation is a positional
|
|
350
|
+
// yargs-parser swallowed. Hand it back rather than silently losing it.
|
|
351
|
+
const affirmative = raw === true || ['1', 'true', 'yes'].includes(String(raw).toLowerCase());
|
|
352
|
+
return { keep: true, strayValue: affirmative ? null : String(raw) };
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Branches offered when the user has to pick a base ref interactively (no
|
|
356
|
+
* candidate matched). Remote + local branches, most recently committed first,
|
|
357
|
+
* `origin/HEAD` filtered out (it is an alias, not a branch).
|
|
358
|
+
*/
|
|
359
|
+
function listBaseRefChoices(repoDir, limit = 25) {
|
|
360
|
+
let out = '';
|
|
361
|
+
try {
|
|
362
|
+
out = git(repoDir, [
|
|
363
|
+
'for-each-ref',
|
|
364
|
+
'--sort=-committerdate',
|
|
365
|
+
'--format=%(refname:short)',
|
|
366
|
+
'refs/remotes',
|
|
367
|
+
'refs/heads',
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
catch (_a) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
const refs = out
|
|
374
|
+
.split(/\r?\n/)
|
|
375
|
+
.map((l) => l.trim())
|
|
376
|
+
.filter((l) => l && !l.endsWith('/HEAD'));
|
|
377
|
+
return [...new Set(refs)].slice(0, limit);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* List all database names on the local Mongo, or null when that is impossible
|
|
381
|
+
* (mongosh missing / Mongo unreachable).
|
|
382
|
+
*
|
|
383
|
+
* Used to OBSERVE what actually exists instead of deriving it: sharded test DBs
|
|
384
|
+
* (`<base>-<id>-test-<n>`) have an unbounded index, so no static candidate list
|
|
385
|
+
* can cover them. Callers must still gate every observed name through
|
|
386
|
+
* {@link isTicketScopedDb} before dropping — observation widens the candidate
|
|
387
|
+
* set, never the safety rules.
|
|
388
|
+
*/
|
|
389
|
+
function listDatabaseNames(mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
|
|
390
|
+
try {
|
|
391
|
+
const out = (0, child_process_1.execFileSync)('mongosh', [
|
|
392
|
+
`${mongoBaseUri}/admin`,
|
|
393
|
+
'--quiet',
|
|
394
|
+
'--eval',
|
|
395
|
+
'db.adminCommand({ listDatabases: 1, nameOnly: true }).databases.forEach(d => print(d.name))',
|
|
396
|
+
], { killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'], timeout: MONGOSH_TIMEOUT_MS })
|
|
397
|
+
.toString()
|
|
398
|
+
.split('\n')
|
|
399
|
+
.map((line) => line.trim())
|
|
400
|
+
.filter(Boolean);
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
catch (_a) {
|
|
404
|
+
// Fall back to the project's own `mongodb` driver (see dropDatabase).
|
|
405
|
+
const res = runWithProjectDriver(driverPaths, "const { databases } = await c.db('admin').admin().listDatabases({ nameOnly: true });" +
|
|
406
|
+
' databases.forEach((d) => console.log(d.name));', { LT_MONGO_URI: mongoBaseUri });
|
|
407
|
+
if (res.outcome !== 'ok')
|
|
408
|
+
return null;
|
|
409
|
+
return res.stdout
|
|
410
|
+
.split('\n')
|
|
411
|
+
.map((line) => line.trim())
|
|
412
|
+
.filter(Boolean);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
197
415
|
/** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
|
|
198
416
|
function listWorktrees(repoDir) {
|
|
199
417
|
let out = '';
|
|
@@ -219,6 +437,35 @@ function listWorktrees(repoDir) {
|
|
|
219
437
|
result.push(finalizeWorktree(current));
|
|
220
438
|
return result;
|
|
221
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Decide which databases `lt ticket stop` may drop — pure, so the decision guarding an
|
|
442
|
+
* irreversible action is unit-testable instead of buried in a gluegun `run()` closure.
|
|
443
|
+
*
|
|
444
|
+
* The name to drop is never observed; it is DERIVED, or read from the global registry.
|
|
445
|
+
* Both sources can point at a database the ticket never owned, so every candidate is
|
|
446
|
+
* validated against the ticket's shape and anything that does not match is refused:
|
|
447
|
+
*
|
|
448
|
+
* • App-only project → no MongoDB exists at all → nothing to drop.
|
|
449
|
+
* • Foreign registry → the registry is keyed by slug alone, so `<slug>-<id>` can be
|
|
450
|
+
* a genuinely different project (`myapp` + ticket `admin` vs. a
|
|
451
|
+
* real `myapp-admin`). Its `dbName` is that project's — ignore it.
|
|
452
|
+
* • Reserved ticket id → `local`/`dev`/`test` derive back onto the PROJECT's own dev or
|
|
453
|
+
* test DB. Refused by {@link isTicketScopedDb}.
|
|
454
|
+
*/
|
|
455
|
+
function planTicketDbDrop(args) {
|
|
456
|
+
var _a, _b;
|
|
457
|
+
const { hasApi, observedDbNames, projectDevDb, registryEntry, ticketId, worktreePath } = args;
|
|
458
|
+
if (!hasApi)
|
|
459
|
+
return { foreignEntryPath: null, refused: [], targets: [] };
|
|
460
|
+
const trusted = (registryEntry === null || registryEntry === void 0 ? void 0 : registryEntry.path) && (0, dev_state_1.sameRealPath)(registryEntry.path, worktreePath) ? registryEntry : undefined;
|
|
461
|
+
const foreignEntryPath = registryEntry && !trusted ? ((_a = registryEntry.path) !== null && _a !== void 0 ? _a : null) : null;
|
|
462
|
+
const devDb = (_b = trusted === null || trusted === void 0 ? void 0 : trusted.dbName) !== null && _b !== void 0 ? _b : (0, dev_project_1.deriveTicketDbName)(projectDevDb, ticketId);
|
|
463
|
+
const shardCandidates = (observedDbNames !== null && observedDbNames !== void 0 ? observedDbNames : []).filter((name) => name.startsWith(`${devDb}-test-`));
|
|
464
|
+
// eslint-disable-next-line perfectionist/sort-sets -- dev DB first is the meaningful (and drop) order
|
|
465
|
+
const candidates = [...new Set([devDb, (0, dev_project_1.deriveTestDbName)(devDb), ...shardCandidates])];
|
|
466
|
+
const targets = candidates.filter((db) => isTicketScopedDb(db, ticketId, projectDevDb));
|
|
467
|
+
return { foreignEntryPath, refused: candidates.filter((db) => !targets.includes(db)), targets };
|
|
468
|
+
}
|
|
222
469
|
/** Read the ticket id this worktree is tagged with, or null. */
|
|
223
470
|
function readTicketMarker(root) {
|
|
224
471
|
const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
|
|
@@ -232,6 +479,37 @@ function readTicketMarker(root) {
|
|
|
232
479
|
return null;
|
|
233
480
|
}
|
|
234
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Resolve the ref a fresh ticket branch is created from.
|
|
484
|
+
*
|
|
485
|
+
* An explicit `--base` always wins (and is reported as missing when it does not
|
|
486
|
+
* resolve). Otherwise the repo's base branch is DISCOVERED, because it is not
|
|
487
|
+
* called the same everywhere — `nest-server` uses `develop`, the lt starters use
|
|
488
|
+
* `dev`, GitHub defaults to `main`:
|
|
489
|
+
*
|
|
490
|
+
* origin/dev → origin/develop → the remote's HEAD → origin/main → origin/master
|
|
491
|
+
* → the same names as LOCAL branches (repo without a remote)
|
|
492
|
+
*
|
|
493
|
+
* `ref: null` means none of them exists — the caller then asks the user
|
|
494
|
+
* (`lt ticket start`) instead of failing on a hard-coded `origin/dev`.
|
|
495
|
+
*/
|
|
496
|
+
function resolveBaseRef(repoDir, explicit) {
|
|
497
|
+
var _a;
|
|
498
|
+
const wanted = explicit === null || explicit === void 0 ? void 0 : explicit.trim();
|
|
499
|
+
if (wanted) {
|
|
500
|
+
return {
|
|
501
|
+
candidates: [wanted],
|
|
502
|
+
explicit: true,
|
|
503
|
+
ref: gitRefExists(repoDir, wanted) ? wanted : null,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const candidates = baseRefCandidates(repoDir);
|
|
507
|
+
return {
|
|
508
|
+
candidates,
|
|
509
|
+
explicit: false,
|
|
510
|
+
ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
235
513
|
/**
|
|
236
514
|
* Resolve the dev identity + DB name for a project root, ticket-aware.
|
|
237
515
|
*
|
|
@@ -268,6 +546,47 @@ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
|
|
|
268
546
|
git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
|
|
269
547
|
}
|
|
270
548
|
}
|
|
549
|
+
/** Escape a literal database-name fragment for use inside a RegExp. */
|
|
550
|
+
function escapeRegExpForDb(value) {
|
|
551
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Run a CONSTANT driver snippet via `node -e` using the project's own `mongodb`
|
|
555
|
+
* package (nest-server APIs always depend on it). SAME injection-safety doctrine
|
|
556
|
+
* as the mongosh path: the eval string is constant, every variable travels via
|
|
557
|
+
* env — never interpolated into code. `action` receives the connected client as
|
|
558
|
+
* `c` and must be a constant string from THIS module, never caller input.
|
|
559
|
+
*/
|
|
560
|
+
function runWithProjectDriver(driverPaths, action, env) {
|
|
561
|
+
let driver = null;
|
|
562
|
+
for (const dir of driverPaths.filter(Boolean)) {
|
|
563
|
+
try {
|
|
564
|
+
driver = require.resolve('mongodb', { paths: [dir] });
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
catch (_a) {
|
|
568
|
+
/* try next path */
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (!driver)
|
|
572
|
+
return { outcome: 'no-driver', stdout: '' };
|
|
573
|
+
const script = 'const { MongoClient } = require(process.env.LT_MONGO_DRIVER);' +
|
|
574
|
+
'MongoClient.connect(process.env.LT_MONGO_URI, { serverSelectionTimeoutMS: 8000 })' +
|
|
575
|
+
`.then(async (c) => { ${action} await c.close(); process.exit(0); })` +
|
|
576
|
+
'.catch(() => process.exit(2));';
|
|
577
|
+
try {
|
|
578
|
+
const stdout = (0, child_process_1.execFileSync)(process.execPath, ['-e', script], {
|
|
579
|
+
env: Object.assign(Object.assign(Object.assign({}, process.env), env), { LT_MONGO_DRIVER: driver }),
|
|
580
|
+
killSignal: 'SIGKILL',
|
|
581
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
582
|
+
timeout: MONGOSH_TIMEOUT_MS,
|
|
583
|
+
}).toString();
|
|
584
|
+
return { outcome: 'ok', stdout };
|
|
585
|
+
}
|
|
586
|
+
catch (_b) {
|
|
587
|
+
return { outcome: 'failed', stdout: '' };
|
|
588
|
+
}
|
|
589
|
+
}
|
|
271
590
|
/** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
|
|
272
591
|
const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
|
|
273
592
|
/** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
|
|
@@ -323,7 +642,13 @@ function worktreeSafetyReport(worktreePath) {
|
|
|
323
642
|
const { realDirty } = classifyWorktreeDirt(worktreePath);
|
|
324
643
|
let unpushed = 0;
|
|
325
644
|
try {
|
|
326
|
-
|
|
645
|
+
// Only meaningful when a remote EXISTS: in a local-only repo (e.g. a fresh
|
|
646
|
+
// `lt fullstack init` without --git-link) "push first" is impossible, and the
|
|
647
|
+
// branch survives worktree removal anyway — counting every commit as
|
|
648
|
+
// "unpushed" would turn the gate into a permanent dead end.
|
|
649
|
+
if (git(worktreePath, ['remote'])) {
|
|
650
|
+
unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
|
|
651
|
+
}
|
|
327
652
|
}
|
|
328
653
|
catch (_a) {
|
|
329
654
|
/* no remotes / detached HEAD → cannot determine; treat as 0 */
|
|
@@ -336,6 +661,26 @@ function writeTicketMarker(root, id) {
|
|
|
336
661
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
337
662
|
(0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
|
|
338
663
|
}
|
|
664
|
+
/** Branch names a project may use as its integration branch, in preference order. */
|
|
665
|
+
const BASE_BRANCH_NAMES = ['dev', 'develop', 'main', 'master'];
|
|
666
|
+
/**
|
|
667
|
+
* The refs {@link resolveBaseRef} probes, in order. Remote branches first (a
|
|
668
|
+
* ticket must start from the freshest integration state), the remote's own HEAD
|
|
669
|
+
* ahead of the guessed `main`/`master`, and the local branches last so a repo
|
|
670
|
+
* without a remote still works.
|
|
671
|
+
*/
|
|
672
|
+
function baseRefCandidates(repoDir) {
|
|
673
|
+
const remoteHead = gitRemoteHead(repoDir);
|
|
674
|
+
const ordered = [
|
|
675
|
+
'origin/dev',
|
|
676
|
+
'origin/develop',
|
|
677
|
+
...(remoteHead ? [remoteHead] : []),
|
|
678
|
+
'origin/main',
|
|
679
|
+
'origin/master',
|
|
680
|
+
...BASE_BRANCH_NAMES,
|
|
681
|
+
];
|
|
682
|
+
return ordered.filter((ref, i) => ordered.indexOf(ref) === i); // dedupe, order preserved
|
|
683
|
+
}
|
|
339
684
|
/**
|
|
340
685
|
* Split a worktree's uncommitted changes into "auto-discardable" (framework-
|
|
341
686
|
* generated files + pristine lt-dev self-heal patches) and "real" developer work.
|
|
@@ -347,10 +692,14 @@ function classifyWorktreeDirt(worktreePath) {
|
|
|
347
692
|
const realDirty = [];
|
|
348
693
|
for (const line of gitStatusPorcelain(worktreePath)) {
|
|
349
694
|
const p = porcelainPath(line);
|
|
350
|
-
if (GENERATED_PATHS.test(p) ||
|
|
695
|
+
if (GENERATED_PATHS.test(p) ||
|
|
696
|
+
isPristineLtDevPatch(worktreePath, p) ||
|
|
697
|
+
isPristineLtDevGitignoreAppend(worktreePath, p)) {
|
|
351
698
|
autoDiscardable.push(p);
|
|
352
|
-
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
353
701
|
realDirty.push(p);
|
|
702
|
+
}
|
|
354
703
|
}
|
|
355
704
|
return { autoDiscardable, realDirty };
|
|
356
705
|
}
|
|
@@ -363,6 +712,23 @@ function finalizeWorktree(partial) {
|
|
|
363
712
|
function git(cwd, args) {
|
|
364
713
|
return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
365
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* The branch a remote's HEAD points at (`origin/main`, `origin/develop`, …), or
|
|
717
|
+
* null. stderr is swallowed: a repo whose `refs/remotes/<remote>/HEAD` was never
|
|
718
|
+
* set is the normal case here, not an error worth printing.
|
|
719
|
+
*/
|
|
720
|
+
function gitRemoteHead(repoDir, remote = 'origin') {
|
|
721
|
+
try {
|
|
722
|
+
const out = (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], {
|
|
723
|
+
encoding: 'utf8',
|
|
724
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
725
|
+
});
|
|
726
|
+
return out.trim() || null;
|
|
727
|
+
}
|
|
728
|
+
catch (_a) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
366
732
|
/**
|
|
367
733
|
* `git status --porcelain` lines, each kept VERBATIM (not trimmed).
|
|
368
734
|
*
|
|
@@ -374,13 +740,53 @@ function git(cwd, args) {
|
|
|
374
740
|
function gitStatusPorcelain(cwd) {
|
|
375
741
|
let out = '';
|
|
376
742
|
try {
|
|
377
|
-
out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], {
|
|
743
|
+
out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], {
|
|
744
|
+
encoding: 'utf8',
|
|
745
|
+
});
|
|
378
746
|
}
|
|
379
747
|
catch (_a) {
|
|
380
748
|
return [];
|
|
381
749
|
}
|
|
382
750
|
return out.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
383
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* True when the dirty `.gitignore` differs from HEAD by EXACTLY the `.lt-dev/`
|
|
754
|
+
* line that `lt dev up` appends on every start (see `addToGitignore`). Older
|
|
755
|
+
* templates ship without that line, so EVERY ticket worktree's first `up` dirties
|
|
756
|
+
* `.gitignore` — and without this check every `lt ticket stop` then refuses over
|
|
757
|
+
* a machine-made change. Any other edit (removed lines, additional added lines)
|
|
758
|
+
* keeps it real work.
|
|
759
|
+
*/
|
|
760
|
+
function isPristineLtDevGitignoreAppend(worktreePath, relPath) {
|
|
761
|
+
if (relPath !== '.gitignore')
|
|
762
|
+
return false;
|
|
763
|
+
let head = '';
|
|
764
|
+
try {
|
|
765
|
+
head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', 'HEAD:.gitignore'], {
|
|
766
|
+
encoding: 'utf8',
|
|
767
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
catch (_a) {
|
|
771
|
+
return false; // not tracked in HEAD → a NEW .gitignore is real work
|
|
772
|
+
}
|
|
773
|
+
let work = '';
|
|
774
|
+
try {
|
|
775
|
+
work = (0, fs_1.readFileSync)((0, path_1.join)(worktreePath, '.gitignore'), 'utf8');
|
|
776
|
+
}
|
|
777
|
+
catch (_b) {
|
|
778
|
+
return false; // deleted → real change
|
|
779
|
+
}
|
|
780
|
+
const norm = (s) => s
|
|
781
|
+
.split(/\r?\n/)
|
|
782
|
+
.map((l) => l.trim())
|
|
783
|
+
.filter(Boolean);
|
|
784
|
+
const headLines = norm(head);
|
|
785
|
+
const workLines = norm(work);
|
|
786
|
+
const removed = headLines.filter((l) => !workLines.includes(l));
|
|
787
|
+
const added = workLines.filter((l) => !headLines.includes(l));
|
|
788
|
+
return removed.length === 0 && added.length > 0 && added.every((l) => l === '.lt-dev/');
|
|
789
|
+
}
|
|
384
790
|
/**
|
|
385
791
|
* True when the dirty tracked file at `relPath` (relative to the worktree root)
|
|
386
792
|
* differs from its committed (HEAD) version by EXACTLY the lt-dev self-heal
|
|
@@ -398,7 +804,9 @@ function isPristineLtDevPatch(worktreePath, relPath) {
|
|
|
398
804
|
try {
|
|
399
805
|
// NOT the trimming `git()` helper — the trailing newline must survive so the
|
|
400
806
|
// comparison against the (untrimmed) working-tree content is exact.
|
|
401
|
-
head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], {
|
|
807
|
+
head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], {
|
|
808
|
+
encoding: 'utf8',
|
|
809
|
+
});
|
|
402
810
|
}
|
|
403
811
|
catch (_a) {
|
|
404
812
|
return false; // not tracked at HEAD (e.g. a brand-new file) → never auto-discard
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.healCheckWrapper = healCheckWrapper;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
4
5
|
const fs_1 = require("fs");
|
|
5
6
|
const path_1 = require("path");
|
|
6
7
|
/** Marker value for the report-driven check wrapper. */
|
|
@@ -39,12 +40,22 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
39
40
|
return changed;
|
|
40
41
|
}
|
|
41
42
|
// 1. Ensure scripts/check.mjs exists and matches the bundled canonical version.
|
|
43
|
+
// GUARD: never overwrite a check.mjs with UNCOMMITTED local modifications —
|
|
44
|
+
// that would silently destroy work that exists nowhere else. A committed
|
|
45
|
+
// divergence is overwritten (recoverable via `git diff`/history, and the
|
|
46
|
+
// canonical wrapper is the supported version); an uncommitted one is kept and
|
|
47
|
+
// reported via the 'scripts/check.mjs (skipped: uncommitted changes)' entry.
|
|
42
48
|
const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
|
|
43
49
|
const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
|
|
44
50
|
if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
|
|
45
|
-
(0, fs_1.
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
if ((0, fs_1.existsSync)(targetScript) && hasUncommittedChanges(projectRoot, 'scripts/check.mjs')) {
|
|
52
|
+
changed.push('scripts/check.mjs (skipped: uncommitted changes — commit or discard them, then re-run)');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
|
|
56
|
+
(0, fs_1.copyFileSync)(assetPath, targetScript);
|
|
57
|
+
changed.push('scripts/check.mjs');
|
|
58
|
+
}
|
|
48
59
|
}
|
|
49
60
|
// 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
|
|
50
61
|
if (scripts.check !== WRAPPER) {
|
|
@@ -57,3 +68,22 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
57
68
|
}
|
|
58
69
|
return changed;
|
|
59
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* True when `relPath` has UNCOMMITTED modifications in the project's git tree.
|
|
73
|
+
* Overwriting such a file would destroy work that exists nowhere else — a
|
|
74
|
+
* committed file is recoverable via git, an uncommitted edit is not. Non-git
|
|
75
|
+
* projects (or git errors) return false: there the overwrite is the only way
|
|
76
|
+
* to distribute fixes, and `git` cannot protect what it does not track.
|
|
77
|
+
*/
|
|
78
|
+
function hasUncommittedChanges(projectRoot, relPath) {
|
|
79
|
+
try {
|
|
80
|
+
const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'status', '--porcelain', '--', relPath], {
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
+
});
|
|
84
|
+
return out.trim().length > 0;
|
|
85
|
+
}
|
|
86
|
+
catch (_a) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|