@lenne.tech/cli 1.30.0 → 1.31.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.
@@ -39,13 +39,51 @@ const StatusCommand = {
39
39
  warning('No projects registered. Run `lt dev init` in a project.');
40
40
  }
41
41
  else {
42
+ // One lsof over EVERY registered internal port, so liveness reflects
43
+ // what is actually bound — not just whether a supervisor PID survives a
44
+ // crashed ts-node. (See classifyComponentHealth.)
45
+ const allPorts = [];
46
+ for (const slug of slugs) {
47
+ const e = reg.projects[slug];
48
+ if (e.internalPorts.api)
49
+ allPorts.push(e.internalPorts.api);
50
+ if (e.internalPorts.app)
51
+ allPorts.push(e.internalPorts.app);
52
+ }
53
+ const snap = yield (0, dev_process_1.listenSnapshot)(allPorts);
42
54
  for (const slug of slugs) {
43
55
  const e = reg.projects[slug];
44
56
  const session = (0, dev_state_1.loadSession)(e.path);
45
- const apiAlive = (session === null || session === void 0 ? void 0 : session.pids.api) ? (0, dev_state_1.isPidAlive)(session.pids.api) : false;
46
- const appAlive = (session === null || session === void 0 ? void 0 : session.pids.app) ? (0, dev_state_1.isPidAlive)(session.pids.app) : false;
47
- const status = apiAlive || appAlive ? colors.green('●') : colors.dim('○');
48
- info(` ${status} ${slug.padEnd(30)} ${colors.dim(e.path)}`);
57
+ // Only components the project actually has (a registered internal
58
+ // port) count toward the health summary.
59
+ const comps = [];
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) }));
62
+ }
63
+ 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) }));
65
+ }
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');
69
+ let status;
70
+ let note = '';
71
+ if (allRunning) {
72
+ status = colors.green('●');
73
+ }
74
+ else if (anyRunning) {
75
+ // Some up, some down — honest "partially up" rather than green.
76
+ status = colors.yellow('◐');
77
+ note = colors.yellow(' degraded — `lt dev up` to restart the down half');
78
+ }
79
+ else if (anyCrashed) {
80
+ status = colors.yellow('◐');
81
+ note = colors.yellow(' crashed — `lt dev up` to restart');
82
+ }
83
+ else {
84
+ status = colors.dim('○');
85
+ }
86
+ info(` ${status} ${slug.padEnd(30)} ${colors.dim(e.path)}${note}`);
49
87
  for (const [sub, host] of Object.entries(e.subdomains))
50
88
  info(` ${sub.padEnd(6)} https://${host}`);
51
89
  }
@@ -128,22 +166,56 @@ const StatusCommand = {
128
166
  info(colors.dim(' no `lt dev up` session active'));
129
167
  }
130
168
  else {
131
- const apiAlive = session.pids.api ? (0, dev_state_1.isPidAlive)(session.pids.api) : false;
132
- const appAlive = session.pids.app ? (0, dev_state_1.isPidAlive)(session.pids.app) : false;
133
- info(` api: ${apiAlive ? colors.green('running') : colors.red('dead')} (pid ${(_a = session.pids.api) !== null && _a !== void 0 ? _a : '-'})`);
134
- info(` app: ${appAlive ? colors.green('running') : colors.red('dead')} (pid ${(_b = session.pids.app) !== null && _b !== void 0 ? _b : '-'})`);
135
- info(colors.dim(` started: ${session.startedAt}`));
136
- // Live port snapshot
169
+ // Probe the actual ports FIRST: a component is only truly "running" when
170
+ // its supervisor PID is alive AND its internal port is bound. A crashed
171
+ // ts-node leaves nodemon alive ("waiting for file changes"), so the
172
+ // wrapper PID alone reads as "running" while nothing serves the port.
137
173
  const ports = [entry.internalPorts.api, entry.internalPorts.app].filter((p) => typeof p === 'number');
174
+ const snap = yield (0, dev_process_1.listenSnapshot)(ports);
175
+ const apiHealth = (0, dev_state_1.classifyComponentHealth)({
176
+ pid: session.pids.api,
177
+ portBound: entry.internalPorts.api ? snap.has(entry.internalPorts.api) : false,
178
+ });
179
+ const appHealth = (0, dev_state_1.classifyComponentHealth)({
180
+ pid: session.pids.app,
181
+ portBound: entry.internalPorts.app ? snap.has(entry.internalPorts.app) : false,
182
+ });
183
+ const label = (health) => health === 'running'
184
+ ? colors.green('running')
185
+ : health === 'crashed'
186
+ ? colors.yellow('crashed (supervisor up, port not listening)')
187
+ : colors.red('dead');
188
+ if (session.pids.api !== undefined || entry.internalPorts.api) {
189
+ info(` api: ${label(apiHealth)} (pid ${(_a = session.pids.api) !== null && _a !== void 0 ? _a : '-'})`);
190
+ }
191
+ if (session.pids.app !== undefined || entry.internalPorts.app) {
192
+ info(` app: ${label(appHealth)} (pid ${(_b = session.pids.app) !== null && _b !== void 0 ? _b : '-'})`);
193
+ }
194
+ info(colors.dim(` started: ${session.startedAt}`));
195
+ // Live port snapshot — the authoritative "what is actually bound" view.
138
196
  if (ports.length > 0) {
139
197
  info('');
140
198
  info(colors.bold(' Live upstream state'));
141
- const snap = yield (0, dev_process_1.listenSnapshot)(ports);
142
199
  for (const p of ports) {
143
200
  const r = snap.get(p);
144
201
  info(` ${p}: ${r ? colors.green(`bound to ${r.command} (pid ${r.pid})`) : colors.dim('free')}`);
145
202
  }
146
203
  }
204
+ // Surface any present-but-not-serving component (crashed OR dead) and
205
+ // point at the now-selective `lt dev up`, which restarts just the down
206
+ // half and leaves the healthy one running.
207
+ const apiPresent = session.pids.api !== undefined || !!entry.internalPorts.api;
208
+ const appPresent = session.pids.app !== undefined || !!entry.internalPorts.app;
209
+ const down = [
210
+ apiPresent && apiHealth !== 'running' ? 'api' : null,
211
+ appPresent && appHealth !== 'running' ? 'app' : null,
212
+ ].filter((c) => c !== null);
213
+ if (down.length > 0) {
214
+ const crashed = (apiPresent && apiHealth === 'crashed') || (appPresent && appHealth === 'crashed');
215
+ info('');
216
+ warning(` ${down.join(' + ')} not serving${crashed ? ' (supervisor still up — crashed)' : ''}. ` +
217
+ `Run \`lt dev up\` to restart ${down.length === 1 ? 'it' : 'them'}.`);
218
+ }
147
219
  }
148
220
  // Isolated test stack (`lt dev test`), if one is up / left with --keep.
149
221
  const testSession = (0, dev_state_1.loadSession)(layout.root, dev_state_1.TEST_SESSION_FILE);
@@ -165,30 +165,11 @@ const UpCommand = {
165
165
  info(colors.dim(' (Continuing — env-aware files will work; legacy files may bind on 3000/3001 and miss Caddy.)'));
166
166
  }
167
167
  }
168
- // Already running?
168
+ // Load any existing session up-front. The "is it already running?" decision
169
+ // is deliberately deferred until AFTER port allocation: a recorded wrapper
170
+ // PID being alive is NOT proof a component serves (a crashed ts-node leaves
171
+ // nodemon alive), so we probe the actual internal ports once we know them.
169
172
  const existingSession = (0, dev_state_1.loadSession)(layout.root);
170
- if (existingSession) {
171
- const apiUp = existingSession.pids.api ? (0, dev_state_1.isPidAlive)(existingSession.pids.api) : false;
172
- const appUp = existingSession.pids.app ? (0, dev_state_1.isPidAlive)(existingSession.pids.app) : false;
173
- if (apiUp || appUp) {
174
- warning(`Already running (api pid ${(_a = existingSession.pids.api) !== null && _a !== void 0 ? _a : '-'}, app pid ${(_b = existingSession.pids.app) !== null && _b !== void 0 ? _b : '-'}).`);
175
- // Surface the bound URLs so the user can copy them out without having
176
- // to look up `lt dev status` separately. Falls back to the in-process
177
- // identity/registry data — both sources stay in sync via saveRegistry.
178
- const existingEntry = (0, dev_state_1.loadRegistry)().projects[identity.slug];
179
- printProjectUrls(info, {
180
- apiHostname: (_c = identity.subdomains.api) === null || _c === void 0 ? void 0 : _c.hostname,
181
- apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
182
- appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
183
- appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
184
- dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
185
- });
186
- info('Run `lt dev down` first.');
187
- if (!parameters.options.fromGluegunMenu)
188
- process.exit(1);
189
- return 'dev up: already running';
190
- }
191
- }
192
173
  // Allocate internal ports (reuse existing if registered), verify they are
193
174
  // free, AND reserve them in the registry — all ATOMICALLY under a cross-
194
175
  // process lock, so two simultaneous `lt ticket start` (each → `lt dev up`)
@@ -197,15 +178,34 @@ const UpCommand = {
197
178
  let appPort;
198
179
  try {
199
180
  yield (0, dev_state_1.withRegistryLock)(() => __awaiter(void 0, void 0, void 0, function* () {
200
- var _a, _b;
201
181
  const reg = (0, dev_state_1.loadRegistry)();
202
182
  const entry = reg.projects[identity.slug];
203
183
  const taken = (0, dev_state_1.takenInternalPorts)(reg, identity.slug);
204
- apiPort = (_a = entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) !== null && _a !== void 0 ? _a : (layout.apiDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
184
+ let apiPortReused = false;
185
+ let appPortReused = false;
186
+ if (entry === null || entry === void 0 ? void 0 : entry.internalPorts.api) {
187
+ apiPort = entry.internalPorts.api;
188
+ apiPortReused = true;
189
+ }
190
+ else if (layout.apiDir) {
191
+ apiPort = (0, dev_state_1.allocateInternalPort)(4000, taken);
192
+ }
205
193
  if (apiPort)
206
194
  taken.add(apiPort);
207
- appPort = (_b = entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) !== null && _b !== void 0 ? _b : (layout.appDir ? (0, dev_state_1.allocateInternalPort)(4000, taken) : undefined);
208
- const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
195
+ if (entry === null || entry === void 0 ? void 0 : entry.internalPorts.app) {
196
+ appPort = entry.internalPorts.app;
197
+ appPortReused = true;
198
+ }
199
+ else if (layout.appDir) {
200
+ appPort = (0, dev_state_1.allocateInternalPort)(4000, taken);
201
+ }
202
+ // Only FRESHLY allocated ports must be verified free here. A reused
203
+ // (registered) port may legitimately be bound by our own still-healthy
204
+ // component; the spawn phase below decides per-component whether to keep
205
+ // or restart it (and reclaims a reused port held by a crashed/orphaned
206
+ // process). Free-checking a reused port would falsely abort a partial
207
+ // restart of just the crashed half.
208
+ const portsToCheck = [apiPortReused ? undefined : apiPort, appPortReused ? undefined : appPort].filter((p) => typeof p === 'number');
209
209
  const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
210
210
  for (const p of portsToCheck) {
211
211
  const r = snap.get(p);
@@ -232,6 +232,49 @@ const UpCommand = {
232
232
  process.exit(1);
233
233
  return 'dev up: port in use';
234
234
  }
235
+ // ── Health-aware (re)start decision ──────────────────────────────────────
236
+ // Probe the just-resolved ports so we can tell a still-serving component
237
+ // from a crashed one (supervisor PID alive, port free). Only dead/crashed
238
+ // components get (re)started; a healthy one keeps running untouched.
239
+ const hasApi = Boolean(layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort);
240
+ const hasApp = Boolean(layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort);
241
+ const healthSnap = yield (0, dev_process_1.listenSnapshot)([apiPort, appPort].filter((p) => typeof p === 'number'));
242
+ const apiHealth = hasApi
243
+ ? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, portBound: !!apiPort && healthSnap.has(apiPort) })
244
+ : undefined;
245
+ const appHealth = hasApp
246
+ ? (0, dev_state_1.classifyComponentHealth)({ pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, portBound: !!appPort && healthSnap.has(appPort) })
247
+ : undefined;
248
+ // All present components already serving → nothing to do.
249
+ const presentHealth = [apiHealth, appHealth].filter((h) => h !== undefined);
250
+ if (presentHealth.length > 0 && presentHealth.every((h) => h === 'running')) {
251
+ warning(`Already running (api pid ${(_a = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api) !== null && _a !== void 0 ? _a : '-'}, app pid ${(_b = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app) !== null && _b !== void 0 ? _b : '-'}) — all components healthy.`);
252
+ const existingEntry = (0, dev_state_1.loadRegistry)().projects[identity.slug];
253
+ printProjectUrls(info, {
254
+ apiHostname: (_c = identity.subdomains.api) === null || _c === void 0 ? void 0 : _c.hostname,
255
+ apiUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.api,
256
+ appHostname: (_d = identity.subdomains.app) === null || _d === void 0 ? void 0 : _d.hostname,
257
+ appUpstreamPort: existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.internalPorts.app,
258
+ dbName: (_e = existingEntry === null || existingEntry === void 0 ? void 0 : existingEntry.dbName) !== null && _e !== void 0 ? _e : dbName,
259
+ });
260
+ info('Run `lt dev down` first to force a full restart.');
261
+ if (!parameters.options.fromGluegunMenu)
262
+ process.exit();
263
+ return 'dev up: already running';
264
+ }
265
+ // Partial restart — at least one component is healthy and at least one is
266
+ // down. Announce what we keep vs. restart so the user sees the honest state.
267
+ const partialRestart = presentHealth.some((h) => h === 'running');
268
+ if (partialRestart) {
269
+ if (apiHealth === 'running')
270
+ info(colors.dim(`api healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api}) → keeping`));
271
+ else if (apiHealth)
272
+ warning(`api ${apiHealth} (port ${apiPort} not serving) → restarting`);
273
+ if (appHealth === 'running')
274
+ info(colors.dim(`app healthy (pid ${existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app}) → keeping`));
275
+ else if (appHealth)
276
+ warning(`app ${appHealth} (port ${appPort} not serving) → restarting`);
277
+ }
235
278
  // Caddy block + reload.
236
279
  const routes = [];
237
280
  if (identity.subdomains.api && apiPort)
@@ -271,40 +314,77 @@ const UpCommand = {
271
314
  const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
272
315
  const pids = {};
273
316
  const rotationNotes = [];
274
- if (layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort) {
275
- const apiResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
276
- cwd: layout.apiDir,
277
- env: devEnv.api.env,
278
- logFile: (0, path_1.join)(layout.root, '.lt-dev', 'api.log'),
279
- });
280
- if (apiResult) {
281
- pids.api = apiResult.pid;
282
- if (apiResult.rotated.rotated && apiResult.rotated.archivePath !== undefined) {
283
- rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_f = apiResult.rotated.previousSize) !== null && _f !== void 0 ? _f : 0));
317
+ const started = [];
318
+ const kept = [];
319
+ // Free a reused internal port before respawning: stop a CRASHED component's
320
+ // still-alive supervisor group (otherwise its idle nodemon leaks and a
321
+ // second one stacks on top), and kill any orphaned listener squatting the
322
+ // port (e.g. a ts-node whose supervisor already died). No-op on a clean
323
+ // first start (no prev PID, port free).
324
+ const reclaimPort = (prevPid, port, health) => __awaiter(void 0, void 0, void 0, function* () {
325
+ if (health === 'crashed' && prevPid)
326
+ yield (0, dev_process_1.terminateProcessGroup)(prevPid);
327
+ const bound = port ? healthSnap.get(port) : undefined;
328
+ if (bound === null || bound === void 0 ? void 0 : bound.pid)
329
+ yield (0, dev_process_1.terminateProcessGroup)(bound.pid);
330
+ });
331
+ if (hasApi && layout.apiDir && apiPort) {
332
+ if (apiHealth === 'running') {
333
+ pids.api = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api;
334
+ kept.push('api');
335
+ }
336
+ else {
337
+ yield reclaimPort(existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api, apiPort, apiHealth !== null && apiHealth !== void 0 ? apiHealth : 'dead');
338
+ const apiResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['start'], {
339
+ cwd: layout.apiDir,
340
+ env: devEnv.api.env,
341
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', 'api.log'),
342
+ });
343
+ if (apiResult) {
344
+ pids.api = apiResult.pid;
345
+ started.push('api');
346
+ if (apiResult.rotated.rotated && apiResult.rotated.archivePath !== undefined) {
347
+ rotationNotes.push(formatRotationNote('api', apiResult.rotated.archivePath, (_f = apiResult.rotated.previousSize) !== null && _f !== void 0 ? _f : 0));
348
+ }
284
349
  }
285
350
  }
286
351
  }
287
- if (layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort) {
288
- const appResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
289
- cwd: layout.appDir,
290
- env: devEnv.app.env,
291
- logFile: (0, path_1.join)(layout.root, '.lt-dev', 'app.log'),
292
- });
293
- if (appResult) {
294
- pids.app = appResult.pid;
295
- if (appResult.rotated.rotated && appResult.rotated.archivePath !== undefined) {
296
- rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_g = appResult.rotated.previousSize) !== null && _g !== void 0 ? _g : 0));
352
+ if (hasApp && layout.appDir && appPort) {
353
+ if (appHealth === 'running') {
354
+ pids.app = existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app;
355
+ kept.push('app');
356
+ }
357
+ else {
358
+ yield reclaimPort(existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app, appPort, appHealth !== null && appHealth !== void 0 ? appHealth : 'dead');
359
+ const appResult = (0, dev_process_1.spawnDetached)(pnpmBin, ['dev'], {
360
+ cwd: layout.appDir,
361
+ env: devEnv.app.env,
362
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', 'app.log'),
363
+ });
364
+ if (appResult) {
365
+ pids.app = appResult.pid;
366
+ started.push('app');
367
+ if (appResult.rotated.rotated && appResult.rotated.archivePath !== undefined) {
368
+ rotationNotes.push(formatRotationNote('app', appResult.rotated.archivePath, (_g = appResult.rotated.previousSize) !== null && _g !== void 0 ? _g : 0));
369
+ }
297
370
  }
298
371
  }
299
372
  }
300
- // Persist the session (PIDs). The registry entry (ports) was already reserved
301
- // atomically before the spawn, above.
302
- (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() });
373
+ // Persist the session (PIDs) merging kept (healthy) + freshly started PIDs.
374
+ // The registry entry (ports) was already reserved atomically above. On a
375
+ // partial restart we preserve the original session start time.
376
+ const startedAt = existingSession && kept.length > 0 ? existingSession.startedAt : new Date().toISOString();
377
+ (0, dev_state_1.saveSession)(layout.root, { pids, startedAt });
303
378
  // Write the ENV bridge so external tools (Playwright, IDE test runners,
304
379
  // custom shell scripts) can pick up the URLs without inheriting our shell.
305
380
  const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
306
381
  info(colors.dim(`ENV bridge: ${bridgePath}`));
307
- success(`Started: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
382
+ const summary = started.length === 0
383
+ ? 'Nothing restarted'
384
+ : kept.length > 0
385
+ ? `Restarted ${started.join('+')} (kept ${kept.join('+')})`
386
+ : `Started ${started.join('+')}`;
387
+ success(`${summary}: api pid=${(_h = pids.api) !== null && _h !== void 0 ? _h : '-'}, app pid=${(_j = pids.app) !== null && _j !== void 0 ? _j : '-'}`);
308
388
  // Echo the bound URLs next to the PIDs as well — the "Starting" block
309
389
  // prints them before the spawn, but on a long boot log they scroll out
310
390
  // of view, so repeating them here keeps PID + URL visually grouped.
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.paths = exports.TEST_SESSION_FILE = void 0;
13
+ exports.classifyComponentHealth = classifyComponentHealth;
13
14
  exports.allocateInternalPort = allocateInternalPort;
14
15
  exports.clearSession = clearSession;
15
16
  exports.detectSlugConflict = detectSlugConflict;
@@ -36,6 +37,17 @@ exports.withRegistryLock = withRegistryLock;
36
37
  const fs_1 = require("fs");
37
38
  const os_1 = require("os");
38
39
  const path_1 = require("path");
40
+ /**
41
+ * Classify a component's true health from its recorded wrapper PID plus whether
42
+ * its internal port is actually bound (caller provides the port probe result,
43
+ * typically from a single {@link import('./dev-process').listenSnapshot} call).
44
+ */
45
+ function classifyComponentHealth(opts) {
46
+ const wrapperAlive = typeof opts.pid === 'number' && isPidAlive(opts.pid);
47
+ if (!wrapperAlive)
48
+ return 'dead';
49
+ return opts.portBound ? 'running' : 'crashed';
50
+ }
39
51
  const REGISTRY_PATH = process.env.LT_DEV_REGISTRY_PATH || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'projects.json');
40
52
  const SESSION_DIR = '.lt-dev';
41
53
  const SESSION_FILE = 'state.json';
package/docs/commands.md CHANGED
@@ -451,8 +451,23 @@ lt dev up
451
451
  **Pre-flight guards (exit code 1 each):**
452
452
  - Caddy not installed (`lt dev install` first)
453
453
  - Caddy daemon not running (run `lt dev install` — it bootstraps the lt-dev service)
454
- - Already running for this project (`lt dev down` first)
455
- - Internal port already in use
454
+ - A FRESHLY allocated internal port is already in use by a foreign process
455
+
456
+ **Health-aware (re)start (idempotent):** `lt dev up` is safe to re-run. It probes
457
+ the actual internal ports — not just the recorded supervisor PID — so it can tell
458
+ a still-serving component from a *crashed* one (the supervisor / nodemon survives a
459
+ ts-node crash and the recorded PID stays alive while nothing listens on the port).
460
+ Behaviour:
461
+ - **All present components truly serving** → no-op, exits 0 with "already running".
462
+ - **Some serving, some down** → restarts ONLY the down component(s); a healthy one
463
+ keeps running untouched and its PID is preserved in the session.
464
+ - Before respawning a crashed component it terminates that supervisor's whole
465
+ process group (so its idle `nodemon` doesn't leak / stack a second one) and
466
+ reclaims any orphaned listener still squatting the reused port.
467
+
468
+ This is the fix for the "`status` says api running but no data loads" case: a
469
+ crashed ts-node dev API is healed by simply re-running `lt dev up` (it does not
470
+ fall back to compiled `node dist` — ts-node is kept so code edits hot-reload).
456
471
 
457
472
  **Logs:** `<root>/.lt-dev/api.log`, `<root>/.lt-dev/app.log` (append-mode).
458
473
 
@@ -485,7 +500,19 @@ lt dev status --all # every project in the registry
485
500
 
486
501
  **Alias:** `lt d s`
487
502
 
488
- The current-project view shows subdomains → upstream ports, db URI, session PIDs (alive/dead), and live `lsof` state. The `--all` view lists every project, with a `●`/`○` indicator for running state.
503
+ The current-project view shows subdomains → upstream ports, db URI, per-component
504
+ health, and live `lsof` state. **Health is honest:** a component is reported
505
+ `running` only when its supervisor PID is alive AND its internal port is actually
506
+ bound. A supervisor that survived a ts-node crash (PID alive, port free) is shown
507
+ as `crashed (supervisor up, port not listening)` instead of the old misleading
508
+ `running`, with a hint to run `lt dev up` to restart just that one.
509
+
510
+ The `--all` view lists every project with a single indicator:
511
+ - `●` (green) — all present components serving
512
+ - `◐` (yellow) — `degraded` (some up, some down) or `crashed` (supervisor up, port free)
513
+ - `○` (dim) — stopped
514
+
515
+ A single `lsof` snapshot covers every registered port, so `--all` stays fast.
489
516
 
490
517
  ---
491
518
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",