Files
flynn/src/cli/gtasks-auth.ts
T
William Valentin f204ff1dd7 feat(tools): add Google Docs, Drive, and Tasks read-only tools
Add three new Google service integrations following the established
Gmail/GCal pattern:

- Google Docs (docs.list, docs.search, docs.read): list, search, and
  read document content as plain text via Docs + Drive APIs
- Google Drive (drive.list, drive.search, drive.read): list, search,
  and read files with export support for Workspace files (Docs→text,
  Sheets→CSV, Slides→text)
- Google Tasks (tasks.lists, tasks.list): list task lists and tasks
  with status, due dates, and notes

Each service has its own config section, OAuth auth command, tool
policy group, and test suite (53 new tests). The setup wizard now
offers to configure all Google services together and run OAuth auth
flows automatically after saving config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 12:59:15 -08:00

246 lines
8.2 KiB
TypeScript

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/tasks.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. */
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. */
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). */
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 registerGtasksAuthCommand(program: Command): void {
program
.command('gtasks-auth')
.description('Authenticate with Google Tasks 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 gtasksConfig = config.automation.gtasks;
if (!gtasksConfig) {
console.error('Error: automation.gtasks is not configured in config.yaml');
process.exit(1);
}
// 2. Read credentials
const credentialsPath = expandPath(gtasksConfig.credentials_file ?? '~/.config/flynn/gtasks-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(gtasksConfig.token_file ?? '~/.config/flynn/gtasks-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 gtasks-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('Google Tasks authentication complete!');
});
}