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,
|
getZaiApiKey,
|
||||||
type ZaiAuthInfo,
|
type ZaiAuthInfo,
|
||||||
} from './zai.js';
|
} 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 { google, type Auth } from 'googleapis';
|
||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { dirname, resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { v1 } from '@google-cloud/pubsub';
|
import type { v1 } from '@google-cloud/pubsub';
|
||||||
import type { GmailConfig } from '../config/schema.js';
|
import type { GmailConfig } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||||
import { parseInterval } from './heartbeat.js';
|
import { parseInterval } from './heartbeat.js';
|
||||||
import { sanitizeHtml } from '../utils/html.js';
|
import { sanitizeHtml } from '../utils/html.js';
|
||||||
import { auditLogger } from '../audit/index.js';
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
import { createGoogleOAuth2Client, expandPath } from '../google/oauth.js';
|
||||||
|
|
||||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -178,6 +177,13 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
this.messageHandler = handler;
|
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.
|
* Handle a Pub/Sub push notification from Google.
|
||||||
* Called by the gateway when POST /gmail/push is received.
|
* 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.');
|
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)) {
|
if (!existsSync(expandedCredsPath)) {
|
||||||
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
|
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') {
|
if (project_id && typeof project_id === 'string') {
|
||||||
this.googleProjectId = project_id;
|
this.googleProjectId = project_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client_id || !client_secret) {
|
return createGoogleOAuth2Client({
|
||||||
throw new Error('Invalid credentials file — missing client_id or client_secret');
|
service: 'gmail',
|
||||||
}
|
credentialsFile: this.config.credentials_file,
|
||||||
|
tokenFile: this.config.token_file,
|
||||||
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 oauth2Client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -608,30 +591,4 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
.replace(/\{\{labels\}\}/g, email.labels.join(', '));
|
.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', () => {
|
describe('doctor checks', () => {
|
||||||
const testDir = join(tmpdir(), 'flynn-test-doctor');
|
const testDir = join(tmpdir(), 'flynn-test-doctor');
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
try { rmSync(testDir, { recursive: true }); } catch {}
|
try { rmSync(testDir, { recursive: true }); } catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,6 +203,97 @@ automation:
|
|||||||
expect(gmailCheck?.detail).toContain('flynn gmail-auth');
|
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 () => {
|
it('reports PASS for Gmail when enabled (poll only)', async () => {
|
||||||
mkdirSync(testDir, { recursive: true });
|
mkdirSync(testDir, { recursive: true });
|
||||||
const configPath = join(testDir, 'config.yaml');
|
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
|
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) => {
|
const checkConfigExists: Check = async (ctx) => {
|
||||||
if (existsSync(ctx.configPath)) {
|
if (existsSync(ctx.configPath)) {
|
||||||
return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` };
|
return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` };
|
||||||
@@ -184,18 +207,6 @@ const checkModelConnectivity: Check = async (ctx) => {
|
|||||||
return 'auto';
|
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 store = loadAuthStore();
|
||||||
|
|
||||||
const storeOpenAIOAuthPresent = (): boolean => {
|
const storeOpenAIOAuthPresent = (): boolean => {
|
||||||
@@ -551,7 +562,10 @@ const checkGmail: Check = async (ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenPath = expandPath(gmail.token_file ?? '~/.config/flynn/gmail-token.json');
|
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' };
|
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');
|
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;
|
const withWarnings = warnings.length > 0 ? `${detail} — ${warnings.join('; ')}` : detail;
|
||||||
|
|
||||||
return { status: warnings.length > 0 ? 'warn' : 'pass', label: 'Gmail configured', detail: withWarnings };
|
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) => {
|
const checkMinioExtractors: Check = async (ctx) => {
|
||||||
if (!ctx.config) {
|
if (!ctx.config) {
|
||||||
return { status: 'skip', label: 'MinIO ingest extractors', detail: '(config invalid)' };
|
return { status: 'skip', label: 'MinIO ingest extractors', detail: '(config invalid)' };
|
||||||
@@ -634,6 +732,10 @@ const allChecks: Check[] = [
|
|||||||
checkModelConnectivity,
|
checkModelConnectivity,
|
||||||
checkTelegram,
|
checkTelegram,
|
||||||
checkGmail,
|
checkGmail,
|
||||||
|
checkGcal,
|
||||||
|
checkGdocs,
|
||||||
|
checkGdrive,
|
||||||
|
checkGtasks,
|
||||||
checkMinioExtractors,
|
checkMinioExtractors,
|
||||||
checkMcpServers,
|
checkMcpServers,
|
||||||
checkSkills,
|
checkSkills,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { homedir } from 'os';
|
|||||||
import { createServer, type Server } from 'http';
|
import { createServer, type Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
import { storeGoogleToken } from '../auth/google.js';
|
||||||
|
|
||||||
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
||||||
const REDIRECT_PORT = 3000;
|
const REDIRECT_PORT = 3000;
|
||||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some filesystems — not critical
|
// 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. */
|
/** 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 { createServer, type Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
import { storeGoogleToken } from '../auth/google.js';
|
||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
'https://www.googleapis.com/auth/documents.readonly',
|
'https://www.googleapis.com/auth/documents.readonly',
|
||||||
@@ -93,6 +94,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some filesystems — not critical
|
// 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. */
|
/** 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 { createServer, type Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
import { storeGoogleToken } from '../auth/google.js';
|
||||||
|
|
||||||
const SCOPES = ['https://www.googleapis.com/auth/drive.readonly'];
|
const SCOPES = ['https://www.googleapis.com/auth/drive.readonly'];
|
||||||
const REDIRECT_PORT = 3000;
|
const REDIRECT_PORT = 3000;
|
||||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some filesystems — not critical
|
// 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. */
|
/** 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 { createServer, type Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
import { storeGoogleToken } from '../auth/google.js';
|
||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
// Explicitly request Gmail settings scope required by filters.create.
|
// Explicitly request Gmail settings scope required by filters.create.
|
||||||
@@ -95,6 +96,10 @@ export function saveToken(tokenPath: string, token: unknown): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some filesystems — not critical
|
// 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. */
|
/** 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 { createServer, type Server } from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
import { storeGoogleToken } from '../auth/google.js';
|
||||||
|
|
||||||
const SCOPES = ['https://www.googleapis.com/auth/tasks.readonly'];
|
const SCOPES = ['https://www.googleapis.com/auth/tasks.readonly'];
|
||||||
const REDIRECT_PORT = 3000;
|
const REDIRECT_PORT = 3000;
|
||||||
@@ -90,6 +91,10 @@ function saveToken(tokenPath: string, token: unknown): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// chmod may fail on some filesystems — not critical
|
// 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. */
|
/** 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('anthropic-auth');
|
||||||
expect(commandNames).toContain('zai-auth');
|
expect(commandNames).toContain('zai-auth');
|
||||||
expect(commandNames).toContain('gemini-auth');
|
expect(commandNames).toContain('gemini-auth');
|
||||||
|
expect(commandNames).toContain('google-auth');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers doctor strict flag on doctor command', () => {
|
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 { registerGdocsAuthCommand } from './gdocs-auth.js';
|
||||||
import { registerGdriveAuthCommand } from './gdrive-auth.js';
|
import { registerGdriveAuthCommand } from './gdrive-auth.js';
|
||||||
import { registerGtasksAuthCommand } from './gtasks-auth.js';
|
import { registerGtasksAuthCommand } from './gtasks-auth.js';
|
||||||
|
import { registerGoogleAuthCommand } from './google-auth.js';
|
||||||
import { registerOpenaiAuthCommand } from './openai-auth.js';
|
import { registerOpenaiAuthCommand } from './openai-auth.js';
|
||||||
import { registerOpenaiKeyCommand } from './openai-key.js';
|
import { registerOpenaiKeyCommand } from './openai-key.js';
|
||||||
import { registerZaiAuthCommand } from './zai-auth.js';
|
import { registerZaiAuthCommand } from './zai-auth.js';
|
||||||
@@ -54,6 +55,7 @@ export function createProgram(): Command {
|
|||||||
registerGdocsAuthCommand(program);
|
registerGdocsAuthCommand(program);
|
||||||
registerGdriveAuthCommand(program);
|
registerGdriveAuthCommand(program);
|
||||||
registerGtasksAuthCommand(program);
|
registerGtasksAuthCommand(program);
|
||||||
|
registerGoogleAuthCommand(program);
|
||||||
registerOpenaiAuthCommand(program);
|
registerOpenaiAuthCommand(program);
|
||||||
registerOpenaiKeyCommand(program);
|
registerOpenaiKeyCommand(program);
|
||||||
registerZaiAuthCommand(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 { google } from 'googleapis';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { GcalConfig } from '../../config/schema.js';
|
import type { GcalConfig } from '../../config/schema.js';
|
||||||
|
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||||
import type { Tool, ToolResult } from '../types.js';
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
|
|
||||||
/** Expand ~ to home directory. */
|
function createOAuth2Client(config: NonNullable<GcalConfig>) {
|
||||||
function expandPath(p: string): string {
|
return createGoogleOAuth2Client({
|
||||||
if (p.startsWith('~/') || p === '~') {
|
service: 'gcal',
|
||||||
return resolve(homedir(), p.slice(2));
|
credentialsFile: config.credentials_file,
|
||||||
}
|
tokenFile: config.token_file,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventSummary {
|
interface EventSummary {
|
||||||
|
|||||||
@@ -1,52 +1,14 @@
|
|||||||
import { google, type Auth } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { GdocsConfig } from '../../config/schema.js';
|
import type { GdocsConfig } from '../../config/schema.js';
|
||||||
|
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||||
import type { Tool, ToolResult } from '../types.js';
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
|
|
||||||
/** Expand ~ to home directory. */
|
function createOAuth2Client(config: NonNullable<GdocsConfig>) {
|
||||||
function expandPath(p: string): string {
|
return createGoogleOAuth2Client({
|
||||||
if (p.startsWith('~/') || p === '~') {
|
service: 'gdocs',
|
||||||
return resolve(homedir(), p.slice(2));
|
credentialsFile: config.credentials_file,
|
||||||
}
|
tokenFile: config.token_file,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocSummary {
|
interface DocSummary {
|
||||||
|
|||||||
@@ -1,52 +1,14 @@
|
|||||||
import { google, type Auth } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { GdriveConfig } from '../../config/schema.js';
|
import type { GdriveConfig } from '../../config/schema.js';
|
||||||
|
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||||
import type { Tool, ToolResult } from '../types.js';
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
|
|
||||||
/** Expand ~ to home directory. */
|
function createOAuth2Client(config: NonNullable<GdriveConfig>) {
|
||||||
function expandPath(p: string): string {
|
return createGoogleOAuth2Client({
|
||||||
if (p.startsWith('~/') || p === '~') {
|
service: 'gdrive',
|
||||||
return resolve(homedir(), p.slice(2));
|
credentialsFile: config.credentials_file,
|
||||||
}
|
tokenFile: config.token_file,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSummary {
|
interface FileSummary {
|
||||||
|
|||||||
@@ -1,53 +1,15 @@
|
|||||||
import { google, type Auth } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { GmailConfig } from '../../config/schema.js';
|
import type { GmailConfig } from '../../config/schema.js';
|
||||||
|
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||||
import type { Tool, ToolResult } from '../types.js';
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
import { sanitizeHtml } from '../../utils/html.js';
|
import { sanitizeHtml } from '../../utils/html.js';
|
||||||
|
|
||||||
/** Expand ~ to home directory. */
|
function createOAuth2Client(config: NonNullable<GmailConfig>) {
|
||||||
function expandPath(p: string): string {
|
return createGoogleOAuth2Client({
|
||||||
if (p.startsWith('~/') || p === '~') {
|
service: 'gmail',
|
||||||
return resolve(homedir(), p.slice(2));
|
credentialsFile: config.credentials_file,
|
||||||
}
|
tokenFile: config.token_file,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmailSummary {
|
interface EmailSummary {
|
||||||
|
|||||||
@@ -1,52 +1,14 @@
|
|||||||
import { google, type Auth } from 'googleapis';
|
import { google } from 'googleapis';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { GtasksConfig } from '../../config/schema.js';
|
import type { GtasksConfig } from '../../config/schema.js';
|
||||||
|
import { createGoogleOAuth2Client } from '../../google/oauth.js';
|
||||||
import type { Tool, ToolResult } from '../types.js';
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
|
|
||||||
/** Expand ~ to home directory. */
|
function createOAuth2Client(config: NonNullable<GtasksConfig>) {
|
||||||
function expandPath(p: string): string {
|
return createGoogleOAuth2Client({
|
||||||
if (p.startsWith('~/') || p === '~') {
|
service: 'gtasks',
|
||||||
return resolve(homedir(), p.slice(2));
|
credentialsFile: config.credentials_file,
|
||||||
}
|
tokenFile: config.token_file,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskListSummary {
|
interface TaskListSummary {
|
||||||
|
|||||||
Reference in New Issue
Block a user