This commit is contained in:
2026-05-22 14:29:46 +08:00
parent 249e361b59
commit d898d98a0e
5 changed files with 105 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import ProxyList from "./screens/ProxyList.jsx";
import PlanPreview from "./screens/PlanPreview.jsx";
import RunLog from "./screens/RunLog.jsx";
import FrpConfigForm from "./screens/FrpConfigForm.jsx";
import SshKeyPrompt from "./screens/SshKeyPrompt.jsx";
function App() {
const { exit } = useApp();
@@ -67,6 +68,8 @@ function renderScreen(screen, nav) {
return <ProxyForm nav={nav} {...screen.props} />;
case "proxy-list":
return <ProxyList nav={nav} {...screen.props} />;
case "ssh-key":
return <SshKeyPrompt nav={nav} {...screen.props} />;
case "plan":
return <PlanPreview nav={nav} {...screen.props} />;
case "run":

View File

@@ -95,10 +95,36 @@ function runStep(step, index, emitter, state) {
if (step.kind === "zshrc-plugins") {
return updateZshrcPlugins(step, index, emitter);
}
if (step.kind === "ssh-authorized-key") {
return appendAuthorizedKey(step, index, emitter);
}
return Promise.reject(new Error(`Unknown step kind: ${step.kind}`));
}
function appendAuthorizedKey(step, index, emitter) {
try {
fs.mkdirSync(step.sshDir, { recursive: true, mode: 0o700 });
try { fs.chmodSync(step.sshDir, 0o700); } catch { /* ignore */ }
const existing = fs.existsSync(step.path) ? fs.readFileSync(step.path, "utf8") : "";
const lines = existing.split(/\r?\n/);
if (lines.some((line) => line.trim() === step.key)) {
emitter.emit("event", { type: "log", index, stream: "stdout", text: `key already present in ${step.path}` });
return Promise.resolve();
}
const next = existing.endsWith("\n") || existing.length === 0
? `${existing}${step.key}\n`
: `${existing}\n${step.key}\n`;
fs.writeFileSync(step.path, next, { mode: 0o600 });
try { fs.chmodSync(step.path, 0o600); } catch { /* ignore */ }
emitter.emit("event", { type: "log", index, stream: "stdout", text: `appended key to ${step.path}` });
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
}
function runCommand(step, index, emitter, state) {
return new Promise((resolve, reject) => {
const child = spawn(step.command, step.args, {
@@ -240,6 +266,9 @@ function formatStep(step) {
if (step.kind === "zshrc-plugins") {
return `update plugins in ${step.path}`;
}
if (step.kind === "ssh-authorized-key") {
return `append public key to ${step.path}`;
}
return step.label || step.kind;
}

View File

@@ -50,6 +50,7 @@ function zshInstallPlan() {
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("git credential helper = store", "git", ["config", "--global", "credential.helper", "store"]),
run("change shell to zsh", "sudo", ["chsh", "-s", "/bin/zsh", os.userInfo().username]),
run("install oh-my-zsh", "sh", ["-c", OH_MY_ZSH_COMMAND]),
run("install nvm + node LTS", "bash", ["-c", NVM_INSTALL_COMMAND]),
@@ -77,6 +78,33 @@ function gitPluginStep(name, repo, destination) {
return run(`clone ${name}`, "git", ["clone", repo, destination]);
}
function sshAuthorizedKeyPlan(rawKey) {
const key = (rawKey || "").trim();
if (!key) {
throw new Error("Public key is required.");
}
if (/\r|\n/.test(key)) {
throw new Error("Public key must be a single line.");
}
if (!/^(ssh-(rsa|dss|ed25519)|ecdsa-sha2-\S+|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)\s+\S+/.test(key)) {
throw new Error("That does not look like an OpenSSH public key.");
}
const home = os.homedir();
return {
title: "Append public key to ~/.ssh/authorized_keys",
steps: [
{
kind: "ssh-authorized-key",
label: "append key to authorized_keys",
sshDir: path.join(home, ".ssh"),
path: path.join(home, ".ssh", "authorized_keys"),
key,
},
],
};
}
function sshInstallPlan() {
return {
title: "Install OpenSSH server",
@@ -318,6 +346,7 @@ function writeFile(label, filePath, content) {
module.exports = {
zshInstallPlan,
sshInstallPlan,
sshAuthorizedKeyPlan,
frpInstallPlan,
frpInitConfigPlan,
frpAddProxyPlan,

View File

@@ -8,6 +8,7 @@ export default function MainMenu({ nav }) {
const items = [
{ label: "Install zsh + oh-my-zsh + nvm", value: "zsh" },
{ label: "Install OpenSSH server", value: "ssh" },
{ label: "Add SSH public key to authorized_keys", value: "ssh-key" },
{ label: "FRP setup ▸", value: "frp" },
{ label: "Bootstrap (zsh + ssh + frp)", value: "bootstrap" },
{ label: "Quit", value: "quit" },
@@ -21,6 +22,9 @@ export default function MainMenu({ nav }) {
case "ssh":
nav.push({ name: "plan", props: { plan: sshInstallPlan(), origin: "main" } });
return;
case "ssh-key":
nav.push({ name: "ssh-key" });
return;
case "frp":
nav.push({ name: "frp" });
return;

View File

@@ -0,0 +1,40 @@
import React, { useState } from "react";
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import { sshAuthorizedKeyPlan } from "../lib/tasks.js";
export default function SshKeyPrompt({ nav }) {
const [key, setKey] = useState("");
const [error, setError] = useState(null);
useInput((input, k) => {
if (k.return && key.trim().length > 0) {
try {
const plan = sshAuthorizedKeyPlan(key);
nav.replace({ name: "plan", props: { plan, origin: "main" } });
} catch (err) {
setError(err.message);
}
}
});
return (
<Box flexDirection="column">
<Text>Paste the contents of your <Text bold>.pub</Text> file (single line):</Text>
<Text dimColor>It will be appended to ~/.ssh/authorized_keys (dedup'd).</Text>
<Box marginTop={1}>
<Text>key </Text>
<TextInput value={key} onChange={(v) => { setKey(v); if (error) setError(null); }} />
</Box>
{error ? (
<Box marginTop={1}>
<Text color="red">{error}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text dimColor>Press Enter to continue · Esc to cancel</Text>
</Box>
</Box>
);
}