opencode-idea 0.1.4 → 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 +120 -83
- package/package.json +6 -1
- package/src/ide-guidance.js +32 -0
- package/src/ide-guidance.template.md +6 -100
- 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,238 @@
|
|
|
1
|
+
// remote_exec 的编排测试:缓存、未命中全量刷新、IDE 不可用时的降级。
|
|
2
|
+
//
|
|
3
|
+
// 真正的 SSH 连接不在单测里跑 —— 这里只验证"用哪一份服务器列表"。
|
|
4
|
+
|
|
5
|
+
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
|
6
|
+
import {tmpdir} from 'node:os';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
|
|
9
|
+
import {afterAll, describe, expect, it} from 'vitest';
|
|
10
|
+
|
|
11
|
+
import {createRemoteToolset, remoteDownloadTool, remoteExecTool, remoteListServersTool, remoteUploadTool} from './tools.js';
|
|
12
|
+
|
|
13
|
+
const workDir = mkdtempSync(join(tmpdir(), 'remote-tools-'));
|
|
14
|
+
afterAll(() => rmSync(workDir, {recursive: true, force: true}));
|
|
15
|
+
|
|
16
|
+
/** 准备一个项目,带可选的 `.opencode/remote-shell/hosts.json`。 */
|
|
17
|
+
function projectWith(options = {}) {
|
|
18
|
+
const root = mkdtempSync(join(workDir, 'project-'));
|
|
19
|
+
if (options.projectHosts) {
|
|
20
|
+
const dir = join(root, '.opencode', 'remote-shell');
|
|
21
|
+
mkdirSync(dir, {recursive: true});
|
|
22
|
+
writeFileSync(join(dir, 'hosts.json'), JSON.stringify({hosts: options.projectHosts}));
|
|
23
|
+
}
|
|
24
|
+
const globalPath = join(workDir, `global-${Math.random().toString(36).slice(2)}.json`);
|
|
25
|
+
if (options.globalHosts) {
|
|
26
|
+
writeFileSync(globalPath, JSON.stringify({hosts: options.globalHosts}));
|
|
27
|
+
}
|
|
28
|
+
return {root, globalPath};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ideServer = (name, overrides = {}) => ({
|
|
32
|
+
name,
|
|
33
|
+
host: '10.0.0.1',
|
|
34
|
+
port: 22,
|
|
35
|
+
username: 'root',
|
|
36
|
+
password: 'p',
|
|
37
|
+
source: 'ide',
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('createRemoteToolset', () => {
|
|
42
|
+
it('缓存窗口内不重复打 IDE', async () => {
|
|
43
|
+
const {root, globalPath} = projectWith();
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const toolset = createRemoteToolset({
|
|
46
|
+
projectPath: root,
|
|
47
|
+
globalHostsPath: globalPath,
|
|
48
|
+
fetchIdeServers: async () => {
|
|
49
|
+
calls += 1;
|
|
50
|
+
return [ideServer('a')];
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await toolset.load();
|
|
55
|
+
await toolset.load();
|
|
56
|
+
expect(calls).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('IDE 不可用时退到项目级 / 全局,且项目级覆盖全局', async () => {
|
|
60
|
+
const {root, globalPath} = projectWith({
|
|
61
|
+
globalHosts: {a: {host: 'global-host', username: 'u'}, b: {host: 'global-only', username: 'u'}},
|
|
62
|
+
projectHosts: {a: {host: 'project-host', username: 'u'}},
|
|
63
|
+
});
|
|
64
|
+
const toolset = createRemoteToolset({
|
|
65
|
+
projectPath: root,
|
|
66
|
+
globalHostsPath: globalPath,
|
|
67
|
+
fetchIdeServers: async () => {
|
|
68
|
+
throw new Error('IDE down');
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const servers = await toolset.load();
|
|
73
|
+
expect(servers.map((entry) => `${entry.name}:${entry.host}:${entry.source}`)).toEqual([
|
|
74
|
+
'a:project-host:project',
|
|
75
|
+
'b:global-only:global',
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('三层都只有 mapping 的条目会被丢掉(没有连接信息)', async () => {
|
|
80
|
+
const {root, globalPath} = projectWith({
|
|
81
|
+
projectHosts: {ghost: {deployments: [{name: 'app', localPath: '/l', deployPath: '/r'}]}},
|
|
82
|
+
});
|
|
83
|
+
const toolset = createRemoteToolset({
|
|
84
|
+
projectPath: root,
|
|
85
|
+
globalHostsPath: globalPath,
|
|
86
|
+
fetchIdeServers: async () => [],
|
|
87
|
+
});
|
|
88
|
+
expect(await toolset.load()).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('未命中会强制全量刷新(不是只查那个 key)', async () => {
|
|
92
|
+
const {root, globalPath} = projectWith();
|
|
93
|
+
let calls = 0;
|
|
94
|
+
const toolset = createRemoteToolset({
|
|
95
|
+
projectPath: root,
|
|
96
|
+
globalHostsPath: globalPath,
|
|
97
|
+
fetchIdeServers: async () => {
|
|
98
|
+
calls += 1;
|
|
99
|
+
// 第一次只有 a;第二次新增了 deploy1 —— 模拟"用户在 IDE 里刚加完"。
|
|
100
|
+
// 新条目故意不带密码:这样 exec 走到 no-credentials 就停,不会真的连 SSH。
|
|
101
|
+
return calls === 1
|
|
102
|
+
? [ideServer('a')]
|
|
103
|
+
: [ideServer('a'), ideServer('deploy1', {password: undefined})];
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const outcome = await toolset.exec('deploy1', 'true');
|
|
108
|
+
expect(calls).toBe(2);
|
|
109
|
+
expect(outcome.kind).toBe('no-credentials');
|
|
110
|
+
}, 20000);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('工具定义', () => {
|
|
114
|
+
it('remote_exec / remote_list_servers 的 schema', () => {
|
|
115
|
+
const toolset = createRemoteToolset({
|
|
116
|
+
projectPath: workDir,
|
|
117
|
+
globalHostsPath: join(workDir, 'none.json'),
|
|
118
|
+
fetchIdeServers: async () => [],
|
|
119
|
+
});
|
|
120
|
+
const exec = remoteExecTool(toolset);
|
|
121
|
+
expect(exec.name).toBe('remote_exec');
|
|
122
|
+
expect(exec.input.required).toEqual(['server', 'command']);
|
|
123
|
+
|
|
124
|
+
const list = remoteListServersTool(toolset);
|
|
125
|
+
expect(list.name).toBe('remote_list_servers');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('名字传错时返回全部候选,而不是一句"不存在"', async () => {
|
|
129
|
+
const {root, globalPath} = projectWith({globalHosts: {5090: {host: 'h', username: 'u'}}});
|
|
130
|
+
const toolset = createRemoteToolset({
|
|
131
|
+
projectPath: root,
|
|
132
|
+
globalHostsPath: globalPath,
|
|
133
|
+
fetchIdeServers: async () => [ideServer('deploy1')],
|
|
134
|
+
});
|
|
135
|
+
const exec = remoteExecTool(toolset);
|
|
136
|
+
const {content} = await exec.execute({server: 'dev1', command: 'ls'});
|
|
137
|
+
expect(content).toContain('没有名为 "dev1" 的服务器');
|
|
138
|
+
expect(content).toContain('deploy1');
|
|
139
|
+
expect(content).toContain('5090');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('文件同步工具', () => {
|
|
144
|
+
it('deployment 名找不到时列出全部候选', async () => {
|
|
145
|
+
const {root, globalPath} = projectWith({
|
|
146
|
+
globalHosts: {
|
|
147
|
+
dev: {
|
|
148
|
+
host: 'h',
|
|
149
|
+
username: 'u',
|
|
150
|
+
password: 'p',
|
|
151
|
+
deployments: [{name: 'app', localPath: '/local/app', deployPath: '/srv/app'}],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const toolset = createRemoteToolset({
|
|
156
|
+
projectPath: root,
|
|
157
|
+
globalHostsPath: globalPath,
|
|
158
|
+
fetchIdeServers: async () => [],
|
|
159
|
+
});
|
|
160
|
+
const {content} = await remoteUploadTool(toolset).execute({deployment: 'api'});
|
|
161
|
+
expect(content).toContain('没有名为 "api" 的 deployment');
|
|
162
|
+
expect(content).toContain('app /local/app ↔ dev:/srv/app');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('mapping 名对但没凭据时报错,不尝试连接', async () => {
|
|
166
|
+
const {root, globalPath} = projectWith({
|
|
167
|
+
globalHosts: {
|
|
168
|
+
dev: {
|
|
169
|
+
host: 'h',
|
|
170
|
+
username: 'u',
|
|
171
|
+
deployments: [{name: 'app', localPath: '/local/app', deployPath: '/srv/app'}],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const toolset = createRemoteToolset({
|
|
176
|
+
projectPath: root,
|
|
177
|
+
globalHostsPath: globalPath,
|
|
178
|
+
fetchIdeServers: async () => [],
|
|
179
|
+
});
|
|
180
|
+
const {content} = await remoteDownloadTool(toolset).execute({deployment: 'app'});
|
|
181
|
+
expect(content).toContain('用密码认证,但没有密码');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('remote_list_servers 带出 mapping', async () => {
|
|
185
|
+
const {root, globalPath} = projectWith({
|
|
186
|
+
globalHosts: {
|
|
187
|
+
dev: {
|
|
188
|
+
host: 'h',
|
|
189
|
+
username: 'u',
|
|
190
|
+
password: 'p',
|
|
191
|
+
deployments: [{name: 'app', localPath: '/local/app', deployPath: '/srv/app'}],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
const toolset = createRemoteToolset({
|
|
196
|
+
projectPath: root,
|
|
197
|
+
globalHostsPath: globalPath,
|
|
198
|
+
fetchIdeServers: async () => [],
|
|
199
|
+
});
|
|
200
|
+
const {content} = await remoteListServersTool(toolset).execute({});
|
|
201
|
+
expect(content).toContain('- dev h:22 u [全局]');
|
|
202
|
+
expect(content).toContain('app /local/app ↔ /srv/app');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('认证方式', () => {
|
|
207
|
+
it('三种方式各自判断能不能连', async () => {
|
|
208
|
+
const {hasUsableCredentials} = await import('./tools.js');
|
|
209
|
+
expect(hasUsableCredentials({authType: 'PASSWORD', password: 'p'})).toBe(true);
|
|
210
|
+
expect(hasUsableCredentials({authType: 'PASSWORD'})).toBe(false);
|
|
211
|
+
expect(hasUsableCredentials({authType: 'KEY_PAIR', privateKeyPath: '/k'})).toBe(true);
|
|
212
|
+
expect(hasUsableCredentials({authType: 'KEY_PAIR'})).toBe(false);
|
|
213
|
+
// OpenSSH 靠 agent,本地没有字段也是可用的
|
|
214
|
+
expect(hasUsableCredentials({authType: 'OPEN_SSH'})).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('配置好的 KEY_PAIR 不会被当成缺凭据', async () => {
|
|
218
|
+
const {root, globalPath} = projectWith({
|
|
219
|
+
globalHosts: {
|
|
220
|
+
dev: {
|
|
221
|
+
host: 'h',
|
|
222
|
+
username: 'u',
|
|
223
|
+
authType: 'KEY_PAIR',
|
|
224
|
+
privateKeyPath: '/nonexistent/key',
|
|
225
|
+
deployments: [{name: 'app', localPath: '/l', deployPath: '/r'}],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const toolset = createRemoteToolset({
|
|
230
|
+
projectPath: root,
|
|
231
|
+
globalHostsPath: globalPath,
|
|
232
|
+
fetchIdeServers: async () => [],
|
|
233
|
+
});
|
|
234
|
+
// 会去连 SSH(私钥不存在 → 读文件失败),但错误不是"没有凭据"
|
|
235
|
+
const {content} = await remoteUploadTool(toolset).execute({deployment: 'app'});
|
|
236
|
+
expect(content).not.toContain('没有可用于 SSH 的凭据');
|
|
237
|
+
});
|
|
238
|
+
});
|