#!/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); } }