mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
Executable
+8
@@ -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" "$@"
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Team admin CLI: gstack team <subcommand>
|
||||
*
|
||||
* Subcommands:
|
||||
* create <slug> <name> Create a new team (you become owner)
|
||||
* members List team members
|
||||
* set <key> <value> 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<typeof resolveSyncConfig> } | 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<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
): 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, unknown>[]): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <command> [args]
|
||||
|
||||
Commands:
|
||||
create <slug> <name> Create a new team (you become owner)
|
||||
members List team members
|
||||
set <key> <value> Set a team setting (admin-only)
|
||||
|
||||
Settings:
|
||||
slack-webhook <url> Slack webhook URL for alerts and digests
|
||||
digest-enabled <bool> 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 <slug> <name>');
|
||||
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 <key> <value>');
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user