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.
@@ -0,0 +1,269 @@
1
+ // 远程服务器的三级来源:IDE → 项目 `.opencode/remote-shell/hosts.json` → 全局 `~/.config/remote-shell/hosts.json`。
2
+ //
3
+ // 合并规则(同名以高优先级为准):
4
+ //
5
+ // global(最低) → project → IDE(最高)
6
+ //
7
+ // 这个文件只做**纯数据**的事:读文件、校验形状、合并。调 IDE 取凭据在 `plugin.js`
8
+ // 里(那里才拿得到端口和项目路径),SSH 连接在 `ssh.js` 里。
9
+
10
+ import {existsSync, readFileSync} from 'node:fs';
11
+ import {homedir} from 'node:os';
12
+ import {join} from 'node:path';
13
+
14
+ /** 全局配置。保留 remote-shell 的旧路径,用户的 hosts.json 不用搬。 */
15
+ export const GLOBAL_HOSTS_PATH = join(homedir(), '.config', 'remote-shell', 'hosts.json');
16
+
17
+ /** 项目级配置:与全局同名同格式,跟着仓库走。 */
18
+ export function projectHostsPath(projectPath) {
19
+ return join(projectPath, '.opencode', 'remote-shell', 'hosts.json');
20
+ }
21
+
22
+ /**
23
+ * @typedef {{
24
+ * name: string,
25
+ * alias?: string,
26
+ * host?: string,
27
+ * port?: number,
28
+ * username?: string,
29
+ * authType?: string,
30
+ * password?: string,
31
+ * passphrase?: string,
32
+ * privateKeyPath?: string,
33
+ * deployments: Array<{ name: string, localPath: string, deployPath: string }>,
34
+ * source: 'ide' | 'project' | 'global',
35
+ * }} RemoteServer
36
+ */
37
+
38
+ /**
39
+ * 读一个 hosts.json,返回服务器列表;文件不存在 / 内容坏 / 字段不全时返回空数组。
40
+ *
41
+ * `source` 由调用方给:同一个文件在"项目级"和"全局"两个位置读时,条目要能区分。
42
+ *
43
+ * @param {string} path
44
+ * @param {'ide' | 'project' | 'global'} source
45
+ * @returns {RemoteServer[]}
46
+ */
47
+ export function readHostsFile(path, source) {
48
+ if (!existsSync(path)) return [];
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
52
+ } catch {
53
+ // 配置坏了不该让整个 remote_exec 不可用;跳过这一层,其它层照常合并。
54
+ return [];
55
+ }
56
+ const hosts = parsed?.hosts;
57
+ if (!hosts || typeof hosts !== 'object') return [];
58
+
59
+ const servers = [];
60
+ for (const [name, entry] of Object.entries(hosts)) {
61
+ const server = toServer(name, entry, source);
62
+ if (server) servers.push(server);
63
+ }
64
+ return servers;
65
+ }
66
+
67
+ /**
68
+ * 一条 hosts.json 条目 → RemoteServer;缺 host 或 username 的直接丢掉。
69
+ *
70
+ * @param {string} name
71
+ * @param {unknown} entry
72
+ * @param {'ide' | 'project' | 'global'} source
73
+ * @returns {RemoteServer | undefined}
74
+ */
75
+ function toServer(name, entry, source) {
76
+ if (!entry || typeof entry !== 'object') return undefined;
77
+ const record = /** @type {Record<string, unknown>} */ (entry);
78
+ const host = typeof record.host === 'string' ? record.host.trim() : '';
79
+ const username = typeof record.username === 'string' ? record.username.trim() : '';
80
+ const deployments = parseDeployments(record.deployments);
81
+ // 允许"只补 mapping"的条目:没有 host、但带了 deployments 时也算一条补丁,合并时由
82
+ // 低优先级层补上连接信息。没有 host 又没有 mapping 的条目没有任何用途(单独一个 username
83
+ // 既连不上也补不了东西),直接丢掉。三层都只有补丁的条目会在 finalize 时再过滤一次。
84
+ if (!host && deployments.length === 0) return undefined;
85
+
86
+ /** @type {RemoteServer} */
87
+ const server = {name, source, deployments};
88
+ const alias = stringOrUndefined(record.alias);
89
+ if (alias && alias !== name) server.alias = alias;
90
+ if (host) server.host = host;
91
+ if (username) server.username = username;
92
+ if (typeof record.port === 'number' && Number.isFinite(record.port) && record.port > 0) {
93
+ server.port = record.port;
94
+ }
95
+ const authType = stringOrUndefined(record.authType);
96
+ if (authType) server.authType = authType;
97
+ const password = stringOrUndefined(record.password);
98
+ if (password) server.password = password;
99
+ const passphrase = stringOrUndefined(record.passphrase);
100
+ if (passphrase) server.passphrase = passphrase;
101
+ const privateKeyPath = stringOrUndefined(record.privateKeyPath);
102
+ if (privateKeyPath) server.privateKeyPath = privateKeyPath;
103
+ return server;
104
+ }
105
+
106
+ /** @param {unknown} value */
107
+ function stringOrUndefined(value) {
108
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
109
+ }
110
+
111
+ /**
112
+ * 解析一个 server 上的路径映射。缺 name / localPath / deployPath 的条目直接丢掉 ——
113
+ * 少了任何一头都传不了。
114
+ *
115
+ * @param {unknown} raw
116
+ * @returns {Array<{ name: string, localPath: string, deployPath: string }>}
117
+ */
118
+ function parseDeployments(raw) {
119
+ if (!Array.isArray(raw)) return [];
120
+ return raw.flatMap((entry) => {
121
+ if (!entry || typeof entry !== 'object') return [];
122
+ const record = /** @type {Record<string, unknown>} */ (entry);
123
+ const name = typeof record.name === 'string' ? record.name : '';
124
+ const localPath = typeof record.localPath === 'string' ? record.localPath : '';
125
+ const deployPath = typeof record.deployPath === 'string' ? record.deployPath : '';
126
+ if (!name || !localPath || !deployPath) return [];
127
+ return [{name, localPath, deployPath}];
128
+ });
129
+ }
130
+
131
+ /**
132
+ * 把合并后的 server 列表摊平成 deployment 表(按 name 全局唯一)。
133
+ *
134
+ * `server` 挂在每条 mapping 上:上传 / 下载既要知道两端目录,也要知道连哪台机器。
135
+ *
136
+ * @param {ReadonlyArray<RemoteServer>} servers
137
+ * @returns {Array<{ name: string, localPath: string, deployPath: string, server: RemoteServer }>}
138
+ */
139
+ export function collectDeployments(servers) {
140
+ const byName = new Map();
141
+ for (const server of servers) {
142
+ for (const deployment of server.deployments ?? []) {
143
+ byName.set(deployment.name, {...deployment, server});
144
+ }
145
+ }
146
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
147
+ }
148
+
149
+ /**
150
+ * 按 name 合并多层服务器。**后传的层覆盖先传的层**。
151
+ *
152
+ * 调用方按 `[global, project, ide]` 的顺序传,就是"全局 < 项目 < IDE"。
153
+ *
154
+ * @param {ReadonlyArray<ReadonlyArray<RemoteServer>>} layers
155
+ * @returns {RemoteServer[]}
156
+ */
157
+ export function mergeServers(layers) {
158
+ const byName = new Map();
159
+ for (const layer of layers) {
160
+ for (const server of layer) {
161
+ const existing = byName.get(server.name);
162
+ byName.set(server.name, existing ? mergeServer(existing, server) : server);
163
+ }
164
+ }
165
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
166
+ }
167
+
168
+ /**
169
+ * 两层的同一条 server 合并:高优先级的字段覆盖,没提供的保留。
170
+ *
171
+ * **不是整条替换**,理由是常见的配置方式:项目级只想补一条 deployment,不该被迫把
172
+ * 密码再抄一遍;反过来 IDE 里改了 host 也不该把项目级补的 mapping 冲掉。
173
+ *
174
+ * 凭据字段例外:只要高优先级层给了 `authType`,就以它的凭据为准(KEY_PAIR 必须能清掉
175
+ * 低优先级层的 password,否则会带着一个不该用的密码去连接)。
176
+ *
177
+ * @param {RemoteServer} base 低优先级
178
+ * @param {RemoteServer} override 高优先级
179
+ * @returns {RemoteServer}
180
+ */
181
+ function mergeServer(base, override) {
182
+ /** @type {RemoteServer} */
183
+ const merged = {...base};
184
+ for (const [key, value] of Object.entries(override)) {
185
+ if (value === undefined) continue;
186
+ if (key === 'deployments' || key === 'authType') continue;
187
+ // @ts-expect-error 键来自同一个结构
188
+ merged[key] = value;
189
+ }
190
+
191
+ if (override.authType) {
192
+ merged.authType = override.authType;
193
+ merged.password = override.password;
194
+ merged.passphrase = override.passphrase;
195
+ merged.privateKeyPath = override.privateKeyPath;
196
+ }
197
+
198
+ merged.deployments = mergeDeployments(base.deployments ?? [], override.deployments ?? []);
199
+ return merged;
200
+ }
201
+
202
+ /**
203
+ * deployment 按 name 合并;高优先级层没配任何 mapping 时保留低优先级的。
204
+ *
205
+ * @param {Array<{ name: string, localPath: string, deployPath: string }>} base
206
+ * @param {Array<{ name: string, localPath: string, deployPath: string }>} override
207
+ */
208
+ function mergeDeployments(base, override) {
209
+ if (override.length === 0) return base;
210
+ const byName = new Map(base.map((deployment) => [deployment.name, deployment]));
211
+ for (const deployment of override) byName.set(deployment.name, deployment);
212
+ return [...byName.values()];
213
+ }
214
+
215
+ /**
216
+ * 精确按名字找。**不做模糊匹配**:猜错了比列出来更糟,让模型看着全量列表自己选。
217
+ *
218
+ * @param {ReadonlyArray<RemoteServer>} servers
219
+ * @param {string} name
220
+ */
221
+ export function findServer(servers, name) {
222
+ // 旧 remote-shell 的 `findHost` 优先按 alias 匹配;这里保持一致,现有配置里的
223
+ // `"alias": "远程开发机"` 可以继续用。name 命中优先于 alias。
224
+ return servers.find((server) => server.name === name)
225
+ ?? servers.find((server) => server.alias === name);
226
+ }
227
+
228
+ /**
229
+ * 找不到 deployment 时给模型的文本:列出全部候选(名字 / 两端目录 / 所属服务器)。
230
+ *
231
+ * @param {ReturnType<typeof collectDeployments>} deployments
232
+ * @param {string} wanted
233
+ */
234
+ export function deploymentNotFoundText(deployments, wanted) {
235
+ if (deployments.length === 0) {
236
+ return `没有名为 "${wanted}" 的 deployment,当前也没有任何路径映射。` +
237
+ `在 IDE 的 Deployment 里配置,或在 hosts.json 的 host 条目下加 deployments。`;
238
+ }
239
+ const lines = deployments.map((deployment) =>
240
+ `- ${deployment.name} ${deployment.localPath} ↔ ${deployment.server.name}:${deployment.deployPath}`);
241
+ return [
242
+ `没有名为 "${wanted}" 的 deployment。当前全部可用:`,
243
+ '',
244
+ ...lines,
245
+ '',
246
+ '如果指的是其中一条,用它的名字重新调用。',
247
+ ].join('\n');
248
+ }
249
+
250
+ /**
251
+ * 找不到服务器时给模型的文本:列出全部候选(名字 / 地址 / 用户 / 来源),绝不带凭据。
252
+ *
253
+ * @param {ReadonlyArray<RemoteServer>} servers
254
+ * @param {string} wanted
255
+ */
256
+ export function serverNotFoundText(servers, wanted) {
257
+ const lines = servers.map((server) => {
258
+ const source = server.source === 'ide' ? 'IDE' : server.source === 'project' ? '项目' : '全局';
259
+ const label = server.alias ? `${server.name} (${server.alias})` : server.name;
260
+ return `- ${label} ${server.host}:${server.port} ${server.username} [${source}]`;
261
+ });
262
+ return [
263
+ `没有名为 "${wanted}" 的服务器。当前全部可用(IDE → 项目 → 全局,同名以高优先级为准):`,
264
+ '',
265
+ ...lines,
266
+ '',
267
+ '如果指的是其中某一台,用它的名字重新调用。',
268
+ ].join('\n');
269
+ }
@@ -0,0 +1,171 @@
1
+ // 三级合并的纯函数测试:合并顺序、来源标记、坏配置容错。
2
+
3
+ import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+
7
+ import {afterAll, describe, expect, it} from 'vitest';
8
+
9
+ import {collectDeployments, deploymentNotFoundText, findServer, mergeServers, readHostsFile, serverNotFoundText} from './hosts.js';
10
+
11
+ const workDir = mkdtempSync(join(tmpdir(), 'remote-hosts-'));
12
+ afterAll(() => rmSync(workDir, {recursive: true, force: true}));
13
+
14
+ /** @param {string} name @param {string} host @param {'ide'|'project'|'global'} source */
15
+ const server = (name, host, source) => ({name, host, port: 22, username: 'u', source});
16
+
17
+ describe('mergeServers', () => {
18
+ it('同名时后传的层覆盖先传的(global < project < ide)', () => {
19
+ const merged = mergeServers([
20
+ [server('a', 'global-host', 'global'), server('g', 'g-host', 'global')],
21
+ [server('b', 'project-host', 'project')],
22
+ [server('a', 'ide-host', 'ide')],
23
+ ]);
24
+ expect(merged.map((entry) => `${entry.name}:${entry.host}:${entry.source}`)).toEqual([
25
+ 'a:ide-host:ide',
26
+ 'b:project-host:project',
27
+ 'g:g-host:global',
28
+ ]);
29
+ });
30
+
31
+ it('空层不丢其它层', () => {
32
+ expect(mergeServers([[], [server('a', 'h', 'project')], []])).toHaveLength(1);
33
+ });
34
+
35
+ it('项目级只补 deployment 时沿用低优先级的连接信息', () => {
36
+ const merged = mergeServers([
37
+ [{name: 'mbp', host: 'h', port: 22, username: 'u', password: 'p', deployments: [], source: 'global'}],
38
+ [{name: 'mbp', deployments: [{name: 'app', localPath: '/l', deployPath: '/r'}], source: 'project'}],
39
+ ]);
40
+ expect(merged).toHaveLength(1);
41
+ expect(merged[0].host).toBe('h');
42
+ expect(merged[0].password).toBe('p');
43
+ expect(merged[0].deployments).toEqual([{name: 'app', localPath: '/l', deployPath: '/r'}]);
44
+ // 来源标最高优先级那层
45
+ expect(merged[0].source).toBe('project');
46
+ });
47
+
48
+ it('高优先级层给了 authType 时以它的凭据为准(KEY_PAIR 能清掉旧的 password)', () => {
49
+ const merged = mergeServers([
50
+ [{name: 'a', host: 'h', username: 'u', password: 'p', source: 'global'}],
51
+ [{name: 'a', authType: 'KEY_PAIR', privateKeyPath: '/k', source: 'ide'}],
52
+ ]);
53
+ expect(merged[0].password).toBeUndefined();
54
+ expect(merged[0].privateKeyPath).toBe('/k');
55
+ });
56
+ });
57
+
58
+ describe('findServer', () => {
59
+ it('按 alias 也能找到(兼容旧 remote-shell 配置)', () => {
60
+ const servers = [{
61
+ name: 'remote-dev',
62
+ alias: '远程开发机',
63
+ host: 'h',
64
+ port: 22,
65
+ username: 'u',
66
+ source: 'global',
67
+ }];
68
+ expect(findServer(servers, '远程开发机')?.name).toBe('remote-dev');
69
+ expect(findServer(servers, 'remote-dev')?.name).toBe('remote-dev');
70
+ });
71
+
72
+ it('只做精确匹配,不猜', () => {
73
+ const servers = [server('deploy1', 'h', 'ide')];
74
+ expect(findServer(servers, 'deploy1')?.name).toBe('deploy1');
75
+ expect(findServer(servers, 'dev1')).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe('serverNotFoundText', () => {
80
+ it('列出全部候选并标出来源', () => {
81
+ const text = serverNotFoundText(
82
+ [server('deploy1', '10.0.0.5', 'ide'), server('5090', '10.0.0.9', 'global')],
83
+ 'dev1',
84
+ );
85
+ expect(text).toContain('没有名为 "dev1" 的服务器');
86
+ expect(text).toContain('- deploy1 10.0.0.5:22 u [IDE]');
87
+ expect(text).toContain('- 5090 10.0.0.9:22 u [全局]');
88
+ });
89
+ });
90
+
91
+ describe('readHostsFile', () => {
92
+ it('读 hosts 并标来源', () => {
93
+ const path = join(workDir, 'good.json');
94
+ writeFileSync(path, JSON.stringify({
95
+ hosts: {dev: {host: '10.0.0.1', port: 2222, username: 'root', password: 'p'}},
96
+ }));
97
+ const servers = readHostsFile(path, 'project');
98
+ expect(servers).toEqual([
99
+ {
100
+ name: 'dev',
101
+ host: '10.0.0.1',
102
+ port: 2222,
103
+ username: 'root',
104
+ password: 'p',
105
+ deployments: [],
106
+ source: 'project',
107
+ },
108
+ ]);
109
+ });
110
+
111
+ it('文件不存在 / JSON 坏 / 缺 host 时返回空数组,不抛', () => {
112
+ const missing = join(workDir, 'missing.json');
113
+ expect(readHostsFile(missing, 'global')).toEqual([]);
114
+
115
+ const broken = join(workDir, 'broken.json');
116
+ writeFileSync(broken, '{not json');
117
+ expect(readHostsFile(broken, 'global')).toEqual([]);
118
+
119
+ const noHost = join(workDir, 'no-host.json');
120
+ writeFileSync(noHost, JSON.stringify({hosts: {bad: {username: 'u'}}}));
121
+ expect(readHostsFile(noHost, 'global')).toEqual([]);
122
+ });
123
+ });
124
+
125
+ describe('deployments', () => {
126
+ it('从 host 条目里解析路径映射,缺一头的丢掉', () => {
127
+ const path = join(workDir, 'with-deployments.json');
128
+ writeFileSync(path, JSON.stringify({
129
+ hosts: {
130
+ dev: {
131
+ host: '10.0.0.1',
132
+ username: 'u',
133
+ deployments: [
134
+ {name: 'app', localPath: '/local/app', deployPath: '/srv/app'},
135
+ {name: 'broken', localPath: '/local/x'},
136
+ ],
137
+ },
138
+ },
139
+ }));
140
+ expect(readHostsFile(path, 'project')[0].deployments).toEqual([
141
+ {name: 'app', localPath: '/local/app', deployPath: '/srv/app'},
142
+ ]);
143
+ });
144
+
145
+ it('collectDeployments 摊平成全局表,并挂上所属 server', () => {
146
+ const servers = [
147
+ {...server('a', 'h1', 'global'), deployments: [{name: 'x', localPath: '/l', deployPath: '/r'}]},
148
+ {...server('b', 'h2', 'ide'), deployments: [{name: 'y', localPath: '/l2', deployPath: '/r2'}]},
149
+ ];
150
+ const deployments = collectDeployments(servers);
151
+ expect(deployments.map((entry) => `${entry.name}:${entry.server.name}`)).toEqual(['x:a', 'y:b']);
152
+ });
153
+
154
+ it('找不到 deployment 时列出全部候选', () => {
155
+ const deployments = [{name: 'app', localPath: '/l', deployPath: '/r', server: server('a', 'h', 'ide')}];
156
+ expect(deploymentNotFoundText(deployments, 'api')).toContain('app /l ↔ a:/r');
157
+ });
158
+ });
159
+
160
+ describe('只补 mapping 的条目', () => {
161
+ it('没有 host / username 但带了 deployments 时保留为补丁', () => {
162
+ const path = join(workDir, 'patch-only.json');
163
+ writeFileSync(path, JSON.stringify({
164
+ hosts: {mbp: {deployments: [{name: 'app', localPath: '/l', deployPath: '/r'}]}},
165
+ }));
166
+ const servers = readHostsFile(path, 'project');
167
+ expect(servers).toHaveLength(1);
168
+ expect(servers[0].host).toBeUndefined();
169
+ expect(servers[0].deployments).toHaveLength(1);
170
+ });
171
+ });
@@ -0,0 +1,106 @@
1
+ // `/persist-remote-config`:把 IDE 里配置的 server 与 deployment 写进项目配置。
2
+ //
3
+ // 位置固定为 `<项目>/.opencode/remote-shell/hosts.json` —— 和 `hosts.js` 读的是同一个文件,
4
+ // 所以写完立刻能被 remote_list_servers / remote_exec / remote_upload 看到(项目级优先于全局)。
5
+ //
6
+ // 合并规则:IDE 有的 server 整体覆盖,IDE 没有的原样保留。不留备份文件 —— 项目里的配置由 git 管。
7
+ // 凭据照写(用户自己决定这个文件要不要进 git,工具不替用户做这个判断)。
8
+
9
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
10
+ import {join} from 'node:path';
11
+
12
+ /** 项目级配置目录。 */
13
+ export function remoteConfigDir(projectPath) {
14
+ return join(projectPath, '.opencode', 'remote-shell');
15
+ }
16
+
17
+ /** 项目级配置文件。 */
18
+ export function remoteConfigPath(projectPath) {
19
+ return join(remoteConfigDir(projectPath), 'hosts.json');
20
+ }
21
+
22
+ /**
23
+ * 一条 `RemoteServer` → hosts.json 里的条目。
24
+ *
25
+ * 只写这个文件认识(且确实有值)的字段:`authType` 决定 `password` / `privateKeyPath` 谁有意义,
26
+ * 空值不写出来,免得读的时候被当成"显式设成空"。
27
+ *
28
+ * @param {import('./hosts.js').RemoteServer} server
29
+ */
30
+ export function toHostEntry(server) {
31
+ /** @type {Record<string, unknown>} */
32
+ const entry = {
33
+ // alias 默认和 server 名一致:调用时用哪个都行,想换名字直接改这个字段,
34
+ // map 的 key 保持和 IDE 里一致(下次持久化也能正确覆盖)。
35
+ alias: server.alias ?? server.name,
36
+ host: server.host,
37
+ port: server.port ?? 22,
38
+ username: server.username,
39
+ authType: server.authType,
40
+ deployments: (server.deployments ?? []).map((deployment) => ({
41
+ name: deployment.name,
42
+ localPath: deployment.localPath,
43
+ deployPath: deployment.deployPath,
44
+ })),
45
+ };
46
+ if (server.password) entry.password = server.password;
47
+ if (server.passphrase) entry.passphrase = server.passphrase;
48
+ if (server.privateKeyPath) entry.privateKeyPath = server.privateKeyPath;
49
+ return entry;
50
+ }
51
+
52
+ /**
53
+ * 把 IDE 的服务器写进项目配置。
54
+ *
55
+ * @param {{
56
+ * projectPath: string,
57
+ * servers: ReadonlyArray<import('./hosts.js').RemoteServer>,
58
+ * }} options
59
+ * @returns {{
60
+ * path: string,
61
+ * added: number,
62
+ * updated: number,
63
+ * kept: number,
64
+ * total: number,
65
+ * deployments: number,
66
+ * }}
67
+ */
68
+ export function persistRemoteConfig({projectPath, servers}) {
69
+ const dir = remoteConfigDir(projectPath);
70
+ const file = remoteConfigPath(projectPath);
71
+ mkdirSync(dir, {recursive: true});
72
+
73
+ /** @type {Record<string, unknown>} */
74
+ let existing = {};
75
+ if (existsSync(file)) {
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
78
+ existing = parsed?.hosts && typeof parsed.hosts === 'object' ? parsed.hosts : {};
79
+ } catch {
80
+ // 文件坏了也要能覆盖:当成空的,重新写一份合法的。
81
+ existing = {};
82
+ }
83
+ }
84
+
85
+ const next = {...existing};
86
+ let added = 0;
87
+ let updated = 0;
88
+ for (const server of servers) {
89
+ if (next[server.name]) updated += 1;
90
+ else added += 1;
91
+ next[server.name] = toHostEntry(server);
92
+ }
93
+ const ideNames = new Set(servers.map((server) => server.name));
94
+ const kept = Object.keys(existing).filter((name) => !ideNames.has(name)).length;
95
+
96
+ writeFileSync(file, `${JSON.stringify({hosts: next}, null, 2)}\n`, 'utf8');
97
+
98
+ return {
99
+ path: file,
100
+ added,
101
+ updated,
102
+ kept,
103
+ total: Object.keys(next).length,
104
+ deployments: servers.reduce((sum, server) => sum + (server.deployments?.length ?? 0), 0),
105
+ };
106
+ }
@@ -0,0 +1,100 @@
1
+ // `/persist-remote-config` 的纯函数测试:新建、覆盖、保留、备份。
2
+
3
+ import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+
7
+ import {afterAll, describe, expect, it} from 'vitest';
8
+
9
+ import {persistRemoteConfig, remoteConfigPath, toHostEntry} from './persist.js';
10
+
11
+ const workDir = mkdtempSync(join(tmpdir(), 'persist-'));
12
+ afterAll(() => rmSync(workDir, {recursive: true, force: true}));
13
+
14
+ const project = () => mkdtempSync(join(workDir, 'project-'));
15
+
16
+ const server = (name, overrides = {}) => ({
17
+ name,
18
+ host: '10.0.0.1',
19
+ port: 22,
20
+ username: 'u',
21
+ authType: 'PASSWORD',
22
+ password: 'p',
23
+ deployments: [{name: 'app', localPath: '/local/app', deployPath: '/srv/app'}],
24
+ source: 'ide',
25
+ ...overrides,
26
+ });
27
+
28
+ describe('toHostEntry', () => {
29
+ it('写凭据、mapping,不写空字段', () => {
30
+ expect(toHostEntry(server('a'))).toEqual({
31
+ alias: 'a',
32
+ host: '10.0.0.1',
33
+ port: 22,
34
+ username: 'u',
35
+ authType: 'PASSWORD',
36
+ password: 'p',
37
+ deployments: [{name: 'app', localPath: '/local/app', deployPath: '/srv/app'}],
38
+ });
39
+ expect(toHostEntry(server('b', {password: undefined, privateKeyPath: '/k', authType: 'KEY_PAIR'})))
40
+ .not.toHaveProperty('password');
41
+ });
42
+ });
43
+
44
+ describe('persistRemoteConfig', () => {
45
+ it('项目里没有文件时新建,并写入凭据与 mapping', () => {
46
+ const projectPath = project();
47
+ const result = persistRemoteConfig({projectPath, servers: [server('a')]});
48
+
49
+ expect(result.added).toBe(1);
50
+ expect(result.updated).toBe(0);
51
+ expect(result.kept).toBe(0);
52
+ expect(result.deployments).toBe(1);
53
+
54
+ const json = JSON.parse(readFileSync(remoteConfigPath(projectPath), 'utf8'));
55
+ expect(json.hosts.a.alias).toBe('a');
56
+ expect(json.hosts.a.password).toBe('p');
57
+ expect(json.hosts.a.deployments).toEqual([
58
+ {name: 'app', localPath: '/local/app', deployPath: '/srv/app'},
59
+ ]);
60
+ });
61
+
62
+ it('同名覆盖、项目独有保留', () => {
63
+ const projectPath = project();
64
+ const file = remoteConfigPath(projectPath);
65
+ mkdirSync(join(projectPath, '.opencode', 'remote-shell'), {recursive: true});
66
+ writeFileSync(file, JSON.stringify({
67
+ hosts: {
68
+ a: {host: 'old-host', username: 'old-user'},
69
+ manual: {host: 'manual-host', username: 'u'},
70
+ },
71
+ }));
72
+
73
+ const result = persistRemoteConfig({projectPath, servers: [server('a', {host: 'new-host'})]});
74
+
75
+ expect(result.added).toBe(0);
76
+ expect(result.updated).toBe(1);
77
+ expect(result.kept).toBe(1);
78
+ expect(result.total).toBe(2);
79
+
80
+ const json = JSON.parse(readFileSync(file, 'utf8'));
81
+ expect(json.hosts.a.host).toBe('new-host');
82
+ expect(json.hosts.a.alias).toBe('a');
83
+ expect(json.hosts.a.password).toBe('p');
84
+ // IDE 里没有的那条原样保留
85
+ expect(json.hosts.manual.host).toBe('manual-host');
86
+ });
87
+
88
+ it('旧文件坏掉时仍能写入一份合法的配置', () => {
89
+ const projectPath = project();
90
+ const file = remoteConfigPath(projectPath);
91
+ mkdirSync(join(projectPath, '.opencode', 'remote-shell'), {recursive: true});
92
+ writeFileSync(file, '{not json');
93
+
94
+ const result = persistRemoteConfig({projectPath, servers: [server('a')]});
95
+
96
+ expect(result.added).toBe(1);
97
+ expect(existsSync(`${file}.bak`)).toBe(false);
98
+ expect(JSON.parse(readFileSync(file, 'utf8')).hosts.a.host).toBe('10.0.0.1');
99
+ });
100
+ });