mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
feat: community PRs — faster install, skill namespacing, uninstall, Codex fallback, Windows fix, Python patterns (v0.12.9.0) (#561)
* fix: sync package.json version with VERSION file (0.12.7.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf: shallow clone for faster install (#484) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Python/async/SSRF patterns in review checklist (#531) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: namespace skill symlinks with gstack- prefix (#503) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add uninstall script (#323) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: office-hours Claude subagent fallback when Codex unavailable (#464) Updates generateCodexSecondOpinion resolver to always offer second opinion and fall back to Claude subagent when Codex is unavailable or errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: findPort() race condition via net.createServer (#490) Replaces Bun.serve() port probing with net.createServer() for proper async bind/close semantics. Fixes Windows EADDRINUSE race condition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for uninstall, setup prefix, and resolver fallback - Uninstall integration tests: syntax, flags, mock install layout, upgrade path - Setup prefix tests: gstack-* prefixing, --no-prefix, cleanup migration - Resolver tests: Claude subagent fallback in generated SKILL.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.12.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1023,12 +1023,18 @@ describe('CODEX_SECOND_OPINION resolver', () => {
|
||||
});
|
||||
|
||||
test('contains opt-in AskUserQuestion text', () => {
|
||||
expect(content).toContain('second opinion from a different AI model');
|
||||
expect(content).toContain('second opinion from an independent AI perspective');
|
||||
});
|
||||
|
||||
test('contains cross-model synthesis instructions', () => {
|
||||
expect(content).toMatch(/[Ss]ynthesis/);
|
||||
expect(content).toContain('Where Claude agrees with Codex');
|
||||
expect(content).toContain('Where Claude agrees with the second opinion');
|
||||
});
|
||||
|
||||
test('contains Claude subagent fallback', () => {
|
||||
expect(content).toContain('CODEX_NOT_AVAILABLE');
|
||||
expect(content).toContain('Agent tool');
|
||||
expect(content).toContain('SECOND OPINION (Claude subagent)');
|
||||
});
|
||||
|
||||
test('contains premise revision check', () => {
|
||||
@@ -1635,6 +1641,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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user