diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 6195c7b6..1fc08ea9 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -29,6 +29,26 @@ REPO_ROOT=$(git rev-parse --show-toplevel) CLAUDE_MD="$REPO_ROOT/CLAUDE.md" GENERATED=() +# ── Migrate vendored copy if present ────────────────────────── + +if [ -d "$REPO_ROOT/.claude/skills/gstack" ] && [ ! -L "$REPO_ROOT/.claude/skills/gstack" ]; then + if [ -f "$REPO_ROOT/.claude/skills/gstack/VERSION" ] || [ -d "$REPO_ROOT/.claude/skills/gstack/.git" ]; then + echo " Found vendored gstack copy at $REPO_ROOT/.claude/skills/gstack" + echo " Team mode uses the global install — removing vendored copy..." + ( cd "$REPO_ROOT" && git rm -r --cached .claude/skills/gstack/ 2>/dev/null ) || true + if [ -f "$REPO_ROOT/.gitignore" ]; then + if ! grep -qF '.claude/skills/gstack/' "$REPO_ROOT/.gitignore" 2>/dev/null; then + echo '.claude/skills/gstack/' >> "$REPO_ROOT/.gitignore" + fi + else + echo '.claude/skills/gstack/' > "$REPO_ROOT/.gitignore" + fi + rm -rf "$REPO_ROOT/.claude/skills/gstack" + GENERATED+=(".gitignore") + echo " Removed vendored copy and added .claude/skills/gstack/ to .gitignore" + fi +fi + # ── CLAUDE.md snippet ────────────────────────────────────────── if [ "$MODE" = "optional" ]; then diff --git a/gstack-upgrade/SKILL.md b/gstack-upgrade/SKILL.md index 12c3840a..07fe7519 100644 --- a/gstack-upgrade/SKILL.md +++ b/gstack-upgrade/SKILL.md @@ -137,9 +137,9 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` -### Step 4.5: Sync local vendored copy +### Step 4.5: Handle local vendored copy -Use the install directory from Step 2. Check if there's also a local vendored copy that needs updating: +Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: ```bash _ROOT=$(git rev-parse --show-toplevel 2>/dev/null) @@ -151,10 +151,24 @@ if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" fi fi +_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$_TEAM_MODE" ``` -If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. + +```bash +cd "$_ROOT" +git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true +if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then + echo '.claude/skills/gstack/' >> .gitignore +fi +rm -rf "$LOCAL_GSTACK" +``` +Tell user: "Removed vendored copy at `$LOCAL_GSTACK` (team mode active — global install is the source of truth). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): ```bash mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" @@ -243,11 +257,13 @@ Use the output to determine if an upgrade is available. 3. If no output (primary is up to date): check for a stale local vendored copy. -Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`). +Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`) and team mode status (`TEAM_MODE`). **If `LOCAL_GSTACK` is empty** (no local vendored copy): tell the user "You're already on the latest version (v{version})." -**If `LOCAL_GSTACK` is non-empty**, compare versions: +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy using the Step 4.5 team-mode removal bash block above. Tell user: "Global v{version} is up to date. Removed stale vendored copy (team mode active). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`**, compare versions: ```bash PRIMARY_VER=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown") LOCAL_VER=$(cat "$LOCAL_GSTACK/VERSION" 2>/dev/null || echo "unknown") diff --git a/gstack-upgrade/SKILL.md.tmpl b/gstack-upgrade/SKILL.md.tmpl index 9e85478a..af4bcd23 100644 --- a/gstack-upgrade/SKILL.md.tmpl +++ b/gstack-upgrade/SKILL.md.tmpl @@ -139,9 +139,9 @@ cd "$INSTALL_DIR" && ./setup rm -rf "$INSTALL_DIR.bak" "$TMP_DIR" ``` -### Step 4.5: Sync local vendored copy +### Step 4.5: Handle local vendored copy -Use the install directory from Step 2. Check if there's also a local vendored copy that needs updating: +Use the install directory from Step 2. Check if there's also a local vendored copy, and whether team mode is active: ```bash _ROOT=$(git rev-parse --show-toplevel 2>/dev/null) @@ -153,10 +153,24 @@ if [ -n "$_ROOT" ] && [ -d "$_ROOT/.claude/skills/gstack" ]; then LOCAL_GSTACK="$_ROOT/.claude/skills/gstack" fi fi +_TEAM_MODE=$(~/.claude/skills/gstack/bin/gstack-config get team_mode 2>/dev/null || echo "false") echo "LOCAL_GSTACK=$LOCAL_GSTACK" +echo "TEAM_MODE=$_TEAM_MODE" ``` -If `LOCAL_GSTACK` is non-empty, update it by copying from the freshly-upgraded primary install (same approach as README vendored install): +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy. Team mode uses the global install as the single source of truth. + +```bash +cd "$_ROOT" +git rm -r --cached .claude/skills/gstack/ 2>/dev/null || true +if ! grep -qF '.claude/skills/gstack/' .gitignore 2>/dev/null; then + echo '.claude/skills/gstack/' >> .gitignore +fi +rm -rf "$LOCAL_GSTACK" +``` +Tell user: "Removed vendored copy at `$LOCAL_GSTACK` (team mode active — global install is the source of truth). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`:** Update it by copying from the freshly-upgraded primary install (same approach as README vendored install): ```bash mv "$LOCAL_GSTACK" "$LOCAL_GSTACK.bak" cp -Rf "$INSTALL_DIR" "$LOCAL_GSTACK" @@ -245,11 +259,13 @@ Use the output to determine if an upgrade is available. 3. If no output (primary is up to date): check for a stale local vendored copy. -Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`). +Run the Step 2 bash block above to detect the primary install type and directory (`INSTALL_TYPE` and `INSTALL_DIR`). Then run the Step 4.5 detection bash block above to check for a local vendored copy (`LOCAL_GSTACK`) and team mode status (`TEAM_MODE`). **If `LOCAL_GSTACK` is empty** (no local vendored copy): tell the user "You're already on the latest version (v{version})." -**If `LOCAL_GSTACK` is non-empty**, compare versions: +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is `true`:** Remove the vendored copy using the Step 4.5 team-mode removal bash block above. Tell user: "Global v{version} is up to date. Removed stale vendored copy (team mode active). Commit the `.gitignore` change when ready." + +**If `LOCAL_GSTACK` is non-empty AND `TEAM_MODE` is NOT `true`**, compare versions: ```bash PRIMARY_VER=$(cat "$INSTALL_DIR/VERSION" 2>/dev/null || echo "unknown") LOCAL_VER=$(cat "$LOCAL_GSTACK/VERSION" 2>/dev/null || echo "unknown") diff --git a/setup b/setup index 65da9496..ca97764d 100755 --- a/setup +++ b/setup @@ -785,6 +785,7 @@ HOOK_CMD="$SOURCE_GSTACK_DIR/bin/gstack-session-update" if [ "$TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade true 2>/dev/null || true + "$GSTACK_CONFIG" set team_mode true 2>/dev/null || true # Register SessionStart hook in Claude Code settings if [ -x "$SETTINGS_HOOK" ]; then @@ -802,6 +803,7 @@ fi if [ "$NO_TEAM_MODE" -eq 1 ]; then "$GSTACK_CONFIG" set auto_upgrade false 2>/dev/null || true + "$GSTACK_CONFIG" set team_mode false 2>/dev/null || true # Remove SessionStart hook from Claude Code settings if [ -x "$SETTINGS_HOOK" ]; then diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index e2b030fb..660f6687 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -257,6 +257,69 @@ describe('gstack-team-init', () => { const matches = claude.match(/## gstack/g); expect(matches).toHaveLength(1); }); + + test('removes vendored copy when present', () => { + // Create a fake vendored gstack with VERSION file + const vendoredDir = path.join(tmpDir, '.claude', 'skills', 'gstack'); + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + fs.writeFileSync(path.join(vendoredDir, 'README.md'), 'vendored'); + // Track it in git + execSync('git add .claude/skills/gstack/', { cwd: tmpDir }); + execSync('git commit -m "add vendored gstack"', { cwd: tmpDir }); + + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Found vendored gstack copy'); + expect(result.stdout).toContain('Removed vendored copy'); + // Vendored dir should be gone + expect(fs.existsSync(vendoredDir)).toBe(false); + // .gitignore should have the entry + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + expect(gitignore).toContain('.claude/skills/gstack/'); + }); + + test('skips when no vendored copy present', () => { + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Found vendored gstack copy'); + }); + + test('skips when .claude/skills/gstack is a symlink', () => { + // Create a symlink (not a real vendored copy) + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + const targetDir = mkTmpDir(); + fs.writeFileSync(path.join(targetDir, 'VERSION'), '0.14.0.0'); + fs.symlinkSync(targetDir, path.join(skillsDir, 'gstack')); + + const result = run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Found vendored gstack copy'); + // Symlink should still exist + expect(fs.lstatSync(path.join(skillsDir, 'gstack')).isSymbolicLink()).toBe(true); + fs.rmSync(targetDir, { recursive: true, force: true }); + }); + + test('does not duplicate .gitignore entry on re-run', () => { + // Create vendored copy + const vendoredDir = path.join(tmpDir, '.claude', 'skills', 'gstack'); + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + execSync('git add .claude/skills/gstack/', { cwd: tmpDir }); + execSync('git commit -m "add vendored"', { cwd: tmpDir }); + + run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + + // Re-create vendored dir to simulate re-run scenario + fs.mkdirSync(vendoredDir, { recursive: true }); + fs.writeFileSync(path.join(vendoredDir, 'VERSION'), '0.14.0.0'); + run(`${TEAM_INIT} optional`, { cwd: tmpDir }); + + const gitignore = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); + const matches = gitignore.match(/\.claude\/skills\/gstack\//g); + expect(matches).toHaveLength(1); + }); }); describe('setup --team / --no-team / -q', () => {