@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.
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
36
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
37
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -121,6 +154,28 @@ const DoctorCommand = {
121
154
  else if (gs.file && gs.hasDbReset) {
122
155
  line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
123
156
  }
157
+ // 7.5 check.mjs drift: the root wrapper is canonical (bundled with the
158
+ // CLI, synced by `lt fullstack update`). A diverged copy silently
159
+ // misses fixes (idle-watchdog, install hoisting, summed test
160
+ // metrics) — surface it instead of letting copies drift apart.
161
+ try {
162
+ const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
163
+ const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
164
+ const projectCheck = j(layout.root, 'scripts', 'check.mjs');
165
+ const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
166
+ if (filesystem.exists(projectCheck) && filesystem.exists(bundledCheck)) {
167
+ if (read(projectCheck, 'utf8') === read(bundledCheck, 'utf8')) {
168
+ line('OK', colors.green, 'scripts/check.mjs matches the canonical CLI version');
169
+ }
170
+ else {
171
+ line('WARN', colors.yellow, 'scripts/check.mjs differs from the canonical CLI version');
172
+ line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
173
+ }
174
+ }
175
+ }
176
+ catch (_a) {
177
+ /* best-effort diagnostics */
178
+ }
124
179
  // 8. Slug ↔ path: is this project's slug registered to a DIFFERENT checkout?
125
180
  // Two clones of the same project (same package.json "name") share the
126
181
  // slug → Caddy block / ports / DB and collide. Surface it proactively.
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const dev_identity_1 = require("../../lib/dev-identity");
13
+ const dev_project_1 = require("../../lib/dev-project");
14
+ const dev_prune_1 = require("../../lib/dev-prune");
15
+ const dev_state_1 = require("../../lib/dev-state");
16
+ const dev_ticket_1 = require("../../lib/dev-ticket");
17
+ const workspace_integration_1 = require("../../lib/workspace-integration");
18
+ /**
19
+ * `lt dev prune [--dry-run] [--noConfirm]` — collect the leftovers that accumulate
20
+ * around parallel ticket work:
21
+ *
22
+ * 1. ORPHANED TICKET DATABASES of this project: `<base>-<id>`(+`-test`/`-test-<n>`)
23
+ * whose ticket has neither a live worktree nor a live registry entry. The id
24
+ * set comes from this repo's own `feat/*` branches — never from name shapes,
25
+ * so sibling projects sharing a name prefix are safe. DBs recorded via
26
+ * `lt ticket stop --keep-db` are never touched.
27
+ * 2. STALE SHARD TEST DATABASES (`<base>-test-<n>` from `lt dev test --shard`)
28
+ * when no test session is running — they are reset on every run anyway.
29
+ * 3. DEAD REGISTRY ENTRIES (all projects): path no longer exists → the entry and
30
+ * its reserved ports are reclaimed. Databases of dead MAIN projects are NEVER
31
+ * dropped (a deleted folder is not consent to destroy data).
32
+ *
33
+ * `lt dev up` runs the same collection automatically (opt out with --no-prune), so
34
+ * restarting an environment always cleans up after its predecessors.
35
+ */
36
+ const PruneCommand = {
37
+ description: 'Remove orphaned ticket databases, stale shard test DBs and dead registry entries',
38
+ hidden: false,
39
+ name: 'prune',
40
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
41
+ const { filesystem, parameters, print: { colors, error, info, success, warning }, prompt: { confirm }, } = toolbox;
42
+ const dryRun = parameters.options.dryRun === true || parameters.options['dry-run'] === true;
43
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
44
+ let mainRepoRoot = layout.root;
45
+ try {
46
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
47
+ }
48
+ catch (_a) {
49
+ /* not a git repo — registry prune still works, ticket sweep will be empty */
50
+ }
51
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
52
+ const slug = (0, dev_identity_1.buildIdentity)(mainRepoRoot).slug;
53
+ const projectDevDb = (0, dev_project_1.deriveDbName)(mainLayout.apiDir, slug);
54
+ const observed = mainLayout.apiDir ? (0, dev_ticket_1.listDatabaseNames)(undefined, [mainLayout.apiDir, mainRepoRoot]) : null;
55
+ if (mainLayout.apiDir && observed === null) {
56
+ warning('Could not list databases (mongosh missing or MongoDB unreachable) — only the registry is pruned.');
57
+ }
58
+ const plan = (0, dev_prune_1.collectDevPrunePlan)({
59
+ loadRegistry: dev_state_1.loadRegistry,
60
+ mainRepoRoot,
61
+ observedDbNames: observed,
62
+ projectDevDb,
63
+ slug,
64
+ });
65
+ const dbTargets = [...new Set([...plan.orphan.targets, ...plan.shardTargets])];
66
+ if (dbTargets.length === 0 && plan.registryPrune.length === 0) {
67
+ success('Nothing to prune — no orphaned ticket DBs, no stale shard DBs, no dead registry entries.');
68
+ if (plan.orphan.protected.length > 0) {
69
+ info(colors.dim(` kept (registry keptDbs / referenced): ${plan.orphan.protected.join(', ')}`));
70
+ }
71
+ if (!parameters.options.fromGluegunMenu)
72
+ process.exit();
73
+ return 'dev prune: nothing to do';
74
+ }
75
+ if (plan.orphan.targets.length > 0) {
76
+ info(colors.bold(`Orphaned ticket databases (tickets: ${plan.orphan.orphanIds.join(', ')}):`));
77
+ plan.orphan.targets.forEach((db) => info(colors.dim(` • ${db}`)));
78
+ }
79
+ if (plan.shardTargets.length > 0) {
80
+ info(colors.bold('Stale shard test databases (no test session running):'));
81
+ plan.shardTargets.forEach((db) => info(colors.dim(` • ${db}`)));
82
+ }
83
+ if (plan.orphan.protected.length > 0) {
84
+ info(colors.dim(`Kept (recorded via --keep-db or still referenced): ${plan.orphan.protected.join(', ')}`));
85
+ }
86
+ if (plan.registryPrune.length > 0) {
87
+ info(colors.bold('Dead registry entries (path gone — entry + ports reclaimed, databases untouched):'));
88
+ plan.registryPrune.forEach((key) => info(colors.dim(` • ${key}`)));
89
+ }
90
+ if (dryRun) {
91
+ info('');
92
+ info(colors.dim('--dry-run → nothing was changed.'));
93
+ if (!parameters.options.fromGluegunMenu)
94
+ process.exit();
95
+ return 'dev prune: dry run';
96
+ }
97
+ // Dropping databases is irreversible — confirm interactively (documented default
98
+ // for non-interactive callers, mirroring `lt ticket stop`).
99
+ let confirmedDbTargets = dbTargets;
100
+ if (dbTargets.length > 0 && !(0, workspace_integration_1.isNonInteractive)(parameters.options.noConfirm === true)) {
101
+ info('');
102
+ warning(' These databases will be DROPPED (irreversible):');
103
+ dbTargets.forEach((db) => info(colors.dim(` • ${db}`)));
104
+ if (!(yield confirm('Drop them?', true))) {
105
+ info(colors.dim(' keeping the databases.'));
106
+ confirmedDbTargets = [];
107
+ }
108
+ }
109
+ if (confirmedDbTargets.length > 0) {
110
+ const { dropped, reason } = (0, dev_ticket_1.dropDatabases)(confirmedDbTargets, undefined, [mainLayout.apiDir, mainRepoRoot]);
111
+ dropped.forEach((db) => info(colors.dim(` dropped db ${db}`)));
112
+ if (reason === 'no-mongosh') {
113
+ warning(' mongosh is not installed — the databases were NOT dropped.');
114
+ }
115
+ else if (reason === 'unreachable') {
116
+ warning(' MongoDB is not reachable — the databases were NOT dropped.');
117
+ }
118
+ }
119
+ if (plan.registryPrune.length > 0) {
120
+ try {
121
+ // Locked read-modify-write: a parallel `lt dev up` may be registering a
122
+ // project right now — pruning without the lock clobbers its fresh entry.
123
+ const removed = yield (0, dev_state_1.withRegistryLock)(() => {
124
+ const reg = (0, dev_state_1.loadRegistry)();
125
+ const gone = [];
126
+ for (const key of plan.registryPrune) {
127
+ if (reg.projects[key] && !filesystem.exists(reg.projects[key].path)) {
128
+ delete reg.projects[key];
129
+ gone.push(key);
130
+ }
131
+ }
132
+ if (gone.length > 0) {
133
+ (0, dev_state_1.saveRegistry)(reg);
134
+ }
135
+ return gone;
136
+ });
137
+ if (removed.length > 0) {
138
+ info(colors.dim(` removed ${removed.length} dead registry entr${removed.length === 1 ? 'y' : 'ies'}: ${removed.join(', ')}`));
139
+ }
140
+ }
141
+ catch (e) {
142
+ error(` registry update failed: ${e.message}`);
143
+ }
144
+ }
145
+ info('');
146
+ success('Prune complete.');
147
+ if (!parameters.options.fromGluegunMenu)
148
+ process.exit();
149
+ return 'dev prune: done';
150
+ }),
151
+ };
152
+ module.exports = PruneCommand;
@@ -236,6 +236,9 @@ const TestCommand = {
236
236
  info(colors.dim(` app: ${ctx.appUrl} db: ${ctx.dbName}`));
237
237
  info('');
238
238
  const appPm = (0, dev_package_manager_1.pickPackageManager)(layout.appDir);
239
+ // Self-heal: install the app's Playwright browser build if missing —
240
+ // idempotent (~1s when cached); a fresh project otherwise fails every spec.
241
+ (0, dev_test_session_1.ensurePlaywrightBrowsers)(layout.appDir, appPm, (m) => info(colors.dim(m)));
239
242
  exitCode = yield (0, dev_process_1.runChildInherit)(appPm.bin, appPm.runScript('test:e2e', forwarded), {
240
243
  cwd: layout.appDir,
241
244
  env,
@@ -14,10 +14,12 @@ const path_1 = require("path");
14
14
  const caddy_1 = require("../../lib/caddy");
15
15
  const dev_env_1 = require("../../lib/dev-env");
16
16
  const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
17
+ const dev_identity_1 = require("../../lib/dev-identity");
17
18
  const dev_package_manager_1 = require("../../lib/dev-package-manager");
18
19
  const dev_patches_1 = require("../../lib/dev-patches");
19
20
  const dev_process_1 = require("../../lib/dev-process");
20
21
  const dev_project_1 = require("../../lib/dev-project");
22
+ const dev_prune_1 = require("../../lib/dev-prune");
21
23
  const dev_state_1 = require("../../lib/dev-state");
22
24
  const dev_ticket_1 = require("../../lib/dev-ticket");
23
25
  /**
@@ -432,6 +434,67 @@ const UpCommand = {
432
434
  if (pids.app)
433
435
  (0, dev_process_1.killProcessGroup)(pids.app);
434
436
  }
437
+ // AUTO-PRUNE — restarting an environment is the moment to clean up after its
438
+ // predecessors: orphaned ticket DBs (ids from this repo's own feat/* branches,
439
+ // no live worktree/registry entry, `--keep-db` records excluded), stale shard
440
+ // test DBs, and dead-path registry entries. Same guards as `lt dev prune`;
441
+ // best-effort and non-blocking — a failure here must never affect `up` itself.
442
+ // Opt out with `--no-prune`.
443
+ if (parameters.options.prune !== false) {
444
+ try {
445
+ let mainRepoRoot = layout.root;
446
+ try {
447
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
448
+ }
449
+ catch (_m) {
450
+ /* not a git repo — registry prune still applies */
451
+ }
452
+ const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
453
+ const baseSlug = (0, dev_identity_1.buildIdentity)(mainRepoRoot).slug;
454
+ const plan = (0, dev_prune_1.collectDevPrunePlan)({
455
+ loadRegistry: dev_state_1.loadRegistry,
456
+ mainRepoRoot,
457
+ observedDbNames: mainLayout.apiDir ? (0, dev_ticket_1.listDatabaseNames)(undefined, [mainLayout.apiDir, mainRepoRoot]) : null,
458
+ projectDevDb: (0, dev_project_1.deriveDbName)(mainLayout.apiDir, baseSlug),
459
+ slug: baseSlug,
460
+ });
461
+ const dbTargets = [...new Set([...plan.orphan.targets, ...plan.shardTargets])];
462
+ if (dbTargets.length > 0) {
463
+ const { dropped, reason } = (0, dev_ticket_1.dropDatabases)(dbTargets, undefined, [mainLayout.apiDir, mainRepoRoot]);
464
+ if (dropped.length > 0) {
465
+ info(colors.dim(`Pruned ${dropped.length} orphaned test/ticket database(s): ${dropped.join(', ')}`));
466
+ }
467
+ if (reason) {
468
+ info(colors.dim(`(prune incomplete — ${reason === 'no-mongosh' ? 'mongosh missing' : 'MongoDB unreachable'}; run \`lt dev prune\` later)`));
469
+ }
470
+ }
471
+ if (plan.registryPrune.length > 0) {
472
+ // Under the registry lock: a parallel `lt dev up` (another project) may be
473
+ // REGISTERING right now — an unlocked read-modify-write here would clobber
474
+ // its fresh entry (classic lost update; observed with two simultaneous ups).
475
+ const removed = yield (0, dev_state_1.withRegistryLock)(() => {
476
+ const reg = (0, dev_state_1.loadRegistry)();
477
+ const gone = [];
478
+ for (const key of plan.registryPrune) {
479
+ if (reg.projects[key] && !(0, fs_1.existsSync)(reg.projects[key].path)) {
480
+ delete reg.projects[key];
481
+ gone.push(key);
482
+ }
483
+ }
484
+ if (gone.length > 0) {
485
+ (0, dev_state_1.saveRegistry)(reg);
486
+ }
487
+ return gone;
488
+ });
489
+ if (removed.length > 0) {
490
+ info(colors.dim(`Pruned ${removed.length} dead registry entr${removed.length === 1 ? 'y' : 'ies'}: ${removed.join(', ')} (ports reclaimed)`));
491
+ }
492
+ }
493
+ }
494
+ catch (_o) {
495
+ /* never block `up` on cleanup */
496
+ }
497
+ }
435
498
  if (!parameters.options.fromGluegunMenu)
436
499
  process.exit();
437
500
  return `dev up: api=${pids.api}, app=${pids.app}`;
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  const path_1 = require("path");
13
+ const dev_patches_1 = require("../../lib/dev-patches");
13
14
  const framework_detection_1 = require("../../lib/framework-detection");
14
15
  const frontend_framework_detection_1 = require("../../lib/frontend-framework-detection");
15
16
  const heal_check_wrapper_1 = require("../../lib/heal-check-wrapper");
@@ -198,6 +199,15 @@ const NewCommand = {
198
199
  info('');
199
200
  success(` Installed/updated the check wrapper: ${changedCheck.join(', ')}`);
200
201
  }
202
+ // ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
203
+ //
204
+ // Newer templates ship the entry, but pre-existing projects predate it —
205
+ // `lt dev up` then dirties `.gitignore` in every ticket worktree, which
206
+ // used to block every `lt ticket stop` at its safety gate. Idempotent.
207
+ if ((0, dev_patches_1.addToGitignore)(cwd, '.lt-dev/')) {
208
+ info('');
209
+ success(' Added `.lt-dev/` to .gitignore');
210
+ }
201
211
  info('');
202
212
  info(colors.bold('For a comprehensive update of everything, use:'));
203
213
  info('');
@@ -65,6 +65,17 @@ const StartCommand = {
65
65
  }
66
66
  const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
67
67
  const id = (0, dev_ticket_1.deriveTicketId)(name, parameters.options.as != null ? String(parameters.options.as) : undefined);
68
+ // `local`/`dev`/`test`/… would derive a ticket DB that collides with the PROJECT's own
69
+ // dev or test database (both derivations strip a trailing `-(local|dev)` before adding
70
+ // their suffix, so the name round-trips). `lt ticket stop` would then be aimed at the
71
+ // developer's own data. Refuse the id instead of creating that trap.
72
+ if ((0, dev_ticket_1.isReservedTicketId)(id)) {
73
+ error(`Ticket id "${id}" is reserved — its database would collide with the project's own.`);
74
+ info(colors.dim(` Pick a different name, or map it to a distinct id: lt ticket start ${name} --as ${id}-fix`));
75
+ if (!parameters.options.fromGluegunMenu)
76
+ process.exit(1);
77
+ return 'ticket start: reserved id';
78
+ }
68
79
  const branch = typeof parameters.options.branch === 'string' ? parameters.options.branch : (0, dev_ticket_1.defaultTicketBranch)(name);
69
80
  const ticketIdentity = (0, dev_identity_1.buildTicketIdentity)(base, id);
70
81
  const dbName = (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, base.slug), id);