@lenne.tech/cli 1.26.1 → 1.28.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.
@@ -0,0 +1,70 @@
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 child_process_1 = require("child_process");
13
+ const dev_project_1 = require("../../lib/dev-project");
14
+ const dev_ticket_1 = require("../../lib/dev-ticket");
15
+ /**
16
+ * `lt ticket switch <id>` — show a ticket worktree's path and open it in your
17
+ * editor (best-effort). A CLI cannot change the parent shell's directory, so it
18
+ * also prints the `cd` line to copy. `--no-open` only prints.
19
+ */
20
+ const SwitchCommand = {
21
+ alias: ['sw', 'open'],
22
+ description: 'Show a ticket worktree path + open it in your editor',
23
+ name: 'switch',
24
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
25
+ var _a;
26
+ const { filesystem, parameters, print: { colors, error, info, success }, } = toolbox;
27
+ const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
28
+ if (!id) {
29
+ error('Usage: lt ticket switch <id>');
30
+ if (!parameters.options.fromGluegunMenu)
31
+ process.exit(1);
32
+ return 'ticket switch: missing id';
33
+ }
34
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
35
+ let mainRepoRoot;
36
+ try {
37
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
38
+ }
39
+ catch (_b) {
40
+ error('Not inside a git repository.');
41
+ if (!parameters.options.fromGluegunMenu)
42
+ process.exit(1);
43
+ return 'ticket switch: not a git repo';
44
+ }
45
+ const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
46
+ if (!wt) {
47
+ error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
48
+ if (!parameters.options.fromGluegunMenu)
49
+ process.exit(1);
50
+ return 'ticket switch: not found';
51
+ }
52
+ info('');
53
+ info(`Ticket "${id}" → ${wt.path}`);
54
+ info(colors.dim(` cd ${wt.path}`));
55
+ if (parameters.options.open !== false) {
56
+ const editor = process.env.LT_EDITOR || 'code';
57
+ try {
58
+ (0, child_process_1.execFileSync)(editor, [wt.path], { stdio: 'ignore' });
59
+ success(`Opened in ${editor}.`);
60
+ }
61
+ catch (_c) {
62
+ info(colors.dim(` (could not run \`${editor}\` — open the folder manually, or set LT_EDITOR)`));
63
+ }
64
+ }
65
+ if (!parameters.options.fromGluegunMenu)
66
+ process.exit();
67
+ return `ticket switch: ${id}`;
68
+ }),
69
+ };
70
+ module.exports = SwitchCommand;
@@ -0,0 +1,80 @@
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_process_1 = require("../../lib/dev-process");
13
+ const dev_project_1 = require("../../lib/dev-project");
14
+ const dev_ticket_1 = require("../../lib/dev-ticket");
15
+ /**
16
+ * `lt ticket test <id> [--shard N] [-- <args>]` — run the E2E suite for a ticket
17
+ * in ITS isolated stack + DB, by delegating to `lt dev test` inside the ticket
18
+ * worktree (which is ticket-aware via the marker → test DB `<base>-<id>-test`).
19
+ */
20
+ const TestCommand = {
21
+ alias: ['t'],
22
+ description: 'Run the E2E suite for a ticket in its isolated stack',
23
+ name: 'test',
24
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
25
+ var _a;
26
+ const { filesystem, parameters, print: { colors, error, info }, } = toolbox;
27
+ const id = String((_a = parameters.first) !== null && _a !== void 0 ? _a : '').trim();
28
+ if (!id) {
29
+ error('Usage: lt ticket test <id> [--shard N] [--keep] [-- <playwright args>]');
30
+ if (!parameters.options.fromGluegunMenu)
31
+ process.exit(1);
32
+ return 'ticket test: missing id';
33
+ }
34
+ const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
35
+ let mainRepoRoot;
36
+ try {
37
+ mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
38
+ }
39
+ catch (_b) {
40
+ error('Not inside a git repository.');
41
+ if (!parameters.options.fromGluegunMenu)
42
+ process.exit(1);
43
+ return 'ticket test: not a git repo';
44
+ }
45
+ const wt = (0, dev_ticket_1.listWorktrees)(mainRepoRoot).find((w) => w.ticket === id);
46
+ if (!wt) {
47
+ error(`No ticket worktree "${id}" found. See \`lt ticket list\`.`);
48
+ if (!parameters.options.fromGluegunMenu)
49
+ process.exit(1);
50
+ return 'ticket test: not found';
51
+ }
52
+ // Reconstruct `lt dev test` args: forward the common flags + the `--` array.
53
+ const devArgs = ['dev', 'test'];
54
+ if (parameters.options.shard !== undefined) {
55
+ const s = parameters.options.shard;
56
+ devArgs.push(s === true ? '--shard' : `--shard=${s}`);
57
+ }
58
+ if (parameters.options.keep === true)
59
+ devArgs.push('--keep');
60
+ if (parameters.options.debug === true)
61
+ devArgs.push('--debug');
62
+ // Playwright args after `--` (e.g. a spec path / `--grep`): read them from
63
+ // the RAW argv. gluegun's parsed `parameters.array` does not reliably carry
64
+ // post-`--` tokens through the `ticket <id>` positional, which silently ran
65
+ // the WHOLE suite instead of the requested spec.
66
+ const dashIdx = process.argv.indexOf('--');
67
+ const forwarded = dashIdx >= 0 ? process.argv.slice(dashIdx + 1) : [];
68
+ if (forwarded.length > 0)
69
+ devArgs.push('--', ...forwarded);
70
+ info(colors.dim(`(cd ${wt.path} && lt ${devArgs.join(' ')})`));
71
+ const code = yield (0, dev_process_1.runChildInherit)(process.execPath, [process.argv[1], ...devArgs], {
72
+ cwd: wt.path,
73
+ env: process.env,
74
+ });
75
+ if (!parameters.options.fromGluegunMenu)
76
+ process.exit(code !== null && code !== void 0 ? code : 1);
77
+ return `ticket test: ${id} exit=${code}`;
78
+ }),
79
+ };
80
+ module.exports = TestCommand;
@@ -0,0 +1,36 @@
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
+ /**
13
+ * Parallel ticket dev environments (`lt ticket <subcommand>`).
14
+ *
15
+ * Each ticket runs in its OWN git worktree (a fresh branch from `origin/dev`)
16
+ * with its OWN isolated `lt dev` stack — own URLs, ports, Caddy block and empty
17
+ * database — so several tickets can be developed, browser-tested and E2E-tested
18
+ * fully in parallel without ever influencing each other.
19
+ *
20
+ * Subcommands:
21
+ * - `start <name>` — create the worktree + bring the isolated stack up
22
+ * - `list` — dashboard of all ticket envs (URLs, branch, status, DB)
23
+ * - `switch <id>` — show + open a ticket worktree in your editor
24
+ * - `test <id>` — run the E2E suite in the ticket's isolated stack
25
+ * - `stop <id>` — tear the env down + remove the worktree (branch kept)
26
+ */
27
+ module.exports = {
28
+ alias: ['tk'],
29
+ description: 'Parallel ticket dev environments (worktree + isolated lt dev stack)',
30
+ hidden: false,
31
+ name: 'ticket',
32
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
33
+ yield toolbox.helper.showMenu('ticket');
34
+ return 'ticket';
35
+ }),
36
+ };
@@ -24,8 +24,8 @@ const os_1 = require("os");
24
24
  const path_1 = require("path");
25
25
  const HEADER = `# Managed by \`lt dev up\` — do NOT edit, will be overwritten.\n# Removed by \`lt dev down\`. Loaded by Playwright + other tools.\n`;
26
26
  /** Remove the ENV bridge file. No-op if missing. */
27
- function clearEnvBridge(projectRoot) {
28
- const file = envBridgePath(projectRoot);
27
+ function clearEnvBridge(projectRoot, fileName = '.env') {
28
+ const file = envBridgePath(projectRoot, fileName);
29
29
  if (!(0, fs_1.existsSync)(file))
30
30
  return false;
31
31
  try {
@@ -61,16 +61,16 @@ function detectCaddyRootCa() {
61
61
  return null;
62
62
  }
63
63
  /** Resolve the path to the ENV bridge file for a project. */
64
- function envBridgePath(projectRoot) {
65
- return (0, path_1.join)(projectRoot, '.lt-dev', '.env');
64
+ function envBridgePath(projectRoot, fileName = '.env') {
65
+ return (0, path_1.join)(projectRoot, '.lt-dev', fileName);
66
66
  }
67
67
  /**
68
68
  * Write the ENV bridge file. Idempotent — same content = no rewrite.
69
69
  *
70
70
  * Returns the absolute path that was written.
71
71
  */
72
- function writeEnvBridge(projectRoot, devEnv, dbName) {
73
- const file = envBridgePath(projectRoot);
72
+ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
73
+ const file = envBridgePath(projectRoot, fileName);
74
74
  const lines = [];
75
75
  // The App-side env is the more "external" one (Playwright, browser tools).
76
76
  // We expose every URL/storage/api key the App env carries.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildIdentity = buildIdentity;
4
+ exports.buildTestIdentity = buildTestIdentity;
5
+ exports.buildTicketIdentity = buildTicketIdentity;
4
6
  exports.projectSlug = projectSlug;
5
7
  exports.slugify = slugify;
6
8
  /**
@@ -67,6 +69,40 @@ function buildIdentity(root) {
67
69
  }
68
70
  return { root, slug, subdomains };
69
71
  }
72
+ /**
73
+ * Derive an ephemeral "test" identity from a base identity (used by
74
+ * `lt dev test`). Suffixes the slug and every subdomain hostname with
75
+ * `-test`, so the test stack runs on its own URLs / ports / Caddy block,
76
+ * fully parallel to (and isolated from) the dev session.
77
+ *
78
+ * svl.localhost → svl-test.localhost
79
+ * api.svl.localhost → api.svl-test.localhost
80
+ */
81
+ function buildTestIdentity(base, suffix = '-test') {
82
+ const slug = `${base.slug}${suffix}`;
83
+ const subdomains = {};
84
+ for (const [sub, value] of Object.entries(base.subdomains)) {
85
+ subdomains[sub] = Object.assign(Object.assign({}, value), { hostname: value.isPrimaryApp ? `${slug}.localhost` : `${sub}.${slug}.localhost` });
86
+ }
87
+ return { root: base.root, slug, subdomains };
88
+ }
89
+ /**
90
+ * Derive a per-TICKET identity from a base identity (used by `lt ticket` /
91
+ * `lt dev up --ticket`). Suffixes the slug + every subdomain hostname with the
92
+ * ticket id, so each ticket worktree runs on its OWN URLs / ports / Caddy block
93
+ * / DB — fully parallel to and isolated from every other ticket and the base
94
+ * dev session.
95
+ *
96
+ * svl.localhost → svl-2200.localhost
97
+ * api.svl.localhost → api.svl-2200.localhost
98
+ *
99
+ * Mechanically identical to {@link buildTestIdentity} (a named wrapper for
100
+ * readability + intent at the call sites). `id` is already a clean slug (see
101
+ * `deriveTicketId` in dev-ticket.ts).
102
+ */
103
+ function buildTicketIdentity(base, id) {
104
+ return buildTestIdentity(base, `-${id}`);
105
+ }
70
106
  /**
71
107
  * Read the bare project name from package.json (scope stripped).
72
108
  * Falls back to directory basename if no package.json or no `name`.
@@ -78,15 +78,25 @@ function runMigrate(input) {
78
78
  });
79
79
  const identity = (0, dev_identity_1.buildIdentity)(layout.root);
80
80
  const dbName = (0, dev_project_1.deriveDbName)(layout.apiDir, identity.slug);
81
- // 1. Code patches (config.env.ts, nuxt.config.ts, playwright.config.ts).
81
+ // 1. Code patches. Run `autoPatch` over EVERY existing config file (not just
82
+ // the ones a port-detector flags): the patches are idempotent, and some —
83
+ // e.g. the playwright.config `ignoreHTTPSErrors` + shard-aware
84
+ // `LT_DEV_TEST_SHARDS` timeout block — apply to configs that are already
85
+ // env-aware. So `lt dev init` makes any project fully `lt dev test --shard`
86
+ // ready in one command; an up-to-date config is a no-op (`patched: false`).
82
87
  const filesToPatch = [];
83
88
  if (layout.apiDir) {
84
- const f = (0, dev_project_1.apiNeedsPortPatch)(layout.apiDir);
85
- if (f)
86
- filesToPatch.push(f);
89
+ const apiCfg = (0, path_1.join)(layout.apiDir, 'src', 'config.env.ts');
90
+ if ((0, fs_1.existsSync)(apiCfg))
91
+ filesToPatch.push(apiCfg);
92
+ }
93
+ if (layout.appDir) {
94
+ for (const rel of ['nuxt.config.ts', 'playwright.config.ts']) {
95
+ const f = (0, path_1.join)(layout.appDir, rel);
96
+ if ((0, fs_1.existsSync)(f))
97
+ filesToPatch.push(f);
98
+ }
87
99
  }
88
- if (layout.appDir)
89
- filesToPatch.push(...(0, dev_project_1.appNeedsPortPatch)(layout.appDir));
90
100
  const codePatches = filesToPatch.map((f) => (0, dev_patches_1.autoPatch)(f));
91
101
  // 2. CLAUDE.md URL block (root + each subproject — only patches existing files).
92
102
  const claudeCandidates = [
@@ -165,6 +165,16 @@ function patchNuxtConfig(file) {
165
165
  * bracketed by `// >>> lt-dev:bridge >>>` markers.
166
166
  * 2. Hardcoded baseURL/host/url for `http://localhost:3001` →
167
167
  * `process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3001'`.
168
+ * 3. `webServer` wrapped in an `LT_DEV_ACTIVE` guard so Playwright reuses
169
+ * the App already served by `lt dev` / `lt dev test` instead of spawning
170
+ * its own (which would bind the wrong port and miss the isolated stack).
171
+ * 4. `ignoreHTTPSErrors: true` so Playwright's Chromium accepts the lt dev
172
+ * Caddy self-signed cert (required for `lt dev test` over HTTPS).
173
+ * 5. Shard-aware timeouts gated on `LT_DEV_TEST_SHARDS` — a `SHARDED` const
174
+ * plus relaxed `timeout` / `expect` / `navigationTimeout` / `actionTimeout`
175
+ * under sharded load only, so serial + CI keep their tight, fast-failing
176
+ * defaults. Each sub-patch is a graceful no-op on a non-standard config.
177
+ * 6. `slowMo: 10` → `0` (pointless per-action delay, multiplied across shards).
168
178
  */
169
179
  function patchPlaywrightConfig(file) {
170
180
  if (!(0, fs_1.existsSync)(file))
@@ -225,6 +235,67 @@ function patchPlaywrightConfig(file) {
225
235
  count++;
226
236
  }
227
237
  }
238
+ // 3. Wrap `webServer` in an `LT_DEV_ACTIVE` guard so Playwright does NOT
239
+ // start/manage its own server when the App is already served by
240
+ // `lt dev` / `lt dev test` (both export LT_DEV_ACTIVE + the App URL).
241
+ // The original array/object's closing `]`/`}` becomes the ternary's
242
+ // false branch, so no bracket-matching is needed. Idempotent.
243
+ if (!/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(after)) {
244
+ after = after.replace(/webServer:\s*([[{])/, (_match, open) => {
245
+ count++;
246
+ return `webServer: process.env.LT_DEV_ACTIVE ? undefined : ${open}`;
247
+ });
248
+ }
249
+ // 4. ignoreHTTPSErrors — accept the `lt dev` Caddy self-signed cert on
250
+ // `https://*.localhost` (Playwright's bundled Chromium uses its own trust
251
+ // store, so NODE_EXTRA_CA_CERTS alone is not enough). No-op in CI (http).
252
+ // Without this, `lt dev test` fails with ERR_CERT_AUTHORITY_INVALID.
253
+ if (!/ignoreHTTPSErrors/.test(after)) {
254
+ after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
255
+ count++;
256
+ return `${whole}\n${indent} ignoreHTTPSErrors: true,`;
257
+ });
258
+ }
259
+ // 5. Shard-aware timeouts — `lt dev test --shard N` runs N built stacks +
260
+ // N Chromium concurrently; the CPU saturates and SSR slows 2-3x. Relax
261
+ // timeouts ONLY under that load (the CLI exports LT_DEV_TEST_SHARDS), so
262
+ // serial + CI keep their tight values and fast-failure feedback. Each
263
+ // sub-patch is idempotent + a graceful no-op on non-standard configs.
264
+ if (!/const SHARDED\b/.test(after) && /export default defineConfig/.test(after)) {
265
+ const shardConst = '// `lt dev test --shard N` saturates the CPU (N built SSR servers + N Chromium),\n' +
266
+ '// slowing every navigation. Relax timeouts ONLY under that load — the CLI sets\n' +
267
+ '// LT_DEV_TEST_SHARDS — so serial + CI keep their tight, fast-failing defaults.\n' +
268
+ "const SHARDED = Number(process.env.LT_DEV_TEST_SHARDS || '0') > 1;\n\n";
269
+ after = after.replace(/(export default defineConfig)/, `${shardConst}$1`);
270
+ count++;
271
+ }
272
+ // 5a. per-test timeout (`isWindows ? A : B` form) → add the sharded branch.
273
+ if (/timeout:\s*isWindows\s*\?/.test(after) && !/timeout:\s*isWindows\s*\?[^,\n]*SHARDED/.test(after)) {
274
+ after = after.replace(/timeout:\s*isWindows\s*\?\s*([0-9_]+)\s*:\s*([0-9_]+|undefined)/, (_m, a, b) => {
275
+ count++;
276
+ return `timeout: isWindows ? ${a} : SHARDED ? 180_000 : ${b}`;
277
+ });
278
+ }
279
+ // 5b. expect.timeout (only when an `expect: { timeout: N }` already exists).
280
+ if (/expect:\s*\{\s*timeout:\s*[0-9_]+\s*\}/.test(after) && !/expect:\s*\{\s*timeout:\s*SHARDED/.test(after)) {
281
+ after = after.replace(/expect:\s*\{\s*timeout:\s*([0-9_]+)\s*\}/, (_m, t) => {
282
+ count++;
283
+ return `expect: { timeout: SHARDED ? 30_000 : ${t} }`;
284
+ });
285
+ }
286
+ // 5c. navigation/action ceilings under shard (inject into `use` if absent).
287
+ if (!/navigationTimeout/.test(after)) {
288
+ after = after.replace(/(\n(\s*)use:\s*\{)/, (_m, whole, indent) => {
289
+ count++;
290
+ return `${whole}\n${indent} actionTimeout: SHARDED ? 30_000 : undefined,\n${indent} navigationTimeout: SHARDED ? 60_000 : undefined,`;
291
+ });
292
+ }
293
+ // 6. slowMo: 10 → 0 — an artificial per-action delay, pointless and multiplied
294
+ // across N concurrent sharded browsers.
295
+ after = after.replace(/slowMo:\s*10\b/, () => {
296
+ count++;
297
+ return 'slowMo: 0';
298
+ });
228
299
  if (count === 0)
229
300
  return { file, patched: false, replacements: 0 };
230
301
  (0, fs_1.writeFileSync)(file, after, 'utf8');
@@ -13,7 +13,11 @@ exports.checkPortInUse = checkPortInUse;
13
13
  exports.killProcessGroup = killProcessGroup;
14
14
  exports.listenSnapshot = listenSnapshot;
15
15
  exports.rotateLogFile = rotateLogFile;
16
+ exports.runChildInherit = runChildInherit;
17
+ exports.runChildToFile = runChildToFile;
16
18
  exports.spawnDetached = spawnDetached;
19
+ exports.terminateProcessGroup = terminateProcessGroup;
20
+ exports.waitForHttp = waitForHttp;
17
21
  /**
18
22
  * Process + port helpers for `lt dev`.
19
23
  *
@@ -145,6 +149,60 @@ function rotateLogFile(logFile) {
145
149
  }
146
150
  return { archivePath, previousSize, rotated: true };
147
151
  }
152
+ /**
153
+ * Run a child to completion with inherited stdio. Resolves with the exit code.
154
+ *
155
+ * Counterpart of `spawnDetached`: foreground, synchronous-feeling, used for
156
+ * commands the user must see live output from (build, test runners).
157
+ * Errors during spawn resolve as exit code `1` so callers can branch on a
158
+ * single integer instead of try/catch.
159
+ */
160
+ function runChildInherit(cmd, args, opts) {
161
+ return new Promise((resolve) => {
162
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' });
163
+ child.on('error', () => resolve(1));
164
+ child.on('close', (code) => resolve(code));
165
+ });
166
+ }
167
+ /**
168
+ * Run a child to completion with stdout+stderr redirected to a log file.
169
+ * Resolves with the exit code (`1` on spawn error). Like `runChildInherit` but
170
+ * non-interleaving — used to run several children CONCURRENTLY (e.g. parallel
171
+ * Playwright shards) without their console output clobbering each other.
172
+ */
173
+ function runChildToFile(cmd, args, opts) {
174
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(opts.logFile), { recursive: true });
175
+ // Rotate so each run starts with a fresh log (one prior generation kept as
176
+ // `<logFile>.1`) instead of appending run-on-run.
177
+ rotateLogFile(opts.logFile);
178
+ const out = (0, fs_1.openSync)(opts.logFile, 'a');
179
+ const close = () => {
180
+ try {
181
+ (0, fs_1.closeSync)(out);
182
+ }
183
+ catch (_a) {
184
+ /* already closed */
185
+ }
186
+ };
187
+ return new Promise((resolve) => {
188
+ let child;
189
+ try {
190
+ child = (0, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] });
191
+ }
192
+ catch (_a) {
193
+ close();
194
+ return resolve(1);
195
+ }
196
+ child.on('error', () => {
197
+ close();
198
+ resolve(1);
199
+ });
200
+ child.on('close', (code) => {
201
+ close();
202
+ resolve(code);
203
+ });
204
+ });
205
+ }
148
206
  /**
149
207
  * Spawn a detached child whose stdio is redirected to a log file.
150
208
  *
@@ -188,3 +246,99 @@ function spawnDetached(cmd, args, opts) {
188
246
  }
189
247
  }
190
248
  }
249
+ /**
250
+ * Terminate a detached process group RELIABLY: SIGTERM the group, wait up to
251
+ * `graceMs` for it to exit, then SIGKILL anything still alive.
252
+ *
253
+ * Needed because a compiled NestJS API (`node dist`) installs shutdown hooks
254
+ * that catch SIGTERM and can hang on open Mongo connections — a single
255
+ * SIGTERM then "succeeds" (the call returns) while the process keeps
256
+ * listening on its port and holding DB connections. `lt dev test`'s
257
+ * residue-free teardown promise depends on the process actually being gone,
258
+ * so we escalate to SIGKILL after a grace period.
259
+ *
260
+ * Polls every 150ms so a process that exits cleanly returns near-instantly
261
+ * (only a hung process waits the full `graceMs`). Returns true if the process
262
+ * is gone by the end, false if it somehow survived even SIGKILL.
263
+ */
264
+ function terminateProcessGroup(pid_1) {
265
+ return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000) {
266
+ if (!(0, dev_state_1.isValidPid)(pid))
267
+ return false;
268
+ if (!(0, dev_state_1.isPidAlive)(pid))
269
+ return true;
270
+ // Phase 1 — graceful: SIGTERM the group (single-PID fallback inside).
271
+ killProcessGroup(pid);
272
+ const deadline = Date.now() + Math.max(0, graceMs);
273
+ while (Date.now() < deadline) {
274
+ if (!(0, dev_state_1.isPidAlive)(pid))
275
+ return true;
276
+ yield delay(150);
277
+ }
278
+ // Phase 2 — forced: SIGKILL the group, then the single PID.
279
+ if (!(0, dev_state_1.isPidAlive)(pid))
280
+ return true;
281
+ try {
282
+ process.kill(-pid, 'SIGKILL');
283
+ }
284
+ catch (_a) {
285
+ /* group already gone or pid is not a group leader */
286
+ }
287
+ try {
288
+ process.kill(pid, 'SIGKILL');
289
+ }
290
+ catch (_b) {
291
+ /* already dead */
292
+ }
293
+ yield delay(150);
294
+ return !(0, dev_state_1.isPidAlive)(pid);
295
+ });
296
+ }
297
+ /**
298
+ * Poll an HTTPS/HTTP URL until a matching response is observed or `timeoutMs`
299
+ * elapses.
300
+ *
301
+ * Used to wait for dev servers to become reachable before the next step
302
+ * (typically running a test suite). By default treats ANY HTTP status (1xx-5xx)
303
+ * as "up" — a 404 means the server is bound and answering, which is usually
304
+ * what we want to know. Pass `ready` to require a stricter status: an API
305
+ * readiness probe wants a real 2xx on `/meta`, because Caddy answers 502 while
306
+ * its upstream is still booting and the default predicate would accept that
307
+ * prematurely (the cause of the test-suite API-readiness race). Uses `curl`
308
+ * because it is universally available and handles HTTPS-with-self-signed-cert
309
+ * (Caddy) via `-k` for free.
310
+ *
311
+ * Resolves `true` on the first matching response, `false` on timeout. Never rejects.
312
+ */
313
+ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) {
314
+ const start = Date.now();
315
+ return new Promise((resolve) => {
316
+ const tick = () => {
317
+ var _a;
318
+ const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
319
+ stdio: ['ignore', 'pipe', 'ignore'],
320
+ });
321
+ let status = '';
322
+ (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
323
+ child.on('close', () => {
324
+ const code = Number(status.trim());
325
+ // `000` (curl could not connect) parses to 0 → never "ready".
326
+ if (Number.isFinite(code) && code > 0 && ready(code))
327
+ return resolve(true);
328
+ if (Date.now() - start > timeoutMs)
329
+ return resolve(false);
330
+ setTimeout(tick, 500);
331
+ });
332
+ child.on('error', () => {
333
+ if (Date.now() - start > timeoutMs)
334
+ return resolve(false);
335
+ setTimeout(tick, 500);
336
+ });
337
+ };
338
+ tick();
339
+ });
340
+ }
341
+ /** Promise-based delay used by the graceful→forced termination escalation. */
342
+ function delay(ms) {
343
+ return new Promise((resolve) => setTimeout(resolve, ms));
344
+ }
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.apiNeedsPortPatch = apiNeedsPortPatch;
4
4
  exports.appNeedsPortPatch = appNeedsPortPatch;
5
5
  exports.deriveDbName = deriveDbName;
6
+ exports.deriveTestDbName = deriveTestDbName;
7
+ exports.deriveTicketDbName = deriveTicketDbName;
6
8
  exports.resolveLayout = resolveLayout;
7
9
  const fs_1 = require("fs");
8
10
  const path_1 = require("path");
@@ -33,7 +35,10 @@ function appNeedsPortPatch(appDir) {
33
35
  /target:\s*'http:\/\/localhost:3000'/.test(c) ||
34
36
  /baseURL:\s*'http:\/\/localhost:3001'/.test(c) ||
35
37
  /url:\s*'http:\/\/localhost:3001'/.test(c) ||
36
- /host:\s*'http:\/\/localhost:3001'/.test(c));
38
+ /host:\s*'http:\/\/localhost:3001'/.test(c) ||
39
+ // Unguarded Playwright `webServer` (no LT_DEV_ACTIVE guard) — patch it so
40
+ // `lt dev test`'s isolated stack is reused instead of a stray server.
41
+ (/webServer:\s*[[{]/.test(c) && !/webServer:\s*process\.env\.LT_DEV_ACTIVE/.test(c)));
37
42
  });
38
43
  }
39
44
  /** Read `dbName` from the API config (defaults to `<slug>-local`). */
@@ -49,6 +54,34 @@ function deriveDbName(apiDir, slug) {
49
54
  }
50
55
  return `${slug}-local`;
51
56
  }
57
+ /**
58
+ * Derive the dedicated database name for the `lt dev test` stack from the
59
+ * project's dev DB name. Distinct from both `<…>-local` (developer DB) and
60
+ * the API unit-test DB (`<…>-e2e`), so Playwright E2E never touches developer
61
+ * or API-test data.
62
+ *
63
+ * svl-sports-system-local → svl-sports-system-test
64
+ *
65
+ * Uses the `-test` suffix so it passes test-helper guards that only permit
66
+ * local/test databases (name ending in `-local` | `-ci` | `-e2e` | `-test`).
67
+ */
68
+ function deriveTestDbName(devDbName) {
69
+ const base = devDbName.replace(/-(local|dev)$/i, '');
70
+ return `${base}-test`;
71
+ }
72
+ /**
73
+ * Derive the per-TICKET database name from the project's dev DB name, so each
74
+ * ticket worktree reads/writes its OWN database and tickets never collide.
75
+ *
76
+ * svl-sports-system-local + "2200" → svl-sports-system-2200
77
+ *
78
+ * The ticket's isolated `lt dev test` stack then derives its test DB from this
79
+ * via {@link deriveTestDbName} → `svl-sports-system-2200-test`.
80
+ */
81
+ function deriveTicketDbName(devDbName, ticketId) {
82
+ const base = devDbName.replace(/-(local|dev)$/i, '');
83
+ return `${base}-${ticketId}`;
84
+ }
52
85
  /**
53
86
  * Resolve layout starting from `cwd`. Walks up to find a workspace if
54
87
  * cwd is inside `projects/api/` or `projects/app/`.