Merge remote-tracking branch 'origin/main' into garrytan/security-wave-5

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Garry Tan
2026-04-06 00:29:26 -07:00
7 changed files with 137 additions and 12 deletions
+9 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [0.15.14.0] - 2026-04-06
## [0.15.15.0] - 2026-04-06
Community security wave: 8 PRs from 4 contributors, every fix credited as co-author.
@@ -36,6 +36,14 @@ Community security wave: 8 PRs from 4 contributors, every fix credited as co-aut
- State load filters cookies from localhost, .internal, and metadata domains.
- Telemetry sync logs upsert errors from installation tracking.
## [0.15.14.0] - 2026-04-05
### Fixed
- **`gstack-team-init` now detects and removes vendored gstack copies.** When you run `gstack-team-init` inside a repo that has gstack vendored at `.claude/skills/gstack/`, it automatically removes the vendored copy, untracks it from git, and adds it to `.gitignore`. No more stale vendored copies shadowing the global install.
- **`/gstack-upgrade` respects team mode.** Step 4.5 now checks the `team_mode` config. In team mode, vendored copies are removed instead of synced, since the global install is the single source of truth.
- **`team_mode` config key.** `./setup --team` and `./setup --no-team` now set a dedicated `team_mode` config key so the upgrade skill can reliably distinguish team mode from just having auto-upgrade enabled.
## [0.15.13.0] - 2026-04-04 — Team Mode
Teams can now keep every developer on the same gstack version automatically. No more vendoring 342 files into your repo. No more version drift across branches. No more "who upgraded gstack last?" Slack threads. One command, every developer is current.
+1 -1
View File
@@ -1 +1 @@
0.15.14.0
0.15.15.0
+20
View File
@@ -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
+21 -5
View File
@@ -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")
+21 -5
View File
@@ -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")
+2
View File
@@ -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
+63
View File
@@ -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', () => {