@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.
@@ -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,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
- * 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.
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 HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
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
- const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
14
- const OPENCODE_DIR = path.join(HOME, '.config', 'opencode');
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
- const REQUIRED_DEPS = [
17
- { name: 'superpowers', minVersion: '1.0.0' },
18
- { name: 'gstack', minVersion: '1.0.0' }
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
- async function checkDeps() {
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 p of possiblePaths) {
96
- if (fs.existsSync(p)) {
97
- depDir = p;
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 = { 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();
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 { checkBash } = require('./detect-deps.js');
7
-
8
- // Cross-platform home directory resolution with os.homedir() fallback
9
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
10
-
11
- const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
12
- const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
13
- const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
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
- logDeps(await checkDeps());
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' :
@@ -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
- // Cross-platform home directory resolution
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 depCheck = await checkDeps();
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
- function copyDirRecursive(src, dest) {
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