# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands - `npm install` — first-time setup. - `npm run build` — bundles `src/app.jsx` (and everything it imports) to `dist/app.mjs` via esbuild. Required before the CLI can run; `bin/server-config.js` exits with an error if the bundle is missing. - `npm run build:watch` — rebuild on change. - `npm test` — runs `node --test test/*.test.js` against the pure-logic library tests. - `npm run check` — syntax-checks every hand-written `.js` file (does not check `.jsx` — they're consumed by esbuild). - `node bin/server-config.js` — launch the TUI (or `server-config` after `npm link`). It refuses to start until `npm run build` has been run. Requirements: Node ≥18, but in practice ≥20 because Ink 7 + React 19 expect modern runtimes. The TUI requires a real TTY on stdin/stdout; running with redirected stdin will fail with "Raw mode is not supported". ## Architecture The project is split into three layers. **Do not let UI concerns leak into the logic layers, and do not call `child_process.spawn` from anywhere other than the runner** — that single chokepoint is what gives the TUI its uniform spinner/log behavior. ### 1. Pure logic — `src/lib/` - `frp-config.js` (CJS) — parser/renderer for `frpc.toml`. Lossy TOML-ish: accepts both legacy `[name]` headers and modern `[[proxies]]` arrays, migrates legacy ini keys (`server_addr` → `serverAddr`, `token` → `auth.token`, etc.) on read, and always emits the modern form. Internal proxy values are snake_case (`local_ip`, `local_port`, `remote_port`) but rendered camelCase (`localIP`, `localPort`, `remotePort`). `createUniqueProxyName` appends a random 8-char hex suffix to avoid collisions. - `tasks.js` (CJS) — plan builders. Each public function (`zshInstallPlan`, `sshInstallPlan`, `frpInstallPlan`, `frpInitConfigPlan`, `frpAddProxyPlan`, `frpRemoveProxyPlan`, `frpRestartPlan`, `bootstrapPlan`) returns `{ title, steps[] }`. Steps come in three kinds: `run` (spawn a command), `write` (write a file, possibly via `sudo tee`), `zshrc-plugins` (in-place edit of `~/.zshrc`). Plan builders may read the filesystem (e.g. to preserve existing proxies on `--force` re-init) and may throw — call sites must catch. - `runner.js` (CJS) — `startRun(steps)` returns an EventEmitter-backed object that executes the step list and emits `step-start`, `log` (stdout/stderr lines), `step-done`, and `done` events. The runner owns all `child_process.spawn` calls and is the only place that touches stdio. Privileged writes go through `sudo tee` when `canWriteDirectly` returns false. `cancel()` SIGTERMs the active child. The tests (`test/frp-config.test.js`) only exercise `frp-config.js`. They're plain `node:test` and import via `require`, so keep the lib modules in CJS. ### 2. Ink screens — `src/screens/*.jsx` A small screen-stack lives in `src/app.jsx`: `{stack: Screen[]}` with `push`, `replace`, `back`, `home`. Each screen is a self-contained component that takes `nav` (the navigation helpers) and any screen-specific props. Adding a new screen = new file in `src/screens/`, new `case` in the `renderScreen` switch in `app.jsx`. Screens in dependency order: - `MainMenu` → `FrpMenu` (submenu) or `PlanPreview` (for zsh/ssh) or `TokenPrompt` (for bootstrap). - `TokenPrompt` → `FrpConfigForm` (collects `serverAddr`/`serverPort`/`installDir`). - `FrpConfigForm` / `ProxyForm` build a plan and `replace` to `PlanPreview`. - `ProxyList` → `PlanPreview` (for a remove plan). - `PlanPreview` → `RunLog` (the only place that calls `runner.startRun`). - `RunLog` subscribes to runner events with `useReducer`; the last `MAX_LOG_LINES` (12) lines of streamed output are shown in a bordered scrollback. Forms use a `step` index plus arrow-key/Tab traversal between `` widgets. Avoid pulling in a forms library — the pattern is small enough to keep inline. ### 3. Entry/build — `bin/`, `dist/`, esbuild `bin/server-config.js` is a CJS shim that `import()`s `dist/app.mjs`. The bundle is ESM because Ink 7 + yoga-layout use top-level await, which esbuild cannot lower to CJS — that's why `--format=esm` is non-negotiable. Two esbuild quirks worth knowing: 1. **`react-devtools-core` is aliased to a stub** (`src/stubs/react-devtools-core.js`). Ink imports it behind a `process.env.DEV === "true"` check, but esbuild hoists ESM imports to module-load time, so we'd otherwise execute the real package's UMD preamble (which references `self`) and crash. Keep the alias; do not change to `--external` (that reintroduces the crash at runtime). 2. The `--banner:js` injects a `createRequire` so CJS lib modules called from the bundle can still use `require(...)`. Don't drop it without also rewriting the libs to ESM. The bundle is ~1.8 MB — that's mostly React + Yoga, not something to fight. ## Sensitive values & defaults The FRP token comes from the TokenPrompt screen or the `FRP_TOKEN` env var (the screen pre-fills from env). It is never logged or persisted outside the rendered `frpc.toml`. Defaults that callers depend on: server `81.70.134.9:15443`, install dir `/opt/frp/frp__linux_`, systemd unit `frpc`, frp version `0.58.1`. Changing any is a breaking change for existing deployments. ## When adding a new task type 1. Add a plan builder in `src/lib/tasks.js` returning `{ title, steps }`. 2. If you need a new step kind (beyond `run` / `write` / `zshrc-plugins`), extend the `runStep` switch in `src/lib/runner.js` and `formatStep` for the preview label. 3. Add a screen (or extend an existing menu) that constructs the plan and pushes `{ name: "plan", props: { plan } }`. Never spawn commands directly from a screen — always build a plan and route it through `PlanPreview` → `RunLog` so the user sees what's about to happen and gets uniform log/spinner UI.