@openchamber/web 1.11.6 → 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 (47) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.js +443 -2
  3. package/dist/assets/{MarkdownRendererImpl-COdbjw73.js → MarkdownRendererImpl-DaF15QNC.js} +3 -3
  4. package/dist/assets/{MultiRunWindow-BKSHxjMq.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
  5. package/dist/assets/{OnboardingScreen-Chjg337p.js → OnboardingScreen-DTv6YJI1.js} +2 -2
  6. package/dist/assets/{SettingsWindow-C0lRRW8M.js → SettingsWindow-_c3TTL2z.js} +1 -1
  7. package/dist/assets/{TerminalView-Bvil3j1u.js → TerminalView-CuXkDROt.js} +3 -3
  8. package/dist/assets/es-CYoUf2D-.js +15 -0
  9. package/dist/assets/{index-B9LvUHdG.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-Blhx9Fp5.js → main-o8ZERrmU.js} +2 -2
  14. package/dist/assets/miniChat-BZQjpK23.js +2 -0
  15. package/dist/assets/{modelPrefsAutoSave-DRJSYigo.js → modelPrefsAutoSave-wwnbqBk7.js} +110 -108
  16. package/dist/assets/pl-Dq8uAotM.js +15 -0
  17. package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
  18. package/dist/assets/{renderElectronMiniChatApp-BxZRI73j.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/lib/fs/routes.js +5 -0
  27. package/server/lib/fs/routes.test.js +61 -1
  28. package/server/lib/git/DOCUMENTATION.md +1 -0
  29. package/server/lib/git/routes.js +82 -1
  30. package/server/lib/git/service.js +338 -19
  31. package/server/lib/git/service.test.js +414 -8
  32. package/server/lib/opencode/env-runtime.js +52 -4
  33. package/server/lib/opencode/env-runtime.test.js +82 -6
  34. package/server/lib/opencode/openchamber-routes.js +9 -7
  35. package/server/lib/opencode/settings-helpers.js +3 -0
  36. package/server/lib/opencode/settings-runtime.js +39 -1
  37. package/server/lib/opencode/settings-runtime.test.js +39 -0
  38. package/server/lib/skills-catalog/source.js +1 -1
  39. package/dist/assets/es-BZIAUghG.js +0 -15
  40. package/dist/assets/index-UcCH2KN9.css +0 -1
  41. package/dist/assets/ko-DU9l-zox.js +0 -15
  42. package/dist/assets/main-d2-dY4er.js +0 -232
  43. package/dist/assets/miniChat-CJ7-rZFl.js +0 -2
  44. package/dist/assets/pl-CdqzokG-.js +0 -15
  45. package/dist/assets/pt-BR-Bknbr_Y3.js +0 -15
  46. package/dist/assets/uk-Be4E8ZNO.js +0 -15
  47. package/dist/assets/zh-CN-qpPiaZMg.js +0 -15
@@ -1,23 +1,86 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import simpleGit from 'simple-git';
2
7
 
3
- import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
8
+ import {
9
+ checkoutCommit,
10
+ cherryPick,
11
+ getStatus,
12
+ resetToCommit,
13
+ resolveBaseRefForLog,
14
+ revertCommit,
15
+ stageFiles,
16
+ unstageFiles,
17
+ } from './service.js';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Shared test infrastructure
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const tempDirs = [];
24
+
25
+ /** Create a temp dir and register it for afterEach cleanup. */
26
+ const createTempDir = () => {
27
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
28
+ tempDirs.push(dir);
29
+ return dir;
30
+ };
31
+
32
+ const runGit = (cwd, args) =>
33
+ execFileSync('git', args, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ stdio: ['ignore', 'pipe', 'pipe'],
37
+ });
38
+
39
+ const canRunGit = () => {
40
+ try {
41
+ execFileSync('git', ['--version'], { stdio: 'ignore' });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
47
+
48
+ afterEach(() => {
49
+ for (const dir of tempDirs.splice(0)) {
50
+ fs.rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ /**
55
+ * Create a temp repo using simple-git (for tests that need its assertion API).
56
+ * The dir is registered in tempDirs so afterEach handles cleanup automatically.
57
+ */
58
+ async function createTempRepo() {
59
+ const tmpDir = createTempDir();
60
+ const git = simpleGit(tmpDir);
61
+ await git.init();
62
+ await git.addConfig('user.name', 'Test User', false, 'local');
63
+ await git.addConfig('user.email', 'test@example.com', false, 'local');
64
+ await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']);
65
+ return { tmpDir, git };
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // resolveBaseRefForLog
70
+ // ---------------------------------------------------------------------------
4
71
 
5
72
  describe('resolveBaseRefForLog', () => {
6
73
  it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
7
- // Both local 'main' and 'refs/remotes/origin/main' are present.
8
- // The local ref takes precedence — callers that ask for 'main' get 'main'.
9
74
  const checkRef = async (ref) => ref === 'main' || ref === 'refs/remotes/origin/main';
10
75
  expect(await resolveBaseRefForLog('main', checkRef)).toBe('main');
11
76
  });
12
77
 
13
78
  it('falls back to origin/<from> when local ref cannot be resolved but origin can', async () => {
14
- // Local 'main' is absent (e.g. user never checked it out), but origin/main exists.
15
79
  const checkRef = async (ref) => ref === 'refs/remotes/origin/main';
16
80
  expect(await resolveBaseRefForLog('main', checkRef)).toBe('origin/main');
17
81
  });
18
82
 
19
83
  it('returns the original ref when neither local nor origin ref can be resolved', async () => {
20
- // Neither ref exists; return as-is so git surfaces a meaningful error.
21
84
  const checkRef = async () => false;
22
85
  expect(await resolveBaseRefForLog('nonexistent-branch', checkRef)).toBe('nonexistent-branch');
23
86
  });
@@ -38,12 +101,355 @@ describe('resolveBaseRefForLog', () => {
38
101
  });
39
102
  });
40
103
 
104
+ // ---------------------------------------------------------------------------
105
+ // git index path validation
106
+ // ---------------------------------------------------------------------------
107
+
41
108
  describe('git index path validation', () => {
42
109
  it('rejects stage paths outside the repository before invoking git', async () => {
43
- await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
110
+ await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
111
+ 'Path is outside repository: ../secret.txt'
112
+ );
44
113
  });
45
114
 
46
115
  it('rejects unstage paths outside the repository before invoking git', async () => {
47
- await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
116
+ await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow(
117
+ 'Path is outside repository: ../secret.txt'
118
+ );
119
+ });
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // getStatus
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe('getStatus', () => {
127
+ it('handles repositories without upstream tracking', async () => {
128
+ if (!canRunGit()) return;
129
+
130
+ const repo = createTempDir();
131
+ runGit(repo, ['init', '-b', 'main']);
132
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
133
+ runGit(repo, ['config', 'user.name', 'Test User']);
134
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
135
+ runGit(repo, ['add', 'README.md']);
136
+ runGit(repo, ['commit', '-m', 'Initial commit']);
137
+
138
+ await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // checkoutCommit
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('checkoutCommit', () => {
147
+ it('checks out a valid commit and puts the repo in detached HEAD state', async () => {
148
+ const { tmpDir, git } = await createTempRepo();
149
+ const filePath = path.join(tmpDir, 'file.txt');
150
+ await fs.promises.writeFile(filePath, 'first', 'utf8');
151
+ await git.add('file.txt');
152
+ const firstCommit = await git.commit('First commit');
153
+
154
+ await fs.promises.writeFile(filePath, 'second', 'utf8');
155
+ await git.add('file.txt');
156
+ await git.commit('Second commit');
157
+
158
+ const result = await checkoutCommit(tmpDir, firstCommit.commit);
159
+ expect(result).toEqual({ success: true });
160
+
161
+ const status = await git.status();
162
+ expect(status.detached).toBe(true);
163
+ });
164
+
165
+ it('throws an error for an invalid/nonexistent hash', async () => {
166
+ const { tmpDir } = await createTempRepo();
167
+ await expect(checkoutCommit(tmpDir, 'invalidhash123')).rejects.toThrow();
168
+ });
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // cherryPick
173
+ // ---------------------------------------------------------------------------
174
+
175
+ describe('cherryPick', () => {
176
+ it('cherry-picks a commit that applies cleanly', async () => {
177
+ const { tmpDir, git } = await createTempRepo();
178
+ const filePath = path.join(tmpDir, 'file.txt');
179
+ await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
180
+ await git.add('file.txt');
181
+ await git.commit('Initial commit');
182
+
183
+ await git.checkoutBranch('feature', 'HEAD');
184
+ await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
185
+ await git.add('file.txt');
186
+ const featureCommit = await git.commit('Add line3');
187
+
188
+ await git.checkout('main');
189
+ const result = await cherryPick(tmpDir, featureCommit.commit);
190
+ expect(result).toEqual({ success: true, conflict: false });
191
+
192
+ const content = await fs.promises.readFile(filePath, 'utf8');
193
+ expect(content).toBe('line1\nline2\nline3\n');
194
+ });
195
+
196
+ it('returns conflict info when cherry-picking a conflicting commit', async () => {
197
+ const { tmpDir, git } = await createTempRepo();
198
+ const filePath = path.join(tmpDir, 'file.txt');
199
+ await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
200
+ await git.add('file.txt');
201
+ await git.commit('Initial commit');
202
+
203
+ await git.checkoutBranch('feature', 'HEAD');
204
+ await fs.promises.writeFile(filePath, 'line1\nfeature-line2\n', 'utf8');
205
+ await git.add('file.txt');
206
+ const featureCommit = await git.commit('Change line2 in feature');
207
+
208
+ await git.checkout('main');
209
+ await fs.promises.writeFile(filePath, 'line1\nmain-line2\n', 'utf8');
210
+ await git.add('file.txt');
211
+ await git.commit('Change line2 in main');
212
+
213
+ const result = await cherryPick(tmpDir, featureCommit.commit);
214
+ expect(result.success).toBe(false);
215
+ expect(result.conflict).toBe(true);
216
+ expect(Array.isArray(result.conflictFiles)).toBe(true);
217
+ expect(result.conflictFiles.length).toBeGreaterThan(0);
218
+ });
219
+
220
+ it('throws for an invalid/nonexistent hash', async () => {
221
+ const { tmpDir } = await createTempRepo();
222
+ await expect(cherryPick(tmpDir, 'deadbeef00000000')).rejects.toThrow();
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // revertCommit
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe('revertCommit', () => {
231
+ it('reverts a commit and stages the revert changes', async () => {
232
+ const { tmpDir, git } = await createTempRepo();
233
+ const filePath = path.join(tmpDir, 'file.txt');
234
+ await fs.promises.writeFile(filePath, 'line1\nline2\n', 'utf8');
235
+ await git.add('file.txt');
236
+ await git.commit('Initial commit');
237
+
238
+ await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
239
+ await git.add('file.txt');
240
+ const changeCommit = await git.commit('Add line3');
241
+
242
+ const result = await revertCommit(tmpDir, changeCommit.commit);
243
+ expect(result).toEqual({ success: true, conflict: false });
244
+
245
+ const status = await git.status();
246
+ expect(status.staged.length).toBeGreaterThan(0);
247
+ const content = await fs.promises.readFile(filePath, 'utf8');
248
+ expect(content).toBe('line1\nline2\n');
249
+ });
250
+
251
+ it('returns conflict info when reverting causes a conflict', async () => {
252
+ const { tmpDir, git } = await createTempRepo();
253
+ const filePath = path.join(tmpDir, 'file.txt');
254
+ await fs.promises.writeFile(filePath, 'line1\nline2\nline3\n', 'utf8');
255
+ await git.add('file.txt');
256
+ await git.commit('Initial commit');
257
+
258
+ await fs.promises.writeFile(filePath, 'line1\nchanged-a\nline3\n', 'utf8');
259
+ await git.add('file.txt');
260
+ const commitA = await git.commit('Change line2 to changed-a');
261
+
262
+ await fs.promises.writeFile(filePath, 'line1\nchanged-b\nline3\n', 'utf8');
263
+ await git.add('file.txt');
264
+ await git.commit('Change line2 to changed-b');
265
+
266
+ const result = await revertCommit(tmpDir, commitA.commit);
267
+ expect(result.success).toBe(false);
268
+ expect(result.conflict).toBe(true);
269
+ expect(Array.isArray(result.conflictFiles)).toBe(true);
270
+ expect(result.conflictFiles.length).toBeGreaterThan(0);
271
+ });
272
+
273
+ it('throws for an invalid/nonexistent hash', async () => {
274
+ const { tmpDir } = await createTempRepo();
275
+ await expect(revertCommit(tmpDir, 'deadbeef00000000')).rejects.toThrow();
276
+ });
277
+ });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // resetToCommit
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe('resetToCommit', () => {
284
+ it('soft reset moves HEAD without touching the working tree', async () => {
285
+ const { tmpDir, git } = await createTempRepo();
286
+ const filePath = path.join(tmpDir, 'file.txt');
287
+ await fs.promises.writeFile(filePath, 'first\n', 'utf8');
288
+ await git.add('file.txt');
289
+ const firstCommit = await git.commit('First commit');
290
+
291
+ await fs.promises.writeFile(filePath, 'second\n', 'utf8');
292
+ await git.add('file.txt');
293
+ await git.commit('Second commit');
294
+
295
+ const result = await resetToCommit(tmpDir, firstCommit.commit, 'soft');
296
+ expect(result).toEqual({ success: true });
297
+
298
+ const log = await git.log();
299
+ expect(log.latest.hash).toBe(firstCommit.commit);
300
+ const content = await fs.promises.readFile(filePath, 'utf8');
301
+ expect(content).toBe('second\n');
302
+
303
+ const status = await git.status();
304
+ expect(status.staged.length).toBeGreaterThan(0);
305
+ });
306
+
307
+ it('mixed reset moves HEAD and unstages changes', async () => {
308
+ const { tmpDir, git } = await createTempRepo();
309
+ const filePath = path.join(tmpDir, 'file.txt');
310
+ await fs.promises.writeFile(filePath, 'first\n', 'utf8');
311
+ await git.add('file.txt');
312
+ const firstCommit = await git.commit('First commit');
313
+
314
+ await fs.promises.writeFile(filePath, 'second\n', 'utf8');
315
+ await git.add('file.txt');
316
+ await git.commit('Second commit');
317
+
318
+ const result = await resetToCommit(tmpDir, firstCommit.commit, 'mixed');
319
+ expect(result).toEqual({ success: true });
320
+
321
+ const log = await git.log();
322
+ expect(log.latest.hash).toBe(firstCommit.commit);
323
+ const content = await fs.promises.readFile(filePath, 'utf8');
324
+ expect(content).toBe('second\n');
325
+
326
+ const status = await git.status();
327
+ expect(status.staged.length).toBe(0);
328
+ expect(status.modified.length).toBeGreaterThan(0);
329
+ });
330
+
331
+ it('hard reset with clean working tree succeeds', async () => {
332
+ const { tmpDir, git } = await createTempRepo();
333
+ const filePath = path.join(tmpDir, 'file.txt');
334
+ await fs.promises.writeFile(filePath, 'first\n', 'utf8');
335
+ await git.add('file.txt');
336
+ const firstCommit = await git.commit('First commit');
337
+
338
+ await fs.promises.writeFile(filePath, 'second\n', 'utf8');
339
+ await git.add('file.txt');
340
+ await git.commit('Second commit');
341
+
342
+ const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard');
343
+ expect(result).toEqual({ success: true });
344
+
345
+ const log = await git.log();
346
+ expect(log.latest.hash).toBe(firstCommit.commit);
347
+ const content = await fs.promises.readFile(filePath, 'utf8');
348
+ expect(content).toBe('first\n');
349
+
350
+ const status = await git.status();
351
+ expect(status.isClean()).toBe(true);
352
+ });
353
+
354
+ it('hard reset with dirty working tree without force throws', async () => {
355
+ const { tmpDir, git } = await createTempRepo();
356
+ const filePath = path.join(tmpDir, 'file.txt');
357
+ await fs.promises.writeFile(filePath, 'first\n', 'utf8');
358
+ await git.add('file.txt');
359
+ const firstCommit = await git.commit('First commit');
360
+
361
+ await fs.promises.writeFile(filePath, 'second\n', 'utf8');
362
+ await git.add('file.txt');
363
+ await git.commit('Second commit');
364
+
365
+ await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
366
+
367
+ await expect(resetToCommit(tmpDir, firstCommit.commit, 'hard')).rejects.toThrow(
368
+ 'Cannot hard reset: uncommitted changes in working tree'
369
+ );
370
+ });
371
+
372
+ it('hard reset with dirty working tree with force succeeds', async () => {
373
+ const { tmpDir, git } = await createTempRepo();
374
+ const filePath = path.join(tmpDir, 'file.txt');
375
+ await fs.promises.writeFile(filePath, 'first\n', 'utf8');
376
+ await git.add('file.txt');
377
+ const firstCommit = await git.commit('First commit');
378
+
379
+ await fs.promises.writeFile(filePath, 'second\n', 'utf8');
380
+ await git.add('file.txt');
381
+ await git.commit('Second commit');
382
+
383
+ await fs.promises.writeFile(filePath, 'dirty\n', 'utf8');
384
+
385
+ const result = await resetToCommit(tmpDir, firstCommit.commit, 'hard', true);
386
+ expect(result).toEqual({ success: true });
387
+
388
+ const log = await git.log();
389
+ expect(log.latest.hash).toBe(firstCommit.commit);
390
+ const content = await fs.promises.readFile(filePath, 'utf8');
391
+ expect(content).toBe('first\n');
392
+ });
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // hash validation
397
+ // ---------------------------------------------------------------------------
398
+
399
+ describe('hash validation', () => {
400
+ it('checkoutCommit rejects non-hex hash', async () => {
401
+ await expect(checkoutCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
402
+ });
403
+
404
+ it('checkoutCommit rejects ref name', async () => {
405
+ await expect(checkoutCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
406
+ });
407
+
408
+ it('checkoutCommit accepts valid 40-char hex format', async () => {
409
+ await expect(
410
+ checkoutCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
411
+ ).rejects.not.toThrow('Invalid commit hash');
412
+ });
413
+
414
+ it('cherryPick rejects non-hex hash', async () => {
415
+ await expect(cherryPick('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
416
+ });
417
+
418
+ it('cherryPick rejects ref name', async () => {
419
+ await expect(cherryPick('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
420
+ });
421
+
422
+ it('cherryPick accepts valid 40-char hex format', async () => {
423
+ await expect(
424
+ cherryPick('/tmp', '1234567890abcdef1234567890abcdef12345678')
425
+ ).rejects.not.toThrow('Invalid commit hash');
426
+ });
427
+
428
+ it('revertCommit rejects non-hex hash', async () => {
429
+ await expect(revertCommit('/tmp', '--hard')).rejects.toThrow('Invalid commit hash');
430
+ });
431
+
432
+ it('revertCommit rejects ref name', async () => {
433
+ await expect(revertCommit('/tmp', 'HEAD')).rejects.toThrow('Invalid commit hash');
434
+ });
435
+
436
+ it('revertCommit accepts valid 40-char hex format', async () => {
437
+ await expect(
438
+ revertCommit('/tmp', '1234567890abcdef1234567890abcdef12345678')
439
+ ).rejects.not.toThrow('Invalid commit hash');
440
+ });
441
+
442
+ it('resetToCommit rejects non-hex hash', async () => {
443
+ await expect(resetToCommit('/tmp', '--hard', 'soft')).rejects.toThrow('Invalid commit hash');
444
+ });
445
+
446
+ it('resetToCommit rejects ref name', async () => {
447
+ await expect(resetToCommit('/tmp', 'HEAD', 'soft')).rejects.toThrow('Invalid commit hash');
448
+ });
449
+
450
+ it('resetToCommit accepts valid 40-char hex format', async () => {
451
+ await expect(
452
+ resetToCommit('/tmp', '1234567890abcdef1234567890abcdef12345678', 'soft')
453
+ ).rejects.not.toThrow('Invalid commit hash');
48
454
  });
49
455
  });
@@ -51,6 +51,30 @@ export const createOpenCodeEnvRuntime = (deps) => {
51
51
  }
52
52
  };
53
53
 
54
+ const resolveWindowsExecutablePath = (candidate) => {
55
+ if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) {
56
+ return candidate;
57
+ }
58
+
59
+ const trimmed = candidate.trim();
60
+ const ext = path.extname(trimmed).toLowerCase();
61
+ if (ext) {
62
+ return isExecutable(trimmed) ? trimmed : null;
63
+ }
64
+
65
+ const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
66
+ for (const rawExt of pathExt.split(';')) {
67
+ const normalizedExt = rawExt.trim();
68
+ if (!normalizedExt) continue;
69
+ const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`;
70
+ if (isExecutable(withExt)) {
71
+ return withExt;
72
+ }
73
+ }
74
+
75
+ return isExecutable(trimmed) ? trimmed : null;
76
+ };
77
+
54
78
  const searchPathFor = (binaryName) => {
55
79
  const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
56
80
  if (!trimmed) {
@@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
59
83
 
60
84
  const current = process.env.PATH || '';
61
85
  const parts = current.split(path.delimiter).filter(Boolean);
62
- const candidateNames = [trimmed];
86
+ const candidateNames = [];
63
87
 
64
88
  if (process.platform === 'win32' && !path.extname(trimmed)) {
65
89
  const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
@@ -73,6 +97,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
73
97
  }
74
98
  }
75
99
 
100
+ candidateNames.push(trimmed);
101
+
76
102
  for (const dir of parts) {
77
103
  for (const candidateName of candidateNames) {
78
104
  const candidate = path.join(dir, candidateName);
@@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
649
675
  if (!trimmed) {
650
676
  return null;
651
677
  }
678
+ if (process.platform === 'win32') {
679
+ return resolveWindowsExecutablePath(trimmed);
680
+ }
652
681
  return isExecutable(trimmed) ? trimmed : null;
653
682
  };
654
683
 
@@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
669
698
  return null;
670
699
  }
671
700
 
701
+ const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe');
702
+ if (isExecutable(packageShim)) {
703
+ return packageShim;
704
+ }
705
+
672
706
  for (const packageName of getWindowsNativeOpencodePackageNames()) {
673
- const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe');
674
- if (isExecutable(candidate)) {
675
- return candidate;
707
+ const candidates = [
708
+ path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'),
709
+ path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'),
710
+ ];
711
+ for (const candidate of candidates) {
712
+ if (isExecutable(candidate)) {
713
+ return candidate;
714
+ }
676
715
  }
677
716
  }
678
717
 
@@ -816,6 +855,15 @@ export const createOpenCodeEnvRuntime = (deps) => {
816
855
 
817
856
  const directBinary = normalizeExecutableCandidate(candidate);
818
857
  if (directBinary) {
858
+ const directExt = path.extname(directBinary).toLowerCase();
859
+ if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) {
860
+ return {
861
+ binary: process.env.ComSpec || 'cmd.exe',
862
+ args: ['/d', '/s', '/c', 'call', directBinary],
863
+ wrapperType: 'cmd-wrapper',
864
+ };
865
+ }
866
+
819
867
  return {
820
868
  binary: directBinary,
821
869
  args: [],
@@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { createOpenCodeEnvRuntime } from './env-runtime.js';
6
6
 
7
7
  const originalOpencodeBinary = process.env.OPENCODE_BINARY;
8
+ const originalComSpec = process.env.ComSpec;
9
+ const originalPath = process.env.PATH;
10
+ const originalSystemRoot = process.env.SystemRoot;
11
+ const originalWslBinary = process.env.WSL_BINARY;
12
+ const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
8
13
  const originalPlatform = process.platform;
9
14
  const tempDirs = [];
10
15
  const itIf = (condition) => condition ? it : it.skip;
@@ -32,9 +37,39 @@ afterEach(() => {
32
37
 
33
38
  if (typeof originalOpencodeBinary === 'string') {
34
39
  process.env.OPENCODE_BINARY = originalOpencodeBinary;
35
- return;
40
+ } else {
41
+ delete process.env.OPENCODE_BINARY;
42
+ }
43
+
44
+ if (typeof originalComSpec === 'string') {
45
+ process.env.ComSpec = originalComSpec;
46
+ } else {
47
+ delete process.env.ComSpec;
48
+ }
49
+
50
+ if (typeof originalPath === 'string') {
51
+ process.env.PATH = originalPath;
52
+ } else {
53
+ delete process.env.PATH;
54
+ }
55
+
56
+ if (typeof originalSystemRoot === 'string') {
57
+ process.env.SystemRoot = originalSystemRoot;
58
+ } else {
59
+ delete process.env.SystemRoot;
60
+ }
61
+
62
+ if (typeof originalWslBinary === 'string') {
63
+ process.env.WSL_BINARY = originalWslBinary;
64
+ } else {
65
+ delete process.env.WSL_BINARY;
66
+ }
67
+
68
+ if (typeof originalOpenChamberWslBinary === 'string') {
69
+ process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary;
70
+ } else {
71
+ delete process.env.OPENCHAMBER_WSL_BINARY;
36
72
  }
37
- delete process.env.OPENCODE_BINARY;
38
73
  });
39
74
 
40
75
  const createRuntime = (settings) => {
@@ -103,14 +138,55 @@ describe('OpenCode env runtime', () => {
103
138
  });
104
139
  });
105
140
 
106
- it('does not classify failed WSL resolution as an invalid configured binary in strict mode', async () => {
141
+ it('does not classify WSL settings as a native invalid configured binary in strict mode', async () => {
107
142
  setPlatform('win32');
143
+ const dir = createTempDir('openchamber-no-wsl-');
144
+ process.env.PATH = dir;
145
+ process.env.SystemRoot = dir;
146
+ process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe');
147
+ process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe');
108
148
  const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' });
109
149
 
110
150
  const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true });
111
151
 
112
- await expect(rejection).rejects.toThrow('uses WSL');
113
- const error = await rejection.catch((caught) => caught);
114
- expect(error.code).toBeUndefined();
152
+ try {
153
+ await rejection;
154
+ expect(runtime.resolveManagedOpenCodeLaunchSpec('opencode').wrapperType).not.toBe('cmd-wrapper');
155
+ } catch (error) {
156
+ expect(error.message).toContain('uses WSL');
157
+ expect(error.code).toBeUndefined();
158
+ }
159
+ });
160
+
161
+ it('launches Windows cmd shims through cmd call without embedded quotes', () => {
162
+ setPlatform('win32');
163
+ process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
164
+ const dir = createTempDir('openchamber-opencode-cmd-');
165
+ const shim = path.join(dir, 'opencode.cmd');
166
+ fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n');
167
+ const { runtime } = createRuntime({});
168
+
169
+ expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
170
+ binary: 'C:\\Windows\\System32\\cmd.exe',
171
+ args: ['/d', '/s', '/c', 'call', shim],
172
+ wrapperType: 'cmd-wrapper',
173
+ });
174
+ });
175
+
176
+ it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => {
177
+ setPlatform('win32');
178
+ const npmDir = createTempDir('openchamber-opencode-npm-');
179
+ const shim = path.join(npmDir, 'opencode.cmd');
180
+ const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
181
+ fs.mkdirSync(path.dirname(nativeBinary), { recursive: true });
182
+ fs.writeFileSync(nativeBinary, '');
183
+ fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n');
184
+ const { runtime } = createRuntime({});
185
+
186
+ expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
187
+ binary: nativeBinary,
188
+ args: [],
189
+ wrapperType: 'native-wrapper',
190
+ });
115
191
  });
116
192
  });