From 0ff5f4d642f4bd7229b184ef0e7f3717e880cf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Ko=CC=88nig?= Date: Wed, 23 Sep 2026 11:17:37 +0200 Subject: [PATCH] feat(dev): start Caddy on Windows, and never touch a Caddy lt dev did not start (AP-7) Windows had no way to run Caddy: `lt dev install` said "not supported", and every other command then advised running `lt dev install`. Decision D1: no Windows service (it would run as SYSTEM with a CA the user's browser does not trust). Instead `lt dev install` and `lt dev up` start Caddy on demand: `caddy run --config ` via `spawnDetached`, logging to ~/.lenneTech/caddy.log. Not `caddy start`, which hands the background child the caller's stdio. Ownership rule, on every platform: someone who runs Caddy runs it for something. Reloading our config into it would silently reroute or cut off those sites. `detectCaddyOwner` decides by the loaded config, not by the process: - `ours` when an inert `log lt-dev-owner { output discard }` global option is loaded. Comments do not survive adaptation, so the block markers cannot serve as the marker. - `ours` for pre-marker instances when the loaded config equals `caddy adapt` of our file. Callers therefore ask before they rewrite it. - `foreign` for anything else answering on :2019, `none` when nothing answers. Measured on this Mac: the live pre-marker instance reads as ours, a second instance with another config as foreign. `ensureOwnCaddy` is the one gate for up, test, tunnel and the test session. A foreign Caddy gets our Caddyfile path plus `caddy reload` / `caddy run`, never a stop or a reload. down, test teardown, install and uninstall ask first too. doctor and status state "not started by lt dev" without advice. Also fixed: `lt dev install` rewrote the Caddyfile stub on every run, dropping the block of every project that was up. It now only adds the owner marker. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +- __tests__/caddy-owner.test.ts | 260 ++++++++++++++++++++++++++++++++++ docs/commands.md | 19 +-- src/commands/dev/doctor.ts | 25 ++-- src/commands/dev/down.ts | 12 +- src/commands/dev/init.ts | 4 +- src/commands/dev/install.ts | 8 +- src/commands/dev/status.ts | 15 +- src/commands/dev/test.ts | 16 +-- src/commands/dev/tunnel.ts | 16 ++- src/commands/dev/uninstall.ts | 43 ++++-- src/commands/dev/up.ts | 23 +-- src/lib/caddy.ts | 201 +++++++++++++++++++++++++- src/lib/dev-bootstrap.ts | 13 +- src/lib/dev-caddy-gate.ts | 75 ++++++++++ src/lib/dev-install-helper.ts | 97 +++++++++++-- src/lib/dev-service.ts | 58 +++++++- src/lib/dev-test-session.ts | 11 +- 18 files changed, 809 insertions(+), 91 deletions(-) create mode 100644 __tests__/caddy-owner.test.ts create mode 100644 src/lib/dev-caddy-gate.ts diff --git a/CLAUDE.md b/CLAUDE.md index 69fa4d2..16d9e89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,9 +243,9 @@ ports. Developers and Claude Code never see the internal ports. | Identity (slug + subdomains) | `src/lib/dev-identity.ts` | `projectSlug` reads `package.json` "name" (scope stripped, slugified); `buildIdentity` enumerates `projects/api`/`projects/app` (monorepo) or detects `config.env.ts`/`nuxt.config.ts` (standalone). | | ENV builder | `src/lib/dev-env.ts` | Single source of truth for `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_*`, `NUXT_SESSION_PASSWORD`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Always URLs, never bare ports.** `NUXT_PUBLIC_API_PROXY=false` because Caddy + cookie-domain make vite-proxy obsolete. `NUXT_SESSION_PASSWORD` resolves shell export → the app's own `.env` (via the optional `appDir` input) → a fallback derived as `HMAC-SHA256(machine salt, 'lt-dev:session:')`, 32 hex chars; the salt lives at `~/.lenneTech/dev-session-salt` (0600, override `LT_DEV_SESSION_SALT_PATH`). | | Registry + session state | `src/lib/dev-state.ts` | Central registry `~/.lenneTech/projects.json` (override via `LT_DEV_REGISTRY_PATH`); per-project session at `/.lt-dev/state.json`. Atomic writes; PID validation gate via `isValidPid` / `isPidAlive`. | -| Caddy integration | `src/lib/caddy.ts` | One block per project, marked with `# >>> lt-dev: >>>`/`# <<<`. `upsertProjectBlock` is idempotent; `removeProjectBlock` is a no-op when absent. Caddyfile path overridable via `LT_DEV_CADDYFILE`. The daemon is owned by `lt dev install` (see `dev-service.ts`) — **never** rely on `brew services caddy`: its plist hardcodes `--config /opt/homebrew/etc/Caddyfile` and crash-loops against our location, which is the bug that originally blocked the first real `lt dev install`. | +| Caddy integration | `src/lib/caddy.ts` | One block per project, marked with `# >>> lt-dev: >>>`/`# <<<`. `upsertProjectBlock` is idempotent; `removeProjectBlock` is a no-op when absent. Caddyfile path overridable via `LT_DEV_CADDYFILE`. The daemon is owned by `lt dev install` (see `dev-service.ts`) — **never** rely on `brew services caddy`: its plist hardcodes `--config /opt/homebrew/etc/Caddyfile` and crash-loops against our location, which is the bug that originally blocked the first real `lt dev install`. **Never touches a Caddy it did not start:** `detectCaddyOwner` decides by the LOADED config, never by process (a command-line check would be untested on every platform but one). It returns `ours` when the inert `log lt-dev-owner { output discard }` global option is loaded (comments do not survive adaptation, so the block markers cannot serve), or, for pre-marker instances, when the loaded config equals `caddy adapt` of our file; that is why callers ask BEFORE rewriting the Caddyfile. It returns `foreign` for anything else that answers on :2019, and `none` when nothing answers. Every command goes through `dev-caddy-gate.ts#ensureOwnCaddy`. A foreign Caddy gets `foreignCaddyLines` (our path + `caddy reload`/`caddy run`), never a stop or a reload. `__tests__/caddy-owner.test.ts` fails when a file calls `reloadCaddy`/`stopCaddy` without asking. | | 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. | +| Caddy service lifecycle | `src/lib/dev-service.ts` + `src/commands/dev/{install,uninstall}.ts` | Per-OS service runner, chosen by `caddyLaunchMode()`: `service` (macOS/Linux), `on-demand` (Windows: `startCaddyOnDemand` runs `caddy run` via `spawnDetached`, logging to `~/.lenneTech/caddy.log`; not `caddy start`, which hands the child our stdio; decision D1: a Windows service would run as SYSTEM with a CA the user's browser does not trust), `manual` elsewhere. 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). | | 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`. | diff --git a/__tests__/caddy-owner.test.ts b/__tests__/caddy-owner.test.ts new file mode 100644 index 0000000..5c2a545 --- /dev/null +++ b/__tests__/caddy-owner.test.ts @@ -0,0 +1,260 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Our Caddyfile lives in a temp dir: nothing here may touch ~/.lenneTech or the +// Caddy daemon on :2019. Every owner check gets its answers injected. +const tmp = mkdtempSync(join(tmpdir(), 'lt-caddy-owner-')); +process.env.LT_DEV_CADDYFILE = join(tmp, 'Caddyfile'); + +import { + detectCaddyOwner, + ensureCaddyfile, + ensureOwnerMarker, + foreignCaddyLines, + OWNER_LOGGER, + readCaddyfile, + upsertProjectBlock, +} from '../src/lib/caddy'; +import { ensureOwnCaddy } from '../src/lib/dev-caddy-gate'; +import { caddyLaunchMode, startCaddyOnDemand } from '../src/lib/dev-service'; + +afterAll(() => rmSync(tmp, { force: true, recursive: true })); + +const marked = JSON.stringify({ logging: { logs: { [OWNER_LOGGER]: { writer: { output: 'discard' } } } } }); +const answer = (body: string, status = 200) => async () => ({ body, status }); + +describe('detectCaddyOwner — by what is loaded, never by which process', () => { + const noAdapt = async () => { + throw new Error('adapt must not run when the marker already decides'); + }; + + it('none when nothing answers on :2019', async () => { + await expect(detectCaddyOwner({ adaptOwn: noAdapt, fetchConfig: async () => null })).resolves.toBe('none'); + }); + + it('ours when the loaded config carries the owner marker', async () => { + await expect(detectCaddyOwner({ adaptOwn: noAdapt, fetchConfig: answer(marked) })).resolves.toBe('ours'); + }); + + it('foreign when something answers with an error status or no JSON', async () => { + // "nothing readable" is not "nothing there": something holds the port. + const adaptOwn = async () => ({}); + await expect(detectCaddyOwner({ adaptOwn, fetchConfig: answer('forbidden', 403) })).resolves.toBe('foreign'); + await expect(detectCaddyOwner({ adaptOwn, fetchConfig: answer('') })).resolves.toBe('foreign'); + }); + + it('ours without marker when the loaded config IS our adapted Caddyfile (pre-marker installs)', async () => { + const loaded = JSON.stringify({ apps: { http: { servers: { srv0: { listen: [':443'] } } } } }); + // Same content, different key order: still the same config. + const own = { apps: { http: { servers: { srv0: { listen: [':443'] } } } } }; + await expect(detectCaddyOwner({ adaptOwn: async () => own, fetchConfig: answer(loaded) })).resolves.toBe('ours'); + }); + + it('foreign without marker when the loaded config differs from ours', async () => { + const loaded = JSON.stringify({ apps: { http: { servers: { client: { listen: [':443'] } } } } }); + await expect(detectCaddyOwner({ adaptOwn: async () => ({}), fetchConfig: answer(loaded) })).resolves.toBe('foreign'); + }); + + it('foreign when our Caddyfile cannot be adapted — no proof, no reload', async () => { + await expect( + detectCaddyOwner({ adaptOwn: async () => undefined, fetchConfig: answer('{"apps":{}}') }), + ).resolves.toBe('foreign'); + }); +}); + +describe('owner marker in the Caddyfile', () => { + it('goes after the leading comments, as the global options block', () => { + const out = ensureOwnerMarker('# header\n\nfoo.localhost {\n}\n'); + expect(out).toBe(`# header\n\n{\n\tlog ${OWNER_LOGGER} {\n\t\toutput discard\n\t}\n}\n\nfoo.localhost {\n}\n`); + }); + + it('goes INTO an existing global block — a second one would be invalid', () => { + const out = ensureOwnerMarker('# h\n{\n\temail a@b.c\n}\n\nfoo.localhost {\n}\n'); + expect(out.match(/^\{$/gm)).toHaveLength(1); + expect(out).toContain(`{\n\tlog ${OWNER_LOGGER} {\n\t\toutput discard\n\t}\n\temail a@b.c\n}`); + }); + + it('is idempotent', () => { + const once = ensureOwnerMarker('# h\n'); + expect(ensureOwnerMarker(once)).toBe(once); + }); + + it('every write carries it exactly once', () => { + upsertProjectBlock('demo', [{ hostname: 'demo.localhost', upstreamPort: 4000 }]); + upsertProjectBlock('other', [{ hostname: 'other.localhost', upstreamPort: 4001 }]); + expect(readCaddyfile().split(`log ${OWNER_LOGGER}`)).toHaveLength(2); + }); + + it('ensureCaddyfile keeps existing project blocks (install used to reset the file)', () => { + writeFileSync(process.env.LT_DEV_CADDYFILE!, '# old\n# >>> lt-dev:demo >>>\ndemo.localhost {\n}\n# <<< lt-dev:demo <<<\n'); + ensureCaddyfile(); + const after = readFileSync(process.env.LT_DEV_CADDYFILE!, 'utf8'); + expect(after).toContain('# >>> lt-dev:demo >>>'); + expect(after).toContain(`log ${OWNER_LOGGER}`); + }); +}); + +describe('foreignCaddyLines', () => { + it('names our Caddyfile and how to load or run it', () => { + const text = foreignCaddyLines('/x/Caddyfile').join('\n'); + expect(text).toContain('caddy reload --config "/x/Caddyfile" --adapter caddyfile'); + expect(text).toContain('caddy run --config "/x/Caddyfile" --adapter caddyfile'); + }); + + it('never tells anyone to stop or kill their Caddy', () => { + expect(foreignCaddyLines('/x').join('\n')).not.toMatch(/\bstop\b|kill|Stop-Process|taskkill/i); + }); +}); + +describe('ensureOwnCaddy — the one gate every command uses', () => { + const base = { available: async () => true, prepareCaddyfile: () => undefined }; + const never = async () => { + throw new Error('must not start Caddy here'); + }; + + it('ours → ok, nothing started', async () => { + const gate = await ensureOwnCaddy({ startIfDown: true }, { ...base, detectOwner: async () => 'ours', start: never }); + expect(gate).toMatchObject({ ok: true, started: false }); + }); + + it('foreign → refused with the diagnosis, and nothing is started over it', async () => { + const gate = await ensureOwnCaddy( + { startIfDown: true }, + { ...base, detectOwner: async () => 'foreign', launchMode: 'on-demand', start: never }, + ); + expect(gate).toMatchObject({ ok: false, reason: 'foreign' }); + expect(gate.lines.join('\n')).toContain('caddy reload --config'); + }); + + it('none on Windows → starts ours, and only accepts it once it identifies as ours', async () => { + const owners = ['none', 'ours'] as const; + let i = 0; + let started = 0; + const gate = await ensureOwnCaddy( + { startIfDown: true }, + { + ...base, + detectOwner: async () => owners[i++], + launchMode: 'on-demand', + start: async () => { + started++; + return { logFile: 'l', message: 'm', ok: true }; + }, + }, + ); + expect(gate).toMatchObject({ ok: true, started: true }); + expect(started).toBe(1); + }); + + it('none on Windows, but something foreign took :2019 meanwhile → refused', async () => { + const owners = ['none', 'foreign'] as const; + let i = 0; + const gate = await ensureOwnCaddy( + { startIfDown: true }, + { + ...base, + detectOwner: async () => owners[i++], + launchMode: 'on-demand', + start: async () => ({ logFile: 'l', message: 'm', ok: true }), + }, + ); + expect(gate).toMatchObject({ ok: false, reason: 'foreign' }); + }); + + it('none on Windows without startIfDown → down, pointing at `lt dev up`', async () => { + const gate = await ensureOwnCaddy( + { startIfDown: false }, + { ...base, detectOwner: async () => 'none', launchMode: 'on-demand', start: never }, + ); + expect(gate).toMatchObject({ ok: false, reason: 'down' }); + expect(gate.lines[0]).toContain('lt dev up'); + }); + + it('none with a service (macOS/Linux) → never starts it itself, points at `lt dev install`', async () => { + const gate = await ensureOwnCaddy( + { startIfDown: true }, + { ...base, detectOwner: async () => 'none', launchMode: 'service', start: never }, + ); + expect(gate).toMatchObject({ ok: false, reason: 'down' }); + expect(gate.lines[0]).toContain('lt dev install'); + }); + + it('a failed start is a failure, with the log', async () => { + const gate = await ensureOwnCaddy( + { startIfDown: true }, + { + ...base, + detectOwner: async () => 'none', + launchMode: 'on-demand', + start: async () => ({ logFile: '/log', message: 'boom', ok: false }), + }, + ); + expect(gate).toMatchObject({ ok: false, reason: 'start-failed' }); + expect(gate.lines.join('\n')).toContain('/log'); + }); +}); + +describe('Caddy launch on each platform', () => { + it('service on macOS/Linux, on demand on Windows, manual elsewhere', () => { + expect(caddyLaunchMode('darwin')).toBe('service'); + expect(caddyLaunchMode('linux')).toBe('service'); + expect(caddyLaunchMode('win32')).toBe('on-demand'); + expect(caddyLaunchMode('freebsd')).toBe('manual'); + }); + + it('startCaddyOnDemand runs `caddy run` with OUR Caddyfile, logging to a file', async () => { + const calls: { args: string[]; cmd: string; logFile: string }[] = []; + const result = await startCaddyOnDemand({ + resolveBin: async () => 'C:\\caddy\\caddy.exe', + spawn: (cmd, args, opts) => { + calls.push({ args, cmd, logFile: opts.logFile }); + return { pid: 4242, rotated: { rotated: false } }; + }, + waitReady: async () => true, + }); + expect(result.ok).toBe(true); + expect(calls).toEqual([ + { + args: ['run', '--config', process.env.LT_DEV_CADDYFILE, '--adapter', 'caddyfile'], + cmd: 'C:\\caddy\\caddy.exe', + logFile: expect.stringMatching(/caddy\.log$/), + }, + ]); + }); + + it('startCaddyOnDemand reports a Caddy that never answers as a failure', async () => { + const result = await startCaddyOnDemand({ + resolveBin: async () => 'caddy', + spawn: () => ({ pid: 1, rotated: { rotated: false } }), + waitReady: async () => false, + }); + expect(result.ok).toBe(false); + }); +}); + +describe('every write to the running Caddy is preceded by an owner check', () => { + // `reloadCaddy` replaces the running Caddy's whole config and `stopCaddy` ends + // it. A new call site that forgets to ask first would reload our config into a + // client project's Caddy. This does not prove the order inside a file; it + // catches the call site that never asks at all. + it('each file calling reloadCaddy/stopCaddy also asks detectCaddyOwner or ensureOwnCaddy', () => { + const { readdirSync } = require('fs'); + const offenders: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'templates') walk(path); + } else if (path.endsWith('.ts') && !path.endsWith(join('lib', 'caddy.ts'))) { + const src = readFileSync(path, 'utf8'); + if (/\b(reloadCaddy|stopCaddy)\(/.test(src) && !/\b(detectCaddyOwner|ensureOwnCaddy)\(/.test(src)) { + offenders.push(path); + } + } + } + }; + walk(join(__dirname, '..', 'src')); + expect(offenders).toEqual([]); + }); +}); diff --git a/docs/commands.md b/docs/commands.md index 0a115c2..16cf05e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -347,7 +347,9 @@ lt dev ### `lt dev install` -One-time per-machine setup. Idempotent — re-run anytime to diagnose what's missing. Owns the full Caddy lifecycle via a dedicated LaunchAgent (macOS) / systemd-user unit (Linux) — **does not** use `brew services caddy`, whose hardcoded `/opt/homebrew/etc/Caddyfile` path would crash-loop against our `~/.lenneTech/Caddyfile`. +One-time per-machine setup. Idempotent — re-run anytime to diagnose what's missing. Owns the full Caddy lifecycle via a dedicated LaunchAgent (macOS) / systemd-user unit (Linux) — **does not** use `brew services caddy`, whose hardcoded `/opt/homebrew/etc/Caddyfile` path would crash-loop against our `~/.lenneTech/Caddyfile`. On **Windows** there is no service: `lt dev install` and `lt dev up` start Caddy themselves as a background process when none is running. + +**A Caddy that `lt dev` did not start is never touched.** If something else already runs Caddy on `:2019`, every `lt dev` command that would reload it stops instead. It prints the path of our Caddyfile and the `caddy reload` / `caddy run` commands, and leaves the decision to you. `lt dev` recognises its own Caddy by an inert marker in the loaded config (a `log lt-dev-owner { output discard }` global option). An instance started before that marker existed is recognised because its loaded config equals our adapted Caddyfile. **Usage:** ```bash @@ -361,14 +363,15 @@ lt dev install --skip-init # do NOT auto-run `lt dev init` afterwards **What it does:** 1. Verifies `caddy` is on PATH (suggests `brew install caddy` if missing). -2. Creates `~/.lenneTech/Caddyfile` stub if absent. -3. Detects a conflicting `brew services caddy` registration and asks you to stop it. -4. Writes + bootstraps a dedicated service: +2. Refuses to go on if a foreign Caddy holds `:2019` (see above). +3. Creates `~/.lenneTech/Caddyfile` stub if absent. An existing file keeps its project blocks and only gets the owner marker. +4. Detects a conflicting `brew services caddy` registration and asks you to stop it. +5. Writes + bootstraps a dedicated service (Windows: starts Caddy in the background instead): - **macOS:** `~/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`. -5. Waits up to 8s for Caddy's admin endpoint (`http://127.0.0.1:2019/config/`) to respond. -6. Validates the Caddyfile. -7. Reminds you to run the CA trust command **with HOME preserved**: +6. Waits up to 8s for Caddy's admin endpoint (`http://127.0.0.1:2019/config/`) to respond. +7. Validates the Caddyfile. +8. Reminds you to run the CA trust command **with HOME preserved** (Windows: plain `caddy trust`, only if browsers still warn — Windows asks once on Caddy's first start, and `caddy trust` needs the running instance): ```bash sudo -E HOME="$HOME" caddy trust ``` @@ -380,7 +383,7 @@ lt dev install --skip-init # do NOT auto-run `lt dev init` afterwards ### `lt dev uninstall` -Symmetric counterpart to `lt dev install`. Removes the LaunchAgent / systemd-user unit and stops the Caddy daemon. Does **not** remove the caddy binary itself. +Symmetric counterpart to `lt dev install`. Removes the LaunchAgent / systemd-user unit and stops the Caddy daemon. On Windows it stops the Caddy that `lt dev` started, and only that one. Does **not** remove the caddy binary itself. **Usage:** ```bash diff --git a/src/commands/dev/doctor.ts b/src/commands/dev/doctor.ts index b894897..91dace7 100644 --- a/src/commands/dev/doctor.ts +++ b/src/commands/dev/doctor.ts @@ -2,10 +2,10 @@ import { spawn } from 'child_process'; import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { caddyAvailable, caddyDaemonRunning, paths as caddyPaths, validateCaddyfile } from '../../lib/caddy'; +import { caddyAvailable, paths as caddyPaths, detectCaddyOwner, validateCaddyfile } from '../../lib/caddy'; import { probePorts } from '../../lib/dev-process'; import { resolveLayout } from '../../lib/dev-project'; -import { getServicePaths, getServiceStatus, platformSupported } from '../../lib/dev-service'; +import { caddyLaunchMode, getServicePaths, getServiceStatus } from '../../lib/dev-service'; import { detectSlugConflict, loadRegistry, paths as statePaths } from '../../lib/dev-state'; import { checkGlobalSetupTicketSafe, resolveDevIdentity } from '../../lib/dev-ticket'; @@ -46,9 +46,11 @@ const DoctorCommand: GluegunCommand = { } // 2. Service installed (LaunchAgent / systemd-user) - const plat = platformSupported(); - if (plat === 'unsupported') { + const mode = caddyLaunchMode(); + if (mode === 'manual') { line('WARN', colors.yellow, `service management not supported on ${process.platform} — run caddy manually`); + } else if (mode === 'on-demand') { + line('INFO', colors.cyan, 'no service on this platform — `lt dev up` starts Caddy when it is not running'); } else { const svc = await getServiceStatus(); const servicePaths = getServicePaths(); @@ -63,11 +65,18 @@ const DoctorCommand: GluegunCommand = { } } - // 3. Caddy daemon admin endpoint + // 3. Caddy daemon admin endpoint — and whose it is. A foreign Caddy is + // stated, not acted on: no advice to stop it (see `caddy.ts`). It still + // counts as a failure, because `lt dev` cannot route through it. if (hasCaddy) { - const daemon = await caddyDaemonRunning(); - if (daemon) line('OK', colors.green, 'caddy admin (:2019) reachable'); - else { + const owner = await detectCaddyOwner(); + if (owner === 'ours') line('OK', colors.green, 'caddy admin (:2019) reachable, started by lt dev'); + else if (owner === 'foreign') { + line('FAIL', colors.red, 'caddy on :2019 was not started by lt dev — lt dev neither uses nor changes it'); + fails++; + } else if (mode === 'on-demand') { + line('INFO', colors.cyan, 'caddy not running — `lt dev up` starts it'); + } else { line('FAIL', colors.red, 'caddy admin (:2019) unreachable — run `lt dev install`'); fails++; } diff --git a/src/commands/dev/down.ts b/src/commands/dev/down.ts index c670bc3..30f08a0 100644 --- a/src/commands/dev/down.ts +++ b/src/commands/dev/down.ts @@ -1,7 +1,7 @@ import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { reloadCaddy, removeProjectBlock } from '../../lib/caddy'; +import { detectCaddyOwner, foreignCaddyLines, reloadCaddy, removeProjectBlock } from '../../lib/caddy'; import { clearEnvBridge } from '../../lib/dev-env-bridge'; import { killProcessGroup } from '../../lib/dev-process'; import { resolveLayout } from '../../lib/dev-project'; @@ -63,11 +63,19 @@ const DownCommand: GluegunCommand = { ); info(colors.dim(` ${conflict.otherPath}`)); } else { + // Ownership first: it is proven by comparing the loaded config with the + // Caddyfile, so it must be asked before the file changes. + const owner = await detectCaddyOwner(); const removed = removeProjectBlock(identity.slug); - if (removed) { + if (removed && owner === 'ours') { const r = await reloadCaddy(); if (r.ok) success(`Removed Caddy block for "${identity.slug}".`); else warning(`Removed Caddy block but reload failed: ${r.stderr.split('\n')[0]}`); + } else if (removed && owner === 'foreign') { + warning(`Removed the block for "${identity.slug}" from lt dev's Caddyfile, but did not reload:`); + foreignCaddyLines().forEach((l) => info(` ${l}`)); + } else if (removed) { + info(colors.dim(`Removed Caddy block for "${identity.slug}" (Caddy not running; nothing to reload).`)); } } diff --git a/src/commands/dev/init.ts b/src/commands/dev/init.ts index 29d8ac7..345212b 100644 --- a/src/commands/dev/init.ts +++ b/src/commands/dev/init.ts @@ -6,7 +6,7 @@ import { isMachinePrepared, shouldRunInstallBeforeInit } from '../../lib/dev-boo import { runInstall } from '../../lib/dev-install-helper'; import { printMigrateResult, runMigrate } from '../../lib/dev-migrate-helper'; import { resolveLayout } from '../../lib/dev-project'; -import { platformSupported } from '../../lib/dev-service'; +import { caddyLaunchMode } from '../../lib/dev-service'; /** * Initialize an existing project for `lt dev` and apply idempotent @@ -58,7 +58,7 @@ const InitCommand: GluegunCommand = { // install command) makes infinite recursion structurally impossible. const runInstallFirst = shouldRunInstallBeforeInit({ machinePrepared: isMachinePrepared(), - platformSupported: platformSupported() !== 'unsupported', + platformSupported: caddyLaunchMode() !== 'manual', skipInstall: parameters.options.skipInstall === true, }); if (runInstallFirst) { diff --git a/src/commands/dev/install.ts b/src/commands/dev/install.ts index 42937c6..0fe04c2 100644 --- a/src/commands/dev/install.ts +++ b/src/commands/dev/install.ts @@ -23,7 +23,7 @@ import { resolveLayout } from '../../lib/dev-project'; */ const InstallCommand: GluegunCommand = { alias: ['i'], - description: 'Setup Caddy service for lt dev', + description: 'Setup Caddy for lt dev', hidden: false, name: 'install', run: async (toolbox: ExtendedGluegunToolbox) => { @@ -45,6 +45,12 @@ const InstallCommand: GluegunCommand = { if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev install: caddy missing'; } + // A foreign Caddy on :2019: re-running install cannot fix that, only the + // user's own decision can — the lines above name the options. + if (result.foreign) { + if (!parameters.options.fromGluegunMenu) process.exit(1); + return 'dev install: foreign caddy'; + } // Auto-chain: if we're inside an un-initialized lt-dev project, run the // project init now. Calling the runMigrate HELPER (not the init command) diff --git a/src/commands/dev/status.ts b/src/commands/dev/status.ts index 0ff4d7b..f1317be 100644 --- a/src/commands/dev/status.ts +++ b/src/commands/dev/status.ts @@ -1,9 +1,10 @@ import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { caddyAvailable, caddyDaemonRunning } from '../../lib/caddy'; +import { caddyAvailable, detectCaddyOwner } from '../../lib/caddy'; import { probePorts } from '../../lib/dev-process'; import { apiNeedsPortPatch, appNeedsPortPatch, resolveLayout } from '../../lib/dev-project'; +import { caddyLaunchMode } from '../../lib/dev-service'; import { classifyComponentHealth, type ComponentHealth, @@ -164,12 +165,18 @@ const StatusCommand: GluegunCommand = { // Caddy status — quick view whether the daemon is reachable. { const caddyOk = await caddyAvailable(); - const daemonOk = caddyOk ? await caddyDaemonRunning() : false; + const owner = caddyOk ? await detectCaddyOwner() : 'none'; info(''); if (!caddyOk) { warning(' Caddy not installed — run `lt dev install` first.'); - } else if (!daemonOk) { - warning(' Caddy daemon not running — run `lt dev install` to (re)start the lt-dev service.'); + } else if (owner === 'foreign') { + warning(' Caddy on :2019 was not started by lt dev — `lt dev up` will not change it.'); + } else if (owner === 'none') { + warning( + caddyLaunchMode() === 'on-demand' + ? ' Caddy not running — `lt dev up` starts it.' + : ' Caddy daemon not running — run `lt dev install` to (re)start the lt-dev service.', + ); } else { info(colors.dim(' Caddy: ready')); } diff --git a/src/commands/dev/test.ts b/src/commands/dev/test.ts index 113ebd1..40453f7 100644 --- a/src/commands/dev/test.ts +++ b/src/commands/dev/test.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'fs'; import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { caddyAvailable, caddyDaemonRunning } from '../../lib/caddy'; +import { ensureOwnCaddy } from '../../lib/dev-caddy-gate'; import { envBridgePath } from '../../lib/dev-env-bridge'; import { pickPackageManager } from '../../lib/dev-package-manager'; import { runChildInherit } from '../../lib/dev-process'; @@ -124,15 +124,13 @@ const TestCommand: GluegunCommand = { if (!parameters.options.fromGluegunMenu) process.exit(1); return 'dev test: no app'; } - if (!(await caddyAvailable())) { - error('caddy is not installed. Run `lt dev install` first.'); + const caddyGate = await ensureOwnCaddy({ startIfDown: true }); + if (!caddyGate.ok) { + const [first, ...rest] = caddyGate.lines; + error(first); + rest.forEach((l) => info(l)); if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev test: caddy missing'; - } - if (!(await caddyDaemonRunning())) { - error('caddy daemon not running. Run `lt dev install` first.'); - if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev test: caddy daemon down'; + return `dev test: caddy ${caddyGate.reason}`; } // Pre-flight (#3): if the project's playwright.config is not env-aware diff --git a/src/commands/dev/tunnel.ts b/src/commands/dev/tunnel.ts index 3d59e8f..d9d5268 100644 --- a/src/commands/dev/tunnel.ts +++ b/src/commands/dev/tunnel.ts @@ -1,8 +1,8 @@ import { GluegunCommand } from 'gluegun'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { caddyDaemonRunning } from '../../lib/caddy'; import { cloudflaredAvailable, spawnQuickTunnel } from '../../lib/cloudflared'; +import { ensureOwnCaddy } from '../../lib/dev-caddy-gate'; import { buildIdentity } from '../../lib/dev-identity'; import { resolveLayout } from '../../lib/dev-project'; import { loadRegistry } from '../../lib/dev-state'; @@ -72,12 +72,16 @@ const TunnelCommand: GluegunCommand = { return 'dev tunnel: cloudflared missing'; } - // Pre-flight: Caddy must be up — without it cloudflared would forward - // to a dead upstream and the public URL would 502. - if (!(await caddyDaemonRunning())) { - error('Caddy daemon is not running — run `lt dev install` first.'); + // Pre-flight: OUR Caddy must be up — without it cloudflared would forward + // to a dead upstream, and a foreign Caddy has none of our vhosts; either way + // the public URL would 502. Not started here: the routes come from `lt dev up`. + const caddyGate = await ensureOwnCaddy({ startIfDown: false }); + if (!caddyGate.ok) { + const [first, ...rest] = caddyGate.lines; + error(first); + rest.forEach((l) => info(l)); if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev tunnel: caddy down'; + return `dev tunnel: caddy ${caddyGate.reason}`; } const registry = loadRegistry(); if (!registry.projects[identity.slug]) { diff --git a/src/commands/dev/uninstall.ts b/src/commands/dev/uninstall.ts index 99e3b62..0c2ba1e 100644 --- a/src/commands/dev/uninstall.ts +++ b/src/commands/dev/uninstall.ts @@ -3,8 +3,8 @@ import { GluegunCommand } from 'gluegun'; import { join } from 'path'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { paths as caddyPaths } from '../../lib/caddy'; -import { getServicePaths, platformSupported, uninstallService } from '../../lib/dev-service'; +import { paths as caddyPaths, detectCaddyOwner, foreignCaddyLines, stopCaddy } from '../../lib/caddy'; +import { caddyLaunchMode, getServicePaths, uninstallService } from '../../lib/dev-service'; /** * Symmetric counterpart to `lt dev install`. @@ -39,25 +39,40 @@ const UninstallCommand: GluegunCommand = { info(colors.bold('lt dev uninstall — remove the lt-dev Caddy service')); info(colors.dim('─'.repeat(60))); - const plat = platformSupported(); - if (plat === 'unsupported') { + const mode = caddyLaunchMode(); + if (mode === 'manual') { info('No managed service to remove on this platform.'); if (!parameters.options.fromGluegunMenu) process.exit(0); return 'dev uninstall: nothing to do'; } const paths = getServicePaths(); - const result = await uninstallService(); - if (!result.ok) { - error(result.message); - if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev uninstall: failed'; - } - - if (result.removed.length === 0) { - info(colors.dim('Service was not installed.')); + if (mode === 'on-demand') { + // No service to remove: `lt dev` started Caddy itself, so it stops it + // itself — but only its own. A foreign Caddy is reported, not stopped. + const owner = await detectCaddyOwner(); + if (owner === 'ours') { + const stopped = await stopCaddy(); + if (stopped.ok) success('Stopped the lt-dev Caddy.'); + else warning(`Could not stop the lt-dev Caddy: ${stopped.stderr.split('\n')[0]}`); + } else if (owner === 'foreign') { + foreignCaddyLines().forEach((l) => info(l)); + } else { + info(colors.dim('The lt-dev Caddy is not running.')); + } } else { - success(`Removed: ${result.removed.join(', ')}`); + const result = await uninstallService(); + if (!result.ok) { + error(result.message); + if (!parameters.options.fromGluegunMenu) process.exit(1); + return 'dev uninstall: failed'; + } + + if (result.removed.length === 0) { + info(colors.dim('Service was not installed.')); + } else { + success(`Removed: ${result.removed.join(', ')}`); + } } // Optional purge of related state files. diff --git a/src/commands/dev/up.ts b/src/commands/dev/up.ts index f46b253..ac94aed 100644 --- a/src/commands/dev/up.ts +++ b/src/commands/dev/up.ts @@ -3,9 +3,10 @@ import { GluegunCommand } from 'gluegun'; import { join } from 'path'; import { ExtendedGluegunToolbox } from '../../interfaces/extended-gluegun-toolbox'; -import { caddyAvailable, caddyDaemonRunning, CaddyRoute, reloadCaddy, upsertProjectBlock } from '../../lib/caddy'; +import { CaddyRoute, reloadCaddy, upsertProjectBlock } from '../../lib/caddy'; import { CommandHelp } from '../../lib/command-help'; import { isApiCompiledRequested, startCompiledApi } from '../../lib/dev-api-launch'; +import { ensureOwnCaddy } from '../../lib/dev-caddy-gate'; import { buildDevEnv } from '../../lib/dev-env'; import { writeEnvBridge } from '../../lib/dev-env-bridge'; import { buildIdentity } from '../../lib/dev-identity'; @@ -115,17 +116,19 @@ const UpCommand: GluegunCommand = { return 'dev up: not a project'; } - // Pre-flight: Caddy - if (!(await caddyAvailable())) { - error('caddy is not installed. Run `lt dev install` first.'); + // Pre-flight: Caddy must be OURS before the Caddyfile is rewritten below — + // ownership of an instance started before the owner marker existed is proven + // by comparing its config with the file, so the check has to come first. + // On Windows this also starts Caddy when none runs. + const caddyGate = await ensureOwnCaddy({ startIfDown: true }); + if (!caddyGate.ok) { + const [first, ...rest] = caddyGate.lines; + error(first); + rest.forEach((l) => info(l)); if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev up: caddy missing'; - } - if (!(await caddyDaemonRunning())) { - error('caddy daemon is not running. Run `lt dev install` to start the lt-dev service.'); - if (!parameters.options.fromGluegunMenu) process.exit(1); - return 'dev up: caddy daemon down'; + return `dev up: caddy ${caddyGate.reason}`; } + if (caddyGate.started) success('Started Caddy.'); // Ticket-aware: in a `lt ticket` worktree (tagged by a `.lt-dev/ticket` // marker) — or with an explicit `--ticket ` — the slug / URLs / DB are diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 1510fb1..905b479 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -14,8 +14,16 @@ * * Lifecycle is owned by `lt dev install` (one-time setup) and * `lt dev up`/`lt dev down` (per-project block management). + * + * **`lt dev` never touches a Caddy it did not start.** Someone who runs Caddy + * runs it for something, possibly a client project on :443. Reloading our config + * into it would silently reroute or cut off those sites, and the person affected + * would look for the fault everywhere but here. So every write to the running + * instance goes through `detectCaddyOwner` first, and a foreign instance gets a + * diagnosis plus the commands to act on it (`foreignCaddyLines`), never an action. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { request as httpRequest } from 'http'; import { homedir } from 'os'; import { dirname, join } from 'path'; @@ -31,6 +39,30 @@ export interface CaddyRoute { const CADDYFILE_PATH = process.env.LT_DEV_CADDYFILE || join(homedir(), '.lenneTech', 'Caddyfile'); const HEADER = '# Managed by `lt dev`. Per-project blocks are bounded by `# >>> lt-dev: >>>` markers.'; +/** + * Name of the logger that marks a running Caddy as ours. + * + * Comments do not survive: Caddy adapts the Caddyfile to JSON and drops them, so + * the `# >>> lt-dev: >>>` markers never reach `GET /config/`. A named + * logger does, even in a Caddyfile without a single site, and `output discard` + * makes it inert: the default logger keeps logging exactly as before (measured + * with caddy v2.11.3). An explicit marker, rather than recognising our site + * blocks, so that "recognises itself" does not depend on how blocks are named. + */ +export const OWNER_LOGGER = 'lt-dev-owner'; +const OWNER_DIRECTIVE = [`\tlog ${OWNER_LOGGER} {`, '\t\toutput discard', '\t}']; + +/** Who holds the Caddy admin endpoint on :2019. */ +export type CaddyOwner = 'foreign' | 'none' | 'ours'; + +/** Injection points for `detectCaddyOwner`, so every branch is testable without a daemon. */ +export interface CaddyOwnerDeps { + /** `caddy adapt` of our Caddyfile as parsed JSON, or undefined when it cannot be adapted. */ + adaptOwn?: () => Promise; + /** Answer to `GET /config/`, or null when nothing answers at all. */ + fetchConfig?: () => Promise; +} + /** Outcome of a Caddy command. */ export interface CaddyResult { exitCode: null | number; @@ -39,6 +71,12 @@ export interface CaddyResult { stdout: string; } +/** Status and body of an HTTP answer. */ +export interface HttpAnswer { + body: string; + status: number; +} + /** Detect whether `caddy` is on PATH. */ export async function caddyAvailable(): Promise { const result = await runCaddy(['version']); @@ -62,6 +100,86 @@ export async function caddyDaemonRunning(): Promise { return (await httpStatus('http://127.0.0.1:2019/config/', 2000)) !== null; } +/** + * Decide whether the running Caddy is ours: by what it has LOADED, never by which + * process it is. A process check would need the command line, which is read + * differently on every platform, so on each of them one branch would go untested. + * + * - Nothing answers on :2019 → `none`. + * - Something answers, but not with a readable config (an error status, not + * JSON) → `foreign`. Something holds the port, and it is not us. + * - The loaded config carries the `OWNER_LOGGER` marker → `ours`. + * - No marker, but the loaded config is exactly what our Caddyfile adapts to → + * `ours`. This is how instances started before the marker existed are + * recognised; their next reload then carries the marker. Callers must + * therefore ask BEFORE they rewrite the Caddyfile. It also matches a Caddy that + * has loaded nothing while our file is still an empty stub; replacing an empty + * config cannot disturb anything that is being served. + * - Anything else → `foreign`. + */ +export async function detectCaddyOwner(deps: CaddyOwnerDeps = {}): Promise { + const fetchConfig = deps.fetchConfig ?? (() => httpGetText('http://127.0.0.1:2019/config/', 2000)); + const answer = await fetchConfig(); + if (answer === null) return 'none'; + const loaded = answer.status >= 200 && answer.status < 300 ? parseJsonOrUndefined(answer.body) : undefined; + if (loaded === undefined) return 'foreign'; + if (hasOwnerMarker(loaded)) return 'ours'; + const adaptOwn = deps.adaptOwn ?? adaptOwnCaddyfile; + const own = await adaptOwn(); + if (own === undefined) return 'foreign'; + return canonicalJson(loaded ?? {}) === canonicalJson(own ?? {}) ? 'ours' : 'foreign'; +} + +/** + * Make sure our Caddyfile exists and carries the owner marker, without touching + * its project blocks. `lt dev install` used to write the empty stub on every run, + * which dropped the block of every project that was up at the time. + */ +export function ensureCaddyfile(): void { + if (!existsSync(CADDYFILE_PATH)) { + writeCaddyfile('# lt dev — managed Caddyfile\n# Add per-project blocks via `lt dev up`.\n'); + return; + } + const current = readCaddyfile(); + if (ensureOwnerMarker(current) !== current) writeCaddyfile(current); +} + +/** + * Add the owner marker to a Caddyfile, idempotently. A global options block must + * come first in a Caddyfile, so the marker goes into an existing one or becomes + * a new one right after the leading comments. + */ +export function ensureOwnerMarker(content: string): string { + if (new RegExp(`^\\s*log\\s+${OWNER_LOGGER}\\b`, 'm').test(content)) return content; + const lines = content.split('\n'); + let first = 0; + while (first < lines.length && (lines[first].trim() === '' || lines[first].trim().startsWith('#'))) first++; + if (first < lines.length && lines[first].trim() === '{') { + lines.splice(first + 1, 0, ...OWNER_DIRECTIVE); + return lines.join('\n'); + } + const head = lines.slice(0, first).join('\n').replace(/\n+$/, ''); + const rest = lines.slice(first).join('\n').replace(/^\n+/, ''); + const block = ['{', ...OWNER_DIRECTIVE, '}'].join('\n'); + return [head, block, rest].filter((part) => part.length > 0).join('\n\n'); +} + +/** + * What to tell someone whose Caddy on :2019 is not ours. A diagnosis and the + * commands to act on it; deliberately no command that stops their Caddy. Whether + * to end it, adopt our config or ignore us is their decision. + */ +export function foreignCaddyLines(caddyfile: string = CADDYFILE_PATH): string[] { + return [ + 'A Caddy is running on :2019 that `lt dev` did not start. It was left untouched.', + `lt dev's config: ${caddyfile}`, + 'Load it into that Caddy (replaces that Caddy\'s whole configuration):', + ` caddy reload --config "${caddyfile}" --adapter caddyfile`, + 'Or, once :2019 is free, run lt dev\'s own Caddy:', + ` caddy run --config "${caddyfile}" --adapter caddyfile`, + ]; +} + /** Read the current Caddyfile (or empty string). */ export function readCaddyfile(): string { if (!existsSync(CADDYFILE_PATH)) return ''; @@ -69,8 +187,10 @@ export function readCaddyfile(): string { } /** - * Reload Caddy with the global Caddyfile. Caller is responsible for - * starting Caddy in the first place (typically via `lt dev install`). + * Reload Caddy with the global Caddyfile. This REPLACES whatever config the + * running Caddy holds, so callers must have established + * `detectCaddyOwner() === 'ours'` (or passed `ensureOwnCaddy`) first — asked + * before they rewrote the Caddyfile. Starting Caddy is not this function's job. */ export async function reloadCaddy(): Promise { return runCaddy(['reload', '--config', CADDYFILE_PATH, '--adapter', 'caddyfile']); @@ -120,6 +240,14 @@ export function renderProjectBlock(slug: string, routes: CaddyRoute[]): string { return lines.join('\n'); } +/** + * Stop the running Caddy through its admin API. Callers must have established + * `detectCaddyOwner() === 'ours'` first: this stops whatever answers on :2019. + */ +export async function stopCaddy(): Promise { + return runCaddy(['stop']); +} + /** * Insert/replace a project block in the Caddyfile. * @@ -156,10 +284,77 @@ export async function validateCaddyfile(): Promise { /** Write the Caddyfile, ensuring the parent directory exists. */ export function writeCaddyfile(content: string): void { mkdirSync(dirname(CADDYFILE_PATH), { recursive: true }); - const next = content.startsWith('#') ? content : `${HEADER}\n\n${content}`; + const next = ensureOwnerMarker(content.startsWith('#') ? content : `${HEADER}\n\n${content}`); writeFileSync(CADDYFILE_PATH, next.endsWith('\n') ? next : `${next}\n`, 'utf8'); } +/** `caddy adapt` of our Caddyfile, parsed; undefined when it cannot be adapted. */ +async function adaptOwnCaddyfile(): Promise { + if (!existsSync(CADDYFILE_PATH)) return {}; + const result = await runCaddy(['adapt', '--config', CADDYFILE_PATH, '--adapter', 'caddyfile']); + if (!result.ok) return undefined; + const parsed = parseJsonOrUndefined(result.stdout); + return parsed === undefined ? undefined : parsed; +} + +/** JSON with sorted keys, so two equal configs compare equal regardless of key order. */ +function canonicalJson(value: unknown): string { + const sort = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(sort); + if (v && typeof v === 'object') { + return Object.fromEntries( + Object.keys(v as Record) + .sort() + .map((k) => [k, sort((v as Record)[k])]), + ); + } + return v; + }; + return JSON.stringify(sort(value)); +} + +function hasOwnerMarker(config: unknown): boolean { + const logs = (config as null | { logging?: { logs?: Record } })?.logging?.logs; + return !!logs && Object.prototype.hasOwnProperty.call(logs, OWNER_LOGGER); +} + +/** Status and body of a plain-http GET, or null when nothing answers. */ +function httpGetText(url: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: HttpAnswer | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const request = httpRequest(url, { timeout: timeoutMs }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => (body += chunk)); + response.on('end', () => done({ body, status: response.statusCode ?? 0 })); + response.on('error', () => done({ body, status: 0 })); + }); + request.on('timeout', () => { + request.destroy(); + done(null); + }); + request.on('error', () => done(null)); + request.end(); + } catch { + done(null); + } + }); +} + +function parseJsonOrUndefined(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + /** Run a caddy subcommand and capture stdout/stderr. */ function runCaddy(args: string[]): Promise { return new Promise((resolve) => { diff --git a/src/lib/dev-bootstrap.ts b/src/lib/dev-bootstrap.ts index 464f836..5b7bd8a 100644 --- a/src/lib/dev-bootstrap.ts +++ b/src/lib/dev-bootstrap.ts @@ -17,9 +17,10 @@ */ import { existsSync } from 'fs'; +import { paths as caddyPaths } from './caddy'; import { buildIdentity } from './dev-identity'; import { DevProjectLayout } from './dev-project'; -import { getServicePaths } from './dev-service'; +import { caddyLaunchMode, getServicePaths } from './dev-service'; import { loadRegistry } from './dev-state'; /** @@ -37,12 +38,16 @@ export function isLtDevProject(layout: DevProjectLayout): boolean { * install has run; whether the daemon is currently *running* is a * separate concern handled by `lt dev up` / `doctor`. * - * Always false on unsupported platforms (no service model), so the - * chaining never tries to install where it cannot. + * On Windows there is no unit file (Caddy is started on demand), so the + * marker there is our Caddyfile, which `lt dev install` creates. Always false + * where Caddy can only be started by hand, so the chaining never tries to + * install where it cannot. */ export function isMachinePrepared(): boolean { + const mode = caddyLaunchMode(); + if (mode === 'on-demand') return existsSync(caddyPaths.caddyfile); const paths = getServicePaths(); - return paths.platform !== 'unsupported' && existsSync(paths.unitFile); + return mode === 'service' && existsSync(paths.unitFile); } /** diff --git a/src/lib/dev-caddy-gate.ts b/src/lib/dev-caddy-gate.ts new file mode 100644 index 0000000..c980b5e --- /dev/null +++ b/src/lib/dev-caddy-gate.ts @@ -0,0 +1,75 @@ +/** + * The one check every `lt dev` command makes before it relies on Caddy. + * + * Answers "may we use the Caddy on :2019?" and, on Windows, starts ours when + * none runs. Whatever the answer, a caller gets either `ok` or the lines to + * print; no command formulates its own Caddy advice any more. That used to + * produce "run `lt dev install`" on Windows, where `install` said "not + * supported" — advice into the void. + */ +import { caddyAvailable, type CaddyOwner, detectCaddyOwner, ensureCaddyfile, foreignCaddyLines, paths } from './caddy'; +import { caddyLaunchMode, startCaddyOnDemand } from './dev-service'; + +/** Result of `ensureOwnCaddy`. `lines` and `reason` are set when `ok` is false. */ +export interface CaddyGate { + lines: string[]; + ok: boolean; + reason?: 'down' | 'foreign' | 'missing' | 'start-failed'; + started: boolean; +} + +/** Injection points, so each branch is testable without Caddy. */ +export interface CaddyGateDeps { + available?: () => Promise; + detectOwner?: () => Promise; + launchMode?: ReturnType; + prepareCaddyfile?: () => void; + start?: () => Promise<{ logFile: string; message: string; ok: boolean }>; +} + +/** + * Establish that the Caddy on :2019 is ours. + * + * - `ours` → ok. + * - `foreign` → refused, with `foreignCaddyLines`. Never started over, never + * reloaded, never stopped. + * - `none` → on Windows (`on-demand`) and with `startIfDown`, ours is started, + * and then it must identify as ours; anything else is a failed start, not a + * success. With a service (macOS/Linux) the service owns the lifecycle, so the + * answer is `lt dev install`. + */ +export async function ensureOwnCaddy(opts: { startIfDown: boolean }, deps: CaddyGateDeps = {}): Promise { + const available = deps.available ?? caddyAvailable; + const detectOwner = deps.detectOwner ?? (() => detectCaddyOwner()); + const mode = deps.launchMode ?? caddyLaunchMode(); + + if (!(await available())) { + return { lines: ['caddy is not installed. Run `lt dev install` first.'], ok: false, reason: 'missing', started: false }; + } + + const owner = await detectOwner(); + if (owner === 'ours') return { lines: [], ok: true, started: false }; + if (owner === 'foreign') return { lines: foreignCaddyLines(), ok: false, reason: 'foreign', started: false }; + + if (mode === 'on-demand' && opts.startIfDown) { + (deps.prepareCaddyfile ?? ensureCaddyfile)(); + const started = await (deps.start ?? (() => startCaddyOnDemand()))(); + if (!started.ok) { + return { lines: [started.message, ` Log: ${started.logFile}`], ok: false, reason: 'start-failed', started: false }; + } + // Something may have taken :2019 between our check and the start. + const after = await detectOwner(); + if (after === 'ours') return { lines: [], ok: true, started: true }; + if (after === 'foreign') return { lines: foreignCaddyLines(), ok: false, reason: 'foreign', started: false }; + return { lines: [`Caddy did not come up. Log: ${started.logFile}`], ok: false, reason: 'start-failed', started: false }; + } + + return { lines: [downAdvice(mode)], ok: false, reason: 'down', started: false }; +} + +/** What to do when no Caddy runs, per launch mode. */ +function downAdvice(mode: ReturnType): string { + if (mode === 'service') return 'caddy daemon is not running. Run `lt dev install` to start the lt-dev service.'; + if (mode === 'on-demand') return 'caddy is not running. `lt dev up` starts it.'; + return `caddy is not running. Start it with: caddy run --config "${paths.caddyfile}" --adapter caddyfile`; +} diff --git a/src/lib/dev-install-helper.ts b/src/lib/dev-install-helper.ts index fa088a3..f5c3bad 100644 --- a/src/lib/dev-install-helper.ts +++ b/src/lib/dev-install-helper.ts @@ -10,12 +10,23 @@ * caller decides the exit code from the returned result. */ import { ExtendedGluegunToolbox } from '../interfaces/extended-gluegun-toolbox'; -import { caddyAvailable, paths as caddyPaths, validateCaddyfile, writeCaddyfile } from './caddy'; import { + caddyAvailable, + paths as caddyPaths, + detectCaddyOwner, + ensureCaddyfile, + foreignCaddyLines, + readCaddyfile, + reloadCaddy, + validateCaddyfile, +} from './caddy'; +import { + caddyLaunchMode, getServicePaths, getServiceStatus, installService, platformSupported, + startCaddyOnDemand, waitForServiceReady, } from './dev-service'; @@ -24,9 +35,11 @@ export interface RunInstallResult { blocked: boolean; /** caddy is not on PATH. */ caddyMissing: boolean; + /** A Caddy that `lt dev` did not start holds :2019; nothing was changed. */ + foreign?: boolean; /** installService succeeded (unit written + bootstrapped). */ ok: boolean; - /** Platform has no supported service model (not macOS/Linux). */ + /** Platform has no way for `lt dev` to start Caddy (neither service nor on-demand). */ unsupported: boolean; } @@ -51,9 +64,10 @@ export async function runInstall( ); info(colors.dim('─'.repeat(60))); + const mode = caddyLaunchMode(); const plat = platformSupported(); - if (plat === 'unsupported') { - error(`Service management is not supported on ${process.platform}. Only macOS and Linux are covered.`); + if (mode === 'manual') { + error(`Starting Caddy is not supported on ${process.platform}. macOS, Linux and Windows are covered.`); info(` Workaround: run \`${colors.cyan(`caddy run --config ${caddyPaths.caddyfile}`)}\` manually.`); return { blocked: true, caddyMissing: false, ok: false, unsupported: true }; } @@ -68,21 +82,47 @@ export async function runInstall( warning('caddy is not installed.'); info(` → macOS: ${colors.cyan('brew install caddy')}`); info(` → Linux: ${colors.cyan('https://caddyserver.com/docs/install')}`); + info(` → Windows: ${colors.cyan('winget install CaddyServer.Caddy')}`); info(' (Do NOT start it via `brew services` — `lt dev install` runs its own service.)'); blocked = true; } - // 2. Caddyfile stub - writeCaddyfile('# lt dev — managed Caddyfile\n# Add per-project blocks via `lt dev up`.\n'); - success(`Caddyfile present at ${caddyPaths.caddyfile}`); - if (!hasCaddy) { + ensureCaddyfile(); info(''); error('Cannot continue setup until Caddy is installed. Re-run `lt dev install` afterwards.'); return { blocked: true, caddyMissing: true, ok: false, unsupported: false }; } - // 3. brew services conflict warning + // 2. Whose Caddy holds :2019? Asked BEFORE the Caddyfile changes: an instance + // started before the owner marker existed is recognised by its config + // matching the file (see `detectCaddyOwner`). + const owner = await detectCaddyOwner(); + if (owner === 'foreign') { + info(''); + const [first, ...rest] = foreignCaddyLines(); + error(first); + rest.forEach((l) => info(` ${l}`)); + // No service either: it would crash-loop against the occupied :2019. + return { blocked: true, caddyMissing: false, foreign: true, ok: false, unsupported: false }; + } + + // 3. Caddyfile: created if missing, otherwise only given the owner marker — + // never reset. The stub used to be written on every run, which dropped the + // block of every project that was up at the time. + const before = readCaddyfile(); + ensureCaddyfile(); + success(`Caddyfile present at ${caddyPaths.caddyfile}`); + if (owner === 'ours' && readCaddyfile() !== before) { + // Our running Caddy must load the marker now, or its config would no longer + // match the file and the next check would take it for a foreign one. + const reload = await reloadCaddy(); + if (!reload.ok) warning(`Caddy reload failed: ${reload.stderr.split('\n')[0]}`); + } + + if (mode === 'on-demand') return installOnDemand(toolbox, owner === 'ours'); + + // 4. brew services conflict warning const brewConflict = await detectBrewCaddyConflict(); if (brewConflict) { warning('A `brew services caddy` instance is registered.'); @@ -90,7 +130,7 @@ export async function runInstall( info(' `lt dev install` runs its own service — the brew one is no longer needed.'); } - // 4. Install our LaunchAgent / systemd unit + // 5. Install our LaunchAgent / systemd unit const paths = getServicePaths(); info(''); info(`Installing ${plat === 'darwin' ? 'LaunchAgent' : 'systemd-user unit'} at:`); @@ -105,7 +145,7 @@ export async function runInstall( info(colors.dim(installResult.message)); } - // 5. Wait for admin endpoint + // 6. Wait for admin endpoint if (installResult.ok) { info(colors.dim('Waiting for Caddy admin endpoint (:2019) ...')); const ready = await waitForServiceReady(8_000); @@ -124,14 +164,14 @@ export async function runInstall( } } - // 6. Validate Caddyfile + // 7. Validate Caddyfile if (installResult.ok) { const validation = await validateCaddyfile(); if (validation.ok) success('Caddyfile validates'); else warning(`Caddyfile validation: ${validation.stderr.split('\n').slice(0, 2).join(' / ')}`); } - // 7. CA trust + // 8. CA trust info(''); info(colors.bold('Local CA trust')); info(' Caddy creates its local CA on first run. To trust it system-wide,'); @@ -163,3 +203,34 @@ function detectBrewCaddyConflict(): Promise { }); }); } + +/** + * Windows: no service. Start Caddy now unless ours already runs, then point at + * `caddy trust` — which needs a running instance, hence start first, trust + * second (measured: `caddy trust` before the start fails dialling :2019). + */ +async function installOnDemand(toolbox: ExtendedGluegunToolbox, alreadyRunning: boolean): Promise { + const { + print: { colors, error, info, success }, + } = toolbox; + + if (alreadyRunning) { + success('The lt-dev Caddy is already running.'); + } else { + const started = await startCaddyOnDemand(); + if (!started.ok) { + error(started.message); + info(colors.dim(` Log: ${started.logFile}`)); + return { blocked: true, caddyMissing: false, ok: false, unsupported: false }; + } + success(started.message); + } + info(colors.dim(' No service on Windows: `lt dev up` starts Caddy again after a reboot.')); + + info(''); + info(colors.bold('Local CA trust')); + info(' Windows asks once whether to trust Caddy\'s local CA, on Caddy\'s first start.'); + info(` If browsers still warn about ${colors.cyan('https://*.localhost')}, run this while Caddy runs:`); + info(` ${colors.cyan('caddy trust')}`); + return { blocked: false, caddyMissing: false, ok: true, unsupported: false }; +} diff --git a/src/lib/dev-service.ts b/src/lib/dev-service.ts index b22e399..fce1ad5 100644 --- a/src/lib/dev-service.ts +++ b/src/lib/dev-service.ts @@ -18,8 +18,14 @@ * - Linux: systemd-user unit at * `~/.config/systemd/user/lt-dev-caddy.service`, controlled via * `systemctl --user`. - * - Anything else (Windows, BSDs without systemd-user): explicitly - * unsupported — the caller surfaces a clear message. + * - Windows: no service at all. `lt dev install` / `lt dev up` start + * Caddy on demand as a detached background process + * (`startCaddyOnDemand`). Decision D1: a Windows service would run as + * SYSTEM with its own CA while the user's browser trusts the user's + * CA; Caddy binds :443 there without admin rights, so nothing needs a + * service. See `caddyLaunchMode`. + * - Anything else (BSDs without systemd-user): explicitly unsupported — + * the caller surfaces a clear message. * * Tests inject a `ShellRunner` to mock `launchctl` / `systemctl` * without touching the real OS. Render functions stay pure. @@ -30,7 +36,7 @@ import { homedir, platform, userInfo } from 'os'; import { dirname, join } from 'path'; import { paths as caddyPaths } from './caddy'; -import { httpStatus } from './dev-process'; +import { httpStatus, spawnDetached } from './dev-process'; import { findExecutable, type FindExecutableOptions } from './platform'; /** @@ -108,6 +114,19 @@ export interface UninstallServiceResult { let activeRunner: ShellRunner = defaultShellRunner; +/** + * How Caddy gets started on this platform. + * + * - `service`: a LaunchAgent / systemd-user unit owns it (`installService`). + * - `on-demand`: `lt dev` starts it itself when nothing runs (`startCaddyOnDemand`). + * - `manual`: the user starts it; we only print the command. + */ +export function caddyLaunchMode(p: NodeJS.Platform = platform()): 'manual' | 'on-demand' | 'service' { + if (p === 'darwin' || p === 'linux') return 'service'; + if (p === 'win32') return 'on-demand'; + return 'manual'; +} + /** Compute the file-system locations for the service. Pure. */ export function getServicePaths(home: string = userHome(), plat = platformSupported()): ServicePaths { const logFile = join(home, '.lenneTech', 'caddy.log'); @@ -313,6 +332,39 @@ export function setShellRunner(runner: null | ShellRunner): void { activeRunner = runner ?? defaultShellRunner; } +/** + * Start our Caddy as a detached background process, then wait for its admin API. + * + * Only for `caddyLaunchMode() === 'on-demand'`, and only after + * `detectCaddyOwner` answered `none`: this starts a Caddy, it never replaces one. + * + * `caddy run` under our own detached spawn rather than `caddy start`: `caddy + * start` hands its own stdout/stderr to the background child, so a caller that + * captures them leaves the child writing into a pipe that closes when the CLI + * exits. `spawnDetached` sends both to `~/.lenneTech/caddy.log` instead. + */ +export async function startCaddyOnDemand( + deps: { + resolveBin?: () => Promise; + spawn?: typeof spawnDetached; + waitReady?: (timeoutMs: number) => Promise; + } = {}, +): Promise<{ logFile: string; message: string; ok: boolean }> { + const logFile = getServicePaths().logFile; + const bin = await (deps.resolveBin ?? (() => resolveCaddyBin()))(); + if (!bin) return { logFile, message: caddyMissingMessage(), ok: false }; + const spawned = (deps.spawn ?? spawnDetached)( + bin, + ['run', '--config', caddyPaths.caddyfile, '--adapter', 'caddyfile'], + { cwd: userHome(), env: process.env, logFile }, + ); + if (!spawned) return { logFile, message: `could not start ${bin}`, ok: false }; + const ready = await (deps.waitReady ?? waitForServiceReady)(8_000); + return ready + ? { logFile, message: `Caddy started (pid ${spawned.pid}).`, ok: true } + : { logFile, message: 'Caddy was started but its admin API (:2019) did not answer within 8s.', ok: false }; +} + /** Stop the service and remove the unit file. */ export async function uninstallService(): Promise { const plat = platformSupported(); diff --git a/src/lib/dev-test-session.ts b/src/lib/dev-test-session.ts index 001736e..547c421 100644 --- a/src/lib/dev-test-session.ts +++ b/src/lib/dev-test-session.ts @@ -33,8 +33,9 @@ import { existsSync, readdirSync, rmSync } from 'fs'; import { cpus, totalmem } from 'os'; import { join } from 'path'; -import { reloadCaddy, removeProjectBlock, upsertProjectBlock } from './caddy'; +import { detectCaddyOwner, foreignCaddyLines, reloadCaddy, removeProjectBlock, upsertProjectBlock } from './caddy'; import { applyPendingMigrations, findCompiledEntry, resolveApiRuntime } from './dev-api-launch'; +import { ensureOwnCaddy } from './dev-caddy-gate'; import { buildDevEnv, internalUrl } from './dev-env'; import { clearEnvBridge, writeEnvBridge } from './dev-env-bridge'; import { buildTestIdentity, DevIdentity } from './dev-identity'; @@ -337,6 +338,9 @@ export async function bringUpTestSession( if (testIdentity.subdomains.app && appPort) routes.push({ hostname: testIdentity.subdomains.app.hostname, upstreamPort: appPort }); if (routes.length === 0) throw new Error('test session has no subdomains to expose (need an app project).'); + // Ownership before the Caddyfile changes (see `detectCaddyOwner`). + const caddyGate = await ensureOwnCaddy({ startIfDown: true }); + if (!caddyGate.ok) throw new Error(caddyGate.lines.join('\n')); upsertProjectBlock(testIdentity.slug, routes); const reload = await reloadCaddy(); if (!reload.ok) throw new Error(`caddy reload failed:\n${reload.stderr}`); @@ -841,10 +845,13 @@ export async function tearDownTestSession( clearSession(layout.root, names.sessionFile); } + const owner = await detectCaddyOwner(); const removed = removeProjectBlock(testIdentity.slug); - if (removed) { + if (removed && owner === 'ours') { const r = await reloadCaddy(); if (!r.ok && !opts.silent) log.warn(`Removed test Caddy block but reload failed: ${r.stderr.split('\n')[0]}`); + } else if (removed && owner === 'foreign' && !opts.silent) { + log.warn(`Removed the test block from lt dev's Caddyfile, but did not reload:\n${foreignCaddyLines().join('\n')}`); } clearEnvBridge(layout.root, names.bridgeFile);