@boyingliu01/xp-gate 0.8.12 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/xp-gate.js CHANGED
@@ -11,6 +11,7 @@ const { handleBaseline } = require('../lib/baseline.js');
11
11
  const { check } = require('../lib/check.js');
12
12
  const { principles } = require('../lib/principles.js');
13
13
  const { arch } = require('../lib/arch.js');
14
+ const { upgrade } = require('../lib/upgrade.js');
14
15
 
15
16
  function handleUIReview() {
16
17
  const { execSync } = require('child_process');
@@ -115,6 +116,11 @@ const COMMANDS = {
115
116
  description: 'Run architecture validation (Gate 6 standalone, uses architecture.yaml)',
116
117
  run: subargs => arch(subargs).then(code => process.exit(code)),
117
118
  usage: 'xp-gate arch [--config <path>]'
119
+ },
120
+ 'upgrade': {
121
+ description: 'Check for xp-gate updates (--preview for JSON, --apply to upgrade)',
122
+ run: subargs => upgrade(subargs).then(code => process.exit(code)),
123
+ usage: 'xp-gate upgrade [--preview] [--apply]'
118
124
  }
119
125
  };
120
126
 
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @test REQ-001-01 check-version.js
3
+ * @intent 验证版本检查模块所有 7 个导出函数和 8 个 AC 的行为
4
+ * @covers AC-001-01, AC-001-02, AC-001-03, AC-001-04, AC-001-05, AC-001-06, AC-001-07, AC-001-08
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ function fakeHome() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ckv-'));
13
+ process.env.HOME = dir;
14
+ return dir;
15
+ }
16
+
17
+ function cleanupDir(dir) {
18
+ if (dir && fs.existsSync(dir)) {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ // ── suite ──
24
+
25
+ describe('check-version.js — REQ-001-01', () => {
26
+ let mod;
27
+ let origHome;
28
+ let tmpHome;
29
+ let origReadFileSync;
30
+
31
+ beforeEach(() => {
32
+ vi.resetModules();
33
+ origHome = process.env.HOME;
34
+ tmpHome = fakeHome();
35
+ origReadFileSync = fs.readFileSync;
36
+ });
37
+
38
+ afterEach(() => {
39
+ process.env.HOME = origHome;
40
+ cleanupDir(tmpHome);
41
+ fs.readFileSync = origReadFileSync;
42
+ });
43
+
44
+ // ──────────────────────────────────────────
45
+ // AC-001-06: getPackageName()
46
+ // ──────────────────────────────────────────
47
+ describe('getPackageName() — AC-001-06', () => {
48
+ it('reads package name from package.json when available', () => {
49
+ mod = require('../check-version');
50
+ expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
51
+ });
52
+
53
+ it('falls back to DEFAULT on parse failure', () => {
54
+ fs.readFileSync = vi.fn().mockImplementation((fp) => {
55
+ if (fp.endsWith('package.json')) throw new Error('ENOENT');
56
+ return origReadFileSync(fp);
57
+ });
58
+ mod = require('../check-version');
59
+ expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
60
+ });
61
+
62
+ it('falls back to DEFAULT for unscoped names', () => {
63
+ fs.readFileSync = vi.fn().mockImplementation((fp) => {
64
+ if (fp.endsWith('package.json')) return JSON.stringify({ name: 'just-xp-gate' });
65
+ return origReadFileSync(fp);
66
+ });
67
+ mod = require('../check-version');
68
+ expect(mod.getPackageName()).toBe('@boyingliu01/xp-gate');
69
+ });
70
+ });
71
+
72
+ // ──────────────────────────────────────────
73
+ // AC-001-01: getLocalVersion()
74
+ // ──────────────────────────────────────────
75
+ describe('getLocalVersion() — AC-001-01', () => {
76
+ it('returns version from package.json', () => {
77
+ mod = require('../check-version');
78
+ expect(mod.getLocalVersion()).toBe('0.8.12');
79
+ });
80
+
81
+ it('returns null when read fails', () => {
82
+ fs.readFileSync = vi.fn().mockImplementation((fp) => {
83
+ if (fp.endsWith('package.json')) throw new Error('ENOENT');
84
+ return origReadFileSync(fp);
85
+ });
86
+ mod = require('../check-version');
87
+ expect(mod.getLocalVersion()).toBeNull();
88
+ });
89
+
90
+ it('returns null when version field missing', () => {
91
+ fs.readFileSync = vi.fn().mockImplementation((fp) => {
92
+ if (fp.endsWith('package.json')) return JSON.stringify({ name: 'test' });
93
+ return origReadFileSync(fp);
94
+ });
95
+ mod = require('../check-version');
96
+ expect(mod.getLocalVersion()).toBeNull();
97
+ });
98
+ });
99
+
100
+ // ──────────────────────────────────────────
101
+ // compareVersions()
102
+ // ──────────────────────────────────────────
103
+ describe('compareVersions()', () => {
104
+ beforeEach(() => { mod = require('../check-version'); });
105
+
106
+ it('a < b → negative', () => {
107
+ expect(mod.compareVersions('0.8.12', '0.8.13')).toBeLessThan(0);
108
+ });
109
+ it('a > b → positive', () => {
110
+ expect(mod.compareVersions('0.8.13', '0.8.12')).toBeGreaterThan(0);
111
+ });
112
+ it('equal → 0', () => {
113
+ expect(mod.compareVersions('0.8.12', '0.8.12')).toBe(0);
114
+ });
115
+ it('handles different segment counts', () => {
116
+ expect(mod.compareVersions('1.0', '1.0.1')).toBeLessThan(0);
117
+ });
118
+ it('handles major version diffs', () => {
119
+ expect(mod.compareVersions('1.0.0', '2.0.0')).toBeLessThan(0);
120
+ });
121
+ });
122
+
123
+ // ──────────────────────────────────────────
124
+ // AC-001-08: calcLagDays() (internal — tested via checkUpgrade)
125
+ // ──────────────────────────────────────────
126
+ describe('calcLagDays() behavior via checkUpgrade — AC-001-08', () => {
127
+ it('checkUpgrade returns lagDays=0 when no remote version', async () => {
128
+ mod = require('../check-version');
129
+ const r = await mod.checkUpgrade('@nonexistent/pkg-test-only');
130
+ expect(r.lagDays).toBe(0);
131
+ });
132
+
133
+ it('checkUpgrade uses calcLagDays with publishedAt when available', async () => {
134
+ const https = require('https');
135
+ const origGet = https.get;
136
+ const body = JSON.stringify({ latest: '99.99.99' });
137
+ https.get = (_url, _opts, cb) => {
138
+ const callback = typeof _opts === 'function' ? _opts : cb;
139
+ if (!callback) return { on: () => this, destroy: () => {} };
140
+ const mockRes = {
141
+ statusCode: 200,
142
+ on: (evt, handler) => { if (evt === 'end') handler(); return mockRes; },
143
+ };
144
+ callback(mockRes);
145
+ return { on: () => this, destroy: () => {} };
146
+ };
147
+ vi.resetModules();
148
+ mod = require('../check-version');
149
+ const r = await mod.checkUpgrade('@nonexistent/pkg-test-only');
150
+ expect(r.lagDays).toBe(0);
151
+ https.get = origGet;
152
+ });
153
+ });
154
+
155
+ // ──────────────────────────────────────────
156
+ // AC-001-05: cache operations
157
+ // ──────────────────────────────────────────
158
+ describe('cache operations — AC-001-05', () => {
159
+ beforeEach(() => { mod = require('../check-version'); });
160
+
161
+ it('clearCache does not throw when no cache file', () => {
162
+ expect(() => mod.clearCache()).not.toThrow();
163
+ });
164
+
165
+ it('clearCache does not throw after normal module load', () => {
166
+ // Verify the function is exported and callable without error
167
+ expect(() => mod.clearCache()).not.toThrow();
168
+ });
169
+
170
+ it('cachePath returns null when XP_GATE_DIR is inaccessible', () => {
171
+ // Verify clearCache handles null cachePath gracefully
172
+ expect(() => mod.clearCache()).not.toThrow();
173
+ });
174
+ });
175
+
176
+ // ──────────────────────────────────────────
177
+ // AC-001-04: checkUpgrade() — safe defaults
178
+ // Uses isolated https mock to avoid real network dependency.
179
+ // ──────────────────────────────────────────
180
+ describe('checkUpgrade() — AC-001-04', () => {
181
+ function evictCache() {
182
+ const resolved = require.resolve('../check-version');
183
+ const libPrefix = resolved.replace(/check-version\.js$/, '');
184
+ Object.keys(require.cache).forEach(key => {
185
+ if (key.startsWith(libPrefix)) delete require.cache[key];
186
+ });
187
+ delete require.cache[resolved];
188
+ }
189
+
190
+ function withMockedHttps(latestVersion, fn) {
191
+ const fs = require('fs');
192
+ const os = require('os');
193
+ const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
194
+ if (fs.existsSync(cpPath)) {
195
+ try { fs.unlinkSync(cpPath); } catch { }
196
+ }
197
+ evictCache();
198
+ const https = require('https');
199
+ const saved = https.get;
200
+ const body = JSON.stringify({ latest: latestVersion });
201
+ https.get = (_url, _opts, cb) => {
202
+ const callback = typeof _opts === 'function' ? _opts : cb;
203
+ if (!callback) return { on: () => undefined, destroy: () => undefined };
204
+ const mockRes = {
205
+ statusCode: 200,
206
+ on: (evt, handler) => {
207
+ if (evt === 'data') handler(body);
208
+ if (evt === 'end') handler();
209
+ return mockRes;
210
+ },
211
+ };
212
+ callback(mockRes);
213
+ return { on: () => undefined, destroy: () => undefined };
214
+ };
215
+ try {
216
+ const m = require('../check-version');
217
+ return fn(m);
218
+ } finally {
219
+ https.get = saved;
220
+ }
221
+ }
222
+
223
+ it('returns safe defaults (outdated:false) without network', async () => {
224
+ // Mock https to return the SAME version as local → outdated=false, no network dependency
225
+ const result = await withMockedHttps('0.8.12', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
226
+ expect(result.outdated).toBe(false);
227
+ expect(result.local).toBe('0.8.12');
228
+ expect(result.remote).toBe('0.8.12');
229
+ expect(result.lagDays).toBe(0);
230
+ });
231
+
232
+ it('returns outdated=true when remote > local', async () => {
233
+ const result = await withMockedHttps('99.99.99', async (m) => m.checkUpgrade('@nonexistent/pkg-test-only'));
234
+ expect(result.outdated).toBe(true);
235
+ expect(result.local).toBe('0.8.12');
236
+ expect(result.remote).toBe('99.99.99');
237
+ });
238
+ });
239
+
240
+ // ──────────────────────────────────────────
241
+ // AC-001-02 + AC-001-03 + AC-001-07: getRemoteVersion()
242
+ // ──────────────────────────────────────────
243
+ describe('getRemoteVersion() — AC-001-02, AC-001-03, AC-001-07', () => {
244
+ it('gracefully handles network failure', async () => {
245
+ mod = require('../check-version');
246
+ const r = await mod.getRemoteVersion('@nonexistent/pkg-test-only');
247
+ // Without network: null. With network (npm registry proxy): object.
248
+ expect(r === null || (typeof r.latest === 'string')).toBe(true);
249
+ });
250
+ });
251
+
252
+ // ──────────────────────────────────────────
253
+ // formatUpgradeMsg() — not outdated
254
+ // ──────────────────────────────────────────
255
+ describe('formatUpgradeMsg() — up to date', () => {
256
+ beforeEach(() => { mod = require('../check-version'); });
257
+ const r = { outdated: false, local: '0.8.12', remote: '0.8.12', lagDays: 0 };
258
+
259
+ it('cli shows checkmark', () => {
260
+ expect(mod.formatUpgradeMsg(r, 'cli')).toContain('up to date');
261
+ });
262
+ it('doctor returns empty', () => {
263
+ expect(mod.formatUpgradeMsg(r, 'doctor')).toBe('');
264
+ });
265
+ it('plugin returns empty', () => {
266
+ expect(mod.formatUpgradeMsg(r, 'plugin')).toBe('');
267
+ });
268
+ });
269
+
270
+ // ──────────────────────────────────────────
271
+ // formatUpgradeMsg() — outdated (AC-003-01, AC-003-02, AC-003-03)
272
+ // ──────────────────────────────────────────
273
+ describe('formatUpgradeMsg() — outdated (AC-003-01/02/03)', () => {
274
+ beforeEach(() => { mod = require('../check-version'); });
275
+ const r = { outdated: true, local: '0.8.12', remote: '0.8.13', lagDays: 10 };
276
+
277
+ it('cli: full release link + upgrade cmd (AC-003-01)', () => {
278
+ const msg = mod.formatUpgradeMsg(r, 'cli');
279
+ expect(msg).toContain('v0.8.13');
280
+ expect(msg).toContain('github.com');
281
+ expect(msg).toContain('upgrade --apply');
282
+ });
283
+
284
+ it('doctor: remote version + github + retry (AC-003-02)', () => {
285
+ const msg = mod.formatUpgradeMsg(r, 'doctor');
286
+ expect(msg).toContain('v0.8.13');
287
+ expect(msg).toContain('github.com');
288
+ expect(msg).toContain('upgrade --apply');
289
+ });
290
+
291
+ it('plugin lagDays >7: strong (AC-003-03)', () => {
292
+ const msg = mod.formatUpgradeMsg(r, 'plugin');
293
+ expect(msg).toContain('v0.8.13');
294
+ expect(msg).toContain('upgrade recommended');
295
+ });
296
+
297
+ it('plugin lagDays 1-7: soft (AC-003-03)', () => {
298
+ const msg = mod.formatUpgradeMsg({ ...r, lagDays: 3 }, 'plugin');
299
+ expect(msg).toContain('v0.8.13');
300
+ expect(msg).not.toContain('upgrade recommended');
301
+ });
302
+
303
+ it('plugin lagDays <1: silent (AC-003-03)', () => {
304
+ expect(mod.formatUpgradeMsg({ ...r, lagDays: 0 }, 'plugin')).toBe('');
305
+ });
306
+ });
307
+ });
@@ -13,8 +13,6 @@ describe('doctor', () => {
13
13
  let tmpProject;
14
14
  let originalHome;
15
15
  let logSpy;
16
- let warnSpy;
17
- let errorSpy;
18
16
  let execSpy;
19
17
 
20
18
  beforeEach(() => {
@@ -29,8 +27,8 @@ describe('doctor', () => {
29
27
  delete require.cache[require.resolve('../detect-deps.js')];
30
28
  delete require.cache[require.resolve('../shared-paths')];
31
29
  logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
32
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
33
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
30
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
31
+ vi.spyOn(console, 'error').mockImplementation(() => {});
34
32
  });
35
33
 
36
34
  afterEach(() => {
@@ -56,10 +54,6 @@ describe('doctor', () => {
56
54
  return path.join(tmpHome, '.config', 'xp-gate', 'adapters');
57
55
  }
58
56
 
59
- function projectGitDir() {
60
- return path.join(tmpProject, '.git');
61
- }
62
-
63
57
  function projectHooksDir() {
64
58
  return path.join(tmpProject, '.git', 'hooks');
65
59
  }
@@ -168,6 +162,17 @@ describe('doctor', () => {
168
162
  });
169
163
  }
170
164
 
165
+ // doctor() calls checkUpgrade() which hits npm registry; seed cache to skip.
166
+ // check-version.js uses os.homedir() (not process.env.HOME) for XP_GATE_DIR.
167
+ function seedVersionCache() {
168
+ const cacheDir = path.join(os.homedir(), '.xp-gate');
169
+ fs.mkdirSync(cacheDir, { recursive: true });
170
+ fs.writeFileSync(
171
+ path.join(cacheDir, 'version-cache.json'),
172
+ JSON.stringify({ ts: Date.now(), version: '0.8.12', publishedAt: '' })
173
+ );
174
+ }
175
+
171
176
  function mockExecFail() {
172
177
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
173
178
  throw new Error('Command failed');
@@ -178,36 +183,22 @@ describe('doctor', () => {
178
183
 
179
184
  it('AC-05: doctor reports all checks passed for healthy local install', async () => {
180
185
  setupLocalInstall();
186
+ seedVersionCache();
181
187
  mockExecSuccess();
182
188
  const { doctor } = require('../doctor');
183
189
 
184
190
  const result = await doctor([]);
185
191
 
186
192
  expect(result).toBe(0);
187
-
188
- // Should report Config file check
189
- expect(logSpy).toHaveBeenCalledWith(
190
- expect.stringContaining('Config file')
191
- );
192
-
193
- // Should report hooks check
194
- expect(logSpy).toHaveBeenCalledWith(
195
- expect.stringContaining('Hooks')
196
- );
197
-
198
- // Should report Adapters directory check
199
- expect(logSpy).toHaveBeenCalledWith(
200
- expect.stringContaining('Adapters directory')
201
- );
202
-
203
- // Should report all checks passed
204
- expect(logSpy).toHaveBeenCalledWith(
205
- expect.stringContaining('All checks passed')
206
- );
193
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Config file'));
194
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Hooks'));
195
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Adapters directory'));
196
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('All checks passed'));
207
197
  });
208
198
 
209
199
  it('AC-05: doctor reports all checks passed for healthy global install', async () => {
210
200
  setupGlobalInstall();
201
+ seedVersionCache();
211
202
  mockExecSuccess();
212
203
  const { doctor } = require('../doctor');
213
204
 
@@ -223,6 +214,7 @@ describe('doctor', () => {
223
214
 
224
215
  it('AC-08: doctor detects missing hooks in partial install', async () => {
225
216
  setupLocalInstall();
217
+ seedVersionCache();
226
218
  // Remove hooks to simulate partial state
227
219
  fs.unlinkSync(path.join(projectHooksDir(), 'pre-commit'));
228
220
  fs.unlinkSync(path.join(projectHooksDir(), 'pre-push'));
@@ -268,6 +260,7 @@ describe('doctor', () => {
268
260
 
269
261
  it('AC-08: doctor detects missing adapters', async () => {
270
262
  setupLocalInstall();
263
+ seedVersionCache();
271
264
  // Remove adapters dir
272
265
  fs.rmSync(projectAdaptersDir(), { recursive: true, force: true });
273
266
  mockExecSuccess();
@@ -286,6 +279,7 @@ describe('doctor', () => {
286
279
 
287
280
  it('AC-08: doctor detects wrong core.hooksPath in global mode', async () => {
288
281
  setupGlobalInstall();
282
+ seedVersionCache();
289
283
  // Mock hooksPath pointing somewhere else
290
284
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
291
285
  if (cmd.includes('git config --global core.hooksPath')) {
@@ -309,13 +303,9 @@ describe('doctor', () => {
309
303
  return '';
310
304
  });
311
305
  const { doctor } = require('../doctor');
312
-
313
306
  const result = await doctor([]);
314
-
315
307
  expect(result).toBe(1);
316
- expect(logSpy).toHaveBeenCalledWith(
317
- expect.stringContaining('Expected ')
318
- );
308
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Expected '));
319
309
  });
320
310
 
321
311
  // === AC-10: --fix only when mode === "active" ===
@@ -328,49 +318,41 @@ describe('doctor', () => {
328
318
  );
329
319
 
330
320
  const { doctor } = require('../doctor');
331
-
332
321
  const result = await doctor(['--fix']);
333
-
334
322
  expect(result).toBe(0);
335
- // Should NOT attempt fix operations
336
- expect(logSpy).toHaveBeenCalledWith(
337
- expect.stringContaining('xp-gate is not installed')
338
- );
323
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('xp-gate is not installed'));
339
324
  });
340
325
 
341
326
  it('AC-10: doctor --fix reinstall hooks when mode is active and hooks missing', async () => {
342
327
  setupLocalInstall();
328
+ seedVersionCache();
343
329
  // Remove hooks to create a fixable issue
344
330
  fs.unlinkSync(path.join(projectHooksDir(), 'pre-commit'));
345
331
  fs.unlinkSync(path.join(projectHooksDir(), 'pre-push'));
346
332
  mockExecSuccess();
347
333
  const { doctor } = require('../doctor');
348
-
349
334
  const result = await doctor(['--fix']);
350
-
351
335
  expect(result).toBe(0);
352
- // Should have reinstalled hooks
353
336
  expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
354
337
  expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(true);
355
338
  });
356
339
 
357
340
  it('AC-10: doctor --fix reinstall adapters when mode is active and adapters missing', async () => {
358
341
  setupLocalInstall();
342
+ seedVersionCache();
359
343
  // Remove adapters
360
344
  fs.rmSync(projectAdaptersDir(), { recursive: true, force: true });
361
345
  mockExecSuccess();
362
346
  const { doctor } = require('../doctor');
363
-
364
347
  const result = await doctor(['--fix']);
365
-
366
348
  expect(result).toBe(0);
367
- // Should have reinstalled adapters
368
349
  expect(fs.existsSync(projectAdaptersDir())).toBe(true);
369
350
  expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(true);
370
351
  });
371
352
 
372
353
  it('AC-10: doctor --fix corrects core.hooksPath in global mode', async () => {
373
354
  setupGlobalInstall();
355
+ seedVersionCache();
374
356
  // Mock hooksPath pointing somewhere else
375
357
  execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
376
358
  if (cmd.includes('git config --global core.hooksPath')) {
@@ -436,6 +418,7 @@ describe('doctor', () => {
436
418
 
437
419
  it('detects missing environment dependencies', async () => {
438
420
  setupLocalInstall();
421
+ seedVersionCache();
439
422
  mockExecFail();
440
423
  const { doctor } = require('../doctor');
441
424
 
@@ -451,6 +434,7 @@ describe('doctor', () => {
451
434
 
452
435
  it('detects version mismatch when config version differs from package version', async () => {
453
436
  setupLocalInstall();
437
+ seedVersionCache();
454
438
  // Write config with old version
455
439
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
456
440
  cfg.version = '0.3.1.1';
@@ -471,6 +455,7 @@ describe('doctor', () => {
471
455
 
472
456
  it('passes version check when config version matches package version', async () => {
473
457
  setupLocalInstall();
458
+ seedVersionCache();
474
459
  // Write config with matching version
475
460
  const pkg = JSON.parse(fs.readFileSync(
476
461
  path.join(path.dirname(path.dirname(require.resolve('../doctor'))), 'package.json'), 'utf8'
@@ -491,6 +476,7 @@ describe('doctor', () => {
491
476
 
492
477
  it('passes version check when config has no version field (legacy)', async () => {
493
478
  setupLocalInstall();
479
+ seedVersionCache();
494
480
  // Config without version field — legacy install, should not fail
495
481
  mockExecSuccess();
496
482
  const { doctor } = require('../doctor');
@@ -507,6 +493,7 @@ describe('doctor', () => {
507
493
 
508
494
  it('detects templateDir pointing to wrong platform directory', async () => {
509
495
  setupLocalInstall();
496
+ seedVersionCache();
510
497
  // Write config with stale opencode templateDir when qoder is active
511
498
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
512
499
  cfg.templateDir = path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
@@ -534,18 +521,20 @@ describe('doctor', () => {
534
521
 
535
522
  it('passes templateDir check when templateDir matches current platform', async () => {
536
523
  setupLocalInstall();
524
+ seedVersionCache();
537
525
  // Write config with correct qoder templateDir
538
526
  const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
539
527
  cfg.templateDir = path.join(tmpHome, '.qoder', 'git-hooks-template');
540
528
  fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
541
529
 
542
- // Create qoder marker
530
+ // Create qoder marker — must exist BEFORE loading shared-paths
543
531
  fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills'), { recursive: true });
544
532
 
533
+ // Fresh require so shared-paths detectPlatform() sees .qoder/skills
545
534
  delete require.cache[require.resolve('../shared-paths')];
546
535
  delete require.cache[require.resolve('../doctor')];
547
- const { doctor: doc2 } = require('../doctor');
548
536
  mockExecSuccess();
537
+ const { doctor: doc2 } = require('../doctor');
549
538
 
550
539
  const result = await doc2([]);
551
540
 
@@ -557,6 +546,7 @@ describe('doctor', () => {
557
546
 
558
547
  it('passes templateDir check when config has no templateDir field (legacy)', async () => {
559
548
  setupLocalInstall();
549
+ seedVersionCache();
560
550
  // Config without templateDir — legacy install, should not fail
561
551
  mockExecSuccess();
562
552
  const { doctor } = require('../doctor');
@@ -569,10 +559,73 @@ describe('doctor', () => {
569
559
  );
570
560
  });
571
561
 
562
+ // === REQ-001-04: doctor 集成版本升级检查 (AC-004-01/02) ===
563
+
564
+ it('AC-004-01: doctor shows upgrade prompt at end when outdated', async () => {
565
+ setupLocalInstall();
566
+ mockExecSuccess();
567
+ // Remove version cache so checkUpgrade hits the real npm registry
568
+ const cachePath = path.join(tmpHome, '.xp-gate', 'version-cache.json');
569
+ if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath);
570
+
571
+ delete require.cache[require.resolve('../doctor')];
572
+ delete require.cache[require.resolve('../check-version.js')];
573
+ const { doctor } = require('../doctor');
574
+
575
+ const result = await doctor([]);
576
+
577
+ expect(result).toBe(0);
578
+ // Doctor should output upgrade message when outdated
579
+ const output = logSpy.mock.calls.map(c => c[0] || '').join('\n');
580
+ expect(output).toMatch(/newer version|upgrade|v\d+\.\d+\.\d+/);
581
+ }, 30000);
582
+
583
+ it('AC-004-02: doctor does NOT show upgrade prompt when up to date', async () => {
584
+ setupLocalInstall();
585
+ mockExecSuccess();
586
+ // Write a cache with a future version so checkUpgrade says "not outdated"
587
+ const cachePath = path.join(tmpHome, '.xp-gate', 'version-cache.json');
588
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
589
+ fs.writeFileSync(cachePath, JSON.stringify({
590
+ ts: Date.now(),
591
+ version: '999.999.999',
592
+ publishedAt: ''
593
+ }));
594
+
595
+ delete require.cache[require.resolve('../doctor')];
596
+ delete require.cache[require.resolve('../check-version.js')];
597
+ const { doctor } = require('../doctor');
598
+
599
+ const result = await doctor([]);
600
+
601
+ expect(result).toBe(0);
602
+ const output = logSpy.mock.calls.map(c => c[0] || '').join('\n');
603
+ // Should NOT contain upgrade-related messages
604
+ expect(output).not.toMatch(/newer version|upgrade/i);
605
+ });
606
+
607
+ it('AC-004-03: doctor does NOT fail when version check throws', async () => {
608
+ setupLocalInstall();
609
+ seedVersionCache();
610
+ mockExecSuccess();
611
+ // Write corrupt cache to trigger checkUpgrade error path
612
+ const cachePath = path.join(os.homedir(), '.xp-gate', 'version-cache.json');
613
+ fs.writeFileSync(cachePath, 'not json');
614
+
615
+ delete require.cache[require.resolve('../doctor')];
616
+ delete require.cache[require.resolve('../check-version.js')];
617
+ const { doctor } = require('../doctor');
618
+
619
+ // Doctor should NOT throw — version check is non-blocking
620
+ const result = await doctor([]);
621
+ expect(result).toBe(0);
622
+ });
623
+
572
624
  // === Issue #186: --fix syncs global hooks from package source ===
573
625
 
574
626
  it('--fix syncs global hooks when they are outdated', async () => {
575
627
  setupGlobalInstall();
628
+ seedVersionCache();
576
629
  // Write an outdated pre-commit hook (different content)
577
630
  const oldContent = '#!/bin/bash\n# OpenCode Quality Gates - Pre-Commit Hook - OLD VERSION\n';
578
631
  fs.writeFileSync(path.join(globalHooksDir(), 'pre-commit'), oldContent);