@lenne.tech/cli 1.36.0 → 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 +11 -0
- 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 +325 -16
- 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 +5 -5
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +1 -1
package/build/lib/dev-ticket.js
CHANGED
|
@@ -5,13 +5,19 @@ 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;
|
|
11
12
|
exports.gitRefExists = gitRefExists;
|
|
12
13
|
exports.installWorktreeDeps = installWorktreeDeps;
|
|
14
|
+
exports.isReservedTicketId = isReservedTicketId;
|
|
15
|
+
exports.isTicketScopedDb = isTicketScopedDb;
|
|
16
|
+
exports.keepDbFlag = keepDbFlag;
|
|
13
17
|
exports.listBaseRefChoices = listBaseRefChoices;
|
|
18
|
+
exports.listDatabaseNames = listDatabaseNames;
|
|
14
19
|
exports.listWorktrees = listWorktrees;
|
|
20
|
+
exports.planTicketDbDrop = planTicketDbDrop;
|
|
15
21
|
exports.readTicketMarker = readTicketMarker;
|
|
16
22
|
exports.resolveBaseRef = resolveBaseRef;
|
|
17
23
|
exports.resolveDevIdentity = resolveDevIdentity;
|
|
@@ -53,6 +59,23 @@ const dev_project_1 = require("./dev-project");
|
|
|
53
59
|
const dev_state_1 = require("./dev-state");
|
|
54
60
|
/** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
|
|
55
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']);
|
|
56
79
|
/**
|
|
57
80
|
* Check whether a project's Playwright `global-setup` (if it wipes a DB) would
|
|
58
81
|
* ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
|
|
@@ -81,6 +104,11 @@ function checkGlobalSetupTicketSafe(layout) {
|
|
|
81
104
|
catch (_c) {
|
|
82
105
|
return { file, hasDbReset: false, ticketSafe: true };
|
|
83
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
|
+
}
|
|
84
112
|
const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
|
|
85
113
|
if (!hasDbReset)
|
|
86
114
|
return { file, hasDbReset: false, ticketSafe: true };
|
|
@@ -151,18 +179,64 @@ function deriveTicketId(name, asOverride) {
|
|
|
151
179
|
return ticketMatch[1];
|
|
152
180
|
return (0, dev_identity_1.slugify)(trimmed);
|
|
153
181
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
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 = []) {
|
|
156
198
|
try {
|
|
157
|
-
(0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'],
|
|
158
|
-
|
|
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,
|
|
159
217
|
});
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return false;
|
|
218
|
+
if (res.outcome === 'no-driver')
|
|
219
|
+
return 'no-mongosh';
|
|
220
|
+
return res.outcome === 'ok' ? 'dropped' : 'unreachable';
|
|
164
221
|
}
|
|
165
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);
|
|
237
|
+
}
|
|
238
|
+
return { dropped, reason: null };
|
|
239
|
+
}
|
|
166
240
|
/** True if a local branch with this name already exists. */
|
|
167
241
|
function gitBranchExists(repoDir, branch) {
|
|
168
242
|
try {
|
|
@@ -191,7 +265,9 @@ function gitMainRepoRoot(cwd) {
|
|
|
191
265
|
/** True if `ref` resolves to a commit in the repo (any ref kind: local, remote, tag, sha). */
|
|
192
266
|
function gitRefExists(repoDir, ref) {
|
|
193
267
|
try {
|
|
194
|
-
(0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
268
|
+
(0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
269
|
+
stdio: 'ignore',
|
|
270
|
+
});
|
|
195
271
|
return true;
|
|
196
272
|
}
|
|
197
273
|
catch (_a) {
|
|
@@ -208,6 +284,73 @@ function installWorktreeDeps(dir) {
|
|
|
208
284
|
const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
|
|
209
285
|
(0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
|
|
210
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
|
+
}
|
|
211
354
|
/**
|
|
212
355
|
* Branches offered when the user has to pick a base ref interactively (no
|
|
213
356
|
* candidate matched). Remote + local branches, most recently committed first,
|
|
@@ -233,6 +376,42 @@ function listBaseRefChoices(repoDir, limit = 25) {
|
|
|
233
376
|
.filter((l) => l && !l.endsWith('/HEAD'));
|
|
234
377
|
return [...new Set(refs)].slice(0, limit);
|
|
235
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
|
+
}
|
|
236
415
|
/** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
|
|
237
416
|
function listWorktrees(repoDir) {
|
|
238
417
|
let out = '';
|
|
@@ -258,6 +437,35 @@ function listWorktrees(repoDir) {
|
|
|
258
437
|
result.push(finalizeWorktree(current));
|
|
259
438
|
return result;
|
|
260
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
|
+
}
|
|
261
469
|
/** Read the ticket id this worktree is tagged with, or null. */
|
|
262
470
|
function readTicketMarker(root) {
|
|
263
471
|
const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
|
|
@@ -289,10 +497,18 @@ function resolveBaseRef(repoDir, explicit) {
|
|
|
289
497
|
var _a;
|
|
290
498
|
const wanted = explicit === null || explicit === void 0 ? void 0 : explicit.trim();
|
|
291
499
|
if (wanted) {
|
|
292
|
-
return {
|
|
500
|
+
return {
|
|
501
|
+
candidates: [wanted],
|
|
502
|
+
explicit: true,
|
|
503
|
+
ref: gitRefExists(repoDir, wanted) ? wanted : null,
|
|
504
|
+
};
|
|
293
505
|
}
|
|
294
506
|
const candidates = baseRefCandidates(repoDir);
|
|
295
|
-
return {
|
|
507
|
+
return {
|
|
508
|
+
candidates,
|
|
509
|
+
explicit: false,
|
|
510
|
+
ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null,
|
|
511
|
+
};
|
|
296
512
|
}
|
|
297
513
|
/**
|
|
298
514
|
* Resolve the dev identity + DB name for a project root, ticket-aware.
|
|
@@ -330,6 +546,47 @@ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
|
|
|
330
546
|
git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
|
|
331
547
|
}
|
|
332
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
|
+
}
|
|
333
590
|
/** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
|
|
334
591
|
const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
|
|
335
592
|
/** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
|
|
@@ -385,7 +642,13 @@ function worktreeSafetyReport(worktreePath) {
|
|
|
385
642
|
const { realDirty } = classifyWorktreeDirt(worktreePath);
|
|
386
643
|
let unpushed = 0;
|
|
387
644
|
try {
|
|
388
|
-
|
|
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
|
+
}
|
|
389
652
|
}
|
|
390
653
|
catch (_a) {
|
|
391
654
|
/* no remotes / detached HEAD → cannot determine; treat as 0 */
|
|
@@ -429,10 +692,14 @@ function classifyWorktreeDirt(worktreePath) {
|
|
|
429
692
|
const realDirty = [];
|
|
430
693
|
for (const line of gitStatusPorcelain(worktreePath)) {
|
|
431
694
|
const p = porcelainPath(line);
|
|
432
|
-
if (GENERATED_PATHS.test(p) ||
|
|
695
|
+
if (GENERATED_PATHS.test(p) ||
|
|
696
|
+
isPristineLtDevPatch(worktreePath, p) ||
|
|
697
|
+
isPristineLtDevGitignoreAppend(worktreePath, p)) {
|
|
433
698
|
autoDiscardable.push(p);
|
|
434
|
-
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
435
701
|
realDirty.push(p);
|
|
702
|
+
}
|
|
436
703
|
}
|
|
437
704
|
return { autoDiscardable, realDirty };
|
|
438
705
|
}
|
|
@@ -473,13 +740,53 @@ function gitRemoteHead(repoDir, remote = 'origin') {
|
|
|
473
740
|
function gitStatusPorcelain(cwd) {
|
|
474
741
|
let out = '';
|
|
475
742
|
try {
|
|
476
|
-
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
|
+
});
|
|
477
746
|
}
|
|
478
747
|
catch (_a) {
|
|
479
748
|
return [];
|
|
480
749
|
}
|
|
481
750
|
return out.split(/\r?\n/).filter((l) => l.trim() !== '');
|
|
482
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
|
+
}
|
|
483
790
|
/**
|
|
484
791
|
* True when the dirty tracked file at `relPath` (relative to the worktree root)
|
|
485
792
|
* differs from its committed (HEAD) version by EXACTLY the lt-dev self-heal
|
|
@@ -497,7 +804,9 @@ function isPristineLtDevPatch(worktreePath, relPath) {
|
|
|
497
804
|
try {
|
|
498
805
|
// NOT the trimming `git()` helper — the trailing newline must survive so the
|
|
499
806
|
// comparison against the (untrimmed) working-tree content is exact.
|
|
500
|
-
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
|
+
});
|
|
501
810
|
}
|
|
502
811
|
catch (_a) {
|
|
503
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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hoistPackageManager = hoistPackageManager;
|
|
3
4
|
exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
|
|
4
5
|
const js_yaml_1 = require("js-yaml");
|
|
5
6
|
const fs_utils_1 = require("./fs-utils");
|
|
@@ -26,14 +27,92 @@ const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
|
26
27
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
27
28
|
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
|
|
28
29
|
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
30
|
+
/**
|
|
31
|
+
* Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
|
|
32
|
+
* root `package.json`, keeping the highest version and stripping the pin from
|
|
33
|
+
* every sub-project.
|
|
34
|
+
*
|
|
35
|
+
* Unlike the fields above this is a TOP-LEVEL package.json field (not part of the
|
|
36
|
+
* `pnpm` block), and its destination is the root `package.json` — not
|
|
37
|
+
* `pnpm-workspace.yaml` — because Corepack, not pnpm, reads it. Hence its own pass.
|
|
38
|
+
*
|
|
39
|
+
* Why it must not stay in a sub-project: inside a workspace only the ROOT pin governs
|
|
40
|
+
* `pnpm install`. A pin left in `projects/app` is worse than inert — Corepack resolves
|
|
41
|
+
* the NEAREST package.json, so `cd projects/app && pnpm run build` (exactly what
|
|
42
|
+
* projects/app/Dockerfile does) provisions the sub-project's pnpm while the root
|
|
43
|
+
* install ran on another version. One build, two pnpm versions.
|
|
44
|
+
*
|
|
45
|
+
* Why the root needs a pin at all: without `packageManager`, Corepack silently
|
|
46
|
+
* downloads the LATEST pnpm from the registry (verified with an isolated cache, i.e.
|
|
47
|
+
* a fresh container). Together with the root `engines.pnpm: "^11.0.0"` shipped by
|
|
48
|
+
* lt-monorepo, that breaks the day pnpm 12 is released — pnpm enforces `engines.pnpm`
|
|
49
|
+
* hard (`ERR_PNPM_UNSUPPORTED_ENGINE`), so the Docker build dies without a single
|
|
50
|
+
* repo change. The starters carry an exact pin incl. integrity hash
|
|
51
|
+
* (`pnpm@11.13.1+sha512.…`, maintained via `corepack up`); hoisting it preserves both
|
|
52
|
+
* the determinism and the supply-chain check.
|
|
53
|
+
*
|
|
54
|
+
* Mixed package managers (e.g. api pinning yarn, app pinning pnpm) are left untouched
|
|
55
|
+
* rather than silently picking a winner — that is a template bug, not something to
|
|
56
|
+
* paper over.
|
|
57
|
+
*
|
|
58
|
+
* Idempotent: running twice has the same effect as running once.
|
|
59
|
+
*
|
|
60
|
+
* @param options.filesystem Gluegun filesystem tool
|
|
61
|
+
* @param options.projectDir Workspace root (contains the root package.json)
|
|
62
|
+
* @param options.subProjects Sub-project dirs relative to projectDir
|
|
63
|
+
*/
|
|
64
|
+
function hoistPackageManager(options) {
|
|
65
|
+
const { filesystem, projectDir, subProjects } = options;
|
|
66
|
+
const rootPkgPath = `${projectDir}/package.json`;
|
|
67
|
+
const rootPkg = filesystem.exists(rootPkgPath) ? filesystem.read(rootPkgPath, 'json') : null;
|
|
68
|
+
if (!rootPkg)
|
|
69
|
+
return;
|
|
70
|
+
const candidates = [];
|
|
71
|
+
const strippedSubs = [];
|
|
72
|
+
for (const subDir of subProjects) {
|
|
73
|
+
const subPath = `${projectDir}/${subDir}`;
|
|
74
|
+
if (!filesystem.exists(subPath))
|
|
75
|
+
continue;
|
|
76
|
+
// Never mutate a symlinked sub-project — it points at the user's own checkout.
|
|
77
|
+
if ((0, fs_utils_1.isSymlink)(subPath))
|
|
78
|
+
continue;
|
|
79
|
+
const subPkgPath = `${subPath}/package.json`;
|
|
80
|
+
if (!filesystem.exists(subPkgPath))
|
|
81
|
+
continue;
|
|
82
|
+
const subPkg = filesystem.read(subPkgPath, 'json');
|
|
83
|
+
if (typeof (subPkg === null || subPkg === void 0 ? void 0 : subPkg.packageManager) !== 'string')
|
|
84
|
+
continue;
|
|
85
|
+
candidates.push(subPkg.packageManager);
|
|
86
|
+
strippedSubs.push({ path: subPkgPath, pkg: subPkg });
|
|
87
|
+
}
|
|
88
|
+
if (candidates.length === 0)
|
|
89
|
+
return;
|
|
90
|
+
const rootPin = typeof rootPkg.packageManager === 'string' ? rootPkg.packageManager : undefined;
|
|
91
|
+
const all = rootPin ? [rootPin, ...candidates] : candidates;
|
|
92
|
+
// Bail out on mixed managers instead of guessing which one is authoritative.
|
|
93
|
+
const names = new Set(all.map(pmName));
|
|
94
|
+
if (names.size > 1)
|
|
95
|
+
return;
|
|
96
|
+
const winner = all.reduce((best, pin) => (comparePmVersions(pin, best) > 0 ? pin : best));
|
|
97
|
+
if (rootPin !== winner) {
|
|
98
|
+
rootPkg.packageManager = winner;
|
|
99
|
+
filesystem.write(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
|
|
100
|
+
}
|
|
101
|
+
for (const { path, pkg } of strippedSubs) {
|
|
102
|
+
delete pkg.packageManager;
|
|
103
|
+
filesystem.write(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
29
106
|
/**
|
|
30
107
|
* Hoist workspace-scoped pnpm config from sub-projects into the monorepo
|
|
31
108
|
* root `pnpm-workspace.yaml`. After this runs, sub-project pnpm config
|
|
32
109
|
* (package.json#pnpm or a settings-only pnpm-workspace.yaml) is gone, and
|
|
33
110
|
* the root pnpm-workspace.yaml carries the merged union next to `packages:`.
|
|
34
111
|
*
|
|
35
|
-
* Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo
|
|
36
|
-
*
|
|
112
|
+
* Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo runs
|
|
113
|
+
* pnpm 11 (lt-monorepo ships `engines.pnpm: "^11.0.0"`; the exact version
|
|
114
|
+
* comes from the `packageManager` pin that `hoistPackageManager` lifts to
|
|
115
|
+
* the root), and pnpm 11 SILENTLY IGNORES the
|
|
37
116
|
* `pnpm` block in package.json — overrides/build-allowlists/etc. declared
|
|
38
117
|
* there never take effect, regressing `pnpm audit` and the minimum-release
|
|
39
118
|
* -age exemptions. pnpm-workspace.yaml is the pnpm-recommended home and is
|
|
@@ -93,6 +172,26 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
93
172
|
filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
|
|
94
173
|
}
|
|
95
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
|
|
177
|
+
* Returns >0 if `a` is newer, <0 if older, 0 if equal. Numeric segment-wise
|
|
178
|
+
* comparison; the integrity hash and any pre-release suffix are ignored, which is
|
|
179
|
+
* enough for the exact pins Corepack writes (no ranges are legal here).
|
|
180
|
+
*/
|
|
181
|
+
function comparePmVersions(a, b) {
|
|
182
|
+
var _a, _b;
|
|
183
|
+
const segments = (pin) => pmVersion(pin)
|
|
184
|
+
.split('.')
|
|
185
|
+
.map((s) => Number.parseInt(s, 10) || 0);
|
|
186
|
+
const av = segments(a);
|
|
187
|
+
const bv = segments(b);
|
|
188
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
189
|
+
const diff = ((_a = av[i]) !== null && _a !== void 0 ? _a : 0) - ((_b = bv[i]) !== null && _b !== void 0 ? _b : 0);
|
|
190
|
+
if (diff !== 0)
|
|
191
|
+
return diff;
|
|
192
|
+
}
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
96
195
|
/**
|
|
97
196
|
* Move the workspace-scoped pnpm fields from `source` into `rootWs`,
|
|
98
197
|
* deleting each moved field from `source`. Returns true if anything moved.
|
|
@@ -173,6 +272,15 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
173
272
|
const merged = Object.assign(Object.assign({}, rootObj), subObj);
|
|
174
273
|
return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
175
274
|
}
|
|
275
|
+
/** Extract the manager name from a pin (`pnpm@11.13.1+sha512.…` -> `pnpm`). */
|
|
276
|
+
function pmName(pin) {
|
|
277
|
+
return pin.slice(0, Math.max(0, pin.lastIndexOf('@'))) || pin;
|
|
278
|
+
}
|
|
279
|
+
/** Extract the bare version from a pin (`pnpm@11.13.1+sha512.…` -> `11.13.1`). */
|
|
280
|
+
function pmVersion(pin) {
|
|
281
|
+
const afterAt = pin.slice(pin.lastIndexOf('@') + 1);
|
|
282
|
+
return afterAt.split('+')[0];
|
|
283
|
+
}
|
|
176
284
|
/** Parse a YAML file into a plain object, or null on missing/malformed/non-object. */
|
|
177
285
|
function readYaml(filesystem, path) {
|
|
178
286
|
if (!filesystem.exists(path))
|