mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
4fc64f7f96
* fix: top-level skill dirs so Claude discovers unprefixed names Replace directory symlinks (gstack/qa → qa) with real directories containing a SKILL.md symlink. Claude Code auto-prefixes skills nested under a parent dir symlink, so /plan-ceo-review became "Unknown skill" even with skill_prefix=false. Real dirs fix this. Also syncs package.json version to match VERSION file and updates test assertions to match the new mkdir + ln approach. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update symlink references to new top-level directory pattern Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: regression tests for top-level skill directory structure Verifies the invariant that setup/relink creates real directories (not symlinks) at the top level, with SKILL.md symlinks inside. This prevents Claude Code from auto-prefixing skills with gstack- when using --no-prefix. Tests added: - unprefixed skills must be real dirs with SKILL.md symlinks - prefixed skills must also be real dirs with SKILL.md symlinks - old directory symlinks get upgraded to real directories - cleanup functions handle both old symlinks and new dir pattern - link function removes old directory symlinks before mkdir Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: namespace isolation tests for first install + mode switching Verifies the core invariant: when you pick a prefix mode, ONLY that mode's entries exist. Zero pollution from the other mode. - first install --no-prefix: only flat names, zero gstack-* leaks - first install --prefix: only gstack-* names, zero flat leaks - non-TTY defaults to flat names - switching prefix→no-prefix removes ALL gstack-* entries - switching no-prefix→prefix removes ALL flat entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: upgrade migration system — versioned fix scripts for broken state Adds gstack-upgrade/migrations/ directory with version-keyed bash scripts that run automatically during /gstack-upgrade (Step 4.75, after ./setup). Each script is idempotent and handles state fixes that setup alone can't cover. First migration: v0.15.2.0.sh runs gstack-relink to fix stale directory symlinks from pre-v0.15.2.0 installs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: migration script validation + v0.15.2.0 end-to-end fix test Tests that migration scripts are executable, parse without syntax errors, follow the v{VERSION}.sh naming convention, and that v0.15.2.0 actually fixes stale directory symlinks by converting them to real directories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: upgrade migration guide in CONTRIBUTING.md + CLAUDE.md pointer CONTRIBUTING.md: new "Upgrade migrations" section documenting when and how to add migration scripts for broken on-disk state. CLAUDE.md: added note under vendored symlink awareness pointing to CONTRIBUTING.md's migration section when worried about broken installs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
457 lines
20 KiB
TypeScript
457 lines
20 KiB
TypeScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync } 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 BIN = path.join(ROOT, 'bin');
|
|
|
|
let tmpDir: string;
|
|
let skillsDir: string;
|
|
let installDir: string;
|
|
|
|
function run(cmd: string, env: Record<string, string> = {}, expectFail = false): string {
|
|
try {
|
|
return execSync(cmd, {
|
|
cwd: ROOT,
|
|
env: { ...process.env, GSTACK_STATE_DIR: tmpDir, ...env },
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}).trim();
|
|
} catch (e: any) {
|
|
if (expectFail) return (e.stderr || e.stdout || '').toString().trim();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Create a mock gstack install directory with skill subdirs
|
|
function setupMockInstall(skills: string[]): void {
|
|
installDir = path.join(tmpDir, 'gstack-install');
|
|
skillsDir = path.join(tmpDir, 'skills');
|
|
fs.mkdirSync(installDir, { recursive: true });
|
|
fs.mkdirSync(skillsDir, { recursive: true });
|
|
|
|
// Copy the real gstack-config and gstack-relink to the mock install
|
|
const mockBin = path.join(installDir, 'bin');
|
|
fs.mkdirSync(mockBin, { recursive: true });
|
|
fs.copyFileSync(path.join(BIN, 'gstack-config'), path.join(mockBin, 'gstack-config'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755);
|
|
if (fs.existsSync(path.join(BIN, 'gstack-relink'))) {
|
|
fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755);
|
|
}
|
|
if (fs.existsSync(path.join(BIN, 'gstack-patch-names'))) {
|
|
fs.copyFileSync(path.join(BIN, 'gstack-patch-names'), path.join(mockBin, 'gstack-patch-names'));
|
|
fs.chmodSync(path.join(mockBin, 'gstack-patch-names'), 0o755);
|
|
}
|
|
|
|
// Create mock skill directories with proper frontmatter
|
|
for (const skill of skills) {
|
|
fs.mkdirSync(path.join(installDir, skill), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installDir, skill, 'SKILL.md'),
|
|
`---\nname: ${skill}\ndescription: test\n---\n# ${skill}`
|
|
);
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-relink-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('gstack-relink (#578)', () => {
|
|
// Test 11: prefixed symlinks when skill_prefix=true
|
|
test('creates gstack-* symlinks when skill_prefix=true', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
// Set config to prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
// Run relink with env pointing to the mock install
|
|
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify gstack-* symlinks exist
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-review'))).toBe(true);
|
|
expect(output).toContain('gstack-');
|
|
});
|
|
|
|
// Test 12: flat symlinks when skill_prefix=false
|
|
test('creates flat symlinks when skill_prefix=false', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
const output = run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'ship'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'review'))).toBe(true);
|
|
expect(output).toContain('flat');
|
|
});
|
|
|
|
// REGRESSION: unprefixed skills must be real directories, not symlinks (#761)
|
|
// Claude Code auto-prefixes skills nested under a parent dir symlink.
|
|
// e.g., `qa -> gstack/qa` gets discovered as "gstack-qa", not "qa".
|
|
// The fix: create real directories with SKILL.md symlinks inside.
|
|
test('unprefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
for (const skill of ['qa', 'ship', 'review', 'plan-ceo-review']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
// Must be a real directory, NOT a symlink
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
// Must contain a SKILL.md that IS a symlink
|
|
expect(fs.existsSync(skillMdPath)).toBe(true);
|
|
expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true);
|
|
// The SKILL.md symlink must point to the source skill's SKILL.md
|
|
const target = fs.readlinkSync(skillMdPath);
|
|
expect(target).toContain(skill);
|
|
expect(target).toEndWith('/SKILL.md');
|
|
}
|
|
});
|
|
|
|
// Same invariant for prefixed mode
|
|
test('prefixed skills are real directories with SKILL.md symlinks, not dir symlinks', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
for (const skill of ['gstack-qa', 'gstack-ship']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
const skillMdPath = path.join(skillPath, 'SKILL.md');
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(skillMdPath).isSymbolicLink()).toBe(true);
|
|
}
|
|
});
|
|
|
|
// Upgrade: old directory symlinks get replaced with real directories
|
|
test('upgrades old directory symlinks to real directories', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Simulate old behavior: create directory symlinks (the old pattern)
|
|
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa'));
|
|
fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship'));
|
|
// Verify they start as symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
|
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// After relink: must be real directories, not symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa', 'SKILL.md')).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
// FIRST INSTALL: --no-prefix must create ONLY flat names, zero gstack-* pollution
|
|
test('first install --no-prefix: only flat names exist, zero gstack-* entries', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
// Simulate first install: no saved config, pass --no-prefix equivalent
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Enumerate everything in skills dir
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Expected: qa, ship, review, plan-ceo-review, gstack-upgrade (its real name)
|
|
expect(entries.sort()).toEqual(['gstack-upgrade', 'plan-ceo-review', 'qa', 'review', 'ship']);
|
|
// No gstack-qa, gstack-ship, gstack-review, gstack-plan-ceo-review
|
|
const leaked = entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade');
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// FIRST INSTALL: --prefix must create ONLY gstack-* names, zero flat-name pollution
|
|
test('first install --prefix: only gstack-* entries exist, zero flat names', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Expected: gstack-qa, gstack-ship, gstack-review, gstack-plan-ceo-review, gstack-upgrade
|
|
expect(entries.sort()).toEqual([
|
|
'gstack-plan-ceo-review', 'gstack-qa', 'gstack-review', 'gstack-ship', 'gstack-upgrade',
|
|
]);
|
|
// No unprefixed qa, ship, review, plan-ceo-review
|
|
const leaked = entries.filter(e => !e.startsWith('gstack-'));
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// FIRST INSTALL: non-TTY (no saved config, piped stdin) defaults to flat names
|
|
test('non-TTY first install defaults to flat names via relink', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Don't set any config — simulate fresh install
|
|
// gstack-relink reads config; on fresh install config returns empty → defaults to false
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
const entries = fs.readdirSync(skillsDir);
|
|
// Should be flat names (relink defaults to false when config returns empty)
|
|
expect(entries.sort()).toEqual(['qa', 'ship']);
|
|
});
|
|
|
|
// SWITCH: prefix → no-prefix must clean up ALL gstack-* entries
|
|
test('switching prefix to no-prefix removes all gstack-* entries completely', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'plan-ceo-review', 'gstack-upgrade']);
|
|
// Start in prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
let entries = fs.readdirSync(skillsDir);
|
|
expect(entries.filter(e => !e.startsWith('gstack-'))).toEqual([]);
|
|
|
|
// Switch to no-prefix
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
entries = fs.readdirSync(skillsDir);
|
|
// Only flat names + gstack-upgrade (its real name)
|
|
expect(entries.sort()).toEqual(['gstack-upgrade', 'plan-ceo-review', 'qa', 'review', 'ship']);
|
|
const leaked = entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade');
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// SWITCH: no-prefix → prefix must clean up ALL flat entries
|
|
test('switching no-prefix to prefix removes all flat entries completely', () => {
|
|
setupMockInstall(['qa', 'ship', 'review', 'gstack-upgrade']);
|
|
// Start in no-prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
let entries = fs.readdirSync(skillsDir);
|
|
expect(entries.filter(e => e.startsWith('gstack-') && e !== 'gstack-upgrade')).toEqual([]);
|
|
|
|
// Switch to prefix
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
entries = fs.readdirSync(skillsDir);
|
|
// Only gstack-* names
|
|
expect(entries.sort()).toEqual([
|
|
'gstack-qa', 'gstack-review', 'gstack-ship', 'gstack-upgrade',
|
|
]);
|
|
const leaked = entries.filter(e => !e.startsWith('gstack-'));
|
|
expect(leaked).toEqual([]);
|
|
});
|
|
|
|
// Test 13: cleans stale symlinks from opposite mode
|
|
test('cleans up stale symlinks from opposite mode', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Create prefixed symlinks first
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
|
|
// Switch to flat mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// Flat symlinks should exist, prefixed should be gone
|
|
expect(fs.existsSync(path.join(skillsDir, 'qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(false);
|
|
});
|
|
|
|
// Test 14: error when install dir missing
|
|
test('prints error when install dir missing', () => {
|
|
const output = run(`${BIN}/gstack-relink`, {
|
|
GSTACK_INSTALL_DIR: '/nonexistent/path/gstack',
|
|
GSTACK_SKILLS_DIR: '/nonexistent/path/skills',
|
|
}, true);
|
|
expect(output).toContain('setup');
|
|
});
|
|
|
|
// Test: gstack-upgrade does NOT get double-prefixed
|
|
test('does not double-prefix gstack-upgrade directory', () => {
|
|
setupMockInstall(['qa', 'ship', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-upgrade'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-gstack-upgrade'))).toBe(false);
|
|
// Regular skills still get prefixed
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
});
|
|
|
|
// Test 15: gstack-config set skill_prefix triggers relink
|
|
test('gstack-config set skill_prefix triggers relink', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// Run gstack-config set which should auto-trigger relink
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// If relink was triggered, symlinks should exist
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-qa'))).toBe(true);
|
|
expect(fs.existsSync(path.join(skillsDir, 'gstack-ship'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('upgrade migrations', () => {
|
|
const MIGRATIONS_DIR = path.join(ROOT, 'gstack-upgrade', 'migrations');
|
|
|
|
test('migrations directory exists', () => {
|
|
expect(fs.existsSync(MIGRATIONS_DIR)).toBe(true);
|
|
});
|
|
|
|
test('all migration scripts are executable and parse without syntax errors', () => {
|
|
const scripts = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sh'));
|
|
expect(scripts.length).toBeGreaterThan(0);
|
|
for (const script of scripts) {
|
|
const fullPath = path.join(MIGRATIONS_DIR, script);
|
|
// Must be executable
|
|
const stat = fs.statSync(fullPath);
|
|
expect(stat.mode & 0o111).toBeGreaterThan(0);
|
|
// Must parse without syntax errors (bash -n is a syntax check, doesn't execute)
|
|
const result = execSync(`bash -n "${fullPath}" 2>&1`, { encoding: 'utf-8', timeout: 5000 });
|
|
// bash -n outputs nothing on success
|
|
}
|
|
});
|
|
|
|
test('migration filenames follow v{VERSION}.sh pattern', () => {
|
|
const scripts = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sh'));
|
|
for (const script of scripts) {
|
|
expect(script).toMatch(/^v\d+\.\d+\.\d+\.\d+\.sh$/);
|
|
}
|
|
});
|
|
|
|
test('v0.15.2.0 migration runs gstack-relink', () => {
|
|
const content = fs.readFileSync(path.join(MIGRATIONS_DIR, 'v0.15.2.0.sh'), 'utf-8');
|
|
expect(content).toContain('gstack-relink');
|
|
});
|
|
|
|
test('v0.15.2.0 migration fixes stale directory symlinks', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
// Simulate old state: directory symlinks (pre-v0.15.2.0 pattern)
|
|
fs.symlinkSync(path.join(installDir, 'qa'), path.join(skillsDir, 'qa'));
|
|
fs.symlinkSync(path.join(installDir, 'ship'), path.join(skillsDir, 'ship'));
|
|
fs.symlinkSync(path.join(installDir, 'review'), path.join(skillsDir, 'review'));
|
|
// Set no-prefix mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
// Verify old state: symlinks
|
|
expect(fs.lstatSync(path.join(skillsDir, 'qa')).isSymbolicLink()).toBe(true);
|
|
|
|
// Run the migration (it calls gstack-relink internally)
|
|
run(`bash ${path.join(MIGRATIONS_DIR, 'v0.15.2.0.sh')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
|
|
// After migration: real directories with SKILL.md symlinks
|
|
for (const skill of ['qa', 'ship', 'review']) {
|
|
const skillPath = path.join(skillsDir, skill);
|
|
expect(fs.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
expect(fs.lstatSync(skillPath).isDirectory()).toBe(true);
|
|
expect(fs.lstatSync(path.join(skillPath, 'SKILL.md')).isSymbolicLink()).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-patch-names (#620/#578)', () => {
|
|
// Helper to read name: from SKILL.md frontmatter
|
|
function readSkillName(skillDir: string): string | null {
|
|
const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
|
|
const match = content.match(/^name:\s*(.+)$/m);
|
|
return match ? match[1].trim() : null;
|
|
}
|
|
|
|
test('prefix=true patches name: field in SKILL.md', () => {
|
|
setupMockInstall(['qa', 'ship', 'review']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify name: field is patched with gstack- prefix
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
expect(readSkillName(path.join(installDir, 'ship'))).toBe('gstack-ship');
|
|
expect(readSkillName(path.join(installDir, 'review'))).toBe('gstack-review');
|
|
});
|
|
|
|
test('prefix=false restores name: field in SKILL.md', () => {
|
|
setupMockInstall(['qa', 'ship']);
|
|
// First, prefix them
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
// Now switch to flat mode
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix false`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Verify name: field is restored to unprefixed
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('qa');
|
|
expect(readSkillName(path.join(installDir, 'ship'))).toBe('ship');
|
|
});
|
|
|
|
test('gstack-upgrade name: not double-prefixed', () => {
|
|
setupMockInstall(['qa', 'gstack-upgrade']);
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// gstack-upgrade should keep its name, NOT become gstack-gstack-upgrade
|
|
expect(readSkillName(path.join(installDir, 'gstack-upgrade'))).toBe('gstack-upgrade');
|
|
// Regular skill should be prefixed
|
|
expect(readSkillName(path.join(installDir, 'qa'))).toBe('gstack-qa');
|
|
});
|
|
|
|
test('SKILL.md without frontmatter is a no-op', () => {
|
|
setupMockInstall(['qa']);
|
|
// Overwrite qa SKILL.md with no frontmatter
|
|
fs.writeFileSync(path.join(installDir, 'qa', 'SKILL.md'), '# qa\nSome content.');
|
|
run(`${path.join(installDir, 'bin', 'gstack-config')} set skill_prefix true`);
|
|
// Should not crash
|
|
run(`${path.join(installDir, 'bin', 'gstack-relink')}`, {
|
|
GSTACK_INSTALL_DIR: installDir,
|
|
GSTACK_SKILLS_DIR: skillsDir,
|
|
});
|
|
// Content should be unchanged (no name: to patch)
|
|
const content = fs.readFileSync(path.join(installDir, 'qa', 'SKILL.md'), 'utf-8');
|
|
expect(content).toBe('# qa\nSome content.');
|
|
});
|
|
});
|