mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-22 04:38:24 +08:00
feat: add team sync infrastructure (config, auth, push/pull, CLI)
- lib/sync-config.ts: reads .gstack-sync.json + ~/.gstack/auth.json - lib/auth.ts: device auth flow (browser OAuth, local HTTP callback) - lib/sync.ts: Supabase push/pull via raw fetch(), offline queue, cache - lib/cli-sync.ts: CLI handler for gstack-sync commands - bin/gstack-sync: bash wrapper (setup, status, push-*, pull, drain) - .gstack-sync.json.example: template for team setup Zero new dependencies — uses raw fetch() against PostgREST API. All sync is non-fatal with 5s timeout and offline queue fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
211
lib/auth.ts
Normal file
211
lib/auth.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Device auth flow for team sync.
|
||||
*
|
||||
* Opens a browser for Supabase OAuth/magic link, polls for completion,
|
||||
* and saves tokens to ~/.gstack/auth.json.
|
||||
*
|
||||
* Two modes:
|
||||
* 1. Magic link: user enters email → receives link → CLI detects auth via polling
|
||||
* 2. Browser OAuth: opens Supabase auth page → callback to localhost → CLI captures token
|
||||
*
|
||||
* For CI: set GSTACK_SUPABASE_ACCESS_TOKEN env var to skip interactive auth.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { saveAuthTokens, type TeamConfig, type AuthTokens } from './sync-config';
|
||||
|
||||
const AUTH_CALLBACK_PORT = 54321;
|
||||
const AUTH_TIMEOUT_MS = 300_000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Run the interactive device auth flow.
|
||||
*
|
||||
* 1. Starts a local HTTP server on port 54321
|
||||
* 2. Opens the Supabase auth page in the browser (with redirect to localhost)
|
||||
* 3. Waits for the auth callback with tokens
|
||||
* 4. Saves tokens and returns them
|
||||
*/
|
||||
export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
|
||||
return new Promise<AuthTokens>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
server.close();
|
||||
reject(new Error('Auth timed out after 5 minutes. Please try again.'));
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', `http://localhost:${AUTH_CALLBACK_PORT}`);
|
||||
|
||||
// Handle the OAuth callback
|
||||
if (url.pathname === '/auth/callback') {
|
||||
const accessToken = url.searchParams.get('access_token') || url.hash?.match(/access_token=([^&]+)/)?.[1];
|
||||
const refreshToken = url.searchParams.get('refresh_token') || '';
|
||||
const expiresIn = parseInt(url.searchParams.get('expires_in') || '3600', 10);
|
||||
|
||||
if (!accessToken) {
|
||||
// Serve a page that extracts tokens from the URL hash (Supabase puts them there)
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(authCallbackHTML(AUTH_CALLBACK_PORT));
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens: AuthTokens = {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
||||
user_id: url.searchParams.get('user_id') || '',
|
||||
team_id: '', // filled in by sync.ts after first API call
|
||||
email: url.searchParams.get('email') || '',
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(authSuccessHTML());
|
||||
|
||||
clearTimeout(timeout);
|
||||
server.close();
|
||||
|
||||
// Save tokens
|
||||
try {
|
||||
saveAuthTokens(team.supabase_url, tokens);
|
||||
} catch (err: any) {
|
||||
reject(new Error(`Failed to save auth tokens: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(tokens);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle token POST from the callback page
|
||||
if (url.pathname === '/auth/token' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
const tokens: AuthTokens = {
|
||||
access_token: data.access_token || '',
|
||||
refresh_token: data.refresh_token || '',
|
||||
expires_at: Math.floor(Date.now() / 1000) + (data.expires_in || 3600),
|
||||
user_id: data.user?.id || '',
|
||||
team_id: '',
|
||||
email: data.user?.email || '',
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
|
||||
clearTimeout(timeout);
|
||||
server.close();
|
||||
|
||||
saveAuthTokens(team.supabase_url, tokens);
|
||||
resolve(tokens);
|
||||
} catch (err: any) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
server.listen(AUTH_CALLBACK_PORT, '127.0.0.1', () => {
|
||||
const authUrl = buildAuthUrl(team.supabase_url, AUTH_CALLBACK_PORT);
|
||||
console.log(`\nOpening browser for authentication...`);
|
||||
console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`);
|
||||
openBrowser(authUrl);
|
||||
});
|
||||
|
||||
server.on('error', (err: any) => {
|
||||
clearTimeout(timeout);
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${AUTH_CALLBACK_PORT} is in use. Close the other process and try again.`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the Supabase auth URL with localhost callback. */
|
||||
function buildAuthUrl(supabaseUrl: string, port: number): string {
|
||||
const redirectTo = `http://localhost:${port}/auth/callback`;
|
||||
return `${supabaseUrl}/auth/v1/authorize?provider=github&redirect_to=${encodeURIComponent(redirectTo)}`;
|
||||
}
|
||||
|
||||
/** Open a URL in the default browser. */
|
||||
function openBrowser(url: string): void {
|
||||
const { spawnSync } = require('child_process');
|
||||
// macOS
|
||||
if (process.platform === 'darwin') {
|
||||
spawnSync('open', [url], { stdio: 'ignore' });
|
||||
return;
|
||||
}
|
||||
// Linux
|
||||
if (process.platform === 'linux') {
|
||||
spawnSync('xdg-open', [url], { stdio: 'ignore' });
|
||||
return;
|
||||
}
|
||||
// Windows
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('cmd', ['/c', 'start', url], { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML page that extracts tokens from URL hash and POSTs them to the local server. */
|
||||
function authCallbackHTML(port: number): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>gstack auth</title></head>
|
||||
<body>
|
||||
<h2>Completing authentication...</h2>
|
||||
<p id="status">Extracting tokens...</p>
|
||||
<script>
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
const data = {
|
||||
access_token: params.get('access_token'),
|
||||
refresh_token: params.get('refresh_token'),
|
||||
expires_in: parseInt(params.get('expires_in') || '3600'),
|
||||
user: { id: '', email: '' }
|
||||
};
|
||||
if (data.access_token) {
|
||||
fetch('http://localhost:${port}/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(() => {
|
||||
document.getElementById('status').textContent = 'Authenticated! You can close this tab.';
|
||||
}).catch(err => {
|
||||
document.getElementById('status').textContent = 'Error: ' + err.message;
|
||||
});
|
||||
} else {
|
||||
document.getElementById('status').textContent = 'No tokens found in URL. Please try again.';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** HTML page shown after successful auth. */
|
||||
function authSuccessHTML(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>gstack auth</title></head>
|
||||
<body>
|
||||
<h2>Authenticated!</h2>
|
||||
<p>You can close this tab and return to your terminal.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current auth token is expired (or will expire within 5 minutes).
|
||||
*/
|
||||
export function isTokenExpired(tokens: AuthTokens): boolean {
|
||||
if (!tokens.expires_at) return false; // env-var tokens don't expire
|
||||
const buffer = 300; // 5-minute buffer
|
||||
return Math.floor(Date.now() / 1000) >= tokens.expires_at - buffer;
|
||||
}
|
||||
Reference in New Issue
Block a user