@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.
- package/README.md +6 -0
- package/bin/cli.js +443 -2
- package/dist/assets/{MarkdownRendererImpl-COdbjw73.js → MarkdownRendererImpl-DaF15QNC.js} +3 -3
- package/dist/assets/{MultiRunWindow-BKSHxjMq.js → MultiRunWindow-Cl7wS_CB.js} +1 -1
- package/dist/assets/{OnboardingScreen-Chjg337p.js → OnboardingScreen-DTv6YJI1.js} +2 -2
- package/dist/assets/{SettingsWindow-C0lRRW8M.js → SettingsWindow-_c3TTL2z.js} +1 -1
- package/dist/assets/{TerminalView-Bvil3j1u.js → TerminalView-CuXkDROt.js} +3 -3
- package/dist/assets/es-CYoUf2D-.js +15 -0
- package/dist/assets/{index-B9LvUHdG.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-Blhx9Fp5.js → main-o8ZERrmU.js} +2 -2
- package/dist/assets/miniChat-BZQjpK23.js +2 -0
- package/dist/assets/{modelPrefsAutoSave-DRJSYigo.js → modelPrefsAutoSave-wwnbqBk7.js} +110 -108
- package/dist/assets/pl-Dq8uAotM.js +15 -0
- package/dist/assets/pt-BR-nh9s9DFT.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-BxZRI73j.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/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/opencode/env-runtime.js +52 -4
- package/server/lib/opencode/env-runtime.test.js +82 -6
- package/server/lib/opencode/openchamber-routes.js +9 -7
- package/server/lib/opencode/settings-helpers.js +3 -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/dist/assets/es-BZIAUghG.js +0 -15
- package/dist/assets/index-UcCH2KN9.css +0 -1
- package/dist/assets/ko-DU9l-zox.js +0 -15
- package/dist/assets/main-d2-dY4er.js +0 -232
- package/dist/assets/miniChat-CJ7-rZFl.js +0 -2
- package/dist/assets/pl-CdqzokG-.js +0 -15
- package/dist/assets/pt-BR-Bknbr_Y3.js +0 -15
- package/dist/assets/uk-Be4E8ZNO.js +0 -15
- package/dist/assets/zh-CN-qpPiaZMg.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
|
});
|
|
@@ -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 = [
|
|
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
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
});
|