@lenne.tech/cli 1.29.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.
- package/build/commands/dev/status.js +83 -11
- package/build/commands/dev/up.js +131 -51
- package/build/extensions/tools.js +10 -1
- package/build/lib/command-help.js +55 -7
- package/build/lib/dev-state.js +12 -0
- package/docs/commands.md +69 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
package/build/commands/dev/up.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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)
|
|
301
|
-
//
|
|
302
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Tools = void 0;
|
|
4
4
|
const fs_1 = require("fs");
|
|
5
|
+
const command_help_1 = require("../lib/command-help");
|
|
5
6
|
const singleComment = Symbol('singleComment');
|
|
6
7
|
const multiComment = Symbol('multiComment');
|
|
7
8
|
const stripWithoutWhitespace = () => '';
|
|
@@ -27,6 +28,14 @@ class Tools {
|
|
|
27
28
|
* Check if --help-json flag is set; if so, print the command definition as JSON and return true.
|
|
28
29
|
* Commands should call this early and return immediately when it returns true.
|
|
29
30
|
*
|
|
31
|
+
* In normal CLI usage the global `installHelpInterceptor` (see
|
|
32
|
+
* `src/lib/command-help.ts`) handles `--help-json` for ALL commands before
|
|
33
|
+
* their `run()` ever fires — this method is therefore mostly a no-op in
|
|
34
|
+
* production. It is still kept as a public escape hatch for tests that
|
|
35
|
+
* invoke a command's `run()` directly without the interceptor, and for
|
|
36
|
+
* legacy callers — output goes through the same `buildHelpJson` /
|
|
37
|
+
* `emitHelpJson` pair as the global path, so the schema cannot drift.
|
|
38
|
+
*
|
|
30
39
|
* @param definition - The command's help definition (name, description, options, etc.)
|
|
31
40
|
* @returns true if --help-json was handled (caller should return), false otherwise
|
|
32
41
|
*/
|
|
@@ -35,7 +44,7 @@ class Tools {
|
|
|
35
44
|
if (!parameters.options['help-json'] && !parameters.options.helpJson) {
|
|
36
45
|
return false;
|
|
37
46
|
}
|
|
38
|
-
|
|
47
|
+
(0, command_help_1.emitHelpJson)((0, command_help_1.buildHelpJson)({ description: definition.description, name: definition.name }, definition));
|
|
39
48
|
return true;
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
20
|
exports.installHelpInterceptor = installHelpInterceptor;
|
|
21
|
+
exports.isHelpJsonRequested = isHelpJsonRequested;
|
|
21
22
|
exports.isHelpRequested = isHelpRequested;
|
|
22
23
|
exports.loadCommandHelp = loadCommandHelp;
|
|
23
24
|
exports.renderCommandHelp = renderCommandHelp;
|
|
25
|
+
exports.buildHelpJson = buildHelpJson;
|
|
26
|
+
exports.emitHelpJson = emitHelpJson;
|
|
24
27
|
/**
|
|
25
|
-
* Wrap every command's `run` so that `--help` / `-h`
|
|
26
|
-
* without executing. Call once after `build().create()`,
|
|
28
|
+
* Wrap every command's `run` so that `--help` / `-h` and `--help-json` print
|
|
29
|
+
* help and return without executing. Call once after `build().create()`,
|
|
30
|
+
* before `cli.run()`.
|
|
27
31
|
*
|
|
28
32
|
* Idempotent per command (guarded by `__helpWrapped`), so a command is never
|
|
29
33
|
* double-wrapped if this runs more than once in the same process (e.g. tests).
|
|
@@ -43,6 +47,10 @@ function installHelpInterceptor(commands, defaultCommand) {
|
|
|
43
47
|
}
|
|
44
48
|
const originalRun = command.run;
|
|
45
49
|
command.run = (toolbox) => {
|
|
50
|
+
if (isHelpJsonRequested(toolbox === null || toolbox === void 0 ? void 0 : toolbox.parameters)) {
|
|
51
|
+
emitHelpJson(buildHelpJson(command, loadCommandHelp(command.file)));
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
46
54
|
if ((toolbox === null || toolbox === void 0 ? void 0 : toolbox.print) && isHelpRequested(toolbox.parameters)) {
|
|
47
55
|
renderCommandHelp(toolbox.print, command, loadCommandHelp(command.file));
|
|
48
56
|
return undefined;
|
|
@@ -52,10 +60,12 @@ function installHelpInterceptor(commands, defaultCommand) {
|
|
|
52
60
|
command.__helpWrapped = true;
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
/** True when the invocation asks for machine-readable help (`--help-json`). */
|
|
64
|
+
function isHelpJsonRequested(parameters) {
|
|
65
|
+
const options = (parameters === null || parameters === void 0 ? void 0 : parameters.options) || {};
|
|
66
|
+
return options['help-json'] === true || options.helpJson === true;
|
|
67
|
+
}
|
|
68
|
+
/** True when the invocation asks for human-readable help (`--help` or `-h`). */
|
|
59
69
|
function isHelpRequested(parameters) {
|
|
60
70
|
const options = (parameters === null || parameters === void 0 ? void 0 : parameters.options) || {};
|
|
61
71
|
if (options['help-json'] === true || options.helpJson === true) {
|
|
@@ -132,7 +142,7 @@ function renderCommandHelp(print, command, help) {
|
|
|
132
142
|
}
|
|
133
143
|
// Always-present global flags
|
|
134
144
|
print.info(` ${'--help, -h'.padEnd(28)} Show this help and exit (does not run the command)`);
|
|
135
|
-
print.info(` ${'--help-json'.padEnd(28)} Machine-readable help as JSON (
|
|
145
|
+
print.info(` ${'--help-json'.padEnd(28)} Machine-readable help as JSON (does not run the command)`);
|
|
136
146
|
if (!options.some((o) => o.flag === '--noConfirm')) {
|
|
137
147
|
print.info(` ${'--noConfirm'.padEnd(28)} Skip confirmation prompts (where supported)`);
|
|
138
148
|
}
|
|
@@ -159,3 +169,41 @@ function usagePath(command) {
|
|
|
159
169
|
const path = command.commandPath && command.commandPath.length ? command.commandPath : [command.name || ''];
|
|
160
170
|
return `lt ${path.filter(Boolean).join(' ')}`.trim();
|
|
161
171
|
}
|
|
172
|
+
const GLOBAL_HELP_FLAGS = [
|
|
173
|
+
{ description: 'Show human-readable help; the command is NOT executed.', flag: '--help', type: 'boolean' },
|
|
174
|
+
{ description: 'Alias for --help.', flag: '-h', type: 'boolean' },
|
|
175
|
+
{
|
|
176
|
+
description: 'Print this JSON description on stdout; the command is NOT executed.',
|
|
177
|
+
flag: '--help-json',
|
|
178
|
+
type: 'boolean',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
/**
|
|
182
|
+
* Build the JSON shape returned by `--help-json` for a command, merging the
|
|
183
|
+
* gluegun-known metadata (name, commandPath, description, aliases) with the
|
|
184
|
+
* command's optional rich `CommandHelp` export.
|
|
185
|
+
*/
|
|
186
|
+
function buildHelpJson(command, help) {
|
|
187
|
+
const aliases = aliasList(command, help);
|
|
188
|
+
return {
|
|
189
|
+
aliases,
|
|
190
|
+
command: usagePath(command),
|
|
191
|
+
configuration: help === null || help === void 0 ? void 0 : help.configuration,
|
|
192
|
+
description: (help === null || help === void 0 ? void 0 : help.description) || command.description || '',
|
|
193
|
+
examples: help === null || help === void 0 ? void 0 : help.examples,
|
|
194
|
+
features: help === null || help === void 0 ? void 0 : help.features,
|
|
195
|
+
globalFlags: GLOBAL_HELP_FLAGS,
|
|
196
|
+
name: (help === null || help === void 0 ? void 0 : help.name) || command.name || '',
|
|
197
|
+
options: (help === null || help === void 0 ? void 0 : help.options) || [],
|
|
198
|
+
richHelp: Boolean(help),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Emit a help-json payload to stdout as a single pretty-printed JSON document.
|
|
203
|
+
* Kept tiny + side-effect-only so it can be stubbed in tests via a captured
|
|
204
|
+
* `console.log`.
|
|
205
|
+
*/
|
|
206
|
+
function emitHelpJson(payload) {
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
209
|
+
}
|
package/build/lib/dev-state.js
CHANGED
|
@@ -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
|
@@ -10,6 +10,7 @@ This document provides a comprehensive reference for all `lt` CLI commands. For
|
|
|
10
10
|
|
|
11
11
|
## Table of Contents
|
|
12
12
|
|
|
13
|
+
- [Global Flags](#global-flags)
|
|
13
14
|
- [CLI Commands](#cli-commands)
|
|
14
15
|
- [Server Commands](#server-commands)
|
|
15
16
|
- [Local Development Commands](#local-development-commands)
|
|
@@ -32,6 +33,44 @@ This document provides a comprehensive reference for all `lt` CLI commands. For
|
|
|
32
33
|
|
|
33
34
|
---
|
|
34
35
|
|
|
36
|
+
## Global Flags
|
|
37
|
+
|
|
38
|
+
These flags work on **every** `lt` subcommand. They are intercepted before the command's `run()` fires, so the command is never executed when one of them is set.
|
|
39
|
+
|
|
40
|
+
| Flag | Description |
|
|
41
|
+
|--------|-------------|
|
|
42
|
+
| `--help`, `-h` | Print human-readable help (usage, aliases, options, examples). |
|
|
43
|
+
| `--help-json` | Print the same help as a single JSON document on stdout. Stable contract — see shape below. Intended for AI agents and tooling that want to discover a command's surface programmatically. |
|
|
44
|
+
| `--noConfirm` | Skip interactive confirmations (where supported by the command). |
|
|
45
|
+
|
|
46
|
+
**`--help-json` payload shape** (`HelpJsonShape` in [src/lib/command-help.ts](../src/lib/command-help.ts)):
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"aliases": ["c"],
|
|
51
|
+
"command": "lt server create",
|
|
52
|
+
"configuration": "commands.server.create.*",
|
|
53
|
+
"description": "Create new server",
|
|
54
|
+
"examples": ["server create --name Foo --noConfirm"],
|
|
55
|
+
"features": ["..."],
|
|
56
|
+
"globalFlags": [
|
|
57
|
+
{ "flag": "--help", "type": "boolean", "description": "..." },
|
|
58
|
+
{ "flag": "-h", "type": "boolean", "description": "..." },
|
|
59
|
+
{ "flag": "--help-json", "type": "boolean", "description": "..." }
|
|
60
|
+
],
|
|
61
|
+
"name": "create",
|
|
62
|
+
"options": [
|
|
63
|
+
{ "flag": "--name", "type": "string", "required": true, "description": "Server name" }
|
|
64
|
+
],
|
|
65
|
+
"richHelp": true
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `richHelp: true` means the command exported a typed `CommandHelp` — `options`, `features`, `examples` and `configuration` are authoritative.
|
|
70
|
+
- `richHelp: false` means only gluegun metadata was available — `options` is the empty array, but `globalFlags` is still guaranteed.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
35
74
|
## CLI Commands
|
|
36
75
|
|
|
37
76
|
### `lt cli create`
|
|
@@ -412,8 +451,23 @@ lt dev up
|
|
|
412
451
|
**Pre-flight guards (exit code 1 each):**
|
|
413
452
|
- Caddy not installed (`lt dev install` first)
|
|
414
453
|
- Caddy daemon not running (run `lt dev install` — it bootstraps the lt-dev service)
|
|
415
|
-
-
|
|
416
|
-
|
|
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).
|
|
417
471
|
|
|
418
472
|
**Logs:** `<root>/.lt-dev/api.log`, `<root>/.lt-dev/app.log` (append-mode).
|
|
419
473
|
|
|
@@ -446,7 +500,19 @@ lt dev status --all # every project in the registry
|
|
|
446
500
|
|
|
447
501
|
**Alias:** `lt d s`
|
|
448
502
|
|
|
449
|
-
The current-project view shows subdomains → upstream ports, db URI,
|
|
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.
|
|
450
516
|
|
|
451
517
|
---
|
|
452
518
|
|