@boyingliu01/xp-gate 0.7.0 → 0.7.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/bin/xp-gate.js +4 -9
- package/lib/__tests__/audit-log.test.ts +107 -0
- package/lib/__tests__/detect-deps.test.js +179 -42
- package/lib/__tests__/doctor.test.js +1 -0
- package/lib/__tests__/gate-audit.test.ts +59 -0
- package/lib/__tests__/init.test.js +15 -0
- package/lib/__tests__/install-skill.test.js +1 -45
- package/lib/__tests__/ui-detector.test.ts +122 -8
- package/lib/__tests__/ui-review.test.ts +119 -0
- package/lib/__tests__/uninstall.test.js +1 -0
- package/lib/detect-deps.js +178 -37
- package/lib/doctor.js +7 -9
- package/lib/gate-audit.ts +26 -42
- package/lib/init.js +42 -13
- package/lib/install-skill.js +20 -48
- package/lib/rollback.js +1 -16
- package/lib/shared-paths.js +30 -0
- package/lib/shared-utils.js +25 -0
- package/lib/ui-detector.ts +3 -3
- package/lib/ui-review.ts +58 -53
- package/lib/uninstall.js +8 -9
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/skills/delphi-review/SKILL.md +112 -77
- package/skills/ralph-loop/SKILL.md +165 -34
- package/skills/sprint-flow/SKILL.md +428 -105
- package/skills/sprint-flow/references/components/skill-invocations.md +1 -1
- package/skills/sprint-flow/references/components/tool-descriptions.md +1 -1
- package/skills/sprint-flow/references/force-levels.md +203 -0
- package/skills/sprint-flow/references/phase-1-plan.md +4 -4
- package/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +20 -1
- package/skills/sprint-flow/templates/auto-estimate-output-template.md +7 -5
- package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
- package/skills/test-specification-alignment/SKILL.md +98 -24
- package/plugins/qoder/AGENTS.md +0 -93
- package/plugins/qoder/README.md +0 -87
- package/plugins/qoder/widgets/quality-report.html +0 -163
- package/plugins/qoder/widgets/sprint-dashboard.html +0 -172
- package/skills/delphi-review/references/qoder-multi-model.md +0 -191
- package/skills/sprint-flow/references/qoder-adaptation.md +0 -173
|
@@ -79,14 +79,7 @@ describe('ui-detector', () => {
|
|
|
79
79
|
expect(result.matchedFiles).toContain('views/login.html');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it('should return true for
|
|
83
|
-
mockExecSync.mockReturnValue('views/old.html\n');
|
|
84
|
-
const { detectUiSprint } = await import('../ui-detector');
|
|
85
|
-
const result = detectUiSprint();
|
|
86
|
-
expect(result.isUiSprint).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('should handle renamed files correctly', async () => {
|
|
82
|
+
it('should return true for renamed UI files', async () => {
|
|
90
83
|
mockExecSync.mockReturnValue('views/a.html → views/b.html\n');
|
|
91
84
|
const { detectUiSprint } = await import('../ui-detector');
|
|
92
85
|
const result = detectUiSprint();
|
|
@@ -129,6 +122,29 @@ describe('ui-detector', () => {
|
|
|
129
122
|
});
|
|
130
123
|
});
|
|
131
124
|
|
|
125
|
+
describe('getChangedFiles', () => {
|
|
126
|
+
it('parses git diff output into file array', async () => {
|
|
127
|
+
mockExecSync.mockReturnValue('src/a.ts\nsrc/b.ts\n');
|
|
128
|
+
const { getChangedFiles } = await import('../ui-detector');
|
|
129
|
+
const files = getChangedFiles('main');
|
|
130
|
+
expect(files).toEqual(['src/a.ts', 'src/b.ts']);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns empty array for empty diff', async () => {
|
|
134
|
+
mockExecSync.mockReturnValue('');
|
|
135
|
+
const { getChangedFiles } = await import('../ui-detector');
|
|
136
|
+
const files = getChangedFiles('main');
|
|
137
|
+
expect(files).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('filters out empty lines', async () => {
|
|
141
|
+
mockExecSync.mockReturnValue('src/a.ts\n\nsrc/b.ts\n');
|
|
142
|
+
const { getChangedFiles } = await import('../ui-detector');
|
|
143
|
+
const files = getChangedFiles('main');
|
|
144
|
+
expect(files).toEqual(['src/a.ts', 'src/b.ts']);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
132
148
|
describe('parseRenamedFile', () => {
|
|
133
149
|
it('should extract new path from renamed file', async () => {
|
|
134
150
|
const { parseRenamedFile } = await import('../ui-detector');
|
|
@@ -197,4 +213,102 @@ describe('ui-detector', () => {
|
|
|
197
213
|
expect(rules).toContain('style-.css');
|
|
198
214
|
});
|
|
199
215
|
});
|
|
216
|
+
|
|
217
|
+
describe('collectUiMatches', () => {
|
|
218
|
+
it('should detect UI files in components directory', async () => {
|
|
219
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
220
|
+
const result = collectUiMatches(['src/components/Button.tsx', 'src/utils/helper.ts']);
|
|
221
|
+
expect(result.isUiSprint).toBe(true);
|
|
222
|
+
expect(result.matchedFiles).toEqual(['src/components/Button.tsx']);
|
|
223
|
+
expect(result.matchedRules.length).toBeGreaterThan(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should exclude __tests__ directory files', async () => {
|
|
227
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
228
|
+
const result = collectUiMatches(['src/__tests__/Button.test.tsx']);
|
|
229
|
+
expect(result.isUiSprint).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('excludes files under src/coverage/', async () => {
|
|
233
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
234
|
+
const result = collectUiMatches(['src/coverage/components/Coverage.tsx']);
|
|
235
|
+
expect(result.isUiSprint).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should handle empty file list', async () => {
|
|
239
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
240
|
+
const result = collectUiMatches([]);
|
|
241
|
+
expect(result.isUiSprint).toBe(false);
|
|
242
|
+
expect(result.matchedFiles).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should collect multiple UI files', async () => {
|
|
246
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
247
|
+
const result = collectUiMatches([
|
|
248
|
+
'src/components/Header.tsx',
|
|
249
|
+
'src/utils/api.ts',
|
|
250
|
+
'views/styles/app.css',
|
|
251
|
+
]);
|
|
252
|
+
expect(result.isUiSprint).toBe(true);
|
|
253
|
+
expect(result.matchedFiles).toHaveLength(2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should aggregate unique rules across files', async () => {
|
|
257
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
258
|
+
const result = collectUiMatches([
|
|
259
|
+
'src/components/Button.tsx',
|
|
260
|
+
'src/components/Card.tsx',
|
|
261
|
+
]);
|
|
262
|
+
expect(result.isUiSprint).toBe(true);
|
|
263
|
+
expect(result.matchedFiles.length).toBeGreaterThan(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should respect .ui-gate-ignore patterns', async () => {
|
|
267
|
+
const { collectUiMatches } = await import('../ui-detector');
|
|
268
|
+
const withoutIgnore = collectUiMatches(['legacy/components/Old.tsx']);
|
|
269
|
+
expect(withoutIgnore.isUiSprint).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('isExcluded and loadUiGateIgnore', () => {
|
|
274
|
+
it('excludes paths matching **/coverage/** with leading directory', async () => {
|
|
275
|
+
const { isExcluded } = await import('../ui-detector');
|
|
276
|
+
expect(isExcluded('src/coverage/report.html', ['**/coverage/**'])).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not match non-excluded paths', async () => {
|
|
280
|
+
const { isExcluded } = await import('../ui-detector');
|
|
281
|
+
expect(isExcluded('src/app.ts', ['**/coverage/**'])).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('returns empty array when .ui-gate-ignore does not exist', async () => {
|
|
285
|
+
const { loadUiGateIgnore } = await import('../ui-detector');
|
|
286
|
+
expect(loadUiGateIgnore('/tmp')).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('excludes paths matching **/node_modules/** with leading directory', async () => {
|
|
290
|
+
const { isExcluded } = await import('../ui-detector');
|
|
291
|
+
expect(isExcluded('lib/node_modules/pkg/index.js', ['**/node_modules/**'])).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('excludes paths matching **/dist/** with leading directory', async () => {
|
|
295
|
+
const { isExcluded } = await import('../ui-detector');
|
|
296
|
+
expect(isExcluded('src/dist/bundle.js', ['**/dist/**'])).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('excludes paths matching **/build/** with leading directory', async () => {
|
|
300
|
+
const { isExcluded } = await import('../ui-detector');
|
|
301
|
+
expect(isExcluded('src/build/output.js', ['**/build/**'])).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('excludes paths matching **/__tests__/**', async () => {
|
|
305
|
+
const { isExcluded } = await import('../ui-detector');
|
|
306
|
+
expect(isExcluded('src/__tests__/Button.test.tsx', ['**/__tests__/**'])).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('excludes paths matching **/*.test.*', async () => {
|
|
310
|
+
const { isExcluded } = await import('../ui-detector');
|
|
311
|
+
expect(isExcluded('src/Button.test.tsx', ['**/*.test.*'])).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
200
314
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test ui-review
|
|
3
|
+
* @intent Verify UI review file generation helpers
|
|
4
|
+
* @covers UI-REVIEW-001
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
vi.mock('child_process', () => ({
|
|
12
|
+
execSync: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const mockExecSync = vi.mocked(execSync);
|
|
16
|
+
const TEST_DIR = join(process.cwd(), '.ui-review-test');
|
|
17
|
+
|
|
18
|
+
describe('ui-review', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
if (existsSync(TEST_DIR)) {
|
|
22
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (existsSync(TEST_DIR)) {
|
|
29
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should parse file list and renamed paths', async () => {
|
|
34
|
+
const { parseFileList } = await import('../ui-review');
|
|
35
|
+
expect(parseFileList('views/a.html → views/b.html\n\nsrc/auth.ts\n')).toEqual([
|
|
36
|
+
'views/b.html',
|
|
37
|
+
'src/auth.ts',
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return changed files from staged and modified diff', async () => {
|
|
42
|
+
mockExecSync.mockReturnValue('src/components/Button.tsx\nviews/index.html\n');
|
|
43
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
44
|
+
expect(getChangedFilesForReview()).toEqual(['src/components/Button.tsx', 'views/index.html']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should fall back to tracked files when no changes are present', async () => {
|
|
48
|
+
mockExecSync
|
|
49
|
+
.mockReturnValueOnce('')
|
|
50
|
+
.mockReturnValueOnce('src/components/Fallback.tsx\n');
|
|
51
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
52
|
+
expect(getChangedFilesForReview()).toEqual(['src/components/Fallback.tsx']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should fall back to find command when git ls-files fails', async () => {
|
|
56
|
+
mockExecSync
|
|
57
|
+
.mockReturnValueOnce('')
|
|
58
|
+
.mockImplementationOnce(() => { throw new Error('not git'); })
|
|
59
|
+
.mockReturnValueOnce('views/index.html\n');
|
|
60
|
+
const { getChangedFilesForReview } = await import('../ui-review');
|
|
61
|
+
expect(getChangedFilesForReview()).toEqual(['views/index.html']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should build an approved review result with 24h expiry', async () => {
|
|
65
|
+
mockExecSync.mockReturnValue('abc123\n');
|
|
66
|
+
const { buildUiReviewResult } = await import('../ui-review');
|
|
67
|
+
const now = new Date('2026-06-02T00:00:00.000Z');
|
|
68
|
+
const result = buildUiReviewResult(['views/index.html'], now);
|
|
69
|
+
expect(result).toMatchObject({
|
|
70
|
+
commit: 'abc123',
|
|
71
|
+
verdict: 'APPROVED',
|
|
72
|
+
design_review: 'APPROVED',
|
|
73
|
+
browser_qa: 'APPROVED',
|
|
74
|
+
ui_changes_detected: ['views/index.html'],
|
|
75
|
+
});
|
|
76
|
+
expect(result.expires).toBe('2026-06-03T00:00:00.000Z');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should run main and exit 0 when no UI changes are detected', async () => {
|
|
80
|
+
mockExecSync.mockReturnValue('src/auth.ts\n');
|
|
81
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
|
82
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
83
|
+
const { main } = await import('../ui-review');
|
|
84
|
+
main();
|
|
85
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
86
|
+
expect(logSpy).toHaveBeenCalledWith('ℹ️ No UI changes detected in staged/modified files.');
|
|
87
|
+
exitSpy.mockRestore();
|
|
88
|
+
logSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should run main and write result when UI changes are detected', async () => {
|
|
92
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(TEST_DIR);
|
|
93
|
+
mockExecSync
|
|
94
|
+
.mockReturnValueOnce('views/index.html\n')
|
|
95
|
+
.mockReturnValueOnce('abc123\n');
|
|
96
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
97
|
+
const { main } = await import('../ui-review');
|
|
98
|
+
main();
|
|
99
|
+
expect(existsSync(join(TEST_DIR, '.ui-gate-result.json'))).toBe(true);
|
|
100
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Generated .ui-gate-result.json'));
|
|
101
|
+
cwdSpy.mockRestore();
|
|
102
|
+
logSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should write .ui-gate-result.json', async () => {
|
|
106
|
+
const { writeUiReviewResult } = await import('../ui-review');
|
|
107
|
+
const result = {
|
|
108
|
+
commit: 'abc123',
|
|
109
|
+
verdict: 'APPROVED',
|
|
110
|
+
expires: '2026-06-03T00:00:00.000Z',
|
|
111
|
+
design_review: 'APPROVED',
|
|
112
|
+
browser_qa: 'APPROVED',
|
|
113
|
+
ui_changes_detected: ['views/index.html'],
|
|
114
|
+
};
|
|
115
|
+
writeUiReviewResult(result, TEST_DIR);
|
|
116
|
+
const parsed = JSON.parse(readFileSync(join(TEST_DIR, '.ui-gate-result.json'), 'utf8'));
|
|
117
|
+
expect(parsed.ui_changes_detected).toEqual(['views/index.html']);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -27,6 +27,7 @@ describe('uninstall', () => {
|
|
|
27
27
|
delete require.cache[require.resolve('../uninstall')];
|
|
28
28
|
delete require.cache[require.resolve('../init')];
|
|
29
29
|
delete require.cache[require.resolve('../detect-deps.js')];
|
|
30
|
+
delete require.cache[require.resolve('../shared-paths')];
|
|
30
31
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
31
32
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
32
33
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
package/lib/detect-deps.js
CHANGED
|
@@ -3,29 +3,73 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { execSync } = require('child_process');
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* Resolve the user's home directory cross-platform.
|
|
8
|
-
* Fallback chain: HOME → USERPROFILE → os.homedir()
|
|
9
|
-
* This covers all known Windows/Linux/macOS/edge cases.
|
|
10
|
-
*/
|
|
11
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
12
|
-
|
|
13
|
-
const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
|
|
14
|
-
const OPENCODE_DIR = path.join(HOME, '.config', 'opencode');
|
|
6
|
+
const { HOME_DIR } = require('./shared-paths.js');
|
|
15
7
|
|
|
16
8
|
const REQUIRED_DEPS = [
|
|
17
|
-
{ name: 'superpowers', minVersion: '1.0.0' },
|
|
18
|
-
{ name: 'gstack', minVersion: '1.0.0' }
|
|
9
|
+
{ name: 'superpowers', repo: 'obra/superpowers', minVersion: '1.0.0' },
|
|
10
|
+
{ name: 'gstack', repo: 'garrytan/gstack', minVersion: '1.0.0' }
|
|
19
11
|
];
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Platform profiles — each AI agent platform has its own skills directory.
|
|
15
|
+
* All platforms require the same dependencies (superpowers, gstack).
|
|
16
|
+
*
|
|
17
|
+
* @covers Issue #128 Bug 1 — Qoder platform was excluded from dependency checks
|
|
18
|
+
*/
|
|
23
19
|
const PLATFORM_PROFILES = {
|
|
24
|
-
opencode: {
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
opencode: {
|
|
21
|
+
skillsDirs: [
|
|
22
|
+
path.join(HOME_DIR, '.config', 'opencode', 'skills'),
|
|
23
|
+
path.join(HOME_DIR, '.config', 'opencode'),
|
|
24
|
+
],
|
|
25
|
+
requiredDeps: REQUIRED_DEPS,
|
|
26
|
+
},
|
|
27
|
+
'claude-code': {
|
|
28
|
+
skillsDirs: [
|
|
29
|
+
path.join(HOME_DIR, '.claude', 'skills'),
|
|
30
|
+
path.join(HOME_DIR, '.claude'),
|
|
31
|
+
],
|
|
32
|
+
requiredDeps: REQUIRED_DEPS,
|
|
33
|
+
},
|
|
34
|
+
qoder: {
|
|
35
|
+
skillsDirs: [
|
|
36
|
+
path.join(HOME_DIR, '.qoder', 'skills'),
|
|
37
|
+
path.join(HOME_DIR, '.qoder'),
|
|
38
|
+
],
|
|
39
|
+
requiredDeps: REQUIRED_DEPS,
|
|
40
|
+
},
|
|
27
41
|
};
|
|
28
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Detect which AI agent platform is currently in use.
|
|
45
|
+
* Checks for platform-specific directories in the user's home.
|
|
46
|
+
* Falls back to 'opencode' if no platform is detected.
|
|
47
|
+
*
|
|
48
|
+
* @returns {'opencode' | 'claude-code' | 'qoder'}
|
|
49
|
+
*/
|
|
50
|
+
function detectPlatform() {
|
|
51
|
+
// Check for Qoder-specific marker
|
|
52
|
+
if (fs.existsSync(path.join(HOME_DIR, '.qoder', 'skills'))) {
|
|
53
|
+
return 'qoder';
|
|
54
|
+
}
|
|
55
|
+
// Check for Claude Code-specific marker
|
|
56
|
+
if (fs.existsSync(path.join(HOME_DIR, '.claude', 'skills'))) {
|
|
57
|
+
return 'claude-code';
|
|
58
|
+
}
|
|
59
|
+
// Default to opencode (most common, backward compatible)
|
|
60
|
+
return 'opencode';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the skills directories for a given platform.
|
|
65
|
+
* @param {string} platform - 'opencode' | 'claude-code' | 'qoder'
|
|
66
|
+
* @returns {string[]}
|
|
67
|
+
*/
|
|
68
|
+
function getSkillsDirs(platform) {
|
|
69
|
+
const profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
|
|
70
|
+
return profile.skillsDirs;
|
|
71
|
+
}
|
|
72
|
+
|
|
29
73
|
/**
|
|
30
74
|
* Check if bash is available on the system.
|
|
31
75
|
* XP-Gate hooks are bash scripts — Windows users need Git Bash installed.
|
|
@@ -92,30 +136,28 @@ function checkBash() {
|
|
|
92
136
|
}
|
|
93
137
|
}
|
|
94
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Check if required dependencies are installed for the given platform.
|
|
141
|
+
* @param {string} [platform='opencode'] - AI agent platform
|
|
142
|
+
* @returns {Promise<{ok: boolean, missing?: string, versionMismatch?: object}>}
|
|
143
|
+
*/
|
|
95
144
|
async function checkDeps(platform = 'opencode') {
|
|
96
|
-
const
|
|
97
|
-
const { requiredDeps, skillsDirs } = profile;
|
|
98
|
-
|
|
99
|
-
// Qoder and other platforms with no hard dependencies pass immediately
|
|
100
|
-
if (requiredDeps.length === 0) {
|
|
101
|
-
return { ok: true, platform };
|
|
102
|
-
}
|
|
145
|
+
const skillsDirs = getSkillsDirs(platform);
|
|
103
146
|
|
|
104
|
-
for (const dep of
|
|
105
|
-
const possiblePaths = skillsDirs.map(dir => path.join(dir, dep.name));
|
|
106
|
-
|
|
147
|
+
for (const dep of REQUIRED_DEPS) {
|
|
107
148
|
let depDir = null;
|
|
108
|
-
for (const
|
|
109
|
-
|
|
110
|
-
|
|
149
|
+
for (const baseDir of skillsDirs) {
|
|
150
|
+
const candidate = path.join(baseDir, dep.name);
|
|
151
|
+
if (fs.existsSync(candidate)) {
|
|
152
|
+
depDir = candidate;
|
|
111
153
|
break;
|
|
112
154
|
}
|
|
113
155
|
}
|
|
114
|
-
|
|
156
|
+
|
|
115
157
|
if (!depDir) {
|
|
116
158
|
return { ok: false, missing: dep.name };
|
|
117
159
|
}
|
|
118
|
-
|
|
160
|
+
|
|
119
161
|
const version = await getSkillVersion(depDir);
|
|
120
162
|
if (version && compareVersions(version, dep.minVersion) < 0) {
|
|
121
163
|
return {
|
|
@@ -128,10 +170,101 @@ async function checkDeps(platform = 'opencode') {
|
|
|
128
170
|
};
|
|
129
171
|
}
|
|
130
172
|
}
|
|
131
|
-
|
|
173
|
+
|
|
132
174
|
return { ok: true };
|
|
133
175
|
}
|
|
134
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Auto-install missing dependencies by cloning from GitHub.
|
|
179
|
+
* @param {string} [platform='opencode'] - AI agent platform
|
|
180
|
+
* @returns {Promise<{ok: boolean, installed?: string[], errors?: Array<{name: string, message: string}>}>}
|
|
181
|
+
*/
|
|
182
|
+
async function autoInstallDeps(platform = 'opencode') {
|
|
183
|
+
const skillsDirs = getSkillsDirs(platform);
|
|
184
|
+
const targetDir = skillsDirs[0]; // Install to the primary skills directory
|
|
185
|
+
|
|
186
|
+
if (!fs.existsSync(targetDir)) {
|
|
187
|
+
try {
|
|
188
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
errors: [{ name: 'mkdir', message: `Cannot create skills directory: ${e.message}` }]
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const check = await checkDeps(platform);
|
|
198
|
+
if (check.ok) {
|
|
199
|
+
return { ok: true, installed: [] };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const errors = [];
|
|
203
|
+
const installed = [];
|
|
204
|
+
|
|
205
|
+
// Check each dep: missing → install, version mismatch → can't auto-fix
|
|
206
|
+
const depCheck = await checkDeps(platform);
|
|
207
|
+
if (depCheck.ok) {
|
|
208
|
+
return { ok: true, installed: [] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// If the issue is version mismatch, auto-install can't help
|
|
212
|
+
if (depCheck.versionMismatch) {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
installed: [],
|
|
216
|
+
errors: [{
|
|
217
|
+
name: depCheck.versionMismatch.name,
|
|
218
|
+
message: `version mismatch: need ${depCheck.versionMismatch.required}, found ${depCheck.versionMismatch.found} (auto-install cannot upgrade)`
|
|
219
|
+
}]
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const dep of REQUIRED_DEPS) {
|
|
224
|
+
// Re-check if this specific dep is missing
|
|
225
|
+
let exists = false;
|
|
226
|
+
for (const baseDir of skillsDirs) {
|
|
227
|
+
if (fs.existsSync(path.join(baseDir, dep.name))) {
|
|
228
|
+
exists = true;
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (exists) continue;
|
|
233
|
+
|
|
234
|
+
const destPath = path.join(targetDir, dep.name);
|
|
235
|
+
const repoUrl = `https://github.com/${dep.repo}.git`;
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
console.log(` ${dep.name}: not found → installing from ${repoUrl} ...`);
|
|
239
|
+
const cp = require('child_process');
|
|
240
|
+
cp.execSync(`git clone --depth 1 "${repoUrl}" "${destPath}"`, {
|
|
241
|
+
encoding: 'utf8',
|
|
242
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
243
|
+
timeout: 120000, // 2 minute timeout
|
|
244
|
+
});
|
|
245
|
+
installed.push(dep.name);
|
|
246
|
+
console.log(` ${dep.name}: OK`);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
errors.push({ name: dep.name, message: e.message || 'git clone failed' });
|
|
249
|
+
console.warn(` ${dep.name}: FAILED (${e.message || 'unknown error'})`);
|
|
250
|
+
// Clean up partial clone
|
|
251
|
+
try {
|
|
252
|
+
if (fs.existsSync(destPath)) {
|
|
253
|
+
fs.rmSync(destPath, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Ignore cleanup failures
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (errors.length > 0) {
|
|
262
|
+
return { ok: false, installed, errors };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { ok: true, installed };
|
|
266
|
+
}
|
|
267
|
+
|
|
135
268
|
async function getSkillVersion(skillDir) {
|
|
136
269
|
const pkgFile = path.join(skillDir, 'package.json');
|
|
137
270
|
if (fs.existsSync(pkgFile)) {
|
|
@@ -140,7 +273,7 @@ async function getSkillVersion(skillDir) {
|
|
|
140
273
|
return pkg.version;
|
|
141
274
|
} catch {}
|
|
142
275
|
}
|
|
143
|
-
|
|
276
|
+
|
|
144
277
|
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
145
278
|
if (fs.existsSync(skillFile)) {
|
|
146
279
|
const content = fs.readFileSync(skillFile, 'utf8');
|
|
@@ -149,22 +282,30 @@ async function getSkillVersion(skillDir) {
|
|
|
149
282
|
return versionMatch[1];
|
|
150
283
|
}
|
|
151
284
|
}
|
|
152
|
-
|
|
285
|
+
|
|
153
286
|
return null;
|
|
154
287
|
}
|
|
155
288
|
|
|
156
289
|
function compareVersions(a, b) {
|
|
157
290
|
const partsA = a.split('.').map(Number);
|
|
158
291
|
const partsB = b.split('.').map(Number);
|
|
159
|
-
|
|
292
|
+
|
|
160
293
|
for (let i = 0; i < 3; i++) {
|
|
161
294
|
const partA = partsA[i] || 0;
|
|
162
295
|
const partB = partsB[i] || 0;
|
|
163
296
|
if (partA > partB) return 1;
|
|
164
297
|
if (partA < partB) return -1;
|
|
165
298
|
}
|
|
166
|
-
|
|
299
|
+
|
|
167
300
|
return 0;
|
|
168
301
|
}
|
|
169
302
|
|
|
170
|
-
module.exports = {
|
|
303
|
+
module.exports = {
|
|
304
|
+
checkDeps,
|
|
305
|
+
checkBash,
|
|
306
|
+
autoInstallDeps,
|
|
307
|
+
detectPlatform,
|
|
308
|
+
getSkillsDirs,
|
|
309
|
+
PLATFORM_PROFILES,
|
|
310
|
+
REQUIRED_DEPS,
|
|
311
|
+
};
|
package/lib/doctor.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
3
|
const { execSync } = require('child_process');
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
|
|
4
|
+
const {
|
|
5
|
+
HOME_DIR,
|
|
6
|
+
CONFIG_DIR,
|
|
7
|
+
CONFIG_FILE,
|
|
8
|
+
GLOBAL_HOOKS_DIR,
|
|
9
|
+
GLOBAL_ADAPTERS_DIR,
|
|
10
|
+
} = require('./shared-paths.js');
|
|
13
11
|
|
|
14
12
|
// npm package source dir (template hooks/adapters)
|
|
15
13
|
const PKG_DIR = path.dirname(__dirname);
|
package/lib/gate-audit.ts
CHANGED
|
@@ -177,64 +177,44 @@ export function computeStats(
|
|
|
177
177
|
* Renames current file to .1, shifts existing archives.
|
|
178
178
|
* Keeps at most MAX_ARCHIVES archived files.
|
|
179
179
|
*/
|
|
180
|
-
|
|
181
|
-
if (!existsSync(logPath))
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
let stats;
|
|
180
|
+
function shouldRotate(logPath: string): boolean {
|
|
181
|
+
if (!existsSync(logPath)) return false;
|
|
186
182
|
try {
|
|
187
|
-
|
|
183
|
+
return statSync(logPath).size >= MAX_FILE_BYTES;
|
|
188
184
|
} catch {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (stats.size < MAX_FILE_BYTES) {
|
|
193
|
-
return;
|
|
185
|
+
return false;
|
|
194
186
|
}
|
|
187
|
+
}
|
|
195
188
|
|
|
196
|
-
|
|
189
|
+
function shiftOldArchives(logPath: string): void {
|
|
197
190
|
for (let i = MAX_ARCHIVES; i >= 2; i--) {
|
|
198
191
|
const from = `${logPath}.${i - 1}`;
|
|
199
192
|
const to = `${logPath}.${i}`;
|
|
200
193
|
if (existsSync(from)) {
|
|
201
|
-
try {
|
|
202
|
-
renameSync(from, to);
|
|
203
|
-
} catch {
|
|
204
|
-
// Race condition or permission issue — skip
|
|
205
|
-
}
|
|
194
|
+
try { renameSync(from, to); } catch { /* skip */ }
|
|
206
195
|
}
|
|
207
196
|
}
|
|
197
|
+
}
|
|
208
198
|
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
if (existsSync(
|
|
212
|
-
try {
|
|
213
|
-
renameSync(firstArchive, `${logPath}.2`);
|
|
214
|
-
} catch {
|
|
215
|
-
// Skip on error
|
|
216
|
-
}
|
|
199
|
+
function archiveCurrent(logPath: string): void {
|
|
200
|
+
const first = `${logPath}.1`;
|
|
201
|
+
if (existsSync(first)) {
|
|
202
|
+
try { renameSync(first, `${logPath}.2`); } catch { /* skip */ }
|
|
217
203
|
}
|
|
218
|
-
|
|
219
204
|
try {
|
|
220
|
-
renameSync(logPath,
|
|
205
|
+
renameSync(logPath, first);
|
|
221
206
|
} catch {
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
writeFileSync(logPath, '', 'utf8');
|
|
225
|
-
} catch {
|
|
226
|
-
console.error('[xp-gate audit] Failed to rotate or truncate log:', logPath);
|
|
227
|
-
}
|
|
207
|
+
try { writeFileSync(logPath, '', 'utf8'); } catch { /* skip */ }
|
|
228
208
|
}
|
|
229
209
|
}
|
|
230
210
|
|
|
231
|
-
|
|
211
|
+
export function rotateIfNeeded(logPath: string): void {
|
|
212
|
+
if (!shouldRotate(logPath)) return;
|
|
213
|
+
shiftOldArchives(logPath);
|
|
214
|
+
archiveCurrent(logPath);
|
|
215
|
+
}
|
|
232
216
|
|
|
233
|
-
|
|
234
|
-
* When called directly via `npx tsx gate-audit.ts record --gate-id ...`,
|
|
235
|
-
* this block handles CLI argument parsing and appends the entry.
|
|
236
|
-
*/
|
|
237
|
-
if (require.main === module) {
|
|
217
|
+
function runCli(): void {
|
|
238
218
|
const args = process.argv.slice(2);
|
|
239
219
|
const command = args[0];
|
|
240
220
|
|
|
@@ -258,7 +238,11 @@ if (require.main === module) {
|
|
|
258
238
|
}
|
|
259
239
|
}
|
|
260
240
|
|
|
261
|
-
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
runCli();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function parseCliOptions(args: string[]): Record<string, string> {
|
|
262
246
|
const opts: Record<string, string> = {};
|
|
263
247
|
for (let i = 0; i < args.length; i++) {
|
|
264
248
|
if (args[i].startsWith('--') && i + 1 < args.length) {
|
|
@@ -269,7 +253,7 @@ function parseCliOptions(args: string[]): Record<string, string> {
|
|
|
269
253
|
return opts;
|
|
270
254
|
}
|
|
271
255
|
|
|
272
|
-
function getCommitHash(): string {
|
|
256
|
+
export function getCommitHash(): string {
|
|
273
257
|
try {
|
|
274
258
|
const { execSync } = require('child_process');
|
|
275
259
|
return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|