opencode-idea 0.1.3 → 0.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/README.md +116 -63
- package/package.json +6 -1
- package/src/ide-guidance.js +32 -0
- package/src/ide-guidance.template.md +9 -64
- package/src/mcp/idea.js +1 -1
- package/src/plugin-tools-guidance.template.md +18 -0
- package/src/plugin.js +445 -20
- package/src/remote/hosts.js +269 -0
- package/src/remote/hosts.test.js +171 -0
- package/src/remote/persist.js +106 -0
- package/src/remote/persist.test.js +100 -0
- package/src/remote/prompts.js +52 -0
- package/src/remote/prompts.test.js +38 -0
- package/src/remote/sftp.js +242 -0
- package/src/remote/sftp.test.js +47 -0
- package/src/remote/ssh.js +195 -0
- package/src/remote/ssh.test.js +48 -0
- package/src/remote/sync.test.js +204 -0
- package/src/remote/tools.js +403 -0
- package/src/remote/tools.test.js +238 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 两条"提示词命令"的正文:`/remote-shell` 与 `/remote-deployment`。
|
|
2
|
+
//
|
|
3
|
+
// 它们不执行任何东西,只往会话里注入一段规则,让模型走本插件的 remote_* 工具,
|
|
4
|
+
// 而不是自己拼 ssh / scp。和 `~/.config/opencode/commands/readonly.md` 是同一个思路,
|
|
5
|
+
// 区别只是注册在插件里(跟着包分发,不需要用户再放一个 md 文件)。
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/remote-shell` 注入的提示词。
|
|
9
|
+
*
|
|
10
|
+
* @param {string} request 命令后面跟的原始文本(可能为空)
|
|
11
|
+
*/
|
|
12
|
+
export function remoteShellPrompt(request) {
|
|
13
|
+
const ask = request.trim();
|
|
14
|
+
return [
|
|
15
|
+
'【远程执行】本次会话的远程操作一律走本插件的工具,不要用 shell 里的 ssh / scp / rsync。',
|
|
16
|
+
'',
|
|
17
|
+
'1. 服务器名不确定 → 先调 remote_list_servers(列出 IDE / 项目 / 全局三层,带来源)',
|
|
18
|
+
'2. 跑命令 → remote_exec(server, command),需要目录就传 workingDirectory',
|
|
19
|
+
' · 每条命令是独立 SSH 连接:cd / export 不跨命令保留',
|
|
20
|
+
'3. 传文件 → remote_upload / remote_download(按 deployment 名)',
|
|
21
|
+
'4. 名字传错时工具会列出全部候选,按列表重新调用,不要猜',
|
|
22
|
+
'',
|
|
23
|
+
ask
|
|
24
|
+
? `如果用户没有指定服务器或要执行的命令,先问清楚再调用,不要自己挑一台。\n\n用户本次的要求:${ask}`
|
|
25
|
+
: '用户这次没有说要在哪台机器上做什么。先问清楚:服务器是哪台、要执行的命令是什么,再调用工具。',
|
|
26
|
+
].join('\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `/remote-deployment` 注入的提示词。
|
|
31
|
+
*
|
|
32
|
+
* @param {string} request
|
|
33
|
+
*/
|
|
34
|
+
export function remoteDeploymentPrompt(request) {
|
|
35
|
+
const ask = request.trim();
|
|
36
|
+
return [
|
|
37
|
+
'【远程部署同步】本次会话的传输一律用 remote_upload / remote_download,不要用 scp / rsync / ssh。',
|
|
38
|
+
'',
|
|
39
|
+
'1. deployment 名不确定 → 先调 remote_list_servers(每条 mapping 显示 本地 ↔ 远程)',
|
|
40
|
+
'2. 上传 → remote_upload;下载 → remote_download',
|
|
41
|
+
'3. mode 默认 overwrite(只覆盖 / 新增);mirror 会删掉目标端多出来的文件,**不可逆**,只在明确要求镜像时用',
|
|
42
|
+
'4. 传输同步返回 files / dirs / bytes,没有进度条、没有 handle 要轮询',
|
|
43
|
+
'',
|
|
44
|
+
ask
|
|
45
|
+
? `如果用户没有指定要同步的 deployment,先问清楚再调用,不要自己挑一条。\n\n用户本次的要求:${ask}`
|
|
46
|
+
: '用户这次没有说要同步哪一条 deployment。先调 remote_list_servers 列出候选,问清楚是哪一条、上传还是下载,再动手。',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 用户看到的简短提示(`metadata.displayText`)。 */
|
|
51
|
+
export const REMOTE_SHELL_DISPLAY = '【远程执行】已切到 remote_* 工具模式;没指定服务器或命令时会先问你。';
|
|
52
|
+
export const REMOTE_DEPLOYMENT_DISPLAY = '【远程部署同步】已切到 remote_upload / remote_download;没指定 deployment 时会先问你。';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 两条提示词命令的正文测试:有参数时带上参数,没参数时要求先问。
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {remoteDeploymentPrompt, remoteShellPrompt} from './prompts.js';
|
|
6
|
+
|
|
7
|
+
describe('remoteShellPrompt', () => {
|
|
8
|
+
it('带上用户的要求,并要求没指定时先问', () => {
|
|
9
|
+
const text = remoteShellPrompt('mbp 看下磁盘');
|
|
10
|
+
expect(text).toContain('remote_list_servers');
|
|
11
|
+
expect(text).toContain('remote_exec');
|
|
12
|
+
expect(text).toContain('不要用 shell 里的 ssh');
|
|
13
|
+
expect(text).toContain('用户本次的要求:mbp 看下磁盘');
|
|
14
|
+
expect(text).toContain('先问清楚');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('没带参数时明确要求先问服务器与命令', () => {
|
|
18
|
+
const text = remoteShellPrompt('');
|
|
19
|
+
expect(text).toContain('先问清楚');
|
|
20
|
+
expect(text).not.toContain('用户本次的要求:');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('remoteDeploymentPrompt', () => {
|
|
25
|
+
it('带上用户的要求,并要求没指定时先问', () => {
|
|
26
|
+
const text = remoteDeploymentPrompt('mcp-test-project download');
|
|
27
|
+
expect(text).toContain('remote_upload');
|
|
28
|
+
expect(text).toContain('remote_download');
|
|
29
|
+
expect(text).toContain('用户本次的要求:mcp-test-project download');
|
|
30
|
+
expect(text).toContain('先问清楚');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('没带参数时提示先列出候选再问', () => {
|
|
34
|
+
const text = remoteDeploymentPrompt(' ');
|
|
35
|
+
expect(text).toContain('remote_list_servers');
|
|
36
|
+
expect(text).not.toContain('用户本次的要求:');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// SFTP 目录同步:`overwrite`(只覆盖/新增)与 `mirror`(再删掉对方多余的文件)。
|
|
2
|
+
//
|
|
3
|
+
// 语义与 rsync 的尾斜杠一致:把 `localPath` 目录的**内容**同步到 `deployPath`,
|
|
4
|
+
// 不会额外生成一层 `localPath` 的目录名。
|
|
5
|
+
//
|
|
6
|
+
// 符号链接跳过并计数 —— 跟随链接会把目标目录之外的整棵树拖进来,那是另一个决定。
|
|
7
|
+
|
|
8
|
+
import {readdir, stat as fsStat} from 'node:fs/promises';
|
|
9
|
+
import {join} from 'node:path';
|
|
10
|
+
import {posix} from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* files: number,
|
|
15
|
+
* dirs: number,
|
|
16
|
+
* bytes: number,
|
|
17
|
+
* skipped: number,
|
|
18
|
+
* deleted: number,
|
|
19
|
+
* }} SyncStats
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** ssh2 的回调式 API → Promise。 */
|
|
23
|
+
function call(fn, ...args) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
fn(...args, (error, value) => (error ? reject(error) : resolve(value)));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 打开一个 SFTP session。
|
|
31
|
+
*
|
|
32
|
+
* @param {import('ssh2').Client} conn
|
|
33
|
+
*/
|
|
34
|
+
export function openSftp(conn) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
conn.sftp((error, sftp) => (error ? reject(error) : resolve(sftp)));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 逐级 `mkdir`,已存在不算错。
|
|
42
|
+
*
|
|
43
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
44
|
+
* @param {string} dir
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureRemoteDir(sftp, dir) {
|
|
47
|
+
if (!dir || dir === '/' || dir === '.') return;
|
|
48
|
+
const normalized = posix.normalize(dir).replace(/\/+$/, '');
|
|
49
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
50
|
+
let current = normalized.startsWith('/') ? '' : '.';
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
current = current === '' ? `/${part}` : `${current}/${part}`;
|
|
53
|
+
try {
|
|
54
|
+
const info = await call(sftp.stat.bind(sftp), current);
|
|
55
|
+
if (!info.isDirectory()) throw new Error(`远程路径 "${current}" 已存在但不是目录`);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code === 2 || /No such file/i.test(String(error?.message))) {
|
|
58
|
+
await call(sftp.mkdir.bind(sftp), current);
|
|
59
|
+
} else {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 递归上传一个本地目录。
|
|
68
|
+
*
|
|
69
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
70
|
+
* @param {string} localDir
|
|
71
|
+
* @param {string} remoteDir
|
|
72
|
+
* @param {SyncStats} stats
|
|
73
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
74
|
+
*/
|
|
75
|
+
async function uploadTree(sftp, localDir, remoteDir, stats, options = {}) {
|
|
76
|
+
await ensureRemoteDir(sftp, remoteDir);
|
|
77
|
+
const entries = await readdir(localDir, {withFileTypes: true});
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (options.signal?.aborted) throw new Error('传输已取消');
|
|
80
|
+
const localPath = join(localDir, entry.name);
|
|
81
|
+
const remotePath = posix.join(remoteDir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
stats.dirs += 1;
|
|
84
|
+
await uploadTree(sftp, localPath, remotePath, stats, options);
|
|
85
|
+
} else if (entry.isFile()) {
|
|
86
|
+
const info = await fsStat(localPath);
|
|
87
|
+
await call(sftp.fastPut.bind(sftp), localPath, remotePath);
|
|
88
|
+
stats.files += 1;
|
|
89
|
+
stats.bytes += info.size;
|
|
90
|
+
} else {
|
|
91
|
+
stats.skipped += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 递归读取远程目录的条目(相对路径 + 是否目录),用于 mirror 的差异删除。
|
|
98
|
+
*
|
|
99
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
100
|
+
* @param {string} remoteDir
|
|
101
|
+
* @param {string} [prefix]
|
|
102
|
+
* @returns {Promise<{ path: string, dir: boolean }[]>}
|
|
103
|
+
*/
|
|
104
|
+
async function listRemoteTree(sftp, remoteDir, prefix = '') {
|
|
105
|
+
const entries = await call(sftp.readdir.bind(sftp), remoteDir).catch((error) => {
|
|
106
|
+
if (error?.code === 2 || /No such file/i.test(String(error?.message))) return [];
|
|
107
|
+
throw error;
|
|
108
|
+
});
|
|
109
|
+
const result = [];
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
const name = entry.filename;
|
|
112
|
+
if (name === '.' || name === '..') continue;
|
|
113
|
+
const relative = prefix ? `${prefix}/${name}` : name;
|
|
114
|
+
const isDir = entry.attrs.isDirectory();
|
|
115
|
+
result.push({path: relative, dir: isDir});
|
|
116
|
+
if (isDir) {
|
|
117
|
+
result.push(...await listRemoteTree(sftp, posix.join(remoteDir, name), relative));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 递归列出本地目录的相对条目,用于 mirror。
|
|
125
|
+
*
|
|
126
|
+
* @param {string} localDir
|
|
127
|
+
* @param {string} [prefix]
|
|
128
|
+
* @returns {Promise<{ path: string, dir: boolean }[]>}
|
|
129
|
+
*/
|
|
130
|
+
async function listLocalTree(localDir, prefix = '') {
|
|
131
|
+
const entries = await readdir(localDir, {withFileTypes: true}).catch(() => []);
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
135
|
+
if (entry.isDirectory()) {
|
|
136
|
+
result.push({path: relative, dir: true});
|
|
137
|
+
result.push(...await listLocalTree(join(localDir, entry.name), relative));
|
|
138
|
+
} else if (entry.isFile()) {
|
|
139
|
+
result.push({path: relative, dir: false});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 上传:本地目录 → 远程目录。
|
|
147
|
+
*
|
|
148
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
149
|
+
* @param {string} localDir
|
|
150
|
+
* @param {string} remoteDir
|
|
151
|
+
* @param {'overwrite' | 'mirror'} mode
|
|
152
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
153
|
+
* @returns {Promise<SyncStats>}
|
|
154
|
+
*/
|
|
155
|
+
export async function uploadDirectory(sftp, localDir, remoteDir, mode, options = {}) {
|
|
156
|
+
const stats = {files: 0, dirs: 0, bytes: 0, skipped: 0, deleted: 0};
|
|
157
|
+
await uploadTree(sftp, localDir, remoteDir, stats, options);
|
|
158
|
+
|
|
159
|
+
if (mode === 'mirror') {
|
|
160
|
+
const localSet = new Set((await listLocalTree(localDir)).map((entry) => entry.path));
|
|
161
|
+
const remoteEntries = await listRemoteTree(sftp, remoteDir);
|
|
162
|
+
// 先删文件再删目录,避免 rmdir 非空。
|
|
163
|
+
for (const entry of remoteEntries.filter((item) => !item.dir)) {
|
|
164
|
+
if (localSet.has(entry.path)) continue;
|
|
165
|
+
await call(sftp.unlink.bind(sftp), posix.join(remoteDir, entry.path));
|
|
166
|
+
stats.deleted += 1;
|
|
167
|
+
}
|
|
168
|
+
for (const entry of remoteEntries.filter((item) => item.dir).reverse()) {
|
|
169
|
+
if (localSet.has(entry.path)) continue;
|
|
170
|
+
await call(sftp.rmdir.bind(sftp), posix.join(remoteDir, entry.path)).catch(() => {});
|
|
171
|
+
stats.deleted += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return stats;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 递归下载一个远程目录。
|
|
179
|
+
*
|
|
180
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
181
|
+
* @param {string} remoteDir
|
|
182
|
+
* @param {string} localDir
|
|
183
|
+
* @param {SyncStats} stats
|
|
184
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
185
|
+
*/
|
|
186
|
+
async function downloadTree(sftp, remoteDir, localDir, stats, options = {}) {
|
|
187
|
+
const {mkdir} = await import('node:fs/promises');
|
|
188
|
+
await mkdir(localDir, {recursive: true});
|
|
189
|
+
const entries = await call(sftp.readdir.bind(sftp), remoteDir).catch((error) => {
|
|
190
|
+
if (error?.code === 2 || /No such file/i.test(String(error?.message))) return [];
|
|
191
|
+
throw error;
|
|
192
|
+
});
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
if (options.signal?.aborted) throw new Error('传输已取消');
|
|
195
|
+
const name = entry.filename;
|
|
196
|
+
if (name === '.' || name === '..') continue;
|
|
197
|
+
const remotePath = posix.join(remoteDir, name);
|
|
198
|
+
const localPath = join(localDir, name);
|
|
199
|
+
if (entry.attrs.isDirectory()) {
|
|
200
|
+
stats.dirs += 1;
|
|
201
|
+
await downloadTree(sftp, remotePath, localPath, stats, options);
|
|
202
|
+
} else if (entry.attrs.isFile()) {
|
|
203
|
+
await call(sftp.fastGet.bind(sftp), remotePath, localPath);
|
|
204
|
+
stats.files += 1;
|
|
205
|
+
stats.bytes += entry.attrs.size ?? 0;
|
|
206
|
+
} else {
|
|
207
|
+
stats.skipped += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 下载:远程目录 → 本地目录。
|
|
214
|
+
*
|
|
215
|
+
* @param {import('ssh2').SFTPWrapper} sftp
|
|
216
|
+
* @param {string} remoteDir
|
|
217
|
+
* @param {string} localDir
|
|
218
|
+
* @param {'overwrite' | 'mirror'} mode
|
|
219
|
+
* @param {{ signal?: AbortSignal }} [options]
|
|
220
|
+
* @returns {Promise<SyncStats>}
|
|
221
|
+
*/
|
|
222
|
+
export async function downloadDirectory(sftp, remoteDir, localDir, mode, options = {}) {
|
|
223
|
+
const stats = {files: 0, dirs: 0, bytes: 0, skipped: 0, deleted: 0};
|
|
224
|
+
await downloadTree(sftp, remoteDir, localDir, stats, options);
|
|
225
|
+
|
|
226
|
+
if (mode === 'mirror') {
|
|
227
|
+
const remoteEntries = await listRemoteTree(sftp, remoteDir);
|
|
228
|
+
const remoteSet = new Set(remoteEntries.map((entry) => entry.path));
|
|
229
|
+
const {rm, rmdir} = await import('node:fs/promises');
|
|
230
|
+
for (const entry of (await listLocalTree(localDir)).filter((item) => !item.dir)) {
|
|
231
|
+
if (remoteSet.has(entry.path)) continue;
|
|
232
|
+
await rm(join(localDir, entry.path), {force: true});
|
|
233
|
+
stats.deleted += 1;
|
|
234
|
+
}
|
|
235
|
+
// 本地空目录也收掉;非空时 rmdir 会失败,忽略即可。
|
|
236
|
+
for (const entry of (await listLocalTree(localDir)).filter((item) => item.dir).reverse()) {
|
|
237
|
+
if (remoteSet.has(entry.path)) continue;
|
|
238
|
+
await rmdir(join(localDir, entry.path)).catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return stats;
|
|
242
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SFTP 层的路径逻辑测试(真实的传输只能手工验证)。
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {ensureRemoteDir} from './sftp.js';
|
|
6
|
+
|
|
7
|
+
/** 一个只实现 stat / mkdir 的假 SFTP,用来观察逐级建目录的顺序。 */
|
|
8
|
+
function fakeSftp({existing = []} = {}) {
|
|
9
|
+
const dirs = new Set(existing);
|
|
10
|
+
const created = [];
|
|
11
|
+
return {
|
|
12
|
+
created,
|
|
13
|
+
stat(path, callback) {
|
|
14
|
+
if (dirs.has(path)) callback(null, {isDirectory: () => true});
|
|
15
|
+
else {
|
|
16
|
+
const error = new Error('No such file');
|
|
17
|
+
error.code = 2;
|
|
18
|
+
callback(error);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
mkdir(path, callback) {
|
|
22
|
+
created.push(path);
|
|
23
|
+
dirs.add(path);
|
|
24
|
+
callback(null);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('ensureRemoteDir', () => {
|
|
30
|
+
it('逐级建目录', async () => {
|
|
31
|
+
const sftp = fakeSftp();
|
|
32
|
+
await ensureRemoteDir(sftp, '/srv/app/static');
|
|
33
|
+
expect(sftp.created).toEqual(['/srv', '/srv/app', '/srv/app/static']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('已存在的层级不重复建', async () => {
|
|
37
|
+
const sftp = fakeSftp({existing: ['/srv', '/srv/app']});
|
|
38
|
+
await ensureRemoteDir(sftp, '/srv/app/static');
|
|
39
|
+
expect(sftp.created).toEqual(['/srv/app/static']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('根目录什么都不做', async () => {
|
|
43
|
+
const sftp = fakeSftp();
|
|
44
|
+
await ensureRemoteDir(sftp, '/');
|
|
45
|
+
expect(sftp.created).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SSH 执行:一次调用 = 一条连接 + 一条命令。
|
|
2
|
+
//
|
|
3
|
+
// 刻意**不做持久 shell**:没有交互式会话就没有提示符解析、没有输出边界猜测、没有
|
|
4
|
+
// 会话生命周期管理。代价是 `cd` / `export` 不跨命令保留 —— 需要目录就传
|
|
5
|
+
// `workingDirectory`(内部拼 `cd '<dir>' && <command>`),需要环境就写进命令本身。
|
|
6
|
+
|
|
7
|
+
import {readFileSync} from 'node:fs';
|
|
8
|
+
import {homedir} from 'node:os';
|
|
9
|
+
import {join} from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {Client} from 'ssh2';
|
|
12
|
+
|
|
13
|
+
/** 连接建立的上限。命令本身**不设超时**:卡住时由用户取消这次调用。 */
|
|
14
|
+
const CONNECT_TIMEOUT_MS = 15000;
|
|
15
|
+
|
|
16
|
+
/** @param {string} path */
|
|
17
|
+
export function expandTilde(path) {
|
|
18
|
+
if (path === '~') return homedir();
|
|
19
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** POSIX 单引号引用;内部单引号用 `'\''` 转义。 */
|
|
24
|
+
export function shellQuote(value) {
|
|
25
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 把 `cd` 包装进命令。`~` 必须当远程的 `$HOME` 处理 —— `cd '~'` 不会展开。
|
|
30
|
+
*
|
|
31
|
+
* @param {string} command
|
|
32
|
+
* @param {string | undefined} workingDirectory
|
|
33
|
+
*/
|
|
34
|
+
export function withWorkingDirectory(command, workingDirectory) {
|
|
35
|
+
const dir = workingDirectory?.trim();
|
|
36
|
+
if (!dir) return command;
|
|
37
|
+
const cd = dir === '~'
|
|
38
|
+
? 'cd "$HOME"'
|
|
39
|
+
: dir.startsWith('~/')
|
|
40
|
+
? `cd "$HOME"/${shellQuote(dir.slice(2))}`
|
|
41
|
+
: `cd ${shellQuote(dir)}`;
|
|
42
|
+
return `${cd} && ${command}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 开一条 SSH 连接。
|
|
47
|
+
*
|
|
48
|
+
* @param {import('./hosts.js').RemoteServer} server
|
|
49
|
+
* @returns {Promise<import('ssh2').Client>}
|
|
50
|
+
*/
|
|
51
|
+
export function connect(server) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const conn = new Client();
|
|
54
|
+
let settled = false;
|
|
55
|
+
/** @param {Error} [error] @param {unknown} [value] */
|
|
56
|
+
const done = (error, value) => {
|
|
57
|
+
if (settled) return;
|
|
58
|
+
settled = true;
|
|
59
|
+
if (error) reject(error);
|
|
60
|
+
else resolve(value);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let privateKey;
|
|
64
|
+
if (server.privateKeyPath) {
|
|
65
|
+
try {
|
|
66
|
+
privateKey = readFileSync(expandTilde(server.privateKeyPath));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reject(new Error(
|
|
69
|
+
`无法读取私钥 "${server.privateKeyPath}": ` +
|
|
70
|
+
(error instanceof Error ? error.message : String(error)),
|
|
71
|
+
));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
conn.on('ready', () => done(undefined, conn));
|
|
77
|
+
conn.on('error', (error) => done(error));
|
|
78
|
+
// close/end 只在连接意外结束时有意义;正常收尾时 settled 已经是 true。
|
|
79
|
+
conn.on('close', () => done(new Error('SSH 连接意外关闭')));
|
|
80
|
+
conn.on('end', () => done(new Error('SSH 连接意外断开')));
|
|
81
|
+
|
|
82
|
+
// 第三种认证方式:OpenSSH config / ssh-agent。IDE 里选了它时不会有 password 也不会有
|
|
83
|
+
// privateKeyPath —— 凭据在 `~/.ssh/config` 与 agent 里,ssh2 要用 agent socket。
|
|
84
|
+
const useAgent = server.authType === 'OPEN_SSH'
|
|
85
|
+
|| (!server.password && !server.privateKeyPath);
|
|
86
|
+
const agent = useAgent ? process.env.SSH_AUTH_SOCK : undefined;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
conn.connect({
|
|
90
|
+
host: server.host,
|
|
91
|
+
port: server.port,
|
|
92
|
+
username: server.username,
|
|
93
|
+
password: server.password,
|
|
94
|
+
privateKey,
|
|
95
|
+
passphrase: server.passphrase,
|
|
96
|
+
agent,
|
|
97
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
98
|
+
keepaliveInterval: 5000,
|
|
99
|
+
keepaliveCountMax: 3,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
done(error instanceof Error ? error : new Error(String(error)));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 在远程执行一条命令。
|
|
109
|
+
*
|
|
110
|
+
* 命令先 base64 编码再交给 `sh`:这样引号、换行、`$` 都不会被本地 shell 或传输层改写,
|
|
111
|
+
* 远程拿到的就是调用方写的那条命令。
|
|
112
|
+
*
|
|
113
|
+
* @param {import('./hosts.js').RemoteServer} server
|
|
114
|
+
* @param {string} command
|
|
115
|
+
* @param {{ workingDirectory?: string }} [options]
|
|
116
|
+
* @returns {Promise<{ exitCode: number | null, stdout: string, stderr: string }>}
|
|
117
|
+
*/
|
|
118
|
+
export async function runRemoteCommand(server, command, options = {}) {
|
|
119
|
+
const conn = await connect(server);
|
|
120
|
+
try {
|
|
121
|
+
return await execOn(conn, withWorkingDirectory(command, options.workingDirectory));
|
|
122
|
+
} finally {
|
|
123
|
+
conn.end();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {import('ssh2').Client} conn
|
|
129
|
+
* @param {string} command 已经包好 `cd` 的命令
|
|
130
|
+
*/
|
|
131
|
+
function execOn(conn, command) {
|
|
132
|
+
const encoded = Buffer.from(command, 'utf8').toString('base64');
|
|
133
|
+
const execCommand = `printf '%s' '${encoded}' | (base64 -d 2>/dev/null || openssl base64 -d 2>/dev/null) | sh`;
|
|
134
|
+
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
let settled = false;
|
|
137
|
+
let stdout = '';
|
|
138
|
+
let stderr = '';
|
|
139
|
+
|
|
140
|
+
/** @param {Error} [error] @param {{ exitCode: number | null }} [value] */
|
|
141
|
+
const finish = (error, value) => {
|
|
142
|
+
if (settled) return;
|
|
143
|
+
settled = true;
|
|
144
|
+
if (error) reject(error);
|
|
145
|
+
else resolve({exitCode: value?.exitCode ?? null, stdout, stderr});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
conn.exec(execCommand, (error, stream) => {
|
|
149
|
+
if (error) {
|
|
150
|
+
finish(error);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
stream.on('data', (chunk) => {
|
|
155
|
+
stdout += chunk.toString('utf8');
|
|
156
|
+
});
|
|
157
|
+
stream.stderr.on('data', (chunk) => {
|
|
158
|
+
stderr += chunk.toString('utf8');
|
|
159
|
+
});
|
|
160
|
+
// 没有内部超时:命令会一直跑到自己结束或连接断掉。卡住的调用由用户取消。
|
|
161
|
+
stream.on('close', (code) => {
|
|
162
|
+
finish(undefined, {exitCode: typeof code === 'number' ? code : null});
|
|
163
|
+
});
|
|
164
|
+
stream.on('error', (streamError) => {
|
|
165
|
+
finish(streamError);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 把 SSH 错误翻译成调用方看得懂的一句话。
|
|
173
|
+
*
|
|
174
|
+
* @param {unknown} error
|
|
175
|
+
* @param {import('./hosts.js').RemoteServer} server
|
|
176
|
+
*/
|
|
177
|
+
export function describeSshError(error, server) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
const level = /** @type {{ level?: string }} */ (error)?.level;
|
|
180
|
+
if (level === 'client-authentication' || /authentication/i.test(message)) {
|
|
181
|
+
return `认证失败(${server.username}@${server.host}:${server.port}):密码/私钥可能已失效,` +
|
|
182
|
+
`请在 IDE 的 Deployment 设置里更新后重试。`;
|
|
183
|
+
}
|
|
184
|
+
if (/ECONNREFUSED/i.test(message)) {
|
|
185
|
+
return `连接被拒绝(${server.host}:${server.port}):主机可能未启动 SSH 服务,或端口不对。`;
|
|
186
|
+
}
|
|
187
|
+
if (/ETIMEDOUT|timed out/i.test(message)) {
|
|
188
|
+
return `连接超时(${server.host}:${server.port}):网络不可达,或需要跳板机。`;
|
|
189
|
+
}
|
|
190
|
+
if (server.authType === 'OPEN_SSH' && /agent|no auth/i.test(message)) {
|
|
191
|
+
return `这台的认证方式是 OpenSSH(ssh-agent / ~/.ssh/config),但本机没有可用的 agent 或私钥:` +
|
|
192
|
+
`确认 SSH_AUTH_SOCK 已设置、或 ~/.ssh/config 里配了 IdentityFile。`;
|
|
193
|
+
}
|
|
194
|
+
return `SSH 执行失败(${server.name}):${message}`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SSH 层的纯函数测试:命令包装、引用、错误翻译。真连接只能手工验证。
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {describeSshError, expandTilde, shellQuote, withWorkingDirectory} from './ssh.js';
|
|
6
|
+
|
|
7
|
+
describe('shellQuote', () => {
|
|
8
|
+
it('包住单引号并在内部转义', () => {
|
|
9
|
+
expect(shellQuote('/data/app')).toBe("'/data/app'");
|
|
10
|
+
expect(shellQuote("it's")).toBe(`'it'\\''s'`);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('withWorkingDirectory', () => {
|
|
15
|
+
it('没传目录时原样返回', () => {
|
|
16
|
+
expect(withWorkingDirectory('ls', undefined)).toBe('ls');
|
|
17
|
+
expect(withWorkingDirectory('ls', ' ')).toBe('ls');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('绝对路径直接 cd', () => {
|
|
21
|
+
expect(withWorkingDirectory('ls', '/data/app')).toBe("cd '/data/app' && ls");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('~ 展开成远程 $HOME,而不是字面量 ~', () => {
|
|
25
|
+
expect(withWorkingDirectory('ls', '~')).toBe('cd "$HOME" && ls');
|
|
26
|
+
expect(withWorkingDirectory('ls', '~/app')).toBe('cd "$HOME"/\'app\' && ls');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('expandTilde', () => {
|
|
31
|
+
it('只展开开头的 ~', () => {
|
|
32
|
+
expect(expandTilde('/a/~/b')).toBe('/a/~/b');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('describeSshError', () => {
|
|
37
|
+
const target = {name: 'mbp', host: '10.0.0.1', port: 22, username: 'root', source: 'ide'};
|
|
38
|
+
|
|
39
|
+
it('认证失败给出可执行的下一步', () => {
|
|
40
|
+
expect(describeSshError(new Error('All configured authentication methods failed'), target))
|
|
41
|
+
.toContain('认证失败');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('连接拒绝 / 超时各有自己的文案', () => {
|
|
45
|
+
expect(describeSshError(new Error('connect ECONNREFUSED'), target)).toContain('连接被拒绝');
|
|
46
|
+
expect(describeSshError(new Error('Timed out while waiting for handshake'), target)).toContain('连接超时');
|
|
47
|
+
});
|
|
48
|
+
});
|