@lzm04521/ssh-mcp-server 1.1.5
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/LICENSE +15 -0
- package/README.md +157 -0
- package/admin-web/dist/assets/index-DXbr5DVR.js +494 -0
- package/admin-web/dist/index.html +6 -0
- package/admin-web/dist/logo.png +0 -0
- package/build/cli/command-line-parser.js +715 -0
- package/build/cli/command-line-parser.js.map +1 -0
- package/build/cli/run-mode.js +44 -0
- package/build/cli/run-mode.js.map +1 -0
- package/build/cli/stdio-proxy.js +304 -0
- package/build/cli/stdio-proxy.js.map +1 -0
- package/build/config/index.js +5 -0
- package/build/config/index.js.map +1 -0
- package/build/config/server.js +16 -0
- package/build/config/server.js.map +1 -0
- package/build/core/mcp-http-server.js +14 -0
- package/build/core/mcp-http-server.js.map +1 -0
- package/build/core/mcp-server.js +95 -0
- package/build/core/mcp-server.js.map +1 -0
- package/build/index.js +98 -0
- package/build/index.js.map +1 -0
- package/build/models/admin-types.js +135 -0
- package/build/models/admin-types.js.map +1 -0
- package/build/models/types.js +2 -0
- package/build/models/types.js.map +1 -0
- package/build/server/index.js +128 -0
- package/build/server/index.js.map +1 -0
- package/build/server/routes/admin.js +929 -0
- package/build/server/routes/admin.js.map +1 -0
- package/build/server/routes/audit.js +9 -0
- package/build/server/routes/audit.js.map +1 -0
- package/build/server/routes/backups.js +11 -0
- package/build/server/routes/backups.js.map +1 -0
- package/build/server/routes/mcp.js +52 -0
- package/build/server/routes/mcp.js.map +1 -0
- package/build/server/routes/settings.js +49 -0
- package/build/server/routes/settings.js.map +1 -0
- package/build/server/routes/system.js +147 -0
- package/build/server/routes/system.js.map +1 -0
- package/build/services/audit-store.js +123 -0
- package/build/services/audit-store.js.map +1 -0
- package/build/services/autostart-service.js +55 -0
- package/build/services/autostart-service.js.map +1 -0
- package/build/services/backup-scheduler.js +90 -0
- package/build/services/backup-scheduler.js.map +1 -0
- package/build/services/backup-service.js +113 -0
- package/build/services/backup-service.js.map +1 -0
- package/build/services/config-store.js +163 -0
- package/build/services/config-store.js.map +1 -0
- package/build/services/defaults.js +35 -0
- package/build/services/defaults.js.map +1 -0
- package/build/services/restart-helper.js +43 -0
- package/build/services/restart-helper.js.map +1 -0
- package/build/services/ssh-connection-manager.js +1775 -0
- package/build/services/ssh-connection-manager.js.map +1 -0
- package/build/services/update-service.js +93 -0
- package/build/services/update-service.js.map +1 -0
- package/build/tools/download.js +41 -0
- package/build/tools/download.js.map +1 -0
- package/build/tools/execute-command.js +53 -0
- package/build/tools/execute-command.js.map +1 -0
- package/build/tools/index.js +17 -0
- package/build/tools/index.js.map +1 -0
- package/build/tools/list-directory.js +57 -0
- package/build/tools/list-directory.js.map +1 -0
- package/build/tools/list-servers.js +51 -0
- package/build/tools/list-servers.js.map +1 -0
- package/build/tools/upload.js +41 -0
- package/build/tools/upload.js.map +1 -0
- package/build/utils/logger.js +28 -0
- package/build/utils/logger.js.map +1 -0
- package/build/utils/ssh-config-parser.js +195 -0
- package/build/utils/ssh-config-parser.js.map +1 -0
- package/build/utils/status-collector.js +226 -0
- package/build/utils/status-collector.js.map +1 -0
- package/build/utils/tool-error.js +20 -0
- package/build/utils/tool-error.js.map +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { DEFAULT_ADMIN_PORT } from "../models/admin-types.js";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import { lookupSshConfig } from "../utils/ssh-config-parser.js";
|
|
7
|
+
import { Logger } from "../utils/logger.js";
|
|
8
|
+
/**
|
|
9
|
+
* Command line argument parser class
|
|
10
|
+
*/
|
|
11
|
+
export class CommandLineParser {
|
|
12
|
+
static DEFAULT_TRANSPORT_MODE = "exec";
|
|
13
|
+
static DEFAULT_SHELL_READY_TIMEOUT_MS = 10000;
|
|
14
|
+
static parseBoolean(value) {
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (typeof value === "boolean") {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const normalized = value.trim().toLowerCase();
|
|
23
|
+
if (normalized === "true") {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (normalized === "false") {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Boolean(value);
|
|
31
|
+
}
|
|
32
|
+
static parseTransportMode(value) {
|
|
33
|
+
if (value === undefined || value === null || value === "") {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (value === "exec" || value === "shell") {
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`transportMode must be either 'exec' or 'shell', got: ${String(value)}`);
|
|
40
|
+
}
|
|
41
|
+
static parseTimeout(value, fieldName) {
|
|
42
|
+
if (value === undefined || value === null || value === "") {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const parsed = typeof value === "number" ? value : parseInt(String(value), 10);
|
|
46
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
47
|
+
throw new Error(`${fieldName} must be a positive number, got: ${String(value)}`);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
static parseMaxOutputBytes(value) {
|
|
52
|
+
if (value === undefined || value === null || value === "") {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const parsed = typeof value === "number" ? value : Number(String(value));
|
|
56
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
57
|
+
throw new Error(`maxOutputBytes must be a non-negative integer, got: ${String(value)}`);
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse command line arguments
|
|
63
|
+
*/
|
|
64
|
+
static parseArgs() {
|
|
65
|
+
const { values, positionals } = parseArgs({
|
|
66
|
+
args: process.argv.slice(2),
|
|
67
|
+
options: {
|
|
68
|
+
"config-file": { type: "string" },
|
|
69
|
+
"ssh-config-file": { type: "string" },
|
|
70
|
+
ssh: { type: "string", multiple: true },
|
|
71
|
+
// Compatible with single connection legacy parameters
|
|
72
|
+
host: { type: "string", short: "h" },
|
|
73
|
+
port: { type: "string", short: "p" },
|
|
74
|
+
username: { type: "string", short: "u" },
|
|
75
|
+
password: { type: "string", short: "w" },
|
|
76
|
+
privateKey: { type: "string", short: "k" },
|
|
77
|
+
passphrase: { type: "string", short: "P" },
|
|
78
|
+
agent: { type: "string", short: "a" },
|
|
79
|
+
whitelist: { type: "string", short: "W" },
|
|
80
|
+
blacklist: { type: "string", short: "B" },
|
|
81
|
+
proxy: { type: "string" },
|
|
82
|
+
socksProxy: { type: "string", short: "s" },
|
|
83
|
+
"allowed-local-paths": { type: "string" },
|
|
84
|
+
"allowed-remote-paths": { type: "string" },
|
|
85
|
+
"transport-mode": { type: "string" },
|
|
86
|
+
"shell-ready-timeout": { type: "string" },
|
|
87
|
+
"command-template": { type: "string" },
|
|
88
|
+
pty: { type: "boolean" },
|
|
89
|
+
"try-keyboard": { type: "boolean" },
|
|
90
|
+
"pre-connect": { type: "boolean" },
|
|
91
|
+
admin: { type: "boolean" },
|
|
92
|
+
"admin-port": { type: "string" },
|
|
93
|
+
},
|
|
94
|
+
allowPositionals: true,
|
|
95
|
+
});
|
|
96
|
+
const configMap = {};
|
|
97
|
+
// Priority 1: Load from config file if specified
|
|
98
|
+
if (values["config-file"]) {
|
|
99
|
+
const configFilePath = path.resolve(values["config-file"]);
|
|
100
|
+
if (!fs.existsSync(configFilePath)) {
|
|
101
|
+
throw new Error(`Config file not found: ${configFilePath}`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const configContent = fs.readFileSync(configFilePath, "utf-8");
|
|
105
|
+
const fileConfig = JSON.parse(configContent);
|
|
106
|
+
// Support both array format and object format
|
|
107
|
+
if (Array.isArray(fileConfig)) {
|
|
108
|
+
// Array format: [{name: "dev", host: "...", ...}, ...]
|
|
109
|
+
for (const config of fileConfig) {
|
|
110
|
+
if (!config.name || !config.host || !config.port || !config.username) {
|
|
111
|
+
throw new Error("Each config in array must include name, host, port, username");
|
|
112
|
+
}
|
|
113
|
+
configMap[config.name] = this.normalizeConfig(config);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (typeof fileConfig === "object" && fileConfig !== null) {
|
|
117
|
+
// Object format: {"dev": {host: "...", ...}, "prod": {...}}
|
|
118
|
+
for (const [name, config] of Object.entries(fileConfig)) {
|
|
119
|
+
const normalizedConfig = this.normalizeConfig(config);
|
|
120
|
+
normalizedConfig.name = name;
|
|
121
|
+
configMap[name] = normalizedConfig;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw new Error("Config file must contain an array or object of SSH configurations");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
if (err instanceof SyntaxError) {
|
|
130
|
+
throw new Error(`Invalid JSON in config file: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Priority 2: Parse --ssh parameters (only if no config file was loaded)
|
|
136
|
+
if (Object.keys(configMap).length === 0) {
|
|
137
|
+
const sshParams = Array.isArray(values.ssh)
|
|
138
|
+
? values.ssh
|
|
139
|
+
: values.ssh
|
|
140
|
+
? [values.ssh]
|
|
141
|
+
: [];
|
|
142
|
+
for (const sshStr of sshParams) {
|
|
143
|
+
let conf;
|
|
144
|
+
// Try to parse as JSON first
|
|
145
|
+
if (sshStr.trim().startsWith("{")) {
|
|
146
|
+
try {
|
|
147
|
+
const jsonConfig = JSON.parse(sshStr);
|
|
148
|
+
conf = this.normalizeConfig(jsonConfig);
|
|
149
|
+
if (!conf.name) {
|
|
150
|
+
throw new Error("JSON config must include 'name' field");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
throw new Error(`Invalid JSON format in --ssh parameter: ${err.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// Fallback to legacy comma-separated format for backward compatibility
|
|
159
|
+
conf = this.parseLegacySshFormat(sshStr);
|
|
160
|
+
}
|
|
161
|
+
if (!conf.name || !conf.host || !conf.port || !conf.username) {
|
|
162
|
+
throw new Error("Each --ssh must include name, host, port, username");
|
|
163
|
+
}
|
|
164
|
+
configMap[conf.name] = conf;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Priority 3: skip legacy single-host validation when --admin (GUI 模式允许零连接)
|
|
168
|
+
if (values.admin === true && Object.keys(configMap).length === 0) {
|
|
169
|
+
return {
|
|
170
|
+
configs: configMap,
|
|
171
|
+
preConnect: values["pre-connect"] === true,
|
|
172
|
+
admin: true,
|
|
173
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
174
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// Priority 3: Compatible with single connection legacy parameters
|
|
178
|
+
if (Object.keys(configMap).length === 0) {
|
|
179
|
+
const host = values.host || positionals[0];
|
|
180
|
+
// 尝试从 SSH config 读取配置
|
|
181
|
+
let sshConfigEntry = null;
|
|
182
|
+
if (host) {
|
|
183
|
+
try {
|
|
184
|
+
sshConfigEntry = lookupSshConfig(host, values["ssh-config-file"]);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
// 显式指定配置文件但读取失败时抛错
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const portStr = values.port || positionals[1] || sshConfigEntry?.port?.toString() || "22";
|
|
192
|
+
const username = values.username || positionals[2] || sshConfigEntry?.user;
|
|
193
|
+
const password = values.password || positionals[3];
|
|
194
|
+
const privateKey = values.privateKey || sshConfigEntry?.identityFile;
|
|
195
|
+
const passphrase = values.passphrase || process.env.SSH_MCP_PASSPHRASE;
|
|
196
|
+
const resolvedAgent = values.agent !== undefined
|
|
197
|
+
? values.agent
|
|
198
|
+
: !password && !privateKey
|
|
199
|
+
? process.env.SSH_AUTH_SOCK
|
|
200
|
+
: undefined;
|
|
201
|
+
const whitelist = values.whitelist;
|
|
202
|
+
const blacklist = values.blacklist;
|
|
203
|
+
const allowedLocalPaths = values["allowed-local-paths"];
|
|
204
|
+
const allowedRemotePaths = values["allowed-remote-paths"];
|
|
205
|
+
const commandTemplate = values["command-template"];
|
|
206
|
+
const pty = values.pty;
|
|
207
|
+
const tryKeyboard = values["try-keyboard"];
|
|
208
|
+
// 实际连接地址:优先使用 SSH config 的 HostName
|
|
209
|
+
const actualHost = sshConfigEntry?.hostName || host;
|
|
210
|
+
if (!actualHost || !portStr || !username || (!password && !privateKey && !resolvedAgent)) {
|
|
211
|
+
throw new Error("Missing required parameters, need to provide host, port, username and password, private key or agent");
|
|
212
|
+
}
|
|
213
|
+
const port = parseInt(portStr, 10);
|
|
214
|
+
if (isNaN(port)) {
|
|
215
|
+
throw new Error("Port must be a valid number");
|
|
216
|
+
}
|
|
217
|
+
configMap["default"] = this.normalizeConfig({
|
|
218
|
+
name: "default",
|
|
219
|
+
host: actualHost,
|
|
220
|
+
port,
|
|
221
|
+
username,
|
|
222
|
+
password,
|
|
223
|
+
privateKey,
|
|
224
|
+
passphrase,
|
|
225
|
+
agent: resolvedAgent,
|
|
226
|
+
proxy: values.proxy,
|
|
227
|
+
socksProxy: values.socksProxy,
|
|
228
|
+
pty: pty !== undefined ? pty : undefined,
|
|
229
|
+
tryKeyboard: tryKeyboard !== undefined ? tryKeyboard : undefined,
|
|
230
|
+
transportMode: values["transport-mode"],
|
|
231
|
+
shellReadyTimeoutMs: values["shell-ready-timeout"],
|
|
232
|
+
commandTemplate,
|
|
233
|
+
commandWhitelist: whitelist
|
|
234
|
+
? whitelist
|
|
235
|
+
.split(",")
|
|
236
|
+
.map((pattern) => pattern.trim())
|
|
237
|
+
.filter(Boolean)
|
|
238
|
+
: undefined,
|
|
239
|
+
commandBlacklist: blacklist
|
|
240
|
+
? blacklist
|
|
241
|
+
.split(",")
|
|
242
|
+
.map((pattern) => pattern.trim())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
: undefined,
|
|
245
|
+
allowedLocalPaths: allowedLocalPaths
|
|
246
|
+
? allowedLocalPaths
|
|
247
|
+
.split(",")
|
|
248
|
+
.map((allowedPath) => allowedPath.trim())
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
: undefined,
|
|
251
|
+
allowedRemotePaths: allowedRemotePaths
|
|
252
|
+
? allowedRemotePaths
|
|
253
|
+
.split(",")
|
|
254
|
+
.map((allowedPath) => allowedPath.trim())
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
: undefined,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
configs: configMap,
|
|
261
|
+
preConnect: values["pre-connect"] === true,
|
|
262
|
+
admin: values.admin === true,
|
|
263
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
264
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Parse legacy comma-separated format: name=dev,host=1.2.3.4,port=22,user=alice,password=xxx
|
|
269
|
+
* @private
|
|
270
|
+
*/
|
|
271
|
+
static parseLegacySshFormat(sshStr) {
|
|
272
|
+
const conf = {};
|
|
273
|
+
const parts = sshStr.split(",");
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
// Only split on the first '=' to handle values containing '='
|
|
276
|
+
const equalIndex = part.indexOf("=");
|
|
277
|
+
if (equalIndex > 0) {
|
|
278
|
+
const k = part.substring(0, equalIndex).trim();
|
|
279
|
+
const v = part.substring(equalIndex + 1).trim();
|
|
280
|
+
if (k && v) {
|
|
281
|
+
conf[k] = v;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const port = parseInt(conf.port, 10);
|
|
286
|
+
if (isNaN(port)) {
|
|
287
|
+
throw new Error(`Port for connection ${conf.name || "unknown"} must be a valid number`);
|
|
288
|
+
}
|
|
289
|
+
return this.normalizeConfig(conf);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Normalize SSH config object to ensure proper types and structure
|
|
293
|
+
* @private
|
|
294
|
+
*/
|
|
295
|
+
static normalizeConfig(config) {
|
|
296
|
+
const port = typeof config.port === "number"
|
|
297
|
+
? config.port
|
|
298
|
+
: parseInt(config.port, 10);
|
|
299
|
+
if (isNaN(port)) {
|
|
300
|
+
throw new Error(`Port must be a valid number, got: ${config.port}`);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
name: config.name,
|
|
304
|
+
host: config.host,
|
|
305
|
+
port,
|
|
306
|
+
username: config.username || config.user,
|
|
307
|
+
password: config.password,
|
|
308
|
+
privateKey: config.privateKey
|
|
309
|
+
? this.normalizeLocalPath(String(config.privateKey))
|
|
310
|
+
: undefined,
|
|
311
|
+
passphrase: config.passphrase || process.env.SSH_MCP_PASSPHRASE,
|
|
312
|
+
agent: config.agent,
|
|
313
|
+
algorithms: config.algorithms,
|
|
314
|
+
proxy: config.proxy,
|
|
315
|
+
socksProxy: config.socksProxy,
|
|
316
|
+
pty: this.parseBoolean(config.pty),
|
|
317
|
+
tryKeyboard: this.parseBoolean(config.tryKeyboard),
|
|
318
|
+
transportMode: this.parseTransportMode(config.transportMode) ||
|
|
319
|
+
this.DEFAULT_TRANSPORT_MODE,
|
|
320
|
+
shellReadyTimeoutMs: this.parseTimeout(config.shellReadyTimeoutMs, "shellReadyTimeoutMs") || this.DEFAULT_SHELL_READY_TIMEOUT_MS,
|
|
321
|
+
shellCommandTimeoutMs: this.parseTimeout(config.shellCommandTimeoutMs, "shellCommandTimeoutMs"),
|
|
322
|
+
commandTimeoutMs: this.parseTimeout(config.commandTimeoutMs, "commandTimeoutMs"),
|
|
323
|
+
connectionTimeoutMs: this.parseTimeout(config.connectionTimeoutMs, "connectionTimeoutMs"),
|
|
324
|
+
sftpTimeoutMs: this.parseTimeout(config.sftpTimeoutMs, "sftpTimeoutMs"),
|
|
325
|
+
maxOutputBytes: this.parseMaxOutputBytes(config.maxOutputBytes),
|
|
326
|
+
keepaliveIntervalMs: this.parseTimeout(config.keepaliveIntervalMs, "keepaliveIntervalMs"),
|
|
327
|
+
keepaliveCountMax: this.parseTimeout(config.keepaliveCountMax, "keepaliveCountMax"),
|
|
328
|
+
commandWhitelist: Array.isArray(config.commandWhitelist)
|
|
329
|
+
? config.commandWhitelist
|
|
330
|
+
: config.whitelist
|
|
331
|
+
? typeof config.whitelist === "string"
|
|
332
|
+
? config.whitelist.split("|").map((s) => s.trim()).filter(Boolean)
|
|
333
|
+
: config.whitelist
|
|
334
|
+
: undefined,
|
|
335
|
+
commandBlacklist: Array.isArray(config.commandBlacklist)
|
|
336
|
+
? config.commandBlacklist
|
|
337
|
+
: config.blacklist
|
|
338
|
+
? typeof config.blacklist === "string"
|
|
339
|
+
? config.blacklist.split("|").map((s) => s.trim()).filter(Boolean)
|
|
340
|
+
: config.blacklist
|
|
341
|
+
: undefined,
|
|
342
|
+
allowedLocalPaths: Array.isArray(config.allowedLocalPaths)
|
|
343
|
+
? config.allowedLocalPaths
|
|
344
|
+
.map((allowedPath) => this.normalizeLocalPath(String(allowedPath)))
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
: typeof config.allowedLocalPaths === "string"
|
|
347
|
+
? config.allowedLocalPaths
|
|
348
|
+
.split("|")
|
|
349
|
+
.map((allowedPath) => this.normalizeLocalPath(allowedPath.trim()))
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
: undefined,
|
|
352
|
+
allowedRemotePaths: Array.isArray(config.allowedRemotePaths)
|
|
353
|
+
? config.allowedRemotePaths
|
|
354
|
+
.map((allowedPath) => this.normalizeRemotePath(String(allowedPath)))
|
|
355
|
+
: typeof config.allowedRemotePaths === "string"
|
|
356
|
+
? config.allowedRemotePaths
|
|
357
|
+
.split("|")
|
|
358
|
+
.map((allowedPath) => this.normalizeRemotePath(allowedPath.trim()))
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
: undefined,
|
|
361
|
+
commandTemplate: this.parseCommandTemplate(config.commandTemplate),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
static parseCommandTemplate(value) {
|
|
365
|
+
if (value === undefined || value === null || value === "") {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const template = String(value);
|
|
369
|
+
if (!template.includes("<command>") && !template.includes("<quotedCommand>")) {
|
|
370
|
+
throw new Error(`commandTemplate must contain '<command>' or '<quotedCommand>' placeholder, got: ${template}`);
|
|
371
|
+
}
|
|
372
|
+
return template;
|
|
373
|
+
}
|
|
374
|
+
static normalizeLocalPath(localPath) {
|
|
375
|
+
return path.resolve(this.expandHomePath(localPath));
|
|
376
|
+
}
|
|
377
|
+
static expandHomePath(localPath) {
|
|
378
|
+
if (localPath === "~") {
|
|
379
|
+
return os.homedir();
|
|
380
|
+
}
|
|
381
|
+
if (localPath.startsWith("~/")) {
|
|
382
|
+
return path.join(os.homedir(), localPath.slice(2));
|
|
383
|
+
}
|
|
384
|
+
return localPath;
|
|
385
|
+
}
|
|
386
|
+
static normalizeRemotePath(remotePath) {
|
|
387
|
+
if (!remotePath) {
|
|
388
|
+
return "";
|
|
389
|
+
}
|
|
390
|
+
if (!path.posix.isAbsolute(remotePath)) {
|
|
391
|
+
throw new Error(`allowedRemotePaths entries must be absolute POSIX paths, got: ${remotePath}`);
|
|
392
|
+
}
|
|
393
|
+
const normalized = path.posix.normalize(remotePath);
|
|
394
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
395
|
+
return normalized.slice(0, -1);
|
|
396
|
+
}
|
|
397
|
+
return normalized;
|
|
398
|
+
}
|
|
399
|
+
static migrateLegacy(raw) {
|
|
400
|
+
if (!raw || typeof raw !== "object" || !raw.connections) {
|
|
401
|
+
return raw;
|
|
402
|
+
}
|
|
403
|
+
Logger.log("检测到旧格式 connections,已迁移至 projects.default.environments.default", "info");
|
|
404
|
+
const hosts = Array.isArray(raw.connections)
|
|
405
|
+
? Object.fromEntries(raw.connections.map((c) => [c.name, c]))
|
|
406
|
+
: raw.connections;
|
|
407
|
+
raw.projects = raw.projects || {};
|
|
408
|
+
if (!raw.projects.default) {
|
|
409
|
+
raw.projects.default = {
|
|
410
|
+
displayName: "默认项目",
|
|
411
|
+
environments: {},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (!raw.projects.default.displayName) {
|
|
415
|
+
raw.projects.default.displayName = "默认项目";
|
|
416
|
+
}
|
|
417
|
+
raw.projects.default.environments = raw.projects.default.environments || {};
|
|
418
|
+
if (!raw.projects.default.environments.default) {
|
|
419
|
+
raw.projects.default.environments.default = {
|
|
420
|
+
displayName: "默认环境",
|
|
421
|
+
hosts: {},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (!raw.projects.default.environments.default.displayName) {
|
|
425
|
+
raw.projects.default.environments.default.displayName = "默认环境";
|
|
426
|
+
}
|
|
427
|
+
raw.projects.default.environments.default.hosts =
|
|
428
|
+
raw.projects.default.environments.default.hosts || {};
|
|
429
|
+
for (const [k, v] of Object.entries(hosts)) {
|
|
430
|
+
const key = v?.name ? String(v.name) : k;
|
|
431
|
+
raw.projects.default.environments.default.hosts[key] = v;
|
|
432
|
+
}
|
|
433
|
+
delete raw.connections;
|
|
434
|
+
return raw;
|
|
435
|
+
}
|
|
436
|
+
static buildDefaultHierarchy(hostCfg) {
|
|
437
|
+
return {
|
|
438
|
+
default: {
|
|
439
|
+
displayName: "默认项目",
|
|
440
|
+
environments: {
|
|
441
|
+
default: {
|
|
442
|
+
displayName: "默认环境",
|
|
443
|
+
hosts: {
|
|
444
|
+
default: hostCfg,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
static parse(argv) {
|
|
452
|
+
const args = argv ?? process.argv.slice(2);
|
|
453
|
+
const { values, positionals } = parseArgs({
|
|
454
|
+
args,
|
|
455
|
+
options: {
|
|
456
|
+
"config-file": { type: "string" },
|
|
457
|
+
"ssh-config-file": { type: "string" },
|
|
458
|
+
ssh: { type: "string", multiple: true },
|
|
459
|
+
host: { type: "string", short: "h" },
|
|
460
|
+
port: { type: "string", short: "p" },
|
|
461
|
+
username: { type: "string", short: "u" },
|
|
462
|
+
password: { type: "string", short: "w" },
|
|
463
|
+
privateKey: { type: "string", short: "k" },
|
|
464
|
+
passphrase: { type: "string", short: "P" },
|
|
465
|
+
agent: { type: "string", short: "a" },
|
|
466
|
+
whitelist: { type: "string", short: "W" },
|
|
467
|
+
blacklist: { type: "string", short: "B" },
|
|
468
|
+
proxy: { type: "string" },
|
|
469
|
+
socksProxy: { type: "string", short: "s" },
|
|
470
|
+
"allowed-local-paths": { type: "string" },
|
|
471
|
+
"allowed-remote-paths": { type: "string" },
|
|
472
|
+
"transport-mode": { type: "string" },
|
|
473
|
+
"shell-ready-timeout": { type: "string" },
|
|
474
|
+
"command-template": { type: "string" },
|
|
475
|
+
pty: { type: "boolean" },
|
|
476
|
+
"try-keyboard": { type: "boolean" },
|
|
477
|
+
"pre-connect": { type: "boolean" },
|
|
478
|
+
admin: { type: "boolean" },
|
|
479
|
+
"admin-port": { type: "string" },
|
|
480
|
+
},
|
|
481
|
+
allowPositionals: true,
|
|
482
|
+
});
|
|
483
|
+
// Priority 1: config file
|
|
484
|
+
if (values["config-file"]) {
|
|
485
|
+
const configFilePath = path.resolve(values["config-file"]);
|
|
486
|
+
if (!fs.existsSync(configFilePath)) {
|
|
487
|
+
throw new Error(`Config file not found: ${configFilePath}`);
|
|
488
|
+
}
|
|
489
|
+
const configContent = fs.readFileSync(configFilePath, "utf-8");
|
|
490
|
+
const raw = JSON.parse(configContent);
|
|
491
|
+
// legacy connections migration
|
|
492
|
+
if (raw.connections) {
|
|
493
|
+
this.migrateLegacy(raw);
|
|
494
|
+
return {
|
|
495
|
+
port: raw.port ?? DEFAULT_ADMIN_PORT,
|
|
496
|
+
projects: raw.projects,
|
|
497
|
+
audit: raw.audit,
|
|
498
|
+
backups: raw.backups,
|
|
499
|
+
security: raw.security,
|
|
500
|
+
preConnect: raw.preConnect,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
// new hierarchy file
|
|
504
|
+
if (raw.projects) {
|
|
505
|
+
return {
|
|
506
|
+
port: raw.port ?? DEFAULT_ADMIN_PORT,
|
|
507
|
+
projects: raw.projects,
|
|
508
|
+
audit: raw.audit,
|
|
509
|
+
backups: raw.backups,
|
|
510
|
+
security: raw.security,
|
|
511
|
+
preConnect: raw.preConnect,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
// flat legacy without wrapper: array or object of hosts -> migrate to default/default
|
|
515
|
+
if (Array.isArray(raw)) {
|
|
516
|
+
const hosts = {};
|
|
517
|
+
for (const c of raw) {
|
|
518
|
+
if (c.name)
|
|
519
|
+
hosts[c.name] = this.normalizeConfig(c);
|
|
520
|
+
}
|
|
521
|
+
Logger.log("检测到旧格式 connections,已迁移至 projects.default.environments.default", "info");
|
|
522
|
+
return {
|
|
523
|
+
port: DEFAULT_ADMIN_PORT,
|
|
524
|
+
projects: {
|
|
525
|
+
default: {
|
|
526
|
+
displayName: "默认项目",
|
|
527
|
+
environments: {
|
|
528
|
+
default: { displayName: "默认环境", hosts },
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (typeof raw === "object" && raw !== null) {
|
|
535
|
+
const maybeHosts = Object.values(raw).some((v) => v && typeof v === "object" && "host" in v);
|
|
536
|
+
if (maybeHosts) {
|
|
537
|
+
const hosts = {};
|
|
538
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
539
|
+
const conf = v;
|
|
540
|
+
const normalized = this.normalizeConfig({ ...conf, name: conf.name || k });
|
|
541
|
+
hosts[normalized.name || k] = normalized;
|
|
542
|
+
}
|
|
543
|
+
Logger.log("检测到旧格式 connections,已迁移至 projects.default.environments.default", "info");
|
|
544
|
+
return {
|
|
545
|
+
port: DEFAULT_ADMIN_PORT,
|
|
546
|
+
projects: {
|
|
547
|
+
default: {
|
|
548
|
+
displayName: "默认项目",
|
|
549
|
+
environments: {
|
|
550
|
+
default: { displayName: "默认环境", hosts },
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return raw;
|
|
558
|
+
}
|
|
559
|
+
// Priority 2: --ssh params -> hierarchy default/default
|
|
560
|
+
const sshParams = Array.isArray(values.ssh)
|
|
561
|
+
? values.ssh
|
|
562
|
+
: values.ssh
|
|
563
|
+
? [values.ssh]
|
|
564
|
+
: [];
|
|
565
|
+
if (sshParams.length > 0) {
|
|
566
|
+
const hosts = {};
|
|
567
|
+
for (const sshStr of sshParams) {
|
|
568
|
+
let conf;
|
|
569
|
+
if (sshStr.trim().startsWith("{")) {
|
|
570
|
+
const jsonConfig = JSON.parse(sshStr);
|
|
571
|
+
conf = this.normalizeConfig(jsonConfig);
|
|
572
|
+
if (!conf.name)
|
|
573
|
+
throw new Error("JSON config must include 'name' field");
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
conf = this.parseLegacySshFormat(sshStr);
|
|
577
|
+
}
|
|
578
|
+
if (!conf.name || !conf.host || !conf.port || !conf.username) {
|
|
579
|
+
throw new Error("Each --ssh must include name, host, port, username");
|
|
580
|
+
}
|
|
581
|
+
hosts[conf.name] = conf;
|
|
582
|
+
}
|
|
583
|
+
Logger.log("检测到旧格式 connections,已迁移至 projects.default.environments.default", "info");
|
|
584
|
+
return {
|
|
585
|
+
port: DEFAULT_ADMIN_PORT,
|
|
586
|
+
projects: {
|
|
587
|
+
default: {
|
|
588
|
+
displayName: "默认项目",
|
|
589
|
+
environments: {
|
|
590
|
+
default: { displayName: "默认环境", hosts },
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
preConnect: values["pre-connect"] === true,
|
|
595
|
+
admin: values.admin === true,
|
|
596
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
597
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// Priority: --admin without hosts
|
|
601
|
+
if (values.admin === true) {
|
|
602
|
+
const host = values.host || positionals[0];
|
|
603
|
+
if (!host) {
|
|
604
|
+
return {
|
|
605
|
+
port: DEFAULT_ADMIN_PORT,
|
|
606
|
+
projects: {},
|
|
607
|
+
preConnect: values["pre-connect"] === true,
|
|
608
|
+
admin: true,
|
|
609
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
610
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Priority 3: single host -> default/default/default
|
|
615
|
+
const host = values.host || positionals[0];
|
|
616
|
+
if (host) {
|
|
617
|
+
let sshConfigEntry = null;
|
|
618
|
+
try {
|
|
619
|
+
sshConfigEntry = lookupSshConfig(host, values["ssh-config-file"]);
|
|
620
|
+
}
|
|
621
|
+
catch (err) {
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
const portStr = values.port ||
|
|
625
|
+
positionals[1] ||
|
|
626
|
+
sshConfigEntry?.port?.toString() ||
|
|
627
|
+
"22";
|
|
628
|
+
const username = values.username ||
|
|
629
|
+
positionals[2] ||
|
|
630
|
+
sshConfigEntry?.user;
|
|
631
|
+
const password = values.password || positionals[3];
|
|
632
|
+
const privateKey = values.privateKey || sshConfigEntry?.identityFile;
|
|
633
|
+
const passphrase = values.passphrase || process.env.SSH_MCP_PASSPHRASE;
|
|
634
|
+
const resolvedAgent = values.agent !== undefined
|
|
635
|
+
? values.agent
|
|
636
|
+
: !password && !privateKey
|
|
637
|
+
? process.env.SSH_AUTH_SOCK
|
|
638
|
+
: undefined;
|
|
639
|
+
const whitelist = values.whitelist;
|
|
640
|
+
const blacklist = values.blacklist;
|
|
641
|
+
const allowedLocalPaths = values["allowed-local-paths"];
|
|
642
|
+
const allowedRemotePaths = values["allowed-remote-paths"];
|
|
643
|
+
const commandTemplate = values["command-template"];
|
|
644
|
+
const pty = values.pty;
|
|
645
|
+
const tryKeyboard = values["try-keyboard"];
|
|
646
|
+
const actualHost = sshConfigEntry?.hostName || host;
|
|
647
|
+
if (!actualHost || !portStr || !username || (!password && !privateKey && !resolvedAgent)) {
|
|
648
|
+
throw new Error("Missing required parameters, need to provide host, port, username and password, private key or agent");
|
|
649
|
+
}
|
|
650
|
+
const port = parseInt(portStr, 10);
|
|
651
|
+
if (isNaN(port))
|
|
652
|
+
throw new Error("Port must be a valid number");
|
|
653
|
+
const hostCfg = this.normalizeConfig({
|
|
654
|
+
name: "default",
|
|
655
|
+
host: actualHost,
|
|
656
|
+
port,
|
|
657
|
+
username,
|
|
658
|
+
password,
|
|
659
|
+
privateKey,
|
|
660
|
+
passphrase,
|
|
661
|
+
agent: resolvedAgent,
|
|
662
|
+
proxy: values.proxy,
|
|
663
|
+
socksProxy: values.socksProxy,
|
|
664
|
+
pty: pty !== undefined ? pty : undefined,
|
|
665
|
+
tryKeyboard: tryKeyboard !== undefined ? tryKeyboard : undefined,
|
|
666
|
+
transportMode: values["transport-mode"],
|
|
667
|
+
shellReadyTimeoutMs: values["shell-ready-timeout"],
|
|
668
|
+
commandTemplate,
|
|
669
|
+
commandWhitelist: whitelist
|
|
670
|
+
? whitelist
|
|
671
|
+
.split(",")
|
|
672
|
+
.map((pattern) => pattern.trim())
|
|
673
|
+
.filter(Boolean)
|
|
674
|
+
: undefined,
|
|
675
|
+
commandBlacklist: blacklist
|
|
676
|
+
? blacklist
|
|
677
|
+
.split(",")
|
|
678
|
+
.map((pattern) => pattern.trim())
|
|
679
|
+
.filter(Boolean)
|
|
680
|
+
: undefined,
|
|
681
|
+
allowedLocalPaths: allowedLocalPaths
|
|
682
|
+
? allowedLocalPaths
|
|
683
|
+
.split(",")
|
|
684
|
+
.map((allowedPath) => allowedPath.trim())
|
|
685
|
+
.filter(Boolean)
|
|
686
|
+
: undefined,
|
|
687
|
+
allowedRemotePaths: allowedRemotePaths
|
|
688
|
+
? allowedRemotePaths
|
|
689
|
+
.split(",")
|
|
690
|
+
.map((allowedPath) => allowedPath.trim())
|
|
691
|
+
.filter(Boolean)
|
|
692
|
+
: undefined,
|
|
693
|
+
});
|
|
694
|
+
Logger.log("单机参数已归入 projects.default.environments.default.hosts.default", "info");
|
|
695
|
+
return {
|
|
696
|
+
port: DEFAULT_ADMIN_PORT,
|
|
697
|
+
projects: this.buildDefaultHierarchy(hostCfg),
|
|
698
|
+
preConnect: values["pre-connect"] === true,
|
|
699
|
+
admin: values.admin === true,
|
|
700
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
701
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
// fallback: empty
|
|
705
|
+
return {
|
|
706
|
+
port: DEFAULT_ADMIN_PORT,
|
|
707
|
+
projects: {},
|
|
708
|
+
preConnect: values["pre-connect"] === true,
|
|
709
|
+
admin: values.admin === true,
|
|
710
|
+
adminPort: values["admin-port"] ? parseInt(String(values["admin-port"]), 10) : undefined,
|
|
711
|
+
configFile: values["config-file"] ? String(values["config-file"]) : undefined,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=command-line-parser.js.map
|