diff --git a/CLAUDE.md b/CLAUDE.md index 69fa4d2..80fa024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -247,7 +247,7 @@ ports. Developers and Claude Code never see the internal ports. | VS Code memory profile | `src/commands/dev/vscode.ts` + `src/lib/vscode-settings.ts` | `lt dev vscode` (alias `vsc`) tunes the USER `settings.json` of VS Code / Insiders / Cursor / VSCodium. Machine-level, not project-level — no registry or Caddy involvement. JSONC-aware via `jsonc-parser` (lazy-required, since gluegun loads every command on every `lt` run) so comments survive; refuses an unparseable file or a symlink; keeps the FIRST `settings.json.bak`. Object-valued exclude maps are MERGED on apply (user entries win) and SUBTRACTED on `--revert`, so an undo never removes a hand-maintained exclusion. `MEMORY_PROFILE` targets the per-root SEMANTIC TS servers and excludes `**/.nuxt*/**` / `**/.output*/**` (segment globs — the bare names miss `.nuxt-test`); `EXCLUDED_FROM_PROFILE` records three commonly recommended keys that were ruled out, surfaced by `--explain`. `--dry-run` uses presence-as-intent (`isPreventingFlagSet`), not `=== true`, because it PREVENTS a write; `--noConfirm` must be an explicit CLI flag — a repo-local `lt.config.json` must not silence a prompt guarding a machine-global write. | | Caddy service lifecycle | `src/lib/dev-service.ts` + `src/commands/dev/{install,uninstall}.ts` | Per-OS service runner. macOS: per-user LaunchAgent `~/Library/LaunchAgents/tech.lenne.lt-dev-caddy.plist` via `launchctl bootstrap gui/`. Linux: `~/.config/systemd/user/lt-dev-caddy.service` via `systemctl --user enable --now`. Render helpers (`renderLaunchAgentPlist`, `renderSystemdUnit`) are pure + unit-tested; side-effecting ops accept an injectable `ShellRunner`. **Critical:** plist sets `HOME=$userHome` env so caddy persists its CA under `~/Library/Application Support/Caddy/`, not the launchd-empty default. `userHome()` is `process.env.HOME || homedir()` because real `os.homedir()` on macOS goes through `getpwuid()` and ignores HOME — required so test files can redirect side-effects to a tmpdir. The `caddy trust` instructions surfaced to users **must** include `-E HOME="$HOME"` for the same reason in sudo context. | | install↔init auto-chaining | `src/lib/dev-bootstrap.ts` (predicates) + `src/lib/dev-install-helper.ts` (`runInstall`) + `src/lib/dev-migrate-helper.ts` (`runMigrate`/`printMigrateResult`) + `src/commands/dev/{install,init}.ts` | `lt dev init` runs install first when `!isMachinePrepared()`; `lt dev install` runs init after when `isLtDevProject() && !isProjectInitialized()`. Non-recursive by construction: commands call the *helpers*, never each other. Opt-outs: `--skip-install` (init), `--skip-init` (install). `runInstall` must NOT call `process.exit`. | -| Process management | `src/lib/dev-process.ts` | `spawnDetached` keeps the Claude Code session unblocked (logs to `/.lt-dev/{api,app}.log`); it wraps the command in `/bin/sh -c 'ulimit -n …; exec "$0" "$@"'` to raise `RLIMIT_NOFILE` before exec (fixes EMFILE chokidar-watcher crashes on boot) — `exec` keeps the recorded PID/process-group identical, and `"$0" "$@"` passes cmd+args verbatim (injection-safe). `killProcessGroup` uses negative-PID SIGTERM to reach the Nest watcher + Vite + Nuxt children. Single-call `listenSnapshot` for multi-port lsof. Foreground helpers: `runChildInherit` (synchronous-feel child with inherited stdio — build/test runners) and `waitForHttp` (curl-based readiness probe over HTTPS; treats any 1xx-5xx as up). | +| Process management | `src/lib/dev-process.ts` | `spawnDetached` keeps the Claude Code session unblocked (logs to `/.lt-dev/{api,app}.log`); it wraps the command in `/bin/sh -c 'ulimit -n …; exec "$0" "$@"'` to raise `RLIMIT_NOFILE` before exec (fixes EMFILE chokidar-watcher crashes on boot) — `exec` keeps the recorded PID/process-group identical, and `"$0" "$@"` passes cmd+args verbatim (injection-safe). **Windows:** a `node -e` trampoline (`windowsTrampoline`) sits in front of the command. Without it the log stays EMPTY: `detached` starts `cmd.exe` (every `.cmd` shim, `pnpm.cmd` included) without a console, the console program below it gets a new one, and Windows replaces the std handles `cmd.exe` only inherited, so the output goes to an extra console window instead (measured on windows-latest: 0 bytes vs. child + grandchild lines with the trampoline; `__tests__/dev-log-capture.test.ts` goes red on the old path). The recorded pid there is the trampoline's; `taskkill /T` from it reaches the tree. `killProcessGroup` uses negative-PID SIGTERM to reach the Nest watcher + Vite + Nuxt children. Single-call `listenSnapshot` for multi-port lsof. Foreground helpers: `runChildInherit` (synchronous-feel child with inherited stdio — build/test runners) and `waitForHttp` (curl-based readiness probe over HTTPS; treats any 1xx-5xx as up). | | Test session (isolated parallel stack) | `src/lib/dev-test-session.ts` (+ `dev-identity.ts#buildTestIdentity`, `dev-state.ts#TEST_SESSION_FILE`) | `bringUpTestSession` boots a SECOND stack (own URLs `-test.localhost` / `api.-test.localhost`, own port band 4500+, own Caddy block `lt-dev:-test`, own DB `<…>-test`, own session file `state.test.json`, own env bridge `.env.test`, own log files `{api,app}.test.log`, own Nuxt build dir `.nuxt-test` + Nitro output dir `.output-test` via `buildTestAppEnv` — DEV-2715/DEV-2724, both project-forwarded and NOT framework-native, with `testAppEntryCandidates()` looking in the isolated dir FIRST and falling back to `.output/`; `.gitignore` is healed with `.nuxt-*`/`.output-*` and the suffixed dirs are removed on teardown) PARALLEL to the dev session — Playwright never touches developer data, and the dev stack keeps running while tests run. API is run **compiled** (`node dist/src/main.js`) for ts-node stability across long suites, **migrated first** (`startTestApi` → `applyPendingMigrations` with the API's own env, abort on failure — the test DB outlives every run, DEV-3289); falls back to `pnpm start`. `tearDownTestSession` is idempotent + residue-free (registry entry, session file, env bridge, Caddy block all dropped). `lt dev down` also tears down any lingering test stack. The `-test` suffix on the DB matches the TestHelper guard pattern `(-local|-ci|-e2e|-test)$`. Sharded runs (`runShardedTestSession` → `buildShardPlaywrightInvocation`) deliberately pass NO `--reporter` so a project's release-gate reporters (e.g. a no-skips gate) survive under `--shard` (DEV-2676); the HTML reporter is isolated per shard via `shardReportDir()` + `PLAYWRIGHT_HTML_OUTPUT_DIR`/`PLAYWRIGHT_HTML_OPEN=never`. | | Workspace/standalone detection | `src/lib/dev-project.ts` | Reuses `workspace-integration.ts` helpers; never duplicates detection logic. Also exports `apiNeedsPortPatch`/`appNeedsPortPatch`/`deriveDbName`/`deriveTestDbName` (test-DB name is `<…>-test`, distinct from `<…>-local` and the API unit-test DB). | | Idempotent legacy port patches | `src/lib/dev-patches.ts` | Patches `config.env.ts` (port 3000), `nuxt.config.ts` (port 3001 + vite proxy target), `playwright.config.ts` (`baseURL`/`host`/`url` + the marker-bracketed `lt-dev:bridge vN` block), and the CLAUDE.md URL block. All return a no-op `PatchResult` for missing files. The bridge block is re-injected on a **version** bump (`BRIDGE_VERSION`) or a **semantic** code change — never for the consumer formatter's restyling, which owns that file. `canonicaliseBridgeSpan` exports that comparison for `dev-ticket.ts`. | diff --git a/__tests__/dev-log-capture.test.ts b/__tests__/dev-log-capture.test.ts new file mode 100644 index 0000000..5147303 --- /dev/null +++ b/__tests__/dev-log-capture.test.ts @@ -0,0 +1,63 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { spawnDetached } from '../src/lib/dev-process'; + +describe('spawnDetached — the log must actually receive the output', () => { + // The silent failure this guards: on Windows `.lt-dev/*.log` was created and + // even rotated ("Rotated previous api log (0B)") but never written, because the + // output of everything below `cmd.exe` went to an extra console window. A log + // that exists but stays empty is worse than none: it looks trustworthy. On + // Windows the command goes through a `.cmd` shim exactly like `pnpm.cmd`; on + // POSIX through the sh wrapper. Both a child and a grandchild must reach the file. + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'lt-dev-log-')); + }); + afterEach(() => { + try { + rmSync(dir, { force: true, recursive: true }); + } catch { + /* a writer on Windows may still hold the file for a moment */ + } + }); + + it('captures stdout and stderr of the child AND of a grandchild', async () => { + const script = join(dir, 'child.js'); + writeFileSync( + script, + [ + "const tag = process.argv[2] || 'child';", + "if (tag === 'child') require('child_process').spawn(process.execPath, [__filename, 'grandchild'], { stdio: 'inherit' });", + "console.log(tag + '-out'); console.error(tag + '-err');", + 'setTimeout(() => {}, 300);', + ].join('\n'), + ); + let cmd = process.execPath; + let args = [script]; + const env = { ...process.env }; + if (process.platform === 'win32') { + writeFileSync(join(dir, 'fakepnpm.cmd'), '@node "%~dp0child.js" %*\r\n'); + cmd = 'fakepnpm'; + args = []; + // The key is `Path` on Windows, and a copied env is a plain, case-SENSITIVE + // object: `env.PATH` would read undefined and hide node from the shim. + const pathKey = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH'; + env[pathKey] = `${dir};${env[pathKey]}`; + } + const logFile = join(dir, 'app.log'); + const result = spawnDetached(cmd, args, { cwd: dir, env, logFile }); + if (!result) throw new Error('spawnDetached returned undefined'); + + const want = ['child-out', 'child-err', 'grandchild-out', 'grandchild-err']; + const deadline = Date.now() + 15_000; + let content = ''; + while (Date.now() < deadline) { + content = existsSync(logFile) ? readFileSync(logFile, 'utf8') : ''; + if (want.every((w) => content.includes(w))) break; + await new Promise((r) => setTimeout(r, 100)); + } + for (const w of want) expect(content).toContain(w); + }); +}); diff --git a/__tests__/dev-log-tail.test.ts b/__tests__/dev-log-tail.test.ts new file mode 100644 index 0000000..ec27f34 --- /dev/null +++ b/__tests__/dev-log-tail.test.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { describeLog, diagnoseLog, findEarlyExits, isSilentLog, tailLines } from '../src/lib/dev-log-tail'; + +describe('dev-log-tail', () => { + const dir = mkdtempSync(join(tmpdir(), 'lt-log-tail-')); + afterAll(() => rmSync(dir, { force: true, recursive: true })); + + it('an EMPTY log is reported as such — never as silence', () => { + const file = join(dir, 'empty.log'); + writeFileSync(file, ''); + const lines = describeLog(diagnoseLog(file)); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('EMPTY'); + expect(lines[0]).toContain('not captured'); + }); + + it('a missing log is reported, not skipped', () => { + expect(describeLog(diagnoseLog(join(dir, 'nope.log')))[0]).toContain('No log at'); + }); + + it('shows the last lines of a log with content, CRLF included', () => { + const file = join(dir, 'api.log'); + writeFileSync(file, 'one\r\ntwo\r\n\r\nCannot determine a GraphQL output type\r\n'); + const d = diagnoseLog(file, 2); + expect(d.state).toBe('ok'); + expect(describeLog(d).slice(1)).toEqual([' two', ' Cannot determine a GraphQL output type']); + }); + + it('tailLines keeps the last n non-empty lines in order', () => { + expect(tailLines('a\n\nb\nc\n', 2)).toEqual(['b', 'c']); + }); + + it('a log still empty well after start is "silent"; a fresh one is not yet', () => { + const file = join(dir, 'silent.log'); + writeFileSync(file, ''); + const d = diagnoseLog(file); + const now = Date.parse('2026-09-23T12:00:30Z'); + expect(isSilentLog(d, '2026-09-23T12:00:00Z', now)).toBe(true); + expect(isSilentLog(d, '2026-09-23T12:00:25Z', now)).toBe(false); + expect(isSilentLog(d, undefined, now)).toBe(false); + expect(isSilentLog(d, 'not a date', now)).toBe(false); + }); + + it('a log with content is never "silent"', () => { + const file = join(dir, 'busy.log'); + writeFileSync(file, 'Local: http://127.0.0.1:4001/\n'); + expect(isSilentLog(diagnoseLog(file), '2026-09-23T12:00:00Z', Date.parse('2026-09-23T13:00:00Z'))).toBe(false); + }); + + it('findEarlyExits names a component whose pid dies within the window, and only that one', async () => { + let t = 0; + const alive = (pid: number) => !(pid === 2 && t >= 2); // app (pid 2) dies on the third look + const dead = await findEarlyExits( + [ + { name: 'api', pid: 1 }, + { name: 'app', pid: 2 }, + { name: 'skipped', pid: undefined }, + ], + { budgetMs: 60_000, isAlive: (pid) => alive(pid), sleep: async () => void t++ }, + ); + expect(dead).toEqual(['app']); + }); + + it('findEarlyExits returns nothing when everything stays up for the whole window', async () => { + const dead = await findEarlyExits([{ name: 'api', pid: 1 }], { + budgetMs: 50, + isAlive: () => true, + sleep: (ms) => new Promise((r) => setTimeout(r, Math.min(ms, 10))), + }); + expect(dead).toEqual([]); + }); + + it('up and status actually use it (wiring, not behaviour: the commands have no harness)', () => { + const { readFileSync } = require('fs'); + const src = (f: string) => readFileSync(join(__dirname, '..', 'src', 'commands', 'dev', f), 'utf8'); + expect(src('up.ts')).toMatch(/findEarlyExits\(/); + expect(src('up.ts')).toMatch(/describeLog\(diagnoseLog\(/); + expect(src('status.ts')).toMatch(/isSilentLog\(/); + expect(src('status.ts')).toMatch(/describeLog\(diagnoseLog\(/); + }); +}); diff --git a/__tests__/dev-process.test.ts b/__tests__/dev-process.test.ts index 3d44cd4..f39209c 100644 --- a/__tests__/dev-process.test.ts +++ b/__tests__/dev-process.test.ts @@ -3,7 +3,15 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs import { tmpdir } from 'os'; import { join } from 'path'; -import { detachedSpawnCommand, rotateLogFile, runChildInherit, spawnDetached, terminateProcessGroup, waitForHttp } from '../src/lib/dev-process'; +import { + detachedSpawnCommand, + rotateLogFile, + runChildInherit, + spawnDetached, + terminateProcessGroup, + waitForHttp, + windowsTrampoline, +} from '../src/lib/dev-process'; describe('rotateLogFile', () => { let dir: string; @@ -88,13 +96,37 @@ describe('detachedSpawnCommand', () => { expect(args[1]).toEqual(expect.stringContaining('exec "$0" "$@"')); }); - it('spawns the command directly on Windows — there is no /bin/sh there', () => { - // A `/bin/sh` that does not exist makes spawn emit an async 'error' event - // instead of returning, which used to take the whole process down. Windows - // also has no RLIMIT_NOFILE, so the wrapper buys nothing there anyway. - const { args, command } = detachedSpawnCommand('node', ['server.js', '--port=1'], 'win32'); - expect(command).toBe('node'); - expect(args).toEqual(['server.js', '--port=1']); + it('runs the command through a Node trampoline on Windows, never /bin/sh', () => { + // No /bin/sh there (spawning it emits an async 'error'), and the trampoline is + // what keeps the log from staying empty — see `detachedSpawnCommand`. + const { args, command } = detachedSpawnCommand('pnpm', ['run', 'dev'], 'win32', 'C:\\node\\node.exe'); + expect(command).toBe('C:\\node\\node.exe'); + expect(args[0]).toBe('-e'); + expect(args[1]).toContain('cross-spawn'); + expect(args[1]).toContain("stdio: 'inherit'"); + expect(args[1]).not.toContain('\n'); + // `--` ends node's own options; cmd and args follow verbatim. + expect(args.slice(2)).toEqual(['--', 'pnpm', 'run', 'dev']); + }); +}); + +describe('windowsTrampoline — runs on every platform, so its behaviour is tested here', () => { + const run = (...args: string[]) => + require('child_process').spawnSync(process.execPath, ['-e', windowsTrampoline(), '--', ...args], { + encoding: 'utf8', + }); + + it('passes stdout, stderr and the exit code through', () => { + const r = run(process.execPath, '-e', 'console.log("to-out"); console.error("to-err"); process.exit(7)'); + expect(r.stdout).toBe('to-out\n'); + expect(r.stderr).toBe('to-err\n'); + expect(r.status).toBe(7); + }); + + it('turns a command that cannot start into exit 127 with a readable line', () => { + const r = run('lt-dev-no-such-command-xyz'); + expect(r.status).toBe(127); + expect(r.stderr).toContain('lt dev: could not start lt-dev-no-such-command-xyz'); }); }); @@ -270,7 +302,10 @@ describe('spawnDetached (sh/exec FD-limit wrapper)', () => { return existsSync(logFile) ? readFileSync(logFile, 'utf8') : ''; } - it('preserves PID identity through the wrapper — the recorded pid IS the real process', async () => { + // POSIX only: on Windows the recorded pid is deliberately the trampoline's (see + // `detachedSpawnCommand`). It lives exactly as long as the command, and + // `taskkill /T` from it reaches the whole tree, which is what the pid is for there. + (process.platform === 'win32' ? it.skip : it)('preserves PID identity through the wrapper — the recorded pid IS the real process', async () => { const logFile = join(dir, 'pid.log'); const result = spawnDetached('node', ['-e', 'process.stdout.write(String(process.pid))'], { cwd: process.cwd(), @@ -330,9 +365,10 @@ describe('spawnDetached (sh/exec FD-limit wrapper)', () => { }); describe('probePorts', () => { - const { isPortBound, probePorts } = require('../src/lib/dev-process'); const nodeNet = require('net'); + const { isPortBound, probePorts } = require('../src/lib/dev-process'); + const listen = (): Promise<{ close: () => void; port: number }> => new Promise((resolve) => { const server = nodeNet.createServer(); @@ -415,9 +451,10 @@ describe('isPidAlive and EPERM', () => { }); describe('probePorts owner lookup — both platform branches, from any host', () => { - const { probePorts } = require('../src/lib/dev-process'); const nodeNet = require('net'); + const { probePorts } = require('../src/lib/dev-process'); + const listen = (): Promise<{ close: () => void; port: number }> => new Promise((resolve) => { const server = nodeNet.createServer(); @@ -506,9 +543,10 @@ describe('probePorts owner lookup — both platform branches, from any host', () }); describe('httpStatus — the probe that replaced `curl -o /dev/null`', () => { - const { httpStatus, waitForHttp } = require('../src/lib/dev-process'); const nodeHttp = require('http'); + const { httpStatus, waitForHttp } = require('../src/lib/dev-process'); + const serve = (handler: (req: unknown, res: { end: () => void; statusCode: number }) => void) => new Promise<{ close: () => void; url: string }>((resolve) => { const server = nodeHttp.createServer(handler); diff --git a/src/commands/dev/status.ts b/src/commands/dev/status.ts index 0ff4d7b..b9cea73 100644 --- a/src/commands/dev/status.ts +++ b/src/commands/dev/status.ts @@ -1,7 +1,9 @@ import { GluegunCommand } from 'gluegun'; +import { join } from 'path'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; import { caddyAvailable, caddyDaemonRunning } from '../../lib/caddy'; +import { describeLog, diagnoseLog, isSilentLog } from '../../lib/dev-log-tail'; import { probePorts } from '../../lib/dev-process'; import { apiNeedsPortPatch, appNeedsPortPatch, resolveLayout } from '../../lib/dev-project'; import { @@ -260,6 +262,25 @@ const StatusCommand: GluegunCommand = { ` ${down.join(' + ')} not serving${crashed ? ' (supervisor still up — crashed)' : ''}. ` + `Run \`lt dev up\` to restart ${down.length === 1 ? 'it' : 'them'}.`, ); + // Why it is down is in its log, so show it here instead of sending the + // reader to look. An empty log is said out loud: that is a finding too. + for (const name of down) { + describeLog(diagnoseLog(join(layout.root, '.lt-dev', `${name}.log`), 8)).forEach((l) => + info(colors.dim(` ${l}`)), + ); + } + } + // A log that exists but stays empty while its component runs: the output + // is going somewhere else (on Windows it once went to an extra console + // window). Worse than no log, because it looks trustworthy. + for (const [name, present] of [ + ['api', apiPresent], + ['app', appPresent], + ] as const) { + const logDiagnosis = diagnoseLog(join(layout.root, '.lt-dev', `${name}.log`)); + if (present && !down.includes(name) && isSilentLog(logDiagnosis, session.startedAt, Date.now())) { + warning(` ${name}: ${logDiagnosis.file} is still EMPTY — its output is not being captured.`); + } } } diff --git a/src/commands/dev/up.ts b/src/commands/dev/up.ts index f46b253..ea09810 100644 --- a/src/commands/dev/up.ts +++ b/src/commands/dev/up.ts @@ -9,6 +9,7 @@ import { isApiCompiledRequested, startCompiledApi } from '../../lib/dev-api-laun import { buildDevEnv } from '../../lib/dev-env'; import { writeEnvBridge } from '../../lib/dev-env-bridge'; import { buildIdentity } from '../../lib/dev-identity'; +import { describeLog, diagnoseLog, findEarlyExits } from '../../lib/dev-log-tail'; import { pickPackageManager } from '../../lib/dev-package-manager'; import { addToGitignore, autoPatch, patchClaudeMd } from '../../lib/dev-patches'; import { killProcessGroup, probePorts, spawnDetached, terminateProcessGroup } from '../../lib/dev-process'; @@ -19,6 +20,7 @@ import { classifyComponentHealth, type ComponentHealth, detectSlugConflict, + isPidAlive, loadRegistry, loadSession, saveRegistry, @@ -513,6 +515,22 @@ const UpCommand: GluegunCommand = { started.length > 0 ? new Date().toISOString() : (existingSession?.startedAt ?? new Date().toISOString()); saveSession(layout.root, { pids, startedAt }); + // A component that dies right after its start used to be reported as + // "Started": the spawn succeeded, and nothing looked again. Watch the fresh + // pids for a moment and show what the log says (or that it is empty). + const earlyExits = await findEarlyExits( + started.map((name) => ({ name, pid: pids[name as 'api' | 'app'] })), + { budgetMs: 3_000, isAlive: isPidAlive }, + ); + if (earlyExits.length > 0) { + for (const name of earlyExits) { + error(`${name} exited right after its start (pid ${pids[name as 'api' | 'app']}).`); + describeLog(diagnoseLog(join(layout.root, '.lt-dev', `${name}.log`))).forEach((l) => info(l)); + } + if (!parameters.options.fromGluegunMenu) process.exit(1); + return `dev up: ${earlyExits.join('+')} exited on start`; + } + // Write the ENV bridge so external tools (Playwright, IDE test runners, // custom shell scripts) can pick up the URLs without inheriting our shell. const bridgePath = writeEnvBridge(layout.root, devEnv, dbName); diff --git a/src/lib/dev-log-tail.ts b/src/lib/dev-log-tail.ts new file mode 100644 index 0000000..060a2a8 --- /dev/null +++ b/src/lib/dev-log-tail.ts @@ -0,0 +1,85 @@ +/** + * What a component's log can tell someone whose component just died. + * + * Two silent failures made this necessary, both seen on a Windows laptop: + * `lt dev up` reported success for an API that was dead seconds later, and its + * log file existed, was even rotated, but stayed at 0 bytes, so nobody looked in + * the extra console window that held the actual error. A diagnosis therefore has + * to tell "the log says X" apart from "the log is empty", and say the latter out + * loud instead of printing nothing. + */ +import { existsSync, readFileSync, statSync } from 'fs'; + +/** What was found in a log file. */ +export interface LogDiagnosis { + /** Absolute path, for the message. */ + file: string; + /** Size in bytes; -1 when the file does not exist. */ + size: number; + /** `missing` / `empty` / `ok` (has content). */ + state: 'empty' | 'missing' | 'ok'; + /** Last non-empty lines, oldest first; empty unless `state === 'ok'`. */ + tail: string[]; +} + +/** + * The lines to print under " is not running". Never an empty list: an + * empty or missing log is itself the finding. + */ +export function describeLog(d: LogDiagnosis): string[] { + if (d.state === 'missing') return [`No log at ${d.file}.`]; + if (d.state === 'empty') { + return [`${d.file} is EMPTY — the output was not captured, so the reason is not in the log.`]; + } + return [`Last lines of ${d.file}:`, ...d.tail.map((l) => ` ${l}`)]; +} + +/** Read `file` and keep its last `lines` non-empty lines. */ +export function diagnoseLog(file: string, lines = 20): LogDiagnosis { + if (!existsSync(file)) return { file, size: -1, state: 'missing', tail: [] }; + const size = statSync(file).size; + if (size === 0) return { file, size, state: 'empty', tail: [] }; + return { file, size, state: 'ok', tail: tailLines(readFileSync(file, 'utf8'), lines) }; +} + +/** + * Components whose pid is gone within `budgetMs` of their start. + * + * Catches what dies at once: a missing script, a package manager that cannot + * start, a crash before any watcher takes over. A crash under nodemon keeps the + * supervisor alive and is `lt dev status`'s job (`crashed` after the grace window). + */ +export async function findEarlyExits( + components: { name: string; pid: number | undefined }[], + opts: { budgetMs: number; isAlive: (pid: number) => boolean; sleep?: (ms: number) => Promise }, +): Promise { + const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const watched = components.filter((c): c is { name: string; pid: number } => typeof c.pid === 'number'); + const dead = new Set(); + const deadline = Date.now() + opts.budgetMs; + do { + for (const c of watched) if (!dead.has(c.name) && !opts.isAlive(c.pid)) dead.add(c.name); + if (dead.size === watched.length) break; + await sleep(250); + } while (Date.now() < deadline); + return watched.filter((c) => dead.has(c.name)).map((c) => c.name); +} + +/** + * True when a component has been up for `minAgeMs` and its log is still empty. + * A booting app writes within seconds (nuxt/nest print a banner), so an empty + * log after that means output is going somewhere else, not that nothing happened. + */ +export function isSilentLog(d: LogDiagnosis, startedAt: string | undefined, now: number, minAgeMs = 15_000): boolean { + if (d.state !== 'empty' || !startedAt) return false; + const age = now - new Date(startedAt).getTime(); + return Number.isFinite(age) && age >= minAgeMs; +} + +/** Last `n` non-empty lines of `content`, oldest first. */ +export function tailLines(content: string, n: number): string[] { + return content + .split(/\r?\n/) + .filter((l) => l.trim().length > 0) + .slice(-n); +} diff --git a/src/lib/dev-process.ts b/src/lib/dev-process.ts index 34e27ee..89c361d 100644 --- a/src/lib/dev-process.ts +++ b/src/lib/dev-process.ts @@ -99,9 +99,23 @@ export interface SpawnOptions { * - the cascade tries a high limit first, falling back on machines with a * lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort. * - * Windows gets the command directly. There is no `/bin/sh` to run the wrapper — - * spawning it fails outright — and no RLIMIT_NOFILE for `ulimit` to raise, so - * the wrapper has nothing to offer there even in principle. + * Windows has no `/bin/sh` and no RLIMIT_NOFILE, but it needs a wrapper for a + * different reason: **without one the log stays empty.** `detached` starts the + * child without a console. When that child is `cmd.exe` (cross-spawn runs every + * `.cmd` shim, `pnpm.cmd` included, through it), the console program below it + * gets a NEW console, and Windows then replaces the standard handles that + * `cmd.exe` merely inherited, our log file among them. The output lands in an + * extra console window instead, and `.lt-dev/*.log` stays at 0 bytes while the + * app runs. Measured on windows-latest (fake `pnpm.cmd` → node → node grandchild): + * 0 bytes this way, and 0 bytes even when `cmd.exe` redirects with `>>` itself. + * On the laptop an API died on start and its error sat unseen in that window. + * + * The fix is a small Node process in between (`windowsTrampoline`). libuv hands + * it the file explicitly, and it starts the real command with `stdio: 'inherit'`, + * which passes the handles explicitly again, so `cmd.exe` gets a console of its + * own and its children inherit both. Measured the same way: 94 child + 46 + * grandchild lines in the file. The recorded pid is the trampoline's, so + * `taskkill /T` from it reaches the whole tree. * * Note for the POSIX path: because `spawn('/bin/sh', …)` almost always succeeds, * a bogus `cmd` does not surface as `pid === undefined` — the inner `exec` fails @@ -114,8 +128,9 @@ export function detachedSpawnCommand( cmd: string, args: string[], platform: NodeJS.Platform = process.platform, + nodePath: string = process.execPath, ): { args: string[]; command: string } { - if (platform === 'win32') return { args, command: cmd }; + if (platform === 'win32') return { args: ['-e', windowsTrampoline(), '--', cmd, ...args], command: nodePath }; const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true'; return { args: ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], command: '/bin/sh' }; } @@ -346,6 +361,8 @@ export function spawnDetached( detached: true, env: opts.env, stdio: ['ignore', out, out], + // Windows only: keep the trampoline's console out of sight. No effect on POSIX. + windowsHide: true, }); // spawn reports "could not start this at all" (missing executable, bad cwd) // through an ASYNCHRONOUS 'error' event — the try/catch around spawn() never @@ -464,6 +481,23 @@ export function waitForHttp( }); } +/** + * Source of the Windows trampoline (see `detachedSpawnCommand`), run as `node -e`. + * cross-spawn is required by absolute path because `-e` resolves modules from the + * child's cwd, i.e. the user's project, not from the CLI. It exits with the + * command's code, so a dead command still reads as a dead pid. + */ +export function windowsTrampoline(): string { + const crossSpawnPath = JSON.stringify(require.resolve('cross-spawn')); + return [ + `const spawn = require(${crossSpawnPath});`, + 'const [cmd, ...args] = process.argv.slice(1);', + "const child = spawn(cmd, args, { stdio: 'inherit', windowsHide: true });", + "child.on('error', (e) => { process.stderr.write('lt dev: could not start ' + cmd + ': ' + e.message + '\\n'); process.exit(127); });", + "child.on('exit', (code) => process.exit(code === null ? 1 : code));", + ].join(' '); // one line: a newline inside a Windows command-line argument is asking for trouble +} + /** stdout of a command, or null when it could not run. */ function captureStdout(command: string, args: string[]): Promise { return new Promise((resolve) => {