@lenne.tech/cli 1.48.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/test.js +5 -3
- package/build/commands/dev/up.js +59 -21
- package/build/commands/fullstack/init.js +12 -3
- package/build/commands/git/squash.js +4 -1
- package/build/extensions/api-mode.js +69 -0
- package/build/extensions/frontend-helper.js +11 -12
- package/build/extensions/server.js +43 -33
- package/build/lib/caddy.js +17 -10
- package/build/lib/check-template-sync.js +62 -2
- package/build/lib/config-env-graphql.js +106 -0
- package/build/lib/cross-env.js +94 -0
- package/build/lib/dev-api-launch.js +34 -8
- 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-package-manager.js +13 -9
- 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 +141 -73
- package/build/lib/dev-ticket.js +13 -1
- package/build/lib/fail-run.js +11 -0
- package/build/lib/heal-vendor-migrate-store.js +4 -3
- package/build/lib/hoist-workspace-pnpm-config.js +17 -6
- package/build/lib/migrate-scripts.js +69 -0
- package/build/lib/module-specifiers.js +144 -0
- package/build/lib/platform.js +53 -1
- package/build/lib/spinner-lifetime.js +245 -0
- package/build/templates/check/check.mjs +59 -9
- package/build/templates/check/lt-monorepo-pin.json +4 -4
- package/build/templates/nest-server-starter/README.md.ejs +13 -6
- package/docs/commands.md +16 -12
- package/package.json +4 -1
- package/build/lib/strip-comments.js +0 -78
|
@@ -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.
|
|
@@ -23,8 +23,10 @@ const dev_ticket_1 = require("../../lib/dev-ticket");
|
|
|
23
23
|
* App mode (default) runs the Playwright suite against a fully ISOLATED test
|
|
24
24
|
* stack (own URLs / ports / Caddy block / dedicated `<…>-test` database) that
|
|
25
25
|
* runs parallel to — and never touches — the developer's `lt dev up` session.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* That DB outlives the run, so pending migrations are applied to it before the
|
|
27
|
+
* API starts; a project's Playwright global-setup may reset it on top (the CLI
|
|
28
|
+
* does not). The stack is torn down automatically when the run finishes
|
|
29
|
+
* (residue-free).
|
|
28
30
|
*
|
|
29
31
|
* API mode (`--api`) runs the standalone API test suite (`pnpm test:e2e` in the
|
|
30
32
|
* API), which already isolates itself on its own DB — no stack is brought up.
|
|
@@ -225,7 +227,7 @@ const TestCommand = {
|
|
|
225
227
|
try {
|
|
226
228
|
const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log, { devDbName });
|
|
227
229
|
const env = Object.assign(Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
|
|
228
|
-
//
|
|
230
|
+
// THIS db, for a project whose global-setup resets or cleans it (allow-listed).
|
|
229
231
|
MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {}));
|
|
230
232
|
if (debug) {
|
|
231
233
|
env.PWDEBUG = '1';
|
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);
|
|
@@ -488,7 +488,7 @@ const NewCommand = {
|
|
|
488
488
|
yield system.run(`cd ${projectDir} && git init --initial-branch=dev`);
|
|
489
489
|
}
|
|
490
490
|
catch (err) {
|
|
491
|
-
|
|
491
|
+
ngBaseSpinner.fail(`Failed to initialize git: ${err.message}`);
|
|
492
492
|
(0, fail_run_1.failRun)(toolbox);
|
|
493
493
|
return;
|
|
494
494
|
}
|
|
@@ -498,7 +498,7 @@ const NewCommand = {
|
|
|
498
498
|
yield system.run(`cd ${projectDir} && git remote add origin ${gitLink}`);
|
|
499
499
|
}
|
|
500
500
|
catch (err) {
|
|
501
|
-
|
|
501
|
+
ngBaseSpinner.fail(`Failed to add remote: ${err.message}`);
|
|
502
502
|
(0, fail_run_1.failRun)(toolbox);
|
|
503
503
|
return;
|
|
504
504
|
}
|
|
@@ -526,7 +526,7 @@ const NewCommand = {
|
|
|
526
526
|
});
|
|
527
527
|
}
|
|
528
528
|
if (!frontendResult.success) {
|
|
529
|
-
|
|
529
|
+
ngBaseSpinner.fail(`Failed to set up ${frontend} frontend: ${frontendResult.path}`);
|
|
530
530
|
(0, fail_run_1.failRun)(toolbox);
|
|
531
531
|
return;
|
|
532
532
|
}
|
|
@@ -774,6 +774,15 @@ const NewCommand = {
|
|
|
774
774
|
// For tests
|
|
775
775
|
return `new workspace ${projectDir} with ${name}`;
|
|
776
776
|
}
|
|
777
|
+
else {
|
|
778
|
+
// The frontend setup reported success but left no `projects/app`. Without this
|
|
779
|
+
// branch the command fell off the end here: no message, no failing exit code,
|
|
780
|
+
// and `ngBaseSpinner` still spinning — which keeps the event loop alive (see
|
|
781
|
+
// the spinner note in CLAUDE.md), so the process never returned at all.
|
|
782
|
+
ngBaseSpinner.fail(`${frontend} was set up but "${projectDir}/projects/app" does not exist — cannot continue`);
|
|
783
|
+
(0, fail_run_1.failRun)(toolbox);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
777
786
|
}),
|
|
778
787
|
};
|
|
779
788
|
exports.default = NewCommand;
|
|
@@ -129,7 +129,10 @@ const NewCommand = {
|
|
|
129
129
|
const mergeBaseSpin = spin(`Get merge ${base}`);
|
|
130
130
|
const mergeBase = yield git.getMergeBase(base);
|
|
131
131
|
if (!mergeBase) {
|
|
132
|
-
error
|
|
132
|
+
// `fail` rather than `error` + `return`: a running spinner holds the event
|
|
133
|
+
// loop open (ora resumes stdin and keeps a render interval), so the old
|
|
134
|
+
// shape did not just print and leave — the command never exited at all.
|
|
135
|
+
mergeBaseSpin.fail(`No merge base found with ${base}!`);
|
|
133
136
|
return;
|
|
134
137
|
}
|
|
135
138
|
mergeBaseSpin.succeed();
|
|
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.ApiMode = void 0;
|
|
13
13
|
const glob_1 = require("glob");
|
|
14
14
|
const path_1 = require("path");
|
|
15
|
+
const config_env_graphql_1 = require("../lib/config-env-graphql");
|
|
15
16
|
/**
|
|
16
17
|
* API Mode processing for nest-server-starter template
|
|
17
18
|
*
|
|
@@ -46,11 +47,18 @@ class ApiMode {
|
|
|
46
47
|
// REST mode: remove graphql regions, keep rest regions
|
|
47
48
|
yield this.removeMode(projectPath, manifest, 'graphql', 'rest');
|
|
48
49
|
yield this.modifyConfigEnvForRest(projectPath);
|
|
50
|
+
this.assertGraphQlDisabled(projectPath);
|
|
49
51
|
}
|
|
50
52
|
else if (mode === 'GraphQL') {
|
|
51
53
|
// GraphQL mode: remove rest regions, keep graphql regions
|
|
52
54
|
yield this.removeMode(projectPath, manifest, 'rest', 'graphql');
|
|
53
55
|
}
|
|
56
|
+
// Both conversions above must leave no marker behind. Verified rather than
|
|
57
|
+
// assumed: a strip that quietly does nothing reports success just as loudly as
|
|
58
|
+
// one that worked (see assertNoMarkersRemain).
|
|
59
|
+
if (mode !== 'Both') {
|
|
60
|
+
this.assertNoMarkersRemain(projectPath, mode === 'Rest' ? 'graphql' : 'rest');
|
|
61
|
+
}
|
|
54
62
|
// Remove manifest and strip-markers script
|
|
55
63
|
this.filesystem.remove(manifestPath);
|
|
56
64
|
this.filesystem.remove((0, path_1.join)(projectPath, 'scripts', 'strip-api-mode-markers.mjs'));
|
|
@@ -167,6 +175,67 @@ class ApiMode {
|
|
|
167
175
|
this.cleanOrphanImports(projectPath);
|
|
168
176
|
});
|
|
169
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Final check of the REST conversion: every environment block in `config.env.ts`
|
|
180
|
+
* must disable GraphQL.
|
|
181
|
+
*
|
|
182
|
+
* `CoreModule.forRoot` reads a missing `graphQl` as ENABLED, so a block without it
|
|
183
|
+
* yields a project that fails at START time with `Cannot determine a GraphQL output
|
|
184
|
+
* type for the "arguments"` — long after the generator reported success. Every step
|
|
185
|
+
* before this one REPLACES an existing property; none of them adds a missing one, so
|
|
186
|
+
* nothing guaranteed the outcome.
|
|
187
|
+
*
|
|
188
|
+
* Repairs what it can (insert the switch) and throws when a block still lacks it,
|
|
189
|
+
* rather than handing over a project that cannot boot.
|
|
190
|
+
*/
|
|
191
|
+
assertGraphQlDisabled(projectPath) {
|
|
192
|
+
const configPath = (0, path_1.join)(projectPath, 'src', 'config.env.ts');
|
|
193
|
+
const content = this.filesystem.read(configPath);
|
|
194
|
+
if (!content) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const { added, content: repaired } = (0, config_env_graphql_1.disableGraphQlInEveryEnvBlock)(content);
|
|
198
|
+
if (added.length > 0) {
|
|
199
|
+
this.filesystem.write(configPath, repaired);
|
|
200
|
+
}
|
|
201
|
+
const offenders = (0, config_env_graphql_1.envBlocksWithoutGraphQlDisabled)(repaired);
|
|
202
|
+
if (offenders.length > 0) {
|
|
203
|
+
throw new Error(`REST conversion incomplete: src/config.env.ts has no \`graphQl: false\` in ${offenders.join(', ')}. ` +
|
|
204
|
+
'The framework treats a missing switch as GraphQL ENABLED, and the server would fail to start. ' +
|
|
205
|
+
'Add it to those environment blocks and re-run.');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Refuse to hand over a project that still carries markers of the removed mode.
|
|
210
|
+
*
|
|
211
|
+
* A leftover `// #region graphql` announces a mode the project does not have — and,
|
|
212
|
+
* worse, means the region CONTENT is still there: resolvers, graphql-only wiring,
|
|
213
|
+
* e2e specs. That is what a silent no-op looks like from the outside, and the strip
|
|
214
|
+
* used to be exactly that on Windows (fixed in the glob helper above): it reported
|
|
215
|
+
* success while touching nothing.
|
|
216
|
+
*
|
|
217
|
+
* Cheap, and it fails at generation time instead of in the user's editor.
|
|
218
|
+
*/
|
|
219
|
+
assertNoMarkersRemain(projectPath, removedMarker) {
|
|
220
|
+
const marked = [];
|
|
221
|
+
for (const dir of ['src', 'tests']) {
|
|
222
|
+
const base = (0, path_1.join)(projectPath, dir);
|
|
223
|
+
if (!this.filesystem.exists(base)) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
for (const file of this.globFiles(base, '**/*.ts')) {
|
|
227
|
+
const content = this.filesystem.read(file);
|
|
228
|
+
if ((content === null || content === void 0 ? void 0 : content.includes(`// #region ${removedMarker}`)) || (content === null || content === void 0 ? void 0 : content.includes(`// #endregion ${removedMarker}`))) {
|
|
229
|
+
marked.push(`${dir}/${(0, path_1.basename)(file)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (marked.length > 0) {
|
|
234
|
+
throw new Error(`API-mode conversion incomplete: ${marked.length} file(s) still carry \`// #region ${removedMarker}\` ` +
|
|
235
|
+
`markers (${marked.slice(0, 5).join(', ')}${marked.length > 5 ? ', …' : ''}). ` +
|
|
236
|
+
'The region content was not removed, so this project still contains code of a mode it does not have.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
170
239
|
/**
|
|
171
240
|
* Resolve a POSIX-style glob pattern against `baseDir` and return the
|
|
172
241
|
* matching files as absolute, OS-native paths.
|
|
@@ -12,7 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.FrontendHelper = void 0;
|
|
13
13
|
const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
|
|
14
14
|
const markdown_table_1 = require("../lib/markdown-table");
|
|
15
|
-
const
|
|
15
|
+
const module_specifiers_1 = require("../lib/module-specifiers");
|
|
16
16
|
const strip_vendor_schema_augmentation_1 = require("../lib/strip-vendor-schema-augmentation");
|
|
17
17
|
const vendor_claude_md_1 = require("../lib/vendor-claude-md");
|
|
18
18
|
/**
|
|
@@ -399,7 +399,7 @@ class FrontendHelper {
|
|
|
399
399
|
}
|
|
400
400
|
}
|
|
401
401
|
// ── Post-conversion verification ─────────────────────────────────────
|
|
402
|
-
const stale = this.findStaleFrontendImports(dest,
|
|
402
|
+
const stale = this.findStaleFrontendImports(dest, module_specifiers_1.isRelativeCoreImport);
|
|
403
403
|
if (stale.length > 0) {
|
|
404
404
|
const { print } = this.toolbox;
|
|
405
405
|
print.warning(`${stale.length} file(s) still contain relative core imports after npm conversion:`);
|
|
@@ -716,8 +716,7 @@ class FrontendHelper {
|
|
|
716
716
|
].join('\n'));
|
|
717
717
|
}
|
|
718
718
|
// ── Post-conversion verification ─────────────────────────────────────
|
|
719
|
-
|
|
720
|
-
const staleImports = this.findStaleFrontendImports(dest, /(?:^|\s)(?:import|from)\s+['"][^'"]*@lenne\.tech\/nuxt-extensions/m, 'app/core/');
|
|
719
|
+
const staleImports = this.findStaleFrontendImports(dest, (specifier) => (0, module_specifiers_1.isPackageImport)(specifier, '@lenne.tech/nuxt-extensions'), 'app/core/');
|
|
721
720
|
if (staleImports.length > 0) {
|
|
722
721
|
const { print } = this.toolbox;
|
|
723
722
|
print.warning(`${staleImports.length} file(s) still contain '@lenne.tech/nuxt-extensions' imports after vendor conversion:`);
|
|
@@ -826,20 +825,20 @@ class FrontendHelper {
|
|
|
826
825
|
/**
|
|
827
826
|
* Scan consumer files for stale imports matching a pattern.
|
|
828
827
|
*/
|
|
829
|
-
findStaleFrontendImports(appDir,
|
|
828
|
+
findStaleFrontendImports(appDir, matches, skipPathContaining) {
|
|
830
829
|
const { filesystem } = this.toolbox;
|
|
831
830
|
const allFiles = this.walkConsumerFiles(appDir);
|
|
832
831
|
const stale = [];
|
|
833
832
|
for (const absFile of allFiles) {
|
|
834
833
|
if (skipPathContaining && absFile.includes(skipPathContaining))
|
|
835
834
|
continue;
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
if (matches) {
|
|
835
|
+
// Ask the parser for the file's module specifiers instead of searching its text.
|
|
836
|
+
// A docblock that DOCUMENTS the conversion quotes the very import syntax a text
|
|
837
|
+
// search looks for, and blanking comments first does not hold either — see
|
|
838
|
+
// src/lib/module-specifiers.ts. A comment is not part of the AST, so the whole
|
|
839
|
+
// false-positive class is gone rather than worked around.
|
|
840
|
+
const specifiers = (0, module_specifiers_1.fileImportedSpecifiers)(absFile, (path) => filesystem.read(path) || undefined);
|
|
841
|
+
if (specifiers.some(matches)) {
|
|
843
842
|
stale.push(absFile.replace(`${appDir}/`, ''));
|
|
844
843
|
}
|
|
845
844
|
}
|