@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.
- package/build/commands/dev/status.js +39 -15
- package/build/commands/dev/up.js +109 -34
- package/build/lib/dev-api-launch.js +89 -0
- package/build/lib/dev-process.js +21 -1
- package/build/lib/dev-state.js +54 -2
- package/build/lib/dev-test-session.js +2 -3
- package/docs/commands.md +24 -7
- package/package.json +1 -1
|
@@ -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)({
|
|
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)({
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
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 (
|
|
79
|
+
if (stack === 'running') {
|
|
72
80
|
status = colors.green('●');
|
|
73
81
|
}
|
|
74
|
-
else if (
|
|
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 (
|
|
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 === '
|
|
188
|
-
? colors.
|
|
189
|
-
:
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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('');
|
package/build/commands/dev/up.js
CHANGED
|
@@ -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
|
|
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)({
|
|
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)({
|
|
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
|
|
287
|
-
// down. Announce what we keep vs. restart so the user sees
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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 (
|
|
401
|
-
// The registry entry (ports) was already reserved atomically
|
|
402
|
-
//
|
|
403
|
-
|
|
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
|
-
?
|
|
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=${(
|
|
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: (
|
|
470
|
+
apiHostname: (_l = identity.subdomains.api) === null || _l === void 0 ? void 0 : _l.hostname,
|
|
420
471
|
apiUpstreamPort: apiPort,
|
|
421
|
-
appHostname: (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|
package/build/lib/dev-process.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/build/lib/dev-state.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
- **
|
|
464
|
-
|
|
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
|
|
471
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|