@boyingliu01/xp-gate 0.8.12 → 0.8.13
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 +6 -0
- package/lib/__tests__/check-version.test.js +307 -0
- package/lib/__tests__/doctor.test.js +100 -47
- package/lib/__tests__/upgrade-exec.test.js +236 -0
- package/lib/__tests__/upgrade.test.js +77 -0
- package/lib/check-version.js +288 -0
- package/lib/doctor.js +10 -0
- package/lib/upgrade.js +116 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/xp-gate-version-check.sh +62 -0
- package/plugins/claude-code/hooks/hooks.json +9 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/index.ts +75 -47
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-001-02 xp-gate upgrade --apply execSync path
|
|
3
|
+
* @intent 验证 upgrade --apply 模式下 execSync 的调用路径和错误处理
|
|
4
|
+
* @covers AC-002-03
|
|
5
|
+
*
|
|
6
|
+
* Must be a separate file (not merged into upgrade.test.js) because:
|
|
7
|
+
* upgrade.test.js has a describe-level beforeEach that calls
|
|
8
|
+
* vi.resetModules() + require('../upgrade'), which runs before
|
|
9
|
+
* EVERY test — including tests in a sibling describe block.
|
|
10
|
+
* This would clobber our mock setup. The helper approach
|
|
11
|
+
* (withMockedEnv) works correctly in isolation, confirmed by
|
|
12
|
+
* standalone test verification.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
describe('upgrade.js --apply execSync path', () => {
|
|
16
|
+
function evictCache() {
|
|
17
|
+
const resolved = require.resolve('../upgrade');
|
|
18
|
+
const cvResolved = require.resolve('../check-version');
|
|
19
|
+
const libPrefix = resolved.replace(/upgrade\.js$/, '');
|
|
20
|
+
Object.keys(require.cache).forEach(key => {
|
|
21
|
+
if (key.startsWith(libPrefix)) delete require.cache[key];
|
|
22
|
+
});
|
|
23
|
+
delete require.cache[resolved];
|
|
24
|
+
delete require.cache[cvResolved];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withMockedEnv(latestVersion, execSyncImpl, fn) {
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const os = require('os');
|
|
30
|
+
const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
|
|
31
|
+
if (fs.existsSync(cpPath)) {
|
|
32
|
+
try { fs.unlinkSync(cpPath); } catch { }
|
|
33
|
+
}
|
|
34
|
+
evictCache();
|
|
35
|
+
const cp = require('child_process');
|
|
36
|
+
const https = require('https');
|
|
37
|
+
const saved = { execSync: cp.execSync, httpsGet: https.get };
|
|
38
|
+
cp.execSync = execSyncImpl;
|
|
39
|
+
const body = JSON.stringify({ latest: latestVersion });
|
|
40
|
+
https.get = (_url, _opts, cb) => {
|
|
41
|
+
const callback = typeof _opts === 'function' ? _opts : cb;
|
|
42
|
+
if (!callback) return { on: () => undefined, destroy: () => undefined };
|
|
43
|
+
const mockRes = {
|
|
44
|
+
statusCode: 200,
|
|
45
|
+
on: (evt, handler) => {
|
|
46
|
+
if (evt === 'data') handler(body);
|
|
47
|
+
if (evt === 'end') handler();
|
|
48
|
+
return mockRes;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
callback(mockRes);
|
|
52
|
+
return { on: () => undefined, destroy: () => undefined };
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const m = require('../upgrade');
|
|
56
|
+
return fn(m);
|
|
57
|
+
} finally {
|
|
58
|
+
cp.execSync = saved.execSync;
|
|
59
|
+
https.get = saved.httpsGet;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it('returns 0 when execSync succeeds', async () => {
|
|
64
|
+
const code = await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => {
|
|
65
|
+
return m.upgrade(['--apply']);
|
|
66
|
+
});
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
}, 10000);
|
|
69
|
+
|
|
70
|
+
it('returns 1 when execSync throws EACCES', async () => {
|
|
71
|
+
const err = new Error('EACCES');
|
|
72
|
+
err.stderr = Buffer.from('EACCES: permission denied');
|
|
73
|
+
const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
|
|
74
|
+
return m.upgrade(['--apply']);
|
|
75
|
+
});
|
|
76
|
+
expect(code).toBe(1);
|
|
77
|
+
}, 10000);
|
|
78
|
+
|
|
79
|
+
it('returns 1 when execSync throws ETIMEDOUT', async () => {
|
|
80
|
+
const err = new Error('ETIMEDOUT');
|
|
81
|
+
err.code = 'ETIMEDOUT';
|
|
82
|
+
err.stderr = Buffer.from('');
|
|
83
|
+
const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
|
|
84
|
+
return m.upgrade(['--apply']);
|
|
85
|
+
});
|
|
86
|
+
expect(code).toBe(1);
|
|
87
|
+
}, 10000);
|
|
88
|
+
|
|
89
|
+
it('returns 1 when execSync throws generic error', async () => {
|
|
90
|
+
const err = new Error('generic failure');
|
|
91
|
+
err.stderr = Buffer.from('');
|
|
92
|
+
const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
|
|
93
|
+
return m.upgrade(['--apply']);
|
|
94
|
+
});
|
|
95
|
+
expect(code).toBe(1);
|
|
96
|
+
}, 10000);
|
|
97
|
+
|
|
98
|
+
it('calls execSync with correct pkgName and version', async () => {
|
|
99
|
+
const mockFn = vi.fn().mockReturnValue(Buffer.from(''));
|
|
100
|
+
await withMockedEnv('99.99.99', mockFn, async (m) => {
|
|
101
|
+
await m.upgrade(['--apply']);
|
|
102
|
+
expect(mockFn).toHaveBeenCalledWith(
|
|
103
|
+
expect.stringMatching(/npm install -g .+@99\.99\.99/),
|
|
104
|
+
expect.objectContaining({ stdio: 'inherit', timeout: 120000 }),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}, 10000);
|
|
108
|
+
|
|
109
|
+
// ── default mode (no --apply/--preview) outdated path ──
|
|
110
|
+
// Covers upgrade.js L110-112: formatUpgradeMsg + console.log
|
|
111
|
+
it('default mode: shows upgrade msg when outdated', async () => {
|
|
112
|
+
await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => {
|
|
113
|
+
const out = [];
|
|
114
|
+
const origLog = console.log;
|
|
115
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
116
|
+
try {
|
|
117
|
+
const code = await m.upgrade([]);
|
|
118
|
+
expect(code).toBe(0);
|
|
119
|
+
const joined = out.join(' ');
|
|
120
|
+
expect(joined).toContain('newer version') || expect(joined).toContain('v99.99.99');
|
|
121
|
+
} finally {
|
|
122
|
+
console.log = origLog;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}, 10000);
|
|
126
|
+
|
|
127
|
+
// ── withMockedEnv variant: also mocks getLocalVersion() to return null ──
|
|
128
|
+
function withMockedEnvNoLocal(latestVersion, execSyncImpl, fn) {
|
|
129
|
+
const fs = require('fs');
|
|
130
|
+
const os = require('os');
|
|
131
|
+
const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
|
|
132
|
+
if (fs.existsSync(cpPath)) {
|
|
133
|
+
try { fs.unlinkSync(cpPath); } catch { }
|
|
134
|
+
}
|
|
135
|
+
evictCache();
|
|
136
|
+
const cp = require('child_process');
|
|
137
|
+
const https = require('https');
|
|
138
|
+
const saved = { execSync: cp.execSync, httpsGet: https.get, fsReadFileSync: fs.readFileSync };
|
|
139
|
+
cp.execSync = execSyncImpl;
|
|
140
|
+
// Make getLocalVersion() return null by making fs.readFileSync throw for package.json
|
|
141
|
+
fs.readFileSync = (filePath, encoding) => {
|
|
142
|
+
if (typeof filePath === 'string' && filePath.includes('package.json')) {
|
|
143
|
+
const err = new Error('ENOENT: no such file');
|
|
144
|
+
err.code = 'ENOENT';
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return saved.fsReadFileSync.call(fs, filePath, encoding);
|
|
148
|
+
};
|
|
149
|
+
const body = JSON.stringify({ latest: latestVersion });
|
|
150
|
+
https.get = (_url, _opts, cb) => {
|
|
151
|
+
const callback = typeof _opts === 'function' ? _opts : cb;
|
|
152
|
+
if (!callback) return { on: () => undefined, destroy: () => undefined };
|
|
153
|
+
const mockRes = {
|
|
154
|
+
statusCode: 200,
|
|
155
|
+
on: (evt, handler) => {
|
|
156
|
+
if (evt === 'data') handler(body);
|
|
157
|
+
if (evt === 'end') handler();
|
|
158
|
+
return mockRes;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
callback(mockRes);
|
|
162
|
+
return { on: () => undefined, destroy: () => undefined };
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
const m = require('../upgrade');
|
|
166
|
+
return fn(m);
|
|
167
|
+
} finally {
|
|
168
|
+
cp.execSync = saved.execSync;
|
|
169
|
+
https.get = saved.httpsGet;
|
|
170
|
+
fs.readFileSync = saved.fsReadFileSync;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Coverage gap: L71-72 — --apply with null local, not outdated ──
|
|
175
|
+
// getLocalVersion() returns null (fs.readFileSync throws), remote matches
|
|
176
|
+
it('--apply mode: null local, not outdated (L71-72)', async () => {
|
|
177
|
+
const out = [];
|
|
178
|
+
const origLog = console.log;
|
|
179
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
180
|
+
try {
|
|
181
|
+
const code = await withMockedEnvNoLocal('0.8.12', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade(['--apply']));
|
|
182
|
+
expect(code).toBe(0);
|
|
183
|
+
expect(out.join(' ')).toBe('xp-gate is up to date.');
|
|
184
|
+
} finally {
|
|
185
|
+
console.log = origLog;
|
|
186
|
+
}
|
|
187
|
+
}, 10000);
|
|
188
|
+
|
|
189
|
+
// ── Coverage gap: L107-108 — default mode with null local, not outdated ──
|
|
190
|
+
it('default mode: null local, not outdated (L107-108)', async () => {
|
|
191
|
+
const out = [];
|
|
192
|
+
const origLog = console.log;
|
|
193
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
194
|
+
try {
|
|
195
|
+
const code = await withMockedEnvNoLocal('0.8.12', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade([]));
|
|
196
|
+
expect(code).toBe(0);
|
|
197
|
+
expect(out.join(' ')).toBe('xp-gate is up to date.');
|
|
198
|
+
} finally {
|
|
199
|
+
console.log = origLog;
|
|
200
|
+
}
|
|
201
|
+
}, 10000);
|
|
202
|
+
|
|
203
|
+
// ── AC-002-01: default mode human-readable output (isolated, no real network) ──
|
|
204
|
+
it('AC-002-01: displays human-readable output (exit 0)', async () => {
|
|
205
|
+
const out = [];
|
|
206
|
+
const origLog = console.log;
|
|
207
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
208
|
+
try {
|
|
209
|
+
const code = await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade([]));
|
|
210
|
+
expect(code).toBe(0);
|
|
211
|
+
expect(out.length + (out.join(' ').length > 0 ? 1 : 0)).toBeGreaterThan(0);
|
|
212
|
+
} finally {
|
|
213
|
+
console.log = origLog;
|
|
214
|
+
}
|
|
215
|
+
}, 10000);
|
|
216
|
+
|
|
217
|
+
// ── AC-002-02: --preview mode JSON output (isolated, no real network) ──
|
|
218
|
+
it('AC-002-02: outputs single-line JSON with version info', async () => {
|
|
219
|
+
const out = [];
|
|
220
|
+
const origLog = console.log;
|
|
221
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
222
|
+
try {
|
|
223
|
+
const code = await withMockedEnv('1.0.0', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade(['--preview']));
|
|
224
|
+
expect(code).toBe(0);
|
|
225
|
+
expect(out.length).toBe(1);
|
|
226
|
+
const parsed = JSON.parse(out[0]);
|
|
227
|
+
expect(parsed).toHaveProperty('local');
|
|
228
|
+
expect(parsed).toHaveProperty('remote');
|
|
229
|
+
expect(parsed).toHaveProperty('outdated');
|
|
230
|
+
expect(parsed).toHaveProperty('lagDays');
|
|
231
|
+
expect(parsed).toHaveProperty('releaseUrl');
|
|
232
|
+
} finally {
|
|
233
|
+
console.log = origLog;
|
|
234
|
+
}
|
|
235
|
+
}, 10000);
|
|
236
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-001-02 xp-gate upgrade CLI command
|
|
3
|
+
* @intent 验证 upgrade 命令三种模式(default/preview/apply)的代码路径和错误处理
|
|
4
|
+
* @covers AC-002-01, AC-002-02, AC-002-03, AC-002-04, AC-002-05, AC-002-06, AC-002-07, AC-002-08
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
function capture() {
|
|
11
|
+
const out = [];
|
|
12
|
+
const err = [];
|
|
13
|
+
const l = console.log;
|
|
14
|
+
const e = console.error;
|
|
15
|
+
console.log = (...a) => { out.push(a.join(' ')); };
|
|
16
|
+
console.error = (...a) => { err.push(a.join(' ')); };
|
|
17
|
+
return { out, err, restore: () => { console.log = l; console.error = e; } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('upgrade.js — REQ-001-02', () => {
|
|
21
|
+
let mod;
|
|
22
|
+
let r;
|
|
23
|
+
|
|
24
|
+
function evictLibCache() {
|
|
25
|
+
const resolved = require.resolve('../upgrade');
|
|
26
|
+
const cvResolved = require.resolve('../check-version');
|
|
27
|
+
const libPrefix = resolved.replace(/upgrade\.js$/, '');
|
|
28
|
+
Object.keys(require.cache).forEach(key => {
|
|
29
|
+
if (key.startsWith(libPrefix)) delete require.cache[key];
|
|
30
|
+
});
|
|
31
|
+
delete require.cache[resolved];
|
|
32
|
+
delete require.cache[cvResolved];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
// Clear disk cache so checkUpgrade doesn't see stale data from
|
|
37
|
+
// upgrade-exec.test.js's mock (which wrote version-cache.json).
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const os = require('os');
|
|
40
|
+
const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
|
|
41
|
+
if (fs.existsSync(cpPath)) { try { fs.unlinkSync(cpPath); } catch { } }
|
|
42
|
+
evictLibCache();
|
|
43
|
+
vi.resetModules();
|
|
44
|
+
r = capture();
|
|
45
|
+
mod = require('../upgrade');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
r.restore();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// AC-002-01 through AC-002-06: tested in upgrade-exec.test.js (isolated, no real network)
|
|
53
|
+
|
|
54
|
+
// AC-002-07: clearCache is exported and works
|
|
55
|
+
describe('clearCache function — AC-002-07', () => {
|
|
56
|
+
it('is accessible from check-version module', () => {
|
|
57
|
+
const cv = require('../check-version');
|
|
58
|
+
expect(typeof cv.clearCache).toBe('function');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// AC-002-08: no hardcoded package name
|
|
63
|
+
describe('getPackageName() usage — AC-002-08', () => {
|
|
64
|
+
it('uses pkgName variable in npm install -g commands, not hardcoded name', () => {
|
|
65
|
+
const source = fs.readFileSync(require.resolve('../upgrade.js'), 'utf8');
|
|
66
|
+
// Only check executable lines (non-comment), not JSDoc or inline comments
|
|
67
|
+
const execLines = source.split('\n')
|
|
68
|
+
.filter(l => l.includes('npm install -g') && !l.includes('//') && !l.includes('*'));
|
|
69
|
+
for (const line of execLines) {
|
|
70
|
+
expect(line).toContain('pkgName');
|
|
71
|
+
expect(line).not.toMatch(/npm install -g @boyingliu01/);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PKG_NAME = '@boyingliu01/xp-gate';
|
|
6
|
+
const REGISTRY_URL = (pkg) => `https://registry.npmjs.org/-/package/${encodeURIComponent(pkg)}/dist-tags`;
|
|
7
|
+
const NETWORK_TIMEOUT_MS = 5000;
|
|
8
|
+
const CACHE_TTL_MS = 300000; // 5 minutes
|
|
9
|
+
const XP_GATE_DIR = (() => {
|
|
10
|
+
try {
|
|
11
|
+
const home = require('os').homedir();
|
|
12
|
+
return path.join(home, '.xp-gate');
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
})();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read the package name from the installed package.json, falling back to DEFAULT_PKG_NAME.
|
|
18
|
+
*/
|
|
19
|
+
function getPackageName() {
|
|
20
|
+
try {
|
|
21
|
+
const pkgDir = path.dirname(__dirname);
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
23
|
+
const name = pkg.name;
|
|
24
|
+
// scoped name like @boyingliu01/xp-gate
|
|
25
|
+
if (name && name.startsWith('@')) return name;
|
|
26
|
+
} catch { /* fallthrough */ }
|
|
27
|
+
return DEFAULT_PKG_NAME;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the local version from the installed package.json.
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
function getLocalVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const pkgDir = path.dirname(__dirname);
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
38
|
+
return pkg.version || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cache entry helpers — atomic file write via temp+rename to prevent concurrent read corruption.
|
|
46
|
+
*/
|
|
47
|
+
function cachePath() {
|
|
48
|
+
if (!XP_GATE_DIR) return null;
|
|
49
|
+
return path.join(XP_GATE_DIR, 'version-cache.json');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureDir(dir) {
|
|
53
|
+
if (!dir) return false;
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
56
|
+
return true;
|
|
57
|
+
} catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readCache() {
|
|
61
|
+
const cp = cachePath();
|
|
62
|
+
if (!cp || !fs.existsSync(cp)) return null;
|
|
63
|
+
try {
|
|
64
|
+
const raw = fs.readFileSync(cp, 'utf8');
|
|
65
|
+
const data = JSON.parse(raw);
|
|
66
|
+
if (data && data.ts && data.version && (Date.now() - data.ts) < CACHE_TTL_MS) {
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
// expired
|
|
70
|
+
return null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeCache(latest, publishedAt) {
|
|
77
|
+
const cp = cachePath();
|
|
78
|
+
if (!cp) return;
|
|
79
|
+
ensureDir(path.dirname(cp));
|
|
80
|
+
const data = JSON.stringify({ ts: Date.now(), version: latest, publishedAt });
|
|
81
|
+
// atomic write: temp → rename
|
|
82
|
+
const tmp = cp + '.tmp.' + process.pid;
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(tmp, data, 'utf8');
|
|
85
|
+
fs.renameSync(tmp, cp);
|
|
86
|
+
} catch {
|
|
87
|
+
try { fs.unlinkSync(tmp); } catch { /* skip */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function clearCache() {
|
|
92
|
+
const cp = cachePath();
|
|
93
|
+
if (cp && fs.existsSync(cp)) {
|
|
94
|
+
try { fs.unlinkSync(cp); } catch { /* skip */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch the published timestamp for a specific version from the npm registry.
|
|
100
|
+
* Uses the dist-tag endpoint's "time" map to get the exact publish time.
|
|
101
|
+
* This is called as a fire-and-forget background request to enrich cache.
|
|
102
|
+
* @param {string} name — package name
|
|
103
|
+
* @param {string} version — version to look up (e.g. "0.8.13")
|
|
104
|
+
* @returns {Promise<string|null>} ISO timestamp string or null
|
|
105
|
+
*/
|
|
106
|
+
function getVersionTime(name, version) {
|
|
107
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const req = https.get(url, { timeout: NETWORK_TIMEOUT_MS }, (res) => {
|
|
110
|
+
let body = '';
|
|
111
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
112
|
+
res.on('end', () => {
|
|
113
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
114
|
+
try {
|
|
115
|
+
const data = JSON.parse(body);
|
|
116
|
+
const time = data && data.time && data.time[version];
|
|
117
|
+
resolve(typeof time === 'string' ? time : null);
|
|
118
|
+
} catch {
|
|
119
|
+
resolve(null);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
req.on('error', () => resolve(null));
|
|
124
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Calculate lagDays between a publish timestamp and now.
|
|
130
|
+
* Returns 0 if publishedAt is empty or unparseable.
|
|
131
|
+
* @param {string} publishedAt — ISO timestamp string
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
function calcLagDays(publishedAt) {
|
|
135
|
+
if (!publishedAt) return 0;
|
|
136
|
+
const published = new Date(publishedAt).getTime();
|
|
137
|
+
if (isNaN(published)) return 0;
|
|
138
|
+
return Math.floor((Date.now() - published) / 86400000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Query npm registry for the latest version + publishedAt timestamp.
|
|
143
|
+
* @param {string} [pkgName] — defaults to dynamic package name
|
|
144
|
+
* @returns {{ latest: string, publishedAt: string }|null}
|
|
145
|
+
*/
|
|
146
|
+
function getRemoteVersion(pkgName) {
|
|
147
|
+
// check cache first
|
|
148
|
+
const cached = readCache();
|
|
149
|
+
if (cached) return { latest: cached.version, publishedAt: cached.publishedAt || '' };
|
|
150
|
+
|
|
151
|
+
const name = pkgName || getPackageName();
|
|
152
|
+
const url = REGISTRY_URL(name);
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
const req = https.get(url, { timeout: NETWORK_TIMEOUT_MS }, (res) => {
|
|
156
|
+
let body = '';
|
|
157
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
158
|
+
res.on('end', () => {
|
|
159
|
+
if (res.statusCode !== 200) {
|
|
160
|
+
// non-200 → return null (non-blocking)
|
|
161
|
+
return resolve(null);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const data = JSON.parse(body);
|
|
165
|
+
const latest = data && data.latest;
|
|
166
|
+
if (typeof latest !== 'string') {
|
|
167
|
+
// missing or non-string latest key
|
|
168
|
+
return resolve(null);
|
|
169
|
+
}
|
|
170
|
+
// Extract publishedAt from the version's time entry.
|
|
171
|
+
// We need to make a second request to get the time info.
|
|
172
|
+
// This is done via getVersionTime() which is an async helper.
|
|
173
|
+
// We resolve({latest, ''}) first and update the cache later
|
|
174
|
+
// with the publishedAt from getVersionTime().
|
|
175
|
+
let publishedAt = '';
|
|
176
|
+
writeCache(latest, publishedAt);
|
|
177
|
+
resolve({ latest, publishedAt });
|
|
178
|
+
// Fire-and-forget: fetch publishedAt asynchronously to update cache
|
|
179
|
+
getVersionTime(name, latest).then(time => {
|
|
180
|
+
if (time) {
|
|
181
|
+
writeCache(latest, time);
|
|
182
|
+
}
|
|
183
|
+
}).catch(() => {});
|
|
184
|
+
} catch {
|
|
185
|
+
// JSON parse failure
|
|
186
|
+
resolve(null);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
req.on('error', () => {
|
|
192
|
+
// network error
|
|
193
|
+
resolve(null);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
req.on('timeout', () => {
|
|
197
|
+
req.destroy();
|
|
198
|
+
resolve(null);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Compare local version against remote. Returns upgrade status.
|
|
205
|
+
* @param {string} [pkgName]
|
|
206
|
+
* @returns {Promise<{ outdated: boolean, local: string|null, remote: string|null, lagDays: number }>}
|
|
207
|
+
*/
|
|
208
|
+
async function checkUpgrade(pkgName) {
|
|
209
|
+
const local = getLocalVersion();
|
|
210
|
+
const remoteResult = await getRemoteVersion(pkgName);
|
|
211
|
+
const remote = remoteResult ? remoteResult.latest : null;
|
|
212
|
+
|
|
213
|
+
if (!local || !remote) {
|
|
214
|
+
return { outdated: false, local, remote, lagDays: 0 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const outdated = compareVersions(local, remote) < 0;
|
|
218
|
+
|
|
219
|
+
// If the cached remoteResult didn't have publishedAt yet
|
|
220
|
+
// (first call — fire-and-forget still in flight), fetch it now
|
|
221
|
+
// and update the cache for next time.
|
|
222
|
+
const name = pkgName || getPackageName();
|
|
223
|
+
let publishedAt = remoteResult ? (remoteResult.publishedAt || '') : '';
|
|
224
|
+
if (!publishedAt) {
|
|
225
|
+
publishedAt = await getVersionTime(name, remote) || '';
|
|
226
|
+
if (publishedAt) {
|
|
227
|
+
writeCache(remote, publishedAt);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const lagDays = calcLagDays(publishedAt);
|
|
231
|
+
|
|
232
|
+
return { outdated, local, remote, lagDays };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Compare two semver-like strings (e.g. "0.8.12" vs "0.8.13").
|
|
237
|
+
* Returns negative if a < b, positive if a > b, 0 if equal.
|
|
238
|
+
*/
|
|
239
|
+
function compareVersions(a, b) {
|
|
240
|
+
const pa = a.split('.').map(Number);
|
|
241
|
+
const pb = b.split('.').map(Number);
|
|
242
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
243
|
+
const na = pa[i] || 0;
|
|
244
|
+
const nb = pb[i] || 0;
|
|
245
|
+
if (na !== nb) return na - nb;
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Format an upgrade notification message for the given context.
|
|
252
|
+
* @param {{ outdated: boolean, local: string, remote: string, lagDays: number }} result
|
|
253
|
+
* @param {'cli'|'doctor'|'plugin'} context
|
|
254
|
+
* @returns {string}
|
|
255
|
+
*/
|
|
256
|
+
function formatUpgradeMsg(result, context) {
|
|
257
|
+
if (!result || !result.outdated || !result.remote) {
|
|
258
|
+
if (context === 'cli') return `\u2713 xp-gate v${result?.local || 'unknown'} is up to date`;
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const releaseUrl = `https://github.com/boyingliu01/xp-gate/releases/tag/v${result.remote}`;
|
|
263
|
+
const upgradeCmd = 'xp-gate upgrade --apply';
|
|
264
|
+
|
|
265
|
+
switch (context) {
|
|
266
|
+
case 'cli':
|
|
267
|
+
return `A newer version v${result.remote} is available (${releaseUrl}) \u2014 run: ${upgradeCmd}`;
|
|
268
|
+
case 'doctor':
|
|
269
|
+
return `Remote: v${result.remote} \u2190 NEW (see: ${releaseUrl})\n Run: ${upgradeCmd}`;
|
|
270
|
+
case 'plugin': {
|
|
271
|
+
if (result.lagDays < 1) return ''; // silent
|
|
272
|
+
if (result.lagDays <= 7) return `Upgrade: v${result.remote} available \u2014 run: ${upgradeCmd}`;
|
|
273
|
+
return `New version v${result.remote} available (you have v${result.local}) \u2014 upgrade recommended \u2014 run: ${upgradeCmd}`;
|
|
274
|
+
}
|
|
275
|
+
default:
|
|
276
|
+
return '';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
getPackageName,
|
|
282
|
+
getLocalVersion,
|
|
283
|
+
getRemoteVersion,
|
|
284
|
+
checkUpgrade,
|
|
285
|
+
formatUpgradeMsg,
|
|
286
|
+
compareVersions,
|
|
287
|
+
clearCache,
|
|
288
|
+
};
|
package/lib/doctor.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
detectPlatform,
|
|
11
11
|
getTemplateDir,
|
|
12
12
|
} = require('./shared-paths.js');
|
|
13
|
+
const { checkUpgrade, formatUpgradeMsg } = require('./check-version.js');
|
|
13
14
|
|
|
14
15
|
// npm package source dir (template hooks/adapters)
|
|
15
16
|
const PKG_DIR = path.dirname(__dirname);
|
|
@@ -397,6 +398,15 @@ async function doctor(args) {
|
|
|
397
398
|
|
|
398
399
|
printReport(checks);
|
|
399
400
|
|
|
401
|
+
// --- Check 7: Version upgrade check ---
|
|
402
|
+
try {
|
|
403
|
+
const upgradeResult = await checkUpgrade();
|
|
404
|
+
const msg = formatUpgradeMsg(upgradeResult, 'doctor');
|
|
405
|
+
if (msg) {
|
|
406
|
+
console.log(`\n ℹ ${msg}`);
|
|
407
|
+
}
|
|
408
|
+
} catch { /* non-blocking — don't fail doctor on network issue */ }
|
|
409
|
+
|
|
400
410
|
if (issues === 0) {
|
|
401
411
|
console.log('\n✓ All checks passed');
|
|
402
412
|
return 0;
|