From ff03f744045d21f34e75b5a32ca47fb802922afb Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 10:33:01 -0800 Subject: [PATCH] feat(cli): add gmail-auth command for OAuth2 token setup Implements `flynn gmail-auth` to complete the OAuth2 flow that GmailWatcher references but was never built. Supports local callback server (default) and --manual paste mode. Adds Gmail health check to `flynn doctor`. Co-Authored-By: Claude Opus 4.6 --- docs/plans/state.json | 1 + src/cli/doctor.test.ts | 51 ++++++++ src/cli/doctor.ts | 31 +++++ src/cli/gmail-auth.test.ts | 104 ++++++++++++++++ src/cli/gmail-auth.ts | 245 +++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 2 + 6 files changed, 434 insertions(+) create mode 100644 src/cli/gmail-auth.test.ts create mode 100644 src/cli/gmail-auth.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index e16b9a8..bf4bb0f 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -987,6 +987,7 @@ "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", "feature_gap_scorecard": "100/128 match (78%), 0 partial (0%), 28 missing (22%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 1/2 plans complete — metrics backend done, dashboard UI next", + "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "next_up": "GSD Milestone: Operator DX — Phase 3 Plan 02 (Dashboard UI consuming metrics RPC). All phases P0-P8 and Tiers 1-4 complete. Setup wizard added. Remaining gaps: Tier 4 channels (Signal, Matrix, Teams, Google Chat), Tier 5 deferred/niche items" } } diff --git a/src/cli/doctor.test.ts b/src/cli/doctor.test.ts index fc9d88b..aa9d197 100644 --- a/src/cli/doctor.test.ts +++ b/src/cli/doctor.test.ts @@ -112,6 +112,57 @@ models: expect(sessionDb?.status).toBe('pass'); }); + it('reports SKIP for Gmail when not enabled', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gmailCheck = results.find(r => r.label.includes('Gmail')); + expect(gmailCheck?.status).toBe('skip'); + }); + + it('reports WARN for Gmail when token missing', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + const credsPath = join(testDir, 'gmail-creds.json'); + writeFileSync(credsPath, '{}'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +automation: + gmail: + enabled: true + credentials_file: "${credsPath}" + token_file: "${join(testDir, 'nonexistent-token.json')}" + output: + channel: telegram + peer: "123" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const gmailCheck = results.find(r => r.label.includes('Gmail')); + expect(gmailCheck?.status).toBe('warn'); + expect(gmailCheck?.detail).toContain('flynn gmail-auth'); + }); + it('skips downstream checks when config is invalid', async () => { const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; const results = await runChecks(ctx); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 2897886..339a4af 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander'; import type { Config } from '../config/index.js'; import { getConfigPath, getDataDir, formatStatus, resolveOverlayPath } from './shared.js'; import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { homedir } from 'os'; import { resolve, join } from 'path'; import { parse } from 'yaml'; import { configSchema } from '../config/schema.js'; @@ -216,6 +217,35 @@ const checkTailscale: Check = async (ctx) => { } }; +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +const checkGmail: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Gmail configured', detail: '(config invalid)' }; + } + const gmail = ctx.config.automation.gmail; + if (!gmail?.enabled) { + return { status: 'skip', label: 'Gmail configured', detail: '(not enabled)' }; + } + + const credentialsPath = expandPath(gmail.credentials_file ?? '~/.config/flynn/gmail-credentials.json'); + if (!existsSync(credentialsPath)) { + return { status: 'fail', label: 'Gmail configured', detail: `credentials file not found: ${credentialsPath}` }; + } + + const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json'); + if (!existsSync(tokenPath)) { + return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' }; + } + + return { status: 'pass', label: 'Gmail configured', detail: `(output: ${gmail.output.channel}/${gmail.output.peer})` }; +}; + const allChecks: Check[] = [ checkConfigExists, checkOverlayExists, @@ -226,6 +256,7 @@ const allChecks: Check[] = [ checkSessionDb, checkModelConnectivity, checkTelegram, + checkGmail, checkMcpServers, checkSkills, checkTailscale, diff --git a/src/cli/gmail-auth.test.ts b/src/cli/gmail-auth.test.ts new file mode 100644 index 0000000..98236c6 --- /dev/null +++ b/src/cli/gmail-auth.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { readCredentials, generateAuthUrl, saveToken } from './gmail-auth.js'; +import { writeFileSync, mkdirSync, rmSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('gmail-auth', () => { + const testDir = join(tmpdir(), 'flynn-test-gmail-auth'); + + afterEach(() => { + try { rmSync(testDir, { recursive: true }); } catch {} + }); + + describe('readCredentials', () => { + it('reads installed credentials', () => { + mkdirSync(testDir, { recursive: true }); + const credPath = join(testDir, 'creds.json'); + writeFileSync(credPath, JSON.stringify({ + installed: { + client_id: 'test-id', + client_secret: 'test-secret', + redirect_uris: ['http://localhost:3000'], + }, + })); + + const creds = readCredentials(credPath); + expect(creds.client_id).toBe('test-id'); + expect(creds.client_secret).toBe('test-secret'); + expect(creds.redirect_uris).toEqual(['http://localhost:3000']); + }); + + it('reads web credentials', () => { + mkdirSync(testDir, { recursive: true }); + const credPath = join(testDir, 'creds.json'); + writeFileSync(credPath, JSON.stringify({ + web: { + client_id: 'web-id', + client_secret: 'web-secret', + }, + })); + + const creds = readCredentials(credPath); + expect(creds.client_id).toBe('web-id'); + expect(creds.client_secret).toBe('web-secret'); + }); + + it('throws when file does not exist', () => { + expect(() => readCredentials('/nonexistent/creds.json')).toThrow('not found'); + }); + + it('throws when client_id is missing', () => { + mkdirSync(testDir, { recursive: true }); + const credPath = join(testDir, 'creds.json'); + writeFileSync(credPath, JSON.stringify({ installed: { client_secret: 's' } })); + + expect(() => readCredentials(credPath)).toThrow('missing client_id or client_secret'); + }); + + it('throws when client_secret is missing', () => { + mkdirSync(testDir, { recursive: true }); + const credPath = join(testDir, 'creds.json'); + writeFileSync(credPath, JSON.stringify({ installed: { client_id: 'id' } })); + + expect(() => readCredentials(credPath)).toThrow('missing client_id or client_secret'); + }); + }); + + describe('generateAuthUrl', () => { + it('generates correct OAuth2 URL', () => { + const url = generateAuthUrl('my-client-id', 'my-secret', 'http://localhost:3000'); + expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth'); + expect(url).toContain('client_id=my-client-id'); + expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000'); + expect(url).toContain('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly'); + expect(url).toContain('access_type=offline'); + expect(url).toContain('prompt=consent'); + }); + }); + + describe('saveToken', () => { + it('saves token as JSON with 0o600 permissions', () => { + mkdirSync(testDir, { recursive: true }); + const tokenPath = join(testDir, 'token.json'); + const token = { access_token: 'abc', refresh_token: 'def' }; + + saveToken(tokenPath, token); + + const saved = JSON.parse(readFileSync(tokenPath, 'utf-8')); + expect(saved).toEqual(token); + + const stats = statSync(tokenPath); + // Check owner-only read/write (0o600) + expect(stats.mode & 0o777).toBe(0o600); + }); + + it('creates parent directories if needed', () => { + const tokenPath = join(testDir, 'nested', 'dir', 'token.json'); + saveToken(tokenPath, { token: 'value' }); + + const saved = JSON.parse(readFileSync(tokenPath, 'utf-8')); + expect(saved).toEqual({ token: 'value' }); + }); + }); +}); diff --git a/src/cli/gmail-auth.ts b/src/cli/gmail-auth.ts new file mode 100644 index 0000000..567763d --- /dev/null +++ b/src/cli/gmail-auth.ts @@ -0,0 +1,245 @@ +import type { Command } from 'commander'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { homedir } from 'os'; +import { createServer, type Server } from 'http'; +import { URL } from 'url'; +import { loadConfigSafe } from './shared.js'; + +const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']; +const REDIRECT_PORT = 3000; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; + +/** Expand ~ to the user's home directory. */ +function expandPath(p: string): string { + if (p.startsWith('~/') || p === '~') { + return resolve(homedir(), p.slice(2)); + } + return resolve(p); +} + +/** Read and parse the OAuth2 credentials file. */ +export function readCredentials(credentialsPath: string): { + client_id: string; + client_secret: string; + redirect_uris?: string[]; +} { + if (!existsSync(credentialsPath)) { + throw new Error(`Credentials file not found: ${credentialsPath}`); + } + + const credentials = JSON.parse(readFileSync(credentialsPath, 'utf-8')); + const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {}; + + if (!client_id || !client_secret) { + throw new Error('Invalid credentials file — missing client_id or client_secret'); + } + + return { client_id, client_secret, redirect_uris }; +} + +/** Generate the OAuth2 authorization URL. */ +export function generateAuthUrl(clientId: string, clientSecret: string, redirectUri: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; +} + +/** Exchange authorization code for tokens using Google's token endpoint. */ +async function exchangeCodeForTokens( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise> { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Token exchange failed (${response.status}): ${body}`); + } + + return response.json() as Promise>; +} + +/** Save token to disk with restrictive permissions (0o600). */ +export function saveToken(tokenPath: string, token: unknown): void { + const dir = dirname(tokenPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf-8'); + try { + chmodSync(tokenPath, 0o600); + } catch { + // chmod may fail on some filesystems — not critical + } +} + +/** Start a temporary HTTP server to receive the OAuth callback. */ +function waitForCallback(port: number): Promise<{ code: string; server: Server }> { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authorization failed

${error}

`); + reject(new Error(`OAuth error: ${error}`)); + server.close(); + return; + } + + if (code) { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

Authorization successful!

You can close this tab and return to the terminal.

'); + resolve({ code, server }); + return; + } + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

Missing authorization code

'); + }); + + server.listen(port, () => {}); + server.on('error', reject); + }); +} + +/** Try to open a URL in the user's browser. */ +async function openBrowser(url: string): Promise { + const { exec } = await import('child_process'); + const command = process.platform === 'darwin' ? 'open' : 'xdg-open'; + return new Promise((resolve) => { + exec(`${command} ${JSON.stringify(url)}`, (error) => { + resolve(!error); + }); + }); +} + +/** Manual code entry via stdin. */ +async function promptForCode(): Promise { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question('Enter the authorization code: ', (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +export function registerGmailAuthCommand(program: Command): void { + program + .command('gmail-auth') + .description('Authenticate with Gmail via OAuth2') + .option('-c, --config ', 'Config file path') + .option('--manual', 'Manually paste the authorization code instead of using a local server') + .action(async (opts: { config?: string; manual?: boolean }) => { + // 1. Load config + const { config, error } = loadConfigSafe(opts.config); + if (error || !config) { + console.error(`Error: ${error ?? 'Could not load config'}`); + process.exit(1); + } + + const gmailConfig = config.automation.gmail; + if (!gmailConfig) { + console.error('Error: automation.gmail is not configured in config.yaml'); + process.exit(1); + } + + // 2. Read credentials + const credentialsPath = expandPath(gmailConfig.credentials_file ?? '~/.config/flynn/gmail-credentials.json'); + let creds: ReturnType; + try { + creds = readCredentials(credentialsPath); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + const tokenPath = expandPath(gmailConfig.token_file ?? '~/.config/flynn/gmail-token.json'); + + // 3. Check if already authenticated + if (existsSync(tokenPath)) { + console.log(`Token already exists at ${tokenPath}`); + console.log('Delete it first if you want to re-authenticate.'); + process.exit(0); + } + + const redirectUri = opts.manual + ? (creds.redirect_uris?.[0] ?? 'urn:ietf:wg:oauth:2.0:oob') + : REDIRECT_URI; + + // 4. Generate auth URL + const authUrl = generateAuthUrl(creds.client_id, creds.client_secret, redirectUri); + + if (opts.manual) { + // Manual flow + console.log('\nOpen this URL in your browser:\n'); + console.log(authUrl); + console.log(''); + const code = await promptForCode(); + const token = await exchangeCodeForTokens(code, creds.client_id, creds.client_secret, redirectUri); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } else { + // Local server flow + console.log('Starting local server for OAuth callback...'); + + let callbackResult: { code: string; server: Server }; + try { + const callbackPromise = waitForCallback(REDIRECT_PORT); + + const opened = await openBrowser(authUrl); + if (!opened) { + console.log('\nCould not open browser. Open this URL manually:\n'); + console.log(authUrl); + } else { + console.log('\nBrowser opened. Complete the authorization flow...'); + } + + console.log(`\nWaiting for callback on http://localhost:${REDIRECT_PORT}...`); + callbackResult = await callbackPromise; + } catch (err) { + console.error(`\nError: ${err instanceof Error ? err.message : err}`); + console.log('\nTry again with --manual flag: flynn gmail-auth --manual'); + process.exit(1); + } + + try { + const token = await exchangeCodeForTokens( + callbackResult.code, + creds.client_id, + creds.client_secret, + redirectUri, + ); + saveToken(tokenPath, token); + console.log(`\nToken saved to ${tokenPath}`); + } finally { + callbackResult.server.close(); + } + } + + console.log('Gmail authentication complete!'); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index d7234b1..6d3ced9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -8,6 +8,7 @@ import { registerConfigCommand } from './config-cmd.js'; import { registerTuiCommand } from './tui.js'; import { registerCompletionCommand } from './completion.js'; import { registerSetupCommand } from './setup.js'; +import { registerGmailAuthCommand } from './gmail-auth.js'; export function createProgram(): Command { const program = new Command(); @@ -25,6 +26,7 @@ export function createProgram(): Command { registerConfigCommand(program); registerCompletionCommand(program); registerSetupCommand(program); + registerGmailAuthCommand(program); return program; }