betterssh 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/core.mjs +124 -0
- package/index.mjs +130 -0
- package/mcp.mjs +148 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# betterssh
|
|
2
|
+
|
|
3
|
+
诊断并修复**多账户 SSH 配置**的命令行工具。一条命令找出 `git` remote 用错账户 / 密钥的问题,并给出可复制粘贴的修复命令。
|
|
4
|
+
|
|
5
|
+
> 如果你在同一台机器上管理多个 GitHub / GitLab / Bitbucket / Gitee 账号,大概率被「明明配好了 key 却 push 不上去」坑过。`betterssh` 帮你一眼定位。
|
|
6
|
+
|
|
7
|
+
## 快速开始(零安装)
|
|
8
|
+
|
|
9
|
+
在任意 git 仓库目录下:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx betterssh
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
输出示例:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
🔍 betterssh diagnose /path/to/your/repo
|
|
19
|
+
|
|
20
|
+
发现 3 个账户: GitHub×2 GitLab×1
|
|
21
|
+
|
|
22
|
+
● remote origin → github.com (acme/widgets, GitHub, ssh)
|
|
23
|
+
⚠ 裸主机名 "github.com",有 2 个 GitHub 账户,SSH 用默认 key 大概率失败
|
|
24
|
+
✅ 建议指向具体账户:
|
|
25
|
+
github.com-work → git@github.com-work:acme/widgets.git
|
|
26
|
+
github.com-personal → git@github.com-personal:acme/widgets.git
|
|
27
|
+
betterssh fix --to <别名>
|
|
28
|
+
|
|
29
|
+
💡 想可视化管理所有账户与密钥? → betterssh.com
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 命令
|
|
33
|
+
|
|
34
|
+
| 命令 | 说明 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `betterssh diagnose [path]` | 诊断 repo 的 SSH remote 配置(默认当前目录) |
|
|
37
|
+
| `betterssh fix [path]` | 自动修复;有多个候选时用 `--to <别名>` 指定 |
|
|
38
|
+
| `betterssh list` | 列出所有 SSH 账户 |
|
|
39
|
+
| `betterssh help` | 帮助 |
|
|
40
|
+
|
|
41
|
+
### 选项
|
|
42
|
+
|
|
43
|
+
- `--json` — 以 JSON 输出(供 AI / 脚本消费)
|
|
44
|
+
- `--to <hostAlias>` — `fix` 时指定要切换到的账户别名
|
|
45
|
+
- `-y, --yes` — `fix` 跳过确认
|
|
46
|
+
|
|
47
|
+
## 隐私
|
|
48
|
+
|
|
49
|
+
完全离线运行:只读取 `~/.ssh/config` 和仓库的 `.git/config`,不发任何网络请求、不收集任何数据。`fix` 仅调用本地 `git remote set-url`。
|
|
50
|
+
|
|
51
|
+
## 桌面应用
|
|
52
|
+
|
|
53
|
+
需要可视化管理账户、生成密钥、跟踪过期、备份恢复?试试 **[BetterSSH 桌面应用](https://betterssh.com)**(macOS,免费,MIT 开源)。
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/core.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* betterssh 核心引擎(共享给 CLI 与 MCP server)
|
|
3
|
+
* 纯逻辑、零依赖、完全离线:只读 ~/.ssh/config 与 .git/config;applyFix 调本地 git。
|
|
4
|
+
*/
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, basename } from "node:path";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export const PLATFORM_LABELS = { github: "GitHub", gitlab: "GitLab", bitbucket: "Bitbucket", gitee: "Gitee", custom: "Custom" };
|
|
15
|
+
const PLATFORM_PATTERNS = [
|
|
16
|
+
[/github\.com/i, "github"], [/gitlab\.com/i, "gitlab"],
|
|
17
|
+
[/bitbucket\.org/i, "bitbucket"], [/gitee\.com/i, "gitee"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function detectPlatform(hostName) {
|
|
21
|
+
for (const [pat, plat] of PLATFORM_PATTERNS) if (pat.test(hostName)) return plat;
|
|
22
|
+
return "custom";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadAccounts() {
|
|
26
|
+
const path = join(homedir(), ".ssh", "config");
|
|
27
|
+
let content = "";
|
|
28
|
+
try { content = await readFile(path, "utf-8"); } catch { return []; }
|
|
29
|
+
const blocks = [];
|
|
30
|
+
let cur = null;
|
|
31
|
+
for (const line of content.split("\n")) {
|
|
32
|
+
const t = line.trim();
|
|
33
|
+
const hostMatch = t.match(/^Host\s+(.+)$/i);
|
|
34
|
+
if (hostMatch) { if (cur) blocks.push(cur); cur = { host: hostMatch[1].trim(), options: {} }; }
|
|
35
|
+
else if (cur && t && !t.startsWith("#")) { const m = t.match(/^(\S+)[\s=]+(.+)$/); if (m) cur.options[m[1]] = m[2].trim(); }
|
|
36
|
+
}
|
|
37
|
+
if (cur) blocks.push(cur);
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
return blocks
|
|
40
|
+
.filter((b) => b.host !== "*" && !b.host.includes("*"))
|
|
41
|
+
.filter((b) => (seen.has(b.host) ? false : (seen.add(b.host), true)))
|
|
42
|
+
.map((b) => {
|
|
43
|
+
const hostName = b.options.HostName || b.options.Hostname || b.host;
|
|
44
|
+
return { host: b.host, hostName, user: b.options.User || "git", identityFile: b.options.IdentityFile || "", platform: detectPlatform(hostName) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseRemoteUrl(url) {
|
|
49
|
+
const ssh = url.match(/^(?:\w+@)?([\w.\-]+):([^/]+)\/(.+?)(?:\.git)?$/);
|
|
50
|
+
if (ssh) return { url, protocol: "ssh", hostname: ssh[1], owner: ssh[2], repo: ssh[3], platform: detectPlatform(ssh[1]) };
|
|
51
|
+
const https = url.match(/^https?:\/\/([\w.\-]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
52
|
+
if (https) return { url, protocol: "https", hostname: https[1], owner: https[2], repo: https[3], platform: detectPlatform(https[1]) };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function parseGitRemotes(projectPath) {
|
|
57
|
+
const gitConfigPath = join(projectPath, ".git", "config");
|
|
58
|
+
let content;
|
|
59
|
+
try { content = await readFile(gitConfigPath, "utf-8"); } catch { return []; }
|
|
60
|
+
const remotes = [];
|
|
61
|
+
let name = null;
|
|
62
|
+
for (const line of content.split("\n")) {
|
|
63
|
+
const t = line.trim();
|
|
64
|
+
const rm = t.match(/^\[remote\s+"(.+)"\]$/);
|
|
65
|
+
if (rm) { name = rm[1]; continue; }
|
|
66
|
+
if (t.startsWith("[")) { name = null; continue; }
|
|
67
|
+
if (name) { const um = t.match(/^url\s*=\s*(.+)$/); if (um) { const p = parseRemoteUrl(um[1].trim()); if (p) remotes.push({ ...p, name }); } }
|
|
68
|
+
}
|
|
69
|
+
return remotes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 核心分析:返回结构化诊断结果 */
|
|
73
|
+
export async function analyze(projectPath) {
|
|
74
|
+
const accounts = await loadAccounts();
|
|
75
|
+
const result = {
|
|
76
|
+
projectPath, projectName: basename(projectPath),
|
|
77
|
+
isGitRepo: existsSync(join(projectPath, ".git")),
|
|
78
|
+
accountCount: accounts.length, accounts, remotes: [], hasIssues: false,
|
|
79
|
+
};
|
|
80
|
+
if (!result.isGitRepo) return result;
|
|
81
|
+
|
|
82
|
+
for (const r of await parseGitRemotes(projectPath)) {
|
|
83
|
+
const entry = {
|
|
84
|
+
name: r.name, hostname: r.hostname, owner: r.owner, repo: r.repo,
|
|
85
|
+
platform: r.platform, protocol: r.protocol, currentUrl: r.url,
|
|
86
|
+
status: "ok", message: "", fixes: [],
|
|
87
|
+
};
|
|
88
|
+
if (r.platform === "custom") { entry.status = "skipped"; entry.message = "自定义/未知平台"; result.remotes.push(entry); continue; }
|
|
89
|
+
|
|
90
|
+
const sameP = accounts.filter((a) => a.platform === r.platform);
|
|
91
|
+
const mkFix = (a) => ({ host: a.host, url: `git@${a.host}:${r.owner}/${r.repo}.git`, command: `git -C "${projectPath}" remote set-url ${r.name} git@${a.host}:${r.owner}/${r.repo}.git` });
|
|
92
|
+
|
|
93
|
+
if (r.protocol === "https") {
|
|
94
|
+
if (sameP.length > 0) { entry.status = "fixable"; entry.message = "用的是 HTTPS,建议改用 SSH"; entry.fixes = sameP.map(mkFix); result.hasIssues = true; }
|
|
95
|
+
else { entry.status = "skipped"; entry.message = "HTTPS remote,无对应 SSH 账户"; }
|
|
96
|
+
result.remotes.push(entry); continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const exactAlias = accounts.find((a) => a.host.toLowerCase() === r.hostname.toLowerCase());
|
|
100
|
+
if (exactAlias) { entry.status = "ok"; entry.message = `已正确指向 ${exactAlias.host}`; entry.matchedAccount = exactAlias.host; result.remotes.push(entry); continue; }
|
|
101
|
+
|
|
102
|
+
if (sameP.length > 0) {
|
|
103
|
+
entry.status = "fixable";
|
|
104
|
+
entry.message = sameP.length > 1
|
|
105
|
+
? `裸主机名 "${r.hostname}",有 ${sameP.length} 个 ${PLATFORM_LABELS[r.platform]} 账户,SSH 用默认 key 大概率失败`
|
|
106
|
+
: `裸主机名 "${r.hostname}",未指向账户别名`;
|
|
107
|
+
entry.fixes = sameP.map(mkFix);
|
|
108
|
+
result.hasIssues = true;
|
|
109
|
+
} else { entry.status = "no_account"; entry.message = `无匹配的 ${PLATFORM_LABELS[r.platform]} 账户`; result.hasIssues = true; }
|
|
110
|
+
result.remotes.push(entry);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 应用修复:git remote set-url。返回 {success, oldUrl, newUrl, error?} */
|
|
116
|
+
export async function applyFix(projectPath, remoteName, newUrl) {
|
|
117
|
+
try {
|
|
118
|
+
const { stdout } = await execFileAsync("git", ["-C", projectPath, "remote", "get-url", remoteName]);
|
|
119
|
+
await execFileAsync("git", ["-C", projectPath, "remote", "set-url", remoteName, newUrl]);
|
|
120
|
+
return { success: true, oldUrl: stdout.trim(), newUrl };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { success: false, newUrl, error: err instanceof Error ? err.message : String(err) };
|
|
123
|
+
}
|
|
124
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* betterssh — 多账户 SSH 管理 CLI
|
|
4
|
+
*
|
|
5
|
+
* 命令: diagnose [path] | fix [path] | list | help
|
|
6
|
+
* 选项: --json | --to <hostAlias> | -y/--yes
|
|
7
|
+
*
|
|
8
|
+
* 完全离线:只读 ~/.ssh/config 与 .git/config;fix 调本地 git。
|
|
9
|
+
* 桌面版(可视化): betterssh.com
|
|
10
|
+
*/
|
|
11
|
+
import { analyze, applyFix, loadAccounts, PLATFORM_LABELS } from "./core.mjs";
|
|
12
|
+
|
|
13
|
+
// ---------- ANSI ----------
|
|
14
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
15
|
+
const c = useColor
|
|
16
|
+
? { reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", gray: "\x1b[90m" }
|
|
17
|
+
: new Proxy({}, { get: () => "" });
|
|
18
|
+
const paint = (s, color) => `${color}${s}${c.reset}`;
|
|
19
|
+
|
|
20
|
+
function printDiagnose(res) {
|
|
21
|
+
console.log(`\n${paint("🔍 betterssh diagnose", c.bold + c.cyan)} ${paint(res.projectPath, c.gray)}\n`);
|
|
22
|
+
if (res.accountCount === 0) {
|
|
23
|
+
console.log(paint(" 未找到任何 SSH 账户(~/.ssh/config 为空或不存在)。", c.yellow));
|
|
24
|
+
console.log(paint(" → 用 BetterSSH 桌面应用可一键生成与配置: betterssh.com\n", c.dim)); return;
|
|
25
|
+
}
|
|
26
|
+
const byP = {};
|
|
27
|
+
for (const a of res.accounts) (byP[a.platform] ??= []).push(a);
|
|
28
|
+
console.log(` 发现 ${paint(res.accountCount, c.bold)} 个账户: ${Object.entries(byP).map(([p, l]) => `${PLATFORM_LABELS[p]}×${l.length}`).join(" ")}\n`);
|
|
29
|
+
if (!res.isGitRepo) { console.log(paint(" 当前目录不是 git 仓库,跳过 remote 诊断。\n", c.dim)); return; }
|
|
30
|
+
if (res.remotes.length === 0) { console.log(paint(" 该仓库没有配置 remote。\n", c.dim)); return; }
|
|
31
|
+
|
|
32
|
+
for (const e of res.remotes) {
|
|
33
|
+
const tag = `${e.owner}/${e.repo}, ${PLATFORM_LABELS[e.platform] || e.platform}, ${e.protocol}`;
|
|
34
|
+
console.log(` ${paint("●", c.blue)} remote ${paint(e.name, c.bold)} → ${e.hostname} ${paint(`(${tag})`, c.gray)}`);
|
|
35
|
+
if (e.status === "ok") console.log(` ${paint("✓", c.green)} ${e.message}\n`);
|
|
36
|
+
else if (e.status === "skipped") console.log(paint(` ℹ ${e.message}\n`, c.dim));
|
|
37
|
+
else {
|
|
38
|
+
console.log(paint(` ⚠ ${e.message}`, c.yellow));
|
|
39
|
+
if (e.fixes.length === 1) {
|
|
40
|
+
console.log(` ${paint("✅ 建议:", c.green)} ${paint(e.fixes[0].url, c.bold)}`);
|
|
41
|
+
console.log(paint(` ${e.fixes[0].command}`, c.dim));
|
|
42
|
+
console.log(paint(` 或直接: betterssh fix (在该目录)\n`, c.dim));
|
|
43
|
+
} else if (e.fixes.length > 1) {
|
|
44
|
+
console.log(` ${paint("✅ 建议指向具体账户:", c.green)}`);
|
|
45
|
+
for (const f of e.fixes) console.log(` ${paint(f.host, c.bold)} → ${paint(f.url, c.cyan)}`);
|
|
46
|
+
console.log(paint(` betterssh fix --to <别名>\n`, c.dim));
|
|
47
|
+
} else console.log("");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
console.log(res.hasIssues ? paint(" 发现需修复项。运行 " + paint("betterssh fix", c.bold) + paint(" 自动修复。", c.yellow), c.yellow) : paint(" ✅ 所有 remote 配置正确。", c.green + c.bold));
|
|
51
|
+
console.log(paint(" 💡 想可视化管理所有账户与密钥? → betterssh.com\n", c.dim));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fix(projectPath, opts) {
|
|
55
|
+
const res = await analyze(projectPath);
|
|
56
|
+
if (!res.isGitRepo) { console.log(paint(" 不是 git 仓库,无法修复。", c.yellow)); return; }
|
|
57
|
+
const fixable = res.remotes.filter((e) => e.status === "fixable" || e.status === "no_account");
|
|
58
|
+
if (fixable.length === 0) { console.log(paint("\n ✅ 没有需要修复的 remote。\n", c.green)); return; }
|
|
59
|
+
|
|
60
|
+
for (const e of fixable) {
|
|
61
|
+
if (e.fixes.length === 0) { console.log(paint(` remote ${e.name}: ${e.message}(无可用修复)`, c.yellow)); continue; }
|
|
62
|
+
let chosen;
|
|
63
|
+
if (opts.to) chosen = e.fixes.find((f) => f.host === opts.to);
|
|
64
|
+
else if (e.fixes.length === 1) chosen = e.fixes[0];
|
|
65
|
+
if (!chosen) {
|
|
66
|
+
console.log(paint(`\n remote ${paint(e.name, c.bold)} 有多个候选,请用 --to 指定:`, c.yellow));
|
|
67
|
+
for (const f of e.fixes) console.log(` --to ${paint(f.host, c.cyan)} → ${f.url}`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const r = await applyFix(projectPath, e.name, chosen.url);
|
|
71
|
+
if (r.success) console.log(paint(` ✅ 已修复 ${e.name}: ${r.oldUrl} → ${r.newUrl}`, c.green));
|
|
72
|
+
else console.log(paint(` ❌ 修复失败 ${e.name}: ${r.error}`, c.red));
|
|
73
|
+
}
|
|
74
|
+
console.log();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function list(json) {
|
|
78
|
+
const accounts = await loadAccounts();
|
|
79
|
+
if (json) { console.log(JSON.stringify(accounts, null, 2)); return; }
|
|
80
|
+
console.log(`\n${paint("betterssh — SSH 账户", c.bold + c.cyan)}\n`);
|
|
81
|
+
if (accounts.length === 0) { console.log(paint(" (无账户)\n", c.dim)); return; }
|
|
82
|
+
for (const a of accounts) {
|
|
83
|
+
console.log(` ${paint(a.host, c.bold)} ${paint(PLATFORM_LABELS[a.platform], c.gray)}`);
|
|
84
|
+
console.log(paint(` HostName ${a.hostName} User ${a.user} Key ${a.identityFile || "(默认)"}`, c.dim));
|
|
85
|
+
}
|
|
86
|
+
console.log();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function help() {
|
|
90
|
+
console.log(`
|
|
91
|
+
${paint("betterssh", c.bold + c.cyan)} — 多账户 SSH 管理 CLI
|
|
92
|
+
|
|
93
|
+
${paint("用法:", c.bold)}
|
|
94
|
+
betterssh diagnose [path] 诊断 repo 的 SSH remote 配置(默认当前目录)
|
|
95
|
+
betterssh fix [path] 自动修复(歧义项用 --to <别名> 指定)
|
|
96
|
+
betterssh list 列出所有 SSH 账户
|
|
97
|
+
betterssh help 帮助
|
|
98
|
+
|
|
99
|
+
${paint("选项:", c.bold)}
|
|
100
|
+
--json JSON 输出(供 AI / 脚本)
|
|
101
|
+
--to <hostAlias> fix 时指定目标账户别名
|
|
102
|
+
-y, --yes fix 跳过确认
|
|
103
|
+
|
|
104
|
+
${paint("AI / MCP:", c.bold)} 运行 ${paint("betterssh-mcp", c.cyan)} 启动 MCP server,供 Claude / Cursor 调用。
|
|
105
|
+
${paint("说明:", c.dim)} 完全离线,只读 ~/.ssh/config 与 .git/config。
|
|
106
|
+
桌面版(可视化 / 密钥生成 / 备份): ${paint("betterssh.com", c.cyan)}
|
|
107
|
+
`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const argv = process.argv.slice(2);
|
|
111
|
+
const flags = { json: argv.includes("--json"), yes: argv.includes("-y") || argv.includes("--yes"), to: undefined };
|
|
112
|
+
const toIdx = argv.indexOf("--to"); if (toIdx >= 0) flags.to = argv[toIdx + 1];
|
|
113
|
+
const positional = argv.filter((a, i) => !a.startsWith("-") && argv[i - 1] !== "--to");
|
|
114
|
+
const cmd = positional[0] ?? "diagnose";
|
|
115
|
+
const pathArg = positional[1] || process.cwd();
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (cmd === "diagnose") {
|
|
119
|
+
const res = await analyze(pathArg);
|
|
120
|
+
if (flags.json) console.log(JSON.stringify(res, null, 2)); else printDiagnose(res);
|
|
121
|
+
process.exitCode = res.hasIssues ? 1 : 0;
|
|
122
|
+
} else if (cmd === "fix") await fix(pathArg, flags);
|
|
123
|
+
else if (cmd === "list") await list(flags.json);
|
|
124
|
+
else if (["help", "-h", "--help"].includes(cmd)) help();
|
|
125
|
+
else { console.log(paint(`未知命令: ${cmd}`, c.red)); help(); process.exit(1); }
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (flags.json) console.log(JSON.stringify({ error: e.message }));
|
|
128
|
+
else console.error(paint(`错误: ${e.message}`, c.red));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
package/mcp.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* betterssh-mcp — MCP server(stdio,零依赖)
|
|
4
|
+
*
|
|
5
|
+
* 把 betterssh 的能力暴露为 MCP 工具,供 Claude Desktop / Cursor / Claude Code 等调用:
|
|
6
|
+
* - ssh_diagnose 诊断某个 repo 的 SSH remote 配置
|
|
7
|
+
* - ssh_fix 修复 remote(指向正确的账户别名)
|
|
8
|
+
* - ssh_list_accounts 列出本机所有 SSH 账户
|
|
9
|
+
*
|
|
10
|
+
* 实现:newline-delimited JSON-RPC 2.0 over stdio(MCP stdio transport)。
|
|
11
|
+
* 日志走 stderr,协议消息走 stdout。
|
|
12
|
+
*/
|
|
13
|
+
import { analyze, applyFix, loadAccounts } from "./core.mjs";
|
|
14
|
+
|
|
15
|
+
const SERVER_NAME = "betterssh";
|
|
16
|
+
const SERVER_VERSION = "0.1.0";
|
|
17
|
+
const DEFAULT_PROTOCOL = "2024-11-05";
|
|
18
|
+
|
|
19
|
+
const log = (...a) => process.stderr.write("[betterssh-mcp] " + a.join(" ") + "\n");
|
|
20
|
+
|
|
21
|
+
const TOOLS = [
|
|
22
|
+
{
|
|
23
|
+
name: "ssh_diagnose",
|
|
24
|
+
description:
|
|
25
|
+
"诊断一个 git 仓库的 SSH remote 配置,找出多账户场景下 remote 用错账户/密钥导致 push/pull 失败的问题,并给出建议的修复 URL。完全离线。返回结构化 JSON。",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
path: { type: "string", description: "git 仓库的绝对路径。省略则使用 MCP server 的当前工作目录。" },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "ssh_fix",
|
|
35
|
+
description:
|
|
36
|
+
"修复一个 git 仓库的 remote,使其指向正确的 SSH 账户别名(执行 git remote set-url)。当某 remote 有多个候选账户时,必须用 to 指定目标账户别名。先调用 ssh_diagnose 查看候选。",
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
path: { type: "string", description: "git 仓库的绝对路径。省略则使用当前工作目录。" },
|
|
41
|
+
remote: { type: "string", description: "要修复的 remote 名称,如 origin。省略则修复所有可明确修复的 remote。" },
|
|
42
|
+
to: { type: "string", description: "目标账户别名(~/.ssh/config 里的 Host)。有歧义时必填。" },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "ssh_list_accounts",
|
|
48
|
+
description: "列出本机 ~/.ssh/config 中配置的所有 SSH 账户(host 别名、平台、HostName、密钥路径)。",
|
|
49
|
+
inputSchema: { type: "object", properties: {} },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
async function callTool(name, args = {}) {
|
|
54
|
+
const cwd = process.cwd();
|
|
55
|
+
if (name === "ssh_list_accounts") {
|
|
56
|
+
return await loadAccounts();
|
|
57
|
+
}
|
|
58
|
+
if (name === "ssh_diagnose") {
|
|
59
|
+
return await analyze(args.path || cwd);
|
|
60
|
+
}
|
|
61
|
+
if (name === "ssh_fix") {
|
|
62
|
+
const projectPath = args.path || cwd;
|
|
63
|
+
const res = await analyze(projectPath);
|
|
64
|
+
if (!res.isGitRepo) return { applied: [], error: "不是 git 仓库" };
|
|
65
|
+
let targets = res.remotes.filter((e) => e.status === "fixable" || e.status === "no_account");
|
|
66
|
+
if (args.remote) targets = targets.filter((e) => e.name === args.remote);
|
|
67
|
+
const applied = [];
|
|
68
|
+
for (const e of targets) {
|
|
69
|
+
if (e.fixes.length === 0) { applied.push({ remote: e.name, success: false, error: e.message }); continue; }
|
|
70
|
+
let chosen = args.to ? e.fixes.find((f) => f.host === args.to) : (e.fixes.length === 1 ? e.fixes[0] : null);
|
|
71
|
+
if (!chosen) {
|
|
72
|
+
applied.push({ remote: e.name, success: false, ambiguous: true, candidates: e.fixes.map((f) => f.host), error: "有多个候选账户,请用 to 指定" });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const r = await applyFix(projectPath, e.name, chosen.url);
|
|
76
|
+
applied.push({ remote: e.name, ...r });
|
|
77
|
+
}
|
|
78
|
+
return { applied };
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- JSON-RPC over stdio ----------
|
|
84
|
+
function send(msg) {
|
|
85
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
86
|
+
}
|
|
87
|
+
function reply(id, result) { send({ jsonrpc: "2.0", id, result }); }
|
|
88
|
+
function replyError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
|
|
89
|
+
|
|
90
|
+
async function handle(msg) {
|
|
91
|
+
const { id, method, params } = msg;
|
|
92
|
+
// 通知(无 id)
|
|
93
|
+
if (id === undefined || id === null) {
|
|
94
|
+
// notifications/initialized 等,无需响应
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
if (method === "initialize") {
|
|
99
|
+
const protocolVersion = params?.protocolVersion || DEFAULT_PROTOCOL;
|
|
100
|
+
reply(id, {
|
|
101
|
+
protocolVersion,
|
|
102
|
+
capabilities: { tools: {} },
|
|
103
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
104
|
+
});
|
|
105
|
+
} else if (method === "ping") {
|
|
106
|
+
reply(id, {});
|
|
107
|
+
} else if (method === "tools/list") {
|
|
108
|
+
reply(id, { tools: TOOLS });
|
|
109
|
+
} else if (method === "tools/call") {
|
|
110
|
+
const toolName = params?.name;
|
|
111
|
+
const args = params?.arguments || {};
|
|
112
|
+
try {
|
|
113
|
+
const data = await callTool(toolName, args);
|
|
114
|
+
reply(id, { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] });
|
|
115
|
+
} catch (err) {
|
|
116
|
+
reply(id, { content: [{ type: "text", text: `错误: ${err.message}` }], isError: true });
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
replyError(id, -32601, `Method not found: ${method}`);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
replyError(id, -32603, err.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let buffer = "";
|
|
127
|
+
const pending = new Set();
|
|
128
|
+
process.stdin.setEncoding("utf-8");
|
|
129
|
+
process.stdin.on("data", (chunk) => {
|
|
130
|
+
buffer += chunk;
|
|
131
|
+
let nl;
|
|
132
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
133
|
+
const line = buffer.slice(0, nl).trim();
|
|
134
|
+
buffer = buffer.slice(nl + 1);
|
|
135
|
+
if (!line) continue;
|
|
136
|
+
let msg;
|
|
137
|
+
try { msg = JSON.parse(line); } catch { log("解析失败:", line.slice(0, 80)); continue; }
|
|
138
|
+
const p = Promise.resolve(handle(msg)).catch((e) => log("handler error:", e.message)).finally(() => pending.delete(p));
|
|
139
|
+
pending.add(p);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// stdin 关闭后,等所有在途请求处理完再退出(否则异步 tools/call 会被截断)
|
|
143
|
+
process.stdin.on("end", async () => {
|
|
144
|
+
await Promise.allSettled([...pending]);
|
|
145
|
+
process.exit(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
log(`started (${SERVER_NAME} v${SERVER_VERSION}), waiting for MCP client on stdio...`);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "betterssh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "诊断并修复多账户 SSH 配置 —— 一条命令找出 git remote 用错账户/密钥的问题。BetterSSH 桌面应用的 CLI 伴侣。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"betterssh": "index.mjs",
|
|
8
|
+
"betterssh-mcp": "mcp.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.mjs",
|
|
12
|
+
"core.mjs",
|
|
13
|
+
"mcp.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"ssh",
|
|
21
|
+
"ssh-config",
|
|
22
|
+
"git",
|
|
23
|
+
"github",
|
|
24
|
+
"gitlab",
|
|
25
|
+
"multiple-accounts",
|
|
26
|
+
"ssh-keys",
|
|
27
|
+
"cli",
|
|
28
|
+
"devtools"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "https://betterssh.com",
|
|
31
|
+
"bugs": "https://github.com/bbbond123/BetterSSH/issues",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/bbbond123/BetterSSH.git",
|
|
35
|
+
"directory": "cli"
|
|
36
|
+
},
|
|
37
|
+
"author": "jamesw",
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|