From 46c82ce8ec9c6b6e8a5109646784e9a7fa99504d Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 16 Mar 2026 02:44:24 -0500 Subject: [PATCH] feat: add team admin CLI + migration 007 (settings, cooldowns, create_team RPC) New `gstack team` CLI with create, members, set subcommands. Migration adds team_settings (admin-only), alert_cooldowns (edge-fn dedup), and create_team() SECURITY DEFINER RPC for atomic team + first member creation. 9 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/gstack-team | 8 + lib/cli-team.ts | 276 ++++++++++++++++++ .../007_team_settings_and_functions.sql | 94 ++++++ test/lib-team-admin.test.ts | 85 ++++++ 4 files changed, 463 insertions(+) create mode 100755 bin/gstack-team create mode 100644 lib/cli-team.ts create mode 100644 supabase/migrations/007_team_settings_and_functions.sql create mode 100644 test/lib-team-admin.test.ts diff --git a/bin/gstack-team b/bin/gstack-team new file mode 100755 index 00000000..6eb7004b --- /dev/null +++ b/bin/gstack-team @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# gstack team — team admin CLI +# Delegates to lib/cli-team.ts via bun + +GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" +exec bun run "$GSTACK_DIR/lib/cli-team.ts" "$@" diff --git a/lib/cli-team.ts b/lib/cli-team.ts new file mode 100644 index 00000000..449bc3ae --- /dev/null +++ b/lib/cli-team.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env bun +/** + * Team admin CLI: gstack team + * + * Subcommands: + * create Create a new team (you become owner) + * members List team members + * set Set a team setting (admin-only) + */ + +import { resolveSyncConfig, isSyncConfigured, getTeamConfig, getAuthTokens } from './sync-config'; +import { pullTable } from './sync'; +import { isTokenExpired } from './auth'; + +// --- Types --- + +interface TeamMember { + user_id: string; + role: string; + email?: string; +} + +// --- Helpers --- + +async function getValidToken(): Promise<{ token: string; config: ReturnType } | null> { + const config = resolveSyncConfig(); + if (!config) { + console.error('Team sync not configured. Run: gstack sync setup'); + return null; + } + + const token = config.auth.access_token; + if (!token) { + console.error('Not authenticated. Run: gstack sync setup'); + return null; + } + + return { token, config }; +} + +async function supabaseRPC( + supabaseUrl: string, + anonKey: string, + token: string, + fnName: string, + body: Record, +): Promise<{ ok: boolean; data?: any; error?: string; status: number }> { + try { + const res = await fetch(`${supabaseUrl}/rest/v1/rpc/${fnName}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': anonKey, + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + const text = await res.text(); + let errorMsg: string; + try { + const json = JSON.parse(text); + errorMsg = json.message || json.error || text; + } catch { + errorMsg = text; + } + return { ok: false, error: errorMsg, status: res.status }; + } + + const data = await res.json(); + return { ok: true, data, status: res.status }; + } catch (err: any) { + return { ok: false, error: err.message, status: 0 }; + } +} + +async function supabaseUpsert( + supabaseUrl: string, + anonKey: string, + token: string, + table: string, + data: Record, +): Promise<{ ok: boolean; error?: string }> { + try { + const res = await fetch(`${supabaseUrl}/rest/v1/${table}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': anonKey, + 'Authorization': `Bearer ${token}`, + 'Prefer': 'resolution=merge-duplicates', + }, + body: JSON.stringify(data), + signal: AbortSignal.timeout(10_000), + }); + + if (!res.ok) { + const text = await res.text(); + let errorMsg: string; + try { + const json = JSON.parse(text); + errorMsg = json.message || json.error || text; + } catch { + errorMsg = text; + } + return { ok: false, error: errorMsg }; + } + + return { ok: true }; + } catch (err: any) { + return { ok: false, error: err.message }; + } +} + +// --- Formatting (pure functions) --- + +/** Format team members as a terminal table. Pure function for testing. */ +export function formatMembersTable(members: Record[]): string { + if (members.length === 0) return 'No team members found.\n'; + + const lines: string[] = []; + lines.push(''); + lines.push('Team Members'); + lines.push('═'.repeat(60)); + lines.push( + ' ' + + 'Email / User ID'.padEnd(35) + + 'Role'.padEnd(12) + + 'Joined' + ); + lines.push('─'.repeat(60)); + + for (const m of members) { + const who = String(m.email || m.user_id || 'unknown').slice(0, 33).padEnd(35); + const role = String(m.role || 'member').padEnd(12); + // team_members doesn't have created_at, so use a placeholder + const joined = '—'; + lines.push(` ${who}${role}${joined}`); + } + + lines.push('─'.repeat(60)); + lines.push(` ${members.length} member${members.length === 1 ? '' : 's'}`); + lines.push(''); + return lines.join('\n'); +} + +// --- Subcommands --- + +async function cmdCreate(slug: string, name: string): Promise { + const auth = await getValidToken(); + if (!auth) return; + + const { config } = auth; + const result = await supabaseRPC( + config!.team.supabase_url, + config!.team.supabase_anon_key, + auth.token, + 'create_team', + { team_slug: slug, team_name: name }, + ); + + if (!result.ok) { + if (result.status === 409 || (result.error && result.error.includes('unique'))) { + console.error(`Team slug "${slug}" is already taken. Try a different slug.`); + } else { + console.error(`Failed to create team: ${result.error}`); + } + process.exit(1); + } + + console.log(`Team "${name}" created (slug: ${slug})`); + console.log(`Team ID: ${result.data}`); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Share your .gstack-sync.json with team members (it\'s safe to commit)'); + console.log(' 2. Team members run: gstack sync setup'); + console.log(' 3. Add members via Supabase dashboard'); +} + +async function cmdMembers(): Promise { + if (!isSyncConfigured()) { + console.error('Team sync not configured. Run: gstack sync setup'); + process.exit(1); + } + + const members = await pullTable('team_members'); + console.log(formatMembersTable(members)); +} + +async function cmdSet(key: string, value: string): Promise { + const auth = await getValidToken(); + if (!auth) return; + + const { config } = auth; + const teamId = config!.auth.team_id; + + const result = await supabaseUpsert( + config!.team.supabase_url, + config!.team.supabase_anon_key, + auth.token, + 'team_settings', + { team_id: teamId, key, value, updated_at: new Date().toISOString() }, + ); + + if (!result.ok) { + if (result.error && result.error.includes('policy')) { + console.error('Permission denied. Only team admins/owners can change settings.'); + } else { + console.error(`Failed to set ${key}: ${result.error}`); + } + process.exit(1); + } + + console.log(`Set ${key} = ${value}`); +} + +function printUsage(): void { + console.log(` +gstack team — team admin CLI + +Usage: gstack team [args] + +Commands: + create Create a new team (you become owner) + members List team members + set Set a team setting (admin-only) + +Settings: + slack-webhook Slack webhook URL for alerts and digests + digest-enabled Enable/disable weekly digest (true/false) + +Examples: + gstack team create acme "Acme Engineering" + gstack team members + gstack team set slack-webhook https://hooks.slack.com/services/T.../B.../xxx + gstack team set digest-enabled true +`); +} + +// --- Main --- + +if (import.meta.main) { + const command = process.argv[2]; + const args = process.argv.slice(3); + + switch (command) { + case 'create': { + if (args.length < 2) { + console.error('Usage: gstack team create '); + process.exit(1); + } + cmdCreate(args[0], args.slice(1).join(' ')); + break; + } + case 'members': + cmdMembers(); + break; + case 'set': { + if (args.length < 2) { + console.error('Usage: gstack team set '); + process.exit(1); + } + cmdSet(args[0], args.slice(1).join(' ')); + break; + } + case '--help': case '-h': case 'help': case undefined: + printUsage(); + break; + default: + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } +} diff --git a/supabase/migrations/007_team_settings_and_functions.sql b/supabase/migrations/007_team_settings_and_functions.sql new file mode 100644 index 00000000..9b5820dc --- /dev/null +++ b/supabase/migrations/007_team_settings_and_functions.sql @@ -0,0 +1,94 @@ +-- 007_team_settings_and_functions.sql — Team settings, alert cooldowns, and RPC functions. +-- +-- Adds: +-- 1. team_settings (key-value per team, admin-only) +-- 2. alert_cooldowns (dedup for regression alerts, edge-fn only) +-- 3. create_team() RPC (SECURITY DEFINER — atomic team + first member creation) + +-- ─── team_settings ────────────────────────────────────────── + +create table if not exists team_settings ( + team_id uuid references teams(id) on delete cascade, + key text not null, + value text not null, + updated_at timestamptz default now(), + primary key (team_id, key) +); + +alter table team_settings enable row level security; + +-- Admins can read settings +create policy "admin_read_settings" on team_settings + for select using ( + team_id in ( + select team_id from team_members + where user_id = auth.uid() and role in ('owner', 'admin') + ) + ); + +-- Admins can write settings +create policy "admin_write_settings" on team_settings + for all using ( + team_id in ( + select team_id from team_members + where user_id = auth.uid() and role in ('owner', 'admin') + ) + ); + +-- ─── alert_cooldowns ──────────────────────────────────────── + +create table if not exists alert_cooldowns ( + team_id uuid references teams(id) on delete cascade, + repo_slug text not null, + alert_type text not null, + last_sent_at timestamptz not null default now(), + primary key (team_id, repo_slug, alert_type) +); + +-- No RLS — only accessed by edge functions via service_role key. +-- Edge functions bypass RLS anyway, but we explicitly leave it disabled +-- since no user should query this table directly. + +-- ─── create_team() RPC ────────────────────────────────────── + +-- SECURITY DEFINER: runs as the table owner, bypassing RLS. +-- This solves the chicken-and-egg problem: to INSERT the first +-- team_member, you'd need to already be a member (which the +-- admins_manage_members policy requires). This function does +-- both atomically. +-- +-- Called via: POST /rest/v1/rpc/create_team +-- Body: { "team_slug": "my-team", "team_name": "My Team" } + +create or replace function create_team(team_slug text, team_name text) +returns uuid +language plpgsql +security definer +set search_path = public +as $$ +declare + new_team_id uuid; +begin + -- Validate inputs + if team_slug is null or length(trim(team_slug)) = 0 then + raise exception 'team_slug cannot be empty'; + end if; + if team_name is null or length(trim(team_name)) = 0 then + raise exception 'team_name cannot be empty'; + end if; + if auth.uid() is null then + raise exception 'must be authenticated'; + end if; + + -- Create team + insert into teams (slug, name) + values (trim(team_slug), trim(team_name)) + returning id into new_team_id; + + -- Add caller as owner + insert into team_members (team_id, user_id, role) + values (new_team_id, auth.uid(), 'owner'); + + return new_team_id; +end; +$$; diff --git a/test/lib-team-admin.test.ts b/test/lib-team-admin.test.ts new file mode 100644 index 00000000..e551d7cb --- /dev/null +++ b/test/lib-team-admin.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for lib/cli-team.ts — team admin pure functions. + */ + +import { describe, test, expect } from 'bun:test'; +import { formatMembersTable } from '../lib/cli-team'; + +describe('formatMembersTable', () => { + test('formats members as table', () => { + const members = [ + { user_id: 'u1', email: 'alice@test.com', role: 'owner' }, + { user_id: 'u2', email: 'bob@test.com', role: 'member' }, + { user_id: 'u3', email: 'carol@test.com', role: 'admin' }, + ]; + const output = formatMembersTable(members); + + expect(output).toContain('Team Members'); + expect(output).toContain('alice@test.com'); + expect(output).toContain('bob@test.com'); + expect(output).toContain('carol@test.com'); + expect(output).toContain('owner'); + expect(output).toContain('member'); + expect(output).toContain('admin'); + expect(output).toContain('3 members'); + }); + + test('returns message for empty array', () => { + const output = formatMembersTable([]); + expect(output).toContain('No team members'); + }); + + test('singular member count', () => { + const members = [{ user_id: 'u1', role: 'owner' }]; + const output = formatMembersTable(members); + expect(output).toContain('1 member'); + expect(output).not.toContain('1 members'); + }); + + test('handles missing email gracefully', () => { + const members = [{ user_id: 'uuid-1234-abcd', role: 'member' }]; + const output = formatMembersTable(members); + expect(output).toContain('uuid-1234-abcd'); + expect(output).not.toContain('undefined'); + }); + + test('truncates long emails', () => { + const members = [{ user_id: 'u1', email: 'very-long-email-address-that-exceeds-the-column-width@extremely-long-domain-name.com', role: 'member' }]; + const output = formatMembersTable(members); + // Should not break the table layout + expect(output).toContain('Team Members'); + expect(output).toContain('member'); + }); +}); + +describe('gstack team CLI', () => { + test('help shows usage', () => { + const proc = Bun.spawnSync(['bun', 'run', 'lib/cli-team.ts', '--help']); + const stdout = proc.stdout?.toString() || ''; + expect(stdout).toContain('gstack team'); + expect(stdout).toContain('create'); + expect(stdout).toContain('members'); + expect(stdout).toContain('set'); + }); + + test('unknown command exits with error', () => { + const proc = Bun.spawnSync(['bun', 'run', 'lib/cli-team.ts', 'nonsense']); + expect(proc.exitCode).toBe(1); + const stderr = proc.stderr?.toString() || ''; + expect(stderr).toContain('Unknown command'); + }); + + test('create without args shows usage', () => { + const proc = Bun.spawnSync(['bun', 'run', 'lib/cli-team.ts', 'create']); + expect(proc.exitCode).toBe(1); + const stderr = proc.stderr?.toString() || ''; + expect(stderr).toContain('Usage'); + }); + + test('set without args shows usage', () => { + const proc = Bun.spawnSync(['bun', 'run', 'lib/cli-team.ts', 'set']); + expect(proc.exitCode).toBe(1); + const stderr = proc.stderr?.toString() || ''; + expect(stderr).toContain('Usage'); + }); +});