@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.
@@ -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
+ }
@@ -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, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
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 and falls back to 'which'
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 possiblePaths = [(0, path_1.join)((0, os_1.homedir)(), '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude'];
56
- for (const p of possiblePaths) {
57
- if ((0, fs_1.existsSync)(p)) {
58
- return p;
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, child_process_1.spawnSync)(cli, args.split(' '), {
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,
@@ -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
- exports.DEFAULT_CODEX_MARKETPLACE_ROOT = process.env.LT_CODEX_MARKETPLACE_ROOT || '/Users/kaihaase/code/lenneTech/codex';
14
- function findCodexCli() {
15
- const possiblePaths = [
16
- (0, path_1.join)((0, os_1.homedir)(), '.local', 'bin', 'codex'),
17
- (0, path_1.join)((0, os_1.homedir)(), '.codex', 'bin', 'codex'),
18
- '/usr/local/bin/codex',
19
- '/opt/homebrew/bin/codex',
20
- '/usr/bin/codex',
21
- ];
22
- for (const path of possiblePaths) {
23
- if ((0, fs_1.existsSync)(path)) {
24
- return path;
25
- }
26
- }
27
- try {
28
- const result = (0, child_process_1.spawnSync)('which', ['codex'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
29
- const path = (result.stdout || '').trim();
30
- if (result.status === 0 && path && (0, fs_1.existsSync)(path)) {
31
- return path;
32
- }
33
- }
34
- catch (_a) {
35
- // Codex CLI not found in PATH.
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, child_process_1.spawnSync)(cli, args, {
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)('node', [entry], { cwd: apiDir, env: Object.assign(Object.assign({}, env), { NODE_ENV: 'local' }), logFile });
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
- const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(apiDir, 'package.json'), 'utf8'));
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 (_b) {
87
- return false;
138
+ catch (_a) {
139
+ return undefined;
88
140
  }
89
141
  }
@@ -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
- // Raise the soft file-descriptor limit before exec-ing the real command.
227
- // macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
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
- if (Date.now() - start > timeoutMs)
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
  });
@@ -204,10 +204,21 @@ function loadSession(root, sessionFile = SESSION_FILE) {
204
204
  }
205
205
  return null;
206
206
  }
207
- /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
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 (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
221
+ return fs_1.realpathSync.native(a) === fs_1.realpathSync.native(b);
211
222
  }
212
223
  catch (_a) {
213
224
  return a === b;