xp-gate 0.5.1
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/adapter-common.sh +192 -0
- package/adapters/cpp.sh +76 -0
- package/adapters/dart.sh +41 -0
- package/adapters/flutter.sh +41 -0
- package/adapters/go.sh +59 -0
- package/adapters/iac.sh +189 -0
- package/adapters/java.sh +191 -0
- package/adapters/kotlin.sh +77 -0
- package/adapters/objectivec.sh +38 -0
- package/adapters/powershell.sh +138 -0
- package/adapters/python.sh +104 -0
- package/adapters/shell.sh +55 -0
- package/adapters/swift.sh +44 -0
- package/adapters/typescript.sh +61 -0
- package/bin/xp-gate.js +157 -0
- package/hooks/adapter-common.sh +192 -0
- package/hooks/pre-commit +1667 -0
- package/hooks/pre-push +395 -0
- package/lib/__tests__/detect-deps.test.js +209 -0
- package/lib/__tests__/doctor.test.js +448 -0
- package/lib/__tests__/download-skill.test.js +281 -0
- package/lib/__tests__/init.test.js +327 -0
- package/lib/__tests__/install-skill.test.js +326 -0
- package/lib/__tests__/migrate.test.js +212 -0
- package/lib/__tests__/rollback.test.js +183 -0
- package/lib/__tests__/ui-detector.test.ts +200 -0
- package/lib/__tests__/uninstall-skill.test.js +189 -0
- package/lib/__tests__/uninstall.test.js +589 -0
- package/lib/__tests__/update-skill.test.js +276 -0
- package/lib/detect-deps.js +157 -0
- package/lib/doctor.js +370 -0
- package/lib/download-skill.js +96 -0
- package/lib/init.js +367 -0
- package/lib/install-skill.js +184 -0
- package/lib/migrate.js +120 -0
- package/lib/rollback.js +78 -0
- package/lib/ui-detector.ts +99 -0
- package/lib/uninstall-skill.js +69 -0
- package/lib/uninstall.js +401 -0
- package/lib/update-skill.js +90 -0
- package/package.json +39 -0
- package/plugins/claude-code/.claude-plugin/plugin.json +21 -0
- package/plugins/claude-code/bin/delphi-review-guard.sh +68 -0
- package/plugins/claude-code/bin/xp-gate-check +47 -0
- package/plugins/claude-code/hooks/hooks.json +37 -0
- package/skills/delphi-review/.delphi-config.json.example +45 -0
- package/skills/delphi-review/AGENTS.md +54 -0
- package/skills/delphi-review/INSTALL.md +152 -0
- package/skills/delphi-review/SKILL.md +371 -0
- package/skills/delphi-review/evals/evals.json +82 -0
- package/skills/delphi-review/opencode.json.delphi.example +56 -0
- package/skills/delphi-review/references/code-walkthrough.md +486 -0
- package/skills/ralph-loop/SKILL.md +330 -0
- package/skills/ralph-loop/evals/evals.json +311 -0
- package/skills/ralph-loop/evolution-history.json +59 -0
- package/skills/ralph-loop/evolution-log.md +16 -0
- package/skills/ralph-loop/references/components/memory.md +55 -0
- package/skills/ralph-loop/references/components/middleware.md +54 -0
- package/skills/ralph-loop/references/components/skill-invocations.md +39 -0
- package/skills/ralph-loop/references/components/system-prompt.md +24 -0
- package/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
- package/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
- package/skills/ralph-loop/templates/progress-log.md +36 -0
- package/skills/sprint-flow/SKILL.md +600 -0
- package/skills/sprint-flow/evals/evals.json +78 -0
- package/skills/sprint-flow/evolution-history.json +39 -0
- package/skills/sprint-flow/evolution-log.md +23 -0
- package/skills/sprint-flow/references/components/memory.md +87 -0
- package/skills/sprint-flow/references/components/middleware.md +72 -0
- package/skills/sprint-flow/references/components/skill-invocations.md +104 -0
- package/skills/sprint-flow/references/components/system-prompt.md +27 -0
- package/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
- package/skills/sprint-flow/references/phase-0-think.md +115 -0
- package/skills/sprint-flow/references/phase-1-plan.md +178 -0
- package/skills/sprint-flow/references/phase-2-build.md +198 -0
- package/skills/sprint-flow/references/phase-3-review.md +213 -0
- package/skills/sprint-flow/references/phase-4-uat.md +125 -0
- package/skills/sprint-flow/references/phase-5-feedback.md +100 -0
- package/skills/sprint-flow/references/phase-6-ship.md +193 -0
- package/skills/sprint-flow/references/phase-7-land.md +140 -0
- package/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
- package/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
- package/skills/sprint-flow/templates/pain-document-template.md +115 -0
- package/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
- package/skills/test-specification-alignment/AGENTS.md +59 -0
- package/skills/test-specification-alignment/SKILL.md +605 -0
- package/skills/test-specification-alignment/evals/evals.json +75 -0
- package/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
- package/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
- package/skills/test-specification-alignment/references/specification-format.md +348 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test download-skill
|
|
3
|
+
* @intent Verify downloadFromGitHub/downloadTarball/downloadWithRetry/downloadFile/verifyChecksum behaviors
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { EventEmitter } = require('events');
|
|
11
|
+
const { Writable } = require('stream');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mock https.get with controllable behavior.
|
|
15
|
+
* opts (or array of opts for sequential calls):
|
|
16
|
+
* - statusCode (default 200)
|
|
17
|
+
* - body (default 'data')
|
|
18
|
+
* - errorAfter (boolean) — emit 'error' on the request instead of responding
|
|
19
|
+
* - redirectTo (string url) — sends 302 with location header
|
|
20
|
+
*/
|
|
21
|
+
function mockHttpsGet(optsOrList) {
|
|
22
|
+
const list = Array.isArray(optsOrList) ? [...optsOrList] : [optsOrList];
|
|
23
|
+
// Synchronous fake createWriteStream — the real one opens the FD asynchronously
|
|
24
|
+
// and races against downloadFile's unlinkSync(dest) in 301/302/error branches,
|
|
25
|
+
// causing ENOENT unhandled errors. The fake writes via writeFileSync (sync,
|
|
26
|
+
// file present immediately) and emits 'finish' on end().
|
|
27
|
+
vi.spyOn(fs, 'createWriteStream').mockImplementation((dest) => {
|
|
28
|
+
let buf = '';
|
|
29
|
+
try {
|
|
30
|
+
fs.writeFileSync(dest, '');
|
|
31
|
+
} catch {
|
|
32
|
+
/* dest dir may not exist; downstream code will surface that */
|
|
33
|
+
}
|
|
34
|
+
const stream = new EventEmitter();
|
|
35
|
+
stream.write = (chunk) => {
|
|
36
|
+
buf += chunk;
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
stream.end = () => {
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(dest, buf);
|
|
42
|
+
} catch {
|
|
43
|
+
/* file may have been unlinked by source; safe to ignore */
|
|
44
|
+
}
|
|
45
|
+
process.nextTick(() => stream.emit('finish'));
|
|
46
|
+
};
|
|
47
|
+
stream.close = () => {};
|
|
48
|
+
return stream;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
vi.spyOn(https, 'get').mockImplementation((url, options, cb) => {
|
|
52
|
+
const callback = typeof options === 'function' ? options : cb;
|
|
53
|
+
const req = new EventEmitter();
|
|
54
|
+
const config = list.shift() || { statusCode: 200, body: 'data' };
|
|
55
|
+
process.nextTick(() => {
|
|
56
|
+
if (config.errorAfter) {
|
|
57
|
+
req.emit('error', new Error('Network error'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const response = new EventEmitter();
|
|
61
|
+
response.statusCode = config.statusCode != null ? config.statusCode : 200;
|
|
62
|
+
response.headers = config.redirectTo ? { location: config.redirectTo } : {};
|
|
63
|
+
response.pipe = (file) => {
|
|
64
|
+
file.write(config.body != null ? config.body : 'data');
|
|
65
|
+
file.end();
|
|
66
|
+
};
|
|
67
|
+
callback(response);
|
|
68
|
+
});
|
|
69
|
+
return req;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('download-skill', () => {
|
|
74
|
+
let tmpHome, originalHome;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
originalHome = process.env.HOME;
|
|
78
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dl-'));
|
|
79
|
+
process.env.HOME = tmpHome;
|
|
80
|
+
vi.resetModules();
|
|
81
|
+
delete require.cache[require.resolve('../download-skill')];
|
|
82
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
83
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
84
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
90
|
+
process.env.HOME = originalHome;
|
|
91
|
+
vi.restoreAllMocks();
|
|
92
|
+
if (fs.existsSync(tmpHome)) {
|
|
93
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function cacheDir() {
|
|
98
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'cache');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('downloadFromGitHub', () => {
|
|
102
|
+
// Flat repo name avoids '/' becoming a subdir in cacheFile (source only mkdirs CACHE_DIR root).
|
|
103
|
+
it('downloads file to cache dir and returns the path', async () => {
|
|
104
|
+
mockHttpsGet({ statusCode: 200, body: '# Skill content' });
|
|
105
|
+
const { downloadFromGitHub } = require('../download-skill');
|
|
106
|
+
|
|
107
|
+
const result = await downloadFromGitHub('flatrepo', 'skills/foo/SKILL.md');
|
|
108
|
+
|
|
109
|
+
expect(result).toBe(
|
|
110
|
+
path.join(cacheDir(), 'flatrepo-skills-foo-SKILL.md.md')
|
|
111
|
+
);
|
|
112
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
113
|
+
expect(fs.readFileSync(result, 'utf8')).toBe('# Skill content');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('creates cache directory if missing', async () => {
|
|
117
|
+
mockHttpsGet({ statusCode: 200, body: 'data' });
|
|
118
|
+
const { downloadFromGitHub } = require('../download-skill');
|
|
119
|
+
|
|
120
|
+
expect(fs.existsSync(cacheDir())).toBe(false);
|
|
121
|
+
await downloadFromGitHub('flatrepo', 'a/b');
|
|
122
|
+
expect(fs.existsSync(cacheDir())).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('uses default version=main when not specified', async () => {
|
|
126
|
+
mockHttpsGet({ statusCode: 200, body: 'data' });
|
|
127
|
+
const { downloadFromGitHub } = require('../download-skill');
|
|
128
|
+
await downloadFromGitHub('flatrepo', 'path/to/file');
|
|
129
|
+
expect(https.get).toHaveBeenCalled();
|
|
130
|
+
const calledUrl = https.get.mock.calls[0][0];
|
|
131
|
+
expect(calledUrl).toContain('/main/');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('uses custom version when specified', async () => {
|
|
135
|
+
mockHttpsGet({ statusCode: 200, body: 'data' });
|
|
136
|
+
const { downloadFromGitHub } = require('../download-skill');
|
|
137
|
+
await downloadFromGitHub('flatrepo', 'path/file', 'v1.2.3');
|
|
138
|
+
expect(https.get.mock.calls[0][0]).toContain('/v1.2.3/');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('downloadTarball', () => {
|
|
143
|
+
it('downloads tarball with correct filename', async () => {
|
|
144
|
+
mockHttpsGet({ statusCode: 200, body: 'tarball-bytes' });
|
|
145
|
+
const { downloadTarball } = require('../download-skill');
|
|
146
|
+
|
|
147
|
+
const result = await downloadTarball('boyingliu01/xp-gate');
|
|
148
|
+
|
|
149
|
+
expect(result).toBe(path.join(cacheDir(), 'boyingliu01-xp-gate.tgz'));
|
|
150
|
+
expect(fs.existsSync(result)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('uses default main version', async () => {
|
|
154
|
+
mockHttpsGet({ statusCode: 200, body: 'data' });
|
|
155
|
+
const { downloadTarball } = require('../download-skill');
|
|
156
|
+
await downloadTarball('user/repo');
|
|
157
|
+
expect(https.get.mock.calls[0][0]).toContain('/tarball/main');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('downloadWithRetry', () => {
|
|
162
|
+
it('succeeds on first try when no errors', async () => {
|
|
163
|
+
mockHttpsGet({ statusCode: 200, body: 'ok' });
|
|
164
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
165
|
+
|
|
166
|
+
const dest = path.join(tmpHome, 'out.txt');
|
|
167
|
+
await downloadWithRetry('https://example.com/x', dest, 3);
|
|
168
|
+
|
|
169
|
+
expect(https.get).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe('ok');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('retries and eventually succeeds (sleep delays stubbed)', async () => {
|
|
174
|
+
// Stub setTimeout so sleep() resolves immediately, without patching setImmediate.
|
|
175
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((fn) => {
|
|
176
|
+
Promise.resolve().then(fn);
|
|
177
|
+
return 0;
|
|
178
|
+
});
|
|
179
|
+
mockHttpsGet([
|
|
180
|
+
{ errorAfter: true },
|
|
181
|
+
{ statusCode: 200, body: 'recovered' },
|
|
182
|
+
]);
|
|
183
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
184
|
+
|
|
185
|
+
const dest = path.join(tmpHome, 'retry.txt');
|
|
186
|
+
await downloadWithRetry('https://example.com/x', dest, 3);
|
|
187
|
+
|
|
188
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe('recovered');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws original error after exhausting retries', async () => {
|
|
192
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((fn) => {
|
|
193
|
+
Promise.resolve().then(fn);
|
|
194
|
+
return 0;
|
|
195
|
+
});
|
|
196
|
+
mockHttpsGet([
|
|
197
|
+
{ errorAfter: true },
|
|
198
|
+
{ errorAfter: true },
|
|
199
|
+
]);
|
|
200
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
201
|
+
|
|
202
|
+
const dest = path.join(tmpHome, 'fail.txt');
|
|
203
|
+
await expect(
|
|
204
|
+
downloadWithRetry('https://example.com/x', dest, 2)
|
|
205
|
+
).rejects.toThrow('Network error');
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('downloadFile (via downloadWithRetry retries=1)', () => {
|
|
210
|
+
it('follows 302 redirect to new location', async () => {
|
|
211
|
+
mockHttpsGet([
|
|
212
|
+
{ statusCode: 302, redirectTo: 'https://newlocation.example.com/final' },
|
|
213
|
+
{ statusCode: 200, body: 'redirected-content' },
|
|
214
|
+
]);
|
|
215
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
216
|
+
|
|
217
|
+
const dest = path.join(tmpHome, 'redirect.txt');
|
|
218
|
+
await downloadWithRetry('https://example.com/orig', dest, 1);
|
|
219
|
+
|
|
220
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe('redirected-content');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('follows 301 redirect', async () => {
|
|
224
|
+
mockHttpsGet([
|
|
225
|
+
{ statusCode: 301, redirectTo: 'https://newloc.example/final' },
|
|
226
|
+
{ statusCode: 200, body: 'permanent-redirect' },
|
|
227
|
+
]);
|
|
228
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
229
|
+
|
|
230
|
+
const dest = path.join(tmpHome, 'r301.txt');
|
|
231
|
+
await downloadWithRetry('https://example.com/orig', dest, 1);
|
|
232
|
+
|
|
233
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe('permanent-redirect');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('rejects with HTTP error for non-2xx/3xx status', async () => {
|
|
237
|
+
mockHttpsGet({ statusCode: 404 });
|
|
238
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
239
|
+
|
|
240
|
+
const dest = path.join(tmpHome, '404.txt');
|
|
241
|
+
await expect(downloadWithRetry('https://example.com/x', dest, 1)).rejects.toThrow(
|
|
242
|
+
'HTTP 404'
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('rejects and unlinks dest on request error', async () => {
|
|
247
|
+
mockHttpsGet({ errorAfter: true });
|
|
248
|
+
const { downloadWithRetry } = require('../download-skill');
|
|
249
|
+
|
|
250
|
+
const dest = path.join(tmpHome, 'err.txt');
|
|
251
|
+
await expect(downloadWithRetry('https://example.com/x', dest, 1)).rejects.toThrow(
|
|
252
|
+
'Network error'
|
|
253
|
+
);
|
|
254
|
+
// The createWriteStream call will create the file, the error handler should unlink it
|
|
255
|
+
expect(fs.existsSync(dest)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('verifyChecksum', () => {
|
|
260
|
+
it('does not throw when checksum matches', () => {
|
|
261
|
+
const file = path.join(tmpHome, 'data.txt');
|
|
262
|
+
const content = 'hello world';
|
|
263
|
+
fs.writeFileSync(file, content);
|
|
264
|
+
const expected = crypto.createHash('sha256').update(content).digest('hex');
|
|
265
|
+
|
|
266
|
+
const { verifyChecksum } = require('../download-skill');
|
|
267
|
+
expect(() => verifyChecksum(file, expected)).not.toThrow();
|
|
268
|
+
// File preserved
|
|
269
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('throws and deletes file on mismatch', () => {
|
|
273
|
+
const file = path.join(tmpHome, 'bad.txt');
|
|
274
|
+
fs.writeFileSync(file, 'real-content');
|
|
275
|
+
|
|
276
|
+
const { verifyChecksum } = require('../download-skill');
|
|
277
|
+
expect(() => verifyChecksum(file, 'wrong-hash-zzz')).toThrow(/Checksum mismatch/);
|
|
278
|
+
expect(fs.existsSync(file)).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test init
|
|
3
|
+
* @intent Verify init() correctly handles global/local installation modes, dependency checks, and config persistence
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const childProcess = require('child_process');
|
|
9
|
+
|
|
10
|
+
describe('init', () => {
|
|
11
|
+
let tmpHome;
|
|
12
|
+
let tmpProject;
|
|
13
|
+
let originalHome;
|
|
14
|
+
let logSpy;
|
|
15
|
+
let warnSpy;
|
|
16
|
+
let errorSpy;
|
|
17
|
+
let execSpy;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
originalHome = process.env.HOME;
|
|
21
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-init-'));
|
|
22
|
+
tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-proj-'));
|
|
23
|
+
process.env.HOME = tmpHome;
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
delete require.cache[require.resolve('../init')];
|
|
26
|
+
delete require.cache[require.resolve('../detect-deps.js')];
|
|
27
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
28
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
29
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
process.env.HOME = originalHome;
|
|
34
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
35
|
+
fs.rmSync(tmpProject, { recursive: true, force: true });
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function skillsDir() {
|
|
40
|
+
return path.join(tmpHome, '.config', 'opencode', 'skills');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function configFile() {
|
|
44
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setupValidDeps() {
|
|
48
|
+
['superpowers', 'gstack'].forEach((name) => {
|
|
49
|
+
const dir = path.join(skillsDir(), name);
|
|
50
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(dir, 'package.json'),
|
|
53
|
+
JSON.stringify({ version: '2.0.0' })
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mockExecSuccess() {
|
|
59
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
60
|
+
if (cmd === 'git rev-parse --git-dir') {
|
|
61
|
+
return path.join(tmpProject, '.git') + '\n';
|
|
62
|
+
}
|
|
63
|
+
if (cmd.includes('git config --global')) {
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
return '';
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mockExecGitDirOnly() {
|
|
71
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
72
|
+
if (cmd === 'git rev-parse --git-dir') {
|
|
73
|
+
return path.join(tmpProject, '.git') + '\n';
|
|
74
|
+
}
|
|
75
|
+
if (cmd.includes('git config --global')) {
|
|
76
|
+
throw new Error('git config failed');
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mockExecFail() {
|
|
83
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
|
|
84
|
+
throw new Error('Not a git repo');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it('init([]) prints usage and returns 0', async () => {
|
|
89
|
+
const { init } = require('../init');
|
|
90
|
+
const result = await init([]);
|
|
91
|
+
expect(result).toBe(0);
|
|
92
|
+
expect(logSpy).toHaveBeenCalledWith('Choose installation mode:');
|
|
93
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('init([]) with valid deps logs Dependencies: OK', async () => {
|
|
97
|
+
setupValidDeps();
|
|
98
|
+
const { init } = require('../init');
|
|
99
|
+
const result = await init([]);
|
|
100
|
+
expect(result).toBe(0);
|
|
101
|
+
expect(logSpy).toHaveBeenCalledWith('Dependencies: OK\n');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('init([]) with missing superpowers warns Missing dependencies', async () => {
|
|
105
|
+
const { init } = require('../init');
|
|
106
|
+
const result = await init([]);
|
|
107
|
+
expect(result).toBe(0);
|
|
108
|
+
expect(warnSpy).toHaveBeenCalledWith('Warning: Missing dependencies');
|
|
109
|
+
expect(warnSpy).toHaveBeenCalledWith(' - superpowers (required)');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('init([]) with versionMismatch warns version detail', async () => {
|
|
113
|
+
// superpowers too old, gstack good
|
|
114
|
+
const sp = path.join(skillsDir(), 'superpowers');
|
|
115
|
+
fs.mkdirSync(sp, { recursive: true });
|
|
116
|
+
fs.writeFileSync(path.join(sp, 'package.json'), JSON.stringify({ version: '0.0.1' }));
|
|
117
|
+
const gs = path.join(skillsDir(), 'gstack');
|
|
118
|
+
fs.mkdirSync(gs, { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(gs, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
|
120
|
+
|
|
121
|
+
const { init } = require('../init');
|
|
122
|
+
const result = await init([]);
|
|
123
|
+
expect(result).toBe(0);
|
|
124
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
125
|
+
expect.stringContaining('superpowers: need 1.0.0, found 0.0.1')
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('init --global creates global hooks/adapters dirs and writes config', async () => {
|
|
130
|
+
setupValidDeps();
|
|
131
|
+
mockExecSuccess();
|
|
132
|
+
const { init } = require('../init');
|
|
133
|
+
const result = await init(['--global']);
|
|
134
|
+
expect(result).toBe(0);
|
|
135
|
+
expect(fs.existsSync(path.join(tmpHome, '.config', 'xp-gate', 'hooks'))).toBe(true);
|
|
136
|
+
expect(fs.existsSync(path.join(tmpHome, '.config', 'xp-gate', 'adapters'))).toBe(true);
|
|
137
|
+
expect(fs.existsSync(configFile())).toBe(true);
|
|
138
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
139
|
+
expect(cfg.mode).toBe('global');
|
|
140
|
+
expect(cfg.lastInit).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('init --core-only fails when not in git repo (returns 1)', async () => {
|
|
144
|
+
mockExecFail();
|
|
145
|
+
const { init } = require('../init');
|
|
146
|
+
const result = await init(['--core-only']);
|
|
147
|
+
expect(result).toBe(1);
|
|
148
|
+
expect(errorSpy).toHaveBeenCalledWith('Error: Not a git repository');
|
|
149
|
+
expect(errorSpy).toHaveBeenCalledWith('Run xp-gate init from inside a git repository');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('init --core-only succeeds in git repo: creates hooks dir + githooks dir + config', async () => {
|
|
153
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
154
|
+
mockExecSuccess();
|
|
155
|
+
const { init } = require('../init');
|
|
156
|
+
const result = await init(['--core-only']);
|
|
157
|
+
expect(result).toBe(0);
|
|
158
|
+
expect(fs.existsSync(path.join(tmpProject, 'githooks', 'adapters'))).toBe(true);
|
|
159
|
+
expect(fs.existsSync(configFile())).toBe(true);
|
|
160
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
161
|
+
expect(cfg.mode).toBe('local');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('init --full takes the same installLocal path', async () => {
|
|
165
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
166
|
+
mockExecSuccess();
|
|
167
|
+
const { init } = require('../init');
|
|
168
|
+
const result = await init(['--full']);
|
|
169
|
+
expect(result).toBe(0);
|
|
170
|
+
expect(fs.existsSync(path.join(tmpProject, 'githooks', 'adapters'))).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('init --global preserves pre-existing config keys (merge)', async () => {
|
|
174
|
+
setupValidDeps();
|
|
175
|
+
mockExecSuccess();
|
|
176
|
+
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|
177
|
+
fs.writeFileSync(configFile(), JSON.stringify({ existing: 'data' }));
|
|
178
|
+
|
|
179
|
+
const { init } = require('../init');
|
|
180
|
+
const result = await init(['--global']);
|
|
181
|
+
expect(result).toBe(0);
|
|
182
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
183
|
+
expect(cfg.existing).toBe('data');
|
|
184
|
+
expect(cfg.mode).toBe('global');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('init --global with corrupt JSON config overwrites with valid JSON', async () => {
|
|
188
|
+
setupValidDeps();
|
|
189
|
+
mockExecSuccess();
|
|
190
|
+
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|
191
|
+
fs.writeFileSync(configFile(), '{ invalid json');
|
|
192
|
+
|
|
193
|
+
const { init } = require('../init');
|
|
194
|
+
const result = await init(['--global']);
|
|
195
|
+
expect(result).toBe(0);
|
|
196
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
197
|
+
expect(cfg.mode).toBe('global');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('init --global warns when git config --global throws', async () => {
|
|
201
|
+
setupValidDeps();
|
|
202
|
+
mockExecGitDirOnly();
|
|
203
|
+
const { init } = require('../init');
|
|
204
|
+
const result = await init(['--global']);
|
|
205
|
+
expect(result).toBe(0);
|
|
206
|
+
expect(warnSpy).toHaveBeenCalledWith('Warning: Could not set git core.hooksPath config');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('init --global logs success messages including final summary', async () => {
|
|
210
|
+
setupValidDeps();
|
|
211
|
+
mockExecSuccess();
|
|
212
|
+
const { init } = require('../init');
|
|
213
|
+
await init(['--global']);
|
|
214
|
+
expect(logSpy).toHaveBeenCalledWith('\nGlobal setup complete!');
|
|
215
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
216
|
+
'All git repositories will now use xp-gate quality gates.'
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('init --core-only logs final installation complete', async () => {
|
|
221
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
222
|
+
mockExecSuccess();
|
|
223
|
+
const { init } = require('../init');
|
|
224
|
+
await init(['--core-only']);
|
|
225
|
+
expect(logSpy).toHaveBeenCalledWith('\nInstallation complete!');
|
|
226
|
+
expect(logSpy).toHaveBeenCalledWith('Run git commit to trigger quality gates');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('copyHooks copies pre-commit when source file exists', async () => {
|
|
230
|
+
// Write fake hooks to a temp src dir
|
|
231
|
+
// init.js uses srcDir = path.dirname(__dirname) — the src/npm-package dir.
|
|
232
|
+
// We test indirectly via setupGlobal — verify it doesn't throw and creates dest dir.
|
|
233
|
+
setupValidDeps();
|
|
234
|
+
mockExecSuccess();
|
|
235
|
+
// Pre-create a fake hooks file at the real srcDir location
|
|
236
|
+
const realSrcDir = path.dirname(path.dirname(require.resolve('../init')));
|
|
237
|
+
// realSrcDir = src/npm-package
|
|
238
|
+
const hooksSrcDir = path.join(realSrcDir, 'hooks');
|
|
239
|
+
// Don't pollute repo — just verify the dest dir is created even if hooks not present
|
|
240
|
+
const { init } = require('../init');
|
|
241
|
+
const result = await init(['--global']);
|
|
242
|
+
expect(result).toBe(0);
|
|
243
|
+
const destHooks = path.join(tmpHome, '.config', 'xp-gate', 'hooks');
|
|
244
|
+
expect(fs.existsSync(destHooks)).toBe(true);
|
|
245
|
+
// If real hooks exist in repo, they should have been copied
|
|
246
|
+
if (fs.existsSync(path.join(hooksSrcDir, 'pre-commit'))) {
|
|
247
|
+
expect(fs.existsSync(path.join(destHooks, 'pre-commit'))).toBe(true);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('init --core-only also creates template dir under HOME', async () => {
|
|
252
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
253
|
+
mockExecSuccess();
|
|
254
|
+
const { init } = require('../init');
|
|
255
|
+
const result = await init(['--core-only']);
|
|
256
|
+
expect(result).toBe(0);
|
|
257
|
+
const tplDir = path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
|
|
258
|
+
expect(fs.existsSync(tplDir)).toBe(true);
|
|
259
|
+
expect(fs.existsSync(path.join(tplDir, 'adapters'))).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('init prints XP-Gate Initialization header', async () => {
|
|
263
|
+
const { init } = require('../init');
|
|
264
|
+
await init([]);
|
|
265
|
+
expect(logSpy).toHaveBeenCalledWith('XP-Gate Initialization');
|
|
266
|
+
expect(logSpy).toHaveBeenCalledWith('====================\n');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// --- injectKarpathyPrinciples tests ---
|
|
270
|
+
|
|
271
|
+
it('injectKarpathyPrinciples appends section when AGENTS.md exists without Karpathy Principles', async () => {
|
|
272
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
273
|
+
mockExecSuccess();
|
|
274
|
+
setupValidDeps();
|
|
275
|
+
const agentsMd = path.join(tmpProject, 'AGENTS.md');
|
|
276
|
+
fs.writeFileSync(agentsMd, '# Project\n\nSome content.\n');
|
|
277
|
+
|
|
278
|
+
const { init } = require('../init');
|
|
279
|
+
const result = await init(['--core-only']);
|
|
280
|
+
expect(result).toBe(0);
|
|
281
|
+
|
|
282
|
+
const content = fs.readFileSync(agentsMd, 'utf8');
|
|
283
|
+
expect(content).toContain('## AI CODING DISCIPLINE (Karpathy Principles)');
|
|
284
|
+
expect(content).toContain('原则 3: Surgical Changes');
|
|
285
|
+
expect(content).toContain('原则 4: Goal-Driven Execution');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('injectKarpathyPrinciples is idempotent — second init does not duplicate section', async () => {
|
|
289
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
290
|
+
mockExecSuccess();
|
|
291
|
+
setupValidDeps();
|
|
292
|
+
const agentsMd = path.join(tmpProject, 'AGENTS.md');
|
|
293
|
+
fs.writeFileSync(agentsMd, '# Project\n\nSome content.\n');
|
|
294
|
+
|
|
295
|
+
const { init } = require('../init');
|
|
296
|
+
await init(['--core-only']);
|
|
297
|
+
await init(['--core-only']);
|
|
298
|
+
|
|
299
|
+
const content = fs.readFileSync(agentsMd, 'utf8');
|
|
300
|
+
const matches = content.match(/## AI CODING DISCIPLINE \(Karpathy Principles\)/g);
|
|
301
|
+
expect(matches).toBeTruthy();
|
|
302
|
+
expect(matches.length).toBe(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('injectKarpathyPrinciples appends when AGENTS.md has reference to Karpathy but no section header', async () => {
|
|
306
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
307
|
+
mockExecSuccess();
|
|
308
|
+
setupValidDeps();
|
|
309
|
+
const agentsMd = path.join(tmpProject, 'AGENTS.md');
|
|
310
|
+
fs.writeFileSync(agentsMd, '# Project\n\nSee Karpathy Principles at https://example.com\n');
|
|
311
|
+
|
|
312
|
+
const { init } = require('../init');
|
|
313
|
+
await init(['--core-only']);
|
|
314
|
+
|
|
315
|
+
const content = fs.readFileSync(agentsMd, 'utf8');
|
|
316
|
+
expect(content).toContain('## AI CODING DISCIPLINE (Karpathy Principles)');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('injectKarpathyPrinciples skips gracefully when AGENTS.md does not exist', async () => {
|
|
320
|
+
fs.mkdirSync(path.join(tmpProject, '.git', 'hooks'), { recursive: true });
|
|
321
|
+
mockExecSuccess();
|
|
322
|
+
setupValidDeps();
|
|
323
|
+
const { init } = require('../init');
|
|
324
|
+
const result = await init(['--core-only']);
|
|
325
|
+
expect(result).toBe(0);
|
|
326
|
+
});
|
|
327
|
+
});
|