Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uid>`. 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 `<root>/.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 `<root>/.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 `<slug>-test.localhost` / `api.<slug>-test.localhost`, own port band 4500+, own Caddy block `lt-dev:<slug>-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`. |
Expand Down
63 changes: 63 additions & 0 deletions __tests__/dev-log-capture.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
84 changes: 84 additions & 0 deletions __tests__/dev-log-tail.test.ts
Original file line number Diff line number Diff line change
@@ -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\(/);
});
});
62 changes: 50 additions & 12 deletions __tests__/dev-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
});

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading