Files
gstack/test/uninstall.test.ts
garrytan cd7485b0b4 W1: guarded migration of every executable state-root site; uninstall deletes only ~/.gstack
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design,
ios-qa daemon, lib and scripts resolve the state root through
bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the
twin and stop with a reinstall message when it is missing; hooks source it
and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts
delegate to resolveStateRoot. Analytics writers and readers move together
so the usage log stays one file. gstack-uninstall deletes state only at
~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor,
the checkout or the git root, and leaves any other resolved root in place
with the removal command. Fixtures that copy single bins now copy the twin.
2026-09-30 06:38:50 +00:00

564 lines
27 KiB
TypeScript

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', timeout: 30_000 });
expect(result.status).toBe(0);
});
test('--help prints usage and exits 0', () => {
const result = spawnSync('bash', [UNINSTALL, '--help'], { stdio: 'pipe', timeout: 30_000 });
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',
timeout: 30_000,
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', timeout: 30_000 });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('--force removes global Claude skills and state', () => {
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
timeout: 30_000,
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',
timeout: 30_000,
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'),
},
timeout: 30_000,
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',
timeout: 30_000,
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);
});
test('--force removes Cursor gstack skills and leaves other Cursor skills', () => {
// Cursor installs are rendered real dirs, so removal is gated on the
// generated banner in SKILL.md (S5) — the managed fixtures carry it.
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\n# x\n';
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'), { recursive: true });
fs.writeFileSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design', 'SKILL.md'), 'keep');
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship', 'SKILL.md'), banner);
fs.mkdirSync(path.join(mockGitRoot, '.cursor', 'rules'), { recursive: true });
fs.writeFileSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'), 'keep');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
timeout: 30_000,
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);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack'))).toBe(false);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'gstack-review'))).toBe(false);
expect(fs.existsSync(path.join(mockHome, '.cursor', 'skills', 'frontend-design'))).toBe(true);
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'skills', 'gstack-ship'))).toBe(false);
expect(fs.existsSync(path.join(mockGitRoot, '.cursor', 'rules', 'keep.md'))).toBe(true);
});
test("a user's own gstack-prefixed Cursor dir (no banner) survives and is listed", () => {
// S5: the bare gstack* glob must not sweep a dir that merely starts
// with "gstack" — provenance comes from the generated banner, and a
// hand-written SKILL.md never carries it.
const foreign = path.join(mockHome, '.cursor', 'skills', 'gstack-fork-notes');
fs.mkdirSync(foreign, { recursive: true });
fs.writeFileSync(path.join(foreign, 'SKILL.md'), '# my own notes\n');
const foreignLocal = path.join(mockGitRoot, '.cursor', 'skills', 'gstack-my-rules');
fs.mkdirSync(foreignLocal, { recursive: true });
fs.writeFileSync(path.join(foreignLocal, 'SKILL.md'), '# hand-written\n');
const result = spawnSync('bash', [UNINSTALL, '--force'], {
stdio: 'pipe',
timeout: 30_000,
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);
expect(fs.existsSync(foreign)).toBe(true);
expect(fs.existsSync(foreignLocal)).toBe(true);
const stderr = result.stderr.toString();
expect(stderr).toContain('left in place');
expect(stderr).toContain('gstack-fork-notes');
expect(stderr).toContain('gstack-my-rules');
});
});
});
// ----------------------------------------------------------------------
// Hook-cleanup ordering (phantom-hooks fix). Pre-v1.67.2, the settings
// cleanup ran AFTER `rm -rf $CLAUDE_SKILLS/gstack` — and SETTINGS_HOOK
// resolves via $(dirname "$0") INSIDE that root, so a real global uninstall
// (running the installed copy) silently orphaned every hook. Prior tests
// masked this by running the uninstaller from the repo checkout. This test
// runs the INSTALLED copy.
// ----------------------------------------------------------------------
describe('hook cleanup runs before the install root is deleted', () => {
test('uninstall executed FROM the install root still removes hook entries', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-order-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
// The installed copies — the uninstaller under test IS the one inside
// the root it deletes.
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const src = path.join(ROOT, 'bin', b);
const dst = path.join(installBin, b);
fs.copyFileSync(src, dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
PostToolUse: [{
matcher: '(AskUserQuestion|mcp__.*__AskUserQuestion)',
_gstack_source: 'auq-error-fallback',
hooks: [{ type: 'command', command: '/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', timeout: 5 }],
}],
Stop: [{
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook`, timeout: 5 }],
}],
PreCompact: [{ hooks: [{ type: 'command', command: '/Users/me/my-own-hook' }] }],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
timeout: 30_000,
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
},
cwd: tmp,
});
expect(result.status).toBe(0);
// Install root gone…
expect(fs.existsSync(installRoot)).toBe(false);
// …and the hook entries were still cleaned (cleanup ran BEFORE deletion).
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks?.PostToolUse).toBeUndefined();
expect(s.hooks?.Stop).toBeUndefined();
// The user's own hook survives the sweep.
expect(s.hooks?.PreCompact?.[0]?.hooks?.[0]?.command).toBe('/Users/me/my-own-hook');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: the copied uninstaller spawns several bun -e children; on a loaded
// box their cold starts blow bun's default 5s per-test timeout.
}, 30000);
});
describe('the Memorable bridge hook is removed by name and the kept config is left honest', () => {
test('a tag-stripped memorable entry is removed, reported, and memorable_recall is set off under --keep-state', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
UserPromptSubmit: [
{ hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/memorable-user-prompt-hook`, timeout: 5 }] },
{ hooks: [{ type: 'command', command: '"/Users/me/.memorable/bin/memorable" hook user-prompt' }] },
],
},
}, null, 2));
const stateRoot = path.join(mockHome, '.gstack');
fs.mkdirSync(stateRoot, { recursive: true });
const env = { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: stateRoot };
spawnSync('bash', [path.join(installBin, 'gstack-config'), 'set', 'memorable_recall', 'on'], { env, timeout: 20_000 });
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe', timeout: 30_000, env, cwd: tmp, encoding: 'utf-8',
});
expect(result.status).toBe(0);
expect(result.stdout).toContain('Memorable UserPromptSubmit hook');
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
// gstack's entry gone, the vendor's own entry untouched
expect(s.hooks.UserPromptSubmit).toHaveLength(1);
expect(s.hooks.UserPromptSubmit[0].hooks[0].command).toContain('.memorable/bin/memorable');
expect(fs.readFileSync(path.join(stateRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 30000);
});
describe('the Memorable arm stays quiet when nothing of its is registered', () => {
test('no memorable entry -> no "Memorable UserPromptSubmit hook" in the summary, exit 0', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-none-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/Users/me/my-own-hook' }] }] } }, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe', timeout: 30_000, encoding: 'utf-8', cwd: tmp,
env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: path.join(mockHome, '.gstack') },
});
expect(result.status).toBe(0);
expect(result.stdout).not.toContain('Memorable UserPromptSubmit hook');
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
expect(s.hooks.UserPromptSubmit[0].hooks[0].command).toBe('/Users/me/my-own-hook');
// the consent flip only runs when the key reads on: no config file is created just to say off
expect(fs.existsSync(path.join(mockHome, '.gstack', 'config.yaml'))).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}, 30000);
});
describe('hook cleanup under lock contention is loud, never silent (review-army)', () => {
test('a held foreign lock during uninstall surfaces the give-up warning on stderr', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({
hooks: {
Stop: [{
_gstack_source: 'gstack-timeline-stop',
hooks: [{ type: 'command', command: `${installRoot}/hosts/claude/hooks/timeline-stop-hook` }],
}],
},
}, null, 2));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
// A fresh foreign lock: pre-fix, every cleanup call silently skipped and
// uninstall reported clean while orphaning the hooks forever.
fs.mkdirSync(`${settingsFile}.lock`);
fs.writeFileSync(path.join(`${settingsFile}.lock`, 'owner'), 'another-live-process');
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe',
timeout: 30_000,
env: {
...process.env,
HOME: mockHome,
GSTACK_SETTINGS_FILE: settingsFile,
GSTACK_STATE_ROOT: path.join(mockHome, '.gstack'),
GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300',
},
cwd: tmp,
});
const stderr = result.stderr.toString();
const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'));
const cleaned = s.hooks?.Stop === undefined;
// Either the sweep still happened, or the user SEES why it didn't.
expect(cleaned || /could not acquire lock/.test(stderr)).toBe(true);
expect(/could not acquire lock/.test(stderr)).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
// 30s: several settings-hook calls each wait out the 300ms lock give-up,
// and their bun -e cold starts stack up under load — the default 5s
// per-test budget is too tight on a busy box.
}, 30000);
});
describe('the consent key never outlives the hook, even when the config lives outside the removed state dir', () => {
test('full uninstall (no --keep-state) with GSTACK_STATE_ROOT elsewhere: memorable_recall flips off there', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-root-'));
try {
const mockHome = path.join(tmp, 'home');
const otherRoot = path.join(tmp, 'elsewhere');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
fs.mkdirSync(otherRoot, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config', 'gstack-state-root.sh']) {
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const settingsFile = path.join(mockHome, '.claude', 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({ hooks: {} }));
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const env = { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: otherRoot };
expect(spawnSync('bash', [path.join(installBin, 'gstack-config'), 'set', 'memorable_recall', 'on'], { env, timeout: 20_000 }).status).toBe(0);
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force'], {
stdio: 'pipe', timeout: 30_000, encoding: 'utf-8', cwd: tmp, env,
});
expect(result.status).toBe(0);
expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); // the default state dir went
expect(fs.readFileSync(path.join(otherRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/); // the real config did not keep consent
expect(result.stdout).toContain('memorable_recall consent (set off)');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe('the consent flip does not depend on the hook manager being present', () => {
test('gstack-settings-hook missing from the install: memorable_recall still goes off', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-nohook-'));
try {
const mockHome = path.join(tmp, 'home');
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
const installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) { // no settings hook, no session-update
const dst = path.join(installBin, b);
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
fs.chmodSync(dst, 0o755);
}
const stateRoot = path.join(mockHome, '.gstack');
fs.mkdirSync(stateRoot, { recursive: true });
const env = { ...process.env, HOME: mockHome, GSTACK_STATE_ROOT: stateRoot };
expect(spawnSync('bash', [path.join(installBin, 'gstack-config'), 'set', 'memorable_recall', 'on'], { env, timeout: 20_000 }).status).toBe(0);
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
stdio: 'pipe', timeout: 30_000, encoding: 'utf-8', cwd: tmp, env,
});
expect(result.status).toBe(0);
expect(fs.readFileSync(path.join(stateRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe('state-root safety: only the default root is deleted (docs/state-root.md)', () => {
let tmp: string, mockHome: string, installBin: string, installRoot: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-root-'));
mockHome = path.join(tmp, 'home');
installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
installBin = path.join(installRoot, 'bin');
fs.mkdirSync(installBin, { recursive: true });
for (const b of ['gstack-uninstall', 'gstack-config', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', b), path.join(installBin, b));
fs.chmodSync(path.join(installBin, b), 0o755);
}
});
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
function uninstall(args: string[], env: Record<string, string>, input?: string) {
return spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), ...args], {
cwd: tmp, input, encoding: 'utf-8', timeout: 30_000,
env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: path.join(mockHome, 'settings.json'),
GSTACK_HOME: '', GSTACK_STATE_ROOT: '', GSTACK_STATE_DIR: '', CLAUDE_PLUGIN_DATA: '', ...env } as Record<string, string>,
});
}
for (const mode of ['--force', 'interactive'] as const) {
test(`a custom GSTACK_HOME root is left in place with the removal command (${mode}); $HOME/.gstack goes`, () => {
const custom = path.join(tmp, 'custom-state');
fs.mkdirSync(path.join(custom, 'projects'), { recursive: true });
fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true });
const r = mode === '--force' ? uninstall(['--force'], { GSTACK_HOME: custom }) : uninstall([], { GSTACK_HOME: custom }, 'y\n');
expect(r.status).toBe(0);
expect(r.stderr).toContain(`left in place: ${custom} (selected by GSTACK_HOME=${custom})`);
expect(r.stderr).toContain(`fix: after checking it, remove it with rm -rf -- '${custom}'`);
expect(r.stderr).toContain('docs/state-root.md');
expect(fs.existsSync(path.join(custom, 'projects'))).toBe(true);
expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false);
if (mode === 'interactive') expect(r.stdout).toContain(`(kept) ${custom}`);
});
}
for (const [label, target] of [['$HOME', () => mockHome], ['/', () => '/'], ['the gstack checkout', () => installRoot]] as const) {
test(`$HOME/.gstack symlinked to ${label} is refused with exit 2 and a fix line`, () => {
fs.symlinkSync(target(), path.join(mockHome, '.gstack'));
const r = uninstall(['--force'], {});
expect(r.status).toBe(2);
expect(r.stderr).toContain(`gstack-uninstall: refusing to delete ${path.join(mockHome, '.gstack')}: `);
expect(r.stderr).toMatch(/^fix: .*docs\/state-root\.md/m);
expect(fs.existsSync(path.join(installBin, 'gstack-uninstall'))).toBe(true);
expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true);
});
}
test('--keep-state deletes nothing and never refuses', () => {
const custom = path.join(tmp, 'custom-state');
fs.mkdirSync(custom, { recursive: true });
fs.symlinkSync(mockHome, path.join(mockHome, '.gstack'));
const r = uninstall(['--force', '--keep-state'], { GSTACK_HOME: custom });
expect(r.status).toBe(0);
expect(r.stderr).not.toContain('left in place:');
expect(fs.existsSync(custom)).toBe(true);
expect(fs.lstatSync(path.join(mockHome, '.gstack')).isSymbolicLink()).toBe(true);
});
});