@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
@@ -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
  });
@@ -0,0 +1,209 @@
1
+ import { spawn, spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
6
+ const NGROK_API_URL = 'http://127.0.0.1:4040/api/tunnels';
7
+ const NGROK_INSTALL_HELP = 'brew install ngrok';
8
+ const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken <your-ngrok-token>';
9
+
10
+ async function searchPathFor(command) {
11
+ const pathValue = process.env.PATH || '';
12
+ const segments = pathValue.split(path.delimiter).filter(Boolean);
13
+ const WINDOWS_EXTENSIONS = process.platform === 'win32'
14
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
15
+ .split(';')
16
+ .map((ext) => ext.trim().toLowerCase())
17
+ .filter(Boolean)
18
+ .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
19
+ : [''];
20
+
21
+ for (const dir of segments) {
22
+ for (const ext of WINDOWS_EXTENSIONS) {
23
+ const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
24
+ const candidate = path.join(dir, fileName);
25
+ try {
26
+ const stats = fs.statSync(candidate);
27
+ if (!stats.isFile()) {
28
+ continue;
29
+ }
30
+ if (process.platform !== 'win32') {
31
+ try {
32
+ fs.accessSync(candidate, fs.constants.X_OK);
33
+ } catch {
34
+ continue;
35
+ }
36
+ }
37
+ return candidate;
38
+ } catch {
39
+ continue;
40
+ }
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export async function checkNgrokAvailable() {
47
+ const ngrokPath = await searchPathFor('ngrok');
48
+ if (ngrokPath) {
49
+ try {
50
+ const result = spawnSync(ngrokPath, ['version'], {
51
+ encoding: 'utf8',
52
+ stdio: ['pipe', 'pipe', 'pipe'],
53
+ windowsHide: true,
54
+ });
55
+ if (result.status === 0) {
56
+ return { available: true, path: ngrokPath, version: result.stdout.trim() || result.stderr.trim() };
57
+ }
58
+ } catch {
59
+ // Ignore and report unavailable below.
60
+ }
61
+ }
62
+ return { available: false, path: null, version: null };
63
+ }
64
+
65
+ export async function checkNgrokAuthtokenConfigured(ngrokPath = null) {
66
+ if (typeof process.env.NGROK_AUTHTOKEN === 'string' && process.env.NGROK_AUTHTOKEN.trim().length > 0) {
67
+ return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
68
+ }
69
+
70
+ const resolvedPath = ngrokPath || await searchPathFor('ngrok');
71
+ if (!resolvedPath) {
72
+ return { configured: false, detail: `ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}` };
73
+ }
74
+
75
+ try {
76
+ const result = spawnSync(resolvedPath, ['config', 'check'], {
77
+ encoding: 'utf8',
78
+ stdio: ['pipe', 'pipe', 'pipe'],
79
+ windowsHide: true,
80
+ });
81
+ const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
82
+ if (result.status === 0) {
83
+ return { configured: true, detail: output || 'ngrok config is valid.' };
84
+ }
85
+ return { configured: false, detail: output || NGROK_AUTHTOKEN_HELP };
86
+ } catch (error) {
87
+ return {
88
+ configured: false,
89
+ detail: error instanceof Error ? error.message : String(error),
90
+ };
91
+ }
92
+ }
93
+
94
+ export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
95
+ if (typeof fetchImpl !== 'function') {
96
+ return { reachable: false, status: null, error: 'Fetch API is unavailable in this runtime.' };
97
+ }
98
+
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
101
+ try {
102
+ const response = await fetchImpl('https://api.ngrok.com/', {
103
+ method: 'GET',
104
+ signal: controller.signal,
105
+ });
106
+ return { reachable: true, status: response.status, error: null };
107
+ } catch (error) {
108
+ return {
109
+ reachable: false,
110
+ status: null,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ };
113
+ } finally {
114
+ clearTimeout(timeout);
115
+ }
116
+ }
117
+
118
+ const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
119
+ stdio: ['ignore', 'pipe', 'pipe'],
120
+ windowsHide: true,
121
+ env: process.env,
122
+ killSignal: 'SIGINT',
123
+ });
124
+
125
+ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
126
+ if (typeof fetchImpl !== 'function') {
127
+ return null;
128
+ }
129
+ try {
130
+ const response = await fetchImpl(NGROK_API_URL, { method: 'GET' });
131
+ if (!response.ok) {
132
+ return null;
133
+ }
134
+ const payload = await response.json();
135
+ const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
136
+ const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && typeof entry?.public_url === 'string');
137
+ const fallbackTunnel = tunnels.find((entry) => typeof entry?.public_url === 'string');
138
+ return httpsTunnel?.public_url || fallbackTunnel?.public_url || null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ export async function startNgrokQuickTunnel({ port }) {
145
+ const ngrokCheck = await checkNgrokAvailable();
146
+ if (!ngrokCheck.available) {
147
+ throw new Error(`ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}`);
148
+ }
149
+
150
+ const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path);
151
+ if (!authtokenCheck.configured) {
152
+ throw new Error(`ngrok authtoken is not configured. ${NGROK_AUTHTOKEN_HELP}`);
153
+ }
154
+
155
+ if (!Number.isFinite(port)) {
156
+ throw new Error('A local port is required to start an ngrok tunnel');
157
+ }
158
+
159
+ const child = spawnNgrok(['http', String(port)], ngrokCheck.path);
160
+ let publicUrl = null;
161
+
162
+ child.stdout.on('data', () => {
163
+ // Keep stream drained; ngrok exposes the URL via its local API.
164
+ });
165
+
166
+ child.stderr.on('data', (chunk) => {
167
+ process.stderr.write(chunk.toString('utf8'));
168
+ });
169
+
170
+ child.on('error', (error) => {
171
+ console.error(`Ngrok error: ${error.message}`);
172
+ });
173
+
174
+ await new Promise((resolve, reject) => {
175
+ const timeout = setTimeout(() => {
176
+ clearInterval(checkReady);
177
+ try { child.kill('SIGINT'); } catch { /* ignore */ }
178
+ reject(new Error('Ngrok tunnel URL not received within 30 seconds'));
179
+ }, DEFAULT_STARTUP_TIMEOUT_MS);
180
+
181
+ const checkReady = setInterval(async () => {
182
+ publicUrl = await fetchNgrokPublicUrl();
183
+ if (publicUrl) {
184
+ clearTimeout(timeout);
185
+ clearInterval(checkReady);
186
+ resolve(null);
187
+ }
188
+ }, 250);
189
+
190
+ child.once('exit', (code) => {
191
+ clearTimeout(timeout);
192
+ clearInterval(checkReady);
193
+ reject(new Error(`Ngrok exited while starting (code ${code ?? 'unknown'})`));
194
+ });
195
+ });
196
+
197
+ return {
198
+ mode: 'quick',
199
+ stop: () => {
200
+ try {
201
+ child.kill('SIGINT');
202
+ } catch {
203
+ // Ignore.
204
+ }
205
+ },
206
+ process: child,
207
+ getPublicUrl: () => publicUrl,
208
+ };
209
+ }
@@ -521,6 +521,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
521
521
  req.path.startsWith('/api/config/snippets') ||
522
522
  req.path.startsWith('/api/config/settings') ||
523
523
  req.path.startsWith('/api/config/skills') ||
524
+ req.path.startsWith('/api/config/plugins') ||
524
525
  req.path.startsWith('/api/projects') ||
525
526
  req.path.startsWith('/api/fs') ||
526
527
  req.path.startsWith('/api/git') ||