fix
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
dist/
|
||||
|
||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
@@ -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 `<TextInput focus={...} />` widgets. Avoid pulling in a forms library — the pattern is small enough to keep inline.
|
||||
|
||||
### 3. Entry/build — `bin/`, `dist/`, esbuild
|
||||
|
||||
`bin/server-config.js` is a CJS shim that `import()`s `dist/app.mjs`. The bundle is ESM because Ink 7 + yoga-layout use top-level await, which esbuild cannot lower to CJS — that's why `--format=esm` is non-negotiable.
|
||||
|
||||
Two esbuild quirks worth knowing:
|
||||
|
||||
1. **`react-devtools-core` is aliased to a stub** (`src/stubs/react-devtools-core.js`). Ink imports it behind a `process.env.DEV === "true"` check, but esbuild hoists ESM imports to module-load time, so we'd otherwise execute the real package's UMD preamble (which references `self`) and crash. Keep the alias; do not change to `--external` (that reintroduces the crash at runtime).
|
||||
2. The `--banner:js` injects a `createRequire` so CJS lib modules called from the bundle can still use `require(...)`. Don't drop it without also rewriting the libs to ESM.
|
||||
|
||||
The bundle is ~1.8 MB — that's mostly React + Yoga, not something to fight.
|
||||
|
||||
## Sensitive values & defaults
|
||||
|
||||
The FRP token comes from the TokenPrompt screen or the `FRP_TOKEN` env var (the screen pre-fills from env). It is never logged or persisted outside the rendered `frpc.toml`. Defaults that callers depend on: server `81.70.134.9:15443`, install dir `/opt/frp/frp_<version>_linux_<arch>`, systemd unit `frpc`, frp version `0.58.1`. Changing any is a breaking change for existing deployments.
|
||||
|
||||
## When adding a new task type
|
||||
|
||||
1. Add a plan builder in `src/lib/tasks.js` returning `{ title, steps }`.
|
||||
2. If you need a new step kind (beyond `run` / `write` / `zshrc-plugins`), extend the `runStep` switch in `src/lib/runner.js` and `formatStep` for the preview label.
|
||||
3. Add a screen (or extend an existing menu) that constructs the plan and pushes `{ name: "plan", props: { plan } }`.
|
||||
|
||||
Never spawn commands directly from a screen — always build a plan and route it through `PlanPreview` → `RunLog` so the user sees what's about to happen and gets uniform log/spinner UI.
|
||||
190
README.md
190
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_<version>_linux_amd64`
|
||||
- 配置文件:`<install-dir>/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)。
|
||||
|
||||
70
USAGE.md
Normal file
70
USAGE.md
Normal file
@@ -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 # 跑解析器单元测试
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
1187
package-lock.json
generated
Normal file
1187
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
21
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"
|
||||
}
|
||||
|
||||
106
src/app.jsx
Normal file
106
src/app.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Header />
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{renderScreen(screen, nav)}
|
||||
</Box>
|
||||
<Footer screen={screen.name} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderScreen(screen, nav) {
|
||||
switch (screen.name) {
|
||||
case "main":
|
||||
return <MainMenu nav={nav} />;
|
||||
case "frp":
|
||||
return <FrpMenu nav={nav} />;
|
||||
case "token":
|
||||
return <TokenPrompt nav={nav} {...screen.props} />;
|
||||
case "frp-config-form":
|
||||
return <FrpConfigForm nav={nav} {...screen.props} />;
|
||||
case "proxy-form":
|
||||
return <ProxyForm nav={nav} {...screen.props} />;
|
||||
case "proxy-list":
|
||||
return <ProxyList nav={nav} {...screen.props} />;
|
||||
case "plan":
|
||||
return <PlanPreview nav={nav} {...screen.props} />;
|
||||
case "run":
|
||||
return <RunLog nav={nav} {...screen.props} />;
|
||||
default:
|
||||
return <Text>Unknown screen: {screen.name}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>server-config</Text>
|
||||
<Text dimColor> zsh · ssh · frp client</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({ screen }) {
|
||||
const hint =
|
||||
screen === "main"
|
||||
? "↑↓ select · Enter confirm · q quit"
|
||||
: screen === "run"
|
||||
? "(Esc disabled while running)"
|
||||
: "↑↓ select · Enter confirm · Esc back";
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{hint}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function start() {
|
||||
render(<App />);
|
||||
}
|
||||
|
||||
start();
|
||||
730
src/cli.js
730
src/cli.js
@@ -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 <value> 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 <frp-token> [--dry-run]
|
||||
|
||||
server-config frp install --token <frp-token> [options]
|
||||
server-config frp init --token <frp-token> [options]
|
||||
server-config frp add <name> --local-port <port> --remote-port <port> [options]
|
||||
server-config frp remove <name> [--restart]
|
||||
server-config frp list [--config <path>]
|
||||
server-config frp restart [--service-name frpc]
|
||||
|
||||
Common options:
|
||||
--dry-run Print commands and file changes without executing them.
|
||||
|
||||
frp options:
|
||||
--version <version> Default: ${DEFAULT_FRP_VERSION}
|
||||
--arch <arch> Default: ${DEFAULT_FRP_ARCH}
|
||||
--install-dir <path> Default: /opt/frp/frp_<version>_linux_<arch>
|
||||
--config <path> Default: <install-dir>/frpc.toml
|
||||
--service-name <name> Default: ${DEFAULT_SERVICE_NAME}
|
||||
--server-addr <ip> Default: ${DEFAULT_FRP_SERVER_ADDR}
|
||||
--server-port <port> Default: ${DEFAULT_FRP_SERVER_PORT}
|
||||
--token <token> Or set FRP_TOKEN.
|
||||
--tls-enable <true|false> Default: false
|
||||
--tcp-mux <true|false> 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,
|
||||
};
|
||||
226
src/lib/frp-config.js
Normal file
226
src/lib/frp-config.js
Normal file
@@ -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,
|
||||
};
|
||||
229
src/lib/runner.js
Normal file
229
src/lib/runner.js
Normal file
@@ -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 };
|
||||
335
src/lib/tasks.js
Normal file
335
src/lib/tasks.js
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
114
src/screens/FrpConfigForm.jsx
Normal file
114
src/screens/FrpConfigForm.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>FRP configuration ({purpose}):</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{FIELDS.map((field, index) => (
|
||||
<Box key={field.key}>
|
||||
<Box width={18}>
|
||||
<Text color={index === step ? "cyan" : undefined}>
|
||||
{index === step ? "› " : " "}
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={values[field.key]}
|
||||
onChange={update(field.key)}
|
||||
focus={index === step}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Tab/↓ next · ↑ prev · Enter on last field submits · Esc back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
src/screens/FrpMenu.jsx
Normal file
50
src/screens/FrpMenu.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>FRP client:</Text>
|
||||
<Box marginTop={1}>
|
||||
<SelectInput items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
src/screens/MainMenu.jsx
Normal file
49
src/screens/MainMenu.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>Choose an action:</Text>
|
||||
<Box marginTop={1}>
|
||||
<SelectInput items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
src/screens/PlanPreview.jsx
Normal file
49
src/screens/PlanPreview.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>{plan.title}</Text>
|
||||
{initialError ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{initialError}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{plan.steps.length > 0 ? (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
{plan.steps.map((step, index) => (
|
||||
<Box key={index}>
|
||||
<Box width={4}>
|
||||
<Text dimColor>{`${index + 1}.`}</Text>
|
||||
</Box>
|
||||
<Box width={28}>
|
||||
<Text>{step.label}</Text>
|
||||
</Box>
|
||||
<Text dimColor>{formatStep(step)}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
<SelectInput items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
130
src/screens/ProxyForm.jsx
Normal file
130
src/screens/ProxyForm.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>Add frp proxy:</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{FIELDS.map((field, index) => (
|
||||
<Box key={field.key}>
|
||||
<Box width={20}>
|
||||
<Text color={index === step ? "cyan" : undefined}>
|
||||
{index === step ? "› " : " "}
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<TextInput
|
||||
value={values[field.key]}
|
||||
onChange={update(field.key)}
|
||||
focus={index === step}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
<Box>
|
||||
<Box width={20}>
|
||||
<Text color={step === FIELDS.length ? "cyan" : undefined}>
|
||||
{step === FIELDS.length ? "› " : " "}
|
||||
Restart frpc
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>[{restart ? "x" : " "}] (Space to toggle)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Tab/↓ next · ↑ prev · Space toggles restart · Enter on last submits</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
68
src/screens/ProxyList.jsx
Normal file
68
src/screens/ProxyList.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red">{result.error}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Esc to go back.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.proxies.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text>No proxies configured.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Esc to go back.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>Current proxies (select to delete):</Text>
|
||||
<Box marginTop={1}>
|
||||
<SelectInput items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
179
src/screens/RunLog.jsx
Normal file
179
src/screens/RunLog.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text bold>{plan.title}</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{plan.steps.map((step, index) => (
|
||||
<StepRow
|
||||
key={index}
|
||||
index={index}
|
||||
step={step}
|
||||
status={state.statuses[index]}
|
||||
error={state.errors[index]}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{recentLog.length > 0 ? (
|
||||
<Box flexDirection="column" marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
{recentLog.map((entry, i) => (
|
||||
<Text key={i} color={entry.stream === "stderr" ? "yellow" : undefined} dimColor>
|
||||
{entry.text}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{state.finished ? <FinishedFooter state={state} nav={nav} origin={origin} /> : (
|
||||
<Box marginTop={1}>
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text> running…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({ index, step, status, error }) {
|
||||
const symbol = symbolFor(status);
|
||||
const color = colorFor(status);
|
||||
return (
|
||||
<Box>
|
||||
<Box width={3}>
|
||||
<Text color={color}>{symbol}</Text>
|
||||
</Box>
|
||||
<Box width={3}>
|
||||
<Text dimColor>{`${index + 1}.`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text>{step.label}</Text>
|
||||
{error ? <Text color="red"> {error}</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{state.cancelled ? (
|
||||
<Text color="yellow">Cancelled.</Text>
|
||||
) : state.success ? (
|
||||
<Text color="green">All steps completed successfully.</Text>
|
||||
) : (
|
||||
<Text color="red">Run failed.</Text>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<SelectInput items={items} onSelect={handleSelect} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
src/screens/TokenPrompt.jsx
Normal file
33
src/screens/TokenPrompt.jsx
Normal file
@@ -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 (
|
||||
<Box flexDirection="column">
|
||||
<Text>Enter the FRP token (used for <Text bold>auth.token</Text>):</Text>
|
||||
{envToken ? (
|
||||
<Text dimColor>(picked up FRP_TOKEN from environment; edit if you need to override)</Text>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text>token › </Text>
|
||||
<TextInput value={token} onChange={setToken} mask="*" />
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter to continue · Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
8
src/stubs/react-devtools-core.js
vendored
Normal file
8
src/stubs/react-devtools-core.js
vendored
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user