@boyingliu01/xp-gate 0.8.6 → 0.8.8

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.
@@ -446,4 +446,147 @@ describe('doctor', () => {
446
446
  expect.stringContaining('Not found')
447
447
  );
448
448
  });
449
+
450
+ // === Issue #186: Version mismatch detection ===
451
+
452
+ it('detects version mismatch when config version differs from package version', async () => {
453
+ setupLocalInstall();
454
+ // Write config with old version
455
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
456
+ cfg.version = '0.3.1.1';
457
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
458
+ mockExecSuccess();
459
+ const { doctor } = require('../doctor');
460
+
461
+ const result = await doctor([]);
462
+
463
+ expect(result).toBe(1);
464
+ expect(logSpy).toHaveBeenCalledWith(
465
+ expect.stringContaining('Version mismatch')
466
+ );
467
+ expect(logSpy).toHaveBeenCalledWith(
468
+ expect.stringContaining('0.3.1.1')
469
+ );
470
+ });
471
+
472
+ it('passes version check when config version matches package version', async () => {
473
+ setupLocalInstall();
474
+ // Write config with matching version
475
+ const pkg = JSON.parse(fs.readFileSync(
476
+ path.join(path.dirname(path.dirname(require.resolve('../doctor'))), 'package.json'), 'utf8'
477
+ ));
478
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
479
+ cfg.version = pkg.version;
480
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
481
+ mockExecSuccess();
482
+ const { doctor } = require('../doctor');
483
+
484
+ const result = await doctor([]);
485
+
486
+ expect(result).toBe(0);
487
+ expect(logSpy).toHaveBeenCalledWith(
488
+ expect.stringContaining('All checks passed')
489
+ );
490
+ });
491
+
492
+ it('passes version check when config has no version field (legacy)', async () => {
493
+ setupLocalInstall();
494
+ // Config without version field — legacy install, should not fail
495
+ mockExecSuccess();
496
+ const { doctor } = require('../doctor');
497
+
498
+ const result = await doctor([]);
499
+
500
+ expect(result).toBe(0);
501
+ expect(logSpy).toHaveBeenCalledWith(
502
+ expect.stringContaining('All checks passed')
503
+ );
504
+ });
505
+
506
+ // === Issue #188: templateDir validation ===
507
+
508
+ it('detects templateDir pointing to wrong platform directory', async () => {
509
+ setupLocalInstall();
510
+ // Write config with stale opencode templateDir when qoder is active
511
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
512
+ cfg.templateDir = path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
513
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
514
+
515
+ // Create qoder marker to simulate qoder environment
516
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
517
+
518
+ // Need fresh module because shared-paths caches detectPlatform at module load
519
+ delete require.cache[require.resolve('../shared-paths')];
520
+ delete require.cache[require.resolve('../doctor')];
521
+ const { doctor: doc2 } = require('../doctor');
522
+ mockExecSuccess();
523
+
524
+ const result = await doc2([]);
525
+
526
+ expect(result).toBe(1);
527
+ expect(logSpy).toHaveBeenCalledWith(
528
+ expect.stringContaining('templateDir')
529
+ );
530
+ expect(logSpy).toHaveBeenCalledWith(
531
+ expect.stringContaining('opencode')
532
+ );
533
+ });
534
+
535
+ it('passes templateDir check when templateDir matches current platform', async () => {
536
+ setupLocalInstall();
537
+ // Write config with correct qoder templateDir
538
+ const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
539
+ cfg.templateDir = path.join(tmpHome, '.qoder', 'git-hooks-template');
540
+ fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
541
+
542
+ // Create qoder marker
543
+ fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
544
+
545
+ delete require.cache[require.resolve('../shared-paths')];
546
+ delete require.cache[require.resolve('../doctor')];
547
+ const { doctor: doc2 } = require('../doctor');
548
+ mockExecSuccess();
549
+
550
+ const result = await doc2([]);
551
+
552
+ expect(result).toBe(0);
553
+ expect(logSpy).toHaveBeenCalledWith(
554
+ expect.stringContaining('All checks passed')
555
+ );
556
+ });
557
+
558
+ it('passes templateDir check when config has no templateDir field (legacy)', async () => {
559
+ setupLocalInstall();
560
+ // Config without templateDir — legacy install, should not fail
561
+ mockExecSuccess();
562
+ const { doctor } = require('../doctor');
563
+
564
+ const result = await doctor([]);
565
+
566
+ expect(result).toBe(0);
567
+ expect(logSpy).toHaveBeenCalledWith(
568
+ expect.stringContaining('All checks passed')
569
+ );
570
+ });
571
+
572
+ // === Issue #186: --fix syncs global hooks from package source ===
573
+
574
+ it('--fix syncs global hooks when they are outdated', async () => {
575
+ setupGlobalInstall();
576
+ // Write an outdated pre-commit hook (different content)
577
+ const oldContent = '#!/bin/bash\n# OpenCode Quality Gates - Pre-Commit Hook - OLD VERSION\n';
578
+ fs.writeFileSync(path.join(globalHooksDir(), 'pre-commit'), oldContent);
579
+ mockExecSuccess();
580
+ const { doctor } = require('../doctor');
581
+
582
+ const result = await doctor(['--fix']);
583
+
584
+ expect(result).toBe(0);
585
+ // Should have restored from package source
586
+ const restoredContent = fs.readFileSync(path.join(globalHooksDir(), 'pre-commit'), 'utf8');
587
+ expect(restoredContent).not.toBe(oldContent);
588
+ expect(logSpy).toHaveBeenCalledWith(
589
+ expect.stringContaining('Restored')
590
+ );
591
+ });
449
592
  });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @test shared-paths
3
+ * @intent Verify platform-aware template directory resolution
4
+ * @covers Issue #188 — templateDir pointing to OpenCode residue path
5
+ */
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ describe('shared-paths', () => {
11
+ let tmpHome;
12
+ let originalHome;
13
+
14
+ beforeEach(() => {
15
+ originalHome = process.env.HOME;
16
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-sp-'));
17
+ process.env.HOME = tmpHome;
18
+ vi.resetModules();
19
+ delete require.cache[require.resolve('../shared-paths')];
20
+ });
21
+
22
+ afterEach(() => {
23
+ process.env.HOME = originalHome;
24
+ if (tmpHome && fs.existsSync(tmpHome)) {
25
+ fs.rmSync(tmpHome, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ function createPlatformMarker(platform) {
30
+ const dirs = {
31
+ opencode: path.join(tmpHome, '.config', 'opencode', 'skills'),
32
+ 'claude-code': path.join(tmpHome, '.claude', 'skills'),
33
+ qoder: path.join(tmpHome, '.qoder', 'skills'),
34
+ };
35
+ const dir = dirs[platform];
36
+ if (dir) {
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+ }
40
+
41
+ it('returns opencode template dir when no platform marker exists (default)', () => {
42
+ const { TEMPLATE_DIR } = require('../shared-paths');
43
+ expect(TEMPLATE_DIR).toBe(path.join(tmpHome, '.config', 'opencode', 'git-hooks-template'));
44
+ });
45
+
46
+ it('returns opencode template dir when opencode platform marker exists', () => {
47
+ createPlatformMarker('opencode');
48
+ const { TEMPLATE_DIR } = require('../shared-paths');
49
+ expect(TEMPLATE_DIR).toBe(path.join(tmpHome, '.config', 'opencode', 'git-hooks-template'));
50
+ });
51
+
52
+ it('returns claude-code template dir when claude-code platform marker exists', () => {
53
+ createPlatformMarker('claude-code');
54
+ const { TEMPLATE_DIR } = require('../shared-paths');
55
+ expect(TEMPLATE_DIR).toBe(path.join(tmpHome, '.claude', 'git-hooks-template'));
56
+ });
57
+
58
+ it('returns qoder template dir when qoder platform marker exists', () => {
59
+ createPlatformMarker('qoder');
60
+ const { TEMPLATE_DIR } = require('../shared-paths');
61
+ expect(TEMPLATE_DIR).toBe(path.join(tmpHome, '.qoder', 'git-hooks-template'));
62
+ });
63
+
64
+ it('qoder marker takes priority over opencode when both exist', () => {
65
+ createPlatformMarker('opencode');
66
+ createPlatformMarker('qoder');
67
+ const { TEMPLATE_DIR } = require('../shared-paths');
68
+ // Qoder should take priority (checked first)
69
+ expect(TEMPLATE_DIR).toBe(path.join(tmpHome, '.qoder', 'git-hooks-template'));
70
+ });
71
+
72
+ it('detectPlatform returns opencode when no marker exists', () => {
73
+ const { detectPlatform } = require('../shared-paths');
74
+ expect(detectPlatform()).toBe('opencode');
75
+ });
76
+
77
+ it('detectPlatform returns qoder when qoder marker exists', () => {
78
+ createPlatformMarker('qoder');
79
+ const { detectPlatform } = require('../shared-paths');
80
+ expect(detectPlatform()).toBe('qoder');
81
+ });
82
+
83
+ it('detectPlatform returns claude-code when claude-code marker exists', () => {
84
+ createPlatformMarker('claude-code');
85
+ const { detectPlatform } = require('../shared-paths');
86
+ expect(detectPlatform()).toBe('claude-code');
87
+ });
88
+
89
+ it('getTemplateDir returns correct path for each platform', () => {
90
+ const { getTemplateDir } = require('../shared-paths');
91
+
92
+ // No marker → opencode
93
+ expect(getTemplateDir()).toBe(path.join(tmpHome, '.config', 'opencode', 'git-hooks-template'));
94
+
95
+ // Qoder marker
96
+ createPlatformMarker('qoder');
97
+ // Need fresh module to re-evaluate
98
+ delete require.cache[require.resolve('../shared-paths')];
99
+ const { getTemplateDir: getTpl2 } = require('../shared-paths');
100
+ expect(getTpl2()).toBe(path.join(tmpHome, '.qoder', 'git-hooks-template'));
101
+ });
102
+
103
+ it('CONFIG_DIR and GLOBAL_HOOKS_DIR remain under xp-gate regardless of platform', () => {
104
+ createPlatformMarker('qoder');
105
+ const { CONFIG_DIR, GLOBAL_HOOKS_DIR, GLOBAL_ADAPTERS_DIR } = require('../shared-paths');
106
+ expect(CONFIG_DIR).toBe(path.join(tmpHome, '.config', 'xp-gate'));
107
+ expect(GLOBAL_HOOKS_DIR).toBe(path.join(tmpHome, '.config', 'xp-gate', 'hooks'));
108
+ expect(GLOBAL_ADAPTERS_DIR).toBe(path.join(tmpHome, '.config', 'xp-gate', 'adapters'));
109
+ });
110
+ });
package/lib/doctor.js CHANGED
@@ -7,11 +7,26 @@ const {
7
7
  CONFIG_FILE,
8
8
  GLOBAL_HOOKS_DIR,
9
9
  GLOBAL_ADAPTERS_DIR,
10
+ detectPlatform,
11
+ getTemplateDir,
10
12
  } = require('./shared-paths.js');
11
13
 
12
14
  // npm package source dir (template hooks/adapters)
13
15
  const PKG_DIR = path.dirname(__dirname);
14
16
 
17
+ /**
18
+ * Read the package version from the installed package.json.
19
+ * @returns {string|null}
20
+ */
21
+ function getPackageVersion() {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf8'));
24
+ return pkg.version || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
15
30
  /**
16
31
  * Signature strings used to verify file ownership.
17
32
  */
@@ -40,6 +55,10 @@ function getConfig() {
40
55
  }
41
56
  }
42
57
 
58
+ function saveConfig(config) {
59
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
60
+ }
61
+
43
62
  function getGitDir() {
44
63
  try {
45
64
  return execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
@@ -190,17 +209,43 @@ function diagnose() {
190
209
  checks.push(...configResult.checks);
191
210
  issues += configResult.issues;
192
211
 
193
- // --- Check 2: Hooks files ---
212
+ // --- Check 2: Version mismatch ---
213
+ const pkgVersion = getPackageVersion();
214
+ const configVersion = config.version;
215
+ if (configVersion && pkgVersion && configVersion !== pkgVersion) {
216
+ checks.push({
217
+ name: 'Version mismatch',
218
+ status: 'FAIL',
219
+ detail: `config: ${configVersion}, package: ${pkgVersion} — run 'xp-gate doctor --fix' to sync`
220
+ });
221
+ issues++;
222
+ }
223
+
224
+ // --- Check 3: templateDir validation ---
225
+ const configTemplateDir = config.templateDir;
226
+ if (configTemplateDir) {
227
+ const expectedTemplateDir = getTemplateDir();
228
+ if (configTemplateDir !== expectedTemplateDir) {
229
+ checks.push({
230
+ name: 'templateDir',
231
+ status: 'FAIL',
232
+ detail: `points to ${configTemplateDir}, expected ${expectedTemplateDir} for current platform`
233
+ });
234
+ issues++;
235
+ }
236
+ }
237
+
238
+ // --- Check 4: Hooks files ---
194
239
  if (config.mode === 'local') {
195
240
  issues += checkLocalHooks(checks);
196
241
  } else {
197
242
  issues += checkGlobalHooks(checks);
198
243
  }
199
244
 
200
- // --- Check 3: Adapters directory ---
245
+ // --- Check 5: Adapters directory ---
201
246
  issues += checkAdapters(checks, config.mode, getGitDir());
202
247
 
203
- // --- Check 4: Environment dependencies ---
248
+ // --- Check 6: Environment dependencies ---
204
249
  checkEnv(checks);
205
250
 
206
251
  return { checks, issues };
@@ -245,6 +290,24 @@ function fixIssues(checks, config) {
245
290
  const srcDir = PKG_DIR;
246
291
  let fixed = false;
247
292
 
293
+ // Fix version mismatch — update config version to match package
294
+ const pkgVersion = getPackageVersion();
295
+ if (pkgVersion && config.version !== pkgVersion) {
296
+ config.version = pkgVersion;
297
+ saveConfig(config);
298
+ console.log(` ✓ Updated config version to ${pkgVersion}`);
299
+ fixed = true;
300
+ }
301
+
302
+ // Fix templateDir mismatch
303
+ const expectedTemplateDir = getTemplateDir();
304
+ if (config.templateDir && config.templateDir !== expectedTemplateDir) {
305
+ config.templateDir = expectedTemplateDir;
306
+ saveConfig(config);
307
+ console.log(` ✓ Updated templateDir to ${expectedTemplateDir}`);
308
+ fixed = true;
309
+ }
310
+
248
311
  // Fix missing hooks
249
312
  if (config.mode === 'local') {
250
313
  const gitDir = getGitDir();
@@ -4,9 +4,11 @@
4
4
  *
5
5
  * @intent Eliminate duplicate path constants in init.js / uninstall.js / detect-deps.js
6
6
  * @covers Issue #107 — duplicate code between init.js and uninstall.js
7
+ * @covers Issue #188 — templateDir pointing to OpenCode residue path
7
8
  */
8
9
  const path = require('path');
9
10
  const os = require('os');
11
+ const fs = require('fs');
10
12
 
11
13
  /**
12
14
  * Resolve the user's home directory cross-platform.
@@ -16,10 +18,42 @@ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
16
18
 
17
19
  const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
18
20
  const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
19
- const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
20
21
  const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
21
22
  const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
22
23
 
24
+ /**
25
+ * Detect which AI agent platform is currently in use.
26
+ * Mirrors detectPlatform() in detect-deps.js to avoid circular deps.
27
+ * @returns {'opencode' | 'claude-code' | 'qoder'}
28
+ */
29
+ function detectPlatform() {
30
+ if (fs.existsSync(path.join(HOME_DIR, '.qoder', 'skills'))) return 'qoder';
31
+ if (fs.existsSync(path.join(HOME_DIR, '.claude', 'skills'))) return 'claude-code';
32
+ return 'opencode';
33
+ }
34
+
35
+ /**
36
+ * Get the git hooks template directory for the current platform.
37
+ * Each AI agent platform has its own config directory:
38
+ * - opencode: ~/.config/opencode/git-hooks-template
39
+ * - claude-code: ~/.claude/git-hooks-template
40
+ * - qoder: ~/.qoder/git-hooks-template
41
+ *
42
+ * This prevents templateDir from pointing to a stale platform's directory
43
+ * after migrating from one AI agent to another (e.g., OpenCode → Qoder).
44
+ *
45
+ * @returns {string} Path to the platform-specific git hooks template directory
46
+ */
47
+ function getTemplateDir() {
48
+ const platform = detectPlatform();
49
+ const configDir = platform === 'claude-code' ? '.claude' :
50
+ platform === 'qoder' ? '.qoder' :
51
+ path.join('.config', 'opencode');
52
+ return path.join(HOME_DIR, configDir, 'git-hooks-template');
53
+ }
54
+
55
+ const TEMPLATE_DIR = getTemplateDir();
56
+
23
57
  module.exports = {
24
58
  HOME_DIR,
25
59
  CONFIG_DIR,
@@ -27,4 +61,6 @@ module.exports = {
27
61
  TEMPLATE_DIR,
28
62
  GLOBAL_HOOKS_DIR,
29
63
  GLOBAL_ADAPTERS_DIR,
64
+ detectPlatform,
65
+ getTemplateDir,
30
66
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "./bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 6 quality gates, Sprint Flow, and Delphi multi-expert review.",
6
6
  "author": {