# Architecture For contributors. Where the code lives, how the pieces fit, why some things are the way they are. ## High-level shape devup is a single Node binary. The CLI entry resolves config, validates it, then either: - **Renders the TUI** (default) — an Ink app that mounts the process manager, lazy proxies, control-plane socket, and optional config watcher. - **Runs a no-TUI orchestrator** (`--dry-run`, `--once`, subcommands) — same primitives, different lifecycle. The TUI is just one consumer of the underlying machinery. That separation is intentional: subcommands like `devup logs` and `devup install` should work the same way whether or not the TUI is running. ## Directory map ``` src/ index.ts CLI entry; argv parsing, config loading, dispatch config/ types.ts DevStackConfig, ServiceConfig, ExternalService loader.ts find + load + dynamic-import config files validator.ts validateConfig (errors) + collectWarnings cli.ts parseCliArgs + filterServices + USAGE diff.ts diffServices (used by --watch-config) process/ types.ts ProcessState, ProcessStatus, HealthStatus manager.ts ProcessManager: spawn, restart, kill-tree, polling health.ts checkPort (TCP), checkHttp, checkHealth dispatcher installer.ts needsInstall, writeInstallStamp log-sink.ts LogSink: persistent per-service log files external.ts startExternals / stopExternals (Mongo, Redis, ...) lazy/ classifier.ts split services into always-on vs lazy proxy.ts createLazyProxy: TCP relay + idle timer platform/ types.ts Platform interface (kill, stats, openBrowser) detect.ts platform-specific impl loader linux.ts / darwin.ts / win32.ts proxy-config/ types.ts ProxyConfigProvider, ProxyOpts, ServiceState detect.ts registry: 'traefik' / 'nginx' / 'caddy' traefik.ts / nginx.ts / caddy.ts control-plane/ socket-server.ts Unix-socket JSON-RPC orchestrator/ dry-run.ts --dry-run renderer once.ts --once orchestrator (no TUI) subcommands.ts devup logs / install / status / help tui/ App.tsx Top-level Ink component LogsPanel.tsx Logs view StatsPanel.tsx Stats view StatusBar.tsx ServiceList.tsx Picker modal (f, r, o) SearchInput.tsx / search input tips.ts pure pickTip() hooks/ useProcessManager.ts wraps ProcessManager + logs/stats state useKeyBindings.ts key handler + state useProxySync.ts writes the proxy file every 3 s utils.ts parseEnvFile, fmtUptime, compileSearchPattern, detectLogLevel, redactSecrets, nextRamBannerVisibility, buildProcessArgs, buildProcessEnv, calcCpuPercent, sortServiceNames, groupByPhase, needsInstall stamps tests/ unit/ mirrors src/ integration/ process-lifecycle, lazy-proxy, once, installer fixtures/ minimal-config.json, dummy-server.ts, dummy-crash.ts ``` ## Data flow ### Boot ``` parseCliArgs ↓ findConfigFile + loadConfig (dynamic import for .ts/.js) ↓ validateConfig ──► exit 1 on errors collectWarnings ──► print, continue ↓ filterServices (--profile / --services / --only / --skip) ↓ detectPlatform (linux | darwin | win32) ↓ [dispatch] --dry-run → renderDryRun, exit 0 --once → runOnce (no TUI; ProcessManager + externals + waitHealthy) subcommand → runLogs / runInstall / runStatus default → render() ``` ### The TUI lifecycle (``) ``` useProcessManager() → creates ProcessManager; subscribes to onLog / onStateChange useKeyBindings() → keyboard state machine useProxySync() → conditional setInterval(3000) writing the proxy file Boot useEffect: external?.length → startExternals (blocks until healthy) lazy mode → classifyServices; for alwaysOn: start in phases; for lazy: createLazyProxy per service normal mode → start every service in phase order ``` Each phase awaits `Promise.all(waitForPort(...))` over the APIs in that phase before moving on. Webs are not awaited. ### A service spawn ``` ProcessManager.start(svc, colorIdx) 1. checkPort(svc.port) skip if occupied (and not isRestart) 2. runPreBuild() if svc.preBuild — sh -c, waits for exit non-zero → recordCrashedState, return 3. extractWatchPaths(args) pre-flight: verify --watch-path targets exist missing → recordCrashedState, return 4. spawn(svc.cmd, args, { detached: true }) → detached for kill-tree 5. compileReadyPattern regex matched against every line 6. compileErrorPattern regex used to decide if stderr line counts as error 7. lineBuffer on stdout/stderr → markReadyIfMatch + log 8. spawnWatchBuild() if svc.watchBuild — sibling process tracked on state.watchProc 9. proc.on('close') → either crashed (auto-restart with backoff) or stopped intentionally ``` ### Lazy proxy ``` createLazyProxy({ listenPort, targetPort, ... }) net.Server on listenPort on connection: bumpActivity() if (serviceReady && isAlive) → pipeToTarget() else queue + onDemandStart() + waitForPort() + drain queue scheduleIdleCheck on a timer if activeConns.size === 0 && elapsed > periodMs → onIdleStop() ``` ### Reverse-proxy file generation ``` useProxySync(provider, opts, states, enabled) setInterval(3000) → snapshot states → ServiceState map provider.generate(snapshot, opts) → string if changed since last write → provider.write(content, opts) ``` The Traefik / Nginx / Caddy providers are pure `generate()` functions plus a thin `write()` wrapper. ### Hot reload ``` useEffect (--watch-config) fs.watch(configPath) → debounced 250 ms → reload() loadConfig + validate (errors → log, skip) diffServices(running, next) → { added, removed, changed, unchanged } apply: removed → mgr.stop + mgr.state.delete changed → mgr.stop + 800 ms + mgr.install + mgr.start added → mgr.install + mgr.start log "🔁 config reloaded: +X -Y ~Z" ``` ### Control plane ``` startSocketServer(projectName, ctx) net.createServer on ~/.devup/sock-.sock chmod 0600 per connection: readline → dispatch(method, params, ctx) ping / status / restart / stop / logs.tail → JSON response over the same socket ``` ## Cross-platform considerations Three implementations of `Platform`: - **LinuxPlatform** uses `ps` for stats, `kill -` for kill-tree (negative pid = process group). - **DarwinPlatform** extends Linux with macOS-specific quirks (mostly browser opening with `open`). - **Win32Platform** uses `wmic` for stats, `taskkill /T /F` for kill-tree, `cmd /c start` for browser. Integration tests are skipped on Windows where they'd rely on Unix-specific shell behavior (sleep, single-quote strings). The features themselves work on Windows because the runtime code path routes through `cmd /c`. Only the test fixtures are awkward to write cross-platform. ## Tests - **Unit tests**: `node:test` native runner, run in parallel. Test pure helpers, validators, panel rendering (via `ink-testing-library`), process manager with short-lived `node -e` scripts. - **Integration tests**: spawn real processes via fixture servers (`tests/fixtures/dummy-server.ts`). Run in serial. Skipped on Windows where listed in their respective `{ skip: process.platform === 'win32' }`. CI runs unit tests on Linux/macOS/Windows for every push. Integration tests only on `main` (3 OSes). ## Why these design choices - **No EventEmitter event bus.** ProcessManager exposes `events: { onLog, onStateChange }` as plain callbacks. Simpler to test, harder to leak subscribers. - **Pure helpers everywhere**. `compileSearchPattern`, `detectLogLevel`, `redactSecrets`, `nextRamBannerVisibility`, `diffServices`, `extractWatchPaths`, `compileReadyPattern`, `buildServiceUrl`. Each is exported, individually unit-tested, and lives in a small module. The bigger components (panels, process manager) compose them. - **Filesystem perms for control plane auth**. Simpler than any in-process auth, no token rotation, no remote concerns. The cost is no multi-user support — fine for a dev tool. - **`detached: true` + `kill -pid`**. The Unix way to kill a process tree without spawning helpers. Catches grandchildren that the parent doesn't track. - **Per-PR releases via Trusted Publishing**. No `NPM_TOKEN` secret in the repo; provenance is auto-signed with sigstore. ## Adding a new feature The pattern that's worked across 20+ features: 1. Where is the **pure logic** that could be tested without spawning anything? Put that in `utils.ts` or a new `/.ts` with an exported function. Unit-test it before wiring. 2. Where does it **side-effect** (spawn, write file, write to TUI state)? That's a thin layer that calls the pure logic. Integration-test the boundary. 3. Add the CLI flag or config field. Update `validator.ts` if it has shape constraints. Update USAGE / README docs. The validator pattern (errors block, warnings advisory) is the right place to land "this looks suspicious" feedback. Resist temptation to put it in the spawn path — the user has seen it before they care about runtime. ## Coding conventions - TypeScript strict mode. `noImplicitAny`, `strictNullChecks`. Prefer explicit return types on exported functions. - No emojis in code/docs unless the user asked for them. The exception: log output uses 🚀 🔨 ⚡ ⚠ ❌ ✓ to make scanning fast. - Comments explain **why**, not what. The function name should already say what. - One feature = one commit, in the milestone release branch. Bump + CHANGELOG = the last commit of the branch. ## Bumping See [CHANGELOG.md](../CHANGELOG.md) for the format. The flow: 1. Open `release/` from `main`, commit features one by one. 2. Last commit: `chore: release ` with `package.json` bump and `CHANGELOG.md` entry. 3. PR → merge to main. 4. `gh release create --target main --title "" --notes-file <(awk '/^## \[\]/,/^## \[\]/' CHANGELOG.md | sed '$d')`. 5. The `Publish` GitHub Actions workflow runs tests on Linux/macOS/Windows and publishes to npm via OIDC trusted publishing. If publish fails: look at the workflow log, fix in a separate `ci/...` branch, merge, then `gh run rerun --failed`. ## Pointers for the most-touched files - **`src/process/manager.ts`** — ProcessManager class. Hardest file to navigate; spawn lifecycle lives here. - **`src/tui/App.tsx`** — top-level Ink component; orchestrates everything for the TUI mode. Lots of useEffects (boot, control plane, hot reload, tips, paused/scrolled coupling). - **`src/config/validator.ts`** — errors + warnings. Add new shape checks here. - **`src/utils.ts`** — junk drawer for pure helpers. Watch its growth; split into focused files when a section exceeds ~80 lines. ## Where it could be better - **`App.tsx` has too many useEffects** (boot, control plane, hot reload, paused-coupling, tips, resize). Could be split into smaller hooks. Hasn't bothered enough to warrant the refactor yet. - **`ProcessManager` is a god-class**. Manages spawn, restart, watch-build, pre-flight, polling, kill-tree, cleanup. A future split into `Spawner` / `Restarter` / `HealthPoller` would be defensible. - **Tests of TUI components rely on `ink-testing-library` and check substring matches in `lastFrame()`** — brittle if Ink changes its output. Acceptable for now because Ink's output is stable.