@lenne.tech/cli 1.39.0 → 1.41.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.
@@ -58,25 +58,38 @@ const StatusCommand = {
58
58
  // port) count toward the health summary.
59
59
  const comps = [];
60
60
  if (e.internalPorts.api) {
61
- comps.push((0, dev_state_1.classifyComponentHealth)({ pid: session === null || session === void 0 ? void 0 : session.pids.api, portBound: snap.has(e.internalPorts.api) }));
61
+ comps.push((0, dev_state_1.classifyComponentHealth)({
62
+ pid: session === null || session === void 0 ? void 0 : session.pids.api,
63
+ portBound: snap.has(e.internalPorts.api),
64
+ startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
65
+ }));
62
66
  }
63
67
  if (e.internalPorts.app) {
64
- comps.push((0, dev_state_1.classifyComponentHealth)({ pid: session === null || session === void 0 ? void 0 : session.pids.app, portBound: snap.has(e.internalPorts.app) }));
68
+ comps.push((0, dev_state_1.classifyComponentHealth)({
69
+ pid: session === null || session === void 0 ? void 0 : session.pids.app,
70
+ portBound: snap.has(e.internalPorts.app),
71
+ startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
72
+ }));
65
73
  }
66
- const allRunning = comps.length > 0 && comps.every((h) => h === 'running');
67
- const anyRunning = comps.some((h) => h === 'running');
68
- const anyCrashed = comps.some((h) => h === 'crashed');
74
+ // Aggregate the per-component health into one honest stack glyph
75
+ // (pure, unit-tested in dev-state). Presentation (glyph + note) stays here.
76
+ const stack = (0, dev_state_1.summarizeStackHealth)(comps);
69
77
  let status;
70
78
  let note = '';
71
- if (allRunning) {
79
+ if (stack === 'running') {
72
80
  status = colors.green('●');
73
81
  }
74
- else if (anyRunning) {
82
+ else if (stack === 'degraded') {
75
83
  // Some up, some down — honest "partially up" rather than green.
76
84
  status = colors.yellow('◐');
77
85
  note = colors.yellow(' degraded — `lt dev up` to restart the down half');
78
86
  }
79
- else if (anyCrashed) {
87
+ else if (stack === 'starting') {
88
+ // Still booting after a recent `lt dev up` — not crashed, give it a moment.
89
+ status = colors.cyan('◐');
90
+ note = colors.dim(' starting — booting, give it a moment');
91
+ }
92
+ else if (stack === 'crashed') {
80
93
  status = colors.yellow('◐');
81
94
  note = colors.yellow(' crashed — `lt dev up` to restart');
82
95
  }
@@ -177,16 +190,20 @@ const StatusCommand = {
177
190
  const apiHealth = (0, dev_state_1.classifyComponentHealth)({
178
191
  pid: session.pids.api,
179
192
  portBound: entry.internalPorts.api ? snap.has(entry.internalPorts.api) : false,
193
+ startedAt: session.startedAt,
180
194
  });
181
195
  const appHealth = (0, dev_state_1.classifyComponentHealth)({
182
196
  pid: session.pids.app,
183
197
  portBound: entry.internalPorts.app ? snap.has(entry.internalPorts.app) : false,
198
+ startedAt: session.startedAt,
184
199
  });
185
200
  const label = (health) => health === 'running'
186
201
  ? colors.green('running')
187
- : health === 'crashed'
188
- ? colors.yellow('crashed (supervisor up, port not listening)')
189
- : colors.red('dead');
202
+ : health === 'starting'
203
+ ? colors.cyan('starting (booting port not bound yet)')
204
+ : health === 'crashed'
205
+ ? colors.yellow('crashed (supervisor up, port not listening)')
206
+ : colors.red('dead');
190
207
  if (session.pids.api !== undefined || entry.internalPorts.api) {
191
208
  info(` api: ${label(apiHealth)} (pid ${(_a = session.pids.api) !== null && _a !== void 0 ? _a : '-'})`);
192
209
  }
@@ -208,10 +225,17 @@ const StatusCommand = {
208
225
  // half and leaves the healthy one running.
209
226
  const apiPresent = session.pids.api !== undefined || !!entry.internalPorts.api;
210
227
  const appPresent = session.pids.app !== undefined || !!entry.internalPorts.app;
211
- const down = [
212
- apiPresent && apiHealth !== 'running' ? 'api' : null,
213
- appPresent && appHealth !== 'running' ? 'app' : null,
214
- ].filter((c) => c !== null);
228
+ // A `starting` component is booting, not down don't tell the user to
229
+ // restart a healthy stack that just needs a few more seconds. Partition is
230
+ // pure + unit-tested in dev-state.
231
+ const { down, starting } = (0, dev_state_1.partitionComponentStates)([
232
+ { health: apiHealth, name: 'api', present: apiPresent },
233
+ { health: appHealth, name: 'app', present: appPresent },
234
+ ]);
235
+ if (starting.length > 0) {
236
+ info('');
237
+ info(colors.cyan(` ${starting.join(' + ')} still booting — give it a few seconds, then re-run \`lt dev status\`.`));
238
+ }
215
239
  if (down.length > 0) {
216
240
  const crashed = (apiPresent && apiHealth === 'crashed') || (appPresent && appHealth === 'crashed');
217
241
  info('');
@@ -9,9 +9,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.help = void 0;
12
13
  const fs_1 = require("fs");
13
14
  const path_1 = require("path");
14
15
  const caddy_1 = require("../../lib/caddy");
16
+ const dev_api_launch_1 = require("../../lib/dev-api-launch");
15
17
  const dev_env_1 = require("../../lib/dev-env");
16
18
  const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
17
19
  const dev_identity_1 = require("../../lib/dev-identity");
@@ -84,7 +86,7 @@ const UpCommand = {
84
86
  hidden: false,
85
87
  name: 'up',
86
88
  run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
87
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
89
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
88
90
  const { filesystem, parameters, print: { colors, error, info, success, warning }, } = toolbox;
89
91
  const layout = (0, dev_project_1.resolveLayout)(filesystem.cwd(), filesystem);
90
92
  if (!layout.apiDir && !layout.appDir) {
@@ -112,6 +114,10 @@ const UpCommand = {
112
114
  // suffixed so the stack is fully isolated from the base dev session and every
113
115
  // other ticket. Without a ticket this is the plain project identity.
114
116
  const { dbName, identity, ticket } = (0, dev_ticket_1.resolveDevIdentity)(layout, { ticket: parameters.options.ticket });
117
+ // `--api-compiled`: run the API compiled (`node dist`) instead of ts-node.
118
+ // Trades hot reload for stability — ts-node intermittently dies under browser
119
+ // load without a stacktrace (DEV-2525); `lt dev test` already runs compiled.
120
+ const apiCompiled = (0, dev_api_launch_1.isApiCompiledRequested)(parameters.options);
115
121
  // Guard against two checkouts of the SAME project (same package.json "name"
116
122
  // → same slug → shared URLs / ports / DB / Caddy block). If another checkout
117
123
  // is already RUNNING under this slug, abort with a clear message — otherwise
@@ -254,16 +260,33 @@ const UpCommand = {
254
260
  // ── Health-aware (re)start decision ──────────────────────────────────────
255
261
  // Probe the just-resolved ports so we can tell a still-serving component
256
262
  // from a crashed one (supervisor PID alive, port free). Only dead/crashed
257
- // components get (re)started; a healthy one keeps running untouched.
263
+ // components get (re)started; a running one keeps serving untouched — and a
264
+ // still-BOOTING one (`starting`: PID alive, port not bound yet, within the
265
+ // startup grace window after a recent `up`) is ALSO kept, not force-restarted.
266
+ // Passing `startedAt` is what lets the classifier distinguish that boot window
267
+ // from a real crash — otherwise a re-run of `lt dev up` during the API's slow
268
+ // boot would kill the still-booting component and reset its progress (the very
269
+ // false-positive the `starting` state exists to prevent).
258
270
  const hasApi = Boolean(layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort);
259
271
  const hasApp = Boolean(layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort);
260
272
  const healthSnap = yield (0, dev_process_1.listenSnapshot)([apiPort, appPort].filter((p) => typeof p === 'number'));
261
273
  const apiHealth = hasApi
262
- ? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, portBound: !!apiPort && healthSnap.has(apiPort) })
274
+ ? (0, dev_state_1.classifyComponentHealth)({
275
+ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api,
276
+ portBound: !!apiPort && healthSnap.has(apiPort),
277
+ startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
278
+ })
263
279
  : undefined;
264
280
  const appHealth = hasApp
265
- ? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, portBound: !!appPort && healthSnap.has(appPort) })
281
+ ? (0, dev_state_1.classifyComponentHealth)({
282
+ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app,
283
+ portBound: !!appPort && healthSnap.has(appPort),
284
+ startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
285
+ })
266
286
  : undefined;
287
+ // A component we leave running untouched is either serving (`running`) or
288
+ // still within its boot grace window (`starting`).
289
+ const isKeepable = (h) => h === 'running' || h === 'starting';
267
290
  // All present components already serving → nothing to do.
268
291
  const presentHealth = [apiHealth, appHealth].filter((h) => h !== undefined);
269
292
  if (presentHealth.length > 0 && presentHealth.every((h) => h === 'running')) {
@@ -283,18 +306,21 @@ const UpCommand = {
283
306
  process.exit();
284
307
  return 'dev up: already running';
285
308
  }
286
- // Partial restart — at least one component is healthy and at least one is
287
- // down. Announce what we keep vs. restart so the user sees the honest state.
288
- const partialRestart = presentHealth.some((h) => h === 'running');
309
+ // Partial restart — at least one component is kept (running or still booting)
310
+ // and at least one is down. Announce what we keep vs. restart so the user sees
311
+ // the honest state. A `starting` component is kept (booting), NOT restarted.
312
+ const partialRestart = presentHealth.some(isKeepable);
289
313
  if (partialRestart) {
290
- if (apiHealth === 'running')
291
- info(colors.dim(`api healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api}) → keeping`));
292
- else if (apiHealth)
293
- warning(`api ${apiHealth} (port ${apiPort} not serving) → restarting`);
294
- if (appHealth === 'running')
295
- info(colors.dim(`app healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app}) → keeping`));
296
- else if (appHealth)
297
- warning(`app ${appHealth} (port ${appPort} not serving) → restarting`);
314
+ const announceDecision = (name, h, pid, port) => {
315
+ if (h === 'running')
316
+ info(colors.dim(`${name} healthy (pid ${pid}) → keeping`));
317
+ else if (h === 'starting')
318
+ info(colors.cyan(`${name} still booting (pid ${pid}) → keeping (grace window)`));
319
+ else if (h)
320
+ warning(`${name} ${h} (port ${port} not serving) → restarting`);
321
+ };
322
+ announceDecision('api', apiHealth, existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, apiPort);
323
+ announceDecision('app', appHealth, existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, appPort);
298
324
  }
299
325
  // Caddy block + reload.
300
326
  const routes = [];
@@ -321,7 +347,7 @@ const UpCommand = {
321
347
  if (identity.subdomains.app)
322
348
  info(` app: https://${identity.subdomains.app.hostname} → 127.0.0.1:${appPort}`);
323
349
  if (identity.subdomains.api)
324
- info(` api: https://${identity.subdomains.api.hostname} → 127.0.0.1:${apiPort}`);
350
+ info(` api: https://${identity.subdomains.api.hostname} → 127.0.0.1:${apiPort}${apiCompiled ? colors.dim(' (compiled, no hot reload)') : ''}`);
325
351
  if (identity.subdomains.api)
326
352
  info(` db: mongodb://127.0.0.1/${dbName}`);
327
353
  info('');
@@ -350,9 +376,15 @@ const UpCommand = {
350
376
  yield (0, dev_process_1.terminateProcessGroup)(bound.pid);
351
377
  });
352
378
  if (hasApi && layout.apiDir && apiPort) {
353
- if (apiHealth === 'running') {
379
+ if (isKeepable(apiHealth)) {
354
380
  pids.api = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api;
355
381
  kept.push('api');
382
+ // `--api-compiled` only takes effect when the API (re)starts; a running or
383
+ // still-booting API is kept as-is (force-restarting it would contradict the
384
+ // keep logic), so tell the user how to switch a currently-live ts-node API
385
+ // to compiled.
386
+ if (apiCompiled)
387
+ info(colors.dim(' --api-compiled: API already running/booting — `lt dev down` first to switch it.'));
356
388
  }
357
389
  else {
358
390
  yield reclaimPort(existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, apiPort, apiHealth !== null && apiHealth !== void 0 ? apiHealth : 'dead');
@@ -361,11 +393,20 @@ const UpCommand = {
361
393
  // regenerate a foreign lockfile + crash on un-approved build
362
394
  // scripts when run against an npm-only project.
363
395
  const apiPm = (0, dev_package_manager_1.pickPackageManager)(layout.apiDir);
364
- const apiResult = (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
365
- cwd: layout.apiDir,
366
- env: devEnv.api.env,
367
- logFile: (0, path_1.join)(layout.root, '.lt-dev', 'api.log'),
368
- });
396
+ const apiLogFile = (0, path_1.join)(layout.root, '.lt-dev', 'api.log');
397
+ const apiResult = apiCompiled
398
+ ? yield (0, dev_api_launch_1.startCompiledApi)({
399
+ apiDir: layout.apiDir,
400
+ env: devEnv.api.env,
401
+ log: { info, warn: warning },
402
+ logFile: apiLogFile,
403
+ pm: apiPm,
404
+ })
405
+ : (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
406
+ cwd: layout.apiDir,
407
+ env: devEnv.api.env,
408
+ logFile: apiLogFile,
409
+ });
369
410
  if (apiResult) {
370
411
  pids.api = apiResult.pid;
371
412
  started.push('api');
@@ -376,7 +417,7 @@ const UpCommand = {
376
417
  }
377
418
  }
378
419
  if (hasApp && layout.appDir && appPort) {
379
- if (appHealth === 'running') {
420
+ if (isKeepable(appHealth)) {
380
421
  pids.app = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app;
381
422
  kept.push('app');
382
423
  }
@@ -397,28 +438,38 @@ const UpCommand = {
397
438
  }
398
439
  }
399
440
  }
400
- // Persist the session (PIDs) — merging kept (healthy) + freshly started PIDs.
401
- // The registry entry (ports) was already reserved atomically above. On a
402
- // partial restart we preserve the original session start time.
403
- const startedAt = existingSession && kept.length > 0 ? existingSession.startedAt : new Date().toISOString();
441
+ // Persist the session (PIDs) — merging kept (running/booting) + freshly
442
+ // started PIDs. The registry entry (ports) was already reserved atomically
443
+ // above. Reset the session clock ONLY when something was actually (re)started:
444
+ // - A freshly restarted component MUST get "now" so its startup grace window
445
+ // is measured from its real start — inheriting a stale `startedAt` would
446
+ // make a just-restarted, still-booting component read as `crashed` during
447
+ // its own boot (a kept `running` component is classified by port-bound, not
448
+ // `startedAt`, so a fresh timestamp never mis-ages it).
449
+ // - When nothing was started (all kept — e.g. both still booting), preserve
450
+ // the original so repeated `up` during a boot can't keep extending the
451
+ // grace window indefinitely.
452
+ const startedAt = started.length > 0 ? new Date().toISOString() : ((_h = existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt) !== null && _h !== void 0 ? _h : new Date().toISOString());
404
453
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt });
405
454
  // Write the ENV bridge so external tools (Playwright, IDE test runners,
406
455
  // custom shell scripts) can pick up the URLs without inheriting our shell.
407
456
  const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
408
457
  info(colors.dim(`ENV bridge: ${bridgePath}`));
409
458
  const summary = started.length === 0
410
- ? 'Nothing restarted'
459
+ ? kept.length > 0
460
+ ? `Kept ${kept.join('+')} (already running or booting)`
461
+ : 'Nothing restarted'
411
462
  : kept.length > 0
412
463
  ? `Restarted ${started.join('+')} (kept ${kept.join('+')})`
413
464
  : `Started ${started.join('+')}`;
414
- success(`${summary}: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
465
+ success(`${summary}: api pid=${(_j = pids.api) !== null && _j !== void 0 ? _j : '-'}, app pid=${(_k = pids.app) !== null && _k !== void 0 ? _k : '-'}`);
415
466
  // Echo the bound URLs next to the PIDs as well — the "Starting" block
416
467
  // prints them before the spawn, but on a long boot log they scroll out
417
468
  // of view, so repeating them here keeps PID + URL visually grouped.
418
469
  printProjectUrls(info, {
419
- apiHostname: (_k = identity.subdomains.api) === null || _k === void 0 ? void 0 : _k.hostname,
470
+ apiHostname: (_l = identity.subdomains.api) === null || _l === void 0 ? void 0 : _l.hostname,
420
471
  apiUpstreamPort: apiPort,
421
- appHostname: (_l = identity.subdomains.app) === null || _l === void 0 ? void 0 : _l.hostname,
472
+ appHostname: (_m = identity.subdomains.app) === null || _m === void 0 ? void 0 : _m.hostname,
422
473
  appUpstreamPort: appPort,
423
474
  dbName: identity.subdomains.api ? dbName : undefined,
424
475
  });
@@ -446,7 +497,7 @@ const UpCommand = {
446
497
  try {
447
498
  mainRepoRoot = (0, dev_ticket_1.gitMainRepoRoot)(layout.root);
448
499
  }
449
- catch (_m) {
500
+ catch (_o) {
450
501
  /* not a git repo — registry prune still applies */
451
502
  }
452
503
  const mainLayout = (0, dev_project_1.resolveLayout)(mainRepoRoot, filesystem);
@@ -493,7 +544,7 @@ const UpCommand = {
493
544
  }
494
545
  }
495
546
  }
496
- catch (_o) {
547
+ catch (_p) {
497
548
  /* never block `up` on cleanup */
498
549
  }
499
550
  }
@@ -502,4 +553,28 @@ const UpCommand = {
502
553
  return `dev up: api=${pids.api}, app=${pids.app}`;
503
554
  }),
504
555
  };
505
- module.exports = UpCommand;
556
+ exports.help = {
557
+ aliases: ['u'],
558
+ configuration: 'Ephemeral dev-orchestration flags — no lt.config counterpart. Override the spawn binary for both processes via the LT_PNPM_BIN env var.',
559
+ description: 'Start the API + App behind Caddy under stable https://<slug>.localhost URLs. Health-aware and idempotent: re-running (re)starts only the component(s) that are not truly serving.',
560
+ examples: ['dev up', 'dev up --api-compiled', 'dev up --ticket DEV-1234'],
561
+ features: [
562
+ 'Registers a Caddy block and allocates opaque internal ports (4000+).',
563
+ 'Spawns API + App detached; persists PIDs to <root>/.lt-dev/state.json.',
564
+ 'Self-heals legacy hardcoded ports and restarts only the down component(s).',
565
+ ],
566
+ name: 'up',
567
+ options: [
568
+ {
569
+ description: 'Run the API compiled (node dist/src/main.js) instead of ts-node — trades hot reload for stability under browser load (DEV-2525). Builds + migrates first; falls back to the ts-node start if the build fails. Only applies when the API (re)starts.',
570
+ flag: '--api-compiled',
571
+ type: 'boolean',
572
+ },
573
+ {
574
+ description: 'Suffix the slug / URLs / DB for an isolated ticket stack (also auto-detected from a .lt-dev/ticket marker).',
575
+ flag: '--ticket',
576
+ type: 'string',
577
+ },
578
+ ],
579
+ };
580
+ module.exports = Object.assign(UpCommand, { help: exports.help });
@@ -0,0 +1,89 @@
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
+ exports.findCompiledEntry = findCompiledEntry;
13
+ exports.isApiCompiledRequested = isApiCompiledRequested;
14
+ exports.startCompiledApi = startCompiledApi;
15
+ /**
16
+ * Launch strategy for the API process under `lt dev up`.
17
+ *
18
+ * By default `lt dev up` runs the API via ts-node (`<pm> run start` → nodemon →
19
+ * ts-node src/main.ts) for hot reload. Under a browser driving the app, that
20
+ * ts-node process intermittently dies WITHOUT a stacktrace (dev-SSR load plus
21
+ * ts-node's heavier footprint) — see DEV-2525. `lt dev test` already sidesteps
22
+ * this by running the API COMPILED (`node dist/src/main.js`); this module brings
23
+ * the same option to `lt dev up`, opt-in via `--api-compiled`. The trade-off is
24
+ * NO hot reload, so it stays opt-in — the caller decides stability vs. reload.
25
+ */
26
+ const node_fs_1 = require("node:fs");
27
+ const node_path_1 = require("node:path");
28
+ const dev_process_1 = require("./dev-process");
29
+ /** Candidate compiled entry points, in preference order. Single-sourced so `lt dev test` agrees. */
30
+ const COMPILED_ENTRIES = ['dist/src/main.js', 'dist/main.js'];
31
+ /** Resolve the compiled API entry point in `apiDir`, or `undefined` if none was built. */
32
+ function findCompiledEntry(apiDir) {
33
+ return COMPILED_ENTRIES.map((rel) => (0, node_path_1.join)(apiDir, rel)).find((candidate) => (0, node_fs_1.existsSync)(candidate));
34
+ }
35
+ /**
36
+ * True when the caller opted into the compiled API via `--api-compiled`.
37
+ *
38
+ * gluegun parses argv with yargs-parser and declares no booleans, so the flag
39
+ * arrives in several shapes: a value-less `--api-compiled` → boolean `true`, but
40
+ * `--api-compiled=true` → the STRING `'true'` and `--api-compiled=1` → the NUMBER
41
+ * `1`. A bare `=== true` check silently ignores the latter two and drops the very
42
+ * stability fix the user asked for. This is an ENABLE flag, so a mis-parse fails
43
+ * SAFE (default ts-node) — but the repo convention is to honour `true`/`'true'`
44
+ * too (see `dev-ticket.ts#keepDbFlag` for the destructive-flag counterpart).
45
+ */
46
+ function isApiCompiledRequested(options = {}) {
47
+ const affirmative = (value) => value === true || ['1', 'true', 'yes'].includes(String(value).toLowerCase());
48
+ return affirmative(options.apiCompiled) || affirmative(options['api-compiled']);
49
+ }
50
+ /**
51
+ * Build the API and start it compiled (`node dist/src/main.js`). Applies pending
52
+ * migrations first for parity with the ts-node path it replaces (`<pm> run start`
53
+ * = `migrate:up && start:local`). Falls back to the ts-node start when the build
54
+ * fails or produces no dist entry, so this never leaves the developer with a dead
55
+ * API. Returns the detached spawn result (`undefined` when nothing was started).
56
+ */
57
+ function startCompiledApi(options) {
58
+ return __awaiter(this, void 0, void 0, function* () {
59
+ const { apiDir, env, log, logFile, pm } = options;
60
+ log.info('Building API (compiled, for stability — no hot reload) …');
61
+ const build = yield (0, dev_process_1.runChildInherit)(pm.bin, pm.runScript('build'), { cwd: apiDir, env });
62
+ const entry = findCompiledEntry(apiDir);
63
+ if (build === 0 && entry) {
64
+ if (hasScript(apiDir, 'migrate:up')) {
65
+ const migrate = yield (0, dev_process_1.runChildInherit)(pm.bin, pm.runScript('migrate:up'), { cwd: apiDir, env });
66
+ if (migrate !== 0) {
67
+ // Parity with `migrate:up && start:local`: a failed migration must PREVENT the server
68
+ // from starting rather than boot it against a half-migrated DB behind a "Started" banner.
69
+ log.warn(`migrate:up failed (exit ${String(migrate)}) — API NOT started (would run on an un-migrated DB).`);
70
+ return undefined;
71
+ }
72
+ }
73
+ return (0, dev_process_1.spawnDetached)('node', [entry], { cwd: apiDir, env: Object.assign(Object.assign({}, env), { NODE_ENV: 'local' }), logFile });
74
+ }
75
+ log.warn(`compiled API unavailable (build exit ${String(build)}) — falling back to \`${pm.bin} start\` (ts-node).`);
76
+ return (0, dev_process_1.spawnDetached)(pm.bin, pm.runScript('start'), { cwd: apiDir, env, logFile });
77
+ });
78
+ }
79
+ /** True when `package.json` in `apiDir` defines a script named `name`. */
80
+ function hasScript(apiDir, name) {
81
+ var _a;
82
+ try {
83
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(apiDir, 'package.json'), 'utf8'));
84
+ return typeof ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a[name]) === 'string';
85
+ }
86
+ catch (_b) {
87
+ return false;
88
+ }
89
+ }
@@ -223,7 +223,27 @@ function spawnDetached(cmd, args, opts) {
223
223
  const out = (0, fs_1.openSync)(opts.logFile, 'a');
224
224
  let child;
225
225
  try {
226
- child = (0, child_process_1.spawn)(cmd, args, {
226
+ // Raise the soft file-descriptor limit before exec-ing the real command.
227
+ // macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
228
+ // by the terminal that runs `lt dev up` and therefore by these detached children
229
+ // — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
230
+ // The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
231
+ // monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
232
+ // that force a manual `lt dev up`. We wrap the command in `sh -c "ulimit …; exec …"`:
233
+ // - `exec` replaces the shell IN PLACE → the recorded PID and the detached
234
+ // process group are still the real process (PID tracking + group-kill in
235
+ // `terminateProcessGroup` keep working).
236
+ // - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
237
+ // - the cascade tries a high limit first, falling back on machines with a
238
+ // lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
239
+ // Note: because the outer `spawn('/bin/sh', …)` almost always succeeds, a bogus
240
+ // `cmd` no longer surfaces as `pid === undefined` here — the inner `exec` fails
241
+ // (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
242
+ // `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
243
+ // deliberately don't watch for that here: a detached, unref'd child's 127 exit is
244
+ // racy to observe, and callers must be self-correcting against real crashes anyway.
245
+ const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
246
+ child = (0, child_process_1.spawn)('/bin/sh', ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], {
227
247
  cwd: opts.cwd,
228
248
  detached: true,
229
249
  env: opts.env,
@@ -9,8 +9,10 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.paths = exports.TEST_SESSION_FILE = void 0;
12
+ exports.paths = exports.TEST_SESSION_FILE = exports.STARTUP_GRACE_MS = void 0;
13
13
  exports.classifyComponentHealth = classifyComponentHealth;
14
+ exports.partitionComponentStates = partitionComponentStates;
15
+ exports.summarizeStackHealth = summarizeStackHealth;
14
16
  exports.allocateInternalPort = allocateInternalPort;
15
17
  exports.clearSession = clearSession;
16
18
  exports.detectSlugConflict = detectSlugConflict;
@@ -38,16 +40,66 @@ exports.withRegistryLock = withRegistryLock;
38
40
  const fs_1 = require("fs");
39
41
  const os_1 = require("os");
40
42
  const path_1 = require("path");
43
+ /**
44
+ * How long after `lt dev up` a not-yet-bound-but-PID-alive component is treated
45
+ * as `starting` (booting) rather than `crashed`. Covers the slow API boot
46
+ * (compile + Mongo + Better Auth + AI tools + migrations) with headroom.
47
+ */
48
+ exports.STARTUP_GRACE_MS = 60000;
41
49
  /**
42
50
  * Classify a component's true health from its recorded wrapper PID plus whether
43
51
  * its internal port is actually bound (caller provides the port probe result,
44
52
  * typically from a single {@link import('./dev-process').listenSnapshot} call).
53
+ *
54
+ * When `startedAt` is supplied, a PID-alive-but-port-unbound component is reported
55
+ * as `starting` (booting) rather than `crashed` for the first {@link STARTUP_GRACE_MS}
56
+ * after `lt dev up` — see the {@link ComponentHealth} doc block for the full state
57
+ * model and the false-positive it prevents.
45
58
  */
46
59
  function classifyComponentHealth(opts) {
60
+ var _a;
47
61
  const wrapperAlive = typeof opts.pid === 'number' && isPidAlive(opts.pid);
48
62
  if (!wrapperAlive)
49
63
  return 'dead';
50
- return opts.portBound ? 'running' : 'crashed';
64
+ if (opts.portBound)
65
+ return 'running';
66
+ // Wrapper alive but the port is not bound. Within the startup grace window after
67
+ // `lt dev up` this is a still-BOOTING component, not a crash (avoids the
68
+ // false-positive that told users to restart a healthy, still-booting stack).
69
+ const graceMs = (_a = opts.startupGraceMs) !== null && _a !== void 0 ? _a : exports.STARTUP_GRACE_MS;
70
+ if (opts.startedAt) {
71
+ const ageMs = Date.now() - new Date(opts.startedAt).getTime();
72
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < graceMs)
73
+ return 'starting';
74
+ }
75
+ return 'crashed';
76
+ }
77
+ /**
78
+ * Split present components into those still booting (`starting`) vs. genuinely
79
+ * down (present, not running, not starting). A `starting` component is booting,
80
+ * not down — it must NEVER appear in the "restart the down half" hint, otherwise
81
+ * the user is told to restart a healthy, still-booting stack.
82
+ */
83
+ function partitionComponentStates(components) {
84
+ const starting = components.filter((c) => c.present && c.health === 'starting').map((c) => c.name);
85
+ const down = components
86
+ .filter((c) => c.present && c.health !== 'running' && c.health !== 'starting')
87
+ .map((c) => c.name);
88
+ return { down, starting };
89
+ }
90
+ /** See {@link StackHealth} for the precedence rules this implements. */
91
+ function summarizeStackHealth(components) {
92
+ if (components.length === 0)
93
+ return 'stopped';
94
+ if (components.every((h) => h === 'running'))
95
+ return 'running';
96
+ if (components.some((h) => h === 'running'))
97
+ return 'degraded';
98
+ if (components.some((h) => h === 'starting'))
99
+ return 'starting';
100
+ if (components.some((h) => h === 'crashed'))
101
+ return 'crashed';
102
+ return 'stopped';
51
103
  }
52
104
  const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
53
105
  const SESSION_DIR = '.lt-dev';
@@ -47,6 +47,7 @@ const fs_1 = require("fs");
47
47
  const os_1 = require("os");
48
48
  const path_1 = require("path");
49
49
  const caddy_1 = require("./caddy");
50
+ const dev_api_launch_1 = require("./dev-api-launch");
50
51
  const dev_env_1 = require("./dev-env");
51
52
  const dev_env_bridge_1 = require("./dev-env-bridge");
52
53
  const dev_identity_1 = require("./dev-identity");
@@ -245,9 +246,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
245
246
  log.info(log.dim('Building API (compiled, for stable long runs) …'));
246
247
  build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: layout.apiDir, env: process.env });
247
248
  }
248
- const entry = ['dist/src/main.js', 'dist/main.js']
249
- .map((rel) => (0, path_1.join)(layout.apiDir, rel))
250
- .find((p) => (0, fs_1.existsSync)(p));
249
+ const entry = (0, dev_api_launch_1.findCompiledEntry)(layout.apiDir);
251
250
  // Seed a throwaway initial admin into the fresh, isolated test DB so the
252
251
  // standard auth E2E specs run against a set-up system — locally exactly like
253
252
  // the lt-monorepo template CI. Defaults first so an explicitly inherited
package/docs/commands.md CHANGED
@@ -433,6 +433,13 @@ lt dev up
433
433
 
434
434
  **Alias:** `lt d u`
435
435
 
436
+ **Flags:**
437
+ - `--api-compiled` — run the API **compiled** (`node dist/src/main.js`) instead of ts-node. Trades hot reload for stability: under sustained browser / dev-SSR load the ts-node API process intermittently dies without a stacktrace (DEV-2525); the compiled `node` process does not. Opt-in — omit it for the default ts-node hot-reload start. Details:
438
+ - Builds the API first, then applies pending migrations (`migrate:up`) for parity with the default `migrate:up && start:local`; a failed migration aborts the start rather than booting against a half-migrated DB.
439
+ - Auto-falls-back to the ts-node `start` if the build fails or produces no `dist` entry, so you never end up with a dead API.
440
+ - Only takes effect when the API actually (re)starts. A healthy running API is kept as-is — run `lt dev down` first to switch a live ts-node API to compiled.
441
+ - Accepted spellings: `--api-compiled` or `--api-compiled=true`.
442
+
436
443
  **Environment variables injected:**
437
444
  | Variable | Consumer | Example value |
438
445
  |----------|----------|---------------|
@@ -460,15 +467,20 @@ a still-serving component from a *crashed* one (the supervisor / nodemon survive
460
467
  ts-node crash and the recorded PID stays alive while nothing listens on the port).
461
468
  Behaviour:
462
469
  - **All present components truly serving** → no-op, exits 0 with "already running".
463
- - **Some serving, some down** restarts ONLY the down component(s); a healthy one
464
- keeps running untouched and its PID is preserved in the session.
470
+ - **Still booting** (PID alive, port not bound yet, within the 60 s startup grace
471
+ window) KEPT, not restarted. Re-running `lt dev up` while the API is still
472
+ booting must not kill and restart the still-booting component.
473
+ - **Some serving, some down** → restarts ONLY the down (`crashed`/`dead`)
474
+ component(s); a running or still-booting one keeps its PID untouched.
465
475
  - Before respawning a crashed component it terminates that supervisor's whole
466
476
  process group (so its idle `nodemon` doesn't leak / stack a second one) and
467
477
  reclaims any orphaned listener still squatting the reused port.
468
478
 
469
479
  This is the fix for the "`status` says api running but no data loads" case: a
470
- crashed ts-node dev API is healed by simply re-running `lt dev up` (it does not
471
- fall back to compiled `node dist` — ts-node is kept so code edits hot-reload).
480
+ crashed ts-node dev API is healed by simply re-running `lt dev up`. The **automatic**
481
+ heal keeps ts-node (it does not silently switch to compiled `node dist`, so code
482
+ edits still hot-reload); pass the explicit `--api-compiled` flag (see **Flags** above)
483
+ when you deliberately want to trade hot reload for compiled stability.
472
484
 
473
485
  **Logs:** `<root>/.lt-dev/api.log`, `<root>/.lt-dev/app.log` (append-mode).
474
486
 
@@ -525,12 +537,17 @@ lt dev status --all # every project in the registry
525
537
  The current-project view shows subdomains → upstream ports, db URI, per-component
526
538
  health, and live `lsof` state. **Health is honest:** a component is reported
527
539
  `running` only when its supervisor PID is alive AND its internal port is actually
528
- bound. A supervisor that survived a ts-node crash (PID alive, port free) is shown
529
- as `crashed (supervisor up, port not listening)` instead of the old misleading
530
- `running`, with a hint to run `lt dev up` to restart just that one.
540
+ bound. A component that is PID-alive but not yet bound is `starting (booting — port
541
+ not bound yet)` during the first 60 s after `lt dev up` (the slow API boot: swc
542
+ compile + Mongo + Better Auth + migrations) booting, not down, so no restart is
543
+ suggested. Once that grace window elapses with the port still free, a supervisor
544
+ that survived a ts-node crash (PID alive, port free) is shown as `crashed
545
+ (supervisor up, port not listening)` instead of the old misleading `running`, with
546
+ a hint to run `lt dev up` to restart just that one.
531
547
 
532
548
  The `--all` view lists every project with a single indicator:
533
549
  - `●` (green) — all present components serving
550
+ - `◐` (cyan) — `starting` (booting within the startup grace window — give it a moment)
534
551
  - `◐` (yellow) — `degraded` (some up, some down) or `crashed` (supervisor up, port free)
535
552
  - `○` (dim) — stopped
536
553
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",