Files
server-config-cli/CLAUDE.md
2026-05-11 17:19:13 +08:00

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 — 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_addrserverAddr, tokenauth.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:

  • MainMenuFrpMenu (submenu) or PlanPreview (for zsh/ssh) or TokenPrompt (for bootstrap).
  • TokenPromptFrpConfigForm (collects serverAddr/serverPort/installDir).
  • FrpConfigForm / ProxyForm build a plan and replace to PlanPreview.
  • ProxyListPlanPreview (for a remove plan).
  • PlanPreviewRunLog (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 <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:

  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_<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

  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 PlanPreviewRunLog so the user sees what's about to happen and gets uniform log/spinner UI.