fix
This commit is contained in:
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,
|
||||
};
|
||||
Reference in New Issue
Block a user