@lenne.tech/cli 1.49.0 → 1.50.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/doctor.js +27 -10
- package/build/commands/dev/down.js +48 -5
- package/build/commands/dev/status.js +32 -8
- package/build/commands/dev/up.js +59 -21
- package/build/extensions/server.js +1 -1
- package/build/lib/caddy.js +17 -10
- package/build/lib/check-template-sync.js +1 -1
- package/build/lib/dev-env-bridge.js +7 -0
- package/build/lib/dev-env.js +44 -4
- package/build/lib/dev-log-tail.js +91 -0
- package/build/lib/dev-patches.js +15 -5
- package/build/lib/dev-process.js +362 -127
- package/build/lib/dev-project.js +14 -1
- package/build/lib/dev-prune.js +15 -5
- package/build/lib/dev-service.js +29 -15
- package/build/lib/dev-state.js +40 -4
- package/build/lib/dev-test-session.js +58 -36
- package/build/lib/dev-ticket.js +13 -1
- package/build/lib/hoist-workspace-pnpm-config.js +17 -6
- package/build/lib/platform.js +53 -1
- package/build/templates/nest-server-starter/README.md.ejs +13 -6
- package/package.json +4 -1
|
@@ -117,18 +117,31 @@ const DoctorCommand = {
|
|
|
117
117
|
line('WARN', colors.yellow, `Caddyfile validation: ${v.stderr.split('\n')[0]}`);
|
|
118
118
|
}
|
|
119
119
|
// 4. Port 80 / 443 free or held by Caddy
|
|
120
|
+
//
|
|
121
|
+
// "Is it bound" is now a TCP connect, so it answers on every platform — the
|
|
122
|
+
// old `lsof`-only probe degraded to a WARN on Windows and doctor then exited
|
|
123
|
+
// 0 while nothing worked. Only the OCCUPANT'S NAME can still be unavailable,
|
|
124
|
+
// and that is reported as its own state rather than folded into either
|
|
125
|
+
// verdict: a bound port we cannot attribute is not "free", and it is not
|
|
126
|
+
// proof that Caddy is blocked either.
|
|
127
|
+
const portProbe = yield (0, dev_process_1.probePorts)([80, 443]);
|
|
120
128
|
for (const port of [80, 443]) {
|
|
121
|
-
|
|
122
|
-
if (r === null)
|
|
123
|
-
line('WARN', colors.yellow, `lsof unavailable — cannot probe port ${port}`);
|
|
124
|
-
else if (!r.inUse)
|
|
129
|
+
if (!portProbe.bound.has(port)) {
|
|
125
130
|
line('OK', colors.green, `port ${port} free`);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const owner = portProbe.owners.get(port);
|
|
134
|
+
if (!owner) {
|
|
135
|
+
line('WARN', colors.yellow, `port ${port} is in use but the occupant could not be identified` +
|
|
136
|
+
`${portProbe.ownersUnavailable ? ' (no lsof/netstat available)' : ''}`);
|
|
137
|
+
continue;
|
|
131
138
|
}
|
|
139
|
+
if (owner.command.toLowerCase().startsWith('caddy')) {
|
|
140
|
+
line('OK', colors.green, `port ${port} held by caddy (pid ${owner.pid})`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
line('FAIL', colors.red, `port ${port} held by ${owner.command} (pid ${owner.pid}) — Caddy cannot bind`);
|
|
144
|
+
fails++;
|
|
132
145
|
}
|
|
133
146
|
// 5. *.localhost resolves to 127.0.0.1
|
|
134
147
|
const dnsOk = yield dnsResolvesLocalhost('lt-dev-doctor.localhost');
|
|
@@ -273,7 +286,11 @@ const DoctorCommand = {
|
|
|
273
286
|
*/
|
|
274
287
|
function dnsResolvesLocalhost(host) {
|
|
275
288
|
return new Promise((resolve) => {
|
|
276
|
-
const child = (0, child_process_1.spawn)(
|
|
289
|
+
const child = (0, child_process_1.spawn)(
|
|
290
|
+
// `process.execPath`, not a bare `node`: the CLI may run from a Node that
|
|
291
|
+
// is not on PATH at all (a version manager, a packaged install), and a
|
|
292
|
+
// different `node` on PATH would answer for a different runtime.
|
|
293
|
+
process.execPath, [
|
|
277
294
|
'-e',
|
|
278
295
|
`require('dns').lookup(${JSON.stringify(host)}, { all: true }, (e, addrs) => {
|
|
279
296
|
if (e) process.exit(1);
|
|
@@ -16,12 +16,20 @@ const dev_project_1 = require("../../lib/dev-project");
|
|
|
16
16
|
const dev_state_1 = require("../../lib/dev-state");
|
|
17
17
|
const dev_test_session_1 = require("../../lib/dev-test-session");
|
|
18
18
|
const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
19
|
+
const platform_1 = require("../../lib/platform");
|
|
19
20
|
/**
|
|
20
21
|
* Stop the processes started by `lt dev up` and remove the project's
|
|
21
22
|
* Caddy block.
|
|
22
23
|
*
|
|
23
|
-
* - SIGTERM
|
|
24
|
-
*
|
|
24
|
+
* - POSIX: SIGTERM to the detached process GROUP (negative PID), so children
|
|
25
|
+
* (Vite, Nest watcher) receive it too and can shut down gracefully. No
|
|
26
|
+
* escalation — `down` is the polite stop.
|
|
27
|
+
* - Windows: `taskkill /T /F`, i.e. FORCED, while `up`'s reclaim keeps the
|
|
28
|
+
* two-phase `terminateProcessGroup`. Not a choice: Windows has no gentle step
|
|
29
|
+
* (`/T` without `/F` was measured to leave the tree and its port alive), so
|
|
30
|
+
* shutdown hooks do not run there. Details in `killWindowsTree`.
|
|
31
|
+
* - Either way the pid is verified gone afterwards; a survivor is reported,
|
|
32
|
+
* never listed as stopped.
|
|
25
33
|
* - The Caddy block is removed and `caddy reload` is invoked, so the
|
|
26
34
|
* subdomain stops resolving immediately.
|
|
27
35
|
*/
|
|
@@ -46,10 +54,27 @@ const DownCommand = {
|
|
|
46
54
|
stopped.push(`${name} (pid ${pid}, already dead)`);
|
|
47
55
|
continue;
|
|
48
56
|
}
|
|
49
|
-
|
|
57
|
+
// A pid the plan refuses (1, this CLI, a system pid — i.e. a corrupted
|
|
58
|
+
// state.json) is neither signalled nor offered as a copy-paste kill hint:
|
|
59
|
+
// `kill -9 -1` is the broadcast that rebooted a Mac on 2026-09-23.
|
|
60
|
+
const plan = (0, dev_process_1.planTermination)(pid);
|
|
61
|
+
if (plan.kind === 'refuse') {
|
|
62
|
+
warning(`Not stopping ${name}: ${plan.reason} — .lt-dev/state.json looks corrupted.`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
(0, dev_process_1.killProcessGroup)(pid);
|
|
66
|
+
// Verify rather than assume: `killProcessGroup` reports that the signal
|
|
67
|
+
// was delivered, not that the process went. A compiled API with shutdown
|
|
68
|
+
// hooks can sit on SIGTERM while it waits for Mongo; claiming "stopped"
|
|
69
|
+
// then sends the user into the next `lt dev up` with a port collision
|
|
70
|
+
// nobody can trace back.
|
|
71
|
+
if (yield waitForExit(pid, 3000)) {
|
|
50
72
|
stopped.push(`${name} (pid ${pid})`);
|
|
51
|
-
|
|
52
|
-
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
warning(`${name} (pid ${pid}) did not stop — it may still hold its port.`);
|
|
76
|
+
info(colors.dim(` Check with \`lt dev status\`; force it with ${forceKillHint(pid)}`));
|
|
77
|
+
}
|
|
53
78
|
}
|
|
54
79
|
(0, dev_state_1.clearSession)(layout.root);
|
|
55
80
|
}
|
|
@@ -98,3 +123,21 @@ const DownCommand = {
|
|
|
98
123
|
}),
|
|
99
124
|
};
|
|
100
125
|
module.exports = DownCommand;
|
|
126
|
+
/** The command that actually ends a process tree on this platform. */
|
|
127
|
+
function forceKillHint(pid) {
|
|
128
|
+
// `/F` is not optional on Windows: measured, `taskkill /PID <pid> /T` without it
|
|
129
|
+
// fails on the children and leaves the port bound.
|
|
130
|
+
return (0, platform_1.isWindows)() ? `\`taskkill /PID ${pid} /T /F\`` : `\`kill -9 -${pid}\``;
|
|
131
|
+
}
|
|
132
|
+
/** Poll until `pid` is gone, or the budget runs out. */
|
|
133
|
+
function waitForExit(pid, budgetMs) {
|
|
134
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
135
|
+
const deadline = Date.now() + budgetMs;
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
if (!(0, dev_state_1.isPidAlive)(pid))
|
|
138
|
+
return true;
|
|
139
|
+
yield new Promise((resolve) => setTimeout(resolve, 100));
|
|
140
|
+
}
|
|
141
|
+
return !(0, dev_state_1.isPidAlive)(pid);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -9,7 +9,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const path_1 = require("path");
|
|
12
13
|
const caddy_1 = require("../../lib/caddy");
|
|
14
|
+
const dev_log_tail_1 = require("../../lib/dev-log-tail");
|
|
13
15
|
const dev_process_1 = require("../../lib/dev-process");
|
|
14
16
|
const dev_project_1 = require("../../lib/dev-project");
|
|
15
17
|
const dev_state_1 = require("../../lib/dev-state");
|
|
@@ -50,7 +52,7 @@ const StatusCommand = {
|
|
|
50
52
|
if (e.internalPorts.app)
|
|
51
53
|
allPorts.push(e.internalPorts.app);
|
|
52
54
|
}
|
|
53
|
-
const
|
|
55
|
+
const probe = yield (0, dev_process_1.probePorts)(allPorts);
|
|
54
56
|
for (const slug of slugs) {
|
|
55
57
|
const e = reg.projects[slug];
|
|
56
58
|
const session = (0, dev_state_1.loadSession)(e.path);
|
|
@@ -60,14 +62,14 @@ const StatusCommand = {
|
|
|
60
62
|
if (e.internalPorts.api) {
|
|
61
63
|
comps.push((0, dev_state_1.classifyComponentHealth)({
|
|
62
64
|
pid: session === null || session === void 0 ? void 0 : session.pids.api,
|
|
63
|
-
portBound:
|
|
65
|
+
portBound: probe.bound.has(e.internalPorts.api),
|
|
64
66
|
startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
|
|
65
67
|
}));
|
|
66
68
|
}
|
|
67
69
|
if (e.internalPorts.app) {
|
|
68
70
|
comps.push((0, dev_state_1.classifyComponentHealth)({
|
|
69
71
|
pid: session === null || session === void 0 ? void 0 : session.pids.app,
|
|
70
|
-
portBound:
|
|
72
|
+
portBound: probe.bound.has(e.internalPorts.app),
|
|
71
73
|
startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
|
|
72
74
|
}));
|
|
73
75
|
}
|
|
@@ -186,15 +188,15 @@ const StatusCommand = {
|
|
|
186
188
|
// ts-node leaves nodemon alive ("waiting for file changes"), so the
|
|
187
189
|
// wrapper PID alone reads as "running" while nothing serves the port.
|
|
188
190
|
const ports = [entry.internalPorts.api, entry.internalPorts.app].filter((p) => typeof p === 'number');
|
|
189
|
-
const
|
|
191
|
+
const probe = yield (0, dev_process_1.probePorts)(ports);
|
|
190
192
|
const apiHealth = (0, dev_state_1.classifyComponentHealth)({
|
|
191
193
|
pid: session.pids.api,
|
|
192
|
-
portBound: entry.internalPorts.api ?
|
|
194
|
+
portBound: entry.internalPorts.api ? probe.bound.has(entry.internalPorts.api) : false,
|
|
193
195
|
startedAt: session.startedAt,
|
|
194
196
|
});
|
|
195
197
|
const appHealth = (0, dev_state_1.classifyComponentHealth)({
|
|
196
198
|
pid: session.pids.app,
|
|
197
|
-
portBound: entry.internalPorts.app ?
|
|
199
|
+
portBound: entry.internalPorts.app ? probe.bound.has(entry.internalPorts.app) : false,
|
|
198
200
|
startedAt: session.startedAt,
|
|
199
201
|
});
|
|
200
202
|
const label = (health) => health === 'running'
|
|
@@ -216,8 +218,13 @@ const StatusCommand = {
|
|
|
216
218
|
info('');
|
|
217
219
|
info(colors.bold(' Live upstream state'));
|
|
218
220
|
for (const p of ports) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
if (!probe.bound.has(p)) {
|
|
222
|
+
info(` ${p}: ${colors.dim('free')}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const owner = probe.owners.get(p);
|
|
226
|
+
// "bound" is measured; the occupant's name is not always obtainable.
|
|
227
|
+
info(` ${p}: ${owner ? colors.green(`bound to ${owner.command} (pid ${owner.pid})`) : colors.green('bound')}`);
|
|
221
228
|
}
|
|
222
229
|
}
|
|
223
230
|
// Surface any present-but-not-serving component (crashed OR dead) and
|
|
@@ -241,6 +248,23 @@ const StatusCommand = {
|
|
|
241
248
|
info('');
|
|
242
249
|
warning(` ${down.join(' + ')} not serving${crashed ? ' (supervisor still up — crashed)' : ''}. ` +
|
|
243
250
|
`Run \`lt dev up\` to restart ${down.length === 1 ? 'it' : 'them'}.`);
|
|
251
|
+
// Why it is down is in its log, so show it here instead of sending the
|
|
252
|
+
// reader to look. An empty log is said out loud: that is a finding too.
|
|
253
|
+
for (const name of down) {
|
|
254
|
+
(0, dev_log_tail_1.describeLog)((0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`), 8)).forEach((l) => info(colors.dim(` ${l}`)));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// A log that exists but stays empty while its component runs: the output
|
|
258
|
+
// is going somewhere else (on Windows it once went to an extra console
|
|
259
|
+
// window). Worse than no log, because it looks trustworthy.
|
|
260
|
+
for (const [name, present] of [
|
|
261
|
+
['api', apiPresent],
|
|
262
|
+
['app', appPresent],
|
|
263
|
+
]) {
|
|
264
|
+
const logDiagnosis = (0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`));
|
|
265
|
+
if (present && !down.includes(name) && (0, dev_log_tail_1.isSilentLog)(logDiagnosis, session.startedAt, Date.now())) {
|
|
266
|
+
warning(` ${name}: ${logDiagnosis.file} is still EMPTY — its output is not being captured.`);
|
|
267
|
+
}
|
|
244
268
|
}
|
|
245
269
|
}
|
|
246
270
|
// Isolated test stack (`lt dev test`), if one is up / left with --keep.
|
package/build/commands/dev/up.js
CHANGED
|
@@ -17,6 +17,7 @@ const dev_api_launch_1 = require("../../lib/dev-api-launch");
|
|
|
17
17
|
const dev_env_1 = require("../../lib/dev-env");
|
|
18
18
|
const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
|
|
19
19
|
const dev_identity_1 = require("../../lib/dev-identity");
|
|
20
|
+
const dev_log_tail_1 = require("../../lib/dev-log-tail");
|
|
20
21
|
const dev_package_manager_1 = require("../../lib/dev-package-manager");
|
|
21
22
|
const dev_patches_1 = require("../../lib/dev-patches");
|
|
22
23
|
const dev_process_1 = require("../../lib/dev-process");
|
|
@@ -144,17 +145,6 @@ const UpCommand = {
|
|
|
144
145
|
// plugin hook surfaces the ticket context per prompt instead (from the
|
|
145
146
|
// gitignored `.lt-dev/ticket` marker). For the base project we keep the
|
|
146
147
|
// committed URL block up to date as before.
|
|
147
|
-
if (!ticket) {
|
|
148
|
-
const claudeCandidates = [
|
|
149
|
-
(0, path_1.join)(layout.root, 'CLAUDE.md'),
|
|
150
|
-
...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
|
|
151
|
-
...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
|
|
152
|
-
];
|
|
153
|
-
const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
|
|
154
|
-
if (patched.length > 0) {
|
|
155
|
-
info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
148
|
// Always keep `.lt-dev/` (state, env bridge, ticket marker) out of git.
|
|
159
149
|
if ((0, dev_patches_1.addToGitignore)(layout.root, '.lt-dev/')) {
|
|
160
150
|
info(colors.dim('added `.lt-dev/` to .gitignore'));
|
|
@@ -231,11 +221,16 @@ const UpCommand = {
|
|
|
231
221
|
// process). Free-checking a reused port would falsely abort a partial
|
|
232
222
|
// restart of just the crashed half.
|
|
233
223
|
const portsToCheck = [apiPortReused ? undefined : apiPort, appPortReused ? undefined : appPort].filter((p) => typeof p === 'number');
|
|
234
|
-
const
|
|
224
|
+
const probe = yield (0, dev_process_1.probePorts)(portsToCheck);
|
|
235
225
|
for (const p of portsToCheck) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
226
|
+
if (!probe.bound.has(p))
|
|
227
|
+
continue;
|
|
228
|
+
const owner = probe.owners.get(p);
|
|
229
|
+
// The port is occupied either way; naming the occupant is a courtesy the
|
|
230
|
+
// platform may not be able to extend.
|
|
231
|
+
throw new Error(owner
|
|
232
|
+
? `Internal port ${p} already in use by ${owner.command} (pid ${owner.pid}).`
|
|
233
|
+
: `Internal port ${p} already in use by an unidentified process.`);
|
|
239
234
|
}
|
|
240
235
|
// Reserve immediately so a concurrent `lt dev up` sees these as taken.
|
|
241
236
|
const subdomainMap = {};
|
|
@@ -257,6 +252,26 @@ const UpCommand = {
|
|
|
257
252
|
process.exit(1);
|
|
258
253
|
return 'dev up: port in use';
|
|
259
254
|
}
|
|
255
|
+
// The CLAUDE.md URL block is written HERE, after the internal ports are
|
|
256
|
+
// resolved, so it can name both addresses: the `*.localhost` name a browser
|
|
257
|
+
// opens and the loopback address a script must use. It used to run before
|
|
258
|
+
// allocation, where only the name existed — which is why an agent following
|
|
259
|
+
// the block had nothing but a hostname that does not resolve for Node on
|
|
260
|
+
// Windows. NEVER for a ticket worktree: that CLAUDE.md is git-tracked and
|
|
261
|
+
// would carry per-ticket URLs into a commit.
|
|
262
|
+
if (!ticket) {
|
|
263
|
+
const claudeCandidates = [
|
|
264
|
+
(0, path_1.join)(layout.root, 'CLAUDE.md'),
|
|
265
|
+
...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
|
|
266
|
+
...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
|
|
267
|
+
];
|
|
268
|
+
const patched = claudeCandidates
|
|
269
|
+
.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { apiPort, appPort, dbName, identity }))
|
|
270
|
+
.filter((r) => r.patched);
|
|
271
|
+
if (patched.length > 0) {
|
|
272
|
+
info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
260
275
|
// ── Health-aware (re)start decision ──────────────────────────────────────
|
|
261
276
|
// Probe the just-resolved ports so we can tell a still-serving component
|
|
262
277
|
// from a crashed one (supervisor PID alive, port free). Only dead/crashed
|
|
@@ -269,18 +284,18 @@ const UpCommand = {
|
|
|
269
284
|
// false-positive the `starting` state exists to prevent).
|
|
270
285
|
const hasApi = Boolean(layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort);
|
|
271
286
|
const hasApp = Boolean(layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort);
|
|
272
|
-
const
|
|
287
|
+
const healthProbe = yield (0, dev_process_1.probePorts)([apiPort, appPort].filter((p) => typeof p === 'number'));
|
|
273
288
|
const apiHealth = hasApi
|
|
274
289
|
? (0, dev_state_1.classifyComponentHealth)({
|
|
275
290
|
pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api,
|
|
276
|
-
portBound: !!apiPort &&
|
|
291
|
+
portBound: !!apiPort && healthProbe.bound.has(apiPort),
|
|
277
292
|
startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
|
|
278
293
|
})
|
|
279
294
|
: undefined;
|
|
280
295
|
const appHealth = hasApp
|
|
281
296
|
? (0, dev_state_1.classifyComponentHealth)({
|
|
282
297
|
pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app,
|
|
283
|
-
portBound: !!appPort &&
|
|
298
|
+
portBound: !!appPort && healthProbe.bound.has(appPort),
|
|
284
299
|
startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
|
|
285
300
|
})
|
|
286
301
|
: undefined;
|
|
@@ -374,9 +389,19 @@ const UpCommand = {
|
|
|
374
389
|
const reclaimPort = (prevPid, port, health) => __awaiter(void 0, void 0, void 0, function* () {
|
|
375
390
|
if (health === 'crashed' && prevPid)
|
|
376
391
|
yield (0, dev_process_1.terminateProcessGroup)(prevPid);
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
392
|
+
if (!port || !healthProbe.bound.has(port))
|
|
393
|
+
return;
|
|
394
|
+
const owner = healthProbe.owners.get(port);
|
|
395
|
+
if (owner === null || owner === void 0 ? void 0 : owner.pid) {
|
|
396
|
+
yield (0, dev_process_1.terminateProcessGroup)(owner.pid);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Occupied, but the platform could not name the occupant — so there is
|
|
400
|
+
// nothing to terminate. Saying so beats respawning onto a taken port and
|
|
401
|
+
// letting the component fail with an address-in-use nobody connects to this.
|
|
402
|
+
warning(`port ${port} is in use and the occupant could not be identified` +
|
|
403
|
+
`${healthProbe.ownersUnavailable ? ' (no lsof/netstat available)' : ''} — ` +
|
|
404
|
+
'not reclaiming it; the component may fail to bind.');
|
|
380
405
|
});
|
|
381
406
|
if (hasApi && layout.apiDir && apiPort) {
|
|
382
407
|
if (isKeepable(apiHealth)) {
|
|
@@ -454,6 +479,19 @@ const UpCommand = {
|
|
|
454
479
|
// grace window indefinitely.
|
|
455
480
|
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());
|
|
456
481
|
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt });
|
|
482
|
+
// A component that dies right after its start used to be reported as
|
|
483
|
+
// "Started": the spawn succeeded, and nothing looked again. Watch the fresh
|
|
484
|
+
// pids for a moment and show what the log says (or that it is empty).
|
|
485
|
+
const earlyExits = yield (0, dev_log_tail_1.findEarlyExits)(started.map((name) => ({ name, pid: pids[name] })), { budgetMs: 3000, isAlive: dev_state_1.isPidAlive });
|
|
486
|
+
if (earlyExits.length > 0) {
|
|
487
|
+
for (const name of earlyExits) {
|
|
488
|
+
error(`${name} exited right after its start (pid ${pids[name]}).`);
|
|
489
|
+
(0, dev_log_tail_1.describeLog)((0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`))).forEach((l) => info(l));
|
|
490
|
+
}
|
|
491
|
+
if (!parameters.options.fromGluegunMenu)
|
|
492
|
+
process.exit(1);
|
|
493
|
+
return `dev up: ${earlyExits.join('+')} exited on start`;
|
|
494
|
+
}
|
|
457
495
|
// Write the ENV bridge so external tools (Playwright, IDE test runners,
|
|
458
496
|
// custom shell scripts) can pick up the URLs without inheriting our shell.
|
|
459
497
|
const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
|
|
@@ -664,7 +664,7 @@ class Server {
|
|
|
664
664
|
if (!experimental) {
|
|
665
665
|
// Generate README
|
|
666
666
|
yield template.generate({
|
|
667
|
-
props: { description, name },
|
|
667
|
+
props: { description, name, vendor: frameworkMode === 'vendor' },
|
|
668
668
|
target: `${dest}/README.md`,
|
|
669
669
|
template: 'nest-server-starter/README.md.ejs',
|
|
670
670
|
});
|
package/build/lib/caddy.js
CHANGED
|
@@ -36,10 +36,11 @@ exports.writeCaddyfile = writeCaddyfile;
|
|
|
36
36
|
* Lifecycle is owned by `lt dev install` (one-time setup) and
|
|
37
37
|
* `lt dev up`/`lt dev down` (per-project block management).
|
|
38
38
|
*/
|
|
39
|
-
const child_process_1 = require("child_process");
|
|
40
39
|
const fs_1 = require("fs");
|
|
41
40
|
const os_1 = require("os");
|
|
42
41
|
const path_1 = require("path");
|
|
42
|
+
const dev_process_1 = require("./dev-process");
|
|
43
|
+
const platform_1 = require("./platform");
|
|
43
44
|
const CADDYFILE_PATH = process.env.LT_DEV_CADDYFILE || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'Caddyfile');
|
|
44
45
|
const HEADER = '# Managed by `lt dev`. Per-project blocks are bounded by `# >>> lt-dev:<slug> >>>` markers.';
|
|
45
46
|
/** Detect whether `caddy` is on PATH. */
|
|
@@ -49,16 +50,22 @@ function caddyAvailable() {
|
|
|
49
50
|
return result.ok;
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Detect whether the Caddy admin endpoint is reachable (i.e. a daemon is running).
|
|
55
|
+
*
|
|
56
|
+
* This asked `curl -fsS -o /dev/null`, and on Windows that reported a running
|
|
57
|
+
* Caddy as down: `/dev/null` is an ordinary file path there, so curl completed
|
|
58
|
+
* the request, received the 226-byte answer, failed to WRITE it, and exited 23.
|
|
59
|
+
* The exit code was all this function looked at. Measured on the laptop against a
|
|
60
|
+
* Caddy that was listening on :2019 and answering 200.
|
|
61
|
+
*
|
|
62
|
+
* `127.0.0.1` rather than `localhost` for consistency with the reverse-proxy
|
|
63
|
+
* upstreams (see the note above `renderProjectBlock`) — not because `localhost`
|
|
64
|
+
* was the fault here; it was measured to resolve correctly on that machine.
|
|
65
|
+
*/
|
|
53
66
|
function caddyDaemonRunning() {
|
|
54
67
|
return __awaiter(this, void 0, void 0, function* () {
|
|
55
|
-
return
|
|
56
|
-
const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', 'http://localhost:2019/config/'], {
|
|
57
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
58
|
-
});
|
|
59
|
-
child.on('error', () => resolve(false));
|
|
60
|
-
child.on('close', (code) => resolve(code === 0));
|
|
61
|
-
});
|
|
68
|
+
return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 2000)) !== null;
|
|
62
69
|
});
|
|
63
70
|
}
|
|
64
71
|
/** Read the current Caddyfile (or empty string). */
|
|
@@ -162,7 +169,7 @@ function writeCaddyfile(content) {
|
|
|
162
169
|
function runCaddy(args) {
|
|
163
170
|
return new Promise((resolve) => {
|
|
164
171
|
var _a, _b;
|
|
165
|
-
const child = (0,
|
|
172
|
+
const child = (0, platform_1.spawnCmd)('caddy', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
166
173
|
let stdout = '';
|
|
167
174
|
let stderr = '';
|
|
168
175
|
let errored = false;
|
|
@@ -84,7 +84,7 @@ function syncCheckTemplate(options) {
|
|
|
84
84
|
if (spawned.length > 0) {
|
|
85
85
|
throw new Error(`${ref}: the template starts sibling script(s) it does not ship: ${spawned.join(', ')}. ` +
|
|
86
86
|
'resolveCopySet only follows imports, so these would be missing in every generated project. ' +
|
|
87
|
-
|
|
87
|
+
"Add them to the wrapper's import closure, ship them deliberately, or remove the call upstream.");
|
|
88
88
|
}
|
|
89
89
|
const pin = {
|
|
90
90
|
commit,
|
|
@@ -97,6 +97,13 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
|
|
|
97
97
|
// Legacy aliases — see dev-env.ts for the rationale.
|
|
98
98
|
'API_URL',
|
|
99
99
|
'SITE_URL',
|
|
100
|
+
// The loopback addresses. An external runner that RESOLVES a URL from Node —
|
|
101
|
+
// an API helper, a seeding script, a readiness check — needs these: on Windows
|
|
102
|
+
// `*.localhost` does not resolve for Node at all, only for browsers. The
|
|
103
|
+
// public names above stay, because browser-facing values (a Playwright
|
|
104
|
+
// `baseURL`, a link a developer opens) must keep them. See `dev-env.ts#internalUrl`.
|
|
105
|
+
'LT_DEV_API_INTERNAL_URL',
|
|
106
|
+
'LT_DEV_APP_INTERNAL_URL',
|
|
100
107
|
];
|
|
101
108
|
for (const key of exported) {
|
|
102
109
|
const v = devEnv.app.env[key];
|
package/build/lib/dev-env.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.buildDevEnv = buildDevEnv;
|
|
4
|
+
exports.internalUrl = internalUrl;
|
|
4
5
|
/**
|
|
5
6
|
* Build environment variables for `lt dev up`.
|
|
6
7
|
*
|
|
@@ -41,13 +42,16 @@ function buildDevEnv(input) {
|
|
|
41
42
|
const appSub = identity.subdomains.app;
|
|
42
43
|
const apiUrl = apiSub ? `https://${apiSub.hostname}` : '';
|
|
43
44
|
const appUrl = appSub ? `https://${appSub.hostname}` : '';
|
|
45
|
+
// Same two services, addressed the way Node can reach them. See `internalUrl`.
|
|
46
|
+
const apiInternal = internalUrl(apiInternalPort);
|
|
47
|
+
const appInternal = internalUrl(appInternalPort);
|
|
44
48
|
const caPath = (0, dev_env_bridge_1.detectCaddyRootCa)();
|
|
45
|
-
const sharedKeys = Object.assign(Object.assign(Object.assign(Object.assign({
|
|
49
|
+
const sharedKeys = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({
|
|
46
50
|
// Marks the API + App processes as running under `lt dev`. Consumed by the
|
|
47
51
|
// backend to relax dev-only behaviour (rate limiting, Better-Auth
|
|
48
52
|
// user-cache) so E2E suites run without a separate VITEST/PLAYWRIGHT flag.
|
|
49
53
|
// (Also written to the .lt-dev/.env bridge for external test runners.)
|
|
50
|
-
LT_DEV_ACTIVE: 'true' }, (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {})), (caPath ? { NODE_EXTRA_CA_CERTS: caPath } : {}));
|
|
54
|
+
LT_DEV_ACTIVE: 'true' }, (apiInternal ? { LT_DEV_API_INTERNAL_URL: apiInternal } : {})), (appInternal ? { LT_DEV_APP_INTERNAL_URL: appInternal } : {})), (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {})), (caPath ? { NODE_EXTRA_CA_CERTS: caPath } : {}));
|
|
51
55
|
return {
|
|
52
56
|
api: {
|
|
53
57
|
env: Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
|
|
@@ -59,10 +63,10 @@ function buildDevEnv(input) {
|
|
|
59
63
|
internalPort: apiInternalPort,
|
|
60
64
|
},
|
|
61
65
|
app: {
|
|
62
|
-
env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
|
|
66
|
+
env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
|
|
63
67
|
// See API note above: pin the dev server to IPv4 so Caddy's
|
|
64
68
|
// `127.0.0.1` upstream is unambiguous.
|
|
65
|
-
HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl,
|
|
69
|
+
HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (apiInternal || apiUrl ? { NUXT_API_URL: apiInternal || apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
|
|
66
70
|
// Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
|
|
67
71
|
// both subdomains under HTTPS with shared cookie domain, so
|
|
68
72
|
// same-origin trickery is no longer required.
|
|
@@ -75,6 +79,42 @@ function buildDevEnv(input) {
|
|
|
75
79
|
},
|
|
76
80
|
};
|
|
77
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* The loopback address of a component, for anything that RESOLVES a name.
|
|
84
|
+
*
|
|
85
|
+
* `lt dev` is URL-first: a project is reached at `https://<slug>.localhost`, and
|
|
86
|
+
* Caddy proxies that to an opaque internal port. That promise holds for
|
|
87
|
+
* **browsers**. It does not hold for Node: on Windows `*.localhost` subdomains do
|
|
88
|
+
* not resolve at all (`dns.lookup('api.demo.localhost')` → ENOTFOUND, and `curl`
|
|
89
|
+
* agrees), while Chromium resolves them internally without asking a resolver.
|
|
90
|
+
*
|
|
91
|
+
* So the question at every call site is not "which URL" but **"who reads it"**:
|
|
92
|
+
*
|
|
93
|
+
* | | example | address to use |
|
|
94
|
+
* |---|---|---|
|
|
95
|
+
* | a browser resolves it | Playwright `baseURL`, a printed link | the public name |
|
|
96
|
+
* | Node resolves it | readiness probes, SSR fetches, a proxy target | **this** |
|
|
97
|
+
* | another binary resolves it | `cloudflared` | **this** |
|
|
98
|
+
* | nobody resolves it — it is COMPARED | `APP_URL` (CORS / `trustedOrigins`), the Caddy vhost matcher, the tunnel `Host:` header | the public name, untouched |
|
|
99
|
+
*
|
|
100
|
+
* That last row is why "rewrite every internal URL to 127.0.0.1" would be wrong:
|
|
101
|
+
* `APP_URL` is matched as a string against the `Origin` header a browser sends,
|
|
102
|
+
* and the browser arrives from `https://<slug>.localhost`. Rewriting it would
|
|
103
|
+
* break every login — on the platform where only a health probe was broken before.
|
|
104
|
+
*
|
|
105
|
+
* **Loopback on every platform, not only on Windows.** 127.0.0.1 works everywhere,
|
|
106
|
+
* so there is no branch to get wrong, and the path Windows depends on is the one
|
|
107
|
+
* macOS exercises daily. A branch only the other platform runs is an unchecked
|
|
108
|
+
* branch.
|
|
109
|
+
*
|
|
110
|
+
* One consequence to keep in mind: this goes PAST Caddy. For "is the component
|
|
111
|
+
* alive?" that is an advantage — it measures the component, not the proxy. For
|
|
112
|
+
* "does the routing work?" it is the wrong question, and that one belongs to
|
|
113
|
+
* `lt dev doctor` over the public name.
|
|
114
|
+
*/
|
|
115
|
+
function internalUrl(port) {
|
|
116
|
+
return port ? `http://127.0.0.1:${port}` : '';
|
|
117
|
+
}
|
|
78
118
|
/** Postgres convenience URL — used by Postgres-based projects (e.g. nest-base). */
|
|
79
119
|
function buildPostgresUrl(dbName) {
|
|
80
120
|
return `postgresql://${dbName}:${dbName}@localhost:5432/${dbName}`;
|
|
@@ -0,0 +1,91 @@
|
|
|
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.describeLog = describeLog;
|
|
13
|
+
exports.diagnoseLog = diagnoseLog;
|
|
14
|
+
exports.findEarlyExits = findEarlyExits;
|
|
15
|
+
exports.isSilentLog = isSilentLog;
|
|
16
|
+
exports.tailLines = tailLines;
|
|
17
|
+
/**
|
|
18
|
+
* What a component's log can tell someone whose component just died.
|
|
19
|
+
*
|
|
20
|
+
* Two silent failures made this necessary, both seen on a Windows laptop:
|
|
21
|
+
* `lt dev up` reported success for an API that was dead seconds later, and its
|
|
22
|
+
* log file existed, was even rotated, but stayed at 0 bytes, so nobody looked in
|
|
23
|
+
* the extra console window that held the actual error. A diagnosis therefore has
|
|
24
|
+
* to tell "the log says X" apart from "the log is empty", and say the latter out
|
|
25
|
+
* loud instead of printing nothing.
|
|
26
|
+
*/
|
|
27
|
+
const fs_1 = require("fs");
|
|
28
|
+
/**
|
|
29
|
+
* The lines to print under "<name> is not running". Never an empty list: an
|
|
30
|
+
* empty or missing log is itself the finding.
|
|
31
|
+
*/
|
|
32
|
+
function describeLog(d) {
|
|
33
|
+
if (d.state === 'missing')
|
|
34
|
+
return [`No log at ${d.file}.`];
|
|
35
|
+
if (d.state === 'empty') {
|
|
36
|
+
return [`${d.file} is EMPTY — the output was not captured, so the reason is not in the log.`];
|
|
37
|
+
}
|
|
38
|
+
return [`Last lines of ${d.file}:`, ...d.tail.map((l) => ` ${l}`)];
|
|
39
|
+
}
|
|
40
|
+
/** Read `file` and keep its last `lines` non-empty lines. */
|
|
41
|
+
function diagnoseLog(file, lines = 20) {
|
|
42
|
+
if (!(0, fs_1.existsSync)(file))
|
|
43
|
+
return { file, size: -1, state: 'missing', tail: [] };
|
|
44
|
+
const size = (0, fs_1.statSync)(file).size;
|
|
45
|
+
if (size === 0)
|
|
46
|
+
return { file, size, state: 'empty', tail: [] };
|
|
47
|
+
return { file, size, state: 'ok', tail: tailLines((0, fs_1.readFileSync)(file, 'utf8'), lines) };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Components whose pid is gone within `budgetMs` of their start.
|
|
51
|
+
*
|
|
52
|
+
* Catches what dies at once: a missing script, a package manager that cannot
|
|
53
|
+
* start, a crash before any watcher takes over. A crash under nodemon keeps the
|
|
54
|
+
* supervisor alive and is `lt dev status`'s job (`crashed` after the grace window).
|
|
55
|
+
*/
|
|
56
|
+
function findEarlyExits(components, opts) {
|
|
57
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
58
|
+
var _a;
|
|
59
|
+
const sleep = (_a = opts.sleep) !== null && _a !== void 0 ? _a : ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
60
|
+
const watched = components.filter((c) => typeof c.pid === 'number');
|
|
61
|
+
const dead = new Set();
|
|
62
|
+
const deadline = Date.now() + opts.budgetMs;
|
|
63
|
+
do {
|
|
64
|
+
for (const c of watched)
|
|
65
|
+
if (!dead.has(c.name) && !opts.isAlive(c.pid))
|
|
66
|
+
dead.add(c.name);
|
|
67
|
+
if (dead.size === watched.length)
|
|
68
|
+
break;
|
|
69
|
+
yield sleep(250);
|
|
70
|
+
} while (Date.now() < deadline);
|
|
71
|
+
return watched.filter((c) => dead.has(c.name)).map((c) => c.name);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* True when a component has been up for `minAgeMs` and its log is still empty.
|
|
76
|
+
* A booting app writes within seconds (nuxt/nest print a banner), so an empty
|
|
77
|
+
* log after that means output is going somewhere else, not that nothing happened.
|
|
78
|
+
*/
|
|
79
|
+
function isSilentLog(d, startedAt, now, minAgeMs = 15000) {
|
|
80
|
+
if (d.state !== 'empty' || !startedAt)
|
|
81
|
+
return false;
|
|
82
|
+
const age = now - new Date(startedAt).getTime();
|
|
83
|
+
return Number.isFinite(age) && age >= minAgeMs;
|
|
84
|
+
}
|
|
85
|
+
/** Last `n` non-empty lines of `content`, oldest first. */
|
|
86
|
+
function tailLines(content, n) {
|
|
87
|
+
return content
|
|
88
|
+
.split(/\r?\n/)
|
|
89
|
+
.filter((l) => l.trim().length > 0)
|
|
90
|
+
.slice(-n);
|
|
91
|
+
}
|