@boyingliu01/xp-gate 0.6.0 → 0.6.2
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/lib/__tests__/audit-log.test.ts +107 -0
- package/lib/__tests__/detect-deps.test.js +188 -17
- 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 -0
- package/lib/__tests__/ui-detector.test.ts +112 -1
- package/lib/__tests__/ui-review.test.ts +119 -0
- package/lib/__tests__/uninstall.test.js +1 -0
- package/lib/detect-deps.js +181 -27
- package/lib/doctor.js +7 -9
- package/lib/gate-audit.ts +26 -42
- package/lib/init.js +42 -13
- package/lib/install-skill.js +6 -23
- package/lib/rollback.js +1 -16
- package/lib/shared-paths.js +30 -0
- package/lib/shared-utils.js +25 -0
- 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 -29
- package/skills/ralph-loop/SKILL.md +179 -8
- package/skills/sprint-flow/SKILL.md +416 -25
- package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
- package/skills/test-specification-alignment/SKILL.md +98 -1
|
@@ -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,20 +3,72 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const { execSync } = require('child_process');
|
|
5
5
|
|
|
6
|
+
const { HOME_DIR } = require('./shared-paths.js');
|
|
7
|
+
|
|
8
|
+
const REQUIRED_DEPS = [
|
|
9
|
+
{ name: 'superpowers', repo: 'obra/superpowers', minVersion: '1.0.0' },
|
|
10
|
+
{ name: 'gstack', repo: 'garrytan/gstack', minVersion: '1.0.0' }
|
|
11
|
+
];
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
10
18
|
*/
|
|
11
|
-
const
|
|
19
|
+
const PLATFORM_PROFILES = {
|
|
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
|
+
},
|
|
41
|
+
};
|
|
12
42
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|
|
15
62
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
]
|
|
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
|
+
}
|
|
20
72
|
|
|
21
73
|
/**
|
|
22
74
|
* Check if bash is available on the system.
|
|
@@ -84,25 +136,28 @@ function checkBash() {
|
|
|
84
136
|
}
|
|
85
137
|
}
|
|
86
138
|
|
|
87
|
-
|
|
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
|
+
*/
|
|
144
|
+
async function checkDeps(platform = 'opencode') {
|
|
145
|
+
const skillsDirs = getSkillsDirs(platform);
|
|
146
|
+
|
|
88
147
|
for (const dep of REQUIRED_DEPS) {
|
|
89
|
-
const possiblePaths = [
|
|
90
|
-
path.join(SKILLS_DIR, dep.name),
|
|
91
|
-
path.join(OPENCODE_DIR, dep.name)
|
|
92
|
-
];
|
|
93
|
-
|
|
94
148
|
let depDir = null;
|
|
95
|
-
for (const
|
|
96
|
-
|
|
97
|
-
|
|
149
|
+
for (const baseDir of skillsDirs) {
|
|
150
|
+
const candidate = path.join(baseDir, dep.name);
|
|
151
|
+
if (fs.existsSync(candidate)) {
|
|
152
|
+
depDir = candidate;
|
|
98
153
|
break;
|
|
99
154
|
}
|
|
100
155
|
}
|
|
101
|
-
|
|
156
|
+
|
|
102
157
|
if (!depDir) {
|
|
103
158
|
return { ok: false, missing: dep.name };
|
|
104
159
|
}
|
|
105
|
-
|
|
160
|
+
|
|
106
161
|
const version = await getSkillVersion(depDir);
|
|
107
162
|
if (version && compareVersions(version, dep.minVersion) < 0) {
|
|
108
163
|
return {
|
|
@@ -115,10 +170,101 @@ async function checkDeps() {
|
|
|
115
170
|
};
|
|
116
171
|
}
|
|
117
172
|
}
|
|
118
|
-
|
|
173
|
+
|
|
119
174
|
return { ok: true };
|
|
120
175
|
}
|
|
121
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
|
+
|
|
122
268
|
async function getSkillVersion(skillDir) {
|
|
123
269
|
const pkgFile = path.join(skillDir, 'package.json');
|
|
124
270
|
if (fs.existsSync(pkgFile)) {
|
|
@@ -127,7 +273,7 @@ async function getSkillVersion(skillDir) {
|
|
|
127
273
|
return pkg.version;
|
|
128
274
|
} catch {}
|
|
129
275
|
}
|
|
130
|
-
|
|
276
|
+
|
|
131
277
|
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
132
278
|
if (fs.existsSync(skillFile)) {
|
|
133
279
|
const content = fs.readFileSync(skillFile, 'utf8');
|
|
@@ -136,22 +282,30 @@ async function getSkillVersion(skillDir) {
|
|
|
136
282
|
return versionMatch[1];
|
|
137
283
|
}
|
|
138
284
|
}
|
|
139
|
-
|
|
285
|
+
|
|
140
286
|
return null;
|
|
141
287
|
}
|
|
142
288
|
|
|
143
289
|
function compareVersions(a, b) {
|
|
144
290
|
const partsA = a.split('.').map(Number);
|
|
145
291
|
const partsB = b.split('.').map(Number);
|
|
146
|
-
|
|
292
|
+
|
|
147
293
|
for (let i = 0; i < 3; i++) {
|
|
148
294
|
const partA = partsA[i] || 0;
|
|
149
295
|
const partB = partsB[i] || 0;
|
|
150
296
|
if (partA > partB) return 1;
|
|
151
297
|
if (partA < partB) return -1;
|
|
152
298
|
}
|
|
153
|
-
|
|
299
|
+
|
|
154
300
|
return 0;
|
|
155
301
|
}
|
|
156
302
|
|
|
157
|
-
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();
|
package/lib/init.js
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
3
|
const crypto = require('crypto');
|
|
5
|
-
const { checkDeps } = require('./detect-deps.js');
|
|
6
|
-
const {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
|
|
15
|
-
const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
|
|
4
|
+
const { checkDeps, checkBash, autoInstallDeps, detectPlatform } = require('./detect-deps.js');
|
|
5
|
+
const {
|
|
6
|
+
HOME_DIR,
|
|
7
|
+
CONFIG_DIR,
|
|
8
|
+
CONFIG_FILE,
|
|
9
|
+
TEMPLATE_DIR,
|
|
10
|
+
GLOBAL_HOOKS_DIR,
|
|
11
|
+
GLOBAL_ADAPTERS_DIR,
|
|
12
|
+
} = require('./shared-paths.js');
|
|
16
13
|
|
|
17
14
|
function copyHooks(srcDir, destDir) {
|
|
18
15
|
['pre-commit', 'pre-push'].forEach(hook => {
|
|
@@ -54,6 +51,35 @@ function logDeps(depCheck) {
|
|
|
54
51
|
}
|
|
55
52
|
}
|
|
56
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Detect which AI agent platform is in use and check/auto-install deps.
|
|
56
|
+
* @param {string} platform - 'opencode' | 'claude-code' | 'qoder'
|
|
57
|
+
*/
|
|
58
|
+
async function checkAndInstallDeps(platform) {
|
|
59
|
+
const depCheck = await checkDeps(platform);
|
|
60
|
+
if (depCheck.ok) {
|
|
61
|
+
logDeps(depCheck);
|
|
62
|
+
return depCheck;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Auto-install missing deps
|
|
66
|
+
console.log(`Checking dependencies (platform: ${platform})...`);
|
|
67
|
+
const installResult = await autoInstallDeps(platform);
|
|
68
|
+
if (installResult.ok) {
|
|
69
|
+
console.log('Dependencies: OK (auto-installed)\n');
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Auto-install failed — report and continue with warning
|
|
74
|
+
logDeps(depCheck);
|
|
75
|
+
if (installResult.errors) {
|
|
76
|
+
for (const err of installResult.errors) {
|
|
77
|
+
console.warn(` Auto-install failed for ${err.name}: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return depCheck;
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
function printUsage() {
|
|
58
84
|
console.log('Choose installation mode:');
|
|
59
85
|
console.log(' 1) Global — all git projects use the same hooks (recommended)');
|
|
@@ -228,7 +254,10 @@ async function init(args) {
|
|
|
228
254
|
console.warn(` ${bashCheck.message}\n`);
|
|
229
255
|
}
|
|
230
256
|
|
|
231
|
-
|
|
257
|
+
// Detect platform and check/auto-install dependencies
|
|
258
|
+
const platform = detectPlatform();
|
|
259
|
+
console.log(`Platform: ${platform}\n`);
|
|
260
|
+
await checkAndInstallDeps(platform);
|
|
232
261
|
|
|
233
262
|
const installMode = args.includes('--global') ? 'global' :
|
|
234
263
|
args.includes('--core-only') ? 'local' :
|
package/lib/install-skill.js
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
3
|
const https = require('https');
|
|
5
4
|
const http = require('http');
|
|
6
5
|
const { execSync } = require('child_process');
|
|
7
|
-
const { checkDeps } = require('./detect-deps.js');
|
|
6
|
+
const { checkDeps, detectPlatform } = require('./detect-deps.js');
|
|
8
7
|
const { downloadFromGitHub } = require('./download-skill.js');
|
|
9
8
|
const { rollback } = require('./rollback.js');
|
|
9
|
+
const { HOME_DIR, CONFIG_DIR } = require('./shared-paths.js');
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
13
|
-
|
|
14
|
-
const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
15
|
-
const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
|
|
11
|
+
const SKILLS_DIR = path.join(HOME_DIR, '.config', 'opencode', 'skills');
|
|
16
12
|
|
|
17
13
|
const SKILLS_REGISTRY = {
|
|
18
14
|
'sprint-flow': { repo: 'boyingliu01/xp-gate', path: 'skills/sprint-flow' },
|
|
@@ -24,7 +20,8 @@ const SKILLS_REGISTRY = {
|
|
|
24
20
|
async function installSkill(name, options = {}) {
|
|
25
21
|
const { offline = false, verbose = false, force = false } = options;
|
|
26
22
|
|
|
27
|
-
const
|
|
23
|
+
const platform = detectPlatform();
|
|
24
|
+
const depCheck = await checkDeps(platform);
|
|
28
25
|
if (!depCheck.ok) {
|
|
29
26
|
if (depCheck.missing) {
|
|
30
27
|
console.error(`Error: ${depCheck.missing} is required but not installed`);
|
|
@@ -144,21 +141,7 @@ async function downloadFile(url, dest, verbose) {
|
|
|
144
141
|
});
|
|
145
142
|
}
|
|
146
143
|
|
|
147
|
-
|
|
148
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
149
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
150
|
-
|
|
151
|
-
for (const entry of entries) {
|
|
152
|
-
const srcPath = path.join(src, entry.name);
|
|
153
|
-
const destPath = path.join(dest, entry.name);
|
|
154
|
-
|
|
155
|
-
if (entry.isDirectory()) {
|
|
156
|
-
copyDirRecursive(srcPath, destPath);
|
|
157
|
-
} else {
|
|
158
|
-
fs.copyFileSync(srcPath, destPath);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
144
|
+
const { copyDirRecursive } = require('./shared-utils');
|
|
162
145
|
|
|
163
146
|
function ensureConfigDir() {
|
|
164
147
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
package/lib/rollback.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const { copyDirRecursive } = require('./shared-utils');
|
|
4
5
|
|
|
5
6
|
// Cross-platform home directory resolution
|
|
6
7
|
const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
@@ -51,22 +52,6 @@ async function createBackup(installId, skillName) {
|
|
|
51
52
|
return backupDir;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function copyDirRecursive(src, dest) {
|
|
55
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
56
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
57
|
-
|
|
58
|
-
for (const entry of entries) {
|
|
59
|
-
const srcPath = path.join(src, entry.name);
|
|
60
|
-
const destPath = path.join(dest, entry.name);
|
|
61
|
-
|
|
62
|
-
if (entry.isDirectory()) {
|
|
63
|
-
copyDirRecursive(srcPath, destPath);
|
|
64
|
-
} else {
|
|
65
|
-
fs.copyFileSync(srcPath, destPath);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
55
|
function cleanupBackup(installId) {
|
|
71
56
|
const backupDir = path.join(BACKUP_DIR, installId);
|
|
72
57
|
|