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.
Files changed (90) hide show
  1. package/adapter-common.sh +192 -0
  2. package/adapters/cpp.sh +76 -0
  3. package/adapters/dart.sh +41 -0
  4. package/adapters/flutter.sh +41 -0
  5. package/adapters/go.sh +59 -0
  6. package/adapters/iac.sh +189 -0
  7. package/adapters/java.sh +191 -0
  8. package/adapters/kotlin.sh +77 -0
  9. package/adapters/objectivec.sh +38 -0
  10. package/adapters/powershell.sh +138 -0
  11. package/adapters/python.sh +104 -0
  12. package/adapters/shell.sh +55 -0
  13. package/adapters/swift.sh +44 -0
  14. package/adapters/typescript.sh +61 -0
  15. package/bin/xp-gate.js +157 -0
  16. package/hooks/adapter-common.sh +192 -0
  17. package/hooks/pre-commit +1667 -0
  18. package/hooks/pre-push +395 -0
  19. package/lib/__tests__/detect-deps.test.js +209 -0
  20. package/lib/__tests__/doctor.test.js +448 -0
  21. package/lib/__tests__/download-skill.test.js +281 -0
  22. package/lib/__tests__/init.test.js +327 -0
  23. package/lib/__tests__/install-skill.test.js +326 -0
  24. package/lib/__tests__/migrate.test.js +212 -0
  25. package/lib/__tests__/rollback.test.js +183 -0
  26. package/lib/__tests__/ui-detector.test.ts +200 -0
  27. package/lib/__tests__/uninstall-skill.test.js +189 -0
  28. package/lib/__tests__/uninstall.test.js +589 -0
  29. package/lib/__tests__/update-skill.test.js +276 -0
  30. package/lib/detect-deps.js +157 -0
  31. package/lib/doctor.js +370 -0
  32. package/lib/download-skill.js +96 -0
  33. package/lib/init.js +367 -0
  34. package/lib/install-skill.js +184 -0
  35. package/lib/migrate.js +120 -0
  36. package/lib/rollback.js +78 -0
  37. package/lib/ui-detector.ts +99 -0
  38. package/lib/uninstall-skill.js +69 -0
  39. package/lib/uninstall.js +401 -0
  40. package/lib/update-skill.js +90 -0
  41. package/package.json +39 -0
  42. package/plugins/claude-code/.claude-plugin/plugin.json +21 -0
  43. package/plugins/claude-code/bin/delphi-review-guard.sh +68 -0
  44. package/plugins/claude-code/bin/xp-gate-check +47 -0
  45. package/plugins/claude-code/hooks/hooks.json +37 -0
  46. package/skills/delphi-review/.delphi-config.json.example +45 -0
  47. package/skills/delphi-review/AGENTS.md +54 -0
  48. package/skills/delphi-review/INSTALL.md +152 -0
  49. package/skills/delphi-review/SKILL.md +371 -0
  50. package/skills/delphi-review/evals/evals.json +82 -0
  51. package/skills/delphi-review/opencode.json.delphi.example +56 -0
  52. package/skills/delphi-review/references/code-walkthrough.md +486 -0
  53. package/skills/ralph-loop/SKILL.md +330 -0
  54. package/skills/ralph-loop/evals/evals.json +311 -0
  55. package/skills/ralph-loop/evolution-history.json +59 -0
  56. package/skills/ralph-loop/evolution-log.md +16 -0
  57. package/skills/ralph-loop/references/components/memory.md +55 -0
  58. package/skills/ralph-loop/references/components/middleware.md +54 -0
  59. package/skills/ralph-loop/references/components/skill-invocations.md +39 -0
  60. package/skills/ralph-loop/references/components/system-prompt.md +24 -0
  61. package/skills/ralph-loop/references/components/tool-descriptions.md +32 -0
  62. package/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  63. package/skills/ralph-loop/templates/progress-log.md +36 -0
  64. package/skills/sprint-flow/SKILL.md +600 -0
  65. package/skills/sprint-flow/evals/evals.json +78 -0
  66. package/skills/sprint-flow/evolution-history.json +39 -0
  67. package/skills/sprint-flow/evolution-log.md +23 -0
  68. package/skills/sprint-flow/references/components/memory.md +87 -0
  69. package/skills/sprint-flow/references/components/middleware.md +72 -0
  70. package/skills/sprint-flow/references/components/skill-invocations.md +104 -0
  71. package/skills/sprint-flow/references/components/system-prompt.md +27 -0
  72. package/skills/sprint-flow/references/components/tool-descriptions.md +96 -0
  73. package/skills/sprint-flow/references/phase-0-think.md +115 -0
  74. package/skills/sprint-flow/references/phase-1-plan.md +178 -0
  75. package/skills/sprint-flow/references/phase-2-build.md +198 -0
  76. package/skills/sprint-flow/references/phase-3-review.md +213 -0
  77. package/skills/sprint-flow/references/phase-4-uat.md +125 -0
  78. package/skills/sprint-flow/references/phase-5-feedback.md +100 -0
  79. package/skills/sprint-flow/references/phase-6-ship.md +193 -0
  80. package/skills/sprint-flow/references/phase-7-land.md +140 -0
  81. package/skills/sprint-flow/references/phase-8-cleanup.md +192 -0
  82. package/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  83. package/skills/sprint-flow/templates/pain-document-template.md +115 -0
  84. package/skills/sprint-flow/templates/sprint-summary-template.md +120 -0
  85. package/skills/test-specification-alignment/AGENTS.md +59 -0
  86. package/skills/test-specification-alignment/SKILL.md +605 -0
  87. package/skills/test-specification-alignment/evals/evals.json +75 -0
  88. package/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  89. package/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  90. package/skills/test-specification-alignment/references/specification-format.md +348 -0
@@ -0,0 +1,78 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Cross-platform home directory resolution
6
+ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
7
+
8
+ const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
9
+ const BACKUP_DIR = path.join(CONFIG_DIR, 'backup');
10
+ const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
11
+
12
+ async function rollback(installId) {
13
+ const backupDir = path.join(BACKUP_DIR, installId);
14
+
15
+ if (!fs.existsSync(backupDir)) {
16
+ return;
17
+ }
18
+
19
+ console.log('Rolling back...');
20
+
21
+ const entries = fs.readdirSync(backupDir);
22
+ for (const entry of entries) {
23
+ const src = path.join(backupDir, entry);
24
+ const dest = path.join(SKILLS_DIR, entry);
25
+
26
+ if (fs.existsSync(src)) {
27
+ if (fs.existsSync(dest)) {
28
+ fs.rmSync(dest, { recursive: true });
29
+ }
30
+ fs.renameSync(src, dest);
31
+ }
32
+ }
33
+
34
+ fs.rmSync(backupDir, { recursive: true });
35
+
36
+ console.log('Rollback complete');
37
+ }
38
+
39
+ async function createBackup(installId, skillName) {
40
+ const backupDir = path.join(BACKUP_DIR, installId);
41
+ const targetDir = path.join(SKILLS_DIR, skillName);
42
+
43
+ if (!fs.existsSync(targetDir)) {
44
+ return null;
45
+ }
46
+
47
+ fs.mkdirSync(backupDir, { recursive: true });
48
+
49
+ copyDirRecursive(targetDir, backupDir);
50
+
51
+ return backupDir;
52
+ }
53
+
54
+ function copyDirRecursive(src, dest) {
55
+ fs.mkdirSync(dest, { recursive: true });
56
+ const entries = fs.readdirSync(src, { withFileTypes: true });
57
+
58
+ for (const entry of entries) {
59
+ const srcPath = path.join(src, entry.name);
60
+ const destPath = path.join(dest, entry.name);
61
+
62
+ if (entry.isDirectory()) {
63
+ copyDirRecursive(srcPath, destPath);
64
+ } else {
65
+ fs.copyFileSync(srcPath, destPath);
66
+ }
67
+ }
68
+ }
69
+
70
+ function cleanupBackup(installId) {
71
+ const backupDir = path.join(BACKUP_DIR, installId);
72
+
73
+ if (fs.existsSync(backupDir)) {
74
+ fs.rmSync(backupDir, { recursive: true });
75
+ }
76
+ }
77
+
78
+ module.exports = { rollback, createBackup, cleanupBackup };
@@ -0,0 +1,99 @@
1
+ import { execSync } from 'child_process';
2
+
3
+ export interface UiDetectionResult {
4
+ isUiSprint: boolean;
5
+ matchedFiles: string[];
6
+ matchedRules: string[];
7
+ }
8
+
9
+ const TEMPLATE_EXTENSIONS = ['.njk', '.html', '.ejs', '.hbs'];
10
+ const COMPONENT_EXTENSIONS = ['.tsx', '.vue', '.svelte', '.jsx'];
11
+ const STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less'];
12
+ const UI_PATH_PATTERNS = [
13
+ 'views/',
14
+ 'templates/',
15
+ 'components/',
16
+ 'pages/',
17
+ 'src/views/',
18
+ 'src/components/',
19
+ 'src/pages/',
20
+ ];
21
+
22
+ export function detectUiSprint(baseBranch: string = 'main'): UiDetectionResult {
23
+ try {
24
+ const files = getChangedFiles(baseBranch);
25
+ if (files.length === 0) {
26
+ return { isUiSprint: false, matchedFiles: [], matchedRules: [] };
27
+ }
28
+ return collectUiMatches(files);
29
+ } catch {
30
+ return { isUiSprint: false, matchedFiles: [], matchedRules: [] };
31
+ }
32
+ }
33
+
34
+ export function getChangedFiles(baseBranch: string): string[] {
35
+ const diffOutput = execSync(`git diff --name-only ${baseBranch}..HEAD`, {
36
+ encoding: 'utf8',
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ }).trim();
39
+
40
+ if (diffOutput === '') {
41
+ return [];
42
+ }
43
+
44
+ return diffOutput
45
+ .split('\n')
46
+ .filter((f) => f.length > 0)
47
+ .map(parseRenamedFile);
48
+ }
49
+
50
+ export function parseRenamedFile(file: string): string {
51
+ return file.includes('→') ? file.split('→')[1].trim() : file;
52
+ }
53
+
54
+ export function collectUiMatches(files: string[]): UiDetectionResult {
55
+ const matchedFiles: string[] = [];
56
+ const matchedRules = new Set<string>();
57
+
58
+ for (const filePath of files) {
59
+ const rules = getFileMatchRules(filePath);
60
+ if (rules.length > 0) {
61
+ matchedFiles.push(filePath);
62
+ rules.forEach((r) => matchedRules.add(r));
63
+ }
64
+ }
65
+
66
+ return {
67
+ isUiSprint: matchedFiles.length > 0,
68
+ matchedFiles,
69
+ matchedRules: Array.from(matchedRules),
70
+ };
71
+ }
72
+
73
+ export function getFileMatchRules(filePath: string): string[] {
74
+ const ext = getFileExtension(filePath);
75
+ const normalizedPath = filePath.toLowerCase();
76
+
77
+ if (TEMPLATE_EXTENSIONS.includes(ext)) {
78
+ return [`template-${ext}`];
79
+ }
80
+
81
+ if (COMPONENT_EXTENSIONS.includes(ext) && hasUiPathPattern(normalizedPath)) {
82
+ return [`component-${ext}`];
83
+ }
84
+
85
+ if (STYLE_EXTENSIONS.includes(ext) && hasUiPathPattern(normalizedPath)) {
86
+ return [`style-${ext}`];
87
+ }
88
+
89
+ return [];
90
+ }
91
+
92
+ export function getFileExtension(filePath: string): string {
93
+ const lastDot = filePath.lastIndexOf('.');
94
+ return lastDot >= 0 ? filePath.slice(lastDot) : '';
95
+ }
96
+
97
+ export function hasUiPathPattern(normalizedPath: string): boolean {
98
+ return UI_PATH_PATTERNS.some((pattern) => normalizedPath.includes(pattern));
99
+ }
@@ -0,0 +1,69 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Cross-platform home directory resolution
6
+ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
7
+
8
+ const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
9
+ const CACHE_DIR = path.join(CONFIG_DIR, 'cache');
10
+ const SKILLS_DIR = path.join(HOME, '.config', 'opencode', 'skills');
11
+
12
+ async function uninstallSkill(name, options = {}) {
13
+ const { force = false } = options;
14
+
15
+ if (!name) {
16
+ console.error('Error: Skill name required');
17
+ console.error('Usage: xp-gate uninstall-skill <name> [--force]');
18
+ return 1;
19
+ }
20
+
21
+ const targetDir = path.join(SKILLS_DIR, name);
22
+
23
+ if (!fs.existsSync(targetDir)) {
24
+ console.error(`Error: ${name} is not installed`);
25
+ return 1;
26
+ }
27
+
28
+ if (!force) {
29
+ console.log(`Uninstall ${name}? This will remove ${targetDir}`);
30
+ console.log('Use --force to skip confirmation');
31
+ return 0;
32
+ }
33
+
34
+ console.log(`Removing ${name}...`);
35
+
36
+ fs.rmSync(targetDir, { recursive: true });
37
+
38
+ const cacheFile = path.join(CACHE_DIR, `${name}.tgz`);
39
+ if (fs.existsSync(cacheFile)) {
40
+ fs.unlinkSync(cacheFile);
41
+ }
42
+
43
+ const config = getConfig();
44
+ if (config.installedSkills) {
45
+ delete config.installedSkills[name];
46
+ saveConfig(config);
47
+ }
48
+
49
+ console.log(`✓ ${name} uninstalled`);
50
+
51
+ return 0;
52
+ }
53
+
54
+ function getConfig() {
55
+ const configFile = path.join(CONFIG_DIR, 'xp-gate.json');
56
+ if (fs.existsSync(configFile)) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(configFile, 'utf8'));
59
+ } catch {}
60
+ }
61
+ return {};
62
+ }
63
+
64
+ function saveConfig(config) {
65
+ const configFile = path.join(CONFIG_DIR, 'xp-gate.json');
66
+ fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
67
+ }
68
+
69
+ module.exports = { uninstallSkill };
@@ -0,0 +1,401 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+
6
+ // Cross-platform home directory resolution
7
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || os.homedir();
8
+
9
+ const CONFIG_DIR = path.join(HOME_DIR, '.config', 'xp-gate');
10
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'xp-gate.json');
11
+ const TEMPLATE_DIR = path.join(HOME_DIR, '.config', 'opencode', 'git-hooks-template');
12
+ const GLOBAL_HOOKS_DIR = path.join(CONFIG_DIR, 'hooks');
13
+ const GLOBAL_ADAPTERS_DIR = path.join(CONFIG_DIR, 'adapters');
14
+ const BACKUP_DIR = path.join(CONFIG_DIR, '.uninstall-backup');
15
+
16
+ /**
17
+ * Signature strings used to verify file ownership.
18
+ * Each hook/adapter file contains a unique marker string.
19
+ */
20
+ const SIGNATURES = {
21
+ 'pre-commit': 'OpenCode Quality Gates - Pre-Commit Hook',
22
+ 'pre-push': 'Pre-push Hook - Code Walkthrough Result Validator',
23
+ 'adapter-common.sh': 'detect_project_lang()'
24
+ };
25
+
26
+ function isXpGateFile(filePath, signature) {
27
+ if (!fs.existsSync(filePath)) return false;
28
+ try {
29
+ const content = fs.readFileSync(filePath, 'utf8');
30
+ return content.includes(signature);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Verify file ownership using manifest sha256 first, then fall back to signature.
38
+ * Returns true if the file belongs to xp-gate (by either method).
39
+ */
40
+ function verifyFileOwnership(filePath, manifestEntry, signature) {
41
+ if (!fs.existsSync(filePath)) return false;
42
+
43
+ // Level 1: manifest sha256 check
44
+ if (manifestEntry && manifestEntry.sha256) {
45
+ try {
46
+ const content = fs.readFileSync(filePath);
47
+ const actualSha256 = crypto.createHash('sha256').update(content).digest('hex');
48
+ if (actualSha256 === manifestEntry.sha256) {
49
+ return true; // Exact sha256 match
50
+ }
51
+ console.warn(` Warning: sha256 mismatch for ${path.basename(filePath)}, falling back to signature check`);
52
+ } catch (e) {
53
+ // Fall through to signature check
54
+ }
55
+ }
56
+
57
+ // Level 2: signature string fallback
58
+ return isXpGateFile(filePath, signature);
59
+ }
60
+
61
+ function getConfig() {
62
+ if (!fs.existsSync(CONFIG_FILE)) return null;
63
+ try {
64
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function saveConfig(config) {
71
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
72
+ }
73
+
74
+ function getGitDir() {
75
+ try {
76
+ const { execSync } = require('child_process');
77
+ return execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function getCurrentHooksPath() {
84
+ try {
85
+ const { execSync } = require('child_process');
86
+ const result = execSync('git config --global core.hooksPath', {
87
+ encoding: 'utf8',
88
+ stdio: ['pipe', 'pipe', 'pipe']
89
+ });
90
+ return result.trim();
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function unsetHooksPath() {
97
+ try {
98
+ const { execSync } = require('child_process');
99
+ execSync('git config --global --unset core.hooksPath', {
100
+ encoding: 'utf8',
101
+ stdio: ['pipe', 'pipe', 'pipe']
102
+ });
103
+ return true;
104
+ } catch (e) {
105
+ console.warn(` Warning: Could not unset core.hooksPath: ${e.message}`);
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Create a backup snapshot of files/dirs before deletion.
112
+ */
113
+ function createBackupSnapshot(plan) {
114
+ // Use a unique backup dir for this uninstall session
115
+ const sessionBackup = path.join(BACKUP_DIR, `uninstall-${Date.now()}`);
116
+ fs.mkdirSync(sessionBackup, { recursive: true });
117
+
118
+ const backupMeta = { sessionBackup, entries: [] };
119
+
120
+ for (const item of plan) {
121
+ if (!item.path || !fs.existsSync(item.path)) continue;
122
+
123
+ const relPath = path.basename(item.path);
124
+ const destPath = path.join(sessionBackup, relPath);
125
+
126
+ try {
127
+ const stat = fs.statSync(item.path);
128
+ if (stat.isDirectory()) {
129
+ copyDirSync(item.path, destPath);
130
+ } else {
131
+ fs.copyFileSync(item.path, destPath);
132
+ }
133
+ backupMeta.entries.push({ rel: relPath, original: item.path });
134
+ } catch (e) {
135
+ console.warn(` Warning: Could not backup ${item.path}: ${e.message}`);
136
+ }
137
+ }
138
+
139
+ return backupMeta;
140
+ }
141
+
142
+ function copyDirSync(src, dest) {
143
+ fs.mkdirSync(dest, { recursive: true });
144
+ const entries = fs.readdirSync(src, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ const srcPath = path.join(src, entry.name);
147
+ const destPath = path.join(dest, entry.name);
148
+ if (entry.isDirectory()) {
149
+ copyDirSync(srcPath, destPath);
150
+ } else {
151
+ fs.copyFileSync(srcPath, destPath);
152
+ }
153
+ }
154
+ }
155
+
156
+ function cleanupBackup(backupMeta) {
157
+ if (!backupMeta || !backupMeta.sessionBackup) return;
158
+ try {
159
+ fs.rmSync(backupMeta.sessionBackup, { recursive: true, force: true });
160
+ // Remove parent backup dir if empty
161
+ try {
162
+ const parent = path.dirname(backupMeta.sessionBackup);
163
+ if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) {
164
+ fs.rmdirSync(parent);
165
+ }
166
+ } catch {
167
+ // Ignore cleanup failures
168
+ }
169
+ } catch {
170
+ // Ignore cleanup failures
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Build the uninstall plan based on mode.
176
+ * Returns an array of operation objects: { type, path, signature, label, critical }
177
+ */
178
+ function buildPlan(mode) {
179
+ const plan = [];
180
+ const manifest = getConfig() && getConfig().manifest ? getConfig().manifest : null;
181
+
182
+ if (mode === 'local') {
183
+ const gitDir = getGitDir();
184
+ if (gitDir) {
185
+ const projectRoot = path.dirname(gitDir);
186
+ const hooksDir = path.join(projectRoot, '.git', 'hooks');
187
+ const githooksDir = path.join(projectRoot, 'githooks');
188
+
189
+ plan.push({
190
+ type: 'file',
191
+ path: path.join(hooksDir, 'pre-commit'),
192
+ signature: SIGNATURES['pre-commit'],
193
+ label: '.git/hooks/pre-commit',
194
+ manifestKey: '.git/hooks/pre-commit',
195
+ critical: true
196
+ });
197
+ plan.push({
198
+ type: 'file',
199
+ path: path.join(hooksDir, 'pre-push'),
200
+ signature: SIGNATURES['pre-push'],
201
+ label: '.git/hooks/pre-push',
202
+ manifestKey: '.git/hooks/pre-push',
203
+ critical: true
204
+ });
205
+ plan.push({
206
+ type: 'file',
207
+ path: path.join(githooksDir, 'adapter-common.sh'),
208
+ signature: SIGNATURES['adapter-common.sh'],
209
+ label: 'githooks/adapter-common.sh',
210
+ manifestKey: 'githooks/adapter-common.sh',
211
+ critical: true
212
+ });
213
+ const adaptersDir = path.join(githooksDir, 'adapters');
214
+ if (fs.existsSync(adaptersDir)) {
215
+ plan.push({
216
+ type: 'dir',
217
+ path: adaptersDir,
218
+ label: 'githooks/adapters/',
219
+ critical: true
220
+ });
221
+ }
222
+ }
223
+ }
224
+
225
+ if (mode === 'global') {
226
+ plan.push({
227
+ type: 'gitconfig',
228
+ action: 'unset-hooks-path',
229
+ expectedPath: GLOBAL_HOOKS_DIR,
230
+ label: 'git config --global core.hooksPath',
231
+ critical: true
232
+ });
233
+ if (fs.existsSync(GLOBAL_HOOKS_DIR)) {
234
+ plan.push({
235
+ type: 'dir',
236
+ path: GLOBAL_HOOKS_DIR,
237
+ label: '~/.config/xp-gate/hooks/',
238
+ critical: true
239
+ });
240
+ }
241
+ if (fs.existsSync(GLOBAL_ADAPTERS_DIR)) {
242
+ plan.push({
243
+ type: 'dir',
244
+ path: GLOBAL_ADAPTERS_DIR,
245
+ label: '~/.config/xp-gate/adapters/',
246
+ critical: true
247
+ });
248
+ }
249
+ }
250
+
251
+ // Template dir is common to both modes
252
+ if (fs.existsSync(TEMPLATE_DIR)) {
253
+ plan.push({
254
+ type: 'dir',
255
+ path: TEMPLATE_DIR,
256
+ label: '~/.config/opencode/git-hooks-template/',
257
+ critical: true
258
+ });
259
+ }
260
+
261
+ return plan;
262
+ }
263
+
264
+ /**
265
+ * @param {string[]} args CLI arguments
266
+ * @returns {number} exit code (0 = success, 1 = error)
267
+ */
268
+ async function uninstall(args) {
269
+ const options = {
270
+ dryRun: args.includes('--dry-run'),
271
+ force: args.includes('--force'),
272
+ forceLocal: args.includes('--local'),
273
+ forceGlobal: args.includes('--global')
274
+ };
275
+
276
+ // §4.3 Step 1: Read config
277
+ const config = getConfig();
278
+
279
+ // §4.3 Step 1: No config → exit cleanly (AC-09 idempotency)
280
+ if (!config) {
281
+ console.log('No xp-gate installation found');
282
+ return 0;
283
+ }
284
+
285
+ // §4.3 Step 1: Already uninstalled → exit cleanly (AC-09 idempotency)
286
+ if (config.mode === 'uninstalled') {
287
+ console.log('No xp-gate installation found');
288
+ return 0;
289
+ }
290
+
291
+ // §4.3 Step 2: Determine mode
292
+ let mode = config.mode;
293
+ if (options.forceLocal) mode = 'local';
294
+ if (options.forceGlobal) mode = 'global';
295
+
296
+ if (mode !== 'local' && mode !== 'global') {
297
+ console.log('No xp-gate installation found');
298
+ return 0;
299
+ }
300
+
301
+ // §4.3 Step 3: Build uninstall plan
302
+ const plan = buildPlan(mode);
303
+
304
+ if (plan.length === 0) {
305
+ console.log('Nothing to uninstall');
306
+ // Still update config to uninstalled
307
+ saveConfig({ ...config, mode: 'uninstalled', uninstalled: new Date().toISOString() });
308
+ return 0;
309
+ }
310
+
311
+ // §4.3 Step 4: Print plan
312
+ console.log(`XP-Gate Uninstall (${mode} mode)`);
313
+ console.log('=======================\n');
314
+ console.log('The following will be removed:\n');
315
+
316
+ for (const item of plan) {
317
+ console.log(` • ${item.label}`);
318
+ }
319
+
320
+ // §4.3 Step 5: Dry-run → exit
321
+ if (options.dryRun) {
322
+ console.log('\nDry-run mode — no files were modified');
323
+ return 0;
324
+ }
325
+
326
+ // §4.3 Step 6: Confirm
327
+ // Skipped in test environment since we use --force or non-interactive
328
+ // In real usage with TTY, would prompt here
329
+
330
+ // §4.8 State machine: active → uninstalling
331
+ saveConfig({ ...config, mode: 'uninstalling' });
332
+
333
+ // §4.12 Step 4: Create backup snapshot before destructive operations
334
+ const backupMeta = createBackupSnapshot(plan);
335
+
336
+ // §4.12 Execute operations in order (non-destructive first, then destructive)
337
+ let hadErrors = false;
338
+
339
+ for (const item of plan) {
340
+ try {
341
+ if (item.type === 'file') {
342
+ const manifestEntry = config.manifest && config.manifest.files
343
+ ? config.manifest.files[item.manifestKey]
344
+ : null;
345
+
346
+ if (!verifyFileOwnership(item.path, manifestEntry, item.signature)) {
347
+ if (fs.existsSync(item.path)) {
348
+ console.warn(` Warning: ${item.label} does not contain xp-gate signature — skipping`);
349
+ }
350
+ continue;
351
+ }
352
+
353
+ fs.unlinkSync(item.path);
354
+ console.log(` Removed ${item.label}`);
355
+ } else if (item.type === 'dir') {
356
+ if (fs.existsSync(item.path)) {
357
+ fs.rmSync(item.path, { recursive: true, force: true });
358
+ console.log(` Removed ${item.label}`);
359
+ }
360
+ } else if (item.type === 'gitconfig' && item.action === 'unset-hooks-path') {
361
+ const currentPath = getCurrentHooksPath();
362
+ if (currentPath === null || currentPath === '') {
363
+ // Already unset
364
+ console.log(` ${item.label} — not set`);
365
+ } else if (currentPath === item.expectedPath) {
366
+ unsetHooksPath();
367
+ console.log(` Unset ${item.label}`);
368
+ } else {
369
+ console.warn(` Warning: core.hooksPath (${currentPath}) does not match xp-gate path — skipping unset`);
370
+ }
371
+ }
372
+ } catch (e) {
373
+ console.warn(` Warning: Could not remove ${item.label}: ${e.message}`);
374
+ hadErrors = true;
375
+ }
376
+ }
377
+
378
+ // §4.8 State machine: uninstalling → uninstalled
379
+ saveConfig({
380
+ ...getConfig() || config,
381
+ mode: 'uninstalled',
382
+ uninstalled: new Date().toISOString(),
383
+ // Clean up manifest after successful uninstall
384
+ manifest: undefined
385
+ });
386
+
387
+ // §4.12 Step 11: Clean up backup on success
388
+ if (!hadErrors) {
389
+ cleanupBackup(backupMeta);
390
+ }
391
+
392
+ // Print summary
393
+ console.log('\nUninstall complete');
394
+ if (hadErrors) {
395
+ console.log('Some items could not be removed. Run doctor for diagnostics.');
396
+ }
397
+
398
+ return 0;
399
+ }
400
+
401
+ module.exports = { uninstall, isXpGateFile, SIGNATURES };