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,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-1 xp-gate uninstall
|
|
3
|
+
* @intent Verify uninstall correctly reverses init, handles local/global modes, dry-run, safety, and idempotency
|
|
4
|
+
* @covers AC-01, AC-02, AC-03, AC-04, AC-08, AC-09, AC-11
|
|
5
|
+
*/
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const childProcess = require('child_process');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
|
|
12
|
+
describe('uninstall', () => {
|
|
13
|
+
let tmpHome;
|
|
14
|
+
let tmpProject;
|
|
15
|
+
let originalHome;
|
|
16
|
+
let logSpy;
|
|
17
|
+
let warnSpy;
|
|
18
|
+
let errorSpy;
|
|
19
|
+
let execSpy;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
originalHome = process.env.HOME;
|
|
23
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uni-'));
|
|
24
|
+
tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uni-proj-'));
|
|
25
|
+
process.env.HOME = tmpHome;
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
delete require.cache[require.resolve('../uninstall')];
|
|
28
|
+
delete require.cache[require.resolve('../init')];
|
|
29
|
+
delete require.cache[require.resolve('../detect-deps.js')];
|
|
30
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
31
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
32
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
process.env.HOME = originalHome;
|
|
37
|
+
if (tmpHome && fs.existsSync(tmpHome)) {
|
|
38
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
if (tmpProject && fs.existsSync(tmpProject)) {
|
|
41
|
+
fs.rmSync(tmpProject, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function configFile() {
|
|
47
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function globalHooksDir() {
|
|
51
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'hooks');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function globalAdaptersDir() {
|
|
55
|
+
return path.join(tmpHome, '.config', 'xp-gate', 'adapters');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function templateDir() {
|
|
59
|
+
return path.join(tmpHome, '.config', 'opencode', 'git-hooks-template');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function projectGitDir() {
|
|
63
|
+
return path.join(tmpProject, '.git');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function projectHooksDir() {
|
|
67
|
+
return path.join(tmpProject, '.git', 'hooks');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function projectGithooksDir() {
|
|
71
|
+
return path.join(tmpProject, 'githooks');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function projectAdaptersDir() {
|
|
75
|
+
return path.join(tmpProject, 'githooks', 'adapters');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function backupDir() {
|
|
79
|
+
return path.join(tmpHome, '.config', 'xp-gate', '.uninstall-backup');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Create a fake hook file that contains the xp-gate signature
|
|
83
|
+
function createXpGatePreCommit(dir) {
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
path.join(dir, 'pre-commit'),
|
|
87
|
+
'#!/bin/bash\n# OpenCode Quality Gates - Pre-Commit Hook - Test\n'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createXpGatePrePush(dir) {
|
|
92
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(dir, 'pre-push'),
|
|
95
|
+
'#!/bin/bash\n# Pre-push Hook - Code Walkthrough Result Validator\n'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createXpGateAdapterCommon(dir) {
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
path.join(dir, 'adapter-common.sh'),
|
|
103
|
+
'#!/usr/bin/env bash\n\n# Common adapter functions\ndetect_project_lang() {\n echo "typescript"\n}\n'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createXpGateAdapterScripts(dir) {
|
|
108
|
+
fs.mkdirSync(path.join(dir, 'adapters'), { recursive: true });
|
|
109
|
+
fs.writeFileSync(
|
|
110
|
+
path.join(dir, 'adapters', 'typescript.sh'),
|
|
111
|
+
'#!/usr/bin/env bash\necho "ts adapter"\n'
|
|
112
|
+
);
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(dir, 'adapters', 'python.sh'),
|
|
115
|
+
'#!/usr/bin/env bash\necho "py adapter"\n'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Create a custom (non-xp-gate) hook file
|
|
120
|
+
function createCustomHook(dir) {
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(dir, 'pre-commit'),
|
|
124
|
+
'#!/bin/bash\n# My custom hook\n'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Simulate a full local install
|
|
129
|
+
function setupLocalInstall() {
|
|
130
|
+
// Create project .git/hooks with xp-gate hooks
|
|
131
|
+
createXpGatePreCommit(projectHooksDir());
|
|
132
|
+
createXpGatePrePush(projectHooksDir());
|
|
133
|
+
|
|
134
|
+
// Create project githooks/ with adapters
|
|
135
|
+
createXpGateAdapterCommon(projectGithooksDir());
|
|
136
|
+
createXpGateAdapterScripts(projectGithooksDir());
|
|
137
|
+
|
|
138
|
+
// Create template dir
|
|
139
|
+
createXpGatePreCommit(templateDir());
|
|
140
|
+
createXpGatePrePush(templateDir());
|
|
141
|
+
fs.mkdirSync(path.join(templateDir(), 'adapters'), { recursive: true });
|
|
142
|
+
|
|
143
|
+
// Write config
|
|
144
|
+
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|
145
|
+
fs.writeFileSync(
|
|
146
|
+
configFile(),
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
mode: 'local',
|
|
149
|
+
lastInit: new Date().toISOString()
|
|
150
|
+
}, null, 2)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
return configFile();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Simulate a full global install
|
|
157
|
+
function setupGlobalInstall() {
|
|
158
|
+
// Create global hooks dir
|
|
159
|
+
createXpGatePreCommit(globalHooksDir());
|
|
160
|
+
createXpGatePrePush(globalHooksDir());
|
|
161
|
+
|
|
162
|
+
// Create global adapters dir
|
|
163
|
+
createXpGateAdapterCommon(globalAdaptersDir());
|
|
164
|
+
createXpGateAdapterScripts(globalAdaptersDir());
|
|
165
|
+
|
|
166
|
+
// Create template dir
|
|
167
|
+
createXpGatePreCommit(templateDir());
|
|
168
|
+
createXpGatePrePush(templateDir());
|
|
169
|
+
fs.mkdirSync(path.join(templateDir(), 'adapters'), { recursive: true });
|
|
170
|
+
|
|
171
|
+
// Write config
|
|
172
|
+
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
configFile(),
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
mode: 'global',
|
|
177
|
+
lastInit: new Date().toISOString()
|
|
178
|
+
}, null, 2)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return configFile();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mockExecSuccess() {
|
|
185
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
186
|
+
if (cmd === 'git rev-parse --git-dir') {
|
|
187
|
+
return path.join(tmpProject, '.git') + '\n';
|
|
188
|
+
}
|
|
189
|
+
if (cmd.includes('git config --global core.hooksPath')) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
if (cmd.includes('git config --global --unset')) {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
return '';
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function mockExecGlobalHooksPath(expectedPath) {
|
|
200
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation((cmd) => {
|
|
201
|
+
if (cmd.includes('git config --global core.hooksPath')) {
|
|
202
|
+
if (cmd.includes('--unset')) {
|
|
203
|
+
return '';
|
|
204
|
+
}
|
|
205
|
+
// For reading the current value
|
|
206
|
+
return expectedPath + '\n';
|
|
207
|
+
}
|
|
208
|
+
if (cmd === 'git rev-parse --git-dir') {
|
|
209
|
+
return path.join(tmpProject, '.git') + '\n';
|
|
210
|
+
}
|
|
211
|
+
return '';
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mockExecFail() {
|
|
216
|
+
execSpy = vi.spyOn(childProcess, 'execSync').mockImplementation(() => {
|
|
217
|
+
throw new Error('Command failed');
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// === AC-01: local mode complete cleanup ===
|
|
222
|
+
|
|
223
|
+
it('AC-01: uninstall in local mode removes hooks, adapters, template dir, and updates config', async () => {
|
|
224
|
+
setupLocalInstall();
|
|
225
|
+
mockExecSuccess();
|
|
226
|
+
const { uninstall } = require('../uninstall');
|
|
227
|
+
|
|
228
|
+
const result = await uninstall([]);
|
|
229
|
+
|
|
230
|
+
expect(result).toBe(0);
|
|
231
|
+
|
|
232
|
+
// Verify hooks removed from .git/hooks/
|
|
233
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
|
|
234
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
|
|
235
|
+
|
|
236
|
+
// Verify adapters removed from githooks/
|
|
237
|
+
expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(false);
|
|
238
|
+
expect(fs.existsSync(path.join(projectAdaptersDir(), 'python.sh'))).toBe(false);
|
|
239
|
+
expect(fs.existsSync(path.join(projectGithooksDir(), 'adapter-common.sh'))).toBe(false);
|
|
240
|
+
|
|
241
|
+
// Verify template dir removed
|
|
242
|
+
expect(fs.existsSync(templateDir())).toBe(false);
|
|
243
|
+
|
|
244
|
+
// Verify config updated to uninstalled
|
|
245
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
246
|
+
expect(cfg.mode).toBe('uninstalled');
|
|
247
|
+
expect(cfg.uninstalled).toBeDefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('AC-01: uninstall returns 0 even when no files exist (graceful)', async () => {
|
|
251
|
+
fs.mkdirSync(path.dirname(configFile()), { recursive: true });
|
|
252
|
+
fs.writeFileSync(configFile(), JSON.stringify({ mode: 'local', lastInit: '2025-01-01' }, null, 2));
|
|
253
|
+
mockExecSuccess();
|
|
254
|
+
const { uninstall } = require('../uninstall');
|
|
255
|
+
|
|
256
|
+
const result = await uninstall([]);
|
|
257
|
+
expect(result).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// === AC-02: global mode unset core.hooksPath ===
|
|
261
|
+
|
|
262
|
+
it('AC-02: uninstall in global mode unsets core.hooksPath and removes global dirs', async () => {
|
|
263
|
+
setupGlobalInstall();
|
|
264
|
+
const expectedHooksPath = globalHooksDir();
|
|
265
|
+
mockExecGlobalHooksPath(expectedHooksPath);
|
|
266
|
+
const { uninstall } = require('../uninstall');
|
|
267
|
+
|
|
268
|
+
const result = await uninstall([]);
|
|
269
|
+
|
|
270
|
+
expect(result).toBe(0);
|
|
271
|
+
|
|
272
|
+
// Verify unset was called
|
|
273
|
+
expect(execSpy).toHaveBeenCalledWith(
|
|
274
|
+
expect.stringContaining('git config --global --unset core.hooksPath'),
|
|
275
|
+
expect.any(Object)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Verify global hooks/adapters dirs removed
|
|
279
|
+
expect(fs.existsSync(globalHooksDir())).toBe(false);
|
|
280
|
+
expect(fs.existsSync(globalAdaptersDir())).toBe(false);
|
|
281
|
+
|
|
282
|
+
// Verify template dir removed
|
|
283
|
+
expect(fs.existsSync(templateDir())).toBe(false);
|
|
284
|
+
|
|
285
|
+
// Verify config updated
|
|
286
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
287
|
+
expect(cfg.mode).toBe('uninstalled');
|
|
288
|
+
expect(cfg.uninstalled).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('AC-02: uninstall in global mode skips unset when hooksPath does not match', async () => {
|
|
292
|
+
setupGlobalInstall();
|
|
293
|
+
mockExecGlobalHooksPath('/some/other/path');
|
|
294
|
+
const { uninstall } = require('../uninstall');
|
|
295
|
+
|
|
296
|
+
const result = await uninstall([]);
|
|
297
|
+
|
|
298
|
+
expect(result).toBe(0);
|
|
299
|
+
|
|
300
|
+
// Should warn that hooksPath does not match
|
|
301
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
302
|
+
expect.stringContaining('does not match')
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Should NOT have called unset
|
|
306
|
+
const unsetCalls = execSpy.mock.calls.filter(
|
|
307
|
+
c => c[0].includes('--unset')
|
|
308
|
+
);
|
|
309
|
+
expect(unsetCalls.length).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// === AC-03: --dry-run ===
|
|
313
|
+
|
|
314
|
+
it('AC-03: --dry-run does not remove any files', async () => {
|
|
315
|
+
setupLocalInstall();
|
|
316
|
+
mockExecSuccess();
|
|
317
|
+
const { uninstall } = require('../uninstall');
|
|
318
|
+
|
|
319
|
+
const result = await uninstall(['--dry-run']);
|
|
320
|
+
|
|
321
|
+
expect(result).toBe(0);
|
|
322
|
+
|
|
323
|
+
// All files should still exist
|
|
324
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
|
|
325
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(true);
|
|
326
|
+
expect(fs.existsSync(path.join(projectGithooksDir(), 'adapter-common.sh'))).toBe(true);
|
|
327
|
+
expect(fs.existsSync(path.join(projectAdaptersDir(), 'typescript.sh'))).toBe(true);
|
|
328
|
+
expect(fs.existsSync(templateDir())).toBe(true);
|
|
329
|
+
|
|
330
|
+
// Config should still show active mode
|
|
331
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
332
|
+
expect(cfg.mode).toBe('local');
|
|
333
|
+
|
|
334
|
+
// Should have printed plan
|
|
335
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Dry-run'));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// === AC-04: non-xp-gate hooks preserved ===
|
|
339
|
+
|
|
340
|
+
it('AC-04: non-xp-gate hooks files are not deleted', async () => {
|
|
341
|
+
setupLocalInstall();
|
|
342
|
+
// Create a custom hook (must be after setup which creates xp-gate hooks)
|
|
343
|
+
createCustomHook(projectHooksDir());
|
|
344
|
+
mockExecSuccess();
|
|
345
|
+
const { uninstall } = require('../uninstall');
|
|
346
|
+
|
|
347
|
+
const result = await uninstall([]);
|
|
348
|
+
|
|
349
|
+
expect(result).toBe(0);
|
|
350
|
+
|
|
351
|
+
// Custom hook should still exist (no xp-gate signature)
|
|
352
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
|
|
353
|
+
|
|
354
|
+
// Read it - should be the custom one
|
|
355
|
+
const content = fs.readFileSync(path.join(projectHooksDir(), 'pre-commit'), 'utf8');
|
|
356
|
+
expect(content).toContain('My custom hook');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// === AC-08: partial failure detection ===
|
|
360
|
+
|
|
361
|
+
it('AC-08: uninstall detects config file absence and exits cleanly', async () => {
|
|
362
|
+
// No config file at all
|
|
363
|
+
const { uninstall } = require('../uninstall');
|
|
364
|
+
|
|
365
|
+
const result = await uninstall([]);
|
|
366
|
+
|
|
367
|
+
expect(result).toBe(0);
|
|
368
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
369
|
+
expect.stringContaining('xp-gate installation found')
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('AC-08: uninstall continues despite individual file deletion errors', async () => {
|
|
374
|
+
setupLocalInstall();
|
|
375
|
+
mockExecSuccess();
|
|
376
|
+
|
|
377
|
+
// Make one adapter file non-deletable by making it non-existent
|
|
378
|
+
// uninstall should handle this gracefully (skip + warn)
|
|
379
|
+
const { uninstall } = require('../uninstall');
|
|
380
|
+
|
|
381
|
+
const result = await uninstall([]);
|
|
382
|
+
expect(result).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// === AC-09: idempotent ===
|
|
386
|
+
|
|
387
|
+
it('AC-09: second uninstall outputs clean message (idempotent)', async () => {
|
|
388
|
+
setupLocalInstall();
|
|
389
|
+
mockExecSuccess();
|
|
390
|
+
const { uninstall } = require('../uninstall');
|
|
391
|
+
|
|
392
|
+
// First uninstall
|
|
393
|
+
await uninstall([]);
|
|
394
|
+
|
|
395
|
+
// Clear log spy
|
|
396
|
+
logSpy.mockClear();
|
|
397
|
+
|
|
398
|
+
// Second uninstall
|
|
399
|
+
const result = await uninstall([]);
|
|
400
|
+
|
|
401
|
+
expect(result).toBe(0);
|
|
402
|
+
|
|
403
|
+
// Should detect already uninstalled state
|
|
404
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
405
|
+
expect.stringContaining('xp-gate installation found')
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// === AC-11: manifest sha256 vs signature fallback ===
|
|
410
|
+
|
|
411
|
+
it('AC-11: uninstall uses manifest sha256 when available for verification', async () => {
|
|
412
|
+
setupLocalInstall();
|
|
413
|
+
|
|
414
|
+
// Add manifest to config
|
|
415
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
416
|
+
|
|
417
|
+
// Calculate actual sha256 of the hooks file
|
|
418
|
+
const hookPath = path.join(projectHooksDir(), 'pre-commit');
|
|
419
|
+
const hookContent = fs.readFileSync(hookPath);
|
|
420
|
+
const sha256 = crypto.createHash('sha256').update(hookContent).digest('hex');
|
|
421
|
+
|
|
422
|
+
cfg.manifest = {
|
|
423
|
+
version: 1,
|
|
424
|
+
files: {
|
|
425
|
+
'.git/hooks/pre-commit': { sha256, size: hookContent.length },
|
|
426
|
+
'.git/hooks/pre-push': {
|
|
427
|
+
sha256: crypto.createHash('sha256')
|
|
428
|
+
.update(fs.readFileSync(path.join(projectHooksDir(), 'pre-push')))
|
|
429
|
+
.digest('hex'),
|
|
430
|
+
size: fs.readFileSync(path.join(projectHooksDir(), 'pre-push')).length
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
|
|
435
|
+
|
|
436
|
+
mockExecSuccess();
|
|
437
|
+
const { uninstall } = require('../uninstall');
|
|
438
|
+
|
|
439
|
+
const result = await uninstall([]);
|
|
440
|
+
expect(result).toBe(0);
|
|
441
|
+
|
|
442
|
+
// Files should be removed (matched via sha256)
|
|
443
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('AC-11: uninstall falls back to signature detection when manifest sha256 does not match file content', async () => {
|
|
447
|
+
setupLocalInstall();
|
|
448
|
+
|
|
449
|
+
// Add manifest with WRONG sha256
|
|
450
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
451
|
+
cfg.manifest = {
|
|
452
|
+
version: 1,
|
|
453
|
+
files: {
|
|
454
|
+
'.git/hooks/pre-commit': { sha256: 'deadbeef' + '0'.repeat(56), size: 999 },
|
|
455
|
+
'.git/hooks/pre-push': { sha256: 'deadbeef' + '0'.repeat(56), size: 999 }
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
|
|
459
|
+
|
|
460
|
+
mockExecSuccess();
|
|
461
|
+
const { uninstall } = require('../uninstall');
|
|
462
|
+
|
|
463
|
+
const result = await uninstall([]);
|
|
464
|
+
expect(result).toBe(0);
|
|
465
|
+
|
|
466
|
+
// Files should still be removed (fell back to signature detection)
|
|
467
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
|
|
468
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('AC-11: manifest fallback to signature logs warning about sha256 mismatch', async () => {
|
|
472
|
+
setupLocalInstall();
|
|
473
|
+
|
|
474
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
475
|
+
cfg.manifest = {
|
|
476
|
+
version: 1,
|
|
477
|
+
files: {
|
|
478
|
+
'.git/hooks/pre-commit': { sha256: 'wrongsha256' + '0'.repeat(48), size: 999 }
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
fs.writeFileSync(configFile(), JSON.stringify(cfg, null, 2));
|
|
482
|
+
|
|
483
|
+
mockExecSuccess();
|
|
484
|
+
const { uninstall } = require('../uninstall');
|
|
485
|
+
|
|
486
|
+
await uninstall([]);
|
|
487
|
+
|
|
488
|
+
// Should warn about manifest sha256 mismatch and fallback
|
|
489
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
490
|
+
expect.stringContaining('sha256')
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// === Edge cases ===
|
|
495
|
+
|
|
496
|
+
it('returns 0 when config does not exist', async () => {
|
|
497
|
+
const { uninstall } = require('../uninstall');
|
|
498
|
+
const result = await uninstall([]);
|
|
499
|
+
expect(result).toBe(0);
|
|
500
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
501
|
+
expect.stringContaining('xp-gate installation found')
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('skips deletion when file signature does not match', async () => {
|
|
506
|
+
setupLocalInstall();
|
|
507
|
+
|
|
508
|
+
// Replace the pre-commit with something that lacks the signature
|
|
509
|
+
fs.writeFileSync(
|
|
510
|
+
path.join(projectHooksDir(), 'pre-commit'),
|
|
511
|
+
'#!/bin/bash\necho "custom hook"\n'
|
|
512
|
+
);
|
|
513
|
+
// But keep pre-push as xp-gate
|
|
514
|
+
|
|
515
|
+
mockExecSuccess();
|
|
516
|
+
const { uninstall } = require('../uninstall');
|
|
517
|
+
|
|
518
|
+
const result = await uninstall([]);
|
|
519
|
+
expect(result).toBe(0);
|
|
520
|
+
|
|
521
|
+
// Customized pre-commit should remain (no signature match)
|
|
522
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(true);
|
|
523
|
+
// Pre-push should have been removed
|
|
524
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-push'))).toBe(false);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('handles --force flag to skip confirmation', async () => {
|
|
528
|
+
setupLocalInstall();
|
|
529
|
+
mockExecSuccess();
|
|
530
|
+
const { uninstall } = require('../uninstall');
|
|
531
|
+
|
|
532
|
+
const result = await uninstall(['--force']);
|
|
533
|
+
expect(result).toBe(0);
|
|
534
|
+
|
|
535
|
+
expect(fs.existsSync(path.join(projectHooksDir(), 'pre-commit'))).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('handles --local flag to override auto-detection', async () => {
|
|
539
|
+
// Set config to global but pass --local
|
|
540
|
+
setupGlobalInstall();
|
|
541
|
+
mockExecSuccess();
|
|
542
|
+
const { uninstall } = require('../uninstall');
|
|
543
|
+
|
|
544
|
+
const result = await uninstall(['--local']);
|
|
545
|
+
expect(result).toBe(0);
|
|
546
|
+
|
|
547
|
+
// Should have tried to look at project hooks (based on local mode)
|
|
548
|
+
// Since no git dir in this context, should still work gracefully
|
|
549
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
550
|
+
expect(cfg.mode).toBe('uninstalled');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('handles --global flag to override auto-detection', async () => {
|
|
554
|
+
setupLocalInstall();
|
|
555
|
+
mockExecGlobalHooksPath(globalHooksDir());
|
|
556
|
+
const { uninstall } = require('../uninstall');
|
|
557
|
+
|
|
558
|
+
const result = await uninstall(['--global']);
|
|
559
|
+
expect(result).toBe(0);
|
|
560
|
+
|
|
561
|
+
// Should have attempted global cleanup even though config says local
|
|
562
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
563
|
+
expect(cfg.mode).toBe('uninstalled');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('prints plan summary before execution', async () => {
|
|
567
|
+
setupLocalInstall();
|
|
568
|
+
mockExecSuccess();
|
|
569
|
+
const { uninstall } = require('../uninstall');
|
|
570
|
+
|
|
571
|
+
await uninstall(['--dry-run']);
|
|
572
|
+
|
|
573
|
+
// Should list what it would do
|
|
574
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('pre-commit'));
|
|
575
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('pre-push'));
|
|
576
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('adapter-common.sh'));
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('saves rollback snapshot before destructive operations', async () => {
|
|
580
|
+
setupLocalInstall();
|
|
581
|
+
mockExecSuccess();
|
|
582
|
+
const { uninstall } = require('../uninstall');
|
|
583
|
+
|
|
584
|
+
await uninstall([]);
|
|
585
|
+
|
|
586
|
+
// After successful uninstall, backup should be cleaned up
|
|
587
|
+
expect(fs.existsSync(backupDir())).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
});
|