@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.
@@ -135,13 +135,17 @@ function detectWorkspaceLayout(workspaceDir, filesystem) {
135
135
  }
136
136
  /**
137
137
  * Normalize a freshly-populated workspace root after adding or removing a
138
- * sub-project. Runs the three idempotent workspace-hygiene steps that
138
+ * sub-project. Runs the four idempotent workspace-hygiene steps that
139
139
  * `fullstack init`, `add-api`, and `add-app` all need:
140
140
  *
141
141
  * 1. hoist pnpm workspace-scoped config (`overrides`, `allowBuilds`, …) out
142
142
  * of the sub-projects into the root — pnpm only honours it at the root;
143
- * 2. remove nested `pnpm-lock.yaml` files the root lockfile supersedes;
144
- * 3. guarantee a workspace-root `.dockerignore` (Docker never reads a
143
+ * 2. hoist the Corepack `packageManager` pin to the root only the root pin
144
+ * governs the install, and a pin left in a sub-project makes
145
+ * `cd projects/app && pnpm run build` provision a different pnpm than the
146
+ * root install used;
147
+ * 3. remove nested `pnpm-lock.yaml` files the root lockfile supersedes;
148
+ * 4. guarantee a workspace-root `.dockerignore` (Docker never reads a
145
149
  * sub-project's own `.dockerignore` when building from the root context).
146
150
  *
147
151
  * Each step is a no-op when there is nothing to do, so re-runs are safe.
@@ -151,6 +155,7 @@ function finalizeWorkspaceRoot(options) {
151
155
  const { filesystem, projectDir } = options;
152
156
  const subProjects = (_a = options.subProjects) !== null && _a !== void 0 ? _a : ['projects/api', 'projects/app'];
153
157
  (0, hoist_workspace_pnpm_config_1.hoistWorkspacePnpmConfig)({ filesystem, projectDir, subProjects });
158
+ (0, hoist_workspace_pnpm_config_1.hoistPackageManager)({ filesystem, projectDir, subProjects });
154
159
  (0, remove_nested_lockfiles_1.removeNestedLockfiles)({ filesystem, projectDir, subProjects });
155
160
  (0, ensure_root_dockerignore_1.ensureRootDockerignore)({ filesystem, projectDir });
156
161
  }
@@ -197,8 +202,13 @@ function findWorkspaceRoot(startDir, filesystem, maxDepth = 6) {
197
202
  function isNonInteractive(noConfirmFlag) {
198
203
  if (noConfirmFlag)
199
204
  return true;
200
- // process.stdin may be undefined in some test runners guard.
201
- return Boolean(process.stdin && process.stdin.isTTY === false);
205
+ // Non-TTY stdin (pipes, scripts, CI, AI agents) must never sit on an
206
+ // interactive prompt. NOTE: Node leaves `isTTY` UNDEFINED (not false) on
207
+ // non-TTY streams, so the previous `isTTY === false` test classified every
208
+ // piped invocation as interactive — `lt ticket stop` then hung forever on its
209
+ // confirm prompt when run from a script. `!isTTY` treats undefined and false
210
+ // alike; a missing stdin (some test runners) cannot prompt either.
211
+ return !process.stdin || !process.stdin.isTTY;
202
212
  }
203
213
  /**
204
214
  * Reconfigure the cloned nest-base template's `.claude/upstream.json`
@@ -25,7 +25,7 @@
25
25
  * Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
26
26
  * lt-dev `running-check-script` skill relies on: non-zero === failed).
27
27
  */
28
- import { spawn } from "node:child_process";
28
+ import { execSync, spawn } from "node:child_process";
29
29
  import { readdirSync, readFileSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
@@ -66,8 +66,14 @@ function classify(cmd) {
66
66
  const c = cmd.toLowerCase();
67
67
  if (c.includes("vendor-freshness"))
68
68
  return { fatal: false, kind: "vendor", label: "vendor-freshness" };
69
+ // Dependency install — hoisted to ONE workspace-level run (see buildGroups):
70
+ // api and app chains both start with `pnpm install --frozen-lockfile`, and
71
+ // running those CONCURRENTLY (parallel groups) mutates the same workspace
72
+ // node_modules from two processes at once.
73
+ if (/\b(pnpm|npm|yarn|bun)\s+(install|ci)\b/.test(c))
74
+ return { fatal: true, kind: "install", label: "install" };
69
75
  if (c.includes("audit")) return { fatal: true, kind: "audit", label: "audit" };
70
- if (c.includes("format:check") || c.includes("oxfmt") || c.includes("prettier"))
76
+ if (c.includes("format:check") || c.includes("oxfmt"))
71
77
  return { fatal: true, kind: "format", label: "format" };
72
78
  if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
73
79
  if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:unit|test:ci/.test(c))
@@ -86,7 +92,6 @@ function toFixCommand(kind, cmd) {
86
92
  if (kind === "format") {
87
93
  if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, "format");
88
94
  if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, "");
89
- if (/\bprettier\b/.test(cmd)) return cmd.replace(/\s--check\b/, " --write");
90
95
  return cmd;
91
96
  }
92
97
  if (kind === "lint") {
@@ -99,16 +104,30 @@ function toFixCommand(kind, cmd) {
99
104
  }
100
105
 
101
106
  // ── metric parsers ─────────────────────────────────────────────────────────
107
+ // Sum capture group 1 across every match of `re` (which must carry the `g` flag).
108
+ // Returns null when nothing matched, so callers can tell "absent" from "zero".
109
+ function sumMatches(clean, re) {
110
+ let total = null;
111
+ for (const m of clean.matchAll(re)) {
112
+ const n = Number(m[1]);
113
+ if (Number.isFinite(n)) total = (total ?? 0) + n;
114
+ }
115
+ return total;
116
+ }
117
+ // A single test step may invoke vitest more than once (`test` is
118
+ // `vitest:unit && vitest`), emitting one summary block per run. Sum them all —
119
+ // reading only the first silently under-reports every later run: the api step
120
+ // showed "16 passed" (unit only) while its 69 e2e tests ran unseen.
102
121
  function parseVitest(out) {
103
122
  const clean = stripAnsi(out);
104
- const tests = clean.match(/Tests\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
105
- const files = clean.match(/Test Files\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
106
- const failed = clean.match(/Tests\s+(\d+)\s+failed/i);
107
- if (!tests && !files) return null;
123
+ const passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
124
+ const files = sumMatches(clean, /Test Files\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
125
+ const failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
126
+ if (passed == null && files == null) return null;
108
127
  return {
109
- failed: failed ? Number(failed[1]) : 0,
110
- files: files ? Number(files[2]) : null,
111
- passed: tests ? Number(tests[2]) : null,
128
+ failed: failed ?? 0,
129
+ files,
130
+ passed,
112
131
  };
113
132
  }
114
133
  function parseLint(out) {
@@ -142,21 +161,105 @@ async function runAudit(auditCmd) {
142
161
  return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
143
162
  }
144
163
 
164
+ // Watchdog: kill a TEST step whose child produces NO output for this long. A
165
+ // wedged test run (workers idle at 0% CPU — e.g. one spec file grinding through
166
+ // retries after its app/socket state broke under load) otherwise spins the live
167
+ // view forever: the spinner only proves the child process exists, not that it
168
+ // progresses. Only test steps are watched: build / typecheck / audit
169
+ // legitimately buffer all their output to the end (and go silent under a
170
+ // non-TTY pipe), so watching them would false-kill a slow-but-progressing run.
171
+ // Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
172
+ // disables it.
173
+ const IDLE_TIMEOUT_MS = (() => {
174
+ const flag = process.argv.find((a) => a.startsWith("--idle-timeout="));
175
+ const raw = flag ? flag.slice("--idle-timeout=".length) : process.env.CHECK_IDLE_TIMEOUT;
176
+ const DEFAULT_MS = 300 * 1000;
177
+ if (raw === undefined || raw === "") return DEFAULT_MS;
178
+ const seconds = Number(raw);
179
+ if (seconds === 0) return 0; // explicit opt-out
180
+ // Invalid value (typo, unit suffix, negative) → keep the protection at its
181
+ // default rather than silently disabling it.
182
+ if (!Number.isFinite(seconds) || seconds < 0) {
183
+ process.stderr.write(`[check] ignoring invalid idle-timeout "${raw}", using ${DEFAULT_MS / 1000}s\n`);
184
+ return DEFAULT_MS;
185
+ }
186
+ return seconds * 1000;
187
+ })();
188
+
145
189
  // ── command runner ─────────────────────────────────────────────────────────
146
190
  const RUNNING = new Set();
147
- function capture(cmd, cwd) {
191
+
192
+ // Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
193
+ // fork workers). Killing only the direct child orphans the tree — exactly the
194
+ // zombie workers a deadlock leaves behind. Children are collected via pgrep
195
+ // and killed leaves-first.
196
+ function killTree(child, signal = "SIGTERM") {
197
+ const pids = [];
198
+ const collect = (pid) => {
199
+ pids.push(pid);
200
+ let out = "";
201
+ try {
202
+ out = execSync(`pgrep -P ${pid}`, { stdio: ["ignore", "pipe", "ignore"] })
203
+ .toString()
204
+ .trim();
205
+ } catch {
206
+ /* no children */
207
+ }
208
+ if (out) for (const p of out.split("\n")) collect(Number(p));
209
+ };
210
+ collect(child.pid);
211
+ for (const pid of pids.reverse()) {
212
+ try {
213
+ process.kill(pid, signal);
214
+ } catch {
215
+ /* already gone */
216
+ }
217
+ }
218
+ }
219
+
220
+ // idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
221
+ // runs it unwatched. Only callers that KNOW the child streams progress (test
222
+ // steps) should pass a timeout — see runGroup.
223
+ function capture(cmd, cwd, idleTimeoutMs = 0) {
148
224
  return new Promise((resolve) => {
149
225
  const child = spawn(cmd, { cwd, shell: true });
150
226
  RUNNING.add(child);
151
227
  let out = "";
228
+ let idleTimer = null;
229
+ let killTimer = null;
230
+ let watchdogHit = false;
231
+ // Any output resets the watchdog — only complete silence for the full
232
+ // window counts as wedged. Escalate to SIGKILL for processes that ignore
233
+ // SIGTERM.
234
+ const armWatchdog = () => {
235
+ if (!idleTimeoutMs) return;
236
+ clearTimeout(idleTimer);
237
+ idleTimer = setTimeout(() => {
238
+ watchdogHit = true;
239
+ killTree(child);
240
+ killTimer = setTimeout(() => killTree(child, "SIGKILL"), 5000);
241
+ killTimer.unref();
242
+ }, idleTimeoutMs);
243
+ };
152
244
  const onData = (d) => {
153
245
  out += d;
246
+ armWatchdog();
154
247
  if (VERBOSE) process.stdout.write(d);
155
248
  };
249
+ armWatchdog();
156
250
  child.stdout.on("data", onData);
157
251
  child.stderr.on("data", onData);
158
252
  const done = (code, extra) => {
253
+ clearTimeout(idleTimer);
254
+ clearTimeout(killTimer);
159
255
  RUNNING.delete(child);
256
+ if (watchdogHit) {
257
+ const note =
258
+ `[watchdog] step produced no output for ${Math.round(idleTimeoutMs / 1000)}s — ` +
259
+ "process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
260
+ `not a slow run. Re-run the step directly to debug: \`${cmd}\``;
261
+ return resolve({ code: 1, out: `${out}\n${note}` });
262
+ }
160
263
  resolve({ code, out: extra ? `${out}\n${extra}` : out });
161
264
  };
162
265
  child.on("close", (code) => done(code ?? 1));
@@ -166,13 +269,36 @@ function capture(cmd, cwd) {
166
269
  function killAll() {
167
270
  for (const child of RUNNING) {
168
271
  try {
169
- child.kill("SIGTERM");
272
+ killTree(child);
170
273
  } catch {
171
274
  /* already gone */
172
275
  }
173
276
  }
174
277
  }
175
278
 
279
+ // A child killed by a signal surfaces through the package manager as a
280
+ // "Command failed with exit code 143/137" line (SIGTERM/SIGKILL), NOT as a test
281
+ // assertion failure — and the outer shell then reports its own generic exit 1,
282
+ // so `code` alone never reveals it. Surface the signal so the reason isn't
283
+ // mistaken for a real failure: the usual cause is resource pressure (parallel
284
+ // checks/builds swapping the machine) or an external kill.
285
+ function signalExitHint(out) {
286
+ const clean = stripAnsi(out);
287
+ // The watchdog also kills via SIGTERM, so pnpm's "exit code 143" ends up in
288
+ // the output — but that path already carries its own [watchdog] note with the
289
+ // correct (deadlock) diagnosis. Don't stack a contradictory "external kill"
290
+ // hint on top of it.
291
+ if (/\[watchdog\]/.test(clean)) return null;
292
+ const m = clean.match(/Command failed with exit code (137|143)\b/);
293
+ if (!m) return null;
294
+ const sig = m[1] === "143" ? "SIGTERM" : "SIGKILL";
295
+ return (
296
+ `[check] step ended via ${sig} (exit ${m[1]}) — the process was killed, not an assertion failure. ` +
297
+ "Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. " +
298
+ "Re-run this project's check alone to confirm."
299
+ );
300
+ }
301
+
176
302
  // ── live multi-line status (one line per running project) ────────────────────
177
303
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
178
304
  let liveCount = 0;
@@ -255,9 +381,23 @@ function asProject(rel, check) {
255
381
  return { check, dir: rel === "." ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
256
382
  }
257
383
 
258
- // Workspace sub-projects whose `check` is a real chain; if there are none (a
384
+ // Workspace sub-projects and their real check chain; if there are none (a
259
385
  // single-package repo), fall back to the root project — whose real chain lives
260
386
  // in `check:raw`, because the root `check` is THIS wrapper.
387
+ //
388
+ // A member's `check` is frequently THIS wrapper too: the lt starters ship their
389
+ // own scripts/check.mjs so they also work standalone (`lt server create`), and
390
+ // `lt fullstack init` clones them verbatim into projects/*. Treating that as
391
+ // "no real chain" silently dropped EVERY member — the run then fell back to the
392
+ // root, whose chain is just `pnpm -r run check`, and reported the whole
393
+ // monorepo as one opaque step with "no test step / 0 passed" while the members'
394
+ // tests were in fact running, unseen. So resolve a member exactly like the root:
395
+ // wrapper `check` means the real chain lives in `check:raw`.
396
+ function realChain(pkg) {
397
+ if (!IS_ORCHESTRATOR(pkg.scripts?.check)) return pkg.scripts?.check ?? null;
398
+ return pkg.scripts?.["check:raw"] ?? null;
399
+ }
400
+
261
401
  function discoverProjects() {
262
402
  const projects = [];
263
403
  for (const glob of workspaceGlobs()) {
@@ -268,7 +408,8 @@ function discoverProjects() {
268
408
  } catch {
269
409
  continue;
270
410
  }
271
- if (!IS_ORCHESTRATOR(pkg.scripts?.check)) projects.push(asProject(rel, pkg.scripts.check));
411
+ const chain = realChain(pkg);
412
+ if (chain) projects.push(asProject(rel, chain));
272
413
  }
273
414
  }
274
415
  if (projects.length === 0) {
@@ -290,6 +431,7 @@ function discoverProjects() {
290
431
  // package manager) is captured so the run mirrors the chain's own audit.
291
432
  function buildGroups(projects) {
292
433
  let auditCmd = null;
434
+ let installCmd = null;
293
435
  const groups = projects.map((project) => {
294
436
  const steps = [];
295
437
  for (const raw of project.check
@@ -301,11 +443,19 @@ function buildGroups(projects) {
301
443
  if (!auditCmd) auditCmd = raw;
302
444
  continue;
303
445
  }
446
+ if (meta.kind === "install") {
447
+ // Hoisted like the audit: one workspace-level install BEFORE the
448
+ // fan-out. In a pnpm workspace every member's install resolves the
449
+ // whole workspace anyway, and two parallel installs race on the same
450
+ // node_modules.
451
+ if (!installCmd) installCmd = raw;
452
+ continue;
453
+ }
304
454
  steps.push({ ...meta, cmd: toFixCommand(meta.kind, raw), cwd: project.dir });
305
455
  }
306
456
  return { project, steps };
307
457
  });
308
- return { auditCmd, groups };
458
+ return { auditCmd, groups, installCmd };
309
459
  }
310
460
 
311
461
  // ── per-project runner ───────────────────────────────────────────────────────
@@ -320,7 +470,10 @@ async function runGroup(group, states, results, abort) {
320
470
  st.current = step.label;
321
471
  st.stepStart = Date.now();
322
472
  if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
323
- const { code, out } = await capture(step.cmd, step.cwd);
473
+ // Watchdog only on test steps (see IDLE_TIMEOUT_MS): a test runner streams
474
+ // output continuously, so prolonged silence == deadlocked workers. Other
475
+ // steps buffer their output and must run unwatched.
476
+ const { code, out } = await capture(step.cmd, step.cwd, step.kind === "test" ? IDLE_TIMEOUT_MS : 0);
324
477
  const dur = Date.now() - st.stepStart;
325
478
  const r = { dur, kind: step.kind, label: step.label, project: rel };
326
479
  if (step.kind === "test") r.tests = parseVitest(out);
@@ -330,7 +483,12 @@ async function runGroup(group, states, results, abort) {
330
483
  st.failed = step.label;
331
484
  if (!abort.hit) {
332
485
  abort.hit = true;
333
- abort.failure = { out, project: rel, step: `${shortRel(rel)} · ${step.label}` };
486
+ const hint = signalExitHint(out);
487
+ abort.failure = {
488
+ out: hint ? `${out}\n${hint}` : out,
489
+ project: rel,
490
+ step: `${shortRel(rel)} · ${step.label}`,
491
+ };
334
492
  killAll();
335
493
  }
336
494
  return;
@@ -352,8 +510,9 @@ async function main() {
352
510
  console.error(C.red("No workspace projects with a `check` script found."));
353
511
  process.exit(1);
354
512
  }
355
- const { auditCmd, groups } = buildGroups(projects);
356
- const stepCount = groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0);
513
+ const { auditCmd, groups, installCmd } = buildGroups(projects);
514
+ const stepCount =
515
+ groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0) + (installCmd ? 1 : 0);
357
516
  const mode = SEQUENTIAL ? "sequential" : "parallel";
358
517
  const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
359
518
 
@@ -367,6 +526,26 @@ async function main() {
367
526
 
368
527
  const results = [];
369
528
 
529
+ // Step -1 — single hoisted workspace install (before audit and fan-out).
530
+ // The member chains each start with their own `pnpm install --frozen-lockfile`;
531
+ // running it ONCE at the workspace root is equivalent and removes the race of
532
+ // two parallel installs mutating the same node_modules.
533
+ if (installCmd) {
534
+ const t = Date.now();
535
+ if (!TTY) process.stdout.write(` ${C.dim("→")} install\n`);
536
+ else drawLive([`${C.cyan(FRAMES[0])} install`]);
537
+ const { code, out } = await capture(installCmd, ROOT);
538
+ const dur = Date.now() - t;
539
+ if (code !== 0) {
540
+ liveCount = 0; // the failure line must survive — nothing may overwrite it
541
+ console.log(`${C.red("✗")} install ${C.dim(`(${fmtDuration(dur)})`)}`);
542
+ return fail(`install (${installCmd})`, out, started);
543
+ }
544
+ if (!TTY) process.stdout.write(` ${C.green("✓")} install ${C.dim(`(${fmtDuration(dur)})`)}\n`);
545
+ // TTY success: no permanent line — see the audit block below.
546
+ results.push({ dur, kind: "step", label: "install", project: "." });
547
+ }
548
+
370
549
  // Step 0 — single workspace audit (blocking gate, runs before the fan-out).
371
550
  // Mirrors the chain's own audit command (scope/level/PM); skipped only when
372
551
  // the chain has no audit step.
@@ -375,9 +554,9 @@ async function main() {
375
554
  if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
376
555
  else drawLive([`${C.cyan(FRAMES[0])} audit`]);
377
556
  const audit = await runAudit(auditCmd);
378
- liveCount = 0;
379
557
  const dur = Date.now() - t;
380
558
  if (audit.blocking) {
559
+ liveCount = 0; // the failure line must survive — nothing may overwrite it
381
560
  const summary = audit.counts
382
561
  ? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
383
562
  : "failed";
@@ -388,10 +567,16 @@ async function main() {
388
567
  started,
389
568
  );
390
569
  }
391
- console.log(
392
- `${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`,
393
- );
570
+ if (!TTY) {
571
+ process.stdout.write(
572
+ ` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
573
+ );
574
+ }
575
+ // TTY success: NO permanent line — the live status view overwrites the audit
576
+ // row (like every other step); the result lands in the report twice: the
577
+ // Steps list (entry below) and the Vulnerabilities section.
394
578
  results.push({ audit, kind: "audit" });
579
+ results.push({ dur, kind: "step", label: "audit", project: "." });
395
580
  }
396
581
 
397
582
  // Per-project steps — parallel by default, serial with --sequential.
package/docs/commands.md CHANGED
@@ -14,6 +14,7 @@ This document provides a comprehensive reference for all `lt` CLI commands. For
14
14
  - [CLI Commands](#cli-commands)
15
15
  - [Server Commands](#server-commands)
16
16
  - [Local Development Commands](#local-development-commands)
17
+ - [Ticket Commands](#ticket-commands)
17
18
  - [Ports Commands](#ports-commands)
18
19
  - [Git Commands](#git-commands)
19
20
  - [Fullstack Commands](#fullstack-commands)
@@ -488,6 +489,27 @@ Sends `SIGTERM` to the detached process group (negative PID) so descendants —
488
489
 
489
490
  ---
490
491
 
492
+ ### `lt dev prune`
493
+
494
+ Remove the leftovers that accumulate around parallel ticket work.
495
+
496
+ **Usage:**
497
+ ```bash
498
+ lt dev prune # interactive (confirms DB drops, default yes)
499
+ lt dev prune --dry-run # show the plan, change nothing
500
+ lt dev prune --noConfirm # documented default without a prompt
501
+ ```
502
+
503
+ Collects three classes of orphans:
504
+
505
+ 1. **Orphaned ticket databases** of the current project — `<base>-<id>` (+`-test`, `-test-<n>`) whose ticket has neither a live worktree nor a live registry entry. Ticket ids come from this repo's own `feat/*` branches (the durable record `lt ticket stop` keeps), never from name shapes — so sibling projects sharing a name prefix are safe. Databases recorded via `lt ticket stop --keep-db` are never touched.
506
+ 2. **Stale shard test databases** (`<base>-test-<n>` from `lt dev test --shard`) when no test session is running.
507
+ 3. **Dead registry entries** (any project) — the recorded path no longer exists, so the entry and its reserved internal ports are reclaimed. Databases of dead MAIN projects are never dropped (a deleted folder is not consent to destroy data).
508
+
509
+ `lt dev up` runs the same collection automatically after a successful start (opt out with `--no-prune`), so restarting an environment always cleans up after its predecessors. Database access uses `mongosh` when available and falls back to the project's own `mongodb` driver otherwise — a machine without mongosh no longer silently skips every drop.
510
+
511
+ ---
512
+
491
513
  ### `lt dev status`
492
514
 
493
515
  Show what is registered + running.
@@ -616,6 +638,114 @@ lt dev test -- --ui spec.ts # everything after `--` is forwarded to playwri
616
638
 
617
639
  ---
618
640
 
641
+ ## Ticket Commands
642
+
643
+ One fully isolated dev environment per ticket: a git worktree on its own branch, its own
644
+ `lt dev` stack, its own URLs (`<slug>-<id>.localhost`), its own ports, and its own database
645
+ (`<base>-<id>`). Several tickets run side by side without colliding.
646
+
647
+ ### `lt ticket start`
648
+
649
+ Create a ticket environment: worktree + branch + isolated stack.
650
+
651
+ **Usage:**
652
+ ```bash
653
+ lt ticket start DEV-2200
654
+ lt ticket start login-fix --as lf
655
+ lt ticket start DEV-2200 --base origin/develop --no-up
656
+ ```
657
+
658
+ **Parameters:**
659
+
660
+ | Parameter | Description | Default |
661
+ |-----------|-------------|---------|
662
+ | `<name>` | Ticket id (`DEV-2200` → id `2200`) or free feature name (slugified) | — |
663
+ | `--as` | Explicit short id, overriding the derived one | derived from name |
664
+ | `--base` | Base ref for the new branch | probes `origin/dev` → `develop` → remote HEAD → `main` → `master` |
665
+ | `--branch` | Explicit branch name | `feat/<name>` |
666
+ | `--no-up` | Only create the worktree; do not boot the stack | `false` |
667
+
668
+ **Reserved ids:** `local`, `dev`, `test`, `e2e`, `ci`, `prod`, `production`, `staging` are
669
+ refused. Their derived database name would collide with the *project's own* dev/test
670
+ database (both derivations strip a trailing `-(local|dev)` before appending their suffix,
671
+ so e.g. project DB `imo-local` + ticket id `local` derives back to `imo-local`). Use `--as`
672
+ to map such a name to a distinct id.
673
+
674
+ ---
675
+
676
+ ### `lt ticket stop`
677
+
678
+ Tear a ticket environment down: stop the stack, remove the worktree, **drop the ticket's
679
+ databases**. The branch is kept, so committed work is never lost.
680
+
681
+ **Usage:**
682
+ ```bash
683
+ lt ticket stop 2200
684
+ lt ticket stop 2200 --keep-db
685
+ lt ticket stop # from INSIDE a ticket worktree → cleans up "this" env
686
+ ```
687
+
688
+ **Parameters:**
689
+
690
+ | Parameter | Description | Default |
691
+ |-----------|-------------|---------|
692
+ | `<id>` | Ticket id. Omit it inside a ticket worktree to stop *that* environment | from `.lt-dev/ticket` marker |
693
+ | `--keep-db` | Keep the ticket databases instead of dropping them | `false` (they ARE dropped) |
694
+ | `--force` | Remove even with uncommitted changes / unpushed commits | `false` |
695
+ | `--noConfirm` | Skip the confirmation prompt for the database drop | `false` |
696
+ | `--drop-db` | **Deprecated no-op** — dropping is the default now | — |
697
+
698
+ **Databases are dropped by default.** `lt ticket stop` removes the whole environment —
699
+ worktree *and* registry entry — so its databases are orphans the moment it returns: nothing
700
+ references them, nothing lists them, nothing reuses them. Left behind, they simply
701
+ accumulate. The command asks before dropping (unless `--noConfirm`), and `--keep-db` opts
702
+ out entirely.
703
+
704
+ **What it will never drop:** the project's own `<base>-local` / `<base>-test` databases, a
705
+ database belonging to a *different* ticket, or one named by a registry entry that turns out
706
+ to belong to another checkout. Anything that is not provably this ticket's database is
707
+ refused with a warning rather than guessed at.
708
+
709
+ **Ordering:** the worktree is removed *before* the databases are dropped. Removing a worktree
710
+ can fail (locked, modified submodule, permissions); dropping a database cannot be undone. So
711
+ the fallible step runs first — if it fails, nothing was destroyed.
712
+
713
+ ---
714
+
715
+ ### `lt ticket list`
716
+
717
+ Dashboard of all ticket environments: URLs, branch, status, database.
718
+
719
+ **Usage:**
720
+ ```bash
721
+ lt ticket list
722
+ ```
723
+
724
+ ---
725
+
726
+ ### `lt ticket switch`
727
+
728
+ Print a ticket worktree's path and open it in the editor.
729
+
730
+ **Usage:**
731
+ ```bash
732
+ lt ticket switch 2200
733
+ ```
734
+
735
+ ---
736
+
737
+ ### `lt ticket test`
738
+
739
+ Run the E2E suite inside a ticket's isolated stack and database.
740
+
741
+ **Usage:**
742
+ ```bash
743
+ lt ticket test 2200
744
+ lt ticket test 2200 --shard 2
745
+ ```
746
+
747
+ ---
748
+
619
749
  ## Git Commands
620
750
 
621
751
  All git commands support the `--noConfirm` flag and can be configured via `defaults.noConfirm` or `commands.git.noConfirm`.
@@ -383,7 +383,7 @@
383
383
  <span class="k">lt ticket list</span> <span class="c"># Dashboard: alle Tickets + URLs + Branch + Status + DB</span>
384
384
  <span class="k">lt ticket switch</span> <span class="a">2200</span> <span class="c"># Pfad zeigen + im Editor öffnen</span>
385
385
  <span class="k">lt ticket test</span> <span class="a">2200 --shard 2</span> <span class="c"># E2E im isolierten Ticket-Stack/-DB</span>
386
- <span class="k">lt ticket stop</span> <span class="a">2200 --drop-db</span> <span class="c"># down + worktree entfernen (Branch bleibt); --drop-db löscht DBs</span></pre>
386
+ <span class="k">lt ticket stop</span> <span class="a">2200</span> <span class="c"># down + worktree + DBs entfernen (Branch bleibt); --keep-db behält die DBs</span></pre>
387
387
 
388
388
  <div class="grid g3" style="margin-top:8px">
389
389
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#6366f1,#8b5cf6)">🎯</div>
@@ -457,7 +457,7 @@
457
457
  <div class="steps">
458
458
  <div class="step"><div class="n"></div><div><b>Morgens:</b> <code>lt ticket start DEV-2200</code>, <code>lt ticket start DEV-2201</code>, <code>lt ticket start login-fix</code> — drei Tabs, drei VS-Code-Fenster, drei Claude-Sessions. Jede Umgebung ist sofort im Browser unter ihrer eigenen URL.</div></div>
459
459
  <div class="step"><div class="n"></div><div><b>Mittags:</b> Review-Feedback zu 2201 kommt rein — du wechselst per <code>lt ticket switch 2201</code>, fixst, <code>lt ticket test 2201</code> läuft <i>parallel</i> während 2200 weiter im Browser offen ist.</div></div>
460
- <div class="step"><div class="n"></div><div><b>Nachmittags:</b> 2200 ist fertig → committen, pushen, MR. <code>lt ticket stop 2200 --drop-db</code> räumt restlos auf. 2201 und login-fix laufen ungestört weiter.</div></div>
460
+ <div class="step"><div class="n"></div><div><b>Nachmittags:</b> 2200 ist fertig → committen, pushen, MR. <code>lt ticket stop 2200</code> räumt restlos auf (Stack, Worktree und DBs). 2201 und login-fix laufen ungestört weiter.</div></div>
461
461
  <div class="step"><div class="n"></div><div><b>Überblick jederzeit:</b> <code>lt ticket list</code> zeigt alle Umgebungen mit URLs, Branch, Status und DB — du verlierst nie den Faden.</div></div>
462
462
  </div>
463
463
  </div>
@@ -532,7 +532,7 @@
532
532
  <div class="grid g2">
533
533
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#f43f5e,#be123c)">🛡️</div>
534
534
  <h3>Kein versehentlicher Datenverlust</h3>
535
- <p class="small"><code>lt ticket stop</code> verweigert das Entfernen, solange <b>uncommittete</b> oder <b>ungepushte</b> Arbeit existiert — mit klarer Auflistung. <code>--force</code> überschreibt bewusst. Generierte Dateien (<code>.nuxtrc</code> &amp; Co.) blockieren nicht.</p></div>
535
+ <p class="small"><code>lt ticket stop</code> verweigert das Entfernen, solange <b>uncommittete</b> oder <b>ungepushte</b> Arbeit existiert — mit klarer Auflistung. <code>--force</code> überschreibt bewusst. Generierte Dateien (<code>.nuxtrc</code> &amp; Co.) blockieren nicht. Die <b>Ticket-DBs</b> werden dabei gelöscht (sie wären danach verwaist) — vorher fragt der Befehl nach; <code>--keep-db</code> behält sie. Die Projekt-DBs werden dabei <i>nie</i> angefasst.</p></div>
536
536
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#f59e0b,#b45309)">🩺</div>
537
537
  <h3>Selbstdiagnose</h3>
538
538
  <p class="small"><code>lt dev doctor</code> prüft Caddy, CA, DNS, Ports — und warnt, wenn ein <code>global-setup</code> Ticket-Test-DBs nicht zurücksetzen würde, samt exaktem Fix.</p></div>
@@ -541,7 +541,7 @@
541
541
  <p class="small">Jede Claude-Session in einem Ticket-Worktree erkennt am <code>.lt-dev/ticket</code>-Marker automatisch ihr Ticket — und sieht jede Runde Ticket-ID, URLs und DB. Keine getrackte Datei wird verändert.</p></div>
542
542
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#10b981,#047857)">🧹</div>
543
543
  <h3>Restfreier Teardown</h3>
544
- <p class="small">Prozesse, Caddy-Block, Session, Registry-Eintrag, Ports — alles wird sauber zurückgegeben. <code>lt ticket stop</code> ohne ID räumt sogar das <i>aktuelle</i> Worktree auf.</p></div>
544
+ <p class="small">Prozesse, Caddy-Block, Session, Registry-Eintrag, Ports und die <b>Ticket-Datenbanken</b> — alles wird sauber zurückgegeben. <code>lt ticket stop</code> ohne ID räumt sogar das <i>aktuelle</i> Worktree auf.</p></div>
545
545
  </div>
546
546
  </div>
547
547
  </section>
@@ -569,7 +569,7 @@
569
569
  <span class="k">lt ticket list</span> <span class="c"># Dashboard</span>
570
570
  <span class="k">lt ticket switch</span> <span class="a">2200</span> <span class="c"># öffnen</span>
571
571
  <span class="k">lt ticket test</span> <span class="a">2200</span> <span class="c"># isolierte E2E</span>
572
- <span class="k">lt ticket stop</span> <span class="a">2200</span> <span class="c"># aufräumen (Branch bleibt)</span>
572
+ <span class="k">lt ticket stop</span> <span class="a">2200</span> <span class="c"># aufräumen: Worktree + DBs (Branch bleibt)</span>
573
573
  <span class="m">/lt-dev:take-ticket</span> <span class="c"># Ticket im aktuellen Checkout</span>
574
574
  <span class="m">/lt-dev:git:ship</span> <span class="c"># fertig → autonom nach dev</span></pre>
575
575
  </div>
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",