From 6afd8cc2591683a1671d139c5859fe5c06ec77d2 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 16:43:03 +0800 Subject: [PATCH] migration: v0.18.5.0 removes stale /checkpoint install with ownership guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gstack-upgrade/migrations/v0.18.5.0.sh | 104 ++++++++++++++ test/migration-checkpoint-ownership.test.ts | 147 ++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100755 gstack-upgrade/migrations/v0.18.5.0.sh create mode 100644 test/migration-checkpoint-ownership.test.ts diff --git a/gstack-upgrade/migrations/v0.18.5.0.sh b/gstack-upgrade/migrations/v0.18.5.0.sh new file mode 100755 index 00000000..21199f01 --- /dev/null +++ b/gstack-upgrade/migrations/v0.18.5.0.sh @@ -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 diff --git a/test/migration-checkpoint-ownership.test.ts b/test/migration-checkpoint-ownership.test.ts new file mode 100644 index 00000000..2ae81600 --- /dev/null +++ b/test/migration-checkpoint-ownership.test.ts @@ -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'); + }); +});