@openchamber/web 1.11.5 → 1.11.7

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.
Files changed (65) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.js +443 -2
  3. package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-DaF15QNC.js} +1 -1
  4. package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
  5. package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-DTv6YJI1.js} +2 -2
  6. package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-_c3TTL2z.js} +1 -1
  7. package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-CuXkDROt.js} +4 -4
  8. package/dist/assets/es-CYoUf2D-.js +15 -0
  9. package/dist/assets/{index-DHluop4D.js → index-3WXrN3AX.js} +1 -1
  10. package/dist/assets/index-BREIbhcb.css +1 -0
  11. package/dist/assets/ko-2tM0fIna.js +15 -0
  12. package/dist/assets/main-BF3kWAJ9.js +239 -0
  13. package/dist/assets/{main-VVcyjpiF.js → main-o8ZERrmU.js} +2 -2
  14. package/dist/assets/miniChat-BZQjpK23.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-wwnbqBk7.js} +109 -107
  16. package/dist/assets/pl-Dq8uAotM.js +15 -0
  17. package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
  19. package/dist/assets/uk-BZtz0wUV.js +15 -0
  20. package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
  21. package/dist/assets/zh-CN-j_nYMchE.js +15 -0
  22. package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
  23. package/dist/index.html +11 -28
  24. package/dist/mini-chat.html +4 -4
  25. package/package.json +1 -1
  26. package/server/index.js +2 -0
  27. package/server/lib/cloudflare-tunnel.js +3 -5
  28. package/server/lib/fs/routes.js +5 -0
  29. package/server/lib/fs/routes.test.js +61 -1
  30. package/server/lib/git/DOCUMENTATION.md +1 -0
  31. package/server/lib/git/routes.js +82 -1
  32. package/server/lib/git/service.js +338 -19
  33. package/server/lib/git/service.test.js +414 -8
  34. package/server/lib/ngrok-tunnel.js +209 -0
  35. package/server/lib/opencode/core-routes.js +1 -0
  36. package/server/lib/opencode/env-runtime.js +52 -4
  37. package/server/lib/opencode/env-runtime.test.js +82 -6
  38. package/server/lib/opencode/feature-routes-runtime.js +35 -0
  39. package/server/lib/opencode/index.js +19 -0
  40. package/server/lib/opencode/npm-registry.js +157 -0
  41. package/server/lib/opencode/npm-registry.test.js +179 -0
  42. package/server/lib/opencode/openchamber-routes.js +9 -7
  43. package/server/lib/opencode/plugin-routes.js +373 -0
  44. package/server/lib/opencode/plugin-routes.test.js +384 -0
  45. package/server/lib/opencode/plugin-spec.js +107 -0
  46. package/server/lib/opencode/plugin-spec.test.js +154 -0
  47. package/server/lib/opencode/plugins.js +393 -0
  48. package/server/lib/opencode/plugins.test.js +176 -0
  49. package/server/lib/opencode/settings-helpers.js +6 -0
  50. package/server/lib/opencode/settings-helpers.test.js +11 -0
  51. package/server/lib/opencode/settings-runtime.js +39 -1
  52. package/server/lib/opencode/settings-runtime.test.js +39 -0
  53. package/server/lib/skills-catalog/source.js +1 -1
  54. package/server/lib/tunnels/DOCUMENTATION.md +1 -0
  55. package/server/lib/tunnels/providers/ngrok.js +117 -0
  56. package/server/lib/tunnels/types.js +2 -0
  57. package/dist/assets/es-dIVpApmS.js +0 -15
  58. package/dist/assets/index-Bk9IWJe1.css +0 -1
  59. package/dist/assets/ko-Cqf3E9-d.js +0 -15
  60. package/dist/assets/main-D45l3Dxw.js +0 -232
  61. package/dist/assets/miniChat-a9w7WM0c.js +0 -2
  62. package/dist/assets/pl-C577DpsX.js +0 -15
  63. package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
  64. package/dist/assets/uk-CZ7XVz_D.js +0 -15
  65. package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
@@ -0,0 +1,393 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {
5
+ AGENT_SCOPE,
6
+ readConfigFile,
7
+ writeConfig,
8
+ } from './shared.js';
9
+ import { isPathSpec } from './plugin-spec.js';
10
+
11
+ const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
12
+
13
+ /**
14
+ * @typedef {'user' | 'project'} PluginScope
15
+ * @typedef {'npm' | 'path'} PluginParsedKind
16
+ * @typedef {Object} PluginEntry
17
+ * @property {string} id base64url encoded "config:scope:spec"
18
+ * @property {string} spec
19
+ * @property {Record<string, unknown>} [options]
20
+ * @property {PluginScope} scope
21
+ * @property {'config'} kind
22
+ * @property {PluginParsedKind} parsedKind
23
+ * @property {string} sourcePath absolute path to the config file
24
+ * @typedef {Object} PluginFile
25
+ * @property {string} id base64url encoded "file:scope:fileName"
26
+ * @property {string} fileName
27
+ * @property {PluginScope} scope
28
+ * @property {'file'} kind
29
+ * @property {string} absolutePath
30
+ */
31
+
32
+ function codedError(message, code) {
33
+ const error = new Error(message);
34
+ error.code = code;
35
+ return error;
36
+ }
37
+
38
+ function validateScope(scope) {
39
+ if (scope !== AGENT_SCOPE.USER && scope !== AGENT_SCOPE.PROJECT) {
40
+ throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
41
+ }
42
+ }
43
+
44
+ function validatePluginSpec(spec) {
45
+ if (typeof spec !== 'string' || !spec.trim()) {
46
+ throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
47
+ }
48
+ if (spec.includes('\0')) {
49
+ throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
50
+ }
51
+ return spec.trim();
52
+ }
53
+
54
+ function isRecord(value) {
55
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
56
+ }
57
+
58
+ function hasOptions(options) {
59
+ return isRecord(options) && Object.keys(options).length > 0;
60
+ }
61
+
62
+ function parsedKindForSpec(spec) {
63
+ // Path indicators must include Windows paths; scoped npm packages also contain '/'.
64
+ // Do NOT use `includes(path.sep)` — scoped npm packages legitimately contain '/' (e.g. `@gitlab/opencode-gitlab-auth`).
65
+ return isPathSpec(spec) ? 'path' : 'npm';
66
+ }
67
+
68
+ function getActiveOpencodeConfigDir() {
69
+ const customConfigPath = process.env.OPENCODE_CONFIG;
70
+ if (customConfigPath) {
71
+ return path.dirname(path.resolve(customConfigPath));
72
+ }
73
+ return path.join(os.homedir(), '.config', 'opencode');
74
+ }
75
+
76
+ function getActiveUserConfigPaths() {
77
+ const configDir = getActiveOpencodeConfigDir();
78
+ return [
79
+ path.join(configDir, 'config.json'),
80
+ path.join(configDir, 'opencode.json'),
81
+ path.join(configDir, 'opencode.jsonc'),
82
+ ];
83
+ }
84
+
85
+ function getActiveCustomConfigPath() {
86
+ return process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
87
+ }
88
+
89
+ function getPrimaryUserConfigPath() {
90
+ const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
91
+ for (const userPath of [defaultPath, ...fallbackPaths]) {
92
+ if (fs.existsSync(userPath)) {
93
+ return userPath;
94
+ }
95
+ }
96
+ return defaultPath;
97
+ }
98
+
99
+ function getProjectConfigPath(workingDirectory) {
100
+ if (!workingDirectory) return null;
101
+ const candidates = [
102
+ path.join(workingDirectory, 'opencode.json'),
103
+ path.join(workingDirectory, 'opencode.jsonc'),
104
+ path.join(workingDirectory, '.opencode', 'opencode.json'),
105
+ path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
106
+ ];
107
+ return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
108
+ }
109
+
110
+ function readPluginConfigLayers(workingDirectory) {
111
+ const customPath = getActiveCustomConfigPath();
112
+ const userPath = getPrimaryUserConfigPath();
113
+ const projectPath = getProjectConfigPath(workingDirectory);
114
+ return {
115
+ userConfig: readConfigFile(userPath),
116
+ projectConfig: readConfigFile(projectPath),
117
+ customConfig: readConfigFile(customPath),
118
+ paths: {
119
+ userPath,
120
+ projectPath,
121
+ customPath,
122
+ },
123
+ };
124
+ }
125
+
126
+ function validateFileName(fileName) {
127
+ if (typeof fileName !== 'string' || !fileName) {
128
+ throw codedError('Plugin file name is required', 'INVALID_FILENAME');
129
+ }
130
+ if (fileName.includes('/') || fileName.includes('\\') || fileName.includes('..') || !PLUGIN_FILE_NAME_PATTERN.test(fileName)) {
131
+ throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
132
+ }
133
+ return fileName;
134
+ }
135
+
136
+ function ensureProjectConfigPath(workingDirectory) {
137
+ if (!workingDirectory) {
138
+ throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
139
+ }
140
+ const configDir = path.join(workingDirectory, '.opencode');
141
+ fs.mkdirSync(configDir, { recursive: true });
142
+ return path.join(configDir, 'opencode.json');
143
+ }
144
+
145
+ function configSources(layers) {
146
+ const sources = [];
147
+ if (layers.paths.customPath) {
148
+ sources.push({ config: layers.customConfig, filePath: layers.paths.customPath, scope: AGENT_SCOPE.USER });
149
+ } else {
150
+ sources.push({ config: layers.userConfig, filePath: layers.paths.userPath, scope: AGENT_SCOPE.USER });
151
+ }
152
+ if (layers.paths.projectPath) {
153
+ sources.push({ config: layers.projectConfig, filePath: layers.paths.projectPath, scope: AGENT_SCOPE.PROJECT });
154
+ }
155
+ return sources;
156
+ }
157
+
158
+ function splitScopedValue(value) {
159
+ const separator = value.indexOf(':');
160
+ if (separator === -1) {
161
+ throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
162
+ }
163
+ return {
164
+ scope: value.slice(0, separator),
165
+ value: value.slice(separator + 1),
166
+ };
167
+ }
168
+
169
+ function getPluginTarget(id, workingDirectory) {
170
+ const decoded = decodePluginId(id);
171
+ if (decoded.prefix !== 'config') {
172
+ throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
173
+ }
174
+ const { scope, value: spec } = splitScopedValue(decoded.value);
175
+ validateScope(scope);
176
+ const layers = readPluginConfigLayers(workingDirectory);
177
+ const source = configSources(layers).find((candidate) => candidate.scope === scope);
178
+ const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
179
+ const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
180
+ if (!source || index === -1) {
181
+ return null;
182
+ }
183
+ return { source, plugin, index };
184
+ }
185
+
186
+ function pluginDirForScope(scope, workingDirectory) {
187
+ validateScope(scope);
188
+ if (scope === AGENT_SCOPE.PROJECT) {
189
+ if (!workingDirectory) {
190
+ throw codedError('Project scope requires working directory', 'INVALID_SCOPE');
191
+ }
192
+ return path.join(workingDirectory, '.opencode', 'plugins');
193
+ }
194
+ return path.join(getActiveOpencodeConfigDir(), 'plugins');
195
+ }
196
+
197
+ function fileTargetFromId(id, workingDirectory) {
198
+ const decoded = decodePluginId(id);
199
+ if (decoded.prefix !== 'file') {
200
+ throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
201
+ }
202
+ const { scope, value: fileName } = splitScopedValue(decoded.value);
203
+ validateScope(scope);
204
+ validateFileName(fileName);
205
+ return {
206
+ fileName,
207
+ scope,
208
+ absolutePath: path.join(pluginDirForScope(scope, workingDirectory), fileName),
209
+ };
210
+ }
211
+
212
+ function encodePluginId(prefix, value) {
213
+ return Buffer.from(`${prefix}:${value}`).toString('base64url');
214
+ }
215
+
216
+ function decodePluginId(id) {
217
+ const decoded = Buffer.from(id, 'base64url').toString('utf8');
218
+ const separator = decoded.indexOf(':');
219
+ if (separator === -1) {
220
+ throw codedError('Invalid plugin id', 'INVALID_SPEC');
221
+ }
222
+ return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
223
+ }
224
+
225
+ function parsePluginRaw(raw) {
226
+ if (typeof raw === 'string') {
227
+ return { spec: validatePluginSpec(raw) };
228
+ }
229
+ if (Array.isArray(raw) && raw.length === 2 && isRecord(raw[1])) {
230
+ return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
231
+ }
232
+ throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
233
+ }
234
+
235
+ function serializePluginEntry(entry) {
236
+ const spec = validatePluginSpec(entry?.spec);
237
+ if (hasOptions(entry?.options)) {
238
+ return [spec, { ...entry.options }];
239
+ }
240
+ return spec;
241
+ }
242
+
243
+ function listPluginEntries(workingDirectory) {
244
+ const layers = readPluginConfigLayers(workingDirectory);
245
+ return configSources(layers).flatMap((source) => {
246
+ if (!Array.isArray(source.config?.plugin)) {
247
+ return [];
248
+ }
249
+ return source.config.plugin.map((raw) => {
250
+ const parsed = parsePluginRaw(raw);
251
+ return {
252
+ id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
253
+ spec: parsed.spec,
254
+ ...(parsed.options !== undefined ? { options: parsed.options } : {}),
255
+ scope: source.scope,
256
+ kind: 'config',
257
+ parsedKind: parsedKindForSpec(parsed.spec),
258
+ sourcePath: source.filePath,
259
+ };
260
+ });
261
+ });
262
+ }
263
+
264
+ function getPluginEntry(id, workingDirectory) {
265
+ return listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
266
+ }
267
+
268
+ function createPluginEntry(entry, workingDirectory) {
269
+ const spec = validatePluginSpec(entry?.spec);
270
+ const scope = entry?.scope || AGENT_SCOPE.USER;
271
+ validateScope(scope);
272
+
273
+ const layers = readPluginConfigLayers(workingDirectory);
274
+ const existing = configSources(layers).find((source) => (
275
+ source.scope === scope
276
+ && Array.isArray(source.config?.plugin)
277
+ && source.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec)
278
+ ));
279
+ if (existing) {
280
+ throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
281
+ }
282
+
283
+ let targetPath = getPrimaryUserConfigPath();
284
+ let config = {};
285
+ if (scope === AGENT_SCOPE.PROJECT) {
286
+ targetPath = ensureProjectConfigPath(workingDirectory);
287
+ config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
288
+ } else {
289
+ targetPath = layers.paths.customPath || layers.paths.userPath;
290
+ config = layers.paths.customPath ? layers.customConfig : layers.userConfig;
291
+ }
292
+
293
+ if (!Array.isArray(config.plugin)) {
294
+ config.plugin = [];
295
+ }
296
+ config.plugin.push(serializePluginEntry({ spec, options: entry.options }));
297
+ writeConfig(config, targetPath);
298
+ }
299
+
300
+ function updatePluginEntry(id, updates, workingDirectory) {
301
+ const target = getPluginTarget(id, workingDirectory);
302
+ if (!target) {
303
+ throw codedError('Plugin entry not found', 'NOT_FOUND');
304
+ }
305
+ const existing = parsePluginRaw(target.plugin[target.index]);
306
+ const nextSpec = updates?.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
307
+ const nextOptions = updates?.options === undefined ? existing.options : updates.options;
308
+ target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
309
+ writeConfig(target.source.config, target.source.filePath);
310
+ }
311
+
312
+ function deletePluginEntry(id, workingDirectory) {
313
+ const target = getPluginTarget(id, workingDirectory);
314
+ if (!target) {
315
+ throw codedError('Plugin entry not found', 'NOT_FOUND');
316
+ }
317
+ target.plugin.splice(target.index, 1);
318
+ if (target.plugin.length === 0) {
319
+ delete target.source.config.plugin;
320
+ }
321
+ writeConfig(target.source.config, target.source.filePath);
322
+ }
323
+
324
+ function listPluginDirFiles(workingDirectory) {
325
+ const scopes = [AGENT_SCOPE.USER];
326
+ if (workingDirectory) {
327
+ scopes.push(AGENT_SCOPE.PROJECT);
328
+ }
329
+ return scopes.flatMap((scope) => {
330
+ const dir = pluginDirForScope(scope, workingDirectory);
331
+ if (!fs.existsSync(dir)) {
332
+ return [];
333
+ }
334
+ return fs.readdirSync(dir, { withFileTypes: true })
335
+ .filter((entry) => entry.isFile() && PLUGIN_FILE_NAME_PATTERN.test(entry.name) && !entry.name.includes('..'))
336
+ .map((entry) => ({
337
+ id: encodePluginId('file', `${scope}:${entry.name}`),
338
+ fileName: entry.name,
339
+ scope,
340
+ kind: 'file',
341
+ absolutePath: path.join(dir, entry.name),
342
+ }));
343
+ });
344
+ }
345
+
346
+ function readPluginDirFile(id, workingDirectory) {
347
+ const target = fileTargetFromId(id, workingDirectory);
348
+ if (!fs.existsSync(target.absolutePath)) {
349
+ return null;
350
+ }
351
+ return {
352
+ fileName: target.fileName,
353
+ scope: target.scope,
354
+ content: fs.readFileSync(target.absolutePath, 'utf8'),
355
+ };
356
+ }
357
+
358
+ function writePluginDirFile(file, workingDirectory, opts = {}) {
359
+ const fileName = validateFileName(file?.fileName);
360
+ const scope = file?.scope || AGENT_SCOPE.USER;
361
+ validateScope(scope);
362
+ const dir = pluginDirForScope(scope, workingDirectory);
363
+ const absolutePath = path.join(dir, fileName);
364
+ if (!opts.overwrite && fs.existsSync(absolutePath)) {
365
+ throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
366
+ }
367
+ fs.mkdirSync(dir, { recursive: true });
368
+ fs.writeFileSync(absolutePath, file?.content ?? '', 'utf8');
369
+ }
370
+
371
+ function deletePluginDirFile(id, workingDirectory) {
372
+ const target = fileTargetFromId(id, workingDirectory);
373
+ if (!fs.existsSync(target.absolutePath)) {
374
+ throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
375
+ }
376
+ fs.unlinkSync(target.absolutePath);
377
+ }
378
+
379
+ export {
380
+ listPluginEntries,
381
+ getPluginEntry,
382
+ createPluginEntry,
383
+ updatePluginEntry,
384
+ deletePluginEntry,
385
+ listPluginDirFiles,
386
+ readPluginDirFile,
387
+ writePluginDirFile,
388
+ deletePluginDirFile,
389
+ encodePluginId,
390
+ decodePluginId,
391
+ parsePluginRaw,
392
+ serializePluginEntry,
393
+ };
@@ -0,0 +1,176 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ let rootDir;
7
+ let projectDir;
8
+ let userConfigPath;
9
+ let plugins;
10
+
11
+ function thrownBy(fn) {
12
+ try {
13
+ fn();
14
+ } catch (error) {
15
+ return error;
16
+ }
17
+ throw new Error('Expected function to throw');
18
+ }
19
+
20
+ function writeJson(filePath, data) {
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
22
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
23
+ }
24
+
25
+ function readJson(filePath) {
26
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
27
+ }
28
+
29
+ describe('opencode plugins data layer', () => {
30
+ beforeAll(async () => {
31
+ rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-plugins-'));
32
+ userConfigPath = path.join(rootDir, 'user-opencode.json');
33
+ process.env.OPENCODE_CONFIG = userConfigPath;
34
+ plugins = await import('./plugins.js');
35
+ });
36
+
37
+ beforeEach(() => {
38
+ process.env.OPENCODE_CONFIG = userConfigPath;
39
+ projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'));
40
+ fs.rmSync(userConfigPath, { force: true });
41
+ });
42
+
43
+ afterAll(() => {
44
+ fs.rmSync(rootDir, { recursive: true, force: true });
45
+ delete process.env.OPENCODE_CONFIG;
46
+ });
47
+
48
+ test('parses raw plugin entries', () => {
49
+ expect(plugins.parsePluginRaw('foo')).toEqual({ spec: 'foo' });
50
+ expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
51
+ expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
52
+ expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
53
+ expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
54
+ });
55
+
56
+ test('serializes plugin entries', () => {
57
+ expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
58
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
59
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
60
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
61
+ });
62
+
63
+ test('rejects invalid specs and file names', () => {
64
+ expect(() => plugins.createPluginEntry({ spec: 123, scope: 'user' }, projectDir)).toThrow('Plugin spec');
65
+ expect(() => plugins.writePluginDirFile({ fileName: '', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
66
+ expect(() => plugins.writePluginDirFile({ fileName: '../bad.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
67
+ expect(() => plugins.writePluginDirFile({ fileName: 'a/b.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
68
+ expect(() => plugins.writePluginDirFile({ fileName: 'A.js', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
69
+ expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
70
+ });
71
+
72
+ test('creates string and tuple entries with duplicate rejection', () => {
73
+ plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
74
+ plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
75
+
76
+ expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
77
+ expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
78
+ expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
79
+ });
80
+
81
+ test('routes project entries to project config and user entries to custom user config', () => {
82
+ plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
83
+ plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
84
+
85
+ expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
86
+ expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
87
+ });
88
+
89
+ test('re-resolves custom config env between calls', () => {
90
+ const firstConfigPath = path.join(rootDir, 'first', 'opencode.json');
91
+ const secondConfigPath = path.join(rootDir, 'second', 'opencode.json');
92
+
93
+ process.env.OPENCODE_CONFIG = firstConfigPath;
94
+ plugins.createPluginEntry({ spec: 'first-plugin', scope: 'user' }, projectDir);
95
+ plugins.writePluginDirFile({ fileName: 'first.js', content: 'one', scope: 'user' }, projectDir);
96
+
97
+ process.env.OPENCODE_CONFIG = secondConfigPath;
98
+ plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
99
+ plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
100
+
101
+ expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
102
+ expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
103
+ expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
104
+ expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
105
+ });
106
+
107
+ test('updates entries in place and transitions between string and tuple', () => {
108
+ writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
109
+
110
+ plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
111
+ expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
112
+
113
+ plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
114
+ expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
115
+ });
116
+
117
+ test('deletes entries and prunes empty plugin key', () => {
118
+ writeJson(userConfigPath, { plugin: ['only'] });
119
+
120
+ plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:only'), projectDir);
121
+ expect(readJson(userConfigPath)).toEqual({});
122
+ });
123
+
124
+ test('lists entries from user and project layers with scopes and parsed kinds', () => {
125
+ writeJson(userConfigPath, { plugin: ['npm-plugin', '/abs/plugin.js', '@scope/pkg@1.0.0'] });
126
+ writeJson(path.join(projectDir, '.opencode', 'opencode.json'), { plugin: ['./local-plugin.js'] });
127
+
128
+ const entries = plugins.listPluginEntries(projectDir);
129
+ expect(entries).toEqual([
130
+ expect.objectContaining({ spec: 'npm-plugin', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
131
+ expect.objectContaining({ spec: '/abs/plugin.js', scope: 'user', kind: 'config', parsedKind: 'path', sourcePath: userConfigPath }),
132
+ expect.objectContaining({ spec: '@scope/pkg@1.0.0', scope: 'user', kind: 'config', parsedKind: 'npm', sourcePath: userConfigPath }),
133
+ expect.objectContaining({ spec: './local-plugin.js', scope: 'project', kind: 'config', parsedKind: 'path', sourcePath: path.join(projectDir, '.opencode', 'opencode.json') }),
134
+ ]);
135
+ fs.rmSync(userConfigPath, { force: true });
136
+ expect(plugins.listPluginEntries(projectDir)).toEqual([
137
+ expect.objectContaining({ spec: './local-plugin.js', scope: 'project' }),
138
+ ]);
139
+ });
140
+
141
+ test('encodes and decodes ids', () => {
142
+ const id = plugins.encodePluginId('config', 'user:oh-my-openagent@4.3.0');
143
+ expect(plugins.decodePluginId(id)).toEqual({ prefix: 'config', value: 'user:oh-my-openagent@4.3.0' });
144
+ });
145
+
146
+ test('round-trips plugin dir files', () => {
147
+ plugins.writePluginDirFile({ fileName: 'my-plugin.ts', content: 'export default {}', scope: 'project' }, projectDir);
148
+ const file = plugins.listPluginDirFiles(projectDir).find((candidate) => candidate.fileName === 'my-plugin.ts');
149
+
150
+ expect(file).toEqual(expect.objectContaining({ fileName: 'my-plugin.ts', scope: 'project', kind: 'file' }));
151
+ expect(plugins.readPluginDirFile(file.id, projectDir)).toEqual({ fileName: 'my-plugin.ts', scope: 'project', content: 'export default {}' });
152
+ plugins.deletePluginDirFile(file.id, projectDir);
153
+ expect(plugins.listPluginDirFiles(projectDir).filter((candidate) => candidate.scope === 'project')).toEqual([]);
154
+ expect(() => plugins.deletePluginDirFile(file.id, projectDir)).toThrow('not found');
155
+ });
156
+
157
+ test('rejects duplicate plugin dir files unless overwrite is true', () => {
158
+ plugins.writePluginDirFile({ fileName: 'dup.js', content: 'one', scope: 'project' }, projectDir);
159
+ expect(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir)).toThrow('already exists');
160
+ expect(thrownBy(() => plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir))).toHaveProperty('code', 'FILE_EXISTS');
161
+
162
+ plugins.writePluginDirFile({ fileName: 'dup.js', content: 'two', scope: 'project' }, projectDir, { overwrite: true });
163
+ expect(fs.readFileSync(path.join(projectDir, '.opencode', 'plugins', 'dup.js'), 'utf8')).toBe('two');
164
+ });
165
+
166
+ test('lists only valid plugin dir files', () => {
167
+ const dir = path.join(projectDir, '.opencode', 'plugins');
168
+ fs.mkdirSync(dir, { recursive: true });
169
+ fs.writeFileSync(path.join(dir, 'valid.mjs'), '', 'utf8');
170
+ fs.writeFileSync(path.join(dir, 'README.md'), '', 'utf8');
171
+
172
+ expect(plugins.listPluginDirFiles(projectDir).filter((file) => file.scope === 'project')).toEqual([
173
+ expect.objectContaining({ fileName: 'valid.mjs', scope: 'project' }),
174
+ ]);
175
+ });
176
+ });
@@ -115,6 +115,9 @@ export const createSettingsHelpers = (dependencies) => {
115
115
  if (typeof candidate.desktopLanAccessEnabled === 'boolean') {
116
116
  result.desktopLanAccessEnabled = candidate.desktopLanAccessEnabled;
117
117
  }
118
+ if (typeof candidate.desktopUiPassword === 'string') {
119
+ result.desktopUiPassword = candidate.desktopUiPassword.trim();
120
+ }
118
121
  if (Array.isArray(candidate.projects)) {
119
122
  const projects = sanitizeProjects(candidate.projects);
120
123
  if (projects) {
@@ -222,6 +225,9 @@ export const createSettingsHelpers = (dependencies) => {
222
225
  if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
223
226
  result.usageDisplayMode = candidate.usageDisplayMode;
224
227
  }
228
+ if (typeof candidate.usageShowPredValues === 'boolean') {
229
+ result.usageShowPredValues = candidate.usageShowPredValues;
230
+ }
225
231
  if (Array.isArray(candidate.usageDropdownProviders)) {
226
232
  result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
227
233
  }
@@ -52,6 +52,17 @@ describe('settings helpers', () => {
52
52
  });
53
53
  });
54
54
 
55
+ it('accepts desktopUiPassword as a persisted shared setting', () => {
56
+ const helpers = createTestHelpers();
57
+
58
+ expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: ' secret ' })).toEqual({
59
+ desktopUiPassword: 'secret',
60
+ });
61
+ expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: '' })).toEqual({
62
+ desktopUiPassword: '',
63
+ });
64
+ });
65
+
55
66
  it('accepts mobileKeyboardMode as a persisted shared setting', () => {
56
67
  const helpers = createTestHelpers();
57
68
 
@@ -438,6 +438,44 @@ export const createSettingsRuntime = (deps) => {
438
438
  }
439
439
  };
440
440
 
441
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
442
+
443
+ const isTransientWindowsReplaceError = (error) => {
444
+ if (process.platform !== 'win32' || !error || typeof error !== 'object') {
445
+ return false;
446
+ }
447
+ return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY';
448
+ };
449
+
450
+ const replaceFile = async (tmp, target) => {
451
+ const maxAttempts = process.platform === 'win32' ? 6 : 1;
452
+ let lastError = null;
453
+
454
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
455
+ try {
456
+ await fsPromises.rename(tmp, target);
457
+ return;
458
+ } catch (error) {
459
+ lastError = error;
460
+ if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) {
461
+ break;
462
+ }
463
+ await sleep(25 * attempt);
464
+ }
465
+ }
466
+
467
+ if (!isTransientWindowsReplaceError(lastError)) {
468
+ throw lastError;
469
+ }
470
+
471
+ // Windows can transiently reject atomic replacement when another process
472
+ // briefly opens the target file. Preserve atomic rename everywhere it works,
473
+ // but fall back to a direct replacement so settings persistence does not
474
+ // get permanently wedged on Windows desktop installs.
475
+ await fsPromises.copyFile(tmp, target);
476
+ await fsPromises.rm(tmp, { force: true });
477
+ };
478
+
441
479
  const writeSettingsToDisk = async (settings) => {
442
480
  try {
443
481
  await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
@@ -447,7 +485,7 @@ export const createSettingsRuntime = (deps) => {
447
485
  // read-modify-write wipe the settings file.
448
486
  const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
449
487
  await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8');
450
- await fsPromises.rename(tmp, SETTINGS_FILE_PATH);
488
+ await replaceFile(tmp, SETTINGS_FILE_PATH);
451
489
  } catch (error) {
452
490
  console.warn('Failed to write settings file:', error);
453
491
  throw error;