@lenne.tech/cli 1.36.0 → 1.37.1
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/deployment/create.js +5 -3
- 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
|
@@ -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
|
|
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.
|
|
144
|
-
*
|
|
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
|
-
//
|
|
201
|
-
|
|
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")
|
|
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
|
|
105
|
-
const files = clean
|
|
106
|
-
const failed = clean
|
|
107
|
-
if (
|
|
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
|
|
110
|
-
files
|
|
111
|
-
passed
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
392
|
-
|
|
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
|
|
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
|
|
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> & 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> & 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
|