feat(google-auth): centralize oauth token store and service checks
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
export type GoogleService = 'gmail' | 'gcal' | 'gdocs' | 'gdrive' | 'gtasks';
|
||||
|
||||
const AUTH_DIR = resolve(homedir(), '.config/flynn');
|
||||
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
||||
|
||||
export interface GoogleTokenRecord {
|
||||
token: Record<string, unknown>;
|
||||
scopes?: string[];
|
||||
token_file?: string;
|
||||
credentials_file?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GoogleAuthInfo {
|
||||
services: Partial<Record<GoogleService, GoogleTokenRecord>>;
|
||||
}
|
||||
|
||||
interface AuthStore {
|
||||
google?: GoogleAuthInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function isIgnorableStoreError(error: unknown): boolean {
|
||||
if (!(error instanceof Error) || !('code' in error)) {
|
||||
return false;
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'EACCES' || code === 'EPERM' || code === 'EROFS';
|
||||
}
|
||||
|
||||
function safeJsonParse<T>(raw: string): T | null {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readAuthStore(): AuthStore {
|
||||
try {
|
||||
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
||||
const parsed = safeJsonParse<AuthStore>(raw);
|
||||
return parsed ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuthStore(store: AuthStore): void {
|
||||
mkdirSync(AUTH_DIR, { recursive: true });
|
||||
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
|
||||
chmodSync(AUTH_FILE, 0o600);
|
||||
}
|
||||
|
||||
function parseScopes(token: Record<string, unknown>, explicitScopes?: string[]): string[] | undefined {
|
||||
if (explicitScopes && explicitScopes.length > 0) {
|
||||
return explicitScopes;
|
||||
}
|
||||
const scopeValue = token.scope;
|
||||
if (typeof scopeValue !== 'string' || scopeValue.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return scopeValue.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function ensureGoogleAuth(store: AuthStore): GoogleAuthInfo {
|
||||
if (!store.google) {
|
||||
store.google = { services: {} };
|
||||
return store.google;
|
||||
}
|
||||
if (!store.google.services) {
|
||||
store.google.services = {};
|
||||
}
|
||||
return store.google;
|
||||
}
|
||||
|
||||
export function loadStoredGoogleTokenRecord(service: GoogleService): GoogleTokenRecord | null {
|
||||
const store = readAuthStore();
|
||||
return store.google?.services?.[service] ?? null;
|
||||
}
|
||||
|
||||
export function loadStoredGoogleToken(service: GoogleService): Record<string, unknown> | null {
|
||||
return loadStoredGoogleTokenRecord(service)?.token ?? null;
|
||||
}
|
||||
|
||||
export function storeGoogleToken(
|
||||
service: GoogleService,
|
||||
token: Record<string, unknown>,
|
||||
options?: {
|
||||
scopes?: string[];
|
||||
tokenFile?: string;
|
||||
credentialsFile?: string;
|
||||
},
|
||||
): void {
|
||||
const store = readAuthStore();
|
||||
const google = ensureGoogleAuth(store);
|
||||
google.services[service] = {
|
||||
token,
|
||||
scopes: parseScopes(token, options?.scopes),
|
||||
token_file: options?.tokenFile,
|
||||
credentials_file: options?.credentialsFile,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
writeAuthStore(store);
|
||||
} catch (error) {
|
||||
// Preserve legacy token-file behavior when central store cannot be written.
|
||||
if (!isIgnorableStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGoogleToken(service: GoogleService): void {
|
||||
const store = readAuthStore();
|
||||
if (!store.google?.services?.[service]) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete store.google.services[service];
|
||||
|
||||
if (Object.keys(store.google.services).length === 0) {
|
||||
delete store.google;
|
||||
}
|
||||
try {
|
||||
writeAuthStore(store);
|
||||
} catch (error) {
|
||||
if (!isIgnorableStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,3 +53,13 @@ export {
|
||||
getZaiApiKey,
|
||||
type ZaiAuthInfo,
|
||||
} from './zai.js';
|
||||
|
||||
export {
|
||||
loadStoredGoogleTokenRecord,
|
||||
loadStoredGoogleToken,
|
||||
storeGoogleToken,
|
||||
clearGoogleToken,
|
||||
type GoogleService,
|
||||
type GoogleTokenRecord,
|
||||
type GoogleAuthInfo,
|
||||
} from './google.js';
|
||||
|
||||
+15
-58
@@ -1,13 +1,12 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import type { v1 } from '@google-cloud/pubsub';
|
||||
import type { GmailConfig } from '../config/schema.js';
|
||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||
import { parseInterval } from './heartbeat.js';
|
||||
import { sanitizeHtml } from '../utils/html.js';
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
import { createGoogleOAuth2Client, expandPath } from '../google/oauth.js';
|
||||
|
||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||
interface ChannelLookup {
|
||||
@@ -178,6 +177,13 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible path helper used by tests and callers.
|
||||
*/
|
||||
expandPath(p: string): string {
|
||||
return expandPath(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Pub/Sub push notification from Google.
|
||||
* Called by the gateway when POST /gmail/push is received.
|
||||
@@ -218,45 +224,22 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = this.expandPath(credentialsPath);
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
|
||||
const { client_id, client_secret, redirect_uris, project_id } = credentials.installed ?? credentials.web ?? {};
|
||||
const { project_id } = credentials.installed ?? credentials.web ?? {};
|
||||
if (project_id && typeof project_id === 'string') {
|
||||
this.googleProjectId = project_id;
|
||||
}
|
||||
|
||||
if (!client_id || !client_secret) {
|
||||
throw new Error('Invalid credentials file — missing client_id or client_secret');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
// Load stored token
|
||||
const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(
|
||||
`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
// Auto-save refreshed tokens
|
||||
oauth2Client.on('tokens', (newTokens) => {
|
||||
const merged = { ...token, ...newTokens };
|
||||
this.saveToken(merged);
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gmail',
|
||||
credentialsFile: this.config.credentials_file,
|
||||
tokenFile: this.config.token_file,
|
||||
});
|
||||
|
||||
return oauth2Client;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,30 +591,4 @@ export class GmailWatcher implements ChannelAdapter {
|
||||
.replace(/\{\{labels\}\}/g, email.labels.join(', '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand ~ to the user's home directory.
|
||||
*/
|
||||
expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save token to disk with restrictive permissions (0o600).
|
||||
*/
|
||||
private saveToken(token: unknown): void {
|
||||
const tokenPath = this.expandPath(this.config.token_file ?? '~/.config/flynn/gmail-token.json');
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { tmpdir } from 'os';
|
||||
|
||||
describe('doctor checks', () => {
|
||||
const testDir = join(tmpdir(), 'flynn-test-doctor');
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
try { rmSync(testDir, { recursive: true }); } catch {}
|
||||
});
|
||||
|
||||
@@ -201,6 +203,97 @@ automation:
|
||||
expect(gmailCheck?.detail).toContain('flynn gmail-auth');
|
||||
});
|
||||
|
||||
it('reports SKIP for Google Calendar 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 gcalCheck = results.find(r => r.label.includes('Google Calendar'));
|
||||
expect(gcalCheck?.status).toBe('skip');
|
||||
});
|
||||
|
||||
it('reports WARN for Google Calendar when token missing in file and auth store', async () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const configPath = join(testDir, 'config.yaml');
|
||||
const credsPath = join(testDir, 'gcal-creds.json');
|
||||
writeFileSync(credsPath, '{}');
|
||||
writeFileSync(configPath, `
|
||||
telegram:
|
||||
bot_token: "test-token"
|
||||
allowed_chat_ids: [123]
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
automation:
|
||||
gcal:
|
||||
enabled: true
|
||||
credentials_file: "${credsPath}"
|
||||
token_file: "${join(testDir, 'missing-gcal-token.json')}"
|
||||
`);
|
||||
|
||||
const ctx: DoctorContext = { configPath, dataDir: testDir };
|
||||
const results = await runChecks(ctx);
|
||||
|
||||
const gcalCheck = results.find(r => r.label.includes('Google Calendar configured'));
|
||||
expect(gcalCheck?.status).toBe('warn');
|
||||
expect(gcalCheck?.detail).toContain('flynn gcal-auth');
|
||||
});
|
||||
|
||||
it('reports PASS for Google Calendar when token exists in auth store', async () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
process.env.HOME = testDir;
|
||||
const configPath = join(testDir, 'config.yaml');
|
||||
const credsPath = join(testDir, 'gcal-creds.json');
|
||||
writeFileSync(credsPath, '{}');
|
||||
mkdirSync(join(testDir, '.config/flynn'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(testDir, '.config/flynn/auth.json'),
|
||||
JSON.stringify({
|
||||
google: {
|
||||
services: {
|
||||
gcal: {
|
||||
token: { refresh_token: 'refresh-test' },
|
||||
updated_at: '2026-02-24T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(configPath, `
|
||||
telegram:
|
||||
bot_token: "test-token"
|
||||
allowed_chat_ids: [123]
|
||||
models:
|
||||
default:
|
||||
provider: anthropic
|
||||
model: claude-sonnet
|
||||
automation:
|
||||
gcal:
|
||||
enabled: true
|
||||
credentials_file: "${credsPath}"
|
||||
token_file: "${join(testDir, 'missing-gcal-token.json')}"
|
||||
`);
|
||||
|
||||
const ctx: DoctorContext = { configPath, dataDir: testDir };
|
||||
const results = await runChecks(ctx);
|
||||
|
||||
const gcalCheck = results.find(r => r.label.includes('Google Calendar configured'));
|
||||
expect(gcalCheck?.status).toBe('pass');
|
||||
expect(gcalCheck?.detail).toContain('auth_store');
|
||||
});
|
||||
|
||||
it('reports PASS for Gmail when enabled (poll only)', async () => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
const configPath = join(testDir, 'config.yaml');
|
||||
|
||||
+116
-14
@@ -31,6 +31,29 @@ const asRecord = (value: unknown): UnknownRecord | undefined => (
|
||||
value && typeof value === 'object' ? value as UnknownRecord : undefined
|
||||
);
|
||||
|
||||
function loadAuthStore(): Record<string, unknown> {
|
||||
const home = process.env.HOME ?? homedir();
|
||||
const authFile = resolve(home, '.config/flynn/auth.json');
|
||||
try {
|
||||
const raw = readFileSync(authFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return (parsed && typeof parsed === 'object') ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function hasGoogleServiceToken(store: Record<string, unknown>, service: 'gmail' | 'gcal' | 'gdocs' | 'gdrive' | 'gtasks'): boolean {
|
||||
const google = asRecord(store.google);
|
||||
const services = asRecord(google?.services);
|
||||
const record = asRecord(services?.[service]);
|
||||
const token = asRecord(record?.token);
|
||||
return Boolean(
|
||||
(typeof token?.refresh_token === 'string' && token.refresh_token.length > 0)
|
||||
|| (typeof token?.access_token === 'string' && token.access_token.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
const checkConfigExists: Check = async (ctx) => {
|
||||
if (existsSync(ctx.configPath)) {
|
||||
return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` };
|
||||
@@ -184,18 +207,6 @@ const checkModelConnectivity: Check = async (ctx) => {
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
const loadAuthStore = (): Record<string, unknown> => {
|
||||
const home = process.env.HOME ?? homedir();
|
||||
const authFile = resolve(home, '.config/flynn/auth.json');
|
||||
try {
|
||||
const raw = readFileSync(authFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return (parsed && typeof parsed === 'object') ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const store = loadAuthStore();
|
||||
|
||||
const storeOpenAIOAuthPresent = (): boolean => {
|
||||
@@ -551,7 +562,10 @@ const checkGmail: Check = async (ctx) => {
|
||||
}
|
||||
|
||||
const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
const store = loadAuthStore();
|
||||
const hasStoreToken = hasGoogleServiceToken(store, 'gmail');
|
||||
const hasTokenFile = existsSync(tokenPath);
|
||||
if (!hasTokenFile && !hasStoreToken) {
|
||||
return { status: 'warn', label: 'Gmail configured', detail: 'run `flynn gmail-auth` to authenticate' };
|
||||
}
|
||||
|
||||
@@ -592,12 +606,96 @@ const checkGmail: Check = async (ctx) => {
|
||||
}
|
||||
|
||||
modes.push('poll');
|
||||
const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer})`;
|
||||
const tokenSources: string[] = [];
|
||||
if (hasTokenFile) {tokenSources.push('token_file');}
|
||||
if (hasStoreToken) {tokenSources.push('auth_store');}
|
||||
const detail = `(${modes.join(' + ')} -> ${gmail.output.channel}/${gmail.output.peer}; token=${tokenSources.join('+')})`;
|
||||
const withWarnings = warnings.length > 0 ? `${detail} — ${warnings.join('; ')}` : detail;
|
||||
|
||||
return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail: withWarnings };
|
||||
};
|
||||
|
||||
function checkGoogleAutomationService(
|
||||
ctx: DoctorContext,
|
||||
opts: {
|
||||
service: 'gcal' | 'gdocs' | 'gdrive' | 'gtasks';
|
||||
label: string;
|
||||
authCommand: string;
|
||||
credentialsFile?: string;
|
||||
tokenFile?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): CheckResult {
|
||||
if (!ctx.config) {
|
||||
return { status: 'skip', label: opts.label, detail: '(config invalid)' };
|
||||
}
|
||||
if (!opts.enabled) {
|
||||
return { status: 'skip', label: opts.label, detail: '(not enabled)' };
|
||||
}
|
||||
|
||||
const credentialsPath = expandPath(opts.credentialsFile ?? `~/.config/flynn/${opts.service}-credentials.json`);
|
||||
if (!existsSync(credentialsPath)) {
|
||||
return { status: 'fail', label: opts.label, detail: `credentials file not found: ${credentialsPath}` };
|
||||
}
|
||||
|
||||
const tokenPath = expandPath(opts.tokenFile ?? `~/.config/flynn/${opts.service}-token.json`);
|
||||
const hasTokenFile = existsSync(tokenPath);
|
||||
const store = loadAuthStore();
|
||||
const hasStoreToken = hasGoogleServiceToken(store, opts.service);
|
||||
if (!hasTokenFile && !hasStoreToken) {
|
||||
return {
|
||||
status: 'warn',
|
||||
label: opts.label,
|
||||
detail: `run \`flynn ${opts.authCommand}\` to authenticate`,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenSources: string[] = [];
|
||||
if (hasTokenFile) {tokenSources.push('token_file');}
|
||||
if (hasStoreToken) {tokenSources.push('auth_store');}
|
||||
return {
|
||||
status: 'pass',
|
||||
label: opts.label,
|
||||
detail: `(token=${tokenSources.join('+')})`,
|
||||
};
|
||||
}
|
||||
|
||||
const checkGcal: Check = async (ctx) => checkGoogleAutomationService(ctx, {
|
||||
service: 'gcal',
|
||||
label: 'Google Calendar configured',
|
||||
authCommand: 'gcal-auth',
|
||||
enabled: ctx.config?.automation.gcal?.enabled,
|
||||
credentialsFile: ctx.config?.automation.gcal?.credentials_file,
|
||||
tokenFile: ctx.config?.automation.gcal?.token_file,
|
||||
});
|
||||
|
||||
const checkGdocs: Check = async (ctx) => checkGoogleAutomationService(ctx, {
|
||||
service: 'gdocs',
|
||||
label: 'Google Docs configured',
|
||||
authCommand: 'gdocs-auth',
|
||||
enabled: ctx.config?.automation.gdocs?.enabled,
|
||||
credentialsFile: ctx.config?.automation.gdocs?.credentials_file,
|
||||
tokenFile: ctx.config?.automation.gdocs?.token_file,
|
||||
});
|
||||
|
||||
const checkGdrive: Check = async (ctx) => checkGoogleAutomationService(ctx, {
|
||||
service: 'gdrive',
|
||||
label: 'Google Drive configured',
|
||||
authCommand: 'gdrive-auth',
|
||||
enabled: ctx.config?.automation.gdrive?.enabled,
|
||||
credentialsFile: ctx.config?.automation.gdrive?.credentials_file,
|
||||
tokenFile: ctx.config?.automation.gdrive?.token_file,
|
||||
});
|
||||
|
||||
const checkGtasks: Check = async (ctx) => checkGoogleAutomationService(ctx, {
|
||||
service: 'gtasks',
|
||||
label: 'Google Tasks configured',
|
||||
authCommand: 'gtasks-auth',
|
||||
enabled: ctx.config?.automation.gtasks?.enabled,
|
||||
credentialsFile: ctx.config?.automation.gtasks?.credentials_file,
|
||||
tokenFile: ctx.config?.automation.gtasks?.token_file,
|
||||
});
|
||||
|
||||
const checkMinioExtractors: Check = async (ctx) => {
|
||||
if (!ctx.config) {
|
||||
return { status: 'skip', label: 'MinIO ingest extractors', detail: '(config invalid)' };
|
||||
@@ -634,6 +732,10 @@ const allChecks: Check[] = [
|
||||
checkModelConnectivity,
|
||||
checkTelegram,
|
||||
checkGmail,
|
||||
checkGcal,
|
||||
checkGdocs,
|
||||
checkGdrive,
|
||||
checkGtasks,
|
||||
checkMinioExtractors,
|
||||
checkMcpServers,
|
||||
checkSkills,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { loadConfigSafe } from './shared.js';
|
||||
import { storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
||||
const REDIRECT_PORT = 3000;
|
||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
||||
} catch {
|
||||
// chmod may fail on some filesystems — not critical
|
||||
}
|
||||
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeGoogleToken('gcal', token as Record<string, unknown>, { tokenFile: tokenPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a temporary HTTP server to receive the OAuth callback. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { loadConfigSafe } from './shared.js';
|
||||
import { storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/documents.readonly',
|
||||
@@ -93,6 +94,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
||||
} catch {
|
||||
// chmod may fail on some filesystems — not critical
|
||||
}
|
||||
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeGoogleToken('gdocs', token as Record<string, unknown>, { tokenFile: tokenPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a temporary HTTP server to receive the OAuth callback. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { loadConfigSafe } from './shared.js';
|
||||
import { storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
const SCOPES = ['https://www.googleapis.com/auth/drive.readonly'];
|
||||
const REDIRECT_PORT = 3000;
|
||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
||||
} catch {
|
||||
// chmod may fail on some filesystems — not critical
|
||||
}
|
||||
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeGoogleToken('gdrive', token as Record<string, unknown>, { tokenFile: tokenPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a temporary HTTP server to receive the OAuth callback. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { loadConfigSafe } from './shared.js';
|
||||
import { storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
const SCOPES = [
|
||||
// Explicitly request Gmail settings scope required by filters.create.
|
||||
@@ -95,6 +96,10 @@ export function saveToken(tokenPath: string, token: unknown): void {
|
||||
} catch {
|
||||
// chmod may fail on some filesystems — not critical
|
||||
}
|
||||
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeGoogleToken('gmail', token as Record<string, unknown>, { tokenFile: tokenPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a temporary HTTP server to receive the OAuth callback. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Command } from 'commander';
|
||||
import { Command as CommanderProgram } from 'commander';
|
||||
import { registerGmailAuthCommand } from './gmail-auth.js';
|
||||
import { registerGcalAuthCommand } from './gcal-auth.js';
|
||||
import { registerGdocsAuthCommand } from './gdocs-auth.js';
|
||||
import { registerGdriveAuthCommand } from './gdrive-auth.js';
|
||||
import { registerGtasksAuthCommand } from './gtasks-auth.js';
|
||||
|
||||
const GOOGLE_AUTH_SERVICES = ['gmail', 'gcal', 'gdocs', 'gdrive', 'gtasks'] as const;
|
||||
type GoogleAuthService = (typeof GOOGLE_AUTH_SERVICES)[number];
|
||||
|
||||
function parseService(input: string): GoogleAuthService | null {
|
||||
const normalized = input.trim().toLowerCase();
|
||||
return GOOGLE_AUTH_SERVICES.find((service) => service === normalized) ?? null;
|
||||
}
|
||||
|
||||
function registerGoogleServiceCommand(program: Command, service: GoogleAuthService): void {
|
||||
if (service === 'gmail') {
|
||||
registerGmailAuthCommand(program);
|
||||
return;
|
||||
}
|
||||
if (service === 'gcal') {
|
||||
registerGcalAuthCommand(program);
|
||||
return;
|
||||
}
|
||||
if (service === 'gdocs') {
|
||||
registerGdocsAuthCommand(program);
|
||||
return;
|
||||
}
|
||||
if (service === 'gdrive') {
|
||||
registerGdriveAuthCommand(program);
|
||||
return;
|
||||
}
|
||||
registerGtasksAuthCommand(program);
|
||||
}
|
||||
|
||||
export function registerGoogleAuthCommand(program: Command): void {
|
||||
program
|
||||
.command('google-auth')
|
||||
.description('Authenticate Google services via OAuth2 (gmail, gcal, gdocs, gdrive, gtasks)')
|
||||
.requiredOption('-s, --service <service>', `Service: ${GOOGLE_AUTH_SERVICES.join('|')}`)
|
||||
.option('-c, --config <path>', 'Config file path')
|
||||
.option('--manual', 'Manually paste the authorization code instead of using a local server')
|
||||
.action(async (opts: { service: string; config?: string; manual?: boolean }) => {
|
||||
const service = parseService(opts.service);
|
||||
if (!service) {
|
||||
console.error(
|
||||
`Error: invalid --service "${opts.service}". Use one of: ${GOOGLE_AUTH_SERVICES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const scopedProgram = new CommanderProgram();
|
||||
registerGoogleServiceCommand(scopedProgram, service);
|
||||
|
||||
const args = ['node', 'flynn', `${service}-auth`];
|
||||
if (opts.config) {
|
||||
args.push('--config', opts.config);
|
||||
}
|
||||
if (opts.manual) {
|
||||
args.push('--manual');
|
||||
}
|
||||
await scopedProgram.parseAsync(args, { from: 'user' });
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { loadConfigSafe } from './shared.js';
|
||||
import { storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
const SCOPES = ['https://www.googleapis.com/auth/tasks.readonly'];
|
||||
const REDIRECT_PORT = 3000;
|
||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
||||
} catch {
|
||||
// chmod may fail on some filesystems — not critical
|
||||
}
|
||||
|
||||
if (token && typeof token === 'object' && !Array.isArray(token)) {
|
||||
storeGoogleToken('gtasks', token as Record<string, unknown>, { tokenFile: tokenPath });
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a temporary HTTP server to receive the OAuth callback. */
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('CLI program', () => {
|
||||
expect(commandNames).toContain('anthropic-auth');
|
||||
expect(commandNames).toContain('zai-auth');
|
||||
expect(commandNames).toContain('gemini-auth');
|
||||
expect(commandNames).toContain('google-auth');
|
||||
});
|
||||
|
||||
it('registers doctor strict flag on doctor command', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js';
|
||||
import { registerGdocsAuthCommand } from './gdocs-auth.js';
|
||||
import { registerGdriveAuthCommand } from './gdrive-auth.js';
|
||||
import { registerGtasksAuthCommand } from './gtasks-auth.js';
|
||||
import { registerGoogleAuthCommand } from './google-auth.js';
|
||||
import { registerOpenaiAuthCommand } from './openai-auth.js';
|
||||
import { registerOpenaiKeyCommand } from './openai-key.js';
|
||||
import { registerZaiAuthCommand } from './zai-auth.js';
|
||||
@@ -54,6 +55,7 @@ export function createProgram(): Command {
|
||||
registerGdocsAuthCommand(program);
|
||||
registerGdriveAuthCommand(program);
|
||||
registerGtasksAuthCommand(program);
|
||||
registerGoogleAuthCommand(program);
|
||||
registerOpenaiAuthCommand(program);
|
||||
registerOpenaiKeyCommand(program);
|
||||
registerZaiAuthCommand(program);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import type { GoogleService } from '../auth/google.js';
|
||||
import { loadStoredGoogleToken, storeGoogleToken } from '../auth/google.js';
|
||||
|
||||
interface GoogleServiceMeta {
|
||||
tokenFile: string;
|
||||
command: string;
|
||||
configPath: string;
|
||||
}
|
||||
|
||||
const GOOGLE_SERVICE_META: Record<GoogleService, GoogleServiceMeta> = {
|
||||
gmail: {
|
||||
tokenFile: '~/.config/flynn/gmail-token.json',
|
||||
command: 'gmail-auth',
|
||||
configPath: 'automation.gmail',
|
||||
},
|
||||
gcal: {
|
||||
tokenFile: '~/.config/flynn/gcal-token.json',
|
||||
command: 'gcal-auth',
|
||||
configPath: 'automation.gcal',
|
||||
},
|
||||
gdocs: {
|
||||
tokenFile: '~/.config/flynn/gdocs-token.json',
|
||||
command: 'gdocs-auth',
|
||||
configPath: 'automation.gdocs',
|
||||
},
|
||||
gdrive: {
|
||||
tokenFile: '~/.config/flynn/gdrive-token.json',
|
||||
command: 'gdrive-auth',
|
||||
configPath: 'automation.gdrive',
|
||||
},
|
||||
gtasks: {
|
||||
tokenFile: '~/.config/flynn/gtasks-token.json',
|
||||
command: 'gtasks-auth',
|
||||
configPath: 'automation.gtasks',
|
||||
},
|
||||
};
|
||||
|
||||
export interface GoogleOAuthCredentials {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
redirect_uris?: string[];
|
||||
}
|
||||
|
||||
export interface CreateGoogleOAuthClientOptions {
|
||||
service: GoogleService;
|
||||
credentialsFile?: string;
|
||||
tokenFile?: string;
|
||||
}
|
||||
|
||||
export function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
export function readGoogleOAuthCredentials(
|
||||
service: GoogleService,
|
||||
credentialsFile?: string,
|
||||
): GoogleOAuthCredentials {
|
||||
if (!credentialsFile) {
|
||||
throw new Error(`No credentials_file configured. Set ${GOOGLE_SERVICE_META[service].configPath}.credentials_file in config.`);
|
||||
}
|
||||
|
||||
const credentialsPath = expandPath(credentialsFile);
|
||||
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 };
|
||||
}
|
||||
|
||||
export function tokenPathForGoogleService(service: GoogleService, configuredTokenPath?: string): string {
|
||||
return expandPath(configuredTokenPath ?? GOOGLE_SERVICE_META[service].tokenFile);
|
||||
}
|
||||
|
||||
function readTokenFromFile(tokenPath: string): Record<string, unknown> | null {
|
||||
if (!existsSync(tokenPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(tokenPath, 'utf-8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTokenToFile(tokenPath: string, token: Record<string, 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
|
||||
}
|
||||
}
|
||||
|
||||
function loadTokenWithMigration(service: GoogleService, tokenPath: string, credentialsPath: string): Record<string, unknown> | null {
|
||||
const fromStore = loadStoredGoogleToken(service);
|
||||
if (fromStore) {
|
||||
return fromStore;
|
||||
}
|
||||
|
||||
const fromFile = readTokenFromFile(tokenPath);
|
||||
if (!fromFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
storeGoogleToken(service, fromFile, {
|
||||
tokenFile: tokenPath,
|
||||
credentialsFile: credentialsPath,
|
||||
});
|
||||
return fromFile;
|
||||
}
|
||||
|
||||
export function authCommandForGoogleService(service: GoogleService): string {
|
||||
return GOOGLE_SERVICE_META[service].command;
|
||||
}
|
||||
|
||||
export function createGoogleOAuth2Client(options: CreateGoogleOAuthClientOptions): Auth.OAuth2Client {
|
||||
const { service, credentialsFile, tokenFile } = options;
|
||||
const creds = readGoogleOAuthCredentials(service, credentialsFile);
|
||||
const credentialsPath = expandPath(credentialsFile ?? '');
|
||||
const resolvedTokenPath = tokenPathForGoogleService(service, tokenFile);
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
creds.client_id,
|
||||
creds.client_secret,
|
||||
creds.redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
let currentToken = loadTokenWithMigration(service, resolvedTokenPath, credentialsPath);
|
||||
if (!currentToken) {
|
||||
throw new Error(
|
||||
`Token file not found: ${resolvedTokenPath}. Run "flynn ${authCommandForGoogleService(service)}" to authenticate.`,
|
||||
);
|
||||
}
|
||||
|
||||
oauth2Client.setCredentials(currentToken);
|
||||
if (typeof oauth2Client.on === 'function') {
|
||||
oauth2Client.on('tokens', (newTokens) => {
|
||||
currentToken = { ...currentToken, ...newTokens };
|
||||
storeGoogleToken(service, currentToken, {
|
||||
tokenFile: resolvedTokenPath,
|
||||
credentialsFile: credentialsPath,
|
||||
});
|
||||
writeTokenToFile(resolvedTokenPath, currentToken);
|
||||
});
|
||||
}
|
||||
|
||||
return oauth2Client;
|
||||
}
|
||||
@@ -1,52 +1,14 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google } from 'googleapis';
|
||||
import type { GcalConfig } from '../../config/schema.js';
|
||||
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
|
||||
/** Expand ~ to home directory. */
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/** Create an OAuth2 client from Google Calendar config (credentials + token files). */
|
||||
function createOAuth2Client(config: NonNullable<GcalConfig>): Auth.OAuth2Client {
|
||||
const credentialsPath = config.credentials_file;
|
||||
if (!credentialsPath) {
|
||||
throw new Error('No credentials_file configured. Set automation.gcal.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, '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');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gcal-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gcal-auth" to authenticate.`);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
return oauth2Client;
|
||||
function createOAuth2Client(config: NonNullable<GcalConfig>) {
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gcal',
|
||||
credentialsFile: config.credentials_file,
|
||||
tokenFile: config.token_file,
|
||||
});
|
||||
}
|
||||
|
||||
interface EventSummary {
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google } from 'googleapis';
|
||||
import type { GdocsConfig } from '../../config/schema.js';
|
||||
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
|
||||
/** Expand ~ to home directory. */
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/** Create an OAuth2 client from Google Docs config (credentials + token files). */
|
||||
function createOAuth2Client(config: NonNullable<GdocsConfig>): Auth.OAuth2Client {
|
||||
const credentialsPath = config.credentials_file;
|
||||
if (!credentialsPath) {
|
||||
throw new Error('No credentials_file configured. Set automation.gdocs.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, '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');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gdocs-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdocs-auth" to authenticate.`);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
return oauth2Client;
|
||||
function createOAuth2Client(config: NonNullable<GdocsConfig>) {
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gdocs',
|
||||
credentialsFile: config.credentials_file,
|
||||
tokenFile: config.token_file,
|
||||
});
|
||||
}
|
||||
|
||||
interface DocSummary {
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google } from 'googleapis';
|
||||
import type { GdriveConfig } from '../../config/schema.js';
|
||||
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
|
||||
/** Expand ~ to home directory. */
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/** Create an OAuth2 client from config (credentials + token files). */
|
||||
function createOAuth2Client(config: NonNullable<GdriveConfig>): Auth.OAuth2Client {
|
||||
const credentialsPath = config.credentials_file;
|
||||
if (!credentialsPath) {
|
||||
throw new Error('No credentials_file configured. Set automation.gdrive.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, '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');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gdrive-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gdrive-auth" to authenticate.`);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
return oauth2Client;
|
||||
function createOAuth2Client(config: NonNullable<GdriveConfig>) {
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gdrive',
|
||||
credentialsFile: config.credentials_file,
|
||||
tokenFile: config.token_file,
|
||||
});
|
||||
}
|
||||
|
||||
interface FileSummary {
|
||||
|
||||
@@ -1,53 +1,15 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google } from 'googleapis';
|
||||
import type { GmailConfig } from '../../config/schema.js';
|
||||
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import { sanitizeHtml } from '../../utils/html.js';
|
||||
|
||||
/** Expand ~ to home directory. */
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/** Create an OAuth2 client from Gmail config (credentials + token files). */
|
||||
function createOAuth2Client(config: NonNullable<GmailConfig>): Auth.OAuth2Client {
|
||||
const credentialsPath = config.credentials_file;
|
||||
if (!credentialsPath) {
|
||||
throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, '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');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gmail-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
return oauth2Client;
|
||||
function createOAuth2Client(config: NonNullable<GmailConfig>) {
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gmail',
|
||||
credentialsFile: config.credentials_file,
|
||||
tokenFile: config.token_file,
|
||||
});
|
||||
}
|
||||
|
||||
interface EmailSummary {
|
||||
|
||||
@@ -1,52 +1,14 @@
|
||||
import { google, type Auth } from 'googleapis';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { google } from 'googleapis';
|
||||
import type { GtasksConfig } from '../../config/schema.js';
|
||||
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
|
||||
/** Expand ~ to home directory. */
|
||||
function expandPath(p: string): string {
|
||||
if (p.startsWith('~/') || p === '~') {
|
||||
return resolve(homedir(), p.slice(2));
|
||||
}
|
||||
return resolve(p);
|
||||
}
|
||||
|
||||
/** Create an OAuth2 client from Google Tasks config (credentials + token files). */
|
||||
function createOAuth2Client(config: NonNullable<GtasksConfig>): Auth.OAuth2Client {
|
||||
const credentialsPath = config.credentials_file;
|
||||
if (!credentialsPath) {
|
||||
throw new Error('No credentials_file configured. Set automation.gtasks.credentials_file in config.');
|
||||
}
|
||||
|
||||
const expandedCredsPath = expandPath(credentialsPath);
|
||||
if (!existsSync(expandedCredsPath)) {
|
||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||
}
|
||||
|
||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, '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');
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_uris?.[0] ?? 'http://localhost',
|
||||
);
|
||||
|
||||
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gtasks-token.json');
|
||||
if (!existsSync(tokenPath)) {
|
||||
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gtasks-auth" to authenticate.`);
|
||||
}
|
||||
|
||||
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
|
||||
oauth2Client.setCredentials(token);
|
||||
|
||||
return oauth2Client;
|
||||
function createOAuth2Client(config: NonNullable<GtasksConfig>) {
|
||||
return createGoogleOAuth2Client({
|
||||
service: 'gtasks',
|
||||
credentialsFile: config.credentials_file,
|
||||
tokenFile: config.token_file,
|
||||
});
|
||||
}
|
||||
|
||||
interface TaskListSummary {
|
||||
|
||||
Reference in New Issue
Block a user