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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** 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(`<h1>Authorization failed</h1><p>${error}</p>`);
|
||||
reject(new Error(`OAuth error: ${error}`));
|
||||
server.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authorization successful!</h1><p>You can close this tab and return to the terminal.</p>');
|
||||
resolve({ code, server });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Missing authorization code</h1>');
|
||||
});
|
||||
|
||||
server.listen(port, () => {});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Try to open a URL in the user's browser. */
|
||||
async function openBrowser(url: string): Promise<boolean> {
|
||||
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<string> {
|
||||
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 <path>', '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<typeof readCredentials>;
|
||||
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!');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user