From 1b16641e26b1b0eb94ce028207143297a53112d9 Mon Sep 17 00:00:00 2001
From: hanruo <552455797@qq.com>
Date: Mon, 11 May 2026 17:19:13 +0800
Subject: [PATCH] fix
---
.gitignore | 1 +
CLAUDE.md | 63 ++
README.md | 190 ++---
USAGE.md | 70 ++
bin/server-config.js | 19 +-
package-lock.json | 1187 ++++++++++++++++++++++++++++++
package.json | 21 +-
src/app.jsx | 106 +++
src/cli.js | 730 ------------------
src/lib/frp-config.js | 226 ++++++
src/lib/runner.js | 229 ++++++
src/lib/tasks.js | 335 +++++++++
src/screens/FrpConfigForm.jsx | 114 +++
src/screens/FrpMenu.jsx | 50 ++
src/screens/MainMenu.jsx | 49 ++
src/screens/PlanPreview.jsx | 49 ++
src/screens/ProxyForm.jsx | 130 ++++
src/screens/ProxyList.jsx | 68 ++
src/screens/RunLog.jsx | 179 +++++
src/screens/TokenPrompt.jsx | 33 +
src/stubs/react-devtools-core.js | 8 +
test/frp-config.test.js | 2 +-
22 files changed, 3012 insertions(+), 847 deletions(-)
create mode 100644 CLAUDE.md
create mode 100644 USAGE.md
create mode 100644 package-lock.json
create mode 100644 src/app.jsx
delete mode 100644 src/cli.js
create mode 100644 src/lib/frp-config.js
create mode 100644 src/lib/runner.js
create mode 100644 src/lib/tasks.js
create mode 100644 src/screens/FrpConfigForm.jsx
create mode 100644 src/screens/FrpMenu.jsx
create mode 100644 src/screens/MainMenu.jsx
create mode 100644 src/screens/PlanPreview.jsx
create mode 100644 src/screens/ProxyForm.jsx
create mode 100644 src/screens/ProxyList.jsx
create mode 100644 src/screens/RunLog.jsx
create mode 100644 src/screens/TokenPrompt.jsx
create mode 100644 src/stubs/react-devtools-core.js
diff --git a/.gitignore b/.gitignore
index b1a4fa8..fbb8ac8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules/
npm-debug.log*
.DS_Store
+dist/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..074ad55
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,63 @@
+# 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.
diff --git a/README.md b/README.md
index a95411e..e59d2e9 100644
--- a/README.md
+++ b/README.md
@@ -1,146 +1,114 @@
# server-config-cli
-一个用于初始化服务器环境的 Node.js CLI,覆盖 zsh、openssh-server 和 frp client 配置。frp 支持通过命令追加端口穿透配置。
+一个用 [Ink](https://github.com/vadimdemedes/ink) 写的交互式 TUI,用来初始化服务器:zsh + oh-my-zsh + nvm、openssh-server、frp 客户端及其端口穿透配置。
-## 安装和本地使用
+启动后是菜单式界面,没有传统的子命令/flag —— 所有选项、token、端口都在 TUI 里输入。
+
+## 安装
```bash
-npm link
-server-config --help
+npm install
+npm run build # 必须先打包,bin/server-config.js 会加载 dist/app.mjs
+npm link # 可选;之后可以直接 server-config 调起
```
-也可以不 link,直接在项目目录执行:
+也可以不 link,直接:
```bash
-node bin/server-config.js --help
+node bin/server-config.js
```
-建议先用 `--dry-run` 看要执行的命令:
+要求 Node ≥ 18(建议 20+),并且必须在真正的终端里运行(stdin 需要 TTY,否则 Ink 报 "Raw mode is not supported")。
-```bash
-server-config zsh install --dry-run
-server-config ssh install --dry-run
-server-config frp install --token "$FRP_TOKEN" --dry-run
+## 启动后界面
+
+```
+╭───────────────────────────────────────╮
+│ server-config zsh · ssh · frp client │
+╰───────────────────────────────────────╯
+
+Choose an action:
+
+❯ Install zsh + oh-my-zsh + nvm
+ Install OpenSSH server
+ FRP setup ▸
+ Bootstrap (zsh + ssh + frp)
+ Quit
+
+↑↓ select · Enter confirm · q quit
```
-## zsh
+通用键位:
-```bash
-server-config zsh install
-```
+- `↑` `↓` 移动光标,`Enter` 确认
+- `Tab` / `↓` 在表单字段间切换,`↑` 回上一项
+- `Esc` 返回上一屏(运行中除外)
+- 在主菜单按 `q` 退出
-会执行:
+## 各动作做什么
-- `apt update && apt upgrade`
-- 安装 `zsh git curl wget`
-- 切换当前用户 shell 到 `/bin/zsh`
-- 安装 oh-my-zsh
-- 安装 nvm,并执行 `nvm install --lts`
-- 安装 `zsh-autosuggestions` 和 `zsh-syntax-highlighting`
-- 更新 `~/.zshrc` 的插件列表为 `git zsh-autosuggestions zsh-syntax-highlighting`
+### Install zsh + oh-my-zsh + nvm
-安装完成后重新登录 shell,或手动执行:
+会依次执行:
-```bash
-source ~/.zshrc
-```
+- `apt update` + `apt upgrade -y`
+- 装 `zsh git curl wget`
+- `chsh -s /bin/zsh`
+- 从 gitee 镜像装 oh-my-zsh(`RUNZSH=no CHSH=no KEEP_ZSHRC=yes`)
+- 装 nvm(`v0.40.4`)并 `nvm install --lts`、`nvm alias default lts/*`
+- 安装 `zsh-autosuggestions` 和 `zsh-syntax-highlighting`(已存在则 `git pull`)
+- 把 `~/.zshrc` 的 `plugins=(...)` 改成 `git zsh-autosuggestions zsh-syntax-highlighting`
-## ssh
+完成后请 `source ~/.zshrc` 或重开终端。
-```bash
-server-config ssh install
-```
+### Install OpenSSH server
-会安装 `openssh-server`,并执行 `systemctl enable --now ssh`。
+`apt install openssh-server -y` + `systemctl enable --now ssh`。
-## frp
+### FRP setup
-不要把 frp token 写死到仓库。使用环境变量或命令参数传入:
+进入子菜单:
-```bash
-export FRP_TOKEN="your-token"
-server-config frp install --token "$FRP_TOKEN"
-```
+- **Install frp client + service** — 下载 `frp_0.58.1_linux_amd64.tar.gz`,解压、放到 `/opt/frp/frp_0.58.1_linux_amd64/`,写 `frpc.toml` 和 systemd unit,然后 `systemctl enable --now frpc`。需要输入 token,也可以通过 `FRP_TOKEN` 环境变量预填。
+- **Init / rewrite frpc.toml** — 仅重写配置(保留已有代理段)。
+- **Add proxy** — 表单式输入名字 / 类型 / local IP / local port / remote port,写入的实际 name 会带 8 位随机后缀(例如 `ssh-aaa12312`),避免重名。可选执行后重启 frpc。
+- **List / remove proxies** — 选中即生成删除计划,预览后确认执行。
+- **Restart frpc** — `systemctl restart frpc`。
-默认配置:
+### Bootstrap
+
+按顺序执行 zsh + ssh + frp 三套,token 走同一个输入。
+
+## 计划预览和执行
+
+任何会改系统的动作都会先停在 "Plan" 屏:列出每一步要做什么(命令或写文件),可以选 **Run now** 或 **Cancel**。
+
+确认运行后进入实时日志屏:每一步显示状态图标(◐ 运行中 / ✓ 成功 / ✗ 失败),底部 12 行滚动展示子进程 stdout/stderr。任何一步失败立刻停止后续步骤。
+
+## 默认值
-- `serverAddr = "81.70.134.9"`
-- `serverPort = 15443`
-- `auth.token = "$FRP_TOKEN"`
-- `transport.tls.enable = false`
-- `transport.tcpMux = true`
-- `log.to = "/var/log/frpc.log"`
-- `log.level = "info"`
-- `log.maxDays = 7`
- frp 版本:`0.58.1`
-- 安装目录:`/opt/frp/frp_0.58.1_linux_amd64`
-- systemd 服务:`frpc`
+- 安装目录:`/opt/frp/frp__linux_amd64`
+- 配置文件:`/frpc.toml`
+- 服务名:`frpc`
+- 默认服务端:`81.70.134.9:15443`
+- 日志:`/var/log/frpc.log`,level `info`,保留 7 天
+- `transport.tcpMux = true`,`transport.tls.enable = false`
-只初始化配置文件:
+`serverAddr` / `serverPort` / `installDir` 在 FRP 配置表单里都可以改。如果要改更深的配置(log 路径、TLS 等),目前需要直接编辑 `src/lib/tasks.js` 里的 `buildGlobalsFromOptions`。
+
+## 开发
```bash
-server-config frp init --token "$FRP_TOKEN"
+npm run build:watch # 文件变了自动 rebuild
+npm test # 跑 frp-config 解析/渲染的单元测试
+npm run check # 对所有手写 .js 做 node --check
```
-如果配置文件已经存在,默认不会覆盖。需要覆盖时加:
+代码分三层:
-```bash
-server-config frp init --token "$FRP_TOKEN" --force
-```
+- `src/lib/` — 纯逻辑(CJS),没有 Ink/React 依赖:`frp-config.js` 解析渲染,`tasks.js` 输出计划,`runner.js` 执行并发事件。
+- `src/screens/` — Ink/React 组件,每个屏一个文件。
+- `src/app.jsx` — 入口 + screen stack。
-### 增加端口穿透
-
-SSH 示例,本机 ssh 监听 `22`,远端暴露 `17227`:
-
-```bash
-server-config frp add ssh --local-port 22 --remote-port 17227 --restart
-```
-
-实际写入 frp 的 `name` 会自动追加随机后缀,例如 `ssh-aaa12312`,避免与已有代理重名。删除代理时使用 `server-config frp list` 看到的完整名称。
-
-MySQL 示例:
-
-```bash
-server-config frp add mysql --local-port 3306 --remote-port 33061 --restart
-```
-
-如果本地服务不在 `127.0.0.1`,可以指定:
-
-```bash
-server-config frp add web --local-ip 0.0.0.0 --local-port 8080 --remote-port 18080 --restart
-```
-
-查看当前代理:
-
-```bash
-server-config frp list
-```
-
-删除代理:
-
-```bash
-server-config frp remove mysql-aaa12312 --restart
-```
-
-重启 frpc:
-
-```bash
-server-config frp restart
-```
-
-### 自定义路径
-
-```bash
-server-config frp install \
- --token "$FRP_TOKEN" \
- --install-dir /home/scyk/frp_0.58.1_linux_amd64 \
- --config /home/scyk/frp_0.58.1_linux_amd64/frpc.toml
-```
-
-## 一键初始化
-
-```bash
-server-config bootstrap --token "$FRP_TOKEN"
-```
-
-会依次执行 zsh、ssh 和 frp 安装。
+esbuild 把 `src/app.jsx` 打到 `dist/app.mjs`(ESM 输出,因为 Ink 7 / yoga-layout 用了 top-level await)。
diff --git a/USAGE.md b/USAGE.md
new file mode 100644
index 0000000..c637c83
--- /dev/null
+++ b/USAGE.md
@@ -0,0 +1,70 @@
+# 使用说明
+
+## 安装
+
+```bash
+npm install
+npm run build
+npm link # 可选,之后可以全局用 server-config
+```
+
+要求:Node ≥ 18,真实终端(不能 pipe stdin)。
+
+## 启动
+
+```bash
+server-config # 或:node bin/server-config.js
+```
+
+进入交互式菜单。**没有命令行参数**,所有输入都在 TUI 里完成。
+
+## 键位
+
+| 键 | 作用 |
+|----|------|
+| `↑` `↓` | 移动光标 |
+| `Enter` | 确认 |
+| `Tab` / `↓` | 表单字段切换 |
+| `Space` | 切换勾选项(如 "Restart frpc") |
+| `Esc` | 返回上一屏 |
+| `q` | 在主菜单退出 |
+
+## 菜单
+
+主菜单:
+
+- **Install zsh + oh-my-zsh + nvm** — 一键装 zsh / 插件 / nvm + LTS Node。
+- **Install OpenSSH server** — 装 openssh-server 并 enable。
+- **FRP setup ▸** — 进入 frp 子菜单。
+- **Bootstrap** — 顺序执行 zsh + ssh + frp install,token 走同一次输入。
+
+FRP 子菜单:
+
+- **Install frp client + service** — 下载安装 frp + 写配置 + 装 systemd unit + 启动。需要 token。
+- **Init / rewrite frpc.toml** — 仅重写配置,保留已有代理。
+- **Add proxy** — 填名字 / 类型 / 本地 IP+端口 / 远端端口,写入的实际 name 会带 8 位随机后缀。
+- **List / remove proxies** — 列出现有代理,选中即删。
+- **Restart frpc** — `systemctl restart frpc`。
+
+## 流程
+
+任何会改系统的动作都是两步:
+
+1. **Plan 屏** — 列出每一步要做什么(命令或写文件),选 `Run now` 或 `Cancel`。
+2. **Run 屏** — 实时显示 ◐/✓/✗ 状态和子进程输出。失败立刻停止。
+
+## 环境变量
+
+- `FRP_TOKEN` — 启动前 export,TokenPrompt 会自动填好,省去手输。
+
+## 注意
+
+- sudo 没有交互密码框。事先 `sudo -v` 缓存凭据,或配 NOPASSWD,否则带 sudo 的步骤会失败。
+- 默认 frp 服务端 `81.70.134.9:15443`,安装到 `/opt/frp/frp_0.58.1_linux_amd64/`,systemd 服务名 `frpc`。
+
+## 开发
+
+```bash
+npm run build:watch # 监听重建
+npm test # 跑解析器单元测试
+```
diff --git a/bin/server-config.js b/bin/server-config.js
index d025d15..33271f5 100755
--- a/bin/server-config.js
+++ b/bin/server-config.js
@@ -1,3 +1,20 @@
#!/usr/bin/env node
+"use strict";
-require("../src/cli").main(process.argv.slice(2));
+const fs = require("node:fs");
+const path = require("node:path");
+const { pathToFileURL } = require("node:url");
+
+const bundle = path.join(__dirname, "..", "dist", "app.mjs");
+
+if (!fs.existsSync(bundle)) {
+ process.stderr.write(
+ "server-config: bundle not built. Run `npm run build` from the package root first.\n",
+ );
+ process.exit(1);
+}
+
+import(pathToFileURL(bundle).href).catch((error) => {
+ process.stderr.write(`server-config: ${error?.stack || error}\n`);
+ process.exit(1);
+});
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f83d934
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1187 @@
+{
+ "name": "server-config-cli",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "server-config-cli",
+ "version": "0.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "ink": "^7.0.2",
+ "ink-select-input": "^6.2.0",
+ "ink-spinner": "^5.0.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^19.2.0",
+ "react-devtools-core": "^7.0.1"
+ },
+ "bin": {
+ "scc": "bin/server-config.js",
+ "server-config": "bin/server-config.js"
+ },
+ "devDependencies": {
+ "esbuild": "^0.28.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@alcalzone/ansi-tokenize": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz",
+ "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "is-fullwidth-code-point": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+ "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+ "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+ "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+ "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+ "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+ "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+ "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+ "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+ "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+ "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+ "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+ "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+ "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+ "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+ "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+ "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+ "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+ "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+ "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+ "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+ "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+ "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+ "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
+ "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
+ "license": "MIT",
+ "dependencies": {
+ "environment": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/auto-bind": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
+ "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
+ "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.20 <19 || >=20.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz",
+ "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==",
+ "license": "MIT",
+ "dependencies": {
+ "restore-cursor": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz",
+ "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==",
+ "license": "MIT",
+ "dependencies": {
+ "slice-ansi": "^9.0.0",
+ "string-width": "^8.2.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/code-excerpt": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
+ "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==",
+ "license": "MIT",
+ "dependencies": {
+ "convert-to-spaces": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/convert-to-spaces": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
+ "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/environment": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
+ "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.46.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
+ "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
+ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.0",
+ "@esbuild/android-arm": "0.28.0",
+ "@esbuild/android-arm64": "0.28.0",
+ "@esbuild/android-x64": "0.28.0",
+ "@esbuild/darwin-arm64": "0.28.0",
+ "@esbuild/darwin-x64": "0.28.0",
+ "@esbuild/freebsd-arm64": "0.28.0",
+ "@esbuild/freebsd-x64": "0.28.0",
+ "@esbuild/linux-arm": "0.28.0",
+ "@esbuild/linux-arm64": "0.28.0",
+ "@esbuild/linux-ia32": "0.28.0",
+ "@esbuild/linux-loong64": "0.28.0",
+ "@esbuild/linux-mips64el": "0.28.0",
+ "@esbuild/linux-ppc64": "0.28.0",
+ "@esbuild/linux-riscv64": "0.28.0",
+ "@esbuild/linux-s390x": "0.28.0",
+ "@esbuild/linux-x64": "0.28.0",
+ "@esbuild/netbsd-arm64": "0.28.0",
+ "@esbuild/netbsd-x64": "0.28.0",
+ "@esbuild/openbsd-arm64": "0.28.0",
+ "@esbuild/openbsd-x64": "0.28.0",
+ "@esbuild/openharmony-arm64": "0.28.0",
+ "@esbuild/sunos-x64": "0.28.0",
+ "@esbuild/win32-arm64": "0.28.0",
+ "@esbuild/win32-ia32": "0.28.0",
+ "@esbuild/win32-x64": "0.28.0"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/figures": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+ "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
+ "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ink": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.2.tgz",
+ "integrity": "sha512-cnkE2SsDC/gieJ+BD8+gWpXrZPMInv7agBYN5gcKVlQZYp+IKa/FKM5bp1OIuJFp3ZIuRK7ZNxY4MZR3tUzyfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@alcalzone/ansi-tokenize": "^0.3.0",
+ "ansi-escapes": "^7.3.0",
+ "ansi-styles": "^6.2.3",
+ "auto-bind": "^5.0.1",
+ "chalk": "^5.6.2",
+ "cli-boxes": "^4.0.1",
+ "cli-cursor": "^4.0.0",
+ "cli-truncate": "^6.0.0",
+ "code-excerpt": "^4.0.0",
+ "es-toolkit": "^1.45.1",
+ "indent-string": "^5.0.0",
+ "is-in-ci": "^2.0.0",
+ "patch-console": "^2.0.0",
+ "react-reconciler": "^0.33.0",
+ "scheduler": "^0.27.0",
+ "signal-exit": "^3.0.7",
+ "slice-ansi": "^9.0.0",
+ "stack-utils": "^2.0.6",
+ "string-width": "^8.2.0",
+ "terminal-size": "^4.0.1",
+ "type-fest": "^5.5.0",
+ "widest-line": "^6.0.0",
+ "wrap-ansi": "^10.0.0",
+ "ws": "^8.20.0",
+ "yoga-layout": "~3.2.1"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "peerDependencies": {
+ "@types/react": ">=19.2.0",
+ "react": ">=19.2.0",
+ "react-devtools-core": ">=6.1.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-devtools-core": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ink-select-input": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz",
+ "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "figures": "^6.1.0",
+ "to-rotated": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "ink": ">=5.0.0",
+ "react": ">=18.0.0"
+ }
+ },
+ "node_modules/ink-spinner": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz",
+ "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==",
+ "license": "MIT",
+ "dependencies": {
+ "cli-spinners": "^2.7.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "peerDependencies": {
+ "ink": ">=4.0.0",
+ "react": ">=18.0.0"
+ }
+ },
+ "node_modules/ink-text-input": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
+ "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "type-fest": "^4.18.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "ink": ">=5",
+ "react": ">=18"
+ }
+ },
+ "node_modules/ink-text-input/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
+ "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-in-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
+ "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
+ "license": "MIT",
+ "bin": {
+ "is-in-ci": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/patch-console": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz",
+ "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
+ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-devtools-core": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-7.0.1.tgz",
+ "integrity": "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==",
+ "license": "MIT",
+ "dependencies": {
+ "shell-quote": "^1.6.1",
+ "ws": "^7"
+ }
+ },
+ "node_modules/react-devtools-core/node_modules/ws": {
+ "version": "7.5.10",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+ "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.3.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-reconciler": {
+ "version": "0.33.0",
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
+ "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
+ "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==",
+ "license": "MIT",
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/slice-ansi": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
+ "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "is-fullwidth-code-point": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
+ "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/terminal-size": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
+ "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/to-rotated": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
+ "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
+ "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz",
+ "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==",
+ "license": "MIT",
+ "dependencies": {
+ "string-width": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
+ "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.3",
+ "string-width": "^8.2.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yoga-layout": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
index 3e87a67..f97ebfa 100644
--- a/package.json
+++ b/package.json
@@ -1,17 +1,30 @@
{
"name": "server-config-cli",
- "version": "0.1.0",
- "description": "Node.js CLI for bootstrapping server shell, ssh and frp client configuration.",
+ "version": "0.2.0",
+ "description": "Ink-based TUI for bootstrapping server shell, ssh and frp client configuration.",
"bin": {
"server-config": "bin/server-config.js",
"scc": "bin/server-config.js"
},
"scripts": {
- "check": "node --check bin/server-config.js && node --check src/cli.js && node --check test/frp-config.test.js",
- "test": "node --test"
+ "build": "esbuild src/app.jsx --bundle --platform=node --target=node18 --format=esm --jsx=automatic --outfile=dist/app.mjs --alias:react-devtools-core=./src/stubs/react-devtools-core.js --banner:js=\"import { createRequire as __cR } from 'node:module'; const require = __cR(import.meta.url);\"",
+ "build:watch": "npm run build -- --watch",
+ "check": "node --check bin/server-config.js && node --check src/lib/frp-config.js && node --check src/lib/runner.js && node --check src/lib/tasks.js && node --check test/frp-config.test.js",
+ "test": "node --test test/*.test.js"
},
"engines": {
"node": ">=18"
},
+ "dependencies": {
+ "ink": "^7.0.2",
+ "ink-select-input": "^6.2.0",
+ "ink-spinner": "^5.0.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^19.2.0",
+ "react-devtools-core": "^7.0.1"
+ },
+ "devDependencies": {
+ "esbuild": "^0.28.0"
+ },
"license": "MIT"
}
diff --git a/src/app.jsx b/src/app.jsx
new file mode 100644
index 0000000..c87b16a
--- /dev/null
+++ b/src/app.jsx
@@ -0,0 +1,106 @@
+import React, { useState, useCallback } from "react";
+import { render, Box, Text, useApp, useInput } from "ink";
+
+import MainMenu from "./screens/MainMenu.jsx";
+import FrpMenu from "./screens/FrpMenu.jsx";
+import TokenPrompt from "./screens/TokenPrompt.jsx";
+import ProxyForm from "./screens/ProxyForm.jsx";
+import ProxyList from "./screens/ProxyList.jsx";
+import PlanPreview from "./screens/PlanPreview.jsx";
+import RunLog from "./screens/RunLog.jsx";
+import FrpConfigForm from "./screens/FrpConfigForm.jsx";
+
+function App() {
+ const { exit } = useApp();
+ const [stack, setStack] = useState([{ name: "main" }]);
+ const screen = stack[stack.length - 1];
+
+ const push = useCallback((next) => {
+ setStack((current) => [...current, next]);
+ }, []);
+
+ const replace = useCallback((next) => {
+ setStack((current) => [...current.slice(0, -1), next]);
+ }, []);
+
+ const back = useCallback(() => {
+ setStack((current) => (current.length > 1 ? current.slice(0, -1) : current));
+ }, []);
+
+ const home = useCallback(() => {
+ setStack([{ name: "main" }]);
+ }, []);
+
+ useInput((input, key) => {
+ if (key.escape && stack.length > 1 && screen.name !== "run") {
+ back();
+ }
+ if (input === "q" && screen.name === "main") {
+ exit();
+ }
+ });
+
+ const nav = { push, replace, back, home, exit };
+
+ return (
+
+
+
+ {renderScreen(screen, nav)}
+
+
+
+ );
+}
+
+function renderScreen(screen, nav) {
+ switch (screen.name) {
+ case "main":
+ return ;
+ case "frp":
+ return ;
+ case "token":
+ return ;
+ case "frp-config-form":
+ return ;
+ case "proxy-form":
+ return ;
+ case "proxy-list":
+ return ;
+ case "plan":
+ return ;
+ case "run":
+ return ;
+ default:
+ return Unknown screen: {screen.name};
+ }
+}
+
+function Header() {
+ return (
+
+ server-config
+ zsh · ssh · frp client
+
+ );
+}
+
+function Footer({ screen }) {
+ const hint =
+ screen === "main"
+ ? "↑↓ select · Enter confirm · q quit"
+ : screen === "run"
+ ? "(Esc disabled while running)"
+ : "↑↓ select · Enter confirm · Esc back";
+ return (
+
+ {hint}
+
+ );
+}
+
+export function start() {
+ render();
+}
+
+start();
diff --git a/src/cli.js b/src/cli.js
deleted file mode 100644
index 65a9237..0000000
--- a/src/cli.js
+++ /dev/null
@@ -1,730 +0,0 @@
-"use strict";
-
-const fs = require("node:fs");
-const os = require("node:os");
-const path = require("node:path");
-const { randomBytes } = require("node:crypto");
-const { spawnSync } = require("node:child_process");
-
-const DEFAULT_FRP_VERSION = "0.58.1";
-const DEFAULT_FRP_ARCH = "amd64";
-const DEFAULT_FRP_SERVER_ADDR = "81.70.134.9";
-const DEFAULT_FRP_SERVER_PORT = 15443;
-const DEFAULT_SERVICE_NAME = "frpc";
-const NVM_INSTALL_VERSION = "v0.40.4";
-const NVM_INSTALL_COMMAND = `set -euo pipefail
-export NVM_DIR="$HOME/.nvm"
-if [ ! -s "$NVM_DIR/nvm.sh" ]; then
- if command -v curl >/dev/null 2>&1; then
- curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash
- elif command -v wget >/dev/null 2>&1; then
- wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash
- else
- echo "curl or wget is required to install nvm" >&2
- exit 1
- fi
-fi
-. "$NVM_DIR/nvm.sh"
-nvm install --lts
-nvm alias default 'lts/*'
-nvm use --lts`;
-
-function main(argv) {
- const parsed = parseArgs(argv);
-
- if (parsed.flags.help || parsed.positionals.length === 0) {
- printHelp();
- return;
- }
-
- const [group, action, ...rest] = parsed.positionals;
- const commandFlags = parseArgs(rest).flags;
- const flags = { ...parsed.flags, ...commandFlags };
- const runner = createRunner(flags);
-
- try {
- if (group === "zsh" && action === "install") {
- installZsh(runner, flags);
- return;
- }
-
- if (group === "ssh" && action === "install") {
- installSsh(runner);
- return;
- }
-
- if (group === "frp") {
- handleFrp(action, rest, flags, runner);
- return;
- }
-
- if (group === "bootstrap") {
- installZsh(runner, flags);
- installSsh(runner);
- installFrp(runner, flags);
- return;
- }
-
- fail(`Unknown command: ${[group, action].filter(Boolean).join(" ")}`);
- } catch (error) {
- fail(error.message);
- }
-}
-
-function handleFrp(action, argv, rootFlags, runner) {
- const parsed = parseArgs(argv);
- const flags = { ...rootFlags, ...parsed.flags };
-
- if (action === "install") {
- installFrp(runner, flags);
- return;
- }
-
- if (action === "init") {
- initFrpConfig(flags, runner);
- return;
- }
-
- if (action === "add") {
- const name = parsed.positionals[0];
- addFrpProxy(name, flags, runner);
- return;
- }
-
- if (action === "remove") {
- const name = parsed.positionals[0];
- removeFrpProxy(name, flags, runner);
- return;
- }
-
- if (action === "list") {
- listFrpProxies(flags);
- return;
- }
-
- if (action === "restart") {
- runner.run("sudo", ["systemctl", "restart", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]);
- return;
- }
-
- fail(`Unknown frp command: ${action || ""}`.trim());
-}
-
-function installZsh(runner, flags) {
- runner.run("sudo", ["apt", "update"]);
- runner.run("sudo", ["apt", "upgrade", "-y"]);
- runner.run("sudo", ["apt", "install", "zsh", "git", "curl", "wget", "-y"]);
- runner.run("chsh", ["-s", "/bin/zsh"]);
-
- runner.run("sh", [
- "-c",
- 'RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c "$(curl -fsSL https://gitee.com/pocmon/ohmyzsh/raw/master/tools/install.sh)"',
- ]);
- runner.run("bash", ["-c", NVM_INSTALL_COMMAND]);
-
- const customDir = process.env.ZSH_CUSTOM || path.join(os.homedir(), ".oh-my-zsh", "custom");
- installGitPlugin(
- runner,
- "https://github.com/zsh-users/zsh-autosuggestions",
- path.join(customDir, "plugins", "zsh-autosuggestions"),
- );
- installGitPlugin(
- runner,
- "https://github.com/zsh-users/zsh-syntax-highlighting.git",
- path.join(customDir, "plugins", "zsh-syntax-highlighting"),
- );
-
- updateZshrcPlugins(["git", "zsh-autosuggestions", "zsh-syntax-highlighting"], flags.dryRun);
-}
-
-function installGitPlugin(runner, repo, destination) {
- if (fs.existsSync(destination)) {
- runner.run("git", ["-C", destination, "pull", "--ff-only"]);
- return;
- }
-
- runner.run("git", ["clone", repo, destination]);
-}
-
-function updateZshrcPlugins(plugins, dryRun) {
- const zshrc = path.join(os.homedir(), ".zshrc");
- const pluginLine = `plugins=(${plugins.join(" ")})`;
-
- if (dryRun) {
- console.log(`[dry-run] update ${zshrc}: ${pluginLine}`);
- return;
- }
-
- const original = fs.existsSync(zshrc) ? fs.readFileSync(zshrc, "utf8") : "";
- const next = /^plugins=\([^)]*\)$/m.test(original)
- ? original.replace(/^plugins=\([^)]*\)$/m, pluginLine)
- : `${original.trimEnd()}\n${pluginLine}\n`;
-
- fs.writeFileSync(zshrc, next);
- console.log(`Updated ${zshrc}`);
-}
-
-function installSsh(runner) {
- runner.run("sudo", ["apt", "update"]);
- runner.run("sudo", ["apt", "install", "openssh-server", "-y"]);
- runner.run("sudo", ["systemctl", "enable", "--now", "ssh"]);
-}
-
-function installFrp(runner, flags) {
- const version = stringFlag(flags, "version", DEFAULT_FRP_VERSION);
- const arch = stringFlag(flags, "arch", DEFAULT_FRP_ARCH);
- const installDir = getInstallDir(flags, version, arch);
- const archiveName = `frp_${version}_linux_${arch}.tar.gz`;
- const extractedDir = `/tmp/frp_${version}_linux_${arch}`;
- const archivePath = `/tmp/${archiveName}`;
- const url = `https://github.com/fatedier/frp/releases/download/v${version}/${archiveName}`;
-
- runner.run("wget", ["-O", archivePath, url]);
- runner.run("tar", ["-zxf", archivePath, "-C", "/tmp"]);
- runner.run("sudo", ["mkdir", "-p", installDir]);
- runner.run("sudo", ["cp", "-f", path.join(extractedDir, "frpc"), path.join(installDir, "frpc")]);
- runner.run("sudo", ["chmod", "+x", path.join(installDir, "frpc")]);
-
- initFrpConfig({ ...flags, "install-dir": installDir }, runner);
- writeFrpService(flags, runner, installDir);
- runner.run("sudo", ["systemctl", "daemon-reload"]);
- runner.run("sudo", ["systemctl", "enable", "--now", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]);
-}
-
-function initFrpConfig(flags, runner) {
- const configPath = getConfigPath(flags);
- const force = Boolean(flags.force);
- const configExists = fs.existsSync(configPath);
-
- if (configExists && !force) {
- console.log(`${configPath} already exists. Use --force to rewrite it.`);
- return;
- }
-
- const token = stringFlag(flags, "token", process.env.FRP_TOKEN || "");
- if (!token) {
- throw new Error("frp token is required. Pass --token or set FRP_TOKEN.");
- }
-
- const proxies = configExists && force
- ? [...parseFrpConfig(fs.readFileSync(configPath, "utf8")).sections.values()].map((section) => ({
- name: section.name,
- ...section.values,
- }))
- : [];
-
- const config = renderFrpConfig({
- serverAddr: stringFlag(flags, "server-addr", DEFAULT_FRP_SERVER_ADDR),
- serverPort: numberFlag(flags, "server-port", DEFAULT_FRP_SERVER_PORT),
- "auth.method": "token",
- "auth.token": token,
- "transport.tls.enable": booleanFlag(flags, "tls-enable", false),
- "transport.tcpMux": booleanFlag(flags, "tcp-mux", true),
- "log.to": stringFlag(flags, "log-file", "/var/log/frpc.log"),
- "log.level": stringFlag(flags, "log-level", "info"),
- "log.maxDays": numberFlag(flags, "log-max-days", 7),
- }, proxies);
-
- writeFile(configPath, config, runner);
-}
-
-function writeFrpService(flags, runner, installDir) {
- const serviceName = stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME);
- const configPath = getConfigPath({ ...flags, "install-dir": installDir });
- const servicePath = `/etc/systemd/system/${serviceName}.service`;
- const service = `[Unit]
-Description=frp client
-After=network.target
-
-[Service]
-Type=simple
-ExecStart=${path.join(installDir, "frpc")} -c ${configPath}
-Restart=always
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
-`;
-
- writeFile(servicePath, service, runner);
-}
-
-function addFrpProxy(name, flags, runner) {
- if (!name) {
- throw new Error("frp add requires a proxy name, for example: frp add ssh --local-port 22 --remote-port 17227");
- }
-
- assertProxyName(name);
-
- const configPath = getConfigPath(flags);
- if (!fs.existsSync(configPath)) {
- throw new Error(`${configPath} does not exist. Run frp init or frp install first.`);
- }
-
- const localPort = numberFlag(flags, "local-port");
- const remotePort = numberFlag(flags, "remote-port");
- if (!localPort || !remotePort) {
- throw new Error("frp add requires --local-port and --remote-port.");
- }
-
- const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
- const proxyName = createUniqueProxyName(name, config.sections);
- config.sections.set(proxyName, {
- name: proxyName,
- values: {
- type: stringFlag(flags, "type", "tcp"),
- local_ip: stringFlag(flags, "local-ip", "127.0.0.1"),
- local_port: localPort,
- remote_port: remotePort,
- },
- });
-
- console.log(`Added frp proxy ${proxyName}`);
- writeFile(configPath, renderParsedFrpConfig(config), runner);
- restartServiceIfRequested(flags, runner);
-}
-
-function createUniqueProxyName(baseName, sections) {
- for (let attempt = 0; attempt < 10; attempt += 1) {
- const name = `${baseName}-${randomString(8)}`;
- if (!sections.has(name)) {
- return name;
- }
- }
-
- throw new Error(`Could not generate a unique proxy name for ${baseName}.`);
-}
-
-function randomString(length) {
- return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
-}
-
-function removeFrpProxy(name, flags, runner) {
- if (!name) {
- throw new Error("frp remove requires a proxy name.");
- }
-
- const configPath = getConfigPath(flags);
- const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
-
- if (!config.sections.delete(name)) {
- console.log(`No proxy named ${name} in ${configPath}`);
- return;
- }
-
- writeFile(configPath, renderParsedFrpConfig(config), runner);
- restartServiceIfRequested(flags, runner);
-}
-
-function listFrpProxies(flags) {
- const configPath = getConfigPath(flags);
- const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
-
- if (config.sections.size === 0) {
- console.log("No frp proxies configured.");
- return;
- }
-
- for (const section of config.sections.values()) {
- const values = section.values;
- console.log(`${section.name}: ${values.type || "tcp"} ${values.local_ip || "127.0.0.1"}:${values.local_port || "?"} -> remote:${values.remote_port || "?"}`);
- }
-}
-
-function restartServiceIfRequested(flags, runner) {
- if (!flags.restart) {
- return;
- }
-
- runner.run("sudo", ["systemctl", "restart", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]);
-}
-
-function parseFrpConfig(text) {
- const globals = [];
- const sections = new Map();
- let current = null;
-
- for (const line of text.split(/\r?\n/)) {
- const proxyMatch = line.match(/^\s*\[\[proxies\]\]\s*$/);
- if (proxyMatch) {
- current = { name: "", values: {} };
- continue;
- }
-
- const sectionMatch = line.match(/^\s*\[([A-Za-z0-9_.-]+)]\s*$/);
- if (sectionMatch) {
- current = { name: sectionMatch[1], values: {} };
- sections.set(current.name, current);
- continue;
- }
-
- if (!current) {
- globals.push(migrateFrpGlobalLine(line));
- continue;
- }
-
- const keyValue = parseTomlKeyValue(line);
- if (keyValue) {
- applyProxyConfigValue(current, keyValue, sections);
- }
- }
-
- return { globals: trimTrailingBlankLines(globals), sections };
-}
-
-function migrateFrpGlobalLine(line) {
- const keyMap = {
- server_addr: "serverAddr",
- server_port: "serverPort",
- token: "auth.token",
- tls_enable: "transport.tls.enable",
- tcp_mux: "transport.tcpMux",
- log_file: "log.to",
- log_level: "log.level",
- log_max_days: "log.maxDays",
- };
- const match = line.match(/^(\s*)([A-Za-z0-9_.-]+)(\s*=.*)$/);
- if (!match) {
- return line;
- }
-
- const [, indent, key, rest] = match;
- return `${indent}${keyMap[key] || key}${rest}`;
-}
-
-function applyProxyConfigValue(current, keyValue, sections) {
- const key = normalizeProxyKey(keyValue.key);
-
- if (key === "name") {
- if (current.name) {
- sections.delete(current.name);
- }
-
- current.name = String(keyValue.value);
- sections.set(current.name, current);
- return;
- }
-
- current.values[key] = keyValue.value;
-}
-
-function normalizeProxyKey(key) {
- const keyMap = {
- localIP: "local_ip",
- localPort: "local_port",
- remotePort: "remote_port",
- };
-
- return keyMap[key] || key;
-}
-
-function parseTomlKeyValue(line) {
- const withoutComment = line.replace(/\s+#.*$/, "").trim();
- const match = withoutComment.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
- if (!match) {
- return null;
- }
-
- const [, key, rawValue] = match;
- if (/^".*"$/.test(rawValue)) {
- return { key, value: rawValue.slice(1, -1) };
- }
-
- if (rawValue === "true") {
- return { key, value: true };
- }
-
- if (rawValue === "false") {
- return { key, value: false };
- }
-
- if (/^\d+$/.test(rawValue)) {
- return { key, value: Number(rawValue) };
- }
-
- return { key, value: rawValue };
-}
-
-function renderParsedFrpConfig(config) {
- const lines = [...config.globals];
-
- for (const section of config.sections.values()) {
- if (lines.length && lines[lines.length - 1] !== "") {
- lines.push("");
- }
-
- lines.push(renderProxy(section.name, section.values).trimEnd());
- }
-
- return `${lines.join("\n").trimEnd()}\n`;
-}
-
-function renderFrpConfig(globals, proxies) {
- const lines = Object.entries(globals).map(([key, value]) => `${key} = ${formatTomlValue(value)}`);
-
- for (const proxy of proxies) {
- lines.push("");
- lines.push(renderProxy(proxy.name, proxy));
- }
-
- return `${lines.join("\n").trimEnd()}\n`;
-}
-
-function renderProxy(name, values) {
- return `[[proxies]]
-name = ${formatTomlValue(name)}
-type = ${formatTomlValue(values.type || "tcp")}
-localIP = ${formatTomlValue(values.local_ip || "127.0.0.1")}
-localPort = ${formatTomlValue(values.local_port)}
-remotePort = ${formatTomlValue(values.remote_port)}
-`;
-}
-
-function formatTomlValue(value) {
- if (typeof value === "number" || typeof value === "boolean") {
- return String(value);
- }
-
- return JSON.stringify(String(value));
-}
-
-function writeFile(filePath, content, runner) {
- const dir = path.dirname(filePath);
-
- if (runner.dryRun) {
- console.log(`[dry-run] mkdir -p ${dir}`);
- console.log(`[dry-run] write ${filePath}`);
- console.log(content.trimEnd());
- return;
- }
-
- if (canWriteDirectly(filePath)) {
- fs.mkdirSync(dir, { recursive: true });
- fs.writeFileSync(filePath, content);
- console.log(`Wrote ${filePath}`);
- return;
- }
-
- runner.run("sudo", ["mkdir", "-p", dir]);
- const result = spawnSync("sudo", ["tee", filePath], {
- input: content,
- stdio: ["pipe", "ignore", "inherit"],
- encoding: "utf8",
- });
-
- if (result.status !== 0) {
- throw new Error(`Failed to write ${filePath}`);
- }
-
- console.log(`Wrote ${filePath}`);
-}
-
-function canWriteDirectly(filePath) {
- if (process.getuid && process.getuid() === 0) {
- return true;
- }
-
- const dir = path.dirname(filePath);
- if (fs.existsSync(filePath)) {
- return isWritable(filePath);
- }
-
- return fs.existsSync(dir) && isWritable(dir);
-}
-
-function isWritable(target) {
- try {
- fs.accessSync(target, fs.constants.W_OK);
- return true;
- } catch {
- return false;
- }
-}
-
-function createRunner(flags) {
- const dryRun = Boolean(flags.dryRun || flags["dry-run"]);
-
- return {
- dryRun,
- run(command, args) {
- if (dryRun) {
- console.log(`[dry-run] ${formatCommand(command, args)}`);
- return;
- }
-
- const result = spawnSync(command, args, { stdio: "inherit" });
- if (result.error) {
- throw result.error;
- }
-
- if (result.status !== 0) {
- throw new Error(`Command failed: ${formatCommand(command, args)}`);
- }
- },
- };
-}
-
-function formatCommand(command, args) {
- return [command, ...args].map(shellQuote).join(" ");
-}
-
-function shellQuote(value) {
- if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
- return value;
- }
-
- return `'${String(value).replace(/'/g, "'\\''")}'`;
-}
-
-function parseArgs(argv) {
- const flags = {};
- const positionals = [];
-
- for (let index = 0; index < argv.length; index += 1) {
- const arg = argv[index];
-
- if (arg === "--") {
- positionals.push(...argv.slice(index + 1));
- break;
- }
-
- if (arg.startsWith("--")) {
- const eqIndex = arg.indexOf("=");
- const rawKey = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex);
- const key = rawKey.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
- const value = eqIndex === -1 ? argv[index + 1] : arg.slice(eqIndex + 1);
-
- if (eqIndex === -1 && value && !value.startsWith("-")) {
- flags[key] = value;
- flags[toCamel(key)] = value;
- index += 1;
- } else {
- flags[key] = true;
- flags[toCamel(key)] = true;
- }
-
- continue;
- }
-
- if (arg === "-h") {
- flags.help = true;
- continue;
- }
-
- positionals.push(arg);
- }
-
- return { flags, positionals };
-}
-
-function toCamel(key) {
- return key.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
-}
-
-function getInstallDir(flags, version = DEFAULT_FRP_VERSION, arch = DEFAULT_FRP_ARCH) {
- return stringFlag(flags, "install-dir", `/opt/frp/frp_${version}_linux_${arch}`);
-}
-
-function getConfigPath(flags) {
- return stringFlag(flags, "config", path.join(getInstallDir(flags), "frpc.toml"));
-}
-
-function stringFlag(flags, key, fallback = undefined) {
- const value = flags[key] ?? flags[toCamel(key)] ?? fallback;
- return value === undefined ? undefined : String(value);
-}
-
-function numberFlag(flags, key, fallback = undefined) {
- const value = flags[key] ?? flags[toCamel(key)] ?? fallback;
- if (value === undefined) {
- return undefined;
- }
-
- const parsed = Number(value);
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
- throw new Error(`--${key} must be an integer between 1 and 65535.`);
- }
-
- return parsed;
-}
-
-function booleanFlag(flags, key, fallback) {
- const value = flags[key] ?? flags[toCamel(key)] ?? fallback;
- if (typeof value === "boolean") {
- return value;
- }
-
- if (String(value).toLowerCase() === "true") {
- return true;
- }
-
- if (String(value).toLowerCase() === "false") {
- return false;
- }
-
- throw new Error(`--${key} must be true or false.`);
-}
-
-function assertProxyName(name) {
- if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
- throw new Error("Proxy name may only contain letters, numbers, underscore, dash and dot.");
- }
-}
-
-function trimTrailingBlankLines(lines) {
- const next = [...lines];
- while (next.length && next[next.length - 1].trim() === "") {
- next.pop();
- }
-
- return next;
-}
-
-function printHelp() {
- console.log(`Usage:
- server-config zsh install [--dry-run]
- server-config ssh install [--dry-run]
- server-config bootstrap --token [--dry-run]
-
- server-config frp install --token [options]
- server-config frp init --token [options]
- server-config frp add --local-port --remote-port [options]
- server-config frp remove [--restart]
- server-config frp list [--config ]
- server-config frp restart [--service-name frpc]
-
-Common options:
- --dry-run Print commands and file changes without executing them.
-
-frp options:
- --version Default: ${DEFAULT_FRP_VERSION}
- --arch Default: ${DEFAULT_FRP_ARCH}
- --install-dir Default: /opt/frp/frp__linux_
- --config Default: /frpc.toml
- --service-name Default: ${DEFAULT_SERVICE_NAME}
- --server-addr Default: ${DEFAULT_FRP_SERVER_ADDR}
- --server-port Default: ${DEFAULT_FRP_SERVER_PORT}
- --token Or set FRP_TOKEN.
- --tls-enable Default: false
- --tcp-mux Default: true
- --restart Restart frpc after add/remove.
-
-Examples:
- server-config frp install --token "$FRP_TOKEN"
- server-config frp add ssh --local-port 22 --remote-port 17227 --restart
- server-config frp add mysql --local-port 3306 --remote-port 33061 --restart
-`);
-}
-
-function fail(message) {
- console.error(message);
- process.exitCode = 1;
-}
-
-module.exports = {
- main,
- createUniqueProxyName,
- parseArgs,
- parseFrpConfig,
- renderParsedFrpConfig,
-};
diff --git a/src/lib/frp-config.js b/src/lib/frp-config.js
new file mode 100644
index 0000000..1bddb03
--- /dev/null
+++ b/src/lib/frp-config.js
@@ -0,0 +1,226 @@
+"use strict";
+
+const path = require("node:path");
+const { randomBytes } = require("node:crypto");
+
+const DEFAULT_FRP_VERSION = "0.58.1";
+const DEFAULT_FRP_ARCH = "amd64";
+const DEFAULT_FRP_SERVER_ADDR = "81.70.134.9";
+const DEFAULT_FRP_SERVER_PORT = 15443;
+const DEFAULT_SERVICE_NAME = "frpc";
+
+function defaultInstallDir(version = DEFAULT_FRP_VERSION, arch = DEFAULT_FRP_ARCH) {
+ return `/opt/frp/frp_${version}_linux_${arch}`;
+}
+
+function defaultConfigPath(installDir) {
+ return path.join(installDir, "frpc.toml");
+}
+
+function parseFrpConfig(text) {
+ const globals = [];
+ const sections = new Map();
+ let current = null;
+
+ for (const line of text.split(/\r?\n/)) {
+ if (/^\s*\[\[proxies\]\]\s*$/.test(line)) {
+ current = { name: "", values: {} };
+ continue;
+ }
+
+ const sectionMatch = line.match(/^\s*\[([A-Za-z0-9_.-]+)]\s*$/);
+ if (sectionMatch) {
+ current = { name: sectionMatch[1], values: {} };
+ sections.set(current.name, current);
+ continue;
+ }
+
+ if (!current) {
+ globals.push(migrateFrpGlobalLine(line));
+ continue;
+ }
+
+ const keyValue = parseTomlKeyValue(line);
+ if (keyValue) {
+ applyProxyConfigValue(current, keyValue, sections);
+ }
+ }
+
+ return { globals: trimTrailingBlankLines(globals), sections };
+}
+
+function migrateFrpGlobalLine(line) {
+ const keyMap = {
+ server_addr: "serverAddr",
+ server_port: "serverPort",
+ token: "auth.token",
+ tls_enable: "transport.tls.enable",
+ tcp_mux: "transport.tcpMux",
+ log_file: "log.to",
+ log_level: "log.level",
+ log_max_days: "log.maxDays",
+ };
+ const match = line.match(/^(\s*)([A-Za-z0-9_.-]+)(\s*=.*)$/);
+ if (!match) {
+ return line;
+ }
+
+ const [, indent, key, rest] = match;
+ return `${indent}${keyMap[key] || key}${rest}`;
+}
+
+function applyProxyConfigValue(current, keyValue, sections) {
+ const key = normalizeProxyKey(keyValue.key);
+
+ if (key === "name") {
+ if (current.name) {
+ sections.delete(current.name);
+ }
+
+ current.name = String(keyValue.value);
+ sections.set(current.name, current);
+ return;
+ }
+
+ current.values[key] = keyValue.value;
+}
+
+function normalizeProxyKey(key) {
+ const keyMap = {
+ localIP: "local_ip",
+ localPort: "local_port",
+ remotePort: "remote_port",
+ };
+
+ return keyMap[key] || key;
+}
+
+function parseTomlKeyValue(line) {
+ const withoutComment = line.replace(/\s+#.*$/, "").trim();
+ const match = withoutComment.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
+ if (!match) {
+ return null;
+ }
+
+ const [, key, rawValue] = match;
+ if (/^".*"$/.test(rawValue)) {
+ return { key, value: rawValue.slice(1, -1) };
+ }
+
+ if (rawValue === "true") {
+ return { key, value: true };
+ }
+
+ if (rawValue === "false") {
+ return { key, value: false };
+ }
+
+ if (/^\d+$/.test(rawValue)) {
+ return { key, value: Number(rawValue) };
+ }
+
+ return { key, value: rawValue };
+}
+
+function renderParsedFrpConfig(config) {
+ const lines = [...config.globals];
+
+ for (const section of config.sections.values()) {
+ if (lines.length && lines[lines.length - 1] !== "") {
+ lines.push("");
+ }
+
+ lines.push(renderProxy(section.name, section.values).trimEnd());
+ }
+
+ return `${lines.join("\n").trimEnd()}\n`;
+}
+
+function renderFrpConfig(globals, proxies) {
+ const lines = Object.entries(globals).map(([key, value]) => `${key} = ${formatTomlValue(value)}`);
+
+ for (const proxy of proxies) {
+ lines.push("");
+ lines.push(renderProxy(proxy.name, proxy));
+ }
+
+ return `${lines.join("\n").trimEnd()}\n`;
+}
+
+function renderProxy(name, values) {
+ return `[[proxies]]
+name = ${formatTomlValue(name)}
+type = ${formatTomlValue(values.type || "tcp")}
+localIP = ${formatTomlValue(values.local_ip || "127.0.0.1")}
+localPort = ${formatTomlValue(values.local_port)}
+remotePort = ${formatTomlValue(values.remote_port)}
+`;
+}
+
+function formatTomlValue(value) {
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+
+ return JSON.stringify(String(value));
+}
+
+function createUniqueProxyName(baseName, sections) {
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ const name = `${baseName}-${randomString(8)}`;
+ if (!sections.has(name)) {
+ return name;
+ }
+ }
+
+ throw new Error(`Could not generate a unique proxy name for ${baseName}.`);
+}
+
+function randomString(length) {
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
+}
+
+function assertProxyName(name) {
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
+ throw new Error("Proxy name may only contain letters, numbers, underscore, dash and dot.");
+ }
+}
+
+function trimTrailingBlankLines(lines) {
+ const next = [...lines];
+ while (next.length && next[next.length - 1].trim() === "") {
+ next.pop();
+ }
+
+ return next;
+}
+
+function buildGlobals({ serverAddr, serverPort, token, tlsEnable, tcpMux, logFile, logLevel, logMaxDays }) {
+ return {
+ serverAddr,
+ serverPort,
+ "auth.method": "token",
+ "auth.token": token,
+ "transport.tls.enable": tlsEnable,
+ "transport.tcpMux": tcpMux,
+ "log.to": logFile,
+ "log.level": logLevel,
+ "log.maxDays": logMaxDays,
+ };
+}
+
+module.exports = {
+ DEFAULT_FRP_VERSION,
+ DEFAULT_FRP_ARCH,
+ DEFAULT_FRP_SERVER_ADDR,
+ DEFAULT_FRP_SERVER_PORT,
+ DEFAULT_SERVICE_NAME,
+ defaultInstallDir,
+ defaultConfigPath,
+ parseFrpConfig,
+ renderParsedFrpConfig,
+ renderFrpConfig,
+ buildGlobals,
+ createUniqueProxyName,
+ assertProxyName,
+};
diff --git a/src/lib/runner.js b/src/lib/runner.js
new file mode 100644
index 0000000..aab523a
--- /dev/null
+++ b/src/lib/runner.js
@@ -0,0 +1,229 @@
+"use strict";
+
+const fs = require("node:fs");
+const path = require("node:path");
+const { EventEmitter } = require("node:events");
+const { spawn } = require("node:child_process");
+
+function startRun(steps) {
+ const emitter = new EventEmitter();
+ const state = { cancelled: false, child: null };
+
+ setImmediate(async () => {
+ for (let index = 0; index < steps.length; index += 1) {
+ if (state.cancelled) {
+ emitter.emit("event", { type: "done", success: false, cancelled: true });
+ return;
+ }
+
+ const step = steps[index];
+ emitter.emit("event", { type: "step-start", index, step });
+
+ try {
+ await runStep(step, index, emitter, state);
+ if (state.cancelled) {
+ emitter.emit("event", { type: "step-done", index, status: "cancelled" });
+ emitter.emit("event", { type: "done", success: false, cancelled: true });
+ return;
+ }
+
+ emitter.emit("event", { type: "step-done", index, status: "ok" });
+ } catch (error) {
+ emitter.emit("event", {
+ type: "step-done",
+ index,
+ status: "failed",
+ error: error.message || String(error),
+ });
+ emitter.emit("event", { type: "done", success: false });
+ return;
+ }
+ }
+
+ emitter.emit("event", { type: "done", success: true });
+ });
+
+ return {
+ on(event, handler) {
+ emitter.on(event, handler);
+ return this;
+ },
+ cancel() {
+ state.cancelled = true;
+ if (state.child) {
+ try {
+ state.child.kill("SIGTERM");
+ } catch {
+ // best effort
+ }
+ }
+ },
+ };
+}
+
+function runStep(step, index, emitter, state) {
+ if (step.kind === "run") {
+ return runCommand(step, index, emitter, state);
+ }
+ if (step.kind === "write") {
+ return writeStep(step, index, emitter, state);
+ }
+ if (step.kind === "zshrc-plugins") {
+ return updateZshrcPlugins(step, index, emitter);
+ }
+
+ return Promise.reject(new Error(`Unknown step kind: ${step.kind}`));
+}
+
+function runCommand(step, index, emitter, state) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(step.command, step.args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ env: { ...process.env, ...step.env },
+ });
+ state.child = child;
+
+ const log = (stream) => (chunk) => {
+ const text = chunk.toString("utf8");
+ for (const line of text.split(/\r?\n/)) {
+ if (line.length === 0) continue;
+ emitter.emit("event", { type: "log", index, stream, text: line });
+ }
+ };
+
+ child.stdout.on("data", log("stdout"));
+ child.stderr.on("data", log("stderr"));
+
+ child.on("error", (error) => {
+ state.child = null;
+ reject(error);
+ });
+
+ child.on("close", (code, signal) => {
+ state.child = null;
+ if (state.cancelled) {
+ resolve();
+ return;
+ }
+ if (code === 0) {
+ resolve();
+ return;
+ }
+ const detail = signal ? `signal ${signal}` : `exit code ${code}`;
+ reject(new Error(`${step.command} ${(step.args || []).join(" ")} failed (${detail})`));
+ });
+ });
+}
+
+function writeStep(step, index, emitter, state) {
+ const dir = path.dirname(step.path);
+
+ if (canWriteDirectly(step.path)) {
+ try {
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(step.path, step.content);
+ emitter.emit("event", { type: "log", index, stream: "stdout", text: `wrote ${step.path}` });
+ return Promise.resolve();
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ }
+
+ return new Promise((resolve, reject) => {
+ const mkdir = spawn("sudo", ["mkdir", "-p", dir], { stdio: ["ignore", "pipe", "pipe"] });
+ state.child = mkdir;
+ mkdir.stderr.on("data", (chunk) => {
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
+ if (line) emitter.emit("event", { type: "log", index, stream: "stderr", text: line });
+ }
+ });
+ mkdir.on("error", reject);
+ mkdir.on("close", (code) => {
+ state.child = null;
+ if (code !== 0) {
+ reject(new Error(`sudo mkdir -p ${dir} failed (exit ${code})`));
+ return;
+ }
+
+ const tee = spawn("sudo", ["tee", step.path], { stdio: ["pipe", "ignore", "pipe"] });
+ state.child = tee;
+ tee.stderr.on("data", (chunk) => {
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
+ if (line) emitter.emit("event", { type: "log", index, stream: "stderr", text: line });
+ }
+ });
+ tee.on("error", reject);
+ tee.on("close", (teeCode) => {
+ state.child = null;
+ if (teeCode === 0) {
+ emitter.emit("event", { type: "log", index, stream: "stdout", text: `wrote ${step.path}` });
+ resolve();
+ } else {
+ reject(new Error(`sudo tee ${step.path} failed (exit ${teeCode})`));
+ }
+ });
+ tee.stdin.end(step.content);
+ });
+ });
+}
+
+function updateZshrcPlugins(step, index, emitter) {
+ const zshrc = step.path;
+ const pluginLine = `plugins=(${step.plugins.join(" ")})`;
+
+ try {
+ const original = fs.existsSync(zshrc) ? fs.readFileSync(zshrc, "utf8") : "";
+ const next = /^plugins=\([^)]*\)$/m.test(original)
+ ? original.replace(/^plugins=\([^)]*\)$/m, pluginLine)
+ : `${original.trimEnd()}\n${pluginLine}\n`;
+ fs.writeFileSync(zshrc, next);
+ emitter.emit("event", { type: "log", index, stream: "stdout", text: `updated ${zshrc}` });
+ return Promise.resolve();
+ } catch (error) {
+ return Promise.reject(error);
+ }
+}
+
+function canWriteDirectly(filePath) {
+ if (process.getuid && process.getuid() === 0) {
+ return true;
+ }
+
+ const dir = path.dirname(filePath);
+ if (fs.existsSync(filePath)) {
+ return isWritable(filePath);
+ }
+
+ return fs.existsSync(dir) && isWritable(dir);
+}
+
+function isWritable(target) {
+ try {
+ fs.accessSync(target, fs.constants.W_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function formatStep(step) {
+ if (step.kind === "run") {
+ return `${step.command} ${(step.args || []).map(quoteIfNeeded).join(" ")}`.trim();
+ }
+ if (step.kind === "write") {
+ return `write ${step.path}`;
+ }
+ if (step.kind === "zshrc-plugins") {
+ return `update plugins in ${step.path}`;
+ }
+ return step.label || step.kind;
+}
+
+function quoteIfNeeded(value) {
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
+ return value;
+ }
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
+}
+
+module.exports = { startRun, formatStep };
diff --git a/src/lib/tasks.js b/src/lib/tasks.js
new file mode 100644
index 0000000..e384096
--- /dev/null
+++ b/src/lib/tasks.js
@@ -0,0 +1,335 @@
+"use strict";
+
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const {
+ DEFAULT_FRP_VERSION,
+ DEFAULT_FRP_ARCH,
+ DEFAULT_FRP_SERVER_ADDR,
+ DEFAULT_FRP_SERVER_PORT,
+ DEFAULT_SERVICE_NAME,
+ defaultInstallDir,
+ defaultConfigPath,
+ parseFrpConfig,
+ renderParsedFrpConfig,
+ renderFrpConfig,
+ buildGlobals,
+ createUniqueProxyName,
+ assertProxyName,
+} = require("./frp-config");
+
+const NVM_INSTALL_VERSION = "v0.40.4";
+const NVM_INSTALL_COMMAND = `set -euo pipefail
+export NVM_DIR="$HOME/.nvm"
+if [ ! -s "$NVM_DIR/nvm.sh" ]; then
+ if command -v curl >/dev/null 2>&1; then
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash
+ else
+ echo "curl or wget is required to install nvm" >&2
+ exit 1
+ fi
+fi
+. "$NVM_DIR/nvm.sh"
+nvm install --lts
+nvm alias default 'lts/*'
+nvm use --lts`;
+
+const OH_MY_ZSH_COMMAND =
+ 'RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c "$(curl -fsSL https://gitee.com/pocmon/ohmyzsh/raw/master/tools/install.sh)"';
+
+function zshInstallPlan() {
+ const customDir = process.env.ZSH_CUSTOM || path.join(os.homedir(), ".oh-my-zsh", "custom");
+ const autosuggestionsDir = path.join(customDir, "plugins", "zsh-autosuggestions");
+ const syntaxHighlightingDir = path.join(customDir, "plugins", "zsh-syntax-highlighting");
+
+ const steps = [
+ run("apt update", "sudo", ["apt", "update"]),
+ run("apt upgrade", "sudo", ["apt", "upgrade", "-y"]),
+ run("install zsh & friends", "sudo", ["apt", "install", "zsh", "git", "curl", "wget", "-y"]),
+ run("change shell to zsh", "chsh", ["-s", "/bin/zsh"]),
+ run("install oh-my-zsh", "sh", ["-c", OH_MY_ZSH_COMMAND]),
+ run("install nvm + node LTS", "bash", ["-c", NVM_INSTALL_COMMAND]),
+ gitPluginStep("zsh-autosuggestions", "https://github.com/zsh-users/zsh-autosuggestions", autosuggestionsDir),
+ gitPluginStep(
+ "zsh-syntax-highlighting",
+ "https://github.com/zsh-users/zsh-syntax-highlighting.git",
+ syntaxHighlightingDir,
+ ),
+ {
+ kind: "zshrc-plugins",
+ label: "update ~/.zshrc plugin list",
+ path: path.join(os.homedir(), ".zshrc"),
+ plugins: ["git", "zsh-autosuggestions", "zsh-syntax-highlighting"],
+ },
+ ];
+
+ return { title: "Install zsh + oh-my-zsh + nvm", steps };
+}
+
+function gitPluginStep(name, repo, destination) {
+ if (fs.existsSync(destination)) {
+ return run(`update ${name}`, "git", ["-C", destination, "pull", "--ff-only"]);
+ }
+ return run(`clone ${name}`, "git", ["clone", repo, destination]);
+}
+
+function sshInstallPlan() {
+ return {
+ title: "Install OpenSSH server",
+ steps: [
+ run("apt update", "sudo", ["apt", "update"]),
+ run("install openssh-server", "sudo", ["apt", "install", "openssh-server", "-y"]),
+ run("enable + start ssh", "sudo", ["systemctl", "enable", "--now", "ssh"]),
+ ],
+ };
+}
+
+function frpInstallPlan(options = {}) {
+ const version = options.version || DEFAULT_FRP_VERSION;
+ const arch = options.arch || DEFAULT_FRP_ARCH;
+ const installDir = options.installDir || defaultInstallDir(version, arch);
+ const configPath = options.configPath || defaultConfigPath(installDir);
+ const serviceName = options.serviceName || DEFAULT_SERVICE_NAME;
+ const archiveName = `frp_${version}_linux_${arch}.tar.gz`;
+ const archivePath = `/tmp/${archiveName}`;
+ const extractedDir = `/tmp/frp_${version}_linux_${arch}`;
+ const url = `https://github.com/fatedier/frp/releases/download/v${version}/${archiveName}`;
+
+ const token = requireToken(options.token);
+ const force = Boolean(options.force);
+
+ const proxies = (fs.existsSync(configPath) && force)
+ ? [...parseFrpConfig(fs.readFileSync(configPath, "utf8")).sections.values()].map((section) => ({
+ name: section.name,
+ ...section.values,
+ }))
+ : [];
+
+ const configContent = renderFrpConfig(buildGlobalsFromOptions(token, options), proxies);
+ const serviceContent = renderSystemdService(installDir, configPath);
+
+ const steps = [
+ run("download frp archive", "wget", ["-O", archivePath, url]),
+ run("extract archive", "tar", ["-zxf", archivePath, "-C", "/tmp"]),
+ run("create install dir", "sudo", ["mkdir", "-p", installDir]),
+ run("copy frpc binary", "sudo", [
+ "cp",
+ "-f",
+ path.join(extractedDir, "frpc"),
+ path.join(installDir, "frpc"),
+ ]),
+ run("chmod +x frpc", "sudo", ["chmod", "+x", path.join(installDir, "frpc")]),
+ ];
+
+ if (!fs.existsSync(configPath) || force) {
+ steps.push(writeFile(`write ${path.basename(configPath)}`, configPath, configContent));
+ }
+ steps.push(writeFile(`write ${serviceName}.service`, `/etc/systemd/system/${serviceName}.service`, serviceContent));
+ steps.push(run("systemctl daemon-reload", "sudo", ["systemctl", "daemon-reload"]));
+ steps.push(run(`enable + start ${serviceName}`, "sudo", ["systemctl", "enable", "--now", serviceName]));
+
+ return { title: `Install frp ${version} (${serviceName})`, steps };
+}
+
+function frpInitConfigPlan(options = {}) {
+ const installDir = options.installDir || defaultInstallDir();
+ const configPath = options.configPath || defaultConfigPath(installDir);
+ const exists = fs.existsSync(configPath);
+
+ if (exists && !options.force) {
+ throw new Error(`${configPath} already exists. Re-run with --force to rewrite.`);
+ }
+
+ const token = requireToken(options.token);
+ const proxies = (exists && options.force)
+ ? [...parseFrpConfig(fs.readFileSync(configPath, "utf8")).sections.values()].map((section) => ({
+ name: section.name,
+ ...section.values,
+ }))
+ : [];
+
+ const content = renderFrpConfig(buildGlobalsFromOptions(token, options), proxies);
+
+ return {
+ title: `Rewrite ${configPath}`,
+ steps: [writeFile(`write ${path.basename(configPath)}`, configPath, content)],
+ };
+}
+
+function frpAddProxyPlan(options) {
+ const baseName = options.name;
+ if (!baseName) {
+ throw new Error("Proxy name is required.");
+ }
+ assertProxyName(baseName);
+ if (!options.localPort || !options.remotePort) {
+ throw new Error("local port and remote port are required.");
+ }
+
+ const installDir = options.installDir || defaultInstallDir();
+ const configPath = options.configPath || defaultConfigPath(installDir);
+ if (!fs.existsSync(configPath)) {
+ throw new Error(`${configPath} does not exist. Install frp or run init first.`);
+ }
+
+ const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
+ const proxyName = createUniqueProxyName(baseName, config.sections);
+ config.sections.set(proxyName, {
+ name: proxyName,
+ values: {
+ type: options.type || "tcp",
+ local_ip: options.localIp || "127.0.0.1",
+ local_port: options.localPort,
+ remote_port: options.remotePort,
+ },
+ });
+
+ const content = renderParsedFrpConfig(config);
+ const steps = [writeFile(`write proxy ${proxyName}`, configPath, content)];
+ if (options.restart) {
+ steps.push(run(`restart ${options.serviceName || DEFAULT_SERVICE_NAME}`, "sudo", [
+ "systemctl",
+ "restart",
+ options.serviceName || DEFAULT_SERVICE_NAME,
+ ]));
+ }
+
+ return {
+ title: `Add proxy ${proxyName}`,
+ steps,
+ meta: { proxyName },
+ };
+}
+
+function frpRemoveProxyPlan(options) {
+ if (!options.name) {
+ throw new Error("Proxy name is required.");
+ }
+
+ const installDir = options.installDir || defaultInstallDir();
+ const configPath = options.configPath || defaultConfigPath(installDir);
+ if (!fs.existsSync(configPath)) {
+ throw new Error(`${configPath} does not exist.`);
+ }
+
+ const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
+ if (!config.sections.delete(options.name)) {
+ throw new Error(`No proxy named ${options.name} in ${configPath}.`);
+ }
+
+ const content = renderParsedFrpConfig(config);
+ const steps = [writeFile(`remove proxy ${options.name}`, configPath, content)];
+ if (options.restart) {
+ steps.push(run(`restart ${options.serviceName || DEFAULT_SERVICE_NAME}`, "sudo", [
+ "systemctl",
+ "restart",
+ options.serviceName || DEFAULT_SERVICE_NAME,
+ ]));
+ }
+
+ return { title: `Remove proxy ${options.name}`, steps };
+}
+
+function frpRestartPlan(options = {}) {
+ const serviceName = options.serviceName || DEFAULT_SERVICE_NAME;
+ return {
+ title: `Restart ${serviceName}`,
+ steps: [run(`restart ${serviceName}`, "sudo", ["systemctl", "restart", serviceName])],
+ };
+}
+
+function bootstrapPlan(options) {
+ const zsh = zshInstallPlan();
+ const ssh = sshInstallPlan();
+ const frp = frpInstallPlan(options);
+
+ return {
+ title: "Bootstrap: zsh + ssh + frp",
+ steps: [...zsh.steps, ...ssh.steps, ...frp.steps],
+ };
+}
+
+function listProxies(options = {}) {
+ const installDir = options.installDir || defaultInstallDir();
+ const configPath = options.configPath || defaultConfigPath(installDir);
+ if (!fs.existsSync(configPath)) {
+ throw new Error(`${configPath} does not exist.`);
+ }
+
+ const config = parseFrpConfig(fs.readFileSync(configPath, "utf8"));
+ return [...config.sections.values()].map((section) => ({
+ name: section.name,
+ type: section.values.type || "tcp",
+ localIp: section.values.local_ip || "127.0.0.1",
+ localPort: section.values.local_port,
+ remotePort: section.values.remote_port,
+ }));
+}
+
+function buildGlobalsFromOptions(token, options) {
+ return buildGlobals({
+ serverAddr: options.serverAddr || DEFAULT_FRP_SERVER_ADDR,
+ serverPort: options.serverPort || DEFAULT_FRP_SERVER_PORT,
+ token,
+ tlsEnable: options.tlsEnable ?? false,
+ tcpMux: options.tcpMux ?? true,
+ logFile: options.logFile || "/var/log/frpc.log",
+ logLevel: options.logLevel || "info",
+ logMaxDays: options.logMaxDays ?? 7,
+ });
+}
+
+function renderSystemdService(installDir, configPath) {
+ return `[Unit]
+Description=frp client
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=${path.join(installDir, "frpc")} -c ${configPath}
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+`;
+}
+
+function requireToken(value) {
+ const token = value || process.env.FRP_TOKEN || "";
+ if (!token) {
+ throw new Error("frp token is required.");
+ }
+ return token;
+}
+
+function run(label, command, args) {
+ return { kind: "run", label, command, args };
+}
+
+function writeFile(label, filePath, content) {
+ return { kind: "write", label, path: filePath, content };
+}
+
+module.exports = {
+ zshInstallPlan,
+ sshInstallPlan,
+ frpInstallPlan,
+ frpInitConfigPlan,
+ frpAddProxyPlan,
+ frpRemoveProxyPlan,
+ frpRestartPlan,
+ bootstrapPlan,
+ listProxies,
+ DEFAULTS: {
+ version: DEFAULT_FRP_VERSION,
+ arch: DEFAULT_FRP_ARCH,
+ serverAddr: DEFAULT_FRP_SERVER_ADDR,
+ serverPort: DEFAULT_FRP_SERVER_PORT,
+ serviceName: DEFAULT_SERVICE_NAME,
+ },
+};
diff --git a/src/screens/FrpConfigForm.jsx b/src/screens/FrpConfigForm.jsx
new file mode 100644
index 0000000..45df590
--- /dev/null
+++ b/src/screens/FrpConfigForm.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from "react";
+import { Box, Text, useInput } from "ink";
+import TextInput from "ink-text-input";
+
+import {
+ bootstrapPlan,
+ frpInstallPlan,
+ frpInitConfigPlan,
+ DEFAULTS,
+} from "../lib/tasks.js";
+import { defaultInstallDir } from "../lib/frp-config.js";
+
+const FIELDS = [
+ { key: "serverAddr", label: "Server address" },
+ { key: "serverPort", label: "Server port" },
+ { key: "installDir", label: "Install dir" },
+];
+
+export default function FrpConfigForm({ nav, token, purpose }) {
+ const [values, setValues] = useState({
+ serverAddr: DEFAULTS.serverAddr,
+ serverPort: String(DEFAULTS.serverPort),
+ installDir: defaultInstallDir(),
+ });
+ const [step, setStep] = useState(0);
+ const [error, setError] = useState(null);
+
+ useInput((input, key) => {
+ if (key.tab || key.downArrow) {
+ setStep((s) => Math.min(s + 1, FIELDS.length - 1));
+ return;
+ }
+ if (key.upArrow) {
+ setStep((s) => Math.max(s - 1, 0));
+ return;
+ }
+ if (key.return) {
+ if (step < FIELDS.length - 1) {
+ setStep((s) => s + 1);
+ return;
+ }
+ submit();
+ }
+ });
+
+ const update = (key) => (value) => {
+ setValues((v) => ({ ...v, [key]: value }));
+ };
+
+ const submit = () => {
+ const port = Number(values.serverPort);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ setError("Server port must be an integer between 1 and 65535.");
+ return;
+ }
+
+ const options = {
+ token,
+ serverAddr: values.serverAddr.trim() || DEFAULTS.serverAddr,
+ serverPort: port,
+ installDir: values.installDir.trim() || defaultInstallDir(),
+ };
+
+ try {
+ let plan;
+ if (purpose === "bootstrap") {
+ plan = bootstrapPlan(options);
+ } else if (purpose === "frp-install") {
+ plan = frpInstallPlan(options);
+ } else if (purpose === "frp-init") {
+ plan = frpInitConfigPlan({ ...options, force: true });
+ } else {
+ setError(`Unknown purpose: ${purpose}`);
+ return;
+ }
+ nav.replace({ name: "plan", props: { plan, origin: purpose === "bootstrap" ? "main" : "frp" } });
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ return (
+
+ FRP configuration ({purpose}):
+
+ {FIELDS.map((field, index) => (
+
+
+
+ {index === step ? "› " : " "}
+ {field.label}
+
+
+
+
+ ))}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ Tab/↓ next · ↑ prev · Enter on last field submits · Esc back
+
+
+
+ );
+}
diff --git a/src/screens/FrpMenu.jsx b/src/screens/FrpMenu.jsx
new file mode 100644
index 0000000..41e69ae
--- /dev/null
+++ b/src/screens/FrpMenu.jsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { Box, Text } from "ink";
+import SelectInput from "ink-select-input";
+
+import { frpRestartPlan } from "../lib/tasks.js";
+
+export default function FrpMenu({ nav }) {
+ const items = [
+ { label: "Install frp client + service", value: "install" },
+ { label: "Init / rewrite frpc.toml", value: "init" },
+ { label: "Add proxy", value: "add" },
+ { label: "List / remove proxies", value: "list" },
+ { label: "Restart frpc", value: "restart" },
+ { label: "← Back", value: "back" },
+ ];
+
+ const handleSelect = (item) => {
+ switch (item.value) {
+ case "install":
+ nav.push({ name: "token", props: { purpose: "frp-install", origin: "frp" } });
+ return;
+ case "init":
+ nav.push({ name: "token", props: { purpose: "frp-init", origin: "frp" } });
+ return;
+ case "add":
+ nav.push({ name: "proxy-form", props: {} });
+ return;
+ case "list":
+ nav.push({ name: "proxy-list", props: {} });
+ return;
+ case "restart":
+ nav.push({ name: "plan", props: { plan: frpRestartPlan(), origin: "frp" } });
+ return;
+ case "back":
+ nav.back();
+ return;
+ default:
+ return;
+ }
+ };
+
+ return (
+
+ FRP client:
+
+
+
+
+ );
+}
diff --git a/src/screens/MainMenu.jsx b/src/screens/MainMenu.jsx
new file mode 100644
index 0000000..44ed020
--- /dev/null
+++ b/src/screens/MainMenu.jsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { Box, Text } from "ink";
+import SelectInput from "ink-select-input";
+
+import { zshInstallPlan, sshInstallPlan } from "../lib/tasks.js";
+
+export default function MainMenu({ nav }) {
+ const items = [
+ { label: "Install zsh + oh-my-zsh + nvm", value: "zsh" },
+ { label: "Install OpenSSH server", value: "ssh" },
+ { label: "FRP setup ▸", value: "frp" },
+ { label: "Bootstrap (zsh + ssh + frp)", value: "bootstrap" },
+ { label: "Quit", value: "quit" },
+ ];
+
+ const handleSelect = (item) => {
+ switch (item.value) {
+ case "zsh":
+ nav.push({ name: "plan", props: { plan: zshInstallPlan(), origin: "main" } });
+ return;
+ case "ssh":
+ nav.push({ name: "plan", props: { plan: sshInstallPlan(), origin: "main" } });
+ return;
+ case "frp":
+ nav.push({ name: "frp" });
+ return;
+ case "bootstrap":
+ nav.push({
+ name: "token",
+ props: { purpose: "bootstrap", origin: "main" },
+ });
+ return;
+ case "quit":
+ nav.exit();
+ return;
+ default:
+ return;
+ }
+ };
+
+ return (
+
+ Choose an action:
+
+
+
+
+ );
+}
diff --git a/src/screens/PlanPreview.jsx b/src/screens/PlanPreview.jsx
new file mode 100644
index 0000000..ac79fea
--- /dev/null
+++ b/src/screens/PlanPreview.jsx
@@ -0,0 +1,49 @@
+import React from "react";
+import { Box, Text } from "ink";
+import SelectInput from "ink-select-input";
+
+import { formatStep } from "../lib/runner.js";
+
+export default function PlanPreview({ nav, plan, origin, initialError }) {
+ const items = plan.steps.length
+ ? [
+ { label: "Run now", value: "run" },
+ { label: "Cancel", value: "cancel" },
+ ]
+ : [{ label: "Back", value: "cancel" }];
+
+ const handleSelect = (item) => {
+ if (item.value === "run") {
+ nav.replace({ name: "run", props: { plan, origin } });
+ } else {
+ nav.back();
+ }
+ };
+
+ return (
+
+ {plan.title}
+ {initialError ? (
+
+ {initialError}
+
+ ) : null}
+ {plan.steps.length > 0 ? (
+
+ {plan.steps.map((step, index) => (
+
+
+ {`${index + 1}.`}
+
+
+ {step.label}
+
+ {formatStep(step)}
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/screens/ProxyForm.jsx b/src/screens/ProxyForm.jsx
new file mode 100644
index 0000000..69192ac
--- /dev/null
+++ b/src/screens/ProxyForm.jsx
@@ -0,0 +1,130 @@
+import React, { useState } from "react";
+import { Box, Text, useInput } from "ink";
+import TextInput from "ink-text-input";
+
+import { frpAddProxyPlan } from "../lib/tasks.js";
+
+const FIELDS = [
+ { key: "name", label: "Name (e.g. ssh)" },
+ { key: "type", label: "Type" },
+ { key: "localIp", label: "Local IP" },
+ { key: "localPort", label: "Local port" },
+ { key: "remotePort", label: "Remote port" },
+];
+
+export default function ProxyForm({ nav }) {
+ const [values, setValues] = useState({
+ name: "",
+ type: "tcp",
+ localIp: "127.0.0.1",
+ localPort: "",
+ remotePort: "",
+ });
+ const [restart, setRestart] = useState(true);
+ const [step, setStep] = useState(0);
+ const [error, setError] = useState(null);
+
+ const totalRows = FIELDS.length + 1;
+
+ useInput((input, key) => {
+ if (key.tab || key.downArrow) {
+ setStep((s) => Math.min(s + 1, totalRows - 1));
+ return;
+ }
+ if (key.upArrow) {
+ setStep((s) => Math.max(s - 1, 0));
+ return;
+ }
+
+ if (step === totalRows - 1) {
+ if (input === " ") {
+ setRestart((r) => !r);
+ return;
+ }
+ if (key.return) {
+ submit();
+ return;
+ }
+ }
+
+ if (key.return) {
+ if (step < totalRows - 1) {
+ setStep((s) => s + 1);
+ return;
+ }
+ submit();
+ }
+ });
+
+ const update = (key) => (value) => {
+ setValues((v) => ({ ...v, [key]: value }));
+ };
+
+ const submit = () => {
+ setError(null);
+ const localPort = Number(values.localPort);
+ const remotePort = Number(values.remotePort);
+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
+ setError("Local port must be an integer in [1, 65535].");
+ return;
+ }
+ if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
+ setError("Remote port must be an integer in [1, 65535].");
+ return;
+ }
+
+ try {
+ const plan = frpAddProxyPlan({
+ name: values.name.trim(),
+ type: values.type.trim() || "tcp",
+ localIp: values.localIp.trim() || "127.0.0.1",
+ localPort,
+ remotePort,
+ restart,
+ });
+ nav.replace({ name: "plan", props: { plan, origin: "frp" } });
+ } catch (err) {
+ setError(err.message);
+ }
+ };
+
+ return (
+
+ Add frp proxy:
+
+ {FIELDS.map((field, index) => (
+
+
+
+ {index === step ? "› " : " "}
+ {field.label}
+
+
+
+
+ ))}
+
+
+
+ {step === FIELDS.length ? "› " : " "}
+ Restart frpc
+
+
+ [{restart ? "x" : " "}] (Space to toggle)
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ Tab/↓ next · ↑ prev · Space toggles restart · Enter on last submits
+
+
+ );
+}
diff --git a/src/screens/ProxyList.jsx b/src/screens/ProxyList.jsx
new file mode 100644
index 0000000..09f37bb
--- /dev/null
+++ b/src/screens/ProxyList.jsx
@@ -0,0 +1,68 @@
+import React, { useMemo } from "react";
+import { Box, Text } from "ink";
+import SelectInput from "ink-select-input";
+
+import { listProxies, frpRemoveProxyPlan } from "../lib/tasks.js";
+
+export default function ProxyList({ nav }) {
+ const result = useMemo(() => {
+ try {
+ return { proxies: listProxies(), error: null };
+ } catch (err) {
+ return { proxies: [], error: err.message };
+ }
+ }, []);
+
+ if (result.error) {
+ return (
+
+ {result.error}
+
+ Esc to go back.
+
+
+ );
+ }
+
+ if (result.proxies.length === 0) {
+ return (
+
+ No proxies configured.
+
+ Esc to go back.
+
+
+ );
+ }
+
+ const items = [
+ ...result.proxies.map((proxy) => ({
+ label: `${proxy.name.padEnd(28)} ${proxy.type} ${proxy.localIp}:${proxy.localPort} → :${proxy.remotePort}`,
+ value: proxy.name,
+ })),
+ { label: "← Back", value: "__back" },
+ ];
+
+ const handleSelect = (item) => {
+ if (item.value === "__back") {
+ nav.back();
+ return;
+ }
+ try {
+ const plan = frpRemoveProxyPlan({ name: item.value, restart: true });
+ nav.push({ name: "plan", props: { plan, origin: "frp" } });
+ } catch (err) {
+ // surface as a temporary fallback screen - keep simple by showing error and going back
+ nav.replace({ name: "plan", props: { plan: { title: "Error", steps: [] }, origin: "frp", initialError: err.message } });
+ }
+ };
+
+ return (
+
+ Current proxies (select to delete):
+
+
+
+
+ );
+}
diff --git a/src/screens/RunLog.jsx b/src/screens/RunLog.jsx
new file mode 100644
index 0000000..702ac48
--- /dev/null
+++ b/src/screens/RunLog.jsx
@@ -0,0 +1,179 @@
+import React, { useEffect, useReducer } from "react";
+import { Box, Text } from "ink";
+import Spinner from "ink-spinner";
+import SelectInput from "ink-select-input";
+
+import { startRun } from "../lib/runner.js";
+
+const MAX_LOG_LINES = 12;
+
+function reducer(state, action) {
+ switch (action.type) {
+ case "step-start":
+ return {
+ ...state,
+ currentIndex: action.index,
+ statuses: { ...state.statuses, [action.index]: "running" },
+ };
+ case "step-done":
+ return {
+ ...state,
+ statuses: { ...state.statuses, [action.index]: action.status },
+ errors:
+ action.error != null
+ ? { ...state.errors, [action.index]: action.error }
+ : state.errors,
+ };
+ case "log": {
+ const next = [...state.log, action];
+ if (next.length > MAX_LOG_LINES * 4) {
+ next.splice(0, next.length - MAX_LOG_LINES * 4);
+ }
+ return { ...state, log: next };
+ }
+ case "done":
+ return { ...state, finished: true, success: action.success, cancelled: !!action.cancelled };
+ default:
+ return state;
+ }
+}
+
+export default function RunLog({ nav, plan, origin }) {
+ const [state, dispatch] = useReducer(reducer, {
+ statuses: {},
+ errors: {},
+ currentIndex: -1,
+ log: [],
+ finished: false,
+ success: false,
+ cancelled: false,
+ });
+
+ useEffect(() => {
+ const run = startRun(plan.steps);
+ run.on("event", (event) => {
+ dispatch(event);
+ });
+ return () => run.cancel();
+ }, [plan]);
+
+ const recentLog = state.log.slice(-MAX_LOG_LINES);
+
+ return (
+
+ {plan.title}
+
+ {plan.steps.map((step, index) => (
+
+ ))}
+
+ {recentLog.length > 0 ? (
+
+ {recentLog.map((entry, i) => (
+
+ {entry.text}
+
+ ))}
+
+ ) : null}
+ {state.finished ? : (
+
+
+
+
+ running…
+
+ )}
+
+ );
+}
+
+function StepRow({ index, step, status, error }) {
+ const symbol = symbolFor(status);
+ const color = colorFor(status);
+ return (
+
+
+ {symbol}
+
+
+ {`${index + 1}.`}
+
+
+ {step.label}
+ {error ? {error} : null}
+
+
+ );
+}
+
+function symbolFor(status) {
+ switch (status) {
+ case "running":
+ return "◐";
+ case "ok":
+ return "✓";
+ case "failed":
+ return "✗";
+ case "cancelled":
+ return "⊘";
+ default:
+ return "·";
+ }
+}
+
+function colorFor(status) {
+ switch (status) {
+ case "running":
+ return "cyan";
+ case "ok":
+ return "green";
+ case "failed":
+ return "red";
+ case "cancelled":
+ return "yellow";
+ default:
+ return "gray";
+ }
+}
+
+function FinishedFooter({ state, nav, origin }) {
+ const items = [
+ { label: "Back to main menu", value: "home" },
+ ...(origin === "frp" ? [{ label: "Back to FRP menu", value: "frp" }] : []),
+ { label: "Quit", value: "quit" },
+ ];
+
+ const handleSelect = (item) => {
+ if (item.value === "home") {
+ nav.home();
+ } else if (item.value === "frp") {
+ nav.replace({ name: "frp" });
+ // collapse stack down to just main + frp
+ // (replace just swaps top; we want clean stack)
+ } else if (item.value === "quit") {
+ nav.exit();
+ }
+ };
+
+ return (
+
+ {state.cancelled ? (
+ Cancelled.
+ ) : state.success ? (
+ All steps completed successfully.
+ ) : (
+ Run failed.
+ )}
+
+
+
+
+ );
+}
diff --git a/src/screens/TokenPrompt.jsx b/src/screens/TokenPrompt.jsx
new file mode 100644
index 0000000..25ddbff
--- /dev/null
+++ b/src/screens/TokenPrompt.jsx
@@ -0,0 +1,33 @@
+import React, { useState } from "react";
+import { Box, Text, useInput } from "ink";
+import TextInput from "ink-text-input";
+
+export default function TokenPrompt({ nav, purpose }) {
+ const envToken = process.env.FRP_TOKEN || "";
+ const [token, setToken] = useState(envToken);
+
+ useInput((input, key) => {
+ if (key.return && token.trim().length > 0) {
+ nav.replace({
+ name: "frp-config-form",
+ props: { token: token.trim(), purpose },
+ });
+ }
+ });
+
+ return (
+
+ Enter the FRP token (used for auth.token):
+ {envToken ? (
+ (picked up FRP_TOKEN from environment; edit if you need to override)
+ ) : null}
+
+ token ›
+
+
+
+ Press Enter to continue · Esc to cancel
+
+
+ );
+}
diff --git a/src/stubs/react-devtools-core.js b/src/stubs/react-devtools-core.js
new file mode 100644
index 0000000..e14b38f
--- /dev/null
+++ b/src/stubs/react-devtools-core.js
@@ -0,0 +1,8 @@
+// Stub used when bundling the TUI. The real react-devtools-core is only needed
+// when process.env.DEV === "true"; in production we replace it so esbuild does
+// not hoist a heavy ESM import that pulls in browser globals at load time.
+const noop = () => {};
+export default {
+ initialize: noop,
+ connectToDevTools: noop,
+};
diff --git a/test/frp-config.test.js b/test/frp-config.test.js
index fc64b58..b997f6a 100644
--- a/test/frp-config.test.js
+++ b/test/frp-config.test.js
@@ -3,7 +3,7 @@
const assert = require("node:assert/strict");
const test = require("node:test");
-const { createUniqueProxyName, parseFrpConfig, renderParsedFrpConfig } = require("../src/cli");
+const { createUniqueProxyName, parseFrpConfig, renderParsedFrpConfig } = require("../src/lib/frp-config");
test("parses and renders frp proxy sections", () => {
const parsed = parseFrpConfig(`server_addr = "81.70.134.9"