mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
migration: v0.18.5.0 removes stale /checkpoint install with ownership guard
gstack-upgrade/migrations/v0.18.5.0.sh removes the stale on-disk
/checkpoint install so Claude Code's native /rewind alias is no longer
shadowed. Ownership guard inspects the directory itself (not just
SKILL.md) and handles 3 install shapes:
1. ~/.claude/skills/checkpoint is a directory symlink whose canonical
path resolves inside ~/.claude/skills/gstack/ → remove.
2. ~/.claude/skills/checkpoint is a directory containing exactly one
file SKILL.md that's a symlink into gstack → remove (gstack's
prefix-install shape).
3. Anything else (user's own regular file/dir, or a symlink pointing
elsewhere) → leave alone, print a one-line notice.
Also removes ~/.claude/skills/gstack/checkpoint/ unconditionally (gstack
owns that dir).
Portable realpath: `realpath` with python3 fallback for macOS BSD which
lacks readlink -f. Idempotent: missing paths are no-ops.
test/migration-checkpoint-ownership.test.ts ships 7 scenarios covering
all 3 install shapes + idempotency + no-op-when-gstack-not-installed +
SKILL.md-symlink-outside-gstack. Critical safety net for a migration
that mutates user state. Free tier, ~85ms.
This commit is contained in:
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
# Migration: v0.18.5.0 — Remove stale /checkpoint skill installs
|
||||
#
|
||||
# Claude Code ships /checkpoint as a native alias for /rewind, which was
|
||||
# shadowing the gstack checkpoint skill. The skill has been split into
|
||||
# /context-save + /context-restore. This migration removes the old on-disk
|
||||
# install so Claude Code's native /checkpoint is no longer shadowed.
|
||||
#
|
||||
# Ownership guard: the script only removes the install IF it owns it —
|
||||
# i.e., the directory or its SKILL.md is a symlink resolving inside
|
||||
# ~/.claude/skills/gstack/. A user's own /checkpoint skill (regular file,
|
||||
# or symlink pointing elsewhere) is preserved.
|
||||
#
|
||||
# Three supported install shapes to handle:
|
||||
# 1. ~/.claude/skills/checkpoint is a directory symlink into gstack.
|
||||
# 2. ~/.claude/skills/checkpoint is a regular directory whose ONLY file
|
||||
# is a SKILL.md symlink into gstack (gstack's prefix-install shape).
|
||||
# 3. Anything else → leave alone, print notice.
|
||||
#
|
||||
# Idempotent: missing paths are no-ops.
|
||||
set -euo pipefail
|
||||
|
||||
SKILLS_DIR="${HOME}/.claude/skills"
|
||||
OLD_TOPLEVEL="${SKILLS_DIR}/checkpoint"
|
||||
OLD_NAMESPACED="${SKILLS_DIR}/gstack/checkpoint"
|
||||
GSTACK_ROOT_REAL=""
|
||||
|
||||
# Resolve the canonical path of the gstack skills root. If gstack isn't
|
||||
# installed here, there's nothing to migrate.
|
||||
if [ -d "${SKILLS_DIR}/gstack" ]; then
|
||||
# Portable realpath: macOS BSD `readlink` lacks -f. Fall back to python3.
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
GSTACK_ROOT_REAL=$(realpath "${SKILLS_DIR}/gstack" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$GSTACK_ROOT_REAL" ]; then
|
||||
GSTACK_ROOT_REAL=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "${SKILLS_DIR}/gstack" 2>/dev/null || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: canonical-path a target (symlink-safe). Prints the resolved path.
|
||||
resolve_real() {
|
||||
local target="$1"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
realpath "$target" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$target" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Helper: does $1 (canonical path) live inside $2 (canonical path)?
|
||||
path_inside() {
|
||||
local inner="$1"
|
||||
local outer="$2"
|
||||
[ -n "$inner" ] && [ -n "$outer" ] || return 1
|
||||
case "$inner" in
|
||||
"$outer"|"$outer"/*) return 0;;
|
||||
*) return 1;;
|
||||
esac
|
||||
}
|
||||
|
||||
removed_any=0
|
||||
|
||||
# --- Shape 1: top-level ~/.claude/skills/checkpoint
|
||||
if [ -L "$OLD_TOPLEVEL" ]; then
|
||||
# Directory symlink (or file symlink). Canonicalize and check ownership.
|
||||
target_real=$(resolve_real "$OLD_TOPLEVEL")
|
||||
if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
|
||||
rm "$OLD_TOPLEVEL"
|
||||
echo " [v0.18.5.0] Removed stale /checkpoint symlink (was shadowing Claude Code's /rewind alias)."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v0.18.5.0] Leaving $OLD_TOPLEVEL alone — symlink target is outside gstack."
|
||||
fi
|
||||
elif [ -d "$OLD_TOPLEVEL" ]; then
|
||||
# Regular directory. Only remove if it contains exactly one file named
|
||||
# SKILL.md that's a symlink into gstack (gstack's prefix-install shape).
|
||||
entries=$(ls -A "$OLD_TOPLEVEL" 2>/dev/null)
|
||||
if [ "$entries" = "SKILL.md" ] && [ -L "$OLD_TOPLEVEL/SKILL.md" ]; then
|
||||
target_real=$(resolve_real "$OLD_TOPLEVEL/SKILL.md")
|
||||
if [ -n "$GSTACK_ROOT_REAL" ] && path_inside "$target_real" "$GSTACK_ROOT_REAL"; then
|
||||
rm -r "$OLD_TOPLEVEL"
|
||||
echo " [v0.18.5.0] Removed stale /checkpoint install directory (gstack prefix-mode)."
|
||||
removed_any=1
|
||||
else
|
||||
echo " [v0.18.5.0] Leaving $OLD_TOPLEVEL alone — SKILL.md symlink target is outside gstack."
|
||||
fi
|
||||
else
|
||||
echo " [v0.18.5.0] Leaving $OLD_TOPLEVEL alone — not a gstack-owned install (has custom content)."
|
||||
fi
|
||||
fi
|
||||
# Missing → no-op (idempotency).
|
||||
|
||||
# --- Shape 2: ~/.claude/skills/gstack/checkpoint/ (gstack owns this dir unconditionally)
|
||||
if [ -d "$OLD_NAMESPACED" ] || [ -L "$OLD_NAMESPACED" ]; then
|
||||
rm -rf "$OLD_NAMESPACED"
|
||||
echo " [v0.18.5.0] Removed stale ~/.claude/skills/gstack/checkpoint/ (replaced by context-save + context-restore)."
|
||||
removed_any=1
|
||||
fi
|
||||
|
||||
if [ "$removed_any" = "1" ]; then
|
||||
echo " [v0.18.5.0] /checkpoint is now Claude Code's native /rewind alias. Use /context-save to save state and /context-restore to resume."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,147 @@
|
||||
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 MIGRATION = path.join(ROOT, 'gstack-upgrade', 'migrations', 'v0.18.5.0.sh');
|
||||
|
||||
function runMigration(tmpHome: string): { exitCode: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync('bash', [MIGRATION], {
|
||||
env: { ...process.env, HOME: tmpHome },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10_000,
|
||||
});
|
||||
return {
|
||||
exitCode: result.status ?? 1,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function setupFakeGstackRoot(tmpHome: string): string {
|
||||
// A real target that the gstack symlink can resolve into.
|
||||
const gstackDir = path.join(tmpHome, '.claude', 'skills', 'gstack');
|
||||
fs.mkdirSync(path.join(gstackDir, 'checkpoint'), { recursive: true });
|
||||
fs.writeFileSync(path.join(gstackDir, 'checkpoint', 'SKILL.md'), '# fake gstack checkpoint\n');
|
||||
return gstackDir;
|
||||
}
|
||||
|
||||
describe('migration v0.18.5.0 — checkpoint ownership guard', () => {
|
||||
let tmpHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-migration-ownership-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
test('scenario A: directory symlink into gstack → removed', () => {
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
const skillsDir = path.join(tmpHome, '.claude', 'skills');
|
||||
const gstackCheckpoint = path.join(skillsDir, 'gstack', 'checkpoint');
|
||||
const topLevel = path.join(skillsDir, 'checkpoint');
|
||||
fs.symlinkSync(gstackCheckpoint, topLevel);
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fs.existsSync(topLevel)).toBe(false);
|
||||
// Also removes the gstack-owned inner copy (Shape 2 cleanup).
|
||||
expect(fs.existsSync(gstackCheckpoint)).toBe(false);
|
||||
expect(result.stdout).toContain('Removed stale /checkpoint symlink');
|
||||
});
|
||||
|
||||
test('scenario B: directory with SKILL.md symlinked into gstack → removed', () => {
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
const skillsDir = path.join(tmpHome, '.claude', 'skills');
|
||||
const gstackSKILL = path.join(skillsDir, 'gstack', 'checkpoint', 'SKILL.md');
|
||||
const topLevel = path.join(skillsDir, 'checkpoint');
|
||||
fs.mkdirSync(topLevel, { recursive: true });
|
||||
fs.symlinkSync(gstackSKILL, path.join(topLevel, 'SKILL.md'));
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fs.existsSync(topLevel)).toBe(false);
|
||||
expect(result.stdout).toContain('Removed stale /checkpoint install directory');
|
||||
});
|
||||
|
||||
test('scenario C: user-owned regular directory with custom content → preserved', () => {
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
const skillsDir = path.join(tmpHome, '.claude', 'skills');
|
||||
const topLevel = path.join(skillsDir, 'checkpoint');
|
||||
fs.mkdirSync(topLevel, { recursive: true });
|
||||
// User's own custom skill: regular file, not a symlink.
|
||||
fs.writeFileSync(path.join(topLevel, 'SKILL.md'), '# my custom /checkpoint\n');
|
||||
fs.writeFileSync(path.join(topLevel, 'extra.txt'), 'user content\n');
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fs.existsSync(topLevel)).toBe(true);
|
||||
expect(fs.existsSync(path.join(topLevel, 'SKILL.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(topLevel, 'extra.txt'))).toBe(true);
|
||||
expect(result.stdout).toContain('Leaving');
|
||||
expect(result.stdout).toContain('not a gstack-owned install');
|
||||
});
|
||||
|
||||
test('scenario D: symlink pointing outside gstack → preserved', () => {
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
const skillsDir = path.join(tmpHome, '.claude', 'skills');
|
||||
const topLevel = path.join(skillsDir, 'checkpoint');
|
||||
// User's own skill elsewhere on the filesystem.
|
||||
const userSkillDir = path.join(tmpHome, 'my-own-skill');
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# my custom /checkpoint\n');
|
||||
fs.symlinkSync(userSkillDir, topLevel);
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fs.existsSync(topLevel)).toBe(true);
|
||||
// The user's underlying dir is untouched.
|
||||
expect(fs.existsSync(path.join(userSkillDir, 'SKILL.md'))).toBe(true);
|
||||
expect(result.stdout).toContain('Leaving');
|
||||
expect(result.stdout).toContain('outside gstack');
|
||||
});
|
||||
|
||||
test('scenario E: nothing to do → no-op exit 0 (idempotent)', () => {
|
||||
// No checkpoint install at all. First run: nothing removed.
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
// Delete the inner gstack/checkpoint to simulate post-upgrade state.
|
||||
fs.rmSync(path.join(tmpHome, '.claude', 'skills', 'gstack', 'checkpoint'), { recursive: true, force: true });
|
||||
|
||||
const result1 = runMigration(tmpHome);
|
||||
expect(result1.exitCode).toBe(0);
|
||||
|
||||
// Second run: still exit 0, still no-op.
|
||||
const result2 = runMigration(tmpHome);
|
||||
expect(result2.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('scenario F: gstack not installed → no-op exit 0', () => {
|
||||
// No ~/.claude/skills/gstack/ at all. Also no checkpoint install.
|
||||
fs.mkdirSync(path.join(tmpHome, '.claude', 'skills'), { recursive: true });
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
test('scenario G: SKILL.md is a symlink pointing outside gstack → preserved', () => {
|
||||
setupFakeGstackRoot(tmpHome);
|
||||
const skillsDir = path.join(tmpHome, '.claude', 'skills');
|
||||
const topLevel = path.join(skillsDir, 'checkpoint');
|
||||
fs.mkdirSync(topLevel, { recursive: true });
|
||||
// A directory containing SKILL.md that's a symlink pointing outside gstack.
|
||||
const externalSkill = path.join(tmpHome, 'external', 'SKILL.md');
|
||||
fs.mkdirSync(path.dirname(externalSkill), { recursive: true });
|
||||
fs.writeFileSync(externalSkill, '# external skill\n');
|
||||
fs.symlinkSync(externalSkill, path.join(topLevel, 'SKILL.md'));
|
||||
|
||||
const result = runMigration(tmpHome);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(fs.existsSync(topLevel)).toBe(true);
|
||||
expect(fs.existsSync(path.join(topLevel, 'SKILL.md'))).toBe(true);
|
||||
expect(result.stdout).toContain('Leaving');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user