@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.
- package/README.md +6 -0
- package/bin/cli.js +443 -2
- package/dist/assets/{MarkdownRendererImpl-C3-ZpwEx.js → MarkdownRendererImpl-DaF15QNC.js} +1 -1
- package/dist/assets/{MultiRunWindow-BDfPzMDy.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
- package/dist/assets/{OnboardingScreen-DGgh4IXB.js → OnboardingScreen-DTv6YJI1.js} +2 -2
- package/dist/assets/{SettingsWindow-B8QKr5dB.js → SettingsWindow-_c3TTL2z.js} +1 -1
- package/dist/assets/{TerminalView-D7IIkSGJ.js → TerminalView-CuXkDROt.js} +4 -4
- package/dist/assets/es-CYoUf2D-.js +15 -0
- package/dist/assets/{index-DHluop4D.js → index-3WXrN3AX.js} +1 -1
- package/dist/assets/index-BREIbhcb.css +1 -0
- package/dist/assets/ko-2tM0fIna.js +15 -0
- package/dist/assets/main-BF3kWAJ9.js +239 -0
- package/dist/assets/{main-VVcyjpiF.js → main-o8ZERrmU.js} +2 -2
- package/dist/assets/miniChat-BZQjpK23.js +2 -0
- package/dist/assets/{modelPrefsAutoSave-Ctdc3cCY.js → modelPrefsAutoSave-wwnbqBk7.js} +109 -107
- package/dist/assets/pl-Dq8uAotM.js +15 -0
- package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-CsddCM3q.js → renderElectronMiniChatApp-C-Ezew9P.js} +2 -2
- package/dist/assets/uk-BZtz0wUV.js +15 -0
- package/dist/assets/{vendor-.bun-Bum-iBXX.js → vendor-.bun-CV3tusA8.js} +1 -1
- package/dist/assets/zh-CN-j_nYMchE.js +15 -0
- package/dist/assets/zh-TW-B11UpkDJ.js +15 -0
- package/dist/index.html +11 -28
- package/dist/mini-chat.html +4 -4
- package/package.json +1 -1
- package/server/index.js +2 -0
- package/server/lib/cloudflare-tunnel.js +3 -5
- package/server/lib/fs/routes.js +5 -0
- package/server/lib/fs/routes.test.js +61 -1
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +82 -1
- package/server/lib/git/service.js +338 -19
- package/server/lib/git/service.test.js +414 -8
- package/server/lib/ngrok-tunnel.js +209 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/env-runtime.js +52 -4
- package/server/lib/opencode/env-runtime.test.js +82 -6
- package/server/lib/opencode/feature-routes-runtime.js +35 -0
- package/server/lib/opencode/index.js +19 -0
- package/server/lib/opencode/npm-registry.js +157 -0
- package/server/lib/opencode/npm-registry.test.js +179 -0
- package/server/lib/opencode/openchamber-routes.js +9 -7
- package/server/lib/opencode/plugin-routes.js +373 -0
- package/server/lib/opencode/plugin-routes.test.js +384 -0
- package/server/lib/opencode/plugin-spec.js +107 -0
- package/server/lib/opencode/plugin-spec.test.js +154 -0
- package/server/lib/opencode/plugins.js +393 -0
- package/server/lib/opencode/plugins.test.js +176 -0
- package/server/lib/opencode/settings-helpers.js +6 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -0
- package/server/lib/opencode/settings-runtime.js +39 -1
- package/server/lib/opencode/settings-runtime.test.js +39 -0
- package/server/lib/skills-catalog/source.js +1 -1
- package/server/lib/tunnels/DOCUMENTATION.md +1 -0
- package/server/lib/tunnels/providers/ngrok.js +117 -0
- package/server/lib/tunnels/types.js +2 -0
- package/dist/assets/es-dIVpApmS.js +0 -15
- package/dist/assets/index-Bk9IWJe1.css +0 -1
- package/dist/assets/ko-Cqf3E9-d.js +0 -15
- package/dist/assets/main-D45l3Dxw.js +0 -232
- package/dist/assets/miniChat-a9w7WM0c.js +0 -2
- package/dist/assets/pl-C577DpsX.js +0 -15
- package/dist/assets/pt-BR-BeeF6VlK.js +0 -15
- package/dist/assets/uk-CZ7XVz_D.js +0 -15
- package/dist/assets/zh-CN-BMSSqdyO.js +0 -15
|
@@ -1,23 +1,86 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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(
|
|
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(
|
|
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') ||
|