5.8 KiB
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— bundlessrc/app.jsx(and everything it imports) todist/app.mjsvia esbuild. Required before the CLI can run;bin/server-config.jsexits with an error if the bundle is missing.npm run build:watch— rebuild on change.npm test— runsnode --test test/*.test.jsagainst the pure-logic library tests.npm run check— syntax-checks every hand-written.jsfile (does not check.jsx— they're consumed by esbuild).node bin/server-config.js— launch the TUI (orserver-configafternpm link). It refuses to start untilnpm run buildhas 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 forfrpc.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).createUniqueProxyNameappends 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 viasudo tee),zshrc-plugins(in-place edit of~/.zshrc). Plan builders may read the filesystem (e.g. to preserve existing proxies on--forcere-init) and may throw — call sites must catch.runner.js(CJS) —startRun(steps)returns an EventEmitter-backed object that executes the step list and emitsstep-start,log(stdout/stderr lines),step-done, anddoneevents. The runner owns allchild_process.spawncalls and is the only place that touches stdio. Privileged writes go throughsudo teewhencanWriteDirectlyreturns 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) orPlanPreview(for zsh/ssh) orTokenPrompt(for bootstrap).TokenPrompt→FrpConfigForm(collectsserverAddr/serverPort/installDir).FrpConfigForm/ProxyFormbuild a plan andreplacetoPlanPreview.ProxyList→PlanPreview(for a remove plan).PlanPreview→RunLog(the only place that callsrunner.startRun).RunLogsubscribes to runner events withuseReducer; the lastMAX_LOG_LINES(12) lines of streamed output are shown in a bordered scrollback.
Forms use a step index plus arrow-key/Tab traversal between <TextInput focus={...} /> 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:
react-devtools-coreis aliased to a stub (src/stubs/react-devtools-core.js). Ink imports it behind aprocess.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 referencesself) and crash. Keep the alias; do not change to--external(that reintroduces the crash at runtime).- The
--banner:jsinjects acreateRequireso CJS lib modules called from the bundle can still userequire(...). 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_<version>_linux_<arch>, systemd unit frpc, frp version 0.58.1. Changing any is a breaking change for existing deployments.
When adding a new task type
- Add a plan builder in
src/lib/tasks.jsreturning{ title, steps }. - If you need a new step kind (beyond
run/write/zshrc-plugins), extend therunStepswitch insrc/lib/runner.jsandformatStepfor the preview label. - 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.