@lenne.tech/cli 1.49.0 → 1.50.1
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/check/check.mjs +74 -21
- package/build/templates/check/lt-monorepo-pin.json +4 -4
- package/build/templates/nest-server-starter/README.md.ejs +13 -6
- package/package.json +4 -1
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,31 +1,4 @@
|
|
|
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.migrationFailedError = migrationFailedError;
|
|
21
|
-
exports.resolveTestSession = resolveTestSession;
|
|
22
|
-
exports.runShardedTestSession = runShardedTestSession;
|
|
23
|
-
exports.shardReportDir = shardReportDir;
|
|
24
|
-
exports.startTestApi = startTestApi;
|
|
25
|
-
exports.tearDownAllTestSessions = tearDownAllTestSessions;
|
|
26
|
-
exports.tearDownTestSession = tearDownTestSession;
|
|
27
|
-
exports.testAppEntryCandidates = testAppEntryCandidates;
|
|
28
|
-
exports.unreachableStackError = unreachableStackError;
|
|
29
2
|
/**
|
|
30
3
|
* Ephemeral, isolated test session for `lt dev test`.
|
|
31
4
|
*
|
|
@@ -56,7 +29,33 @@ exports.unreachableStackError = unreachableStackError;
|
|
|
56
29
|
* Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
|
|
57
30
|
* session file, registry entry), so a stale session is always safely reclaimed.
|
|
58
31
|
*/
|
|
59
|
-
|
|
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;
|
|
60
59
|
const fs_1 = require("fs");
|
|
61
60
|
const os_1 = require("os");
|
|
62
61
|
const path_1 = require("path");
|
|
@@ -69,6 +68,7 @@ const dev_package_manager_1 = require("./dev-package-manager");
|
|
|
69
68
|
const dev_patches_1 = require("./dev-patches");
|
|
70
69
|
const dev_process_1 = require("./dev-process");
|
|
71
70
|
const dev_project_1 = require("./dev-project");
|
|
71
|
+
const platform_1 = require("./platform");
|
|
72
72
|
const dev_state_1 = require("./dev-state");
|
|
73
73
|
/**
|
|
74
74
|
* Make sure the Playwright browser build the app's OWN playwright version needs
|
|
@@ -83,12 +83,22 @@ const dev_state_1 = require("./dev-state");
|
|
|
83
83
|
* fails (offline), the suite still runs and Playwright's own error explains it.
|
|
84
84
|
*/
|
|
85
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
|
+
};
|
|
86
89
|
try {
|
|
87
90
|
const args = pm.exec('playwright', ['install', 'chromium']);
|
|
88
|
-
(
|
|
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
|
+
}
|
|
89
99
|
}
|
|
90
100
|
catch (_a) {
|
|
91
|
-
|
|
101
|
+
note();
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
104
|
const TEST_API_LOG = 'api.test.log';
|
|
@@ -240,11 +250,14 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
240
250
|
taken.add(apiPort);
|
|
241
251
|
appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
242
252
|
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
|
|
243
|
-
const
|
|
253
|
+
const probe = yield (0, dev_process_1.probePorts)(portsToCheck);
|
|
244
254
|
for (const p of portsToCheck) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.`);
|
|
248
261
|
}
|
|
249
262
|
// Reserve immediately (still under the lock) so a concurrent run sees these
|
|
250
263
|
// ports as taken. PIDs are written to the session file after spawn, below.
|
|
@@ -405,10 +418,19 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
405
418
|
// left to wait for. Being wrong here costs nothing — the timeout still bounds
|
|
406
419
|
// the wait, so this only ever makes the abort arrive sooner.
|
|
407
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;
|
|
408
430
|
// Wait for the test App to answer.
|
|
409
431
|
if (appUrl) {
|
|
410
432
|
log.info(log.dim(`Waiting for ${appUrl} …`));
|
|
411
|
-
const appReady = yield (0, dev_process_1.waitForHttp)(
|
|
433
|
+
const appReady = yield (0, dev_process_1.waitForHttp)(appProbeUrl, 90000, isStackServing, () => gone(pids.app));
|
|
412
434
|
if (!appReady)
|
|
413
435
|
throw unreachableStackError('App', appUrl, appLogPath);
|
|
414
436
|
}
|
|
@@ -418,7 +440,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
418
440
|
// `ensureApiReachableOrSkip` guard (the API-readiness race).
|
|
419
441
|
if (apiUrl) {
|
|
420
442
|
log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
|
|
421
|
-
const apiReady = yield (0, dev_process_1.waitForHttp)(`${
|
|
443
|
+
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiProbeUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
|
|
422
444
|
if (!apiReady)
|
|
423
445
|
throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
|
|
424
446
|
}
|
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
|
|
@@ -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
|
}
|
package/build/lib/platform.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.findExecutable = findExecutable;
|
|
4
37
|
exports.isWindows = isWindows;
|
|
38
|
+
exports.spawnCmd = spawnCmd;
|
|
5
39
|
exports.spawnCmdSync = spawnCmdSync;
|
|
6
40
|
exports.windowsAppData = windowsAppData;
|
|
7
|
-
const cross_spawn_1 = require("cross-spawn");
|
|
41
|
+
const cross_spawn_1 = __importStar(require("cross-spawn"));
|
|
8
42
|
const fs_1 = require("fs");
|
|
9
43
|
const path_1 = require("path");
|
|
10
44
|
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
@@ -48,6 +82,24 @@ function findExecutable(name, options = {}) {
|
|
|
48
82
|
function isWindows(platform = process.platform) {
|
|
49
83
|
return platform === 'win32';
|
|
50
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* `spawn` that also runs `.cmd`/`.bat` shims on Windows.
|
|
87
|
+
*
|
|
88
|
+
* The asynchronous counterpart of `spawnCmdSync`, with the same delegation and
|
|
89
|
+
* the same argument limit — see there. Needed because every long-running child
|
|
90
|
+
* the CLI starts (`pnpm run dev`, `pnpm start`, a Playwright run) is spawned
|
|
91
|
+
* asynchronously, and `pnpm` is `pnpm.cmd` on Windows: Node refuses to exec a
|
|
92
|
+
* `.cmd` directly (CVE-2024-27980, `EINVAL`), so without this nothing starts
|
|
93
|
+
* there at all.
|
|
94
|
+
*
|
|
95
|
+
* `detached` and `stdio` pass through unchanged, so a caller keeps its process
|
|
96
|
+
* group and its log-file descriptors. On Windows cross-spawn interposes
|
|
97
|
+
* `cmd.exe /c`, which means the reported pid is that of `cmd.exe` — the child
|
|
98
|
+
* tree, not a process group, is what a killer has to walk there.
|
|
99
|
+
*/
|
|
100
|
+
function spawnCmd(command, args = [], options = {}) {
|
|
101
|
+
return (0, cross_spawn_1.default)(command, [...args], options);
|
|
102
|
+
}
|
|
51
103
|
/**
|
|
52
104
|
* `spawnSync` that also runs `.cmd`/`.bat` shims on Windows.
|
|
53
105
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @lt-check-wrapper 3.
|
|
2
|
+
// @lt-check-wrapper 3.14.1
|
|
3
3
|
/**
|
|
4
4
|
* Quiet, report-driven wrapper around the project `check` pipeline.
|
|
5
5
|
*
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* scripts/check-wrapper-version.test.mjs fails when it drifts from
|
|
34
34
|
* package.json. In a generated project it keeps the release it came from.
|
|
35
35
|
*/
|
|
36
|
-
import { execFileSync,
|
|
36
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
37
37
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
38
38
|
import { dirname, join } from 'node:path';
|
|
39
39
|
import { fileURLToPath } from 'node:url';
|
|
@@ -430,7 +430,21 @@ const IDLE_TIMEOUT_MS = (() => {
|
|
|
430
430
|
const RUNNING = new Set();
|
|
431
431
|
|
|
432
432
|
/**
|
|
433
|
-
*
|
|
433
|
+
* Whether a number may become a kill target at all.
|
|
434
|
+
*
|
|
435
|
+
* A spawn that failed leaves `child.pid` undefined. On POSIX that only made `pgrep` and
|
|
436
|
+
* `process.kill` throw, but on Windows it became `taskkill /PID undefined /T /F` — a kill
|
|
437
|
+
* command built from a word that is not a pid. And the small numbers are not processes you
|
|
438
|
+
* own: POSIX pid 1 is init/launchd, and 0 and -1 are the group and the kill(2) broadcast to
|
|
439
|
+
* every process of the user (the shape that rebooted a Mac on 2026-09-23). Windows reserves
|
|
440
|
+
* 0 (System Idle) and 4 (System). Nothing a check step spawns can have any of these pids.
|
|
441
|
+
*/
|
|
442
|
+
export function isKillablePid(pid, platform = process.platform) {
|
|
443
|
+
return Number.isInteger(pid) && pid > (platform === 'win32' ? 4 : 1);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* How to kill a process tree on this platform, or null when `pid` must not be touched.
|
|
434
448
|
*
|
|
435
449
|
* Windows has neither `pgrep` nor signals: `process.kill(pid, 'SIGTERM')` there ends the one
|
|
436
450
|
* process and orphans its children, and `taskkill /T` alone was measured to leave the tree
|
|
@@ -445,46 +459,85 @@ const RUNNING = new Set();
|
|
|
445
459
|
* Split out as a pure function so both branches can be tested from either platform.
|
|
446
460
|
*/
|
|
447
461
|
export function killTreePlan(pid, signal, platform = process.platform) {
|
|
462
|
+
if (!isKillablePid(pid, platform)) return null;
|
|
448
463
|
return platform === 'win32' ? { args: ['/PID', String(pid), '/T', '/F'], command: 'taskkill' } : { signal };
|
|
449
464
|
}
|
|
450
465
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
466
|
+
/**
|
|
467
|
+
* Kill `pid` and its whole process tree, with every effect injected.
|
|
468
|
+
*
|
|
469
|
+
* Injecting the platform alone is not enough: the effects would still run for real, against
|
|
470
|
+
* this machine, with whatever pid the test chose. So all four are required and nothing
|
|
471
|
+
* defaults — a test cannot reach a real `taskkill`, `pgrep` or `process.kill` by leaving one
|
|
472
|
+
* out. `killTree` below is the only caller that passes the real ones.
|
|
473
|
+
*
|
|
474
|
+
* - `run(command, args)` executes the Windows plan.
|
|
475
|
+
* - `childrenOf(pid)` returns the direct children of `pid` (POSIX).
|
|
476
|
+
* - `signal(pid, sig)` delivers `sig` to `pid` (POSIX).
|
|
477
|
+
*
|
|
478
|
+
* POSIX order is leaves first, so a parent cannot respawn a child that was just killed, and
|
|
479
|
+
* each pid is signalled at most once. Pids a lookup returns pass the same check as the root —
|
|
480
|
+
* a stray 1 in `pgrep` output must not become a signal to init.
|
|
481
|
+
*/
|
|
482
|
+
export function killTreeWith(pid, sig, { childrenOf, platform, run, signal }) {
|
|
483
|
+
for (const [name, fn] of Object.entries({ childrenOf, run, signal })) {
|
|
484
|
+
if (typeof fn !== 'function') throw new TypeError(`killTreeWith: \`${name}\` must be injected`);
|
|
485
|
+
}
|
|
486
|
+
if (typeof platform !== 'string') throw new TypeError('killTreeWith: `platform` must be injected');
|
|
487
|
+
const plan = killTreePlan(pid, sig, platform);
|
|
488
|
+
if (!plan) return;
|
|
457
489
|
if (plan.command) {
|
|
458
490
|
try {
|
|
459
|
-
|
|
491
|
+
run(plan.command, plan.args);
|
|
460
492
|
} catch {
|
|
461
493
|
/* already gone, or taskkill refused — nothing further to try */
|
|
462
494
|
}
|
|
463
495
|
return;
|
|
464
496
|
}
|
|
465
|
-
const
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
497
|
+
const seen = new Set();
|
|
498
|
+
const order = [];
|
|
499
|
+
const collect = (p) => {
|
|
500
|
+
if (seen.has(p) || !isKillablePid(p, platform)) return;
|
|
501
|
+
seen.add(p);
|
|
502
|
+
let children = [];
|
|
469
503
|
try {
|
|
470
|
-
|
|
471
|
-
.toString()
|
|
472
|
-
.trim();
|
|
504
|
+
children = childrenOf(p);
|
|
473
505
|
} catch {
|
|
474
506
|
/* no children */
|
|
475
507
|
}
|
|
476
|
-
|
|
508
|
+
for (const c of children) collect(c);
|
|
509
|
+
order.push(p);
|
|
477
510
|
};
|
|
478
|
-
collect(
|
|
479
|
-
for (const
|
|
511
|
+
collect(pid);
|
|
512
|
+
for (const p of order) {
|
|
480
513
|
try {
|
|
481
|
-
|
|
514
|
+
signal(p, plan.signal);
|
|
482
515
|
} catch {
|
|
483
516
|
/* already gone */
|
|
484
517
|
}
|
|
485
518
|
}
|
|
486
519
|
}
|
|
487
520
|
|
|
521
|
+
/** Direct children of `pid` via `pgrep -P`; empty when there are none (pgrep exits 1). */
|
|
522
|
+
function pgrepChildren(pid) {
|
|
523
|
+
const out = execFileSync('pgrep', ['-P', String(pid)], { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
524
|
+
.toString()
|
|
525
|
+
.trim();
|
|
526
|
+
return out ? out.split('\n').map(Number) : [];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
|
|
530
|
+
// fork workers). Killing only the direct child orphans the tree — exactly the
|
|
531
|
+
// zombie workers a deadlock leaves behind.
|
|
532
|
+
function killTree(child, signal = 'SIGTERM') {
|
|
533
|
+
killTreeWith(child.pid, signal, {
|
|
534
|
+
childrenOf: pgrepChildren,
|
|
535
|
+
platform: process.platform,
|
|
536
|
+
run: (command, args) => execFileSync(command, args, { stdio: 'ignore' }),
|
|
537
|
+
signal: (pid, sig) => process.kill(pid, sig),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
488
541
|
// idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
|
|
489
542
|
// runs it unwatched. Only callers that KNOW the child streams progress (test
|
|
490
543
|
// steps) should pass a timeout — see runGroup.
|