diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index b4f600f0..60de468d 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1604,6 +1604,50 @@ describe('setup script validation', () => { expect(setupContent).toContain('$HOME/.gstack/repos/gstack'); expect(setupContent).toContain('avoid duplicate skill discovery'); }); + + // --- Symlink prefix tests (PR #503) --- + + test('link_claude_skill_dirs applies gstack- prefix by default', () => { + const fnStart = setupContent.indexOf('link_claude_skill_dirs()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + expect(fnBody).toContain('SKILL_PREFIX'); + expect(fnBody).toContain('link_name="gstack-$skill_name"'); + }); + + test('link_claude_skill_dirs preserves already-prefixed dirs', () => { + const fnStart = setupContent.indexOf('link_claude_skill_dirs()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('linked[@]}', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + // gstack-* dirs should keep their name (e.g., gstack-upgrade stays gstack-upgrade) + expect(fnBody).toContain('gstack-*) link_name="$skill_name"'); + }); + + test('setup supports --no-prefix flag', () => { + expect(setupContent).toContain('--no-prefix'); + expect(setupContent).toContain('SKILL_PREFIX=0'); + }); + + test('cleanup_old_claude_symlinks removes only gstack-pointing symlinks', () => { + expect(setupContent).toContain('cleanup_old_claude_symlinks'); + const fnStart = setupContent.indexOf('cleanup_old_claude_symlinks()'); + const fnEnd = setupContent.indexOf('}', setupContent.indexOf('removed[@]}', fnStart)); + const fnBody = setupContent.slice(fnStart, fnEnd); + // Should check readlink before removing + expect(fnBody).toContain('readlink'); + expect(fnBody).toContain('gstack/*'); + // Should skip already-prefixed dirs + expect(fnBody).toContain('gstack-*) continue'); + }); + + test('cleanup runs before link when prefix is enabled', () => { + // In the Claude install section, cleanup should happen before linking + const claudeInstallSection = setupContent.slice( + setupContent.indexOf('INSTALL_CLAUDE'), + setupContent.lastIndexOf('link_claude_skill_dirs') + ); + expect(claudeInstallSection).toContain('cleanup_old_claude_symlinks'); + }); }); describe('discover-skills hidden directory filtering', () => { diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts new file mode 100644 index 00000000..a7208e87 --- /dev/null +++ b/test/uninstall.test.ts @@ -0,0 +1,165 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const UNINSTALL = path.join(ROOT, 'bin', 'gstack-uninstall'); + +describe('gstack-uninstall', () => { + test('syntax check passes', () => { + const result = spawnSync('bash', ['-n', UNINSTALL], { stdio: 'pipe' }); + expect(result.status).toBe(0); + }); + + test('--help prints usage and exits 0', () => { + const result = spawnSync('bash', [UNINSTALL, '--help'], { stdio: 'pipe' }); + expect(result.status).toBe(0); + const output = result.stdout.toString(); + expect(output).toContain('gstack-uninstall'); + expect(output).toContain('--force'); + expect(output).toContain('--keep-state'); + }); + + test('unknown flag exits with error', () => { + const result = spawnSync('bash', [UNINSTALL, '--bogus'], { + stdio: 'pipe', + env: { ...process.env, HOME: '/nonexistent' }, + }); + expect(result.status).toBe(1); + expect(result.stderr.toString()).toContain('Unknown option'); + }); + + describe('integration tests with mock layout', () => { + let tmpDir: string; + let mockHome: string; + let mockGitRoot: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-test-')); + mockHome = path.join(tmpDir, 'home'); + mockGitRoot = path.join(tmpDir, 'repo'); + + // Create mock gstack install layout + fs.mkdirSync(path.join(mockHome, '.claude', 'skills', 'gstack'), { recursive: true }); + fs.writeFileSync(path.join(mockHome, '.claude', 'skills', 'gstack', 'SKILL.md'), 'test'); + + // Create per-skill symlinks (both old unprefixed and new prefixed) + fs.symlinkSync('gstack/review', path.join(mockHome, '.claude', 'skills', 'review')); + fs.symlinkSync('gstack/ship', path.join(mockHome, '.claude', 'skills', 'gstack-ship')); + + // Create a non-gstack symlink (should NOT be removed) + fs.mkdirSync(path.join(mockHome, '.claude', 'skills', 'other-tool'), { recursive: true }); + + // Create state directory + fs.mkdirSync(path.join(mockHome, '.gstack', 'projects'), { recursive: true }); + fs.writeFileSync(path.join(mockHome, '.gstack', 'config.json'), '{}'); + + // Create mock git repo + fs.mkdirSync(mockGitRoot, { recursive: true }); + spawnSync('git', ['init', '-b', 'main'], { cwd: mockGitRoot, stdio: 'pipe' }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('--force removes global Claude skills and state', () => { + const result = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'), + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + const output = result.stdout.toString(); + expect(output).toContain('gstack uninstalled'); + + // Global skill dir should be removed + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack'))).toBe(false); + + // Per-skill symlinks pointing into gstack/ should be removed + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'review'))).toBe(false); + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack-ship'))).toBe(false); + + // Non-gstack tool should still exist + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true); + + // State should be removed + expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); + }); + + test('--keep-state preserves state directory', () => { + const result = spawnSync('bash', [UNINSTALL, '--force', '--keep-state'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'), + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + + // Skills should be removed + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack'))).toBe(false); + + // State should still exist + expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(true); + expect(fs.existsSync(path.join(mockHome, '.gstack', 'config.json'))).toBe(true); + }); + + test('clean system outputs nothing to remove', () => { + const cleanHome = path.join(tmpDir, 'clean-home'); + fs.mkdirSync(cleanHome, { recursive: true }); + + const result = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: cleanHome, + GSTACK_DIR: path.join(cleanHome, 'nonexistent'), + GSTACK_STATE_DIR: path.join(cleanHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + expect(result.stdout.toString()).toContain('Nothing to remove'); + }); + + test('upgrade path: prefixed install + uninstall cleans both old and new symlinks', () => { + // Simulate the state after setup --no-prefix followed by setup (with prefix): + // Both old unprefixed and new prefixed symlinks exist + // (mockHome already has both 'review' and 'gstack-ship' symlinks) + + const result = spawnSync('bash', [UNINSTALL, '--force'], { + stdio: 'pipe', + env: { + ...process.env, + HOME: mockHome, + GSTACK_DIR: path.join(mockHome, '.claude', 'skills', 'gstack'), + GSTACK_STATE_DIR: path.join(mockHome, '.gstack'), + }, + cwd: mockGitRoot, + }); + + expect(result.status).toBe(0); + + // Both old (review) and new (gstack-ship) symlinks should be gone + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'review'))).toBe(false); + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'gstack-ship'))).toBe(false); + + // Non-gstack should survive + expect(fs.existsSync(path.join(mockHome, '.claude', 'skills', 'other-tool'))).toBe(true); + }); + }); +});