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
package/lib/rollback.js
ADDED
|
@@ -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 };
|
package/lib/uninstall.js
ADDED
|
@@ -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 };
|