@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
package/build/lib/dev-prune.js
CHANGED
|
@@ -43,6 +43,7 @@ const child_process_1 = require("child_process");
|
|
|
43
43
|
const fs_1 = require("fs");
|
|
44
44
|
const path_1 = require("path");
|
|
45
45
|
const dev_project_1 = require("./dev-project");
|
|
46
|
+
const dev_state_1 = require("./dev-state");
|
|
46
47
|
const dev_ticket_1 = require("./dev-ticket");
|
|
47
48
|
/** Branch prefix `lt ticket start` creates worktrees on. */
|
|
48
49
|
const TICKET_BRANCH_PREFIX = 'feat/';
|
|
@@ -188,9 +189,15 @@ function planOrphanTicketDbSweep(args) {
|
|
|
188
189
|
* never auto-dropped).
|
|
189
190
|
*/
|
|
190
191
|
function planRegistryPrune(registry) {
|
|
191
|
-
return Object.entries(registry.projects)
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
return (Object.entries(registry.projects)
|
|
193
|
+
// A relative stored path is not evidence of anything: `existsSync` resolves
|
|
194
|
+
// it against the current process's cwd, so a perfectly live project reads as
|
|
195
|
+
// gone from any other directory — and pruning it reclaims its slug and its
|
|
196
|
+
// reserved internal ports for the next project that asks. "Cannot verify" is
|
|
197
|
+
// not "absent"; these are left alone until a registry write from inside the
|
|
198
|
+
// project replaces the path with an absolute one.
|
|
199
|
+
.filter(([, entry]) => (0, dev_state_1.isVerifiablePath)(entry.path) && !(0, fs_1.existsSync)(entry.path))
|
|
200
|
+
.map(([key]) => key));
|
|
194
201
|
}
|
|
195
202
|
/**
|
|
196
203
|
* Orphaned smoke-test databases. The `/lt-dev:fullstack:smoke-test` command
|
|
@@ -218,12 +225,15 @@ function planSmokeTestDbSweep(args) {
|
|
|
218
225
|
const entries = Object.entries(registry.projects);
|
|
219
226
|
const smokeRunLive = entries.some(([key, entry]) => (key === exports.SMOKE_TEST_DB_PREFIX || key.startsWith(`${exports.SMOKE_TEST_DB_PREFIX}-`)) &&
|
|
220
227
|
entry.path &&
|
|
221
|
-
(0, fs_1.existsSync)(entry.path));
|
|
228
|
+
(!(0, dev_state_1.isVerifiablePath)(entry.path) || (0, fs_1.existsSync)(entry.path)));
|
|
222
229
|
if (smokeRunLive)
|
|
223
230
|
return [];
|
|
224
231
|
const protectedNames = new Set();
|
|
225
232
|
for (const [, entry] of entries) {
|
|
226
|
-
|
|
233
|
+
// `!isVerifiablePath` → we cannot prove the checkout is gone, so the database
|
|
234
|
+
// keeps its protection. Erring the other way would expose a live project's DB
|
|
235
|
+
// to the orphan sweep on the strength of a path we know we cannot read.
|
|
236
|
+
if (entry.dbName && entry.path && (!(0, dev_state_1.isVerifiablePath)(entry.path) || (0, fs_1.existsSync)(entry.path))) {
|
|
227
237
|
protectedNames.add(entry.dbName);
|
|
228
238
|
protectedNames.add((0, dev_project_1.deriveTestDbName)(entry.dbName));
|
|
229
239
|
}
|
package/build/lib/dev-service.js
CHANGED
|
@@ -51,6 +51,8 @@ const fs_1 = require("fs");
|
|
|
51
51
|
const os_1 = require("os");
|
|
52
52
|
const path_1 = require("path");
|
|
53
53
|
const caddy_1 = require("./caddy");
|
|
54
|
+
const dev_process_1 = require("./dev-process");
|
|
55
|
+
const platform_1 = require("./platform");
|
|
54
56
|
/**
|
|
55
57
|
* Resolve the user's home directory in a test-overridable way.
|
|
56
58
|
*
|
|
@@ -139,12 +141,12 @@ function installService() {
|
|
|
139
141
|
ok: false,
|
|
140
142
|
};
|
|
141
143
|
}
|
|
142
|
-
const caddyBin = opts.caddyBin || (yield resolveCaddyBin());
|
|
144
|
+
const caddyBin = opts.caddyBin || (yield resolveCaddyBin(opts.lookup));
|
|
143
145
|
if (!caddyBin) {
|
|
144
146
|
return {
|
|
145
147
|
bootstrapped: false,
|
|
146
148
|
created: false,
|
|
147
|
-
message:
|
|
149
|
+
message: caddyMissingMessage(),
|
|
148
150
|
ok: false,
|
|
149
151
|
};
|
|
150
152
|
}
|
|
@@ -249,14 +251,19 @@ StandardError=append:${cfg.errFile}
|
|
|
249
251
|
WantedBy=default.target
|
|
250
252
|
`;
|
|
251
253
|
}
|
|
252
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Absolute path of `caddy`, so launchd/systemd get a guaranteed path rather than
|
|
256
|
+
* a PATH lookup in an environment they do not share with the user's shell.
|
|
257
|
+
*
|
|
258
|
+
* Resolved in-process instead of by spawning `which`: there is no `which` on
|
|
259
|
+
* Windows, and `where.exe` prints every match rather than the first. The async
|
|
260
|
+
* signature is kept because callers await it and a future platform may need to
|
|
261
|
+
* ask something that does spawn.
|
|
262
|
+
*/
|
|
253
263
|
function resolveCaddyBin() {
|
|
254
|
-
return __awaiter(this,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return undefined;
|
|
258
|
-
const line = r.stdout.split('\n').find((s) => s.trim().length > 0);
|
|
259
|
-
return line ? line.trim() : undefined;
|
|
264
|
+
return __awaiter(this, arguments, void 0, function* (options = {}) {
|
|
265
|
+
var _a;
|
|
266
|
+
return (_a = (0, platform_1.findExecutable)('caddy', options)) !== null && _a !== void 0 ? _a : undefined;
|
|
260
267
|
});
|
|
261
268
|
}
|
|
262
269
|
/** Inject a custom runner (tests). Pass `null` to reset to the real spawner. */
|
|
@@ -315,6 +322,16 @@ function waitForServiceReady() {
|
|
|
315
322
|
return false;
|
|
316
323
|
});
|
|
317
324
|
}
|
|
325
|
+
/** Install hint for the current platform — `brew` is not advice on Linux or Windows. */
|
|
326
|
+
function caddyMissingMessage() {
|
|
327
|
+
if (process.platform === 'darwin') {
|
|
328
|
+
return 'caddy not found on PATH. Install with `brew install caddy`.';
|
|
329
|
+
}
|
|
330
|
+
if (process.platform === 'win32') {
|
|
331
|
+
return 'caddy not found on PATH. Install with `winget install CaddyServer.Caddy` or `scoop install caddy`.';
|
|
332
|
+
}
|
|
333
|
+
return 'caddy not found on PATH. Install it with your package manager.';
|
|
334
|
+
}
|
|
318
335
|
function defaultShellRunner(cmd, args) {
|
|
319
336
|
return new Promise((resolve) => {
|
|
320
337
|
var _a, _b;
|
|
@@ -396,13 +413,10 @@ function installLinux(paths, changed, existed) {
|
|
|
396
413
|
};
|
|
397
414
|
});
|
|
398
415
|
}
|
|
416
|
+
/** See `caddy.ts#caddyDaemonRunning` for why this is not a `curl` any more. */
|
|
399
417
|
function pingCaddyAdmin() {
|
|
400
|
-
return
|
|
401
|
-
|
|
402
|
-
stdio: ['ignore', 'ignore', 'ignore'],
|
|
403
|
-
});
|
|
404
|
-
child.on('error', () => resolve(false));
|
|
405
|
-
child.on('close', (code) => resolve(code === 0));
|
|
418
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
419
|
+
return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 1000)) !== null;
|
|
406
420
|
});
|
|
407
421
|
}
|
|
408
422
|
function sleep(ms) {
|
package/build/lib/dev-state.js
CHANGED
|
@@ -18,6 +18,7 @@ exports.clearSession = clearSession;
|
|
|
18
18
|
exports.detectSlugConflict = detectSlugConflict;
|
|
19
19
|
exports.isPidAlive = isPidAlive;
|
|
20
20
|
exports.isValidPid = isValidPid;
|
|
21
|
+
exports.isVerifiablePath = isVerifiablePath;
|
|
21
22
|
exports.loadRegistry = loadRegistry;
|
|
22
23
|
exports.loadSession = loadSession;
|
|
23
24
|
exports.sameRealPath = sameRealPath;
|
|
@@ -142,13 +143,33 @@ function clearSession(root, sessionFile = SESSION_FILE) {
|
|
|
142
143
|
*/
|
|
143
144
|
function detectSlugConflict(slug, root) {
|
|
144
145
|
const entry = loadRegistry().projects[slug];
|
|
145
|
-
|
|
146
|
+
// A relative stored path cannot be compared to anything: its base is lost, so
|
|
147
|
+
// it names a different directory from every cwd. Entries like that were written
|
|
148
|
+
// by `lt fullstack init` before the path was absolutised, and comparing one
|
|
149
|
+
// produced "slug 't-5' is also registered to another checkout: t-5". Claiming a
|
|
150
|
+
// conflict we cannot establish is worse than staying quiet — the next registry
|
|
151
|
+
// write from inside the project repairs the entry anyway.
|
|
152
|
+
if (!(entry === null || entry === void 0 ? void 0 : entry.path) || !isVerifiablePath(entry.path) || sameRealPath(entry.path, root))
|
|
146
153
|
return null;
|
|
147
154
|
const session = loadSession(entry.path);
|
|
148
155
|
const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p));
|
|
149
156
|
return { otherPath: entry.path, otherSessionAlive };
|
|
150
157
|
}
|
|
151
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Check whether a process with the given PID is currently alive.
|
|
160
|
+
*
|
|
161
|
+
* Signal 0 delivers nothing; it only asks the kernel whether the target exists
|
|
162
|
+
* and may be signalled. The two failures mean opposite things, and collapsing
|
|
163
|
+
* them was a bug:
|
|
164
|
+
*
|
|
165
|
+
* - `ESRCH` — no such process. Dead.
|
|
166
|
+
* - `EPERM` — **the process EXISTS**, this user may not signal it.
|
|
167
|
+
*
|
|
168
|
+
* Reading `EPERM` as dead made `classifyComponentHealth` report a running
|
|
169
|
+
* component as `dead`, so `lt dev up` killed and restarted a healthy stack.
|
|
170
|
+
* Reproducible on any POSIX machine: `process.kill(1, 0)` throws `EPERM`.
|
|
171
|
+
* More likely on Windows, where an elevated shell is ordinary.
|
|
172
|
+
*/
|
|
152
173
|
function isPidAlive(pid) {
|
|
153
174
|
if (!isValidPid(pid))
|
|
154
175
|
return false;
|
|
@@ -156,14 +177,29 @@ function isPidAlive(pid) {
|
|
|
156
177
|
process.kill(pid, 0);
|
|
157
178
|
return true;
|
|
158
179
|
}
|
|
159
|
-
catch (
|
|
160
|
-
return
|
|
180
|
+
catch (err) {
|
|
181
|
+
return (err === null || err === void 0 ? void 0 : err.code) === 'EPERM';
|
|
161
182
|
}
|
|
162
183
|
}
|
|
163
184
|
/** Validate a PID — positive integer, within plausible range. */
|
|
164
185
|
function isValidPid(pid) {
|
|
165
186
|
return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pid < 4194304;
|
|
166
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* True when a stored registry path can be checked against the file system.
|
|
190
|
+
*
|
|
191
|
+
* A relative path in the registry has lost the directory it was relative TO, so
|
|
192
|
+
* `existsSync` / `realpathSync` answer about whatever directory the current
|
|
193
|
+
* process started in. Every such answer is meaningless, and two of them were
|
|
194
|
+
* actively harmful: `planRegistryPrune` reclaimed a live project's slug and
|
|
195
|
+
* reserved ports, and `detectSlugConflict` reported a project as colliding with
|
|
196
|
+
* itself. Legacy entries are therefore treated as "cannot determine" rather than
|
|
197
|
+
* as "gone"; running any `lt dev` command inside the project rewrites the entry
|
|
198
|
+
* with an absolute path and clears the condition.
|
|
199
|
+
*/
|
|
200
|
+
function isVerifiablePath(storedPath) {
|
|
201
|
+
return !!storedPath && (0, path_1.isAbsolute)(storedPath);
|
|
202
|
+
}
|
|
167
203
|
/** Load the central registry; returns an empty one if missing or unreadable. */
|
|
168
204
|
function loadRegistry() {
|
|
169
205
|
if (!(0, fs_1.existsSync)(REGISTRY_PATH))
|
|
@@ -1,37 +1,14 @@
|
|
|
1
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.TEST_NITRO_OUTPUT_DIR = exports.TEST_NUXT_BUILD_DIR = exports.TEST_INITIAL_ADMIN_ENV = void 0;
|
|
13
|
-
exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
|
|
14
|
-
exports.autoShardCount = autoShardCount;
|
|
15
|
-
exports.bringUpTestSession = bringUpTestSession;
|
|
16
|
-
exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
|
|
17
|
-
exports.buildTestAppEnv = buildTestAppEnv;
|
|
18
|
-
exports.hasTestSession = hasTestSession;
|
|
19
|
-
exports.isStackServing = isStackServing;
|
|
20
|
-
exports.resolveTestSession = resolveTestSession;
|
|
21
|
-
exports.runShardedTestSession = runShardedTestSession;
|
|
22
|
-
exports.shardReportDir = shardReportDir;
|
|
23
|
-
exports.tearDownAllTestSessions = tearDownAllTestSessions;
|
|
24
|
-
exports.tearDownTestSession = tearDownTestSession;
|
|
25
|
-
exports.testAppEntryCandidates = testAppEntryCandidates;
|
|
26
|
-
exports.unreachableStackError = unreachableStackError;
|
|
27
2
|
/**
|
|
28
3
|
* Ephemeral, isolated test session for `lt dev test`.
|
|
29
4
|
*
|
|
30
5
|
* Brings up a SECOND, fully separate stack (own URLs, own internal ports,
|
|
31
6
|
* own Caddy block, own database) that runs PARALLEL to — and never touches —
|
|
32
7
|
* the developer's `lt dev up` session. Used to run the Playwright E2E suite
|
|
33
|
-
* against a
|
|
34
|
-
*
|
|
8
|
+
* against a dedicated database so a developer can keep working in their own
|
|
9
|
+
* environment while tests run, and so a test run never pollutes dev data. That
|
|
10
|
+
* database outlives the run — the CLI never drops it — so the API is migrated
|
|
11
|
+
* before it starts, like every other environment (DEV-3289).
|
|
35
12
|
*
|
|
36
13
|
* Topology (for slug `svl`):
|
|
37
14
|
* - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
|
|
@@ -52,7 +29,33 @@ exports.unreachableStackError = unreachableStackError;
|
|
|
52
29
|
* Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
|
|
53
30
|
* session file, registry entry), so a stale session is always safely reclaimed.
|
|
54
31
|
*/
|
|
55
|
-
|
|
32
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
33
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
34
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
35
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
36
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
37
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
38
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.TEST_NITRO_OUTPUT_DIR = exports.TEST_NUXT_BUILD_DIR = exports.TEST_INITIAL_ADMIN_ENV = void 0;
|
|
43
|
+
exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
|
|
44
|
+
exports.autoShardCount = autoShardCount;
|
|
45
|
+
exports.bringUpTestSession = bringUpTestSession;
|
|
46
|
+
exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
|
|
47
|
+
exports.buildTestAppEnv = buildTestAppEnv;
|
|
48
|
+
exports.hasTestSession = hasTestSession;
|
|
49
|
+
exports.isStackServing = isStackServing;
|
|
50
|
+
exports.migrationFailedError = migrationFailedError;
|
|
51
|
+
exports.resolveTestSession = resolveTestSession;
|
|
52
|
+
exports.runShardedTestSession = runShardedTestSession;
|
|
53
|
+
exports.shardReportDir = shardReportDir;
|
|
54
|
+
exports.startTestApi = startTestApi;
|
|
55
|
+
exports.tearDownAllTestSessions = tearDownAllTestSessions;
|
|
56
|
+
exports.tearDownTestSession = tearDownTestSession;
|
|
57
|
+
exports.testAppEntryCandidates = testAppEntryCandidates;
|
|
58
|
+
exports.unreachableStackError = unreachableStackError;
|
|
56
59
|
const fs_1 = require("fs");
|
|
57
60
|
const os_1 = require("os");
|
|
58
61
|
const path_1 = require("path");
|
|
@@ -65,6 +68,7 @@ const dev_package_manager_1 = require("./dev-package-manager");
|
|
|
65
68
|
const dev_patches_1 = require("./dev-patches");
|
|
66
69
|
const dev_process_1 = require("./dev-process");
|
|
67
70
|
const dev_project_1 = require("./dev-project");
|
|
71
|
+
const platform_1 = require("./platform");
|
|
68
72
|
const dev_state_1 = require("./dev-state");
|
|
69
73
|
/**
|
|
70
74
|
* Make sure the Playwright browser build the app's OWN playwright version needs
|
|
@@ -79,12 +83,22 @@ const dev_state_1 = require("./dev-state");
|
|
|
79
83
|
* fails (offline), the suite still runs and Playwright's own error explains it.
|
|
80
84
|
*/
|
|
81
85
|
function ensurePlaywrightBrowsers(appDir, pm, logInfo) {
|
|
86
|
+
const note = () => {
|
|
87
|
+
logInfo === null || logInfo === void 0 ? void 0 : logInfo('playwright install chromium failed — continuing (the suite reports missing browsers itself).');
|
|
88
|
+
};
|
|
82
89
|
try {
|
|
83
90
|
const args = pm.exec('playwright', ['install', 'chromium']);
|
|
84
|
-
(
|
|
91
|
+
// `spawnCmdSync` (spawnSync) does NOT throw on a non-zero exit, unlike the
|
|
92
|
+
// `execFileSync` this replaced — so the status is checked explicitly.
|
|
93
|
+
// Without it the failure would be silent and the suite would die later on a
|
|
94
|
+
// missing browser, which reads as a broken spec rather than a missing install.
|
|
95
|
+
const result = (0, platform_1.spawnCmdSync)(pm.bin, args, { cwd: appDir, stdio: 'ignore', timeout: 300000 });
|
|
96
|
+
if (result.error || result.status !== 0) {
|
|
97
|
+
note();
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
100
|
catch (_a) {
|
|
87
|
-
|
|
101
|
+
note();
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
const TEST_API_LOG = 'api.test.log';
|
|
@@ -99,12 +113,13 @@ const TEST_PORT_BASE = 4500;
|
|
|
99
113
|
* `systemSetup.initialAdmin.*` and seeds the admin ONCE on an empty DB (it never
|
|
100
114
|
* overwrites an existing admin), so a set-up system exists before the suite runs.
|
|
101
115
|
* A fresh template project's standard auth E2E specs assume such a system — they
|
|
102
|
-
* do no self-setup — so without this they fail LOCALLY on
|
|
116
|
+
* do no self-setup — so without this they fail LOCALLY on a test DB that has no
|
|
117
|
+
* users yet (its first run, or after a global-setup reset it).
|
|
103
118
|
* These are the exact values the lt-monorepo template CI uses
|
|
104
119
|
* (`.gitlab-ci.yml` / `.github/workflows/test.yml`), keeping CI ↔ local parity.
|
|
105
120
|
*
|
|
106
|
-
* Injected ONLY into the `lt dev test` API process (its DB is
|
|
107
|
-
*
|
|
121
|
+
* Injected ONLY into the `lt dev test` API process (its DB is the dedicated
|
|
122
|
+
* `<…>-test` one), NEVER into `lt dev up` — no surprise admin in the dev DB.
|
|
108
123
|
*/
|
|
109
124
|
exports.TEST_INITIAL_ADMIN_ENV = {
|
|
110
125
|
NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL: 'ci-admin@test.com',
|
|
@@ -235,11 +250,14 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
235
250
|
taken.add(apiPort);
|
|
236
251
|
appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
237
252
|
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
|
|
238
|
-
const
|
|
253
|
+
const probe = yield (0, dev_process_1.probePorts)(portsToCheck);
|
|
239
254
|
for (const p of portsToCheck) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
255
|
+
if (!probe.bound.has(p))
|
|
256
|
+
continue;
|
|
257
|
+
const owner = probe.owners.get(p);
|
|
258
|
+
throw new Error(owner
|
|
259
|
+
? `test internal port ${p} already in use by ${owner.command} (pid ${owner.pid}).`
|
|
260
|
+
: `test internal port ${p} already in use by an unidentified process.`);
|
|
243
261
|
}
|
|
244
262
|
// Reserve immediately (still under the lock) so a concurrent run sees these
|
|
245
263
|
// ports as taken. PIDs are written to the session file after spawn, below.
|
|
@@ -276,7 +294,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
276
294
|
log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
|
|
277
295
|
if (apiUrl)
|
|
278
296
|
log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
|
|
279
|
-
|
|
297
|
+
// No claim about resetting here: the CLI never drops this DB, and whether a
|
|
298
|
+
// project's global-setup does is the project's business. The claim used to be
|
|
299
|
+
// printed unconditionally and sent a diagnosis looking for a reset that never
|
|
300
|
+
// happened instead of at the un-migrated DB (DEV-3289).
|
|
301
|
+
log.info(` db: mongodb://127.0.0.1/${dbName}`);
|
|
280
302
|
log.info('');
|
|
281
303
|
const devEnv = (0, dev_env_1.buildDevEnv)({
|
|
282
304
|
apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
|
|
@@ -294,43 +316,23 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
294
316
|
// hands it to the Playwright child as well.
|
|
295
317
|
const appEnv = buildTestAppEnv(devEnv.app.env);
|
|
296
318
|
const pids = {};
|
|
297
|
-
// --- API: compiled (`node dist`) for stability;
|
|
298
|
-
// own
|
|
299
|
-
// first shard produced. Per-component PM detection mirrors `lt dev up`:
|
|
300
|
-
// a monorepo with an npm api and a pnpm app must drive each correctly. ---
|
|
319
|
+
// --- API: compiled (`node dist`) for stability, migrated first; falls back to
|
|
320
|
+
// the project's own start script (see `startTestApi`). ---
|
|
301
321
|
if (layout.apiDir && apiPort) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
306
|
-
build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: layout.apiDir, env: process.env });
|
|
307
|
-
}
|
|
308
|
-
const entry = (0, dev_api_launch_1.findCompiledEntry)(layout.apiDir);
|
|
309
|
-
// Seed a throwaway initial admin into the fresh, isolated test DB so the
|
|
310
|
-
// standard auth E2E specs run against a set-up system — locally exactly like
|
|
311
|
-
// the lt-monorepo template CI. Defaults first so an explicitly inherited
|
|
322
|
+
// Seed a throwaway initial admin into the isolated test DB so the standard
|
|
323
|
+
// auth E2E specs run against a set-up system — locally exactly like the
|
|
324
|
+
// lt-monorepo template CI. Defaults first so an explicitly inherited
|
|
312
325
|
// `NSC__…INITIAL_ADMIN__…` still wins (deliberate override respected). Only
|
|
313
326
|
// reached from `lt dev test` — `lt dev up` never calls bringUpTestSession.
|
|
314
327
|
const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
|
|
328
|
-
apiSpawn = (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
|
|
329
|
-
cwd: layout.apiDir,
|
|
330
|
-
env: apiEnv,
|
|
331
|
-
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
332
|
-
});
|
|
333
|
-
}
|
|
328
|
+
const apiSpawn = yield startTestApi({
|
|
329
|
+
apiDir: layout.apiDir,
|
|
330
|
+
apiEnv,
|
|
331
|
+
dbName,
|
|
332
|
+
log,
|
|
333
|
+
logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
|
|
334
|
+
skipBuild,
|
|
335
|
+
});
|
|
334
336
|
if (apiSpawn)
|
|
335
337
|
pids.api = apiSpawn.pid;
|
|
336
338
|
}
|
|
@@ -416,10 +418,19 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
416
418
|
// left to wait for. Being wrong here costs nothing — the timeout still bounds
|
|
417
419
|
// the wait, so this only ever makes the abort arrive sooner.
|
|
418
420
|
const gone = (pid) => pid === undefined || !(0, dev_state_1.isPidAlive)(pid);
|
|
421
|
+
// The probes go to the LOOPBACK address, not the public name: this is Node
|
|
422
|
+
// asking, and on Windows `*.localhost` does not resolve for Node at all — the
|
|
423
|
+
// waits would time out against a stack that is up, and `bringUpTestSession`
|
|
424
|
+
// aborts before Playwright ever starts. Probing the port directly also measures
|
|
425
|
+
// the component rather than the proxy, which is the better question here.
|
|
426
|
+
// The public name stays in the MESSAGES, because that is what the developer
|
|
427
|
+
// opens; the loopback pair is printed next to it above.
|
|
428
|
+
const appProbeUrl = (0, dev_env_1.internalUrl)(appPort) || appUrl;
|
|
429
|
+
const apiProbeUrl = (0, dev_env_1.internalUrl)(apiPort) || apiUrl;
|
|
419
430
|
// Wait for the test App to answer.
|
|
420
431
|
if (appUrl) {
|
|
421
432
|
log.info(log.dim(`Waiting for ${appUrl} …`));
|
|
422
|
-
const appReady = yield (0, dev_process_1.waitForHttp)(
|
|
433
|
+
const appReady = yield (0, dev_process_1.waitForHttp)(appProbeUrl, 90000, isStackServing, () => gone(pids.app));
|
|
423
434
|
if (!appReady)
|
|
424
435
|
throw unreachableStackError('App', appUrl, appLogPath);
|
|
425
436
|
}
|
|
@@ -429,7 +440,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
429
440
|
// `ensureApiReachableOrSkip` guard (the API-readiness race).
|
|
430
441
|
if (apiUrl) {
|
|
431
442
|
log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
|
|
432
|
-
const apiReady = yield (0, dev_process_1.waitForHttp)(`${
|
|
443
|
+
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiProbeUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
|
|
433
444
|
if (!apiReady)
|
|
434
445
|
throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
|
|
435
446
|
}
|
|
@@ -515,6 +526,19 @@ function hasTestSession(root) {
|
|
|
515
526
|
function isStackServing(status) {
|
|
516
527
|
return status > 0 && ![502, 503, 504].includes(status);
|
|
517
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* The abort thrown when the test DB cannot be migrated.
|
|
531
|
+
*
|
|
532
|
+
* An abort rather than a warning for the same reason as `unreachableStackError`:
|
|
533
|
+
* a suite run on an un-migrated DB tests a data state no environment has, and
|
|
534
|
+
* whatever it reports is about the test setup, not about the feature. Unlike the
|
|
535
|
+
* API's boot, `migrate:up` runs in the foreground, so its output is already on
|
|
536
|
+
* screen right above this message.
|
|
537
|
+
*/
|
|
538
|
+
function migrationFailedError(dbName, exitCode) {
|
|
539
|
+
return new Error(`migrate:up failed (exit ${String(exitCode)}) against the test DB "${dbName}" — test API not started, ` +
|
|
540
|
+
'because a suite on an un-migrated DB tests a state no environment has. The migration output is above.');
|
|
541
|
+
}
|
|
518
542
|
/** Build the dedicated test identity + test DB name for a project. */
|
|
519
543
|
function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
|
|
520
544
|
const names = testStackNames(shardIndex);
|
|
@@ -599,6 +623,50 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
|
|
|
599
623
|
function shardReportDir(root, shardIndex) {
|
|
600
624
|
return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
|
|
601
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Start the test stack's API: build → apply pending migrations → start the
|
|
628
|
+
* compiled bundle, or fall back to the project's own `start` script when no
|
|
629
|
+
* bundle is available.
|
|
630
|
+
*
|
|
631
|
+
* The compiled start bypasses that `start` script and therefore the
|
|
632
|
+
* `migrate:up` it chains in front of the server. The test DB outlives every
|
|
633
|
+
* run — the CLI never drops it — so without the explicit step here it drifted
|
|
634
|
+
* behind every environment that does migrate (DEV-3289). The migration runs
|
|
635
|
+
* with `apiEnv`, the API's own env, so both resolve the same database, and
|
|
636
|
+
* before the spawn, because the API syncs its indexes against whatever it finds
|
|
637
|
+
* on boot. A failed migration throws `migrationFailedError`.
|
|
638
|
+
*
|
|
639
|
+
* `skipBuild` (sibling shards) reuses the dist the first shard produced but
|
|
640
|
+
* still migrates: every shard has a database of its own. The fallback does not
|
|
641
|
+
* migrate itself — the `start` script it runs owns that, exactly as under
|
|
642
|
+
* `lt dev up`. Per-directory package manager, like `lt dev up`: a monorepo with
|
|
643
|
+
* an npm API and a pnpm App must drive each with its own.
|
|
644
|
+
*/
|
|
645
|
+
function startTestApi(opts) {
|
|
646
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
647
|
+
const { apiDir, apiEnv, dbName, log, logFile, skipBuild } = opts;
|
|
648
|
+
const apiPm = (0, dev_package_manager_1.pickPackageManager)(apiDir);
|
|
649
|
+
let build = 0;
|
|
650
|
+
if (!skipBuild) {
|
|
651
|
+
log.info(log.dim('Building API (compiled, for stable long runs) …'));
|
|
652
|
+
build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: apiDir, env: process.env });
|
|
653
|
+
}
|
|
654
|
+
const entry = (0, dev_api_launch_1.findCompiledEntry)(apiDir);
|
|
655
|
+
if (build === 0 && entry) {
|
|
656
|
+
const migration = yield (0, dev_api_launch_1.applyPendingMigrations)({ apiDir, env: apiEnv, pm: apiPm });
|
|
657
|
+
if (migration.status === 'failed')
|
|
658
|
+
throw migrationFailedError(dbName, migration.exitCode);
|
|
659
|
+
if (migration.status === 'skipped')
|
|
660
|
+
log.info(log.dim(`no migrate:up script — ${dbName} is not migrated`));
|
|
661
|
+
// Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
|
|
662
|
+
// bundles with `Bun.build({ target: 'bun' })` and dies under node with
|
|
663
|
+
// "__require is not a function" (DEV-3208).
|
|
664
|
+
return (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(apiDir), [entry], { cwd: apiDir, env: apiEnv, logFile });
|
|
665
|
+
}
|
|
666
|
+
log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
|
|
667
|
+
return (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), { cwd: apiDir, env: apiEnv, logFile });
|
|
668
|
+
});
|
|
669
|
+
}
|
|
602
670
|
/**
|
|
603
671
|
* Tear down the unsharded test stack AND every sharded stack discovered on disk
|
|
604
672
|
* (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
|
package/build/lib/dev-ticket.js
CHANGED
|
@@ -57,6 +57,7 @@ const dev_package_manager_1 = require("./dev-package-manager");
|
|
|
57
57
|
const dev_patches_1 = require("./dev-patches");
|
|
58
58
|
const dev_project_1 = require("./dev-project");
|
|
59
59
|
const dev_state_1 = require("./dev-state");
|
|
60
|
+
const platform_1 = require("./platform");
|
|
60
61
|
/** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
|
|
61
62
|
const TICKET_MARKER = 'ticket';
|
|
62
63
|
/** Where ticket databases live. Deliberately loopback-only — never a remote/prod host. */
|
|
@@ -281,8 +282,19 @@ function gitRefExists(repoDir, ref) {
|
|
|
281
282
|
* for fresh scaffolds without a lockfile yet.
|
|
282
283
|
*/
|
|
283
284
|
function installWorktreeDeps(dir) {
|
|
285
|
+
var _a;
|
|
284
286
|
const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
|
|
285
|
-
(
|
|
287
|
+
// `spawnCmdSync` (spawnSync) does NOT throw on a non-zero exit, unlike the
|
|
288
|
+
// `execFileSync` this replaced. The contract here is to throw: the only caller
|
|
289
|
+
// (`lt ticket start`) catches and warns, and swallowing the failure would hand
|
|
290
|
+
// the developer a half-installed worktree with no hint that anything went wrong.
|
|
291
|
+
const result = (0, platform_1.spawnCmdSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
|
|
292
|
+
if (result.error) {
|
|
293
|
+
throw result.error;
|
|
294
|
+
}
|
|
295
|
+
if (result.status !== 0) {
|
|
296
|
+
throw new Error(`${pm.bin} ${pm.installArgs.join(' ')} exited with ${(_a = result.status) !== null && _a !== void 0 ? _a : 'a signal'}`);
|
|
297
|
+
}
|
|
286
298
|
}
|
|
287
299
|
/**
|
|
288
300
|
* True for ticket ids whose derived database would collide with a project-level
|
package/build/lib/fail-run.js
CHANGED
|
@@ -20,6 +20,17 @@ exports.failRun = failRun;
|
|
|
20
20
|
* buffered output, including the spinner's own failure message, which is the one
|
|
21
21
|
* line the operator actually needs.
|
|
22
22
|
*
|
|
23
|
+
* That choice carries a precondition worth stating, because it was violated:
|
|
24
|
+
* setting `process.exitCode` only ends the process once **the event loop drains**,
|
|
25
|
+
* and `bin/lt` has no terminal `process.exit()` after `run()`. A running ora
|
|
26
|
+
* spinner holds the loop open on two counts — its `setInterval` for the animation
|
|
27
|
+
* and the `stdin.resume()` its stdin discarder performs. So a command that called
|
|
28
|
+
* `failRun` and returned while a spinner was still spinning did not exit at all:
|
|
29
|
+
* no message, no prompt back, and the terminal left in raw mode. The remedy is to
|
|
30
|
+
* stop the spinner, NOT to switch this helper to `process.exit()` — that would
|
|
31
|
+
* trade a hang for the truncated-output bug this helper exists to avoid.
|
|
32
|
+
* `__tests__/spinner-lifetime.test.ts` enforces the spinner half.
|
|
33
|
+
*
|
|
23
34
|
* **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
|
|
24
35
|
* (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
|
|
25
36
|
* interactive `lt` menu a command is one step of a longer session, and failing
|
|
@@ -90,9 +90,10 @@ const ts = __importStar(require("typescript"));
|
|
|
90
90
|
* Detection runs on the TypeScript AST, not on regex-stripped text. A regex
|
|
91
91
|
* "lexer" has no string/template/regex-literal state, so a `/*` or `//` inside a
|
|
92
92
|
* literal earlier in the file silently erased the guard and triggered the very
|
|
93
|
-
* overwrite this function must avoid.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* overwrite this function must avoid. Scanning is not enough either: a standalone
|
|
94
|
+
* `ts.createScanner` loop desynchronises on the first interpolating template
|
|
95
|
+
* literal (that is what retired `lib/strip-comments.ts`). The AST solves comments
|
|
96
|
+
* AND nesting in one step, and recognises a backtick require for free.
|
|
96
97
|
*
|
|
97
98
|
* ## Recoverability
|
|
98
99
|
*
|
|
@@ -32,11 +32,16 @@ const fs_utils_1 = require("./fs-utils");
|
|
|
32
32
|
* starter's assessed-advisory allowlist is destroyed rather than merely ignored,
|
|
33
33
|
* and the generated project's very first pipeline goes red on an advisory that
|
|
34
34
|
* was already justified upstream.
|
|
35
|
+
*
|
|
36
|
+
* `peerDependencyRules` (`{ allowedVersions: {...}, ignoreMissing: [...] }`) is
|
|
37
|
+
* the same trap one step later: the starter scope-allows known-stale peer ranges
|
|
38
|
+
* there, and without the hoist the root `check:peers` step fails the first
|
|
39
|
+
* `pnpm run check` of every generated project.
|
|
35
40
|
*/
|
|
36
41
|
const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
37
42
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
38
|
-
/** Objects whose
|
|
39
|
-
const NESTED_ARRAY_FIELDS = ['auditConfig'];
|
|
43
|
+
/** Objects whose inner arrays are unioned and inner maps merged, not replaced. */
|
|
44
|
+
const NESTED_ARRAY_FIELDS = ['auditConfig', 'peerDependencyRules'];
|
|
40
45
|
/** The union of all three, in declaration order. Declared here, with its inputs,
|
|
41
46
|
* because the comment-carrying helpers below default their `fields` parameter to it. */
|
|
42
47
|
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
|
|
@@ -611,11 +616,13 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
611
616
|
const subArr = Array.isArray(subValue) ? subValue : [];
|
|
612
617
|
return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
|
|
613
618
|
}
|
|
614
|
-
// Nested (`auditConfig.ignoreGhsas`
|
|
615
|
-
//
|
|
616
|
-
// key-by-key merge would drop
|
|
619
|
+
// Nested (`auditConfig.ignoreGhsas`, `peerDependencyRules.allowedVersions`, …):
|
|
620
|
+
// union each inner array and merge each inner map instead of letting the
|
|
621
|
+
// sub-project's object replace the root's. A plain key-by-key merge would drop
|
|
622
|
+
// every advisory or peer rule the root had already justified.
|
|
617
623
|
if (isNestedArrayField(field)) {
|
|
618
|
-
const
|
|
624
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
625
|
+
const asObj = (v) => (isMap(v) ? v : {});
|
|
619
626
|
const rootObj = asObj(rootValue);
|
|
620
627
|
const subObj = asObj(subValue);
|
|
621
628
|
const merged = Object.assign({}, rootObj);
|
|
@@ -625,6 +632,10 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
625
632
|
const b = Array.isArray(value) ? value : [];
|
|
626
633
|
merged[key] = Array.from(new Set([...a, ...b])).sort((x, y) => x.localeCompare(y));
|
|
627
634
|
}
|
|
635
|
+
else if (isMap(value) && isMap(merged[key])) {
|
|
636
|
+
const inner = Object.assign(Object.assign({}, merged[key]), value);
|
|
637
|
+
merged[key] = Object.fromEntries(Object.entries(inner).sort(([x], [y]) => x.localeCompare(y)));
|
|
638
|
+
}
|
|
628
639
|
else {
|
|
629
640
|
merged[key] = value;
|
|
630
641
|
}
|