agent-ssh-cli 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/LICENSE +21 -0
- package/README.md +168 -0
- package/bin/agentsshcli.js +7 -0
- package/example.config.json +40 -0
- package/package.json +25 -0
- package/src/cli.js +454 -0
- package/src/config.js +162 -0
- package/src/daemon-client.js +133 -0
- package/src/daemon-paths.js +57 -0
- package/src/ssh-client.js +215 -0
- package/src/ssh-daemon.js +236 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadConfig, findConnection, validateLocalPath, getDefaultConfigPath } from "./config.js";
|
|
6
|
+
import { requestDaemon } from "./daemon-client.js";
|
|
7
|
+
import { normalizeCacheTtl } from "./daemon-paths.js";
|
|
8
|
+
import { executeRemoteCommand, uploadFile, downloadFile } from "./ssh-client.js";
|
|
9
|
+
|
|
10
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(currentDir, "..", "package.json"), "utf8"));
|
|
12
|
+
|
|
13
|
+
const HELP_TEXT = {
|
|
14
|
+
agentsshcli: `
|
|
15
|
+
用法:
|
|
16
|
+
agentsshcli list [--config <path>] [--json]
|
|
17
|
+
agentsshcli exec [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <command>
|
|
18
|
+
agentsshcli exec [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --command <command> [--directory <dir>] [--timeout <ms>]
|
|
19
|
+
agentsshcli upload [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <localPath> <remotePath>
|
|
20
|
+
agentsshcli upload [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --local <path> --remote <path>
|
|
21
|
+
agentsshcli download [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <remotePath> <localPath>
|
|
22
|
+
agentsshcli download [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --remote <path> --local <path>
|
|
23
|
+
agentsshcli init-config
|
|
24
|
+
agentsshcli help [list|exec|upload|download]
|
|
25
|
+
agentsshcli --help
|
|
26
|
+
agentsshcli --version
|
|
27
|
+
|
|
28
|
+
说明:
|
|
29
|
+
agent-ssh-cli 统一入口。
|
|
30
|
+
`,
|
|
31
|
+
sshls: `
|
|
32
|
+
用法:
|
|
33
|
+
agentsshcli list [--config <path>] [--json]
|
|
34
|
+
agentsshcli help list
|
|
35
|
+
agentsshcli --version
|
|
36
|
+
|
|
37
|
+
说明:
|
|
38
|
+
列出当前配置文件中的 SSH 连接。
|
|
39
|
+
`,
|
|
40
|
+
sshx: `
|
|
41
|
+
用法:
|
|
42
|
+
agentsshcli exec [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <command>
|
|
43
|
+
agentsshcli exec [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --command <command> [--directory <dir>] [--timeout <ms>]
|
|
44
|
+
agentsshcli help exec
|
|
45
|
+
agentsshcli --version
|
|
46
|
+
|
|
47
|
+
说明:
|
|
48
|
+
在远端执行命令。
|
|
49
|
+
`,
|
|
50
|
+
sshupload: `
|
|
51
|
+
用法:
|
|
52
|
+
agentsshcli upload [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <localPath> <remotePath>
|
|
53
|
+
agentsshcli upload [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --local <path> --remote <path>
|
|
54
|
+
agentsshcli help upload
|
|
55
|
+
agentsshcli --version
|
|
56
|
+
|
|
57
|
+
说明:
|
|
58
|
+
上传本地文件到远端。
|
|
59
|
+
`,
|
|
60
|
+
sshdownload: `
|
|
61
|
+
用法:
|
|
62
|
+
agentsshcli download [--config <path>] [--no-cache] [--cache-ttl <ms>] <connectionName> <remotePath> <localPath>
|
|
63
|
+
agentsshcli download [--config <path>] [--no-cache] [--cache-ttl <ms>] --connection <name> --remote <path> --local <path>
|
|
64
|
+
agentsshcli help download
|
|
65
|
+
agentsshcli --version
|
|
66
|
+
|
|
67
|
+
说明:
|
|
68
|
+
下载远端文件到本地。
|
|
69
|
+
`
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function printHelp(commandName) {
|
|
73
|
+
process.stdout.write(`${HELP_TEXT[commandName].trim()}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function printVersion() {
|
|
77
|
+
process.stdout.write(`${packageJson.version}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getHelpName(commandName) {
|
|
81
|
+
const names = {
|
|
82
|
+
list: "sshls",
|
|
83
|
+
exec: "sshx",
|
|
84
|
+
upload: "sshupload",
|
|
85
|
+
download: "sshdownload"
|
|
86
|
+
};
|
|
87
|
+
return names[commandName] || commandName;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function initConfig() {
|
|
91
|
+
const target = getDefaultConfigPath();
|
|
92
|
+
const source = path.join(currentDir, "..", "example.config.json");
|
|
93
|
+
if (fs.existsSync(target)) {
|
|
94
|
+
throw new Error(`${target} 已存在,未覆盖`);
|
|
95
|
+
}
|
|
96
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
97
|
+
fs.copyFileSync(source, target);
|
|
98
|
+
process.stdout.write(`已创建 ${target}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseGlobalArgs(argv) {
|
|
102
|
+
const args = [...argv];
|
|
103
|
+
let configPath = getDefaultConfigPath();
|
|
104
|
+
let help = false;
|
|
105
|
+
let version = false;
|
|
106
|
+
let json = false;
|
|
107
|
+
let noCache = false;
|
|
108
|
+
let cacheTtlMs;
|
|
109
|
+
const remaining = [];
|
|
110
|
+
|
|
111
|
+
while (args.length > 0) {
|
|
112
|
+
const current = args.shift();
|
|
113
|
+
if (current === "--help" || current === "-h") {
|
|
114
|
+
help = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (current === "--version" || current === "-v") {
|
|
118
|
+
version = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (current === "--json") {
|
|
122
|
+
json = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (current === "--no-cache") {
|
|
126
|
+
noCache = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (current === "--cache-ttl") {
|
|
130
|
+
const value = args.shift();
|
|
131
|
+
if (!value) {
|
|
132
|
+
throw new Error("--cache-ttl 缺少毫秒值");
|
|
133
|
+
}
|
|
134
|
+
cacheTtlMs = normalizeCacheTtl(value);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (current === "--config") {
|
|
138
|
+
const value = args.shift();
|
|
139
|
+
if (!value) {
|
|
140
|
+
throw new Error("--config 缺少路径");
|
|
141
|
+
}
|
|
142
|
+
configPath = path.resolve(value);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
remaining.push(current, ...args);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
configPath,
|
|
151
|
+
help,
|
|
152
|
+
version,
|
|
153
|
+
json,
|
|
154
|
+
noCache,
|
|
155
|
+
cacheTtlMs,
|
|
156
|
+
args: remaining
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findOptionIndexes(args, names) {
|
|
161
|
+
const indexes = [];
|
|
162
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
163
|
+
if (names.includes(args[index])) {
|
|
164
|
+
indexes.push(index);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return indexes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function takeOption(args, names) {
|
|
171
|
+
const indexes = findOptionIndexes(args, names);
|
|
172
|
+
if (indexes.length > 1) {
|
|
173
|
+
throw new Error(`参数重复声明: ${names[0]}`);
|
|
174
|
+
}
|
|
175
|
+
if (indexes.length === 0) {
|
|
176
|
+
return {
|
|
177
|
+
present: false,
|
|
178
|
+
value: undefined
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const index = indexes[0];
|
|
182
|
+
const value = args[index + 1];
|
|
183
|
+
if (!value || value.startsWith("--")) {
|
|
184
|
+
throw new Error(`${args[index]} 缺少参数值`);
|
|
185
|
+
}
|
|
186
|
+
args.splice(index, 2);
|
|
187
|
+
return {
|
|
188
|
+
present: true,
|
|
189
|
+
value
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensureNoUnknownOptions(args) {
|
|
194
|
+
const unknown = args.find((item) => item.startsWith("--"));
|
|
195
|
+
if (unknown) {
|
|
196
|
+
throw new Error(`不支持的参数: ${unknown}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ensureNoExtraPositionals(args) {
|
|
201
|
+
if (args.length > 0) {
|
|
202
|
+
throw new Error(`存在多余的位置参数: ${args.join(" ")}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function takePositional(args, fieldName) {
|
|
207
|
+
const value = args.shift();
|
|
208
|
+
if (value && value.startsWith("--")) {
|
|
209
|
+
throw new Error(`${fieldName} 位置参数非法: ${value}`);
|
|
210
|
+
}
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ensureNoMixedInput(namedValue, positionalValue, fieldName) {
|
|
215
|
+
if (namedValue !== undefined && positionalValue !== undefined) {
|
|
216
|
+
throw new Error(`${fieldName} 同时使用了命名参数和位置参数,保留一种即可`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resolveValue(args, optionNames, fieldName) {
|
|
221
|
+
const namedOption = takeOption(args, optionNames);
|
|
222
|
+
if (namedOption.present) {
|
|
223
|
+
return namedOption.value;
|
|
224
|
+
}
|
|
225
|
+
return takePositional(args, fieldName);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function parseListArgs(argv) {
|
|
229
|
+
const parsed = parseGlobalArgs(argv);
|
|
230
|
+
if (parsed.help || parsed.version) {
|
|
231
|
+
return parsed;
|
|
232
|
+
}
|
|
233
|
+
if (parsed.args.length > 0) {
|
|
234
|
+
throw new Error(`agentsshcli list 不接受位置参数: ${parsed.args.join(" ")}`);
|
|
235
|
+
}
|
|
236
|
+
return parsed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseExecuteArgs(argv) {
|
|
240
|
+
const parsed = parseGlobalArgs(argv);
|
|
241
|
+
if (parsed.help || parsed.version) {
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
const args = [...parsed.args];
|
|
245
|
+
const connectionName = resolveValue(args, ["--connection", "-c"], "connectionName");
|
|
246
|
+
const command = resolveValue(args, ["--command"], "command");
|
|
247
|
+
const directory = resolveValue(args, ["--directory", "-d"], "directory");
|
|
248
|
+
const timeoutValue = resolveValue(args, ["--timeout", "-t"], "timeout");
|
|
249
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
250
|
+
if (args[index]?.startsWith("--")) {
|
|
251
|
+
throw new Error(`不支持的参数: ${args[index]}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
ensureNoUnknownOptions(args);
|
|
255
|
+
ensureNoExtraPositionals(args);
|
|
256
|
+
if (!connectionName || !command) {
|
|
257
|
+
throw new Error("缺少必填参数 connectionName 或 command,使用 --help 查看说明");
|
|
258
|
+
}
|
|
259
|
+
const timeout = timeoutValue === undefined ? 30000 : Number(timeoutValue);
|
|
260
|
+
if (!Number.isFinite(timeout) || timeout <= 0) {
|
|
261
|
+
throw new Error("timeout 必须是正整数毫秒值");
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
...parsed,
|
|
265
|
+
connectionName,
|
|
266
|
+
command,
|
|
267
|
+
directory,
|
|
268
|
+
timeout
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseTransferArgs(argv, mode) {
|
|
273
|
+
const parsed = parseGlobalArgs(argv);
|
|
274
|
+
if (parsed.help || parsed.version) {
|
|
275
|
+
return parsed;
|
|
276
|
+
}
|
|
277
|
+
const args = [...parsed.args];
|
|
278
|
+
const connectionName = resolveValue(args, ["--connection", "-c"], "connectionName");
|
|
279
|
+
const localOptionNames = ["--local", "-l"];
|
|
280
|
+
const remoteOptionNames = ["--remote", "-r"];
|
|
281
|
+
let localPath;
|
|
282
|
+
let remotePath;
|
|
283
|
+
|
|
284
|
+
if (mode === "upload") {
|
|
285
|
+
localPath = resolveValue(args, localOptionNames, "localPath");
|
|
286
|
+
remotePath = resolveValue(args, remoteOptionNames, "remotePath");
|
|
287
|
+
} else {
|
|
288
|
+
remotePath = resolveValue(args, remoteOptionNames, "remotePath");
|
|
289
|
+
localPath = resolveValue(args, localOptionNames, "localPath");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
ensureNoUnknownOptions(args);
|
|
293
|
+
ensureNoExtraPositionals(args);
|
|
294
|
+
|
|
295
|
+
if (!connectionName || !localPath || !remotePath) {
|
|
296
|
+
throw new Error("缺少必填参数,使用 --help 查看说明");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
...parsed,
|
|
301
|
+
connectionName,
|
|
302
|
+
localPath,
|
|
303
|
+
remotePath
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function runListServers(argv) {
|
|
308
|
+
const parsed = parseListArgs(argv);
|
|
309
|
+
if (parsed.help) {
|
|
310
|
+
printHelp("sshls");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (parsed.version) {
|
|
314
|
+
printVersion();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const configs = loadConfig(parsed.configPath);
|
|
318
|
+
const output = configs.map((item) => ({
|
|
319
|
+
name: item.name,
|
|
320
|
+
host: item.host,
|
|
321
|
+
port: item.port,
|
|
322
|
+
username: item.username
|
|
323
|
+
}));
|
|
324
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function runExecute(argv) {
|
|
328
|
+
const parsed = parseExecuteArgs(argv);
|
|
329
|
+
if (parsed.help) {
|
|
330
|
+
printHelp("sshx");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (parsed.version) {
|
|
334
|
+
printVersion();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const configs = loadConfig(parsed.configPath);
|
|
338
|
+
const connection = findConnection(configs, parsed.connectionName);
|
|
339
|
+
const result = parsed.noCache
|
|
340
|
+
? await executeRemoteCommand(connection, parsed.command, parsed.directory, parsed.timeout)
|
|
341
|
+
: (await requestDaemon(parsed.configPath, {
|
|
342
|
+
operation: "execute",
|
|
343
|
+
configPath: parsed.configPath,
|
|
344
|
+
cwd: process.cwd(),
|
|
345
|
+
connectionName: parsed.connectionName,
|
|
346
|
+
command: parsed.command,
|
|
347
|
+
directory: parsed.directory,
|
|
348
|
+
timeout: parsed.timeout,
|
|
349
|
+
cacheTtlMs: parsed.cacheTtlMs
|
|
350
|
+
})).stdout;
|
|
351
|
+
if (result) {
|
|
352
|
+
process.stdout.write(`${result}\n`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function runUpload(argv) {
|
|
357
|
+
const parsed = parseTransferArgs(argv, "upload");
|
|
358
|
+
if (parsed.help) {
|
|
359
|
+
printHelp("sshupload");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (parsed.version) {
|
|
363
|
+
printVersion();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const configs = loadConfig(parsed.configPath);
|
|
367
|
+
const connection = findConnection(configs, parsed.connectionName);
|
|
368
|
+
if (parsed.noCache) {
|
|
369
|
+
const validatedLocalPath = validateLocalPath(configs, parsed.localPath);
|
|
370
|
+
await uploadFile(connection, validatedLocalPath, parsed.remotePath);
|
|
371
|
+
} else {
|
|
372
|
+
await requestDaemon(parsed.configPath, {
|
|
373
|
+
operation: "upload",
|
|
374
|
+
configPath: parsed.configPath,
|
|
375
|
+
cwd: process.cwd(),
|
|
376
|
+
connectionName: parsed.connectionName,
|
|
377
|
+
localPath: parsed.localPath,
|
|
378
|
+
remotePath: parsed.remotePath,
|
|
379
|
+
cacheTtlMs: parsed.cacheTtlMs
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
process.stdout.write("File uploaded successfully\n");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function runDownload(argv) {
|
|
386
|
+
const parsed = parseTransferArgs(argv, "download");
|
|
387
|
+
if (parsed.help) {
|
|
388
|
+
printHelp("sshdownload");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (parsed.version) {
|
|
392
|
+
printVersion();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const configs = loadConfig(parsed.configPath);
|
|
396
|
+
const connection = findConnection(configs, parsed.connectionName);
|
|
397
|
+
if (parsed.noCache) {
|
|
398
|
+
const validatedLocalPath = validateLocalPath(configs, parsed.localPath);
|
|
399
|
+
fs.mkdirSync(path.dirname(validatedLocalPath), { recursive: true });
|
|
400
|
+
await downloadFile(connection, parsed.remotePath, validatedLocalPath);
|
|
401
|
+
} else {
|
|
402
|
+
await requestDaemon(parsed.configPath, {
|
|
403
|
+
operation: "download",
|
|
404
|
+
configPath: parsed.configPath,
|
|
405
|
+
cwd: process.cwd(),
|
|
406
|
+
connectionName: parsed.connectionName,
|
|
407
|
+
localPath: parsed.localPath,
|
|
408
|
+
remotePath: parsed.remotePath,
|
|
409
|
+
cacheTtlMs: parsed.cacheTtlMs
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
process.stdout.write("File downloaded successfully\n");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function runAgentSshCli(argv) {
|
|
416
|
+
const [command, ...args] = argv;
|
|
417
|
+
if (!command || command === "--help" || command === "-h") {
|
|
418
|
+
printHelp("agentsshcli");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
422
|
+
printVersion();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (command === "help") {
|
|
426
|
+
const target = getHelpName(args[0] || "agentsshcli");
|
|
427
|
+
if (!HELP_TEXT[target]) {
|
|
428
|
+
throw new Error(`未知帮助命令: ${args[0]}`);
|
|
429
|
+
}
|
|
430
|
+
printHelp(target);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (command === "init-config") {
|
|
434
|
+
initConfig();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (command === "list") {
|
|
438
|
+
await runListServers(args);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (command === "exec") {
|
|
442
|
+
await runExecute(args);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (command === "upload") {
|
|
446
|
+
await runUpload(args);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (command === "download") {
|
|
450
|
+
await runDownload(args);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
throw new Error(`未知命令: ${command},使用 agentsshcli --help 查看说明`);
|
|
454
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG_DIR = ".agent-ssh-cli";
|
|
8
|
+
const DEFAULT_CONFIG_FILE = "config.json";
|
|
9
|
+
|
|
10
|
+
function isNonEmptyString(value) {
|
|
11
|
+
return typeof value === "string" && value.trim() !== "";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureRegexArray(patterns, fieldName, index) {
|
|
15
|
+
if (patterns === undefined) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(patterns)) {
|
|
19
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须是字符串数组`);
|
|
20
|
+
}
|
|
21
|
+
return patterns.map((pattern) => {
|
|
22
|
+
if (!isNonEmptyString(pattern)) {
|
|
23
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须只包含非空字符串`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return new RegExp(pattern), pattern;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 含有非法正则: ${pattern},${error.message}`);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensureStringArray(values, fieldName, index) {
|
|
34
|
+
if (values === undefined) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(values)) {
|
|
38
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须是字符串数组`);
|
|
39
|
+
}
|
|
40
|
+
return values.map((value) => {
|
|
41
|
+
if (!isNonEmptyString(value)) {
|
|
42
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 ${fieldName} 必须只包含非空字符串`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeConfigEntry(entry, index) {
|
|
49
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
50
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项必须是对象`);
|
|
51
|
+
}
|
|
52
|
+
if (!isNonEmptyString(entry.name)) {
|
|
53
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 name`);
|
|
54
|
+
}
|
|
55
|
+
if (!isNonEmptyString(entry.host)) {
|
|
56
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 host`);
|
|
57
|
+
}
|
|
58
|
+
if (!isNonEmptyString(entry.username)) {
|
|
59
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项缺少合法的 username`);
|
|
60
|
+
}
|
|
61
|
+
const port = Number(entry.port || 22);
|
|
62
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
63
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 port 非法`);
|
|
64
|
+
}
|
|
65
|
+
if (entry.pty !== undefined && typeof entry.pty !== "boolean") {
|
|
66
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 pty 必须是布尔值`);
|
|
67
|
+
}
|
|
68
|
+
const hasPassword = isNonEmptyString(entry.password);
|
|
69
|
+
const hasPrivateKey = isNonEmptyString(entry.privateKey);
|
|
70
|
+
const hasAgent = isNonEmptyString(entry.agent);
|
|
71
|
+
const authMethodCount = [hasPassword, hasPrivateKey, hasAgent].filter(Boolean).length;
|
|
72
|
+
if (authMethodCount === 0) {
|
|
73
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项必须配置 password、privateKey 或 agent 其中之一`);
|
|
74
|
+
}
|
|
75
|
+
if (authMethodCount > 1) {
|
|
76
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项同时配置了多个认证方式,只允许保留一种`);
|
|
77
|
+
}
|
|
78
|
+
if (entry.passphrase !== undefined && !isNonEmptyString(entry.passphrase)) {
|
|
79
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 passphrase 必须是非空字符串`);
|
|
80
|
+
}
|
|
81
|
+
if (entry.socksProxy !== undefined && !isNonEmptyString(entry.socksProxy)) {
|
|
82
|
+
throw new Error(`ssh-config.json 第 ${index + 1} 项的 socksProxy 必须是非空字符串`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
name: entry.name,
|
|
86
|
+
host: entry.host,
|
|
87
|
+
port,
|
|
88
|
+
username: entry.username,
|
|
89
|
+
password: hasPassword ? entry.password : undefined,
|
|
90
|
+
privateKey: hasPrivateKey ? entry.privateKey : undefined,
|
|
91
|
+
passphrase: entry.passphrase,
|
|
92
|
+
agent: hasAgent ? entry.agent : undefined,
|
|
93
|
+
socksProxy: entry.socksProxy,
|
|
94
|
+
pty: entry.pty,
|
|
95
|
+
allowedLocalPaths: ensureStringArray(entry.allowedLocalPaths, "allowedLocalPaths", index),
|
|
96
|
+
commandWhitelist: ensureRegexArray(entry.commandWhitelist, "commandWhitelist", index),
|
|
97
|
+
commandBlacklist: ensureRegexArray(entry.commandBlacklist, "commandBlacklist", index)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getProjectRoot() {
|
|
102
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getDefaultConfigPath() {
|
|
106
|
+
if (isNonEmptyString(process.env.AGENT_SSH_CONFIG)) {
|
|
107
|
+
return path.resolve(process.env.AGENT_SSH_CONFIG);
|
|
108
|
+
}
|
|
109
|
+
return path.join(os.homedir(), DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILE);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function loadConfig(configPath = getDefaultConfigPath()) {
|
|
113
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new Error(`ssh-config.json 解析失败: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(parsed)) {
|
|
121
|
+
throw new Error("ssh-config.json 必须是数组格式");
|
|
122
|
+
}
|
|
123
|
+
const configs = parsed.map((item, index) => normalizeConfigEntry(item, index));
|
|
124
|
+
if (configs.length === 0) {
|
|
125
|
+
throw new Error("ssh-config.json 不能为空");
|
|
126
|
+
}
|
|
127
|
+
const seenNames = new Set();
|
|
128
|
+
for (const config of configs) {
|
|
129
|
+
if (seenNames.has(config.name)) {
|
|
130
|
+
throw new Error(`ssh-config.json 存在重复的连接名: ${config.name}`);
|
|
131
|
+
}
|
|
132
|
+
seenNames.add(config.name);
|
|
133
|
+
}
|
|
134
|
+
return configs;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function findConnection(configs, connectionName) {
|
|
138
|
+
const name = connectionName || configs[0]?.name;
|
|
139
|
+
const config = configs.find((item) => item.name === name);
|
|
140
|
+
if (!config) {
|
|
141
|
+
throw new Error(`未找到连接配置: ${name}`);
|
|
142
|
+
}
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function validateLocalPath(configs, localPath, baseCwd = process.cwd()) {
|
|
147
|
+
const resolvedCwd = path.resolve(baseCwd);
|
|
148
|
+
const resolvedPath = path.resolve(resolvedCwd, localPath);
|
|
149
|
+
const allowedRoots = new Set([resolvedCwd, getProjectRoot()]);
|
|
150
|
+
for (const config of configs) {
|
|
151
|
+
for (const allowedPath of config.allowedLocalPaths || []) {
|
|
152
|
+
allowedRoots.add(path.resolve(allowedPath));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const isAllowed = Array.from(allowedRoots).some((allowedRoot) => {
|
|
156
|
+
return resolvedPath === allowedRoot || resolvedPath.startsWith(`${allowedRoot}${path.sep}`);
|
|
157
|
+
});
|
|
158
|
+
if (!isAllowed) {
|
|
159
|
+
throw new Error("本地路径不允许访问,必须位于当前工作目录、项目目录或显式允许的路径内");
|
|
160
|
+
}
|
|
161
|
+
return resolvedPath;
|
|
162
|
+
}
|