xp-gate 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapter-common.sh +192 -0
- package/adapters/cpp.sh +76 -0
- package/adapters/dart.sh +41 -0
- package/adapters/flutter.sh +41 -0
- package/adapters/go.sh +59 -0
- package/adapters/iac.sh +189 -0
- package/adapters/java.sh +191 -0
- package/adapters/kotlin.sh +77 -0
- package/adapters/objectivec.sh +38 -0
- package/adapters/powershell.sh +138 -0
- package/adapters/python.sh +104 -0
- package/adapters/shell.sh +55 -0
- package/adapters/swift.sh +44 -0
- package/adapters/typescript.sh +61 -0
- package/bin/xp-gate.js +157 -0
- package/hooks/adapter-common.sh +192 -0
- package/hooks/pre-commit +1667 -0
- package/hooks/pre-push +395 -0
- package/lib/__tests__/detect-deps.test.js +209 -0
- package/lib/__tests__/doctor.test.js +448 -0
- package/lib/__tests__/download-skill.test.js +281 -0
- package/lib/__tests__/init.test.js +327 -0
- package/lib/__tests__/install-skill.test.js +326 -0
- package/lib/__tests__/migrate.test.js +212 -0
- package/lib/__tests__/rollback.test.js +183 -0
- package/lib/__tests__/ui-detector.test.ts +200 -0
- package/lib/__tests__/uninstall-skill.test.js +189 -0
- package/lib/__tests__/uninstall.test.js +589 -0
- package/lib/__tests__/update-skill.test.js +276 -0
- package/lib/detect-deps.js +157 -0
- package/lib/doctor.js +370 -0
- package/lib/download-skill.js +96 -0
- package/lib/init.js +367 -0
- package/lib/install-skill.js +184 -0
- package/lib/migrate.js +120 -0
- package/lib/rollback.js +78 -0
- package/lib/ui-detector.ts +99 -0
- package/lib/uninstall-skill.js +69 -0
- package/lib/uninstall.js +401 -0
- package/lib/update-skill.js +90 -0
- package/package.json +39 -0
- package/plugins/claude-code/.claude-plugin/plugin.json +21 -0
- package/plugins/claude-code/bin/delphi-review-guard.sh +68 -0
- package/plugins/claude-code/bin/xp-gate-check +47 -0
- package/plugins/claude-code/hooks/hooks.json +37 -0
- package/skills/delphi-review/.delphi-config.json.example +45 -0
- package/skills/delphi-review/AGENTS.md +54 -0
- package/skills/delphi-review/INSTALL.md +152 -0
- package/skills/delphi-review/SKILL.md +371 -0
- package/skills/delphi-review/evals/evals.json +82 -0
- package/skills/delphi-review/opencode.json.delphi.example +56 -0
- package/skills/delphi-review/references/code-walkthrough.md +486 -0
- package/skills/ralph-loop/SKILL.md +330 -0
- package/skills/ralph-loop/evals/evals.json +311 -0
- package/skills/ralph-loop/evolution-history.json +59 -0
- package/skills/ralph-loop/evolution-log.md +16 -0
- package/skills/ralph-loop/references/components/memory.md +55 -0
- package/skills/ralph-loop/references/components/middleware.md +54 -0
- package/skills/ralph-loop/references/components/skill-invocations.md +39 -0
- package/skills/ralph-loop/references/components/system-prompt.md +24 -0
- package/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
- package/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
- package/skills/ralph-loop/templates/progress-log.md +36 -0
- package/skills/sprint-flow/SKILL.md +600 -0
- package/skills/sprint-flow/evals/evals.json +78 -0
- package/skills/sprint-flow/evolution-history.json +39 -0
- package/skills/sprint-flow/evolution-log.md +23 -0
- package/skills/sprint-flow/references/components/memory.md +87 -0
- package/skills/sprint-flow/references/components/middleware.md +72 -0
- package/skills/sprint-flow/references/components/skill-invocations.md +104 -0
- package/skills/sprint-flow/references/components/system-prompt.md +27 -0
- package/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
- package/skills/sprint-flow/references/phase-0-think.md +115 -0
- package/skills/sprint-flow/references/phase-1-plan.md +178 -0
- package/skills/sprint-flow/references/phase-2-build.md +198 -0
- package/skills/sprint-flow/references/phase-3-review.md +213 -0
- package/skills/sprint-flow/references/phase-4-uat.md +125 -0
- package/skills/sprint-flow/references/phase-5-feedback.md +100 -0
- package/skills/sprint-flow/references/phase-6-ship.md +193 -0
- package/skills/sprint-flow/references/phase-7-land.md +140 -0
- package/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
- package/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
- package/skills/sprint-flow/templates/pain-document-template.md +115 -0
- package/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
- package/skills/test-specification-alignment/AGENTS.md +59 -0
- package/skills/test-specification-alignment/SKILL.md +605 -0
- package/skills/test-specification-alignment/evals/evals.json +75 -0
- package/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
- package/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
- package/skills/test-specification-alignment/references/specification-format.md +348 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test install-skill
|
|
3
|
+
* @intent Verify installSkill() handles deps check, registry lookup, download, config, and errors
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const { EventEmitter } = require('events');
|
|
10
|
+
|
|
11
|
+
function mockHttpsGet(optsOrList) {
|
|
12
|
+
const list = Array.isArray(optsOrList) ? [...optsOrList] : [optsOrList];
|
|
13
|
+
// Synchronous fake createWriteStream — the real one opens the FD asynchronously
|
|
14
|
+
// and races against downloadFile's unlinkSync(dest) in 301/302/error branches,
|
|
15
|
+
// causing ENOENT unhandled errors.
|
|
16
|
+
vi.spyOn(fs, 'createWriteStream').mockImplementation((dest) => {
|
|
17
|
+
let buf = '';
|
|
18
|
+
try {
|
|
19
|
+
fs.writeFileSync(dest, '');
|
|
20
|
+
} catch {
|
|
21
|
+
/* dest dir may not exist; downstream code will surface that */
|
|
22
|
+
}
|
|
23
|
+
const stream = new EventEmitter();
|
|
24
|
+
stream.write = (chunk) => {
|
|
25
|
+
buf += chunk;
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
stream.end = () => {
|
|
29
|
+
try {
|
|
30
|
+
fs.writeFileSync(dest, buf);
|
|
31
|
+
} catch {
|
|
32
|
+
/* file may have been unlinked by source; safe to ignore */
|
|
33
|
+
}
|
|
34
|
+
process.nextTick(() => stream.emit('finish'));
|
|
35
|
+
};
|
|
36
|
+
stream.close = () => {};
|
|
37
|
+
return stream;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.spyOn(https, 'get').mockImplementation((url, options, cb) => {
|
|
41
|
+
const callback = typeof options === 'function' ? options : cb;
|
|
42
|
+
const req = new EventEmitter();
|
|
43
|
+
const config = list.shift() || { statusCode: 200, body: '# Skill' };
|
|
44
|
+
process.nextTick(() => {
|
|
45
|
+
if (config.errorAfter) {
|
|
46
|
+
req.emit('error', new Error('Network error'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const response = new EventEmitter();
|
|
50
|
+
response.statusCode = config.statusCode != null ? config.statusCode : 200;
|
|
51
|
+
response.headers = config.redirectTo ? { location: config.redirectTo } : {};
|
|
52
|
+
response.pipe = (file) => {
|
|
53
|
+
file.write(config.body != null ? config.body : '# Skill');
|
|
54
|
+
file.end();
|
|
55
|
+
};
|
|
56
|
+
callback(response);
|
|
57
|
+
});
|
|
58
|
+
return req;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('install-skill', () => {
|
|
63
|
+
let tmpHome, originalHome;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
originalHome = process.env.HOME;
|
|
67
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-in-'));
|
|
68
|
+
process.env.HOME = tmpHome;
|
|
69
|
+
vi.resetModules();
|
|
70
|
+
delete require.cache[require.resolve('../install-skill')];
|
|
71
|
+
delete require.cache[require.resolve('../detect-deps')];
|
|
72
|
+
delete require.cache[require.resolve('../download-skill')];
|
|
73
|
+
delete require.cache[require.resolve('../rollback')];
|
|
74
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
75
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
76
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
81
|
+
process.env.HOME = originalHome;
|
|
82
|
+
vi.restoreAllMocks();
|
|
83
|
+
if (fs.existsSync(tmpHome)) {
|
|
84
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function skillsDir() {
|
|
89
|
+
return path.join(tmpHome, '.config', 'opencode', 'skills');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function configDir() {
|
|
93
|
+
return path.join(tmpHome, '.config', 'xp-gate');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setupValidDeps() {
|
|
97
|
+
['superpowers', 'gstack'].forEach((name) => {
|
|
98
|
+
const dir = path.join(skillsDir(), name);
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
path.join(dir, 'package.json'),
|
|
102
|
+
JSON.stringify({ version: '2.0.0' })
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it('returns 1 + error when superpowers dep is missing', async () => {
|
|
108
|
+
const { installSkill } = require('../install-skill');
|
|
109
|
+
const result = await installSkill('sprint-flow');
|
|
110
|
+
expect(result).toBe(1);
|
|
111
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
112
|
+
expect.stringContaining('superpowers is required')
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns 1 + error when gstack dep is missing (superpowers present)', async () => {
|
|
117
|
+
const dir = path.join(skillsDir(), 'superpowers');
|
|
118
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
119
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
|
120
|
+
|
|
121
|
+
const { installSkill } = require('../install-skill');
|
|
122
|
+
const result = await installSkill('sprint-flow');
|
|
123
|
+
expect(result).toBe(1);
|
|
124
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
125
|
+
expect.stringContaining('gstack is required')
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns 1 + error when dep version is too old (versionMismatch branch)', async () => {
|
|
130
|
+
['superpowers', 'gstack'].forEach((name) => {
|
|
131
|
+
const dir = path.join(skillsDir(), name);
|
|
132
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(dir, 'package.json'),
|
|
135
|
+
JSON.stringify({ version: '0.5.0' })
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const { installSkill } = require('../install-skill');
|
|
140
|
+
const result = await installSkill('sprint-flow');
|
|
141
|
+
expect(result).toBe(1);
|
|
142
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('version too old')
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns 1 + Unknown skill error for unregistered name', async () => {
|
|
148
|
+
setupValidDeps();
|
|
149
|
+
const { installSkill } = require('../install-skill');
|
|
150
|
+
const result = await installSkill('not-a-real-skill');
|
|
151
|
+
expect(result).toBe(1);
|
|
152
|
+
expect(console.error).toHaveBeenCalledWith('Error: Unknown skill: not-a-real-skill');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns 0 and writes SKILL.md when install succeeds', async () => {
|
|
156
|
+
setupValidDeps();
|
|
157
|
+
mockHttpsGet({ statusCode: 200, body: '# Sprint Flow\nskill content' });
|
|
158
|
+
|
|
159
|
+
const { installSkill } = require('../install-skill');
|
|
160
|
+
const result = await installSkill('sprint-flow');
|
|
161
|
+
|
|
162
|
+
expect(result).toBe(0);
|
|
163
|
+
const installedFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
|
|
164
|
+
expect(fs.existsSync(installedFile)).toBe(true);
|
|
165
|
+
expect(fs.readFileSync(installedFile, 'utf8')).toContain('Sprint Flow');
|
|
166
|
+
expect(console.log).toHaveBeenCalledWith('✓ sprint-flow installed');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('updates config with installedSkills metadata after successful install', async () => {
|
|
170
|
+
setupValidDeps();
|
|
171
|
+
mockHttpsGet({ statusCode: 200, body: '# Content' });
|
|
172
|
+
|
|
173
|
+
const { installSkill } = require('../install-skill');
|
|
174
|
+
await installSkill('delphi-review');
|
|
175
|
+
|
|
176
|
+
const configFile = path.join(configDir(), 'xp-gate.json');
|
|
177
|
+
expect(fs.existsSync(configFile)).toBe(true);
|
|
178
|
+
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
179
|
+
expect(config.installedSkills).toHaveProperty('delphi-review');
|
|
180
|
+
expect(config.installedSkills['delphi-review'].version).toBe('1.0.0');
|
|
181
|
+
expect(config.installedSkills['delphi-review'].installedAt).toMatch(/^\d{4}-/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns 1 + already-installed error when target exists and force=false', async () => {
|
|
185
|
+
setupValidDeps();
|
|
186
|
+
const target = path.join(skillsDir(), 'sprint-flow');
|
|
187
|
+
fs.mkdirSync(target, { recursive: true });
|
|
188
|
+
fs.writeFileSync(path.join(target, 'SKILL.md'), 'existing');
|
|
189
|
+
|
|
190
|
+
const { installSkill } = require('../install-skill');
|
|
191
|
+
const result = await installSkill('sprint-flow');
|
|
192
|
+
|
|
193
|
+
expect(result).toBe(1);
|
|
194
|
+
expect(console.error).toHaveBeenCalledWith('Error: sprint-flow is already installed');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('backs up and replaces when target exists and force=true', async () => {
|
|
198
|
+
setupValidDeps();
|
|
199
|
+
const target = path.join(skillsDir(), 'sprint-flow');
|
|
200
|
+
fs.mkdirSync(target, { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(target, 'OLD.md'), 'old-content');
|
|
202
|
+
mockHttpsGet({ statusCode: 200, body: '# New Content' });
|
|
203
|
+
|
|
204
|
+
const { installSkill } = require('../install-skill');
|
|
205
|
+
const result = await installSkill('sprint-flow', { force: true });
|
|
206
|
+
|
|
207
|
+
expect(result).toBe(0);
|
|
208
|
+
const skillMd = path.join(target, 'SKILL.md');
|
|
209
|
+
expect(fs.existsSync(skillMd)).toBe(true);
|
|
210
|
+
expect(fs.readFileSync(skillMd, 'utf8')).toContain('New Content');
|
|
211
|
+
|
|
212
|
+
const backupRoot = path.join(configDir(), 'backup');
|
|
213
|
+
expect(fs.existsSync(backupRoot)).toBe(true);
|
|
214
|
+
const backups = fs.readdirSync(backupRoot);
|
|
215
|
+
expect(backups.length).toBeGreaterThan(0);
|
|
216
|
+
const backedUpFile = path.join(backupRoot, backups[0], 'OLD.md');
|
|
217
|
+
expect(fs.existsSync(backedUpFile)).toBe(true);
|
|
218
|
+
expect(fs.readFileSync(backedUpFile, 'utf8')).toBe('old-content');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns 1 + Failed to download when network fails (non-offline)', async () => {
|
|
222
|
+
setupValidDeps();
|
|
223
|
+
mockHttpsGet({ statusCode: 404 });
|
|
224
|
+
|
|
225
|
+
const { installSkill } = require('../install-skill');
|
|
226
|
+
const result = await installSkill('sprint-flow');
|
|
227
|
+
|
|
228
|
+
expect(result).toBe(1);
|
|
229
|
+
expect(console.error).toHaveBeenCalledWith('Error: Failed to download sprint-flow');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns 2 + offline-cache error when offline=true and no cache', async () => {
|
|
233
|
+
setupValidDeps();
|
|
234
|
+
|
|
235
|
+
const { installSkill } = require('../install-skill');
|
|
236
|
+
const result = await installSkill('sprint-flow', { offline: true });
|
|
237
|
+
|
|
238
|
+
expect(result).toBe(2);
|
|
239
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
240
|
+
expect.stringContaining('--offline specified but sprint-flow not in cache')
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('verbose=true prints Downloading and Installed-to logs', async () => {
|
|
245
|
+
setupValidDeps();
|
|
246
|
+
mockHttpsGet({ statusCode: 200, body: '# X' });
|
|
247
|
+
|
|
248
|
+
const { installSkill } = require('../install-skill');
|
|
249
|
+
await installSkill('test-spec', { verbose: true });
|
|
250
|
+
|
|
251
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Downloading '));
|
|
252
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Installed to '));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('verbose=true prints download-failure warning when network errors', async () => {
|
|
256
|
+
setupValidDeps();
|
|
257
|
+
mockHttpsGet({ statusCode: 500 });
|
|
258
|
+
|
|
259
|
+
const { installSkill } = require('../install-skill');
|
|
260
|
+
const result = await installSkill('ralph-loop', { verbose: true });
|
|
261
|
+
|
|
262
|
+
expect(result).toBe(1);
|
|
263
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Download failed'));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('follows redirect (302) to download successfully', async () => {
|
|
267
|
+
setupValidDeps();
|
|
268
|
+
mockHttpsGet([
|
|
269
|
+
{ statusCode: 302, redirectTo: 'https://new.example.com/skill.md' },
|
|
270
|
+
{ statusCode: 200, body: '# Redirected Content' },
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const { installSkill } = require('../install-skill');
|
|
274
|
+
const result = await installSkill('sprint-flow');
|
|
275
|
+
|
|
276
|
+
expect(result).toBe(0);
|
|
277
|
+
const installedFile = path.join(skillsDir(), 'sprint-flow', 'SKILL.md');
|
|
278
|
+
expect(fs.readFileSync(installedFile, 'utf8')).toContain('Redirected Content');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('merges new skill into existing installedSkills config', async () => {
|
|
282
|
+
setupValidDeps();
|
|
283
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
284
|
+
fs.writeFileSync(
|
|
285
|
+
path.join(configDir(), 'xp-gate.json'),
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
installedSkills: { 'other-skill': { version: '0.1.0' } },
|
|
288
|
+
otherSetting: true,
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
mockHttpsGet({ statusCode: 200, body: '# X' });
|
|
292
|
+
|
|
293
|
+
const { installSkill } = require('../install-skill');
|
|
294
|
+
await installSkill('delphi-review');
|
|
295
|
+
|
|
296
|
+
const config = JSON.parse(
|
|
297
|
+
fs.readFileSync(path.join(configDir(), 'xp-gate.json'), 'utf8')
|
|
298
|
+
);
|
|
299
|
+
expect(config.installedSkills).toHaveProperty('other-skill');
|
|
300
|
+
expect(config.installedSkills).toHaveProperty('delphi-review');
|
|
301
|
+
expect(config.otherSetting).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('handles malformed config JSON during update (catch returns {})', async () => {
|
|
305
|
+
setupValidDeps();
|
|
306
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
307
|
+
fs.writeFileSync(path.join(configDir(), 'xp-gate.json'), '{not-valid-json');
|
|
308
|
+
mockHttpsGet({ statusCode: 200, body: '# X' });
|
|
309
|
+
|
|
310
|
+
const { installSkill } = require('../install-skill');
|
|
311
|
+
const result = await installSkill('sprint-flow');
|
|
312
|
+
expect(result).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('returns 1 + Failed to download on network error (non-verbose silent)', async () => {
|
|
316
|
+
setupValidDeps();
|
|
317
|
+
mockHttpsGet({ errorAfter: true });
|
|
318
|
+
|
|
319
|
+
const { installSkill } = require('../install-skill');
|
|
320
|
+
const result = await installSkill('test-spec');
|
|
321
|
+
|
|
322
|
+
expect(result).toBe(1);
|
|
323
|
+
expect(console.warn).not.toHaveBeenCalled();
|
|
324
|
+
expect(console.error).toHaveBeenCalledWith('Error: Failed to download test-spec');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-3 xp-gate migrate
|
|
3
|
+
* @intent Verify migrate helper for v0.4.x→v0.5.x: clean GitHub Packages PAT lines from ~/.npmrc, check old cache
|
|
4
|
+
* @covers AC-06
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
describe('migrate', () => {
|
|
11
|
+
let tmpHome;
|
|
12
|
+
let originalHome;
|
|
13
|
+
let logSpy;
|
|
14
|
+
let warnSpy;
|
|
15
|
+
let errorSpy;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
originalHome = process.env.HOME;
|
|
19
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-migrate-'));
|
|
20
|
+
process.env.HOME = tmpHome;
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
delete require.cache[require.resolve('../migrate')];
|
|
23
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
24
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
25
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
process.env.HOME = originalHome;
|
|
30
|
+
if (tmpHome && fs.existsSync(tmpHome)) {
|
|
31
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function npmrcPath() {
|
|
37
|
+
return path.join(tmpHome, '.npmrc');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cacheDir() {
|
|
41
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'cache');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// === AC-06: clean PAT lines from ~/.npmrc ===
|
|
45
|
+
|
|
46
|
+
it('AC-06: removes npm.pkg.github.com lines from ~/.npmrc', async () => {
|
|
47
|
+
fs.writeFileSync(npmrcPath(), [
|
|
48
|
+
'//npm.pkg.github.com/:_authToken=ghp_abc123',
|
|
49
|
+
'registry=https://npm.pkg.github.com/',
|
|
50
|
+
'@boyingliu01:registry=https://npm.pkg.github.com/',
|
|
51
|
+
'//npm.pkg.github.com/:_authToken=ghp_def456',
|
|
52
|
+
'',
|
|
53
|
+
'# other config',
|
|
54
|
+
'cache=/some/path'
|
|
55
|
+
].join('\n') + '\n');
|
|
56
|
+
|
|
57
|
+
const { migrate } = require('../migrate');
|
|
58
|
+
const result = await migrate([]);
|
|
59
|
+
|
|
60
|
+
expect(result).toBe(0);
|
|
61
|
+
|
|
62
|
+
const content = fs.readFileSync(npmrcPath(), 'utf8');
|
|
63
|
+
expect(content).not.toContain('npm.pkg.github.com');
|
|
64
|
+
expect(content).toContain('# other config');
|
|
65
|
+
expect(content).toContain('cache=/some/path');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('AC-06: does not remove generic PAT lines (only npm.pkg.github.com)', async () => {
|
|
69
|
+
fs.writeFileSync(npmrcPath(), [
|
|
70
|
+
'//npm.pkg.github.com/:_authToken=ghp_abc123',
|
|
71
|
+
'registry=https://some-other-registry.com/',
|
|
72
|
+
'//other-registry.com/:_authToken=ghp_xyz789',
|
|
73
|
+
'always-auth=true'
|
|
74
|
+
].join('\n') + '\n');
|
|
75
|
+
|
|
76
|
+
const { migrate } = require('../migrate');
|
|
77
|
+
const result = await migrate([]);
|
|
78
|
+
|
|
79
|
+
expect(result).toBe(0);
|
|
80
|
+
|
|
81
|
+
const content = fs.readFileSync(npmrcPath(), 'utf8');
|
|
82
|
+
expect(content).not.toContain('npm.pkg.github.com');
|
|
83
|
+
expect(content).toContain('registry=https://some-other-registry.com/');
|
|
84
|
+
expect(content).toContain('//other-registry.com/:_authToken=ghp_xyz789');
|
|
85
|
+
expect(content).toContain('always-auth=true');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('AC-06: prints summary of what was removed from npmrc', async () => {
|
|
89
|
+
fs.writeFileSync(npmrcPath(), [
|
|
90
|
+
'//npm.pkg.github.com/:_authToken=ghp_abc123',
|
|
91
|
+
'cache=/some/path'
|
|
92
|
+
].join('\n') + '\n');
|
|
93
|
+
|
|
94
|
+
const { migrate } = require('../migrate');
|
|
95
|
+
await migrate([]);
|
|
96
|
+
|
|
97
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('npm.pkg.github.com'));
|
|
98
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 npm.pkg.github.com'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('AC-06: does not modify ~/.npmrc when no GitHub Packages lines exist', async () => {
|
|
102
|
+
fs.writeFileSync(npmrcPath(), [
|
|
103
|
+
'registry=https://registry.npmjs.org/',
|
|
104
|
+
'cache=/some/path'
|
|
105
|
+
].join('\n') + '\n');
|
|
106
|
+
|
|
107
|
+
const originalStat = fs.statSync(npmrcPath());
|
|
108
|
+
const { migrate } = require('../migrate');
|
|
109
|
+
const result = await migrate([]);
|
|
110
|
+
|
|
111
|
+
expect(result).toBe(0);
|
|
112
|
+
|
|
113
|
+
const content = fs.readFileSync(npmrcPath(), 'utf8');
|
|
114
|
+
expect(content).toContain('registry=https://registry.npmjs.org/');
|
|
115
|
+
expect(content).toContain('cache=/some/path');
|
|
116
|
+
// File should not have been modified (same content)
|
|
117
|
+
expect(content).toBe('registry=https://registry.npmjs.org/\ncache=/some/path\n');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// === ~/.npmrc does not exist ===
|
|
121
|
+
|
|
122
|
+
it('handles missing ~/.npmrc gracefully (prints skip message)', async () => {
|
|
123
|
+
const { migrate } = require('../migrate');
|
|
124
|
+
const result = await migrate([]);
|
|
125
|
+
|
|
126
|
+
expect(result).toBe(0);
|
|
127
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No ~/.npmrc found'));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// === Cache check ===
|
|
131
|
+
|
|
132
|
+
it('checks ~/.config/xp-gate/cache/ for old cached downloads', async () => {
|
|
133
|
+
fs.mkdirSync(cacheDir(), { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(cacheDir(), 'old-skill.tar.gz'), 'stale cache data');
|
|
135
|
+
fs.writeFileSync(path.join(cacheDir(), 'manifest.json'), '{}');
|
|
136
|
+
|
|
137
|
+
const { migrate } = require('../migrate');
|
|
138
|
+
const result = await migrate([]);
|
|
139
|
+
|
|
140
|
+
expect(result).toBe(0);
|
|
141
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 cached file(s)'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('prints no cache found message when cache dir is missing', async () => {
|
|
145
|
+
const { migrate } = require('../migrate');
|
|
146
|
+
await migrate([]);
|
|
147
|
+
|
|
148
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No old cache'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('prints no cache items when cache dir is empty', async () => {
|
|
152
|
+
fs.mkdirSync(cacheDir(), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const { migrate } = require('../migrate');
|
|
155
|
+
await migrate([]);
|
|
156
|
+
|
|
157
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('empty'));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// === --dry-run flag ===
|
|
161
|
+
|
|
162
|
+
it('--dry-run: prints what would be done without modifying files', async () => {
|
|
163
|
+
fs.writeFileSync(npmrcPath(), [
|
|
164
|
+
'//npm.pkg.github.com/:_authToken=ghp_abc123',
|
|
165
|
+
'cache=/some/path'
|
|
166
|
+
].join('\n') + '\n');
|
|
167
|
+
|
|
168
|
+
const { migrate } = require('../migrate');
|
|
169
|
+
const result = await migrate(['--dry-run']);
|
|
170
|
+
|
|
171
|
+
expect(result).toBe(0);
|
|
172
|
+
|
|
173
|
+
// File should remain unchanged
|
|
174
|
+
const content = fs.readFileSync(npmrcPath(), 'utf8');
|
|
175
|
+
expect(content).toContain('npm.pkg.github.com');
|
|
176
|
+
|
|
177
|
+
// Should print dry-run plan
|
|
178
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Dry-run'));
|
|
179
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('1 npm.pkg.github.com line'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// === Empty npmrc edge case ===
|
|
183
|
+
|
|
184
|
+
it('handles empty .npmrc file gracefully', async () => {
|
|
185
|
+
fs.writeFileSync(npmrcPath(), '');
|
|
186
|
+
|
|
187
|
+
const { migrate } = require('../migrate');
|
|
188
|
+
const result = await migrate([]);
|
|
189
|
+
|
|
190
|
+
expect(result).toBe(0);
|
|
191
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No GitHub Packages'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// === Print summary ===
|
|
195
|
+
|
|
196
|
+
it('prints full summary of actions taken', async () => {
|
|
197
|
+
fs.writeFileSync(npmrcPath(), [
|
|
198
|
+
'//npm.pkg.github.com/:_authToken=ghp_abc123',
|
|
199
|
+
'cache=/some/path'
|
|
200
|
+
].join('\n') + '\n');
|
|
201
|
+
|
|
202
|
+
fs.mkdirSync(cacheDir(), { recursive: true });
|
|
203
|
+
fs.writeFileSync(path.join(cacheDir(), 'old-skill.tar.gz'), 'data');
|
|
204
|
+
|
|
205
|
+
const { migrate } = require('../migrate');
|
|
206
|
+
await migrate([]);
|
|
207
|
+
|
|
208
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Summary'));
|
|
209
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('npmrc'));
|
|
210
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('cache'));
|
|
211
|
+
});
|
|
212
|
+
});
|