From 3df8a77b007f9f1e33f9fb0d93d600fdaf167da3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 23 Mar 2026 15:49:40 -0700 Subject: [PATCH] chore: stage pre-existing community tier changes Community tier auth, backup/restore, and test updates that were already on this branch before the telemetry sprint. Includes updated telemetry prompt test to match 3-option community tier flow. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/gstack-auth | 4 +- bin/gstack-auth-refresh | 2 +- bin/gstack-community-backup | 82 +++++------- bin/gstack-community-restore | 14 ++- package.json | 2 +- supabase/migrations/002_community_tier.sql | 2 +- test/community-tier.test.ts | 138 +++++++++++++++++++++ test/gen-skill-docs.test.ts | 2 +- 8 files changed, 189 insertions(+), 57 deletions(-) create mode 100644 test/community-tier.test.ts diff --git a/bin/gstack-auth b/bin/gstack-auth index 99693a5a..1450dcc2 100755 --- a/bin/gstack-auth +++ b/bin/gstack-auth @@ -56,11 +56,11 @@ TOKJSON chmod 600 "$AUTH_FILE" } -# ─── Helper: extract JSON field (portable, no jq dependency) ─ +# ─── Helper: extract JSON field (using jq) ──────────────────── json_field() { local json="$1" local field="$2" - echo "$json" | grep -o "\"${field}\":[^,}]*" | head -1 | sed "s/\"${field}\"://;s/\"//g;s/ //g" + echo "$json" | jq -r ".${field}" 2>/dev/null | sed 's/null//' } # ─── Subcommand: status ───────────────────────────────────── diff --git a/bin/gstack-auth-refresh b/bin/gstack-auth-refresh index 010d2908..b60a6d86 100755 --- a/bin/gstack-auth-refresh +++ b/bin/gstack-auth-refresh @@ -29,7 +29,7 @@ AUTH_URL="${SUPABASE_URL}/auth/v1" json_field() { local json="$1" local field="$2" - echo "$json" | grep -o "\"${field}\":[^,}]*" | head -1 | sed "s/\"${field}\"://;s/\"//g;s/ //g" + echo "$json" | jq -r ".${field}" 2>/dev/null | sed 's/null//' } # ─── Check auth file exists ───────────────────────────────── diff --git a/bin/gstack-community-backup b/bin/gstack-community-backup index ba87ce9b..0651938e 100755 --- a/bin/gstack-community-backup +++ b/bin/gstack-community-backup @@ -56,16 +56,9 @@ EMAIL="$(echo "$AUTH_JSON" | grep -o '"email":"[^"]*"' | head -1 | sed 's/"email # ─── Build config snapshot ─────────────────────────────────── CONFIG_SNAPSHOT="{}" if [ -f "$STATE_DIR/config.yaml" ]; then - # Convert YAML-like config to JSON - CONFIG_SNAPSHOT="{" - FIRST=true - while IFS=': ' read -r KEY VALUE; do - [ -z "$KEY" ] && continue - [ -z "$VALUE" ] && continue - if [ "$FIRST" = "true" ]; then FIRST=false; else CONFIG_SNAPSHOT="$CONFIG_SNAPSHOT,"; fi - CONFIG_SNAPSHOT="$CONFIG_SNAPSHOT\"$KEY\":\"$VALUE\"" - done < "$STATE_DIR/config.yaml" - CONFIG_SNAPSHOT="$CONFIG_SNAPSHOT}" + # Convert YAML-like config to JSON safely using jq + CONFIG_SNAPSHOT="$(grep -v '^#' "$STATE_DIR/config.yaml" | grep ':' | \ + jq -R 'split(": ") | {(.[0]): .[1]}' | jq -s 'add' || echo "{}")" fi # ─── Build analytics summary ──────────────────────────────── @@ -73,23 +66,18 @@ fi ANALYTICS_SNAPSHOT="{\"skills\":{},\"recent_events\":[]}" if [ -f "$JSONL_FILE" ]; then # Count per-skill totals - SKILL_COUNTS="$(grep -o '"skill":"[^"]*"' "$JSONL_FILE" 2>/dev/null | awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -20)" - - SKILLS_JSON="{" - FIRST=true - while read -r COUNT SKILL; do - [ -z "$SKILL" ] && continue - if [ "$FIRST" = "true" ]; then FIRST=false; else SKILLS_JSON="$SKILLS_JSON,"; fi - SKILLS_JSON="$SKILLS_JSON\"$SKILL\":{\"total_runs\":$COUNT}" - done <<< "$SKILL_COUNTS" - SKILLS_JSON="$SKILLS_JSON}" + SKILL_COUNTS_JSON="$(grep -o '"skill":"[^"]*"' "$JSONL_FILE" 2>/dev/null | \ + awk -F'"' '{print $4}' | sort | uniq -c | sort -rn | head -20 | \ + jq -R 'capture("\\s+(?\\d+)\\s+(?.+)") | {(.skill): {total_runs: (.count|tonumber)}}' | jq -s 'add')" # Last 100 events (strip local-only fields) - RECENT="$(tail -100 "$JSONL_FILE" 2>/dev/null | sed \ - -e 's/,"_repo_slug":"[^"]*"//g' \ - -e 's/,"_branch":"[^"]*"//g' | tr '\n' ',' | sed 's/,$//')" + RECENT_JSON="$(tail -100 "$JSONL_FILE" 2>/dev/null | \ + jq -c 'del(._repo_slug, ._branch)' | jq -s -c '.')" - ANALYTICS_SNAPSHOT="{\"skills\":${SKILLS_JSON},\"recent_events\":[${RECENT}]}" + ANALYTICS_SNAPSHOT="$(jq -n \ + --argjson skills "${SKILL_COUNTS_JSON:-{}}" \ + --argjson recent "${RECENT_JSON:-[]}" \ + '{"skills": $skills, "recent_events": $recent}')" fi # ─── Build retro history snapshot ──────────────────────────── @@ -101,16 +89,7 @@ if [ -d "$STATE_DIR" ]; then fi if [ -n "$RETRO_FILES" ]; then - RETRO_SNAPSHOT="[" - FIRST=true - while IFS= read -r RFILE; do - [ -f "$RFILE" ] || continue - CONTENT="$(cat "$RFILE" 2>/dev/null || true)" - [ -z "$CONTENT" ] && continue - if [ "$FIRST" = "true" ]; then FIRST=false; else RETRO_SNAPSHOT="$RETRO_SNAPSHOT,"; fi - RETRO_SNAPSHOT="$RETRO_SNAPSHOT$CONTENT" - done <<< "$RETRO_FILES" - RETRO_SNAPSHOT="$RETRO_SNAPSHOT]" + RETRO_SNAPSHOT="$(cat $RETRO_FILES 2>/dev/null | jq -s -c '.' || echo "[]")" fi # ─── Upsert to installations table ────────────────────────── @@ -118,20 +97,27 @@ GSTACK_VERSION="$(cat "$GSTACK_DIR/VERSION" 2>/dev/null | tr -d '[:space:]' || e OS="$(uname -s | tr '[:upper:]' '[:lower:]')" NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -# Escape JSON strings that might contain special characters -# Config and retro snapshots are already JSON, analytics too -PAYLOAD="{ - \"installation_id\": \"${USER_ID}\", - \"user_id\": \"${USER_ID}\", - \"email\": \"${EMAIL}\", - \"gstack_version\": \"${GSTACK_VERSION}\", - \"os\": \"${OS}\", - \"config_snapshot\": ${CONFIG_SNAPSHOT}, - \"analytics_snapshot\": ${ANALYTICS_SNAPSHOT}, - \"retro_history\": ${RETRO_SNAPSHOT}, - \"last_backup_at\": \"${NOW_ISO}\", - \"last_seen\": \"${NOW_ISO}\" -}" +PAYLOAD="$(jq -n \ + --arg id "$USER_ID" \ + --arg email "$EMAIL" \ + --arg version "$GSTACK_VERSION" \ + --arg os "$OS" \ + --argjson config "${CONFIG_SNAPSHOT:-{}}" \ + --argjson analytics "${ANALYTICS_SNAPSHOT:-{}}" \ + --argjson retro "${RETRO_SNAPSHOT:-[]}" \ + --arg last_backup "$NOW_ISO" \ + '{ + installation_id: $id, + user_id: $id, + email: $email, + gstack_version: $version, + os: $os, + config_snapshot: $config, + analytics_snapshot: $analytics, + retro_history: $retro, + last_backup_at: $last_backup, + last_seen: $last_backup + }')" # Upsert (POST with Prefer: resolution=merge-duplicates) HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ diff --git a/bin/gstack-community-restore b/bin/gstack-community-restore index c0c26259..b7f4e323 100755 --- a/bin/gstack-community-restore +++ b/bin/gstack-community-restore @@ -110,8 +110,8 @@ if [ -n "$ANALYTICS_DATA" ] && [ "$ANALYTICS_DATA" != "null" ] && [ "$ANALYTICS_ if [ "$DRY_RUN" = "false" ]; then mkdir -p "$ANALYTICS_DIR" # Extract recent_events array and write as JSONL - # This is a simplified restore — recent events from backup become local history - echo " Restoring recent events from backup..." + echo "$ANALYTICS_DATA" | jq -r '.recent_events[] | tojson' > "$JSONL_FILE" 2>/dev/null + echo " Restored $(wc -l < "$JSONL_FILE" | tr -d ' ') recent events from backup." fi fi echo "" @@ -123,7 +123,15 @@ RETRO_DATA="$(echo "$BACKUP" | grep -o '"retro_history":\[.*\]' | sed 's/"retro_ if [ -n "$RETRO_DATA" ] && [ "$RETRO_DATA" != "null" ] && [ "$RETRO_DATA" != "[]" ]; then echo "Retro history found in backup." if [ "$DRY_RUN" = "false" ]; then - echo " Retro history will be merged with local data." + # Merge: each retro in the array is a JSON object. Write as retro-restored-N.json + echo "$RETRO_DATA" | jq -c '.[]' | while read -r RETRO; do + [ -z "$RETRO" ] && continue + TS="$(echo "$RETRO" | jq -r .ts 2>/dev/null | tr -d ':-')" + [ -z "$TS" ] && TS="$(date +%s)" + RNAME="retro-restored-${TS}-$RANDOM.json" + echo "$RETRO" > "$STATE_DIR/$RNAME" + done + echo " Retro history merged with local data ($(echo "$RETRO_DATA" | jq 'length') entries restored)." fi echo "" fi diff --git a/package.json b/package.json index b24b5253..5d90c09a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.11.9.0", + "version": "0.11.10.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/supabase/migrations/002_community_tier.sql b/supabase/migrations/002_community_tier.sql index 3b46d847..0d6b2604 100644 --- a/supabase/migrations/002_community_tier.sql +++ b/supabase/migrations/002_community_tier.sql @@ -6,7 +6,7 @@ ALTER TABLE telemetry_events ADD COLUMN error_message TEXT; ALTER TABLE telemetry_events ADD COLUMN failed_step TEXT; -- Add columns to installations for backup + email + auth identity -ALTER TABLE installations ADD COLUMN user_id UUID; +ALTER TABLE installations ADD COLUMN user_id UUID UNIQUE; ALTER TABLE installations ADD COLUMN email TEXT; ALTER TABLE installations ADD COLUMN config_snapshot JSONB; ALTER TABLE installations ADD COLUMN analytics_snapshot JSONB; diff --git a/test/community-tier.test.ts b/test/community-tier.test.ts new file mode 100644 index 00000000..90fbce54 --- /dev/null +++ b/test/community-tier.test.ts @@ -0,0 +1,138 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { execSync } 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 BIN = path.join(ROOT, 'bin'); + +let tmpDir: string; + +function run(cmd: string, env: Record = {}): string { + try { + return execSync(cmd, { + cwd: ROOT, + env: { ...process.env, GSTACK_STATE_DIR: tmpDir, GSTACK_DIR: ROOT, ...env }, + encoding: 'utf-8', + timeout: 10000, + }).trim(); + } catch (e: any) { + return e.stdout?.toString() || e.message; + } +} + +function setConfig(key: string, value: string) { + run(`${BIN}/gstack-config set ${key} ${value}`); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-comm-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('gstack-auth', () => { + test('status shows not authenticated when no token file', () => { + const output = run(`${BIN}/gstack-auth status`); + expect(output).toContain('Not authenticated'); + }); + + test('logout removes token file', () => { + const authFile = path.join(tmpDir, 'auth-token.json'); + fs.writeFileSync(authFile, '{"access_token":"test"}'); + expect(fs.existsSync(authFile)).toBe(true); + + run(`${BIN}/gstack-auth logout`); + expect(fs.existsSync(authFile)).toBe(false); + }); +}); + +describe('gstack-auth-refresh', () => { + test('--check fails when not authenticated', () => { + // execSync throws on non-zero exit code + try { + execSync(`${BIN}/gstack-auth-refresh --check`, { + env: { ...process.env, GSTACK_STATE_DIR: tmpDir, GSTACK_DIR: ROOT } + }); + expect(false).toBe(true); // Should not reach here + } catch (e: any) { + expect(e.status).toBe(1); + } + }); + + test('--check succeeds when authenticated', () => { + const authFile = path.join(tmpDir, 'auth-token.json'); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + fs.writeFileSync(authFile, JSON.stringify({ + access_token: 'valid', + refresh_token: 'refresh', + expires_at: expiresAt, + email: 'test@example.com', + user_id: 'user-123' + })); + + const status = execSync(`${BIN}/gstack-auth-refresh --check`, { + env: { ...process.env, GSTACK_STATE_DIR: tmpDir, GSTACK_DIR: ROOT } + }); + // Should not throw + }); +}); + +describe('gstack-community-backup', () => { + test('exits early if not community tier', () => { + setConfig('telemetry', 'anonymous'); + const output = run(`${BIN}/gstack-community-backup`); + expect(output).toBe(''); + }); + + test('exits early if not authenticated', () => { + setConfig('telemetry', 'community'); + const output = run(`${BIN}/gstack-community-backup`); + expect(output).toBe(''); + }); + + test('snapshot generation (dry run/mock check)', () => { + setConfig('telemetry', 'community'); + const authFile = path.join(tmpDir, 'auth-token.json'); + fs.writeFileSync(authFile, JSON.stringify({ + access_token: 'valid', + refresh_token: 'refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + email: 'test@example.com', + user_id: 'user-123' + })); + + // Create some data to backup + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'key: "value with \\"quotes\\""\n'); + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir); + fs.writeFileSync(path.join(analyticsDir, 'skill-usage.jsonl'), '{"skill":"qa","duration_s":10,"outcome":"success"}\n'); + + // We can't easily test the Supabase POST without mocking curl or the endpoint + // but we can verify it doesn't crash and respects the rate limit marker. + run(`${BIN}/gstack-community-backup`, { GSTACK_TELEMETRY_ENDPOINT: 'http://localhost:9999' }); + + // It should NOT have created the rate limit marker because the POST failed (HTTP 000) + expect(fs.existsSync(path.join(analyticsDir, '.last-backup-time'))).toBe(false); + }); +}); + +describe('gstack-community-benchmarks', () => { + test('shows no data message when no local analytics', () => { + const output = run(`${BIN}/gstack-community-benchmarks`); + expect(output).toContain('No local analytics data'); + }); + + test('renders comparison table with local data', () => { + const analyticsDir = path.join(tmpDir, 'analytics'); + fs.mkdirSync(analyticsDir); + fs.writeFileSync(path.join(analyticsDir, 'skill-usage.jsonl'), '{"skill":"qa","duration_s":120,"outcome":"success"}\n'); + + const output = run(`${BIN}/gstack-community-benchmarks`); + expect(output).toContain('/qa'); + expect(output).toContain('2m 0s'); + }); +}); diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 32e77a36..43244f5e 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1391,7 +1391,7 @@ describe('telemetry', () => { test('generated SKILL.md contains telemetry opt-in prompt', () => { const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8'); expect(content).toContain('.telemetry-prompted'); - expect(content).toContain('Help gstack get better'); + expect(content).toContain('gstack can share usage data'); expect(content).toContain('gstack-config set telemetry community'); expect(content).toContain('gstack-config set telemetry anonymous'); expect(content).toContain('gstack-config set telemetry off');