@lenne.tech/cli 1.46.0 → 1.48.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/bin/postinstall.js +28 -5
- package/build/commands/dev/doctor.js +45 -3
- package/build/commands/fullstack/update.js +31 -0
- package/build/commands/git/force-pull.js +11 -2
- package/build/commands/git/rebase.js +11 -1
- package/build/commands/git/reset.js +36 -4
- package/build/commands/git/update.js +42 -6
- package/build/extensions/api-mode.js +32 -8
- package/build/extensions/git.js +23 -25
- package/build/extensions/server.js +24 -17
- package/build/lib/check-template-sync.js +127 -0
- package/build/lib/claude-cli.js +14 -25
- package/build/lib/codex-cli.js +24 -31
- package/build/lib/dev-api-launch.js +58 -6
- package/build/lib/dev-process.js +66 -30
- package/build/lib/dev-state.js +13 -2
- package/build/lib/dev-test-session.js +85 -20
- package/build/lib/git-env.js +46 -0
- package/build/lib/heal-check-wrapper.js +128 -11
- package/build/lib/heal-oxlintrc.js +232 -0
- package/build/lib/platform.js +107 -0
- package/build/lib/plugin-utils.js +9 -8
- package/build/lib/strip-vendor-schema-augmentation.js +13 -2
- package/build/lib/vendor-claude-md.js +9 -6
- package/build/lib/workspace-integration.js +12 -2
- package/build/templates/check/check.mjs +270 -268
- package/build/templates/check/lib/ansi.mjs +22 -0
- package/build/templates/check/lib/audit-report.mjs +412 -0
- package/build/templates/check/lib/workspace-packages.mjs +150 -0
- package/build/templates/check/lt-monorepo-pin.json +13 -0
- package/docs/commands.md +1 -11
- package/package.json +13 -14
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CHECK_TEMPLATE_PIN_FILE = exports.CHECK_TEMPLATE_REPOSITORY = void 0;
|
|
4
|
+
exports.readCheckTemplatePin = readCheckTemplatePin;
|
|
5
|
+
exports.syncCheckTemplate = syncCheckTemplate;
|
|
6
|
+
exports.verifyCheckTemplate = verifyCheckTemplate;
|
|
7
|
+
/**
|
|
8
|
+
* Keep `src/templates/check/` a verbatim copy of a PINNED lt-monorepo state.
|
|
9
|
+
*
|
|
10
|
+
* lt-monorepo is the upstream of the check wrapper. The copy here drifted two
|
|
11
|
+
* releases behind once, and because heal treats the bundled copy as canonical,
|
|
12
|
+
* `lt fullstack update` then downgraded freshly created projects. So the
|
|
13
|
+
* template is never edited by hand: `npm run sync:check-template -- --ref <tag|sha>`
|
|
14
|
+
* copies the wrapper plus its transitive imports from that ref and records the
|
|
15
|
+
* commit and a hash per file in a pin file. A Jest test compares the template
|
|
16
|
+
* against that pin without any network access.
|
|
17
|
+
*/
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
const crypto_1 = require("crypto");
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const os_1 = require("os");
|
|
22
|
+
const path_1 = require("path");
|
|
23
|
+
const heal_check_wrapper_1 = require("./heal-check-wrapper");
|
|
24
|
+
exports.CHECK_TEMPLATE_REPOSITORY = 'lenneTech/lt-monorepo';
|
|
25
|
+
exports.CHECK_TEMPLATE_PIN_FILE = 'lt-monorepo-pin.json';
|
|
26
|
+
const DEFAULT_FROM = `https://github.com/${exports.CHECK_TEMPLATE_REPOSITORY}.git`;
|
|
27
|
+
/** Read a pin file; null when it is missing or unparseable. */
|
|
28
|
+
function readCheckTemplatePin(pinPath) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse((0, fs_1.readFileSync)(pinPath, 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch (_a) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Copy `scripts/check.mjs` and its transitive relative imports from `ref` into
|
|
38
|
+
* the template dir and write the pin. Files the previous pin listed but the new
|
|
39
|
+
* closure no longer contains are removed; nothing else in the dir is touched.
|
|
40
|
+
*
|
|
41
|
+
* Refuses a source without an `@lt-check-wrapper` marker: an unversioned
|
|
42
|
+
* template would switch the downgrade guard in heal off without a trace.
|
|
43
|
+
*/
|
|
44
|
+
function syncCheckTemplate(options) {
|
|
45
|
+
var _a, _b;
|
|
46
|
+
const { from = DEFAULT_FROM, ref, templateDir } = options;
|
|
47
|
+
const pinPath = (_a = options.pinPath) !== null && _a !== void 0 ? _a : (0, path_1.join)(templateDir, exports.CHECK_TEMPLATE_PIN_FILE);
|
|
48
|
+
const checkout = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), 'lt-check-template-'));
|
|
49
|
+
try {
|
|
50
|
+
const git = (...args) => (0, child_process_1.execFileSync)('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
51
|
+
git('clone', '--quiet', '--no-checkout', from, checkout);
|
|
52
|
+
git('-C', checkout, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', ref);
|
|
53
|
+
const commit = git('-C', checkout, 'rev-parse', 'HEAD');
|
|
54
|
+
const asset = (0, path_1.join)(checkout, 'scripts', 'check.mjs');
|
|
55
|
+
if (!(0, fs_1.existsSync)(asset)) {
|
|
56
|
+
throw new Error(`${ref}: scripts/check.mjs not found`);
|
|
57
|
+
}
|
|
58
|
+
const version = (0, heal_check_wrapper_1.readWrapperVersion)(asset);
|
|
59
|
+
if (!version) {
|
|
60
|
+
throw new Error(`${ref}: scripts/check.mjs carries no @lt-check-wrapper marker — refusing to pin it`);
|
|
61
|
+
}
|
|
62
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
63
|
+
throw new Error(`${ref}: @lt-check-wrapper marker "${version}" is not a version — refusing to pin it`);
|
|
64
|
+
}
|
|
65
|
+
const previous = readCheckTemplatePin(pinPath);
|
|
66
|
+
const files = {};
|
|
67
|
+
for (const { rel, source } of (0, heal_check_wrapper_1.resolveCopySet)(asset)) {
|
|
68
|
+
const name = rel.replace(/^scripts\//, '');
|
|
69
|
+
const target = (0, path_1.join)(templateDir, name);
|
|
70
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
|
|
71
|
+
(0, fs_1.copyFileSync)(source, target);
|
|
72
|
+
files[name] = hashFile(target);
|
|
73
|
+
}
|
|
74
|
+
for (const stale of Object.keys((_b = previous === null || previous === void 0 ? void 0 : previous.files) !== null && _b !== void 0 ? _b : {})) {
|
|
75
|
+
// Names come from the pin file; never let one reach outside the template dir.
|
|
76
|
+
if (!(stale in files) && !stale.split(/[\\/]/).includes('..')) {
|
|
77
|
+
(0, fs_1.rmSync)((0, path_1.join)(templateDir, stale), { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const pin = {
|
|
81
|
+
commit,
|
|
82
|
+
files: sortKeys(files),
|
|
83
|
+
ref,
|
|
84
|
+
repository: exports.CHECK_TEMPLATE_REPOSITORY,
|
|
85
|
+
version,
|
|
86
|
+
};
|
|
87
|
+
(0, fs_1.writeFileSync)(pinPath, `${JSON.stringify(pin, null, 2)}\n`);
|
|
88
|
+
return pin;
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
(0, fs_1.rmSync)(checkout, { force: true, recursive: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Differences between the template dir and its pin; empty when they match.
|
|
96
|
+
* Checks both directions: every pinned file with its hash, and every file the
|
|
97
|
+
* wrapper actually imports is pinned (a hand-added import would otherwise slip by).
|
|
98
|
+
*/
|
|
99
|
+
function verifyCheckTemplate(templateDir, pin) {
|
|
100
|
+
const problems = [];
|
|
101
|
+
const actual = new Set((0, heal_check_wrapper_1.resolveCopySet)((0, path_1.join)(templateDir, 'check.mjs')).map((c) => c.rel.replace(/^scripts\//, '')));
|
|
102
|
+
for (const [name, hash] of Object.entries(pin.files)) {
|
|
103
|
+
const file = (0, path_1.join)(templateDir, name);
|
|
104
|
+
if (!(0, fs_1.existsSync)(file)) {
|
|
105
|
+
problems.push(`${name}: pinned but missing`);
|
|
106
|
+
}
|
|
107
|
+
else if (hashFile(file) !== hash) {
|
|
108
|
+
problems.push(`${name}: differs from ${exports.CHECK_TEMPLATE_REPOSITORY}@${pin.commit.slice(0, 7)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const name of actual) {
|
|
112
|
+
if (!(name in pin.files)) {
|
|
113
|
+
problems.push(`${name}: imported by the wrapper but not pinned`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const version = (0, heal_check_wrapper_1.readWrapperVersion)((0, path_1.join)(templateDir, 'check.mjs'));
|
|
117
|
+
if (version !== pin.version) {
|
|
118
|
+
problems.push(`check.mjs: marker ${version !== null && version !== void 0 ? version : '(none)'} does not match pinned version ${pin.version}`);
|
|
119
|
+
}
|
|
120
|
+
return problems;
|
|
121
|
+
}
|
|
122
|
+
function hashFile(file) {
|
|
123
|
+
return (0, crypto_1.createHash)('sha256').update((0, fs_1.readFileSync)(file)).digest('hex');
|
|
124
|
+
}
|
|
125
|
+
function sortKeys(record) {
|
|
126
|
+
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
|
127
|
+
}
|
package/build/lib/claude-cli.js
CHANGED
|
@@ -11,10 +11,10 @@ exports.runClaudeCommand = runClaudeCommand;
|
|
|
11
11
|
* Claude CLI utilities
|
|
12
12
|
* Handles detection and execution of Claude CLI commands
|
|
13
13
|
*/
|
|
14
|
-
const child_process_1 = require("child_process");
|
|
15
14
|
const fs_1 = require("fs");
|
|
16
15
|
const os_1 = require("os");
|
|
17
16
|
const path_1 = require("path");
|
|
17
|
+
const platform_1 = require("./platform");
|
|
18
18
|
/**
|
|
19
19
|
* Path to Claude plugins marketplaces directory
|
|
20
20
|
*/
|
|
@@ -31,7 +31,7 @@ exports.CLAUDE_KNOWN_MARKETPLACES_PATH = (0, path_1.join)((0, os_1.homedir)(), '
|
|
|
31
31
|
function checkCommandExists(command) {
|
|
32
32
|
try {
|
|
33
33
|
const [cmd, ...args] = command.trim().split(/\s+/);
|
|
34
|
-
const result = (0,
|
|
34
|
+
const result = (0, platform_1.spawnCmdSync)(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
35
35
|
return result.status === 0;
|
|
36
36
|
}
|
|
37
37
|
catch (_a) {
|
|
@@ -48,27 +48,19 @@ function checkMarketplaceExists(marketplaceName) {
|
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* Find the Claude CLI executable path
|
|
51
|
-
* Checks common installation locations
|
|
51
|
+
* Checks common installation locations first, then PATH
|
|
52
|
+
*
|
|
53
|
+
* On Windows the native installer puts `claude.exe` into `%USERPROFILE%\.local\bin`
|
|
54
|
+
* and `npm i -g` a `claude.cmd` shim into `%APPDATA%\npm`. Both are checked
|
|
55
|
+
* directly, because a shell opened before the install does not have them on PATH yet.
|
|
52
56
|
* @returns Path to Claude CLI or null if not found
|
|
53
57
|
*/
|
|
54
|
-
function findClaudeCli() {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
const result = (0, child_process_1.spawnSync)('which', ['claude'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
63
|
-
const path = (result.stdout || '').trim();
|
|
64
|
-
if (result.status === 0 && path && (0, fs_1.existsSync)(path)) {
|
|
65
|
-
return path;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch (_a) {
|
|
69
|
-
// Claude CLI not found in PATH
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
58
|
+
function findClaudeCli(options = {}) {
|
|
59
|
+
const { env = process.env, home = (0, os_1.homedir)(), platform = process.platform } = options;
|
|
60
|
+
const candidates = (0, platform_1.isWindows)(platform)
|
|
61
|
+
? [path_1.win32.join(home, '.local', 'bin', 'claude.exe'), path_1.win32.join((0, platform_1.windowsAppData)(env, home), 'npm', 'claude.cmd')]
|
|
62
|
+
: [path_1.posix.join(home, '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude'];
|
|
63
|
+
return (0, platform_1.findExecutable)('claude', Object.assign(Object.assign({}, options), { candidates, env, platform }));
|
|
72
64
|
}
|
|
73
65
|
/**
|
|
74
66
|
* List the names of all marketplaces known to the Claude CLI.
|
|
@@ -99,10 +91,7 @@ function readKnownMarketplaces() {
|
|
|
99
91
|
*/
|
|
100
92
|
function runClaudeCommand(cli, args) {
|
|
101
93
|
try {
|
|
102
|
-
const result = (0,
|
|
103
|
-
encoding: 'utf-8',
|
|
104
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
-
});
|
|
94
|
+
const result = (0, platform_1.spawnCmdSync)(cli, args.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
106
95
|
return {
|
|
107
96
|
output: result.stdout + result.stderr,
|
|
108
97
|
success: result.status === 0,
|
package/build/lib/codex-cli.js
CHANGED
|
@@ -6,42 +6,35 @@ exports.runCodexCommand = runCodexCommand;
|
|
|
6
6
|
/**
|
|
7
7
|
* Codex CLI utilities.
|
|
8
8
|
*/
|
|
9
|
-
const child_process_1 = require("child_process");
|
|
10
|
-
const fs_1 = require("fs");
|
|
11
9
|
const os_1 = require("os");
|
|
12
10
|
const path_1 = require("path");
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
11
|
+
const platform_1 = require("./platform");
|
|
12
|
+
/**
|
|
13
|
+
* Codex marketplace checkout used when `lt codex plugins` gets no `--path`.
|
|
14
|
+
* Defaults to the current directory, matching the command's own guidance to run it
|
|
15
|
+
* from a checkout that contains the generated marketplace.
|
|
16
|
+
*/
|
|
17
|
+
exports.DEFAULT_CODEX_MARKETPLACE_ROOT = process.env.LT_CODEX_MARKETPLACE_ROOT || process.cwd();
|
|
18
|
+
/**
|
|
19
|
+
* Find the Codex CLI executable path: common installation locations first, then PATH.
|
|
20
|
+
* On Windows `npm i -g @openai/codex` leaves a `codex.cmd` shim in `%APPDATA%\npm`.
|
|
21
|
+
*/
|
|
22
|
+
function findCodexCli(options = {}) {
|
|
23
|
+
const { env = process.env, home = (0, os_1.homedir)(), platform = process.platform } = options;
|
|
24
|
+
const candidates = (0, platform_1.isWindows)(platform)
|
|
25
|
+
? [path_1.win32.join((0, platform_1.windowsAppData)(env, home), 'npm', 'codex.cmd')]
|
|
26
|
+
: [
|
|
27
|
+
path_1.posix.join(home, '.local', 'bin', 'codex'),
|
|
28
|
+
path_1.posix.join(home, '.codex', 'bin', 'codex'),
|
|
29
|
+
'/usr/local/bin/codex',
|
|
30
|
+
'/opt/homebrew/bin/codex',
|
|
31
|
+
'/usr/bin/codex',
|
|
32
|
+
];
|
|
33
|
+
return (0, platform_1.findExecutable)('codex', Object.assign(Object.assign({}, options), { candidates, env, platform }));
|
|
38
34
|
}
|
|
39
35
|
function runCodexCommand(cli, args) {
|
|
40
36
|
try {
|
|
41
|
-
const result = (0,
|
|
42
|
-
encoding: 'utf-8',
|
|
43
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
-
});
|
|
37
|
+
const result = (0, platform_1.spawnCmdSync)(cli, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
45
38
|
return {
|
|
46
39
|
output: result.stdout + result.stderr,
|
|
47
40
|
success: result.status === 0,
|
|
@@ -11,6 +11,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.findCompiledEntry = findCompiledEntry;
|
|
13
13
|
exports.isApiCompiledRequested = isApiCompiledRequested;
|
|
14
|
+
exports.resolveApiRuntime = resolveApiRuntime;
|
|
14
15
|
exports.startCompiledApi = startCompiledApi;
|
|
15
16
|
/**
|
|
16
17
|
* Launch strategy for the API process under `lt dev up`.
|
|
@@ -47,6 +48,50 @@ function isApiCompiledRequested(options = {}) {
|
|
|
47
48
|
const affirmative = (value) => value === true || ['1', 'true', 'yes'].includes(String(value).toLowerCase());
|
|
48
49
|
return affirmative(options.apiCompiled) || affirmative(options['api-compiled']);
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Which runtime to start the API's COMPILED bundle with.
|
|
53
|
+
*
|
|
54
|
+
* A bundle is only portable across runtimes by accident. `nest-base` builds with
|
|
55
|
+
* `Bun.build({ target: 'bun' })`, whose output calls `__require` — a Bun-only
|
|
56
|
+
* shim. Started under node it dies in milliseconds with "__require is not a
|
|
57
|
+
* function" (DEV-3208), and because the caller used to only WARN when the API
|
|
58
|
+
* never answered, the whole Playwright suite then ran against a dead API: every
|
|
59
|
+
* data-dependent spec passed trivially or skipped, so a run that checked nothing
|
|
60
|
+
* looked exactly like one that checked everything.
|
|
61
|
+
*
|
|
62
|
+
* Detected from the project rather than configured, because the project already
|
|
63
|
+
* states it three times over. Any one signal is enough:
|
|
64
|
+
* - a `bun.lock` / `bun.lockb` IN THIS DIRECTORY (not a nested package's),
|
|
65
|
+
* - `engines.bun` in its package.json,
|
|
66
|
+
* - a `build` / `start` script that invokes `bun` as the COMMAND.
|
|
67
|
+
*
|
|
68
|
+
* The script check matches `bun` as a whole word at a command position, never as
|
|
69
|
+
* a substring: `rollup --bundle` and `node -r bunyan …` are Node projects, and a
|
|
70
|
+
* loose `includes('bun')` would switch them to a runtime they never installed.
|
|
71
|
+
*
|
|
72
|
+
* Defaults to `node` whenever nothing says otherwise, including an unreadable or
|
|
73
|
+
* missing package.json — the historical behaviour, and the safe one: guessing
|
|
74
|
+
* `bun` for a project that does not have it installed turns a working start into
|
|
75
|
+
* an exit-127.
|
|
76
|
+
*
|
|
77
|
+
* This deliberately does NOT live in `dev-package-manager`: that module resolves
|
|
78
|
+
* which PACKAGE MANAGER drives a directory (`pnpm run build`), a separate axis
|
|
79
|
+
* from which runtime executes an already-built bundle. A Bun project whose
|
|
80
|
+
* scripts pnpm happens to run still needs `bun dist/main.js`.
|
|
81
|
+
*/
|
|
82
|
+
function resolveApiRuntime(apiDir) {
|
|
83
|
+
var _a, _b, _c;
|
|
84
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(apiDir, 'bun.lock')) || (0, node_fs_1.existsSync)((0, node_path_1.join)(apiDir, 'bun.lockb')))
|
|
85
|
+
return 'bun';
|
|
86
|
+
const pkg = readPackageJson(apiDir);
|
|
87
|
+
if (!pkg)
|
|
88
|
+
return 'node';
|
|
89
|
+
if ((_a = pkg.engines) === null || _a === void 0 ? void 0 : _a.bun)
|
|
90
|
+
return 'bun';
|
|
91
|
+
// `bun` as the command itself, or after a shell separator (`&&`, `;`, `|`).
|
|
92
|
+
const invokesBun = /(?:^|[&|;]\s*)bun\b/;
|
|
93
|
+
return [(_b = pkg.scripts) === null || _b === void 0 ? void 0 : _b.build, (_c = pkg.scripts) === null || _c === void 0 ? void 0 : _c.start].some((script) => script && invokesBun.test(script)) ? 'bun' : 'node';
|
|
94
|
+
}
|
|
50
95
|
/**
|
|
51
96
|
* Build the API and start it compiled (`node dist/src/main.js`). Applies pending
|
|
52
97
|
* migrations first for parity with the ts-node path it replaces (`<pm> run start`
|
|
@@ -70,7 +115,11 @@ function startCompiledApi(options) {
|
|
|
70
115
|
return undefined;
|
|
71
116
|
}
|
|
72
117
|
}
|
|
73
|
-
return (0, dev_process_1.spawnDetached)(
|
|
118
|
+
return (0, dev_process_1.spawnDetached)(resolveApiRuntime(apiDir), [entry], {
|
|
119
|
+
cwd: apiDir,
|
|
120
|
+
env: Object.assign(Object.assign({}, env), { NODE_ENV: 'local' }),
|
|
121
|
+
logFile,
|
|
122
|
+
});
|
|
74
123
|
}
|
|
75
124
|
log.warn(`compiled API unavailable (build exit ${String(build)}) — falling back to \`${pm.bin} start\` (ts-node).`);
|
|
76
125
|
return (0, dev_process_1.spawnDetached)(pm.bin, pm.runScript('start'), { cwd: apiDir, env, logFile });
|
|
@@ -78,12 +127,15 @@ function startCompiledApi(options) {
|
|
|
78
127
|
}
|
|
79
128
|
/** True when `package.json` in `apiDir` defines a script named `name`. */
|
|
80
129
|
function hasScript(apiDir, name) {
|
|
81
|
-
var _a;
|
|
130
|
+
var _a, _b;
|
|
131
|
+
return typeof ((_b = (_a = readPackageJson(apiDir)) === null || _a === void 0 ? void 0 : _a.scripts) === null || _b === void 0 ? void 0 : _b[name]) === 'string';
|
|
132
|
+
}
|
|
133
|
+
/** The parsed `package.json` in `apiDir`, or `undefined` when it is missing or unparsable. */
|
|
134
|
+
function readPackageJson(apiDir) {
|
|
82
135
|
try {
|
|
83
|
-
|
|
84
|
-
return typeof ((_a = pkg.scripts) === null || _a === void 0 ? void 0 : _a[name]) === 'string';
|
|
136
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(apiDir, 'package.json'), 'utf8'));
|
|
85
137
|
}
|
|
86
|
-
catch (
|
|
87
|
-
return
|
|
138
|
+
catch (_a) {
|
|
139
|
+
return undefined;
|
|
88
140
|
}
|
|
89
141
|
}
|
package/build/lib/dev-process.js
CHANGED
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.checkPortInUse = checkPortInUse;
|
|
13
|
+
exports.detachedSpawnCommand = detachedSpawnCommand;
|
|
13
14
|
exports.killProcessGroup = killProcessGroup;
|
|
14
15
|
exports.listenSnapshot = listenSnapshot;
|
|
15
16
|
exports.rotateLogFile = rotateLogFile;
|
|
@@ -59,6 +60,40 @@ function checkPortInUse(port) {
|
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* How a detached child is actually launched on this platform.
|
|
65
|
+
*
|
|
66
|
+
* POSIX raises the soft file-descriptor limit before exec-ing the real command.
|
|
67
|
+
* macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
|
|
68
|
+
* by the terminal that runs `lt dev up` and therefore by these detached children
|
|
69
|
+
* — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
|
|
70
|
+
* The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
|
|
71
|
+
* monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
|
|
72
|
+
* that force a manual `lt dev up`. Hence `sh -c "ulimit …; exec …"`:
|
|
73
|
+
* - `exec` replaces the shell IN PLACE → the recorded PID and the detached
|
|
74
|
+
* process group are still the real process (PID tracking + group-kill in
|
|
75
|
+
* `terminateProcessGroup` keep working).
|
|
76
|
+
* - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
|
|
77
|
+
* - the cascade tries a high limit first, falling back on machines with a
|
|
78
|
+
* lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
|
|
79
|
+
*
|
|
80
|
+
* Windows gets the command directly. There is no `/bin/sh` to run the wrapper —
|
|
81
|
+
* spawning it fails outright — and no RLIMIT_NOFILE for `ulimit` to raise, so
|
|
82
|
+
* the wrapper has nothing to offer there even in principle.
|
|
83
|
+
*
|
|
84
|
+
* Note for the POSIX path: because `spawn('/bin/sh', …)` almost always succeeds,
|
|
85
|
+
* a bogus `cmd` does not surface as `pid === undefined` — the inner `exec` fails
|
|
86
|
+
* (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
|
|
87
|
+
* `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
|
|
88
|
+
* deliberately don't watch for that here: a detached, unref'd child's 127 exit is
|
|
89
|
+
* racy to observe, and callers must be self-correcting against real crashes anyway.
|
|
90
|
+
*/
|
|
91
|
+
function detachedSpawnCommand(cmd, args, platform = process.platform) {
|
|
92
|
+
if (platform === 'win32')
|
|
93
|
+
return { args, command: cmd };
|
|
94
|
+
const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
|
|
95
|
+
return { args: ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], command: '/bin/sh' };
|
|
96
|
+
}
|
|
62
97
|
/** Send SIGTERM to a detached process group; falls back to single-PID kill. */
|
|
63
98
|
function killProcessGroup(pid) {
|
|
64
99
|
if (!(0, dev_state_1.isValidPid)(pid))
|
|
@@ -215,6 +250,8 @@ function runChildToFile(cmd, args, opts) {
|
|
|
215
250
|
* so closing prevents fd leaks and avoids racing-write artifacts on
|
|
216
251
|
* filesystems where O_APPEND is not atomic.
|
|
217
252
|
*
|
|
253
|
+
* See `detachedSpawnCommand` for how the child is launched per platform.
|
|
254
|
+
*
|
|
218
255
|
* Returns the child PID, or undefined if spawn failed.
|
|
219
256
|
*/
|
|
220
257
|
function spawnDetached(cmd, args, opts) {
|
|
@@ -223,32 +260,22 @@ function spawnDetached(cmd, args, opts) {
|
|
|
223
260
|
const out = (0, fs_1.openSync)(opts.logFile, 'a');
|
|
224
261
|
let child;
|
|
225
262
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// by the terminal that runs `lt dev up` and therefore by these detached children
|
|
229
|
-
// — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
|
|
230
|
-
// The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
|
|
231
|
-
// monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
|
|
232
|
-
// that force a manual `lt dev up`. We wrap the command in `sh -c "ulimit …; exec …"`:
|
|
233
|
-
// - `exec` replaces the shell IN PLACE → the recorded PID and the detached
|
|
234
|
-
// process group are still the real process (PID tracking + group-kill in
|
|
235
|
-
// `terminateProcessGroup` keep working).
|
|
236
|
-
// - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
|
|
237
|
-
// - the cascade tries a high limit first, falling back on machines with a
|
|
238
|
-
// lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
|
|
239
|
-
// Note: because the outer `spawn('/bin/sh', …)` almost always succeeds, a bogus
|
|
240
|
-
// `cmd` no longer surfaces as `pid === undefined` here — the inner `exec` fails
|
|
241
|
-
// (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
|
|
242
|
-
// `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
|
|
243
|
-
// deliberately don't watch for that here: a detached, unref'd child's 127 exit is
|
|
244
|
-
// racy to observe, and callers must be self-correcting against real crashes anyway.
|
|
245
|
-
const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
|
|
246
|
-
child = (0, child_process_1.spawn)('/bin/sh', ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], {
|
|
263
|
+
const spawned = detachedSpawnCommand(cmd, args);
|
|
264
|
+
child = (0, child_process_1.spawn)(spawned.command, spawned.args, {
|
|
247
265
|
cwd: opts.cwd,
|
|
248
266
|
detached: true,
|
|
249
267
|
env: opts.env,
|
|
250
268
|
stdio: ['ignore', out, out],
|
|
251
269
|
});
|
|
270
|
+
// spawn reports "could not start this at all" (missing executable, bad cwd)
|
|
271
|
+
// through an ASYNCHRONOUS 'error' event — the try/catch around spawn() never
|
|
272
|
+
// sees it. With no listener, Node escalates an unhandled 'error' to a process
|
|
273
|
+
// crash, so one unstartable child would take the whole CLI down with it. That
|
|
274
|
+
// is not hypothetical: on Windows `/bin/sh` does not exist, and this line is
|
|
275
|
+
// what turned that into "Test suite failed to run" instead of one red test.
|
|
276
|
+
// Nothing to do but swallow it — the child is detached and its log file is
|
|
277
|
+
// already closed below; callers are self-correcting against a dead PID.
|
|
278
|
+
child.on('error', () => undefined);
|
|
252
279
|
child.unref();
|
|
253
280
|
if (child.pid === undefined)
|
|
254
281
|
return undefined;
|
|
@@ -330,7 +357,15 @@ function terminateProcessGroup(pid_1) {
|
|
|
330
357
|
*
|
|
331
358
|
* Resolves `true` on the first matching response, `false` on timeout. Never rejects.
|
|
332
359
|
*/
|
|
333
|
-
function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600
|
|
360
|
+
function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600,
|
|
361
|
+
/**
|
|
362
|
+
* Optional early exit: return `true` once there is nothing left to wait FOR —
|
|
363
|
+
* typically "the process I am waiting on has exited". Without it a server that
|
|
364
|
+
* crashes on boot is indistinguishable from one that is still starting, and the
|
|
365
|
+
* caller burns the entire timeout (120s for the `lt dev test` API) before
|
|
366
|
+
* learning something that was decided in the first 300ms.
|
|
367
|
+
*/
|
|
368
|
+
abort = () => false) {
|
|
334
369
|
const start = Date.now();
|
|
335
370
|
return new Promise((resolve) => {
|
|
336
371
|
const tick = () => {
|
|
@@ -340,20 +375,21 @@ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status
|
|
|
340
375
|
});
|
|
341
376
|
let status = '';
|
|
342
377
|
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
|
|
378
|
+
const retry = () => {
|
|
379
|
+
// Order matters: a LAST probe already ran above, so a server that came up
|
|
380
|
+
// just before dying is still reported ready. Only then does `abort` end it.
|
|
381
|
+
if (Date.now() - start > timeoutMs || abort())
|
|
382
|
+
return resolve(false);
|
|
383
|
+
setTimeout(tick, 500);
|
|
384
|
+
};
|
|
343
385
|
child.on('close', () => {
|
|
344
386
|
const code = Number(status.trim());
|
|
345
387
|
// `000` (curl could not connect) parses to 0 → never "ready".
|
|
346
388
|
if (Number.isFinite(code) && code > 0 && ready(code))
|
|
347
389
|
return resolve(true);
|
|
348
|
-
|
|
349
|
-
return resolve(false);
|
|
350
|
-
setTimeout(tick, 500);
|
|
351
|
-
});
|
|
352
|
-
child.on('error', () => {
|
|
353
|
-
if (Date.now() - start > timeoutMs)
|
|
354
|
-
return resolve(false);
|
|
355
|
-
setTimeout(tick, 500);
|
|
390
|
+
retry();
|
|
356
391
|
});
|
|
392
|
+
child.on('error', retry);
|
|
357
393
|
};
|
|
358
394
|
tick();
|
|
359
395
|
});
|
package/build/lib/dev-state.js
CHANGED
|
@@ -204,10 +204,21 @@ function loadSession(root, sessionFile = SESSION_FILE) {
|
|
|
204
204
|
}
|
|
205
205
|
return null;
|
|
206
206
|
}
|
|
207
|
-
/**
|
|
207
|
+
/**
|
|
208
|
+
* True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var).
|
|
209
|
+
*
|
|
210
|
+
* `.native` rather than plain `realpathSync`, because on Windows the JS
|
|
211
|
+
* implementation follows symlinks but keeps 8.3 short components: a path spelled
|
|
212
|
+
* `C:\Users\RUNNER~1\…` and the same directory spelled `C:\Users\runneradmin\…`
|
|
213
|
+
* stay different strings. Both spellings occur in practice — `os.tmpdir()` hands
|
|
214
|
+
* out the short form while git prints the long one — so a registry entry written
|
|
215
|
+
* under one and a cwd under the other would read as a DIFFERENT checkout. The
|
|
216
|
+
* native variant asks the OS for the final name and collapses both.
|
|
217
|
+
* No behavioural difference on POSIX.
|
|
218
|
+
*/
|
|
208
219
|
function sameRealPath(a, b) {
|
|
209
220
|
try {
|
|
210
|
-
return
|
|
221
|
+
return fs_1.realpathSync.native(a) === fs_1.realpathSync.native(b);
|
|
211
222
|
}
|
|
212
223
|
catch (_a) {
|
|
213
224
|
return a === b;
|