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,204 @@
|
|
|
1
|
+
// 目录同步的沙箱测试:真实的 uploadDirectory / downloadDirectory,配一个内存 SFTP。
|
|
2
|
+
//
|
|
3
|
+
// 这里盯的是 **mapping 语义**:本地目录内容落到远程目录、嵌套层级、overwrite 不删多余、
|
|
4
|
+
// mirror 删多余。传输出错(认证、断线)不在这里测。
|
|
5
|
+
|
|
6
|
+
import {mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
|
7
|
+
import {tmpdir} from 'node:os';
|
|
8
|
+
import {join, posix} from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {afterAll, describe, expect, it} from 'vitest';
|
|
11
|
+
|
|
12
|
+
import {downloadDirectory, uploadDirectory} from './sftp.js';
|
|
13
|
+
|
|
14
|
+
const workDir = mkdtempSync(join(tmpdir(), 'remote-sync-'));
|
|
15
|
+
afterAll(() => rmSync(workDir, {recursive: true, force: true}));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 内存 SFTP:一张 path → { dir: true } | { dir: false, data: string } 的表。
|
|
19
|
+
*
|
|
20
|
+
* 只实现这几次同步会用到的方法,行为对齐 ssh2:stat 对不存在的路径回 code 2。
|
|
21
|
+
*/
|
|
22
|
+
function memorySftp(initial = {}) {
|
|
23
|
+
const nodes = new Map([['/', {dir: true}]]);
|
|
24
|
+
for (const [path, data] of Object.entries(initial)) {
|
|
25
|
+
const parts = path.split('/').filter(Boolean);
|
|
26
|
+
let current = '';
|
|
27
|
+
for (const part of parts.slice(0, -1)) {
|
|
28
|
+
current += `/${part}`;
|
|
29
|
+
if (!nodes.has(current)) nodes.set(current, {dir: true});
|
|
30
|
+
}
|
|
31
|
+
nodes.set(path, {dir: false, data});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const missing = () => {
|
|
35
|
+
const error = new Error('No such file');
|
|
36
|
+
error.code = 2;
|
|
37
|
+
return error;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
nodes,
|
|
42
|
+
stat(path, callback) {
|
|
43
|
+
const node = nodes.get(posix.normalize(path));
|
|
44
|
+
if (!node) return callback(missing());
|
|
45
|
+
callback(null, {
|
|
46
|
+
isDirectory: () => node.dir,
|
|
47
|
+
isFile: () => !node.dir,
|
|
48
|
+
size: node.dir ? 0 : Buffer.byteLength(node.data),
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
mkdir(path, callback) {
|
|
52
|
+
nodes.set(posix.normalize(path), {dir: true});
|
|
53
|
+
callback(null);
|
|
54
|
+
},
|
|
55
|
+
readdir(path, callback) {
|
|
56
|
+
const dir = posix.normalize(path);
|
|
57
|
+
const prefix = dir === '/' ? '/' : `${dir}/`;
|
|
58
|
+
const entries = [];
|
|
59
|
+
for (const [nodePath, node] of nodes) {
|
|
60
|
+
if (!nodePath.startsWith(prefix)) continue;
|
|
61
|
+
const rest = nodePath.slice(prefix.length);
|
|
62
|
+
if (!rest || rest.includes('/')) continue;
|
|
63
|
+
entries.push({
|
|
64
|
+
filename: rest,
|
|
65
|
+
attrs: {
|
|
66
|
+
isDirectory: () => node.dir,
|
|
67
|
+
isFile: () => !node.dir,
|
|
68
|
+
size: node.dir ? 0 : Buffer.byteLength(node.data),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
callback(null, entries);
|
|
73
|
+
},
|
|
74
|
+
fastPut(localPath, remotePath, callback) {
|
|
75
|
+
nodes.set(posix.normalize(remotePath), {dir: false, data: readFileSync(localPath, 'utf8')});
|
|
76
|
+
callback(null);
|
|
77
|
+
},
|
|
78
|
+
fastGet(remotePath, localPath, callback) {
|
|
79
|
+
const node = nodes.get(posix.normalize(remotePath));
|
|
80
|
+
if (!node) return callback(missing());
|
|
81
|
+
writeFileSync(localPath, node.data);
|
|
82
|
+
callback(null);
|
|
83
|
+
},
|
|
84
|
+
unlink(path, callback) {
|
|
85
|
+
nodes.delete(posix.normalize(path));
|
|
86
|
+
callback(null);
|
|
87
|
+
},
|
|
88
|
+
rmdir(path, callback) {
|
|
89
|
+
nodes.delete(posix.normalize(path));
|
|
90
|
+
callback(null);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 建一棵本地目录,返回根路径。`files` 的 key 是相对路径。 */
|
|
96
|
+
function localTree(files) {
|
|
97
|
+
const root = mkdtempSync(join(workDir, 'tree-'));
|
|
98
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
99
|
+
const full = join(root, relative);
|
|
100
|
+
mkdirSync(join(full, '..'), {recursive: true});
|
|
101
|
+
writeFileSync(full, content);
|
|
102
|
+
}
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 把内存 SFTP 的某棵子树拍平成 `相对路径: 内容`,方便整体断言。 */
|
|
107
|
+
function snapshot(sftp, root) {
|
|
108
|
+
const result = {};
|
|
109
|
+
const prefix = root === '/' ? '/' : `${root}/`;
|
|
110
|
+
for (const [path, node] of sftp.nodes) {
|
|
111
|
+
if (!path.startsWith(prefix) || path === root) continue;
|
|
112
|
+
result[path.slice(prefix.length)] = node.dir ? '(dir)' : node.data;
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('uploadDirectory', () => {
|
|
118
|
+
it('把本地目录的内容落到远程目录(不多生成一层目录名)', async () => {
|
|
119
|
+
const local = localTree({
|
|
120
|
+
'a.txt': 'A',
|
|
121
|
+
'sub/b.txt': 'B',
|
|
122
|
+
'empty/.keep': '',
|
|
123
|
+
});
|
|
124
|
+
const sftp = memorySftp();
|
|
125
|
+
|
|
126
|
+
await uploadDirectory(sftp, local, '/srv/app', 'overwrite');
|
|
127
|
+
|
|
128
|
+
expect(snapshot(sftp, '/srv/app')).toEqual({
|
|
129
|
+
'a.txt': 'A',
|
|
130
|
+
'sub': '(dir)',
|
|
131
|
+
'sub/b.txt': 'B',
|
|
132
|
+
'empty': '(dir)',
|
|
133
|
+
'empty/.keep': '',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('overwrite 只覆盖同名文件,远程多余的文件留着', async () => {
|
|
138
|
+
const local = localTree({'a.txt': 'new'});
|
|
139
|
+
const sftp = memorySftp({
|
|
140
|
+
'/srv/app/a.txt': 'old',
|
|
141
|
+
'/srv/app/stale.txt': 'keep me',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const stats = await uploadDirectory(sftp, local, '/srv/app', 'overwrite');
|
|
145
|
+
|
|
146
|
+
expect(snapshot(sftp, '/srv/app')).toEqual({'a.txt': 'new', 'stale.txt': 'keep me'});
|
|
147
|
+
expect(stats.files).toBe(1);
|
|
148
|
+
expect(stats.deleted).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('mirror 删掉远程多出来的文件与目录', async () => {
|
|
152
|
+
const local = localTree({'a.txt': 'new'});
|
|
153
|
+
const sftp = memorySftp({
|
|
154
|
+
'/srv/app/a.txt': 'old',
|
|
155
|
+
'/srv/app/stale.txt': 'gone',
|
|
156
|
+
'/srv/app/old/sub.txt': 'gone too',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const stats = await uploadDirectory(sftp, local, '/srv/app', 'mirror');
|
|
160
|
+
|
|
161
|
+
expect(snapshot(sftp, '/srv/app')).toEqual({'a.txt': 'new'});
|
|
162
|
+
// stale.txt + old/sub.txt + old 目录
|
|
163
|
+
expect(stats.deleted).toBe(3);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('统计 files / dirs / bytes', async () => {
|
|
167
|
+
const local = localTree({'a.txt': 'AAAA', 'sub/b.txt': 'BB'});
|
|
168
|
+
const sftp = memorySftp();
|
|
169
|
+
|
|
170
|
+
const stats = await uploadDirectory(sftp, local, '/srv/app', 'overwrite');
|
|
171
|
+
|
|
172
|
+
expect(stats.files).toBe(2);
|
|
173
|
+
expect(stats.dirs).toBe(1);
|
|
174
|
+
expect(stats.bytes).toBe(6);
|
|
175
|
+
expect(stats.skipped).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('downloadDirectory', () => {
|
|
180
|
+
it('把远程目录的内容拉到本地目录', async () => {
|
|
181
|
+
const local = join(workDir, 'downloaded');
|
|
182
|
+
const sftp = memorySftp({
|
|
183
|
+
'/srv/app/a.txt': 'A',
|
|
184
|
+
'/srv/app/sub/b.txt': 'B',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await downloadDirectory(sftp, '/srv/app', local, 'overwrite');
|
|
188
|
+
|
|
189
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf8')).toBe('A');
|
|
190
|
+
expect(readFileSync(join(local, 'sub/b.txt'), 'utf8')).toBe('B');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('overwrite 保留本地多余文件,mirror 删掉它们', async () => {
|
|
194
|
+
const local = localTree({'a.txt': 'old', 'stale.txt': 'keep me'});
|
|
195
|
+
const sftp = memorySftp({'/srv/app/a.txt': 'new'});
|
|
196
|
+
|
|
197
|
+
await downloadDirectory(sftp, '/srv/app', local, 'overwrite');
|
|
198
|
+
expect(readFileSync(join(local, 'stale.txt'), 'utf8')).toBe('keep me');
|
|
199
|
+
|
|
200
|
+
await downloadDirectory(sftp, '/srv/app', local, 'mirror');
|
|
201
|
+
expect(readFileSync(join(local, 'a.txt'), 'utf8')).toBe('new');
|
|
202
|
+
expect(() => readFileSync(join(local, 'stale.txt'), 'utf8')).toThrow();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// 远程执行与文件同步的工具:`remote_exec` / `remote_list_servers` /
|
|
2
|
+
// `remote_upload` / `remote_download`。
|
|
3
|
+
//
|
|
4
|
+
// 依赖注入而非直接 import 插件上下文:`fetchIdeServers` 由 `plugin.js` 提供
|
|
5
|
+
// (它才知道 IDE 端口和项目路径),这里只负责缓存、合并、调用 SSH / SFTP。
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
collectDeployments,
|
|
9
|
+
deploymentNotFoundText,
|
|
10
|
+
findServer,
|
|
11
|
+
mergeServers,
|
|
12
|
+
projectHostsPath,
|
|
13
|
+
readHostsFile,
|
|
14
|
+
serverNotFoundText,
|
|
15
|
+
GLOBAL_HOSTS_PATH,
|
|
16
|
+
} from './hosts.js';
|
|
17
|
+
import {describeSshError, runRemoteCommand} from './ssh.js';
|
|
18
|
+
import {downloadDirectory, openSftp, uploadDirectory} from './sftp.js';
|
|
19
|
+
import {connect} from './ssh.js';
|
|
20
|
+
|
|
21
|
+
/** 服务器列表的缓存时长。命中就不打 IDE MCP;miss / 连接失败会强制刷新。 */
|
|
22
|
+
export const SERVER_CACHE_TTL_MS = 60000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* projectPath: string,
|
|
27
|
+
* fetchIdeServers: () => Promise<Array<import('./hosts.js').RemoteServer>>,
|
|
28
|
+
* globalHostsPath?: string,
|
|
29
|
+
* }} options
|
|
30
|
+
*/
|
|
31
|
+
export function createRemoteToolset({projectPath, fetchIdeServers, globalHostsPath = GLOBAL_HOSTS_PATH}) {
|
|
32
|
+
/** @type {{ at: number, servers: Array<import('./hosts.js').RemoteServer> } | undefined} */
|
|
33
|
+
let cache;
|
|
34
|
+
/** 最近一次 IDE 没读成的错误(配置不完整 / 没连上),给展示层转给用户。 */
|
|
35
|
+
/** @type {Error | undefined} */
|
|
36
|
+
let lastIdeError;
|
|
37
|
+
/** @type {Promise<Array<import('./hosts.js').RemoteServer>> | undefined} */
|
|
38
|
+
let inFlight;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 全量加载三层并合并。
|
|
42
|
+
*
|
|
43
|
+
* @param {{ force?: boolean } | undefined} [options]
|
|
44
|
+
*/
|
|
45
|
+
const load = async ({force = false} = {}) => {
|
|
46
|
+
if (!force && cache && Date.now() - cache.at < SERVER_CACHE_TTL_MS) return cache.servers;
|
|
47
|
+
if (inFlight) return inFlight;
|
|
48
|
+
const run = (async () => {
|
|
49
|
+
// IDE 失败 ≠ 整体失败:还有项目级与全局两级可以用。但错误要留住 ——
|
|
50
|
+
// 配置不完整(缺 host / 缺凭据)是用户该去 IDE 里修的东西,不能悄悄退化成
|
|
51
|
+
// "项目配置里没有这台机器"。
|
|
52
|
+
const ide = await fetchIdeServers()
|
|
53
|
+
.then((value) => {
|
|
54
|
+
lastIdeError = undefined;
|
|
55
|
+
return value;
|
|
56
|
+
})
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
lastIdeError = error instanceof Error ? error : new Error(String(error));
|
|
59
|
+
return [];
|
|
60
|
+
});
|
|
61
|
+
const project = readHostsFile(projectHostsPath(projectPath), 'project');
|
|
62
|
+
const global = readHostsFile(globalHostsPath, 'global');
|
|
63
|
+
// 合并后仍然缺 host / username 的条目(只写了 mapping 的补丁)在这里丢掉:
|
|
64
|
+
// 它没有连接信息,执行时只会变成一个看不懂的 SSH 错误。
|
|
65
|
+
const servers = mergeServers([global, project, ide])
|
|
66
|
+
.filter((server) => server.host && server.username)
|
|
67
|
+
.map((server) => ({...server, port: server.port ?? 22}));
|
|
68
|
+
cache = {at: Date.now(), servers};
|
|
69
|
+
return servers;
|
|
70
|
+
})();
|
|
71
|
+
inFlight = run;
|
|
72
|
+
try {
|
|
73
|
+
return await run;
|
|
74
|
+
} finally {
|
|
75
|
+
if (inFlight === run) inFlight = undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 找一台服务器,连接失败时刷新凭据重试一次。
|
|
81
|
+
*
|
|
82
|
+
* @template T
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @param {(server: import('./hosts.js').RemoteServer) => Promise<T>} action
|
|
85
|
+
* @returns {Promise<{kind: 'ok', value: T, retried: boolean} | {kind: 'missing', servers: Array<import('./hosts.js').RemoteServer>} | {kind: 'no-credentials', server: import('./hosts.js').RemoteServer} | {kind: 'failed', server: import('./hosts.js').RemoteServer, error: unknown}>}
|
|
86
|
+
*/
|
|
87
|
+
const withServer = async (name, action) => {
|
|
88
|
+
let servers = await load();
|
|
89
|
+
let server = findServer(servers, name);
|
|
90
|
+
if (!server) {
|
|
91
|
+
servers = await load({force: true});
|
|
92
|
+
server = findServer(servers, name);
|
|
93
|
+
}
|
|
94
|
+
if (!server) return {kind: 'missing', servers};
|
|
95
|
+
if (!hasUsableCredentials(server)) return {kind: 'no-credentials', server};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return {kind: 'ok', server, value: await action(server), retried: false};
|
|
99
|
+
} catch {
|
|
100
|
+
// 凭据可能刚在 IDE 里改过:刷新后重试一次,仍失败才把错误交出去。
|
|
101
|
+
const refreshed = await load({force: true}).catch(() => servers);
|
|
102
|
+
const retry = findServer(refreshed, name);
|
|
103
|
+
if (!retry) return {kind: 'missing', servers: refreshed};
|
|
104
|
+
if (!hasUsableCredentials(retry)) return {kind: 'no-credentials', server: retry};
|
|
105
|
+
try {
|
|
106
|
+
return {kind: 'ok', server: retry, value: await action(retry), retried: true};
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return {kind: 'failed', server: retry, error};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 找一条 deployment(按名字全局唯一)。
|
|
115
|
+
*
|
|
116
|
+
* @param {string} name
|
|
117
|
+
*/
|
|
118
|
+
const findDeployment = async (name) => {
|
|
119
|
+
let deployments = collectDeployments(await load());
|
|
120
|
+
let target = deployments.find((entry) => entry.name === name);
|
|
121
|
+
if (!target) {
|
|
122
|
+
deployments = collectDeployments(await load({force: true}));
|
|
123
|
+
target = deployments.find((entry) => entry.name === name);
|
|
124
|
+
}
|
|
125
|
+
return {target, deployments};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 执行一条远程命令。
|
|
130
|
+
*
|
|
131
|
+
* @param {string} name
|
|
132
|
+
* @param {string} command
|
|
133
|
+
* @param {string | undefined} workingDirectory
|
|
134
|
+
*/
|
|
135
|
+
const exec = async (name, command, workingDirectory) => {
|
|
136
|
+
const outcome = await withServer(name, (server) => runRemoteCommand(server, command, {workingDirectory}));
|
|
137
|
+
if (outcome.kind !== 'ok') return outcome;
|
|
138
|
+
return {kind: 'ok', server: outcome.server, result: outcome.value, retried: outcome.retried};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 按 mapping 同步一个目录。`direction` 决定哪边是源。
|
|
143
|
+
*
|
|
144
|
+
* @param {string} name
|
|
145
|
+
* @param {'overwrite' | 'mirror'} mode
|
|
146
|
+
* @param {'upload' | 'download'} direction
|
|
147
|
+
*/
|
|
148
|
+
const sync = async (name, mode, direction) => {
|
|
149
|
+
const {target, deployments} = await findDeployment(name);
|
|
150
|
+
if (!target) return {kind: 'missing', deployments};
|
|
151
|
+
|
|
152
|
+
const outcome = await withServer(target.server.name, async (server) => {
|
|
153
|
+
const conn = await connect(server);
|
|
154
|
+
try {
|
|
155
|
+
const sftp = await openSftp(conn);
|
|
156
|
+
return direction === 'upload'
|
|
157
|
+
? await uploadDirectory(sftp, target.localPath, target.deployPath, mode)
|
|
158
|
+
: await downloadDirectory(sftp, target.deployPath, target.localPath, mode);
|
|
159
|
+
} finally {
|
|
160
|
+
conn.end();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
if (outcome.kind !== 'ok') return outcome;
|
|
164
|
+
return {kind: 'ok', deployment: target, stats: outcome.value, retried: outcome.retried};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
load,
|
|
169
|
+
exec,
|
|
170
|
+
upload: (name, mode) => sync(name, mode, 'upload'),
|
|
171
|
+
download: (name, mode) => sync(name, mode, 'download'),
|
|
172
|
+
/** 最近一次 IDE 读取失败的原因;成功时为 undefined。 */
|
|
173
|
+
ideError: () => lastIdeError,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `remote_exec` 的工具定义。
|
|
179
|
+
*
|
|
180
|
+
* @param {ReturnType<typeof createRemoteToolset>} toolset
|
|
181
|
+
*/
|
|
182
|
+
export function remoteExecTool(toolset) {
|
|
183
|
+
return {
|
|
184
|
+
name: 'remote_exec',
|
|
185
|
+
description:
|
|
186
|
+
'用户说“去某台机器上执行 / 重启 / 看日志”、或提到部署机 / 开发机 / 测试机时用它 —— 不要自己拼 ssh / scp。' +
|
|
187
|
+
'在远程服务器上执行一条命令,返回输出与退出码。' +
|
|
188
|
+
'每条命令是一条独立 SSH 连接:cd / export 不会跨命令保留,需要目录就传 workingDirectory。' +
|
|
189
|
+
'传错服务器名时会返回全部可用服务器,按返回的列表重新调用即可。',
|
|
190
|
+
input: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
server: {
|
|
194
|
+
type: 'string',
|
|
195
|
+
description: '服务器名,来自 remote_list_servers 或上一次报错里列出的候选。',
|
|
196
|
+
},
|
|
197
|
+
command: {type: 'string', description: '要执行的 shell 命令。'},
|
|
198
|
+
workingDirectory: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
description: '可选:先 cd 到这个远程目录再执行(支持 ~ 与绝对路径)。',
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
required: ['server', 'command'],
|
|
204
|
+
},
|
|
205
|
+
execute: async (args) => {
|
|
206
|
+
const server = typeof args?.server === 'string' ? args.server.trim() : '';
|
|
207
|
+
const command = typeof args?.command === 'string' ? args.command : '';
|
|
208
|
+
if (!server) return {content: 'server 不能为空。'};
|
|
209
|
+
if (!command.trim()) return {content: 'command 不能为空。'};
|
|
210
|
+
const workingDirectory = typeof args?.workingDirectory === 'string' ? args.workingDirectory : undefined;
|
|
211
|
+
|
|
212
|
+
const outcome = await toolset.exec(server, command, workingDirectory);
|
|
213
|
+
switch (outcome.kind) {
|
|
214
|
+
case 'missing': {
|
|
215
|
+
const ideError = toolset.ideError();
|
|
216
|
+
const prefix = ideError
|
|
217
|
+
? `IDE 的 Deployment 配置有问题,这次只用了项目 / 全局的配置:\n${ideError.message}\n\n`
|
|
218
|
+
: '';
|
|
219
|
+
return {content: prefix + serverNotFoundText(outcome.servers, server)};
|
|
220
|
+
}
|
|
221
|
+
case 'no-credentials':
|
|
222
|
+
return {content: noCredentialsText(outcome.server)};
|
|
223
|
+
case 'failed':
|
|
224
|
+
return {
|
|
225
|
+
content:
|
|
226
|
+
`[${outcome.server.name}] 执行失败\n` +
|
|
227
|
+
describeSshError(outcome.error, outcome.server),
|
|
228
|
+
};
|
|
229
|
+
default:
|
|
230
|
+
return {content: formatRunSummary(outcome.server, outcome.result, outcome.retried === true)};
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* `remote_list_servers`:服务器 + 每台机器上挂的 deployment。
|
|
238
|
+
*
|
|
239
|
+
* @param {ReturnType<typeof createRemoteToolset>} toolset
|
|
240
|
+
*/
|
|
241
|
+
export function remoteListServersTool(toolset) {
|
|
242
|
+
return {
|
|
243
|
+
name: 'remote_list_servers',
|
|
244
|
+
description:
|
|
245
|
+
'列出当前可用的远程服务器与它们上面的路径映射,带 [IDE] / [项目] / [全局] 来源标记。' +
|
|
246
|
+
'不确定服务器名或 deployment 名时先调它。',
|
|
247
|
+
input: {type: 'object', properties: {}, required: []},
|
|
248
|
+
execute: async () => {
|
|
249
|
+
const servers = await toolset.load();
|
|
250
|
+
/** @type {string[]} */
|
|
251
|
+
const lines = [];
|
|
252
|
+
const ideError = toolset.ideError();
|
|
253
|
+
if (ideError) {
|
|
254
|
+
lines.push('⚠ IDE 的 Deployment 配置有问题,下面只列出项目 / 全局配置里的服务器:');
|
|
255
|
+
lines.push(ideError.message);
|
|
256
|
+
lines.push('');
|
|
257
|
+
}
|
|
258
|
+
if (servers.length === 0) {
|
|
259
|
+
lines.push('当前没有可用的远程服务器。在 IDE 的 Deployment 里添加 SFTP 服务器,或配置 hosts.json。');
|
|
260
|
+
return {content: lines.join('\n')};
|
|
261
|
+
}
|
|
262
|
+
for (const entry of servers) {
|
|
263
|
+
const source = entry.source === 'ide' ? 'IDE' : entry.source === 'project' ? '项目' : '全局';
|
|
264
|
+
// 认证方式也报出来:OPEN_SSH 走 agent,不需要密码;KEY_PAIR 看私钥。
|
|
265
|
+
const auth = entry.authType ? ` ${entry.authType}` : '';
|
|
266
|
+
const label = entry.alias ? `${entry.name} (${entry.alias})` : entry.name;
|
|
267
|
+
lines.push(`- ${label} ${entry.host}:${entry.port} ${entry.username} [${source}]${auth}`);
|
|
268
|
+
for (const deployment of entry.deployments ?? []) {
|
|
269
|
+
lines.push(` ${deployment.name} ${deployment.localPath} ↔ ${deployment.deployPath}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return {content: lines.join('\n')};
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* `remote_upload` / `remote_download` 的共用定义。
|
|
279
|
+
*
|
|
280
|
+
* @param {ReturnType<typeof createRemoteToolset>} toolset
|
|
281
|
+
* @param {'upload' | 'download'} direction
|
|
282
|
+
*/
|
|
283
|
+
function transferTool(toolset, direction) {
|
|
284
|
+
const isUpload = direction === 'upload';
|
|
285
|
+
return {
|
|
286
|
+
name: isUpload ? 'remote_upload' : 'remote_download',
|
|
287
|
+
description: isUpload
|
|
288
|
+
? '把一条 deployment 的本地目录上传到它的远程目录。默认 overwrite(只覆盖/新增,远程多余文件保留);' +
|
|
289
|
+
'mode=mirror 时还会删掉远程多出来的文件。'
|
|
290
|
+
: '把一条 deployment 的远程目录拉回本地目录。默认 overwrite(只覆盖/新增,本地多余文件保留);' +
|
|
291
|
+
'mode=mirror 时还会删掉本地多出来的文件。',
|
|
292
|
+
input: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
deployment: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
description: 'deployment 名,来自 remote_list_servers 里列出的那条。',
|
|
298
|
+
},
|
|
299
|
+
mode: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
enum: ['overwrite', 'mirror'],
|
|
302
|
+
description: '默认 overwrite;mirror 会删除目标端多出来的文件(不可逆)。',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
required: ['deployment'],
|
|
306
|
+
},
|
|
307
|
+
execute: async (args) => {
|
|
308
|
+
const name = typeof args?.deployment === 'string' ? args.deployment.trim() : '';
|
|
309
|
+
if (!name) return {content: 'deployment 不能为空。'};
|
|
310
|
+
const mode = args?.mode === 'mirror' ? 'mirror' : 'overwrite';
|
|
311
|
+
|
|
312
|
+
const outcome = isUpload ? await toolset.upload(name, mode) : await toolset.download(name, mode);
|
|
313
|
+
switch (outcome.kind) {
|
|
314
|
+
case 'missing':
|
|
315
|
+
return {content: deploymentNotFoundText(outcome.deployments, name)};
|
|
316
|
+
case 'no-credentials':
|
|
317
|
+
return {content: noCredentialsText(outcome.server)};
|
|
318
|
+
case 'failed':
|
|
319
|
+
return {
|
|
320
|
+
content:
|
|
321
|
+
`[${outcome.server.name}] 传输失败\n` +
|
|
322
|
+
describeSshError(outcome.error, outcome.server),
|
|
323
|
+
};
|
|
324
|
+
default: {
|
|
325
|
+
const verb = isUpload ? '上传' : '下载';
|
|
326
|
+
const stats = outcome.stats;
|
|
327
|
+
const head = `[${outcome.deployment.name}] ${verb}完成 (${mode})`;
|
|
328
|
+
const lines = [
|
|
329
|
+
`${stats.files} files, ${stats.dirs} dirs, ${formatBytes(stats.bytes)}`,
|
|
330
|
+
];
|
|
331
|
+
if (stats.skipped > 0) lines.push(`skipped (symlink/other): ${stats.skipped}`);
|
|
332
|
+
if (stats.deleted > 0) lines.push(`deleted: ${stats.deleted}`);
|
|
333
|
+
if (outcome.retried) lines.push('retried after refreshing credentials');
|
|
334
|
+
return {content: `${head}\n${lines.join('\n')}`};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @param {ReturnType<typeof createRemoteToolset>} toolset
|
|
343
|
+
*/
|
|
344
|
+
export function remoteUploadTool(toolset) {
|
|
345
|
+
return transferTool(toolset, 'upload');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {ReturnType<typeof createRemoteToolset>} toolset
|
|
350
|
+
*/
|
|
351
|
+
export function remoteDownloadTool(toolset) {
|
|
352
|
+
return transferTool(toolset, 'download');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 一次执行的结果 → 模型可读的文本。
|
|
357
|
+
*
|
|
358
|
+
* @param {import('./hosts.js').RemoteServer} server
|
|
359
|
+
* @param {{ exitCode: number | null, stdout: string, stderr: string }} result
|
|
360
|
+
* @param {boolean} retried
|
|
361
|
+
*/
|
|
362
|
+
export function formatRunSummary(server, result, retried = false) {
|
|
363
|
+
const head = [`[${server.name}] exitCode ${result.exitCode ?? 'null'}`];
|
|
364
|
+
if (retried) head.push('retried');
|
|
365
|
+
const body = [result.stdout.trimEnd()];
|
|
366
|
+
if (result.stderr.trim()) body.push(`[stderr]\n${result.stderr.trimEnd()}`);
|
|
367
|
+
return `${head.join(', ')}\n${body.filter(Boolean).join('\n')}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 这台能不能连:PASSWORD 看密码,KEY_PAIR 看私钥,OPEN_SSH 靠 agent(不需要本地字段)。
|
|
372
|
+
*
|
|
373
|
+
* @param {import('./hosts.js').RemoteServer} server
|
|
374
|
+
*/
|
|
375
|
+
export function hasUsableCredentials(server) {
|
|
376
|
+
if (server.authType === 'OPEN_SSH') return true;
|
|
377
|
+
return Boolean(server.password || server.privateKeyPath);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 缺凭据时给模型 / 用户的说明,按认证方式区分该去改哪里。 */
|
|
381
|
+
function noCredentialsText(server) {
|
|
382
|
+
if (server.authType === 'KEY_PAIR') {
|
|
383
|
+
return `服务器 "${server.name}" 用私钥认证,但没有私钥路径。在 IDE 的 Deployment 设置里选一次私钥文件。`;
|
|
384
|
+
}
|
|
385
|
+
if (server.authType && server.authType !== 'PASSWORD' && server.authType !== 'OPEN_SSH') {
|
|
386
|
+
return `服务器 "${server.name}" 的认证方式 ${server.authType} 暂不支持。`;
|
|
387
|
+
}
|
|
388
|
+
return `服务器 "${server.name}" 用密码认证,但没有密码。在 IDE 的 Deployment 设置里重新选一次 SSH 配置(` +
|
|
389
|
+
`引用的那条可能已被删除),或在 hosts.json 里直接填 password。`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @param {number} bytes */
|
|
393
|
+
export function formatBytes(bytes) {
|
|
394
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
395
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
396
|
+
let value = bytes;
|
|
397
|
+
let unit = 0;
|
|
398
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
399
|
+
value /= 1024;
|
|
400
|
+
unit += 1;
|
|
401
|
+
}
|
|
402
|
+
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
403
|
+
}
|