@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.
Files changed (40) hide show
  1. package/bin/xp-gate.js +4 -9
  2. package/lib/__tests__/audit-log.test.ts +107 -0
  3. package/lib/__tests__/detect-deps.test.js +179 -42
  4. package/lib/__tests__/doctor.test.js +1 -0
  5. package/lib/__tests__/gate-audit.test.ts +59 -0
  6. package/lib/__tests__/init.test.js +15 -0
  7. package/lib/__tests__/install-skill.test.js +1 -45
  8. package/lib/__tests__/ui-detector.test.ts +122 -8
  9. package/lib/__tests__/ui-review.test.ts +119 -0
  10. package/lib/__tests__/uninstall.test.js +1 -0
  11. package/lib/detect-deps.js +178 -37
  12. package/lib/doctor.js +7 -9
  13. package/lib/gate-audit.ts +26 -42
  14. package/lib/init.js +42 -13
  15. package/lib/install-skill.js +20 -48
  16. package/lib/rollback.js +1 -16
  17. package/lib/shared-paths.js +30 -0
  18. package/lib/shared-utils.js +25 -0
  19. package/lib/ui-detector.ts +3 -3
  20. package/lib/ui-review.ts +58 -53
  21. package/lib/uninstall.js +8 -9
  22. package/package.json +1 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/skills/delphi-review/SKILL.md +112 -77
  25. package/skills/ralph-loop/SKILL.md +165 -34
  26. package/skills/sprint-flow/SKILL.md +428 -105
  27. package/skills/sprint-flow/references/components/skill-invocations.md +1 -1
  28. package/skills/sprint-flow/references/components/tool-descriptions.md +1 -1
  29. package/skills/sprint-flow/references/force-levels.md +203 -0
  30. package/skills/sprint-flow/references/phase-1-plan.md +4 -4
  31. package/skills/sprint-flow/references/phase-minus-0-5-auto-estimate.md +20 -1
  32. package/skills/sprint-flow/templates/auto-estimate-output-template.md +7 -5
  33. package/skills/sprint-flow/templates/sprint-progress-template.md +151 -0
  34. package/skills/test-specification-alignment/SKILL.md +98 -24
  35. package/plugins/qoder/AGENTS.md +0 -93
  36. package/plugins/qoder/README.md +0 -87
  37. package/plugins/qoder/widgets/quality-report.html +0 -163
  38. package/plugins/qoder/widgets/sprint-dashboard.html +0 -172
  39. package/skills/delphi-review/references/qoder-multi-model.md +0 -191
  40. 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 deleted UI files', async () => {
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(() => {});
@@ -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
- // Platform-specific dependency profiles
22
- // Qoder has no external skill dependencies (superpowers/gstack not required)
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: { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
25
- qoder: { requiredDeps: [], skillsDirs: [path.join(HOME, '.qoder', 'skills')] },
26
- 'claude-code': { requiredDeps: REQUIRED_DEPS, skillsDirs: [SKILLS_DIR, OPENCODE_DIR] },
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 profile = PLATFORM_PROFILES[platform] || PLATFORM_PROFILES.opencode;
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 requiredDeps) {
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 p of possiblePaths) {
109
- if (fs.existsSync(p)) {
110
- depDir = p;
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 = { checkDeps, checkBash };
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
- // Cross-platform home directory resolution
7
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
8
-
9
- const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
10
- const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
11
- const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
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
- export function rotateIfNeeded(logPath: string): void {
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
- stats = statSync(logPath);
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
- // Shift archives: .2 -> .3, .1 -> .2
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
- // Current -> .1
210
- const firstArchive = `${logPath}.1`;
211
- if (existsSync(firstArchive)) {
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, firstArchive);
205
+ renameSync(logPath, first);
221
206
  } catch {
222
- // If rename fails, truncate the current file instead
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
- // ── CLI entry point (direct invocation from hooks) ───────────────────────────
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
- function parseCliOptions(args: string[]): Record<string, string> {
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();