@deepfish-ai/deepfish-ssh-remote-control 1.0.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.en.md +67 -0
- package/README.md +67 -0
- package/index.js +700 -0
- package/package.json +42 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SSH Remote Control Tool
|
|
2
|
+
|
|
3
|
+
[中文](./README.md) | **English**
|
|
4
|
+
|
|
5
|
+
## Introduction
|
|
6
|
+
|
|
7
|
+
The SSH Remote Control Tool is a custom Deepfish tool module that provides remote server management capabilities based on the SSH2 protocol. It supports multiple connection management, remote command execution, file upload/download, and is suitable for daily server operations, file transfer, remote debugging, and similar scenarios.
|
|
8
|
+
|
|
9
|
+
Core capabilities:
|
|
10
|
+
|
|
11
|
+
- **Connection management**: Add, switch, and delete SSH connections interactively. Supports both password and private key authentication.
|
|
12
|
+
- **Remote command execution**: Execute shell commands on remote servers and retrieve command output.
|
|
13
|
+
- **File transfer**: Upload or download a single file or an entire directory with progress display.
|
|
14
|
+
- **Connection testing**: Quickly verify whether the current SSH connection is available, with detailed diagnostics for authentication failures.
|
|
15
|
+
- **Persistent configuration**: Store connection configurations in an encrypted local JSON file to help protect sensitive information.
|
|
16
|
+
|
|
17
|
+
## Tool List
|
|
18
|
+
|
|
19
|
+
| Function | Description |
|
|
20
|
+
|----------|-------------|
|
|
21
|
+
| `sshRemoteControl` | Main SSH remote control function. Operation types are selected by the `action` parameter. |
|
|
22
|
+
|
|
23
|
+
### Supported Actions
|
|
24
|
+
|
|
25
|
+
| action | Description |
|
|
26
|
+
|--------|-------------|
|
|
27
|
+
| `init` | Initialize or read the current SSH connection |
|
|
28
|
+
| `test_connection` | Test whether the current SSH connection is available |
|
|
29
|
+
| `list_connections` | List all saved connections |
|
|
30
|
+
| `get_config_path` | Get the local configuration file path |
|
|
31
|
+
| `add_connection` | Add an SSH connection interactively |
|
|
32
|
+
| `set_current_interactive` | Set the current connection interactively |
|
|
33
|
+
| `switch_connection` | Switch to a specified connection |
|
|
34
|
+
| `delete_connection` | Delete a specified connection |
|
|
35
|
+
| `exec_command` | Execute a command on the remote server |
|
|
36
|
+
| `upload_path` | Upload a file or directory to the remote server |
|
|
37
|
+
| `download_path` | Download a file or directory from the remote server |
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### Install Deepfish
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g deepfish-ai
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Add the Tool
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g @deepfish-ai/deepfish-ssh-remote-control
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Usage Examples
|
|
54
|
+
|
|
55
|
+
After adding the tool, you can invoke it directly in a Deepfish conversation using natural language:
|
|
56
|
+
|
|
57
|
+
> ai "Test whether the current SSH connection is working properly"
|
|
58
|
+
|
|
59
|
+
> ai "Run the `ls -la /root` command on the remote server"
|
|
60
|
+
|
|
61
|
+
> ai "Upload the local `C:\project\dist` directory to `/var/www` on the remote server"
|
|
62
|
+
|
|
63
|
+
> ai "Download `/var/log/nginx/access.log` from the remote server to local `D:\logs`"
|
|
64
|
+
|
|
65
|
+
> ai "Add a new SSH connection"
|
|
66
|
+
|
|
67
|
+
> ai "Switch to the connection named `prod`"
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# SSH 远程控制工具
|
|
2
|
+
|
|
3
|
+
**中文** | [English](./README.en.md)
|
|
4
|
+
|
|
5
|
+
## 功能介绍
|
|
6
|
+
|
|
7
|
+
SSH 远程控制工具是 Deepfish 的自定义工具模块,提供基于 SSH2 协议的远程服务器管理能力。支持多连接管理、远程命令执行、文件上传/下载等功能,适用于日常服务器运维、文件传输、远程调试等场景。
|
|
8
|
+
|
|
9
|
+
核心能力:
|
|
10
|
+
|
|
11
|
+
- **连接管理**:支持交互式新增、切换、删除 SSH 连接,支持密码和私钥两种认证方式
|
|
12
|
+
- **远程命令执行**:在远程服务器上执行 Shell 命令并获取输出
|
|
13
|
+
- **文件传输**:支持上传/下载单个文件或整个目录,带进度显示
|
|
14
|
+
- **连接测试**:快速验证 SSH 连接是否可用,认证失败时提供详细诊断信息
|
|
15
|
+
- **配置持久化**:连接配置加密存储于本地 JSON 文件,敏感信息不泄露
|
|
16
|
+
|
|
17
|
+
## 工具清单
|
|
18
|
+
|
|
19
|
+
| 函数名 | 描述 |
|
|
20
|
+
|--------|------|
|
|
21
|
+
| `sshRemoteControl` | SSH 远程控制主函数,通过 `action` 参数区分操作类型 |
|
|
22
|
+
|
|
23
|
+
### 支持的操作类型
|
|
24
|
+
|
|
25
|
+
| action | 描述 |
|
|
26
|
+
|--------|------|
|
|
27
|
+
| `init` | 初始化或读取当前 SSH 连接 |
|
|
28
|
+
| `test_connection` | 测试当前 SSH 连接是否可用 |
|
|
29
|
+
| `list_connections` | 列出所有已保存的连接 |
|
|
30
|
+
| `get_config_path` | 获取本地配置文件路径 |
|
|
31
|
+
| `add_connection` | 交互式新增 SSH 连接 |
|
|
32
|
+
| `set_current_interactive` | 交互式设置当前连接 |
|
|
33
|
+
| `switch_connection` | 切换到指定连接 |
|
|
34
|
+
| `delete_connection` | 删除指定连接 |
|
|
35
|
+
| `exec_command` | 在远程服务器执行命令 |
|
|
36
|
+
| `upload_path` | 上传文件或目录到远程服务器 |
|
|
37
|
+
| `download_path` | 从远程服务器下载文件或目录 |
|
|
38
|
+
|
|
39
|
+
## 快速开始
|
|
40
|
+
|
|
41
|
+
### 安装 Deepfish
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g deepfish-ai
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 添加工具
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g @deepfish-ai/deepfish-ssh-remote-control
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 使用示例
|
|
54
|
+
|
|
55
|
+
添加完成后,在 Deepfish 对话中直接使用自然语言调用:
|
|
56
|
+
|
|
57
|
+
>ai "帮我测试一下当前的 SSH 连接是否正常"
|
|
58
|
+
|
|
59
|
+
>ai "在远程服务器上执行 `ls -la /root` 命令"
|
|
60
|
+
|
|
61
|
+
>ai "把本地的 `C:\project\dist` 目录上传到远程服务器的 `/var/www`"
|
|
62
|
+
|
|
63
|
+
>ai "从远程服务器下载 `/var/log/nginx/access.log` 到本地 `D:\logs`"
|
|
64
|
+
|
|
65
|
+
>ai "新增一个 SSH 连接"
|
|
66
|
+
|
|
67
|
+
>ai "切换到名称为 `prod` 的连接"
|
package/index.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
const inquirer = require('inquirer');
|
|
5
|
+
const { Client } = require('ssh2');
|
|
6
|
+
const SftpClient = require('ssh2-sftp-client');
|
|
7
|
+
const SALT = 'ROMAN-123'
|
|
8
|
+
const CryptoJS = require('crypto-js');
|
|
9
|
+
|
|
10
|
+
function encrypt(text) {
|
|
11
|
+
if (!text) return '';
|
|
12
|
+
return CryptoJS.AES.encrypt(text, SALT).toString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function decrypt(ciphertext) {
|
|
16
|
+
if (!ciphertext) return '';
|
|
17
|
+
const bytes = CryptoJS.AES.decrypt(ciphertext, SALT);
|
|
18
|
+
return bytes.toString(CryptoJS.enc.Utf8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CONFIG_FILE = path.join(__dirname, 'ssh_config.json');
|
|
22
|
+
|
|
23
|
+
function emptyConfig() {
|
|
24
|
+
return { curSSH: '', list: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ensureConfigFile() {
|
|
28
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
29
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(emptyConfig(), null, 2), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readConfig() {
|
|
34
|
+
ensureConfigFile();
|
|
35
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
36
|
+
const config = raw.trim() ? JSON.parse(raw) : emptyConfig();
|
|
37
|
+
if (!config || typeof config !== 'object' || !Array.isArray(config.list)) {
|
|
38
|
+
throw new Error('配置文件格式错误:顶层必须是对象并包含 list 数组');
|
|
39
|
+
}
|
|
40
|
+
if (typeof config.curSSH !== 'string') config.curSSH = '';
|
|
41
|
+
// 迁移:将未加密的明文密码加密存储
|
|
42
|
+
let migrated = false;
|
|
43
|
+
for (const conn of config.list) {
|
|
44
|
+
if (conn.password && !conn._encrypted) {
|
|
45
|
+
conn.password = encrypt(conn.password);
|
|
46
|
+
conn._encrypted = true;
|
|
47
|
+
migrated = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (migrated) {
|
|
51
|
+
writeConfig(config);
|
|
52
|
+
}
|
|
53
|
+
return config;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeConfig(config) {
|
|
57
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeConnection(conn) {
|
|
61
|
+
return { name: conn.name || '', host: conn.host || '' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 去掉路径首尾可能带的引号/空白(用户在终端粘贴路径时常见)
|
|
65
|
+
function stripQuotes(value) {
|
|
66
|
+
let str = String(value == null ? '' : value).trim();
|
|
67
|
+
if (str.length >= 2) {
|
|
68
|
+
const first = str[0];
|
|
69
|
+
const last = str[str.length - 1];
|
|
70
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
71
|
+
str = str.slice(1, -1).trim();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return str;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizePort(port) {
|
|
78
|
+
const value = port === undefined || port === null || port === '' ? 22 : Number(port);
|
|
79
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
80
|
+
throw new Error('SSH 端口必须是 1-65535 之间的整数');
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateConnection(conn, list, originalName) {
|
|
86
|
+
if (!conn || typeof conn !== 'object') throw new Error('连接配置必须是对象');
|
|
87
|
+
const normalized = {
|
|
88
|
+
name: String(conn.name || '').trim(),
|
|
89
|
+
host: String(conn.host || '').trim(),
|
|
90
|
+
port: normalizePort(conn.port),
|
|
91
|
+
username: String(conn.username || '').trim(),
|
|
92
|
+
password: conn.password ? encrypt(String(conn.password)) : '',
|
|
93
|
+
_encrypted: true,
|
|
94
|
+
privateKey: conn.privateKey ? stripQuotes(conn.privateKey) : '',
|
|
95
|
+
passphrase: conn.passphrase ? String(conn.passphrase) : '',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!normalized.name) throw new Error('连接别名 name 不能为空');
|
|
99
|
+
if (!normalized.host) throw new Error('主机地址 host 不能为空');
|
|
100
|
+
if (!normalized.username) throw new Error('登录账号 username 不能为空');
|
|
101
|
+
if (!normalized.password && !normalized.privateKey) throw new Error('必须提供密码或私钥路径中的一种认证方式');
|
|
102
|
+
|
|
103
|
+
const duplicatedName = list.some((item) => item.name === normalized.name && item.name !== originalName);
|
|
104
|
+
if (duplicatedName) throw new Error('连接别名 name 不可重复');
|
|
105
|
+
const duplicatedHost = list.some((item) => item.host === normalized.host && item.name !== originalName);
|
|
106
|
+
if (duplicatedHost) throw new Error('主机地址 host 不可重复');
|
|
107
|
+
return normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getCurrentConnection(config) {
|
|
111
|
+
if (!config.list.length) throw new Error('连接列表为空,请先新增远程连接配置');
|
|
112
|
+
const current = config.list.find((item) => item.name === config.curSSH);
|
|
113
|
+
if (!current) throw new Error('当前 curSSH 不存在于连接列表中,请先设置当前连接');
|
|
114
|
+
return current;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildSshConfig(conn) {
|
|
118
|
+
const sshConfig = {
|
|
119
|
+
host: conn.host,
|
|
120
|
+
port: normalizePort(conn.port),
|
|
121
|
+
username: conn.username,
|
|
122
|
+
readyTimeout: 20000,
|
|
123
|
+
// 允许尝试更多算法,兼容老服务器
|
|
124
|
+
algorithms: {
|
|
125
|
+
serverHostKey: ['ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'rsa-sha2-256', 'rsa-sha2-512'],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
if (conn.privateKey) {
|
|
129
|
+
if (!fs.existsSync(conn.privateKey)) {
|
|
130
|
+
throw new Error(`私钥文件不存在:${conn.privateKey}`);
|
|
131
|
+
}
|
|
132
|
+
sshConfig.privateKey = fs.readFileSync(conn.privateKey);
|
|
133
|
+
if (conn.passphrase) sshConfig.passphrase = conn.passphrase;
|
|
134
|
+
} else {
|
|
135
|
+
sshConfig.password = decrypt(conn.password);
|
|
136
|
+
}
|
|
137
|
+
return sshConfig;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 友好化常见 SSH 连接错误
|
|
141
|
+
function describeSshError(err, conn) {
|
|
142
|
+
const raw = err && err.message ? err.message : String(err);
|
|
143
|
+
const target = conn ? `${conn.username}@${conn.host}:${conn.port || 22}` : '';
|
|
144
|
+
if (/All configured authentication methods failed/i.test(raw)) {
|
|
145
|
+
const tips = [
|
|
146
|
+
`SSH 认证失败 (${target})。已使用:${conn && conn.privateKey ? `私钥 ${conn.privateKey}` : '密码'}`,
|
|
147
|
+
'可能原因与排查:',
|
|
148
|
+
'1) 用户名不对:请确认服务器是否允许该用户名登录(如 root/ubuntu/ec2-user 等)。',
|
|
149
|
+
'2) 公钥未授权:服务器 ~/.ssh/authorized_keys 中没有该私钥对应的公钥;',
|
|
150
|
+
` 请在本地执行 ssh-keygen -y -f "${conn && conn.privateKey ? conn.privateKey : '<私钥路径>'}" 得到公钥后,追加到服务器对应用户的 authorized_keys。`,
|
|
151
|
+
'3) 私钥需要口令但未填写 passphrase。',
|
|
152
|
+
'4) 服务器 sshd_config 禁用了 PubkeyAuthentication 或 PermitRootLogin。',
|
|
153
|
+
'5) 选错了私钥(同名不同 key)。',
|
|
154
|
+
];
|
|
155
|
+
return tips.join('\n');
|
|
156
|
+
}
|
|
157
|
+
if (/ENOTFOUND|getaddrinfo/i.test(raw)) {
|
|
158
|
+
return `无法解析主机:${conn && conn.host}。请检查 host 是否正确。`;
|
|
159
|
+
}
|
|
160
|
+
if (/ECONNREFUSED/i.test(raw)) {
|
|
161
|
+
return `连接被拒绝:${target}。请检查端口是否正确、sshd 是否在监听、安全组/防火墙是否放行。`;
|
|
162
|
+
}
|
|
163
|
+
if (/ETIMEDOUT|Timed out while waiting for handshake/i.test(raw)) {
|
|
164
|
+
return `连接超时:${target}。请检查网络可达性、安全组/防火墙端口是否放行。`;
|
|
165
|
+
}
|
|
166
|
+
if (/Cannot parse privateKey|bad passphrase|integrity check failed|Encrypted private OpenSSH key/i.test(raw)) {
|
|
167
|
+
return `私钥读取失败:${raw}。若私钥已加密,请确保填写了正确的 passphrase;若为新版 OpenSSH 加密格式,请改用未加密的私钥。`;
|
|
168
|
+
}
|
|
169
|
+
return raw;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 测试连接:只建立 SSH 会话再立即关闭
|
|
173
|
+
function testConnection(conn) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const client = new Client();
|
|
176
|
+
let settled = false;
|
|
177
|
+
client
|
|
178
|
+
.on('ready', () => {
|
|
179
|
+
settled = true;
|
|
180
|
+
client.end();
|
|
181
|
+
resolve(true);
|
|
182
|
+
})
|
|
183
|
+
.on('error', (err) => {
|
|
184
|
+
if (settled) return;
|
|
185
|
+
settled = true;
|
|
186
|
+
reject(err);
|
|
187
|
+
})
|
|
188
|
+
.connect(buildSshConfig(conn));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ask(rl, question) {
|
|
193
|
+
return new Promise((resolve) => rl.question(question, (answer) => resolve(answer)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function askConnection(list) {
|
|
197
|
+
const requiredText = (label) => (input) => {
|
|
198
|
+
const value = String(input || '').trim();
|
|
199
|
+
return value ? true : `${label} 不能为空`;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const answers = await inquirer.prompt([
|
|
203
|
+
{
|
|
204
|
+
type: 'input',
|
|
205
|
+
name: 'name',
|
|
206
|
+
message: '请输入连接别名 name:',
|
|
207
|
+
validate: (input) => {
|
|
208
|
+
const value = String(input || '').trim();
|
|
209
|
+
if (!value) return '连接别名 name 不能为空';
|
|
210
|
+
if (list.some((item) => item.name === value)) return '连接别名已存在,请更换';
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
filter: (input) => String(input || '').trim(),
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: 'input',
|
|
217
|
+
name: 'host',
|
|
218
|
+
message: '请输入主机地址 host:',
|
|
219
|
+
validate: (input) => {
|
|
220
|
+
const value = String(input || '').trim();
|
|
221
|
+
if (!value) return '主机地址 host 不能为空';
|
|
222
|
+
if (list.some((item) => item.host === value)) return '主机地址已存在,请更换';
|
|
223
|
+
return true;
|
|
224
|
+
},
|
|
225
|
+
filter: (input) => String(input || '').trim(),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
type: 'input',
|
|
229
|
+
name: 'port',
|
|
230
|
+
message: '请输入 SSH 端口 port:',
|
|
231
|
+
default: 22,
|
|
232
|
+
validate: (input) => {
|
|
233
|
+
const value = Number(input);
|
|
234
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
235
|
+
return 'SSH 端口必须是 1-65535 之间的整数';
|
|
236
|
+
}
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
filter: (input) => Number(input),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: 'input',
|
|
243
|
+
name: 'username',
|
|
244
|
+
message: '请输入登录账号 username:',
|
|
245
|
+
validate: requiredText('登录账号 username'),
|
|
246
|
+
filter: (input) => String(input || '').trim(),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
type: 'list',
|
|
250
|
+
name: 'authType',
|
|
251
|
+
message: '请选择认证方式:',
|
|
252
|
+
default: 'password',
|
|
253
|
+
choices: [
|
|
254
|
+
{ name: '密码 password', value: 'password' },
|
|
255
|
+
{ name: '私钥 privateKey', value: 'privateKey' },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
type: 'password',
|
|
260
|
+
name: 'password',
|
|
261
|
+
message: '请输入登录密码:',
|
|
262
|
+
mask: '*',
|
|
263
|
+
when: (ans) => ans.authType === 'password',
|
|
264
|
+
validate: (input) => (input ? true : '登录密码不能为空'),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
type: 'input',
|
|
268
|
+
name: 'privateKey',
|
|
269
|
+
message: '请输入本地私钥文件完整路径:',
|
|
270
|
+
when: (ans) => ans.authType === 'privateKey',
|
|
271
|
+
validate: (input) => {
|
|
272
|
+
const value = stripQuotes(input);
|
|
273
|
+
if (!value) return '私钥路径不能为空';
|
|
274
|
+
if (!fs.existsSync(value)) return '私钥文件不存在,请检查路径';
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
277
|
+
filter: (input) => stripQuotes(input),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
type: 'password',
|
|
281
|
+
name: 'passphrase',
|
|
282
|
+
message: '如私钥有口令请输入,若没有直接回车:',
|
|
283
|
+
mask: '*',
|
|
284
|
+
when: (ans) => ans.authType === 'privateKey',
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
return validateConnection(
|
|
289
|
+
{
|
|
290
|
+
name: answers.name,
|
|
291
|
+
host: answers.host,
|
|
292
|
+
port: answers.port,
|
|
293
|
+
username: answers.username,
|
|
294
|
+
password: answers.password || '',
|
|
295
|
+
privateKey: answers.privateKey || '',
|
|
296
|
+
passphrase: answers.passphrase || '',
|
|
297
|
+
},
|
|
298
|
+
list
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function addConnectionInteractively() {
|
|
303
|
+
const config = readConfig();
|
|
304
|
+
const conn = await askConnection(config.list);
|
|
305
|
+
config.list.push(conn);
|
|
306
|
+
config.curSSH = conn.name;
|
|
307
|
+
writeConfig(config);
|
|
308
|
+
return safeConnection(conn);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function setCurrentInteractively() {
|
|
312
|
+
const config = readConfig();
|
|
313
|
+
if (!config.list.length) throw new Error('连接列表为空,请先新增远程连接配置');
|
|
314
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
315
|
+
try {
|
|
316
|
+
const name = await ask(rl, '请输入要设为当前连接的 name:');
|
|
317
|
+
const target = config.list.find((item) => item.name === String(name).trim());
|
|
318
|
+
if (!target) throw new Error('指定连接不存在');
|
|
319
|
+
config.curSSH = target.name;
|
|
320
|
+
writeConfig(config);
|
|
321
|
+
return safeConnection(target);
|
|
322
|
+
} finally {
|
|
323
|
+
rl.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function ensureInitialized() {
|
|
328
|
+
const config = readConfig();
|
|
329
|
+
const validCurrent = config.curSSH && config.list.some((item) => item.name === config.curSSH);
|
|
330
|
+
if (config.list.length && validCurrent) return config;
|
|
331
|
+
const conn = await askConnection(config.list);
|
|
332
|
+
config.list.push(conn);
|
|
333
|
+
config.curSSH = conn.name;
|
|
334
|
+
writeConfig(config);
|
|
335
|
+
return config;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function runCommand(conn, command, cwd) {
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const client = new Client();
|
|
341
|
+
let stdout = '';
|
|
342
|
+
let stderr = '';
|
|
343
|
+
const finalCommand = cwd ? `cd ${shellQuote(cwd)} && ${command}` : command;
|
|
344
|
+
client
|
|
345
|
+
.on('ready', () => {
|
|
346
|
+
client.exec(finalCommand, (err, stream) => {
|
|
347
|
+
if (err) {
|
|
348
|
+
client.end();
|
|
349
|
+
reject(err);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
stream
|
|
353
|
+
.on('close', (code, signal) => {
|
|
354
|
+
client.end();
|
|
355
|
+
resolve({ stdout, stderr, code, signal });
|
|
356
|
+
})
|
|
357
|
+
.on('data', (data) => {
|
|
358
|
+
stdout += data.toString();
|
|
359
|
+
});
|
|
360
|
+
stream.stderr.on('data', (data) => {
|
|
361
|
+
stderr += data.toString();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
})
|
|
365
|
+
.on('error', reject)
|
|
366
|
+
.connect(buildSshConfig(conn));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function shellQuote(value) {
|
|
371
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function formatBytes(bytes) {
|
|
375
|
+
const num = Number(bytes) || 0;
|
|
376
|
+
if (num < 1024) return `${num} B`;
|
|
377
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
378
|
+
let size = num / 1024;
|
|
379
|
+
let i = 0;
|
|
380
|
+
while (size >= 1024 && i < units.length - 1) {
|
|
381
|
+
size /= 1024;
|
|
382
|
+
i += 1;
|
|
383
|
+
}
|
|
384
|
+
return `${size.toFixed(2)} ${units[i]}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function createFileProgress(label) {
|
|
388
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
389
|
+
let lastRender = 0;
|
|
390
|
+
let finished = false;
|
|
391
|
+
const startTs = Date.now();
|
|
392
|
+
|
|
393
|
+
const render = (transferred, total, force = false) => {
|
|
394
|
+
if (finished) return;
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
if (!force && now - lastRender < 100) return;
|
|
397
|
+
lastRender = now;
|
|
398
|
+
const safeTotal = Number(total) || 0;
|
|
399
|
+
const ratio = safeTotal > 0 ? Math.min(transferred / safeTotal, 1) : 0;
|
|
400
|
+
const percent = (ratio * 100).toFixed(1).padStart(5, ' ');
|
|
401
|
+
const barLen = 24;
|
|
402
|
+
const filled = Math.round(barLen * ratio);
|
|
403
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
|
|
404
|
+
const elapsed = (now - startTs) / 1000 || 0.001;
|
|
405
|
+
const speed = elapsed > 0 ? transferred / elapsed : 0;
|
|
406
|
+
const line = `${label} [${bar}] ${percent}% ${formatBytes(transferred)}/${formatBytes(safeTotal)} ${formatBytes(speed)}/s`;
|
|
407
|
+
if (isTTY) {
|
|
408
|
+
process.stdout.write(`\r${line.padEnd(80, ' ')}`);
|
|
409
|
+
} else {
|
|
410
|
+
process.stdout.write(`${line}\n`);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
step: (totalTransferred, _chunk, total) => {
|
|
416
|
+
render(totalTransferred, total);
|
|
417
|
+
},
|
|
418
|
+
done: (total) => {
|
|
419
|
+
render(total, total, true);
|
|
420
|
+
finished = true;
|
|
421
|
+
process.stdout.write('\n');
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function uploadPath(conn, localPath, remotePath) {
|
|
427
|
+
const sftp = new SftpClient();
|
|
428
|
+
try {
|
|
429
|
+
await sftp.connect(buildSshConfig(conn));
|
|
430
|
+
const stat = fs.statSync(localPath);
|
|
431
|
+
if (stat.isDirectory()) {
|
|
432
|
+
let count = 0;
|
|
433
|
+
const onUpload = (info) => {
|
|
434
|
+
count += 1;
|
|
435
|
+
process.stdout.write(`[上传] (${count}) ${info.source} -> ${info.destination}\n`);
|
|
436
|
+
};
|
|
437
|
+
sftp.on('upload', onUpload);
|
|
438
|
+
try {
|
|
439
|
+
await sftp.uploadDir(localPath, remotePath);
|
|
440
|
+
} finally {
|
|
441
|
+
sftp.removeListener('upload', onUpload);
|
|
442
|
+
}
|
|
443
|
+
process.stdout.write(`[上传完成] 共上传 ${count} 个文件\n`);
|
|
444
|
+
return { localPath, remotePath, files: count };
|
|
445
|
+
}
|
|
446
|
+
const progress = createFileProgress(`[上传] ${path.basename(localPath)}`);
|
|
447
|
+
await sftp.fastPut(localPath, remotePath, { step: progress.step });
|
|
448
|
+
progress.done(stat.size);
|
|
449
|
+
return { localPath, remotePath, size: stat.size };
|
|
450
|
+
} finally {
|
|
451
|
+
await sftp.end().catch(() => undefined);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function downloadPath(conn, remotePath, localPath) {
|
|
456
|
+
const sftp = new SftpClient();
|
|
457
|
+
try {
|
|
458
|
+
await sftp.connect(buildSshConfig(conn));
|
|
459
|
+
const remoteStat = await sftp.stat(remotePath);
|
|
460
|
+
const isDirectory = Boolean(remoteStat.isDirectory || remoteStat.type === 'd');
|
|
461
|
+
if (isDirectory) {
|
|
462
|
+
let count = 0;
|
|
463
|
+
const onDownload = (info) => {
|
|
464
|
+
count += 1;
|
|
465
|
+
process.stdout.write(`[下载] (${count}) ${info.source} -> ${info.destination}\n`);
|
|
466
|
+
};
|
|
467
|
+
sftp.on('download', onDownload);
|
|
468
|
+
try {
|
|
469
|
+
await sftp.downloadDir(remotePath, localPath);
|
|
470
|
+
} finally {
|
|
471
|
+
sftp.removeListener('download', onDownload);
|
|
472
|
+
}
|
|
473
|
+
process.stdout.write(`[下载完成] 共下载 ${count} 个文件\n`);
|
|
474
|
+
return { remotePath, localPath, files: count };
|
|
475
|
+
}
|
|
476
|
+
const progress = createFileProgress(`[下载] ${path.basename(remotePath)}`);
|
|
477
|
+
const totalSize = Number(remoteStat.size) || 0;
|
|
478
|
+
await sftp.fastGet(remotePath, localPath, { step: progress.step });
|
|
479
|
+
progress.done(totalSize);
|
|
480
|
+
return { remotePath, localPath, size: totalSize };
|
|
481
|
+
} finally {
|
|
482
|
+
await sftp.end().catch(() => undefined);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function tryParseJSON(value) {
|
|
487
|
+
if (typeof value !== 'string') return value;
|
|
488
|
+
const trimmed = value.trim();
|
|
489
|
+
if (!trimmed) return value;
|
|
490
|
+
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
|
|
491
|
+
try {
|
|
492
|
+
return JSON.parse(trimmed);
|
|
493
|
+
} catch (_) {
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function resolveInvocation(action, options) {
|
|
499
|
+
let resolvedAction = tryParseJSON(action);
|
|
500
|
+
let resolvedOptions = tryParseJSON(options);
|
|
501
|
+
|
|
502
|
+
// Case: 第一个参数是包含 { action, options/... } 的对象
|
|
503
|
+
if (resolvedAction && typeof resolvedAction === 'object' && !Array.isArray(resolvedAction)) {
|
|
504
|
+
const obj = resolvedAction;
|
|
505
|
+
const innerOptions = tryParseJSON(obj.options);
|
|
506
|
+
// 优先使用显式 options 字段,否则把除 action 外的字段视为参数
|
|
507
|
+
if (innerOptions && typeof innerOptions === 'object') {
|
|
508
|
+
resolvedOptions = innerOptions;
|
|
509
|
+
} else if (!resolvedOptions || typeof resolvedOptions !== 'object') {
|
|
510
|
+
const rest = { ...obj };
|
|
511
|
+
delete rest.action;
|
|
512
|
+
delete rest.options;
|
|
513
|
+
resolvedOptions = rest;
|
|
514
|
+
}
|
|
515
|
+
resolvedAction = obj.action;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (!resolvedOptions || typeof resolvedOptions !== 'object' || Array.isArray(resolvedOptions)) {
|
|
519
|
+
resolvedOptions = {};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
action: String(resolvedAction || '').trim(),
|
|
524
|
+
params: resolvedOptions,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function sshRemoteControl(action, options = {}) {
|
|
529
|
+
const { action: normalizedAction, params } = resolveInvocation(action, options);
|
|
530
|
+
try {
|
|
531
|
+
|
|
532
|
+
if (normalizedAction === 'add_connection') {
|
|
533
|
+
const added = await addConnectionInteractively();
|
|
534
|
+
return {
|
|
535
|
+
success: true,
|
|
536
|
+
data: {
|
|
537
|
+
added,
|
|
538
|
+
confirmed: true,
|
|
539
|
+
message: '连接已添加成功并保存到配置文件,无需再次向用户确认。',
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (normalizedAction === 'set_current_interactive') {
|
|
545
|
+
const current = await setCurrentInteractively();
|
|
546
|
+
return { success: true, data: { current } };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (normalizedAction === 'list_connections') {
|
|
550
|
+
const config = readConfig();
|
|
551
|
+
return { success: true, data: { curSSH: config.curSSH, list: config.list.map(safeConnection) } };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (normalizedAction === 'get_config_path') {
|
|
555
|
+
ensureConfigFile();
|
|
556
|
+
return { success: true, data: { configPath: CONFIG_FILE } };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (normalizedAction === 'delete_connection') {
|
|
560
|
+
const name = String(params.name || '').trim();
|
|
561
|
+
if (!name) throw new Error('删除连接需要提供 name');
|
|
562
|
+
const config = readConfig();
|
|
563
|
+
const before = config.list.length;
|
|
564
|
+
config.list = config.list.filter((item) => item.name !== name);
|
|
565
|
+
if (config.list.length === before) throw new Error('指定连接不存在');
|
|
566
|
+
if (config.curSSH === name) config.curSSH = config.list[0] ? config.list[0].name : '';
|
|
567
|
+
writeConfig(config);
|
|
568
|
+
return { success: true, data: { deleted: name, curSSH: config.curSSH } };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (normalizedAction === 'switch_connection') {
|
|
572
|
+
const name = String(params.name || '').trim();
|
|
573
|
+
if (!name) throw new Error('切换连接需要提供 name');
|
|
574
|
+
const config = readConfig();
|
|
575
|
+
const target = config.list.find((item) => item.name === name);
|
|
576
|
+
if (!target) throw new Error('指定连接不存在');
|
|
577
|
+
config.curSSH = target.name;
|
|
578
|
+
writeConfig(config);
|
|
579
|
+
return { success: true, data: { current: safeConnection(target) } };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (normalizedAction === 'init') {
|
|
583
|
+
const config = await ensureInitialized();
|
|
584
|
+
const current = getCurrentConnection(config);
|
|
585
|
+
return { success: true, data: { curSSH: config.curSSH, current: safeConnection(current) } };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const config = await ensureInitialized();
|
|
589
|
+
const current = getCurrentConnection(config);
|
|
590
|
+
|
|
591
|
+
if (normalizedAction === 'test_connection') {
|
|
592
|
+
await testConnection(current);
|
|
593
|
+
return { success: true, data: { current: safeConnection(current), message: 'SSH 认证成功,连接可用' } };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (normalizedAction === 'exec_command') {
|
|
597
|
+
const command = String(params.command || '').trim();
|
|
598
|
+
if (!command) throw new Error('执行远程命令需要提供 command');
|
|
599
|
+
const result = await runCommand(current, command, params.cwd);
|
|
600
|
+
return { success: true, data: result };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (normalizedAction === 'upload_path') {
|
|
604
|
+
const localPath = stripQuotes(params.localPath);
|
|
605
|
+
const remotePath = stripQuotes(params.remotePath);
|
|
606
|
+
if (!localPath || !remotePath) throw new Error('上传需要提供 localPath 和 remotePath');
|
|
607
|
+
const result = await uploadPath(current, localPath, remotePath);
|
|
608
|
+
return { success: true, data: result };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (normalizedAction === 'download_path') {
|
|
612
|
+
const remotePath = stripQuotes(params.remotePath);
|
|
613
|
+
const localPath = stripQuotes(params.localPath);
|
|
614
|
+
if (!remotePath || !localPath) throw new Error('下载需要提供 remotePath 和 localPath');
|
|
615
|
+
const result = await downloadPath(current, remotePath, localPath);
|
|
616
|
+
return { success: true, data: result };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
throw new Error('未知 action,可用值:init、test_connection、upload_path、download_path、exec_command、list_connections、switch_connection、delete_connection、add_connection、set_current_interactive、get_config_path');
|
|
620
|
+
} catch (err) {
|
|
621
|
+
// 拿到当前连接信息以便给出更精准的诊断
|
|
622
|
+
let conn = null;
|
|
623
|
+
try {
|
|
624
|
+
const cfg = readConfig();
|
|
625
|
+
conn = cfg.list.find((item) => item.name === cfg.curSSH) || null;
|
|
626
|
+
} catch (_) {
|
|
627
|
+
conn = null;
|
|
628
|
+
}
|
|
629
|
+
return { success: false, error: describeSshError(err, conn) };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const functions = {
|
|
634
|
+
sshRemoteControl,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const descriptions = [
|
|
638
|
+
{
|
|
639
|
+
type: 'function',
|
|
640
|
+
function: {
|
|
641
|
+
name: 'sshRemoteControl',
|
|
642
|
+
description: [
|
|
643
|
+
'本地 SSH 远程管理工具。调用时必须严格按如下结构传参:{ "action": "<操作类型>", "options": { <该操作所需的参数> } }。',
|
|
644
|
+
'options 必须是对象(不能为字符串、不能为空对象,除非该 action 不需要参数)。所有该操作所需的参数都要放进 options 内部,不要放在顶层。',
|
|
645
|
+
'可用 action 及其 options 必填字段:',
|
|
646
|
+
'- init: options 可为空 {}。用于初始化或读取当前连接。',
|
|
647
|
+
'- test_connection: options 可为空 {}。仅测试当前连接的 SSH 认证是否成功,认证失败时返回详细诊断信息。',
|
|
648
|
+
'- list_connections: options 可为空 {}。返回所有连接和当前 curSSH。',
|
|
649
|
+
'- get_config_path: options 可为空 {}。返回本地配置文件的绝对路径,供用户查看。',
|
|
650
|
+
'- add_connection: options 可为空 {}。在本地终端交互式新增一个 SSH 连接并自动保存,返回 success=true 即视为已成功保存,无需再向用户二次确认。',
|
|
651
|
+
'- set_current_interactive: options 可为空 {}。交互式设置当前连接。',
|
|
652
|
+
'- switch_connection: options 必填 { "name": "<连接别名>" }。',
|
|
653
|
+
'- delete_connection: options 必填 { "name": "<连接别名>" }。',
|
|
654
|
+
'- exec_command: options 必填 { "command": "<要执行的远程命令>", "cwd": "<可选远程工作目录>" }。',
|
|
655
|
+
'- upload_path: options 必填 { "localPath": "<本地文件或目录绝对路径>", "remotePath": "<远程目标绝对路径>" }。',
|
|
656
|
+
'- download_path: options 必填 { "remotePath": "<远程文件或目录绝对路径>", "localPath": "<本地目标绝对路径>" }。',
|
|
657
|
+
'示例:{"action":"upload_path","options":{"localPath":"C:/Users/me/1.png","remotePath":"/root/1.png"}}。',
|
|
658
|
+
'敏感配置仅由本地程序读写,返回数据不会包含密码、私钥内容或口令。',
|
|
659
|
+
].join('\n'),
|
|
660
|
+
parameters: {
|
|
661
|
+
type: 'object',
|
|
662
|
+
properties: {
|
|
663
|
+
action: {
|
|
664
|
+
type: 'string',
|
|
665
|
+
enum: [
|
|
666
|
+
'init',
|
|
667
|
+
'test_connection',
|
|
668
|
+
'upload_path',
|
|
669
|
+
'download_path',
|
|
670
|
+
'exec_command',
|
|
671
|
+
'list_connections',
|
|
672
|
+
'switch_connection',
|
|
673
|
+
'delete_connection',
|
|
674
|
+
'add_connection',
|
|
675
|
+
'set_current_interactive',
|
|
676
|
+
'get_config_path',
|
|
677
|
+
],
|
|
678
|
+
description: '操作类型,必须从枚举值中选择。',
|
|
679
|
+
},
|
|
680
|
+
options: {
|
|
681
|
+
type: 'object',
|
|
682
|
+
description:
|
|
683
|
+
'该操作所需的参数对象。具体字段见 description:exec_command 需 command(可选 cwd);upload_path 需 localPath、remotePath;download_path 需 remotePath、localPath;switch_connection/delete_connection 需 name;init、list_connections、get_config_path、add_connection、set_current_interactive 可传 {}。所有参数都必须放在 options 内部,禁止放到顶层。',
|
|
684
|
+
properties: {
|
|
685
|
+
command: { type: 'string', description: 'exec_command 要执行的远程命令。' },
|
|
686
|
+
cwd: { type: 'string', description: 'exec_command 的远程工作目录(可选)。' },
|
|
687
|
+
localPath: { type: 'string', description: 'upload_path/download_path 的本地路径(绝对路径)。' },
|
|
688
|
+
remotePath: { type: 'string', description: 'upload_path/download_path 的远程路径(绝对路径)。' },
|
|
689
|
+
name: { type: 'string', description: 'switch_connection/delete_connection 的连接别名。' },
|
|
690
|
+
},
|
|
691
|
+
additionalProperties: true,
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
required: ['action', 'options'],
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
];
|
|
699
|
+
|
|
700
|
+
module.exports = { functions, descriptions };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepfish-ai/deepfish-ssh-remote-control",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Deepfish SSH remote control tool module using ssh2 and ssh2-sftp-client.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepfish",
|
|
7
|
+
"deepfish-ai",
|
|
8
|
+
"ssh",
|
|
9
|
+
"ssh2",
|
|
10
|
+
"sftp",
|
|
11
|
+
"remote-control",
|
|
12
|
+
"remote-server",
|
|
13
|
+
"server-management",
|
|
14
|
+
"automation",
|
|
15
|
+
"devops"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/qq306863030/deepfish-extensions.git",
|
|
20
|
+
"directory": "tools/deepfish-ssh-remote-control"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/qq306863030/deepfish-extensions/tree/master/tools/deepfish-ssh-remote-control#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/qq306863030/deepfish-extensions/issues"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"README.md",
|
|
32
|
+
"README.en.md"
|
|
33
|
+
],
|
|
34
|
+
"main": "index.js",
|
|
35
|
+
"type": "commonjs",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"crypto-js": "^4.2.0",
|
|
38
|
+
"inquirer": "^8.2.7",
|
|
39
|
+
"ssh2": "latest",
|
|
40
|
+
"ssh2-sftp-client": "latest"
|
|
41
|
+
}
|
|
42
|
+
}
|