From 210bc68493d8ab5dcea41f85e0d142deb07a28e0 Mon Sep 17 00:00:00 2001 From: cjus Date: Mon, 11 May 2026 09:20:54 -0600 Subject: [PATCH] fix gmail packaging gaps for binary installs (PNX-171) - thread ctx.solracHome through gmail client via createGmailClientApi factory; paths root under $SOLRAC_HOME/integrations/gmail/ instead of hardcoded ~/.solrac/gmail - add `solrac gmail-auth ` subcommand so curl-pipe binary installs can bootstrap OAuth without a source checkout; banner prints resolved home + gmail dir, missing-credentials error links to Google Cloud Console - move scripts/gmail-auth.ts to src/integrations-builtin/gmail/auth-cli.ts so it ships in the compiled binary; single path for source + binary - update USAGE.md with new flow + migration note for pre-PNX-171 layout --- .gitignore | 2 + docs/INSTALL.md | 11 + docs/USAGE.md | 51 ++- src/config.ts | 2 +- .../integrations-builtin/gmail/auth-cli.ts | 164 ++++---- src/integrations-builtin/gmail/client.test.ts | 147 +++++++ src/integrations-builtin/gmail/client.ts | 361 ++++++++++-------- src/integrations-builtin/gmail/index.ts | 36 +- src/integrations-builtin/notion/index.test.ts | 1 + src/integrations.test.ts | 2 + src/integrations.ts | 10 +- src/main.ts | 36 +- test/smokes/integrations.ts | 4 +- test/smokes/notion-smoke.ts | 1 + test/smokes/ollama.ts | 4 +- 15 files changed, 551 insertions(+), 281 deletions(-) rename scripts/gmail-auth.ts => src/integrations-builtin/gmail/auth-cli.ts (51%) create mode 100644 src/integrations-builtin/gmail/client.test.ts diff --git a/.gitignore b/.gitignore index 637e65c..47d62c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ node_modules/ # npm prepare/pretest/prebuild:bin lifecycle hooks; manually via # `npm run embed:web-sanitize`. src/web-sanitize.embed.js + +integrations/gmail/ diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f268016..cbb6ef7 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -63,6 +63,17 @@ solrac You should see structured JSON log lines on stdout. DM your bot — the first message should produce a šŸ¤” / šŸ¦™ / šŸ™‚ thinking stub within a second. +## CLI subcommands + +The binary supports a small set of subcommands beyond the default server boot: + +| Command | Purpose | +|---------|---------| +| `solrac` | Boot the server (default). | +| `solrac gmail-auth ` | One-time OAuth bootstrap for a Gmail account. Opens the browser, captures the redirect, and writes tokens to `$SOLRAC_HOME/integrations/gmail/.json`. See [`docs/USAGE.md`](./USAGE.md) → Gmail integration for the full setup. | + +Subcommands do **not** require `ANTHROPIC_API_KEY`, `TELEGRAM_BOT_TOKEN`, or `ALLOWLIST_BOOTSTRAP` to be set — they run before solrac's full env validation, so a fresh install can authenticate Gmail accounts before configuring the bot. + ## Upgrading Just rerun the install command: diff --git a/docs/USAGE.md b/docs/USAGE.md index 1db0f2f..556c662 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -690,6 +690,8 @@ The destructive ops (`delete`, `send`) carry **two** safeguards: solrac's Telegr ##### Setup (~5 min one-time + ~1 min per account) +All gmail on-disk state lives under `$SOLRAC_HOME/integrations/gmail/`. With the default `$SOLRAC_HOME=~/.solrac` that's `~/.solrac/integrations/gmail/`; with a custom value (e.g. `SOLRAC_HOME=/var/solrac`) the paths follow. + ```bash # 1. Install Gmail's optional runtime deps. Run this in the same directory # as solrac's `package.json` (the cloned repo root, e.g. `~/code/solrac`). @@ -699,17 +701,25 @@ The destructive ops (`delete`, `send`) carry **two** safeguards: solrac's Telegr cd /path/to/solrac # the directory with package.json npm install --save googleapis google-auth-library -# 2. Get an OAuth client credentials.json from Google Cloud Console: -# APIs & Services → Credentials → Create credentials → OAuth client ID. -# Application type: "Desktop app". -# Save the downloaded JSON to: -mkdir -p ~/.solrac/gmail -mv ~/Downloads/client_secret_*.json ~/.solrac/gmail/credentials.json +# (Skip step 1 entirely on a binary install — `solrac` ships googleapis + +# google-auth-library bundled.) -# 3. Authenticate one or more accounts (opens browser per call). -bun scripts/gmail-auth.ts personal -bun scripts/gmail-auth.ts work -# Each writes ~/.solrac/gmail/.json + appends to accounts.json. +# 2. Get an OAuth client credentials.json from Google Cloud Console: +# - Enable Gmail API: https://console.cloud.google.com/apis/library/gmail.googleapis.com +# - Create OAuth client: https://console.cloud.google.com/apis/credentials +# → Create Credentials → OAuth client ID → Desktop app +# Save the downloaded JSON to (substitute $SOLRAC_HOME as needed): +mkdir -p ~/.solrac/integrations/gmail +mv ~/Downloads/client_secret_*.json ~/.solrac/integrations/gmail/credentials.json + +# 3. Authenticate one or more accounts (opens browser per call). Works +# identically from a source checkout (`bun src/main.ts gmail-auth …`) or +# a curl-pipe binary install (`solrac gmail-auth …`). +solrac gmail-auth personal +solrac gmail-auth work +# Each writes $SOLRAC_HOME/integrations/gmail/.json + appends to +# accounts.json. The command prints the resolved solracHome + gmailDir on +# its first two lines so the operator sees exactly where files land. # 4. Restart solrac. Boot log should show: # integrations.gmail.loaded accountCount:2 toolCount:11 @@ -719,11 +729,22 @@ If Gmail is unconfigured, the boot log distinguishes which precondition failed: | Log event | Meaning | |---|---| -| `integrations.gmail.deps_missing` | `googleapis` / `google-auth-library` not installed. Run the `npm install` above. | -| `integrations.gmail.disabled` | `~/.solrac/gmail/credentials.json` not found. Get it from Google Cloud Console. | -| `integrations.gmail.no_accounts` | Credentials present, but no accounts authed. Run `bun scripts/gmail-auth.ts `. | +| `integrations.gmail.deps_missing` | `googleapis` / `google-auth-library` not installed. Run the `npm install` above. (Source checkouts only — the binary bundles these.) | +| `integrations.gmail.disabled` | `credentials.json` not found at the path in `expectedAt`. Get it from Google Cloud Console. | +| `integrations.gmail.no_accounts` | Credentials present, but no accounts authed. Run `solrac gmail-auth `. | | `integrations.gmail.loaded` | All set. Tool count + account count reported. | +##### Migrating from `~/.solrac/gmail/` (pre-PNX-171 layout) + +Before PNX-171, gmail state lived in `~/.solrac/gmail/` regardless of `SOLRAC_HOME`. Move it once: + +```bash +mkdir -p "$SOLRAC_HOME/integrations" +mv ~/.solrac/gmail "$SOLRAC_HOME/integrations/gmail" +``` + +If `SOLRAC_HOME` is unset and you're on the default `~/.solrac`, the move is `mv ~/.solrac/gmail ~/.solrac/integrations/gmail`. No re-auth needed — token files carry over verbatim. + ##### Use cases ``` @@ -737,8 +758,8 @@ The third one will require approving the send via inline-keyboard. The agent mus ##### Limits to know - **`gmail_delete_message` is permanent.** Use `gmail_trash_message` (Trash, recoverable 30 days) for normal deletes. The permanent-delete tool exists for cases where you really mean it. -- **OAuth refresh** is automatic. The integration writes refreshed tokens back to `~/.solrac/gmail/.json` whenever Google rotates them. -- **Scope is fixed** at read + modify + send + userinfo (for email-address discovery). To narrow scope, edit `scripts/gmail-auth.ts` SCOPES and re-auth per account. +- **OAuth refresh** is automatic. The integration writes refreshed tokens back to `$SOLRAC_HOME/integrations/gmail/.json` whenever Google rotates them. +- **Scope is fixed** at read + modify + send + userinfo (for email-address discovery). To narrow scope, edit `src/integrations-builtin/gmail/auth-cli.ts` SCOPES and re-auth per account. - **The `googleapis` package is ~30MB.** That's why it's an optional dep, not a runtime requirement. If you don't want Gmail, skip step 1 and Gmail self-gates on `deps_missing`. #### `notion` — single-token Notion workspace diff --git a/src/config.ts b/src/config.ts index 1b81075..2a13b8a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -231,7 +231,7 @@ function parseDefaultEngine(raw: string | undefined): DefaultEngine { * flag) so the same binary behaves correctly whether it's `bun src/main.ts` * in a checkout or `solrac` from `/usr/local/bin/`. */ -function resolveSolracHome(raw: string | undefined): string { +export function resolveSolracHome(raw: string | undefined): string { if (raw && raw.trim() !== "") return resolve(raw.trim()); if (existsSync(resolve(process.cwd(), "SOUL.md"))) return process.cwd(); return resolve(homedir(), ".solrac"); diff --git a/scripts/gmail-auth.ts b/src/integrations-builtin/gmail/auth-cli.ts similarity index 51% rename from scripts/gmail-auth.ts rename to src/integrations-builtin/gmail/auth-cli.ts index 8cb74be..d1dfc20 100644 --- a/scripts/gmail-auth.ts +++ b/src/integrations-builtin/gmail/auth-cli.ts @@ -1,49 +1,30 @@ -#!/usr/bin/env bun /** - * @fileoverview Gmail OAuth bootstrap CLI for the blessed Gmail integration. - * @purpose One-time interactive auth: opens a browser for Google's OAuth - * consent screen, captures the redirect, exchanges the code for - * tokens, and writes them to `~/.solrac/gmail/.json` plus - * `~/.solrac/gmail/accounts.json`. + * @fileoverview Gmail OAuth bootstrap, surfaced as `solrac gmail-auth `. + * @purpose One-time interactive OAuth per Gmail account the agent can access. + * Opens a browser for Google consent, captures the redirect on a + * loopback server, exchanges the code for tokens, and writes them + * to `$SOLRAC_HOME/integrations/gmail/.json` plus an entry + * in `accounts.json`. * - * Run once per Gmail account the operator wants the agent to access: - * - * bun scripts/gmail-auth.ts personal - * bun scripts/gmail-auth.ts work - * - * Prerequisite: download an OAuth client credentials.json from - * Google Cloud Console (APIs & Services → Credentials) and save it to - * `~/.solrac/gmail/credentials.json` first. The redirect URI in your - * OAuth client config should include `http://localhost` (the script - * binds a random ephemeral port at runtime; Google accepts loopback - * with any port). - * - * Adapted from `apps/utcp-tools/scripts/gmail-auth.ts` with two changes: - * - * 1. **Paths in `~/.solrac/gmail/`** instead of relative-to-script. - * Solrac is a self-contained deployment; OAuth state belongs in - * the operator's home dir, not next to the source. - * - * 2. **Bun shebang.** Solrac runs on Bun (no `tsx`); the operator - * invokes via `bun scripts/gmail-auth.ts ` rather than - * `pnpm gmail:auth`. + * Why this lives in `src/` (not `scripts/`): the curl-pipe binary install + * ships `solrac` only — no `bun`, no source tree. Putting the bootstrap + * behind a subcommand lets it run from the same binary the operator + * already has. * * Cross-references: - * - src/integrations-builtin/gmail/client.ts — reads what this writes. + * - ./client.ts — reads what this writes. + * - src/main.ts — `argv[2] === "gmail-auth"` dispatch arm. * - docs/USAGE.md#integrations — operator-facing setup walkthrough. */ -import { OAuth2Client } from "google-auth-library"; -import { google } from "googleapis"; import { exec } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { resolveSolracHome } from "../../config.ts"; +import { resolveGmailPaths, type AccountsConfig } from "./client.ts"; -const GMAIL_DIR = join(homedir(), ".solrac", "gmail"); -const CREDENTIALS_PATH = join(GMAIL_DIR, "credentials.json"); -const ACCOUNTS_PATH = join(GMAIL_DIR, "accounts.json"); +type LooseAny = any; // eslint-disable-line @typescript-eslint/no-explicit-any const SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", @@ -57,53 +38,94 @@ interface CredentialsFile { web?: { client_id: string; client_secret: string }; } -interface AccountInfo { - email: string; - tokenFile: string; - scopes: string[]; - createdAt: string; +function printUsage(): void { + console.error("Usage: solrac gmail-auth "); + console.error("Example: solrac gmail-auth personal"); } -type AccountsConfig = Record; +function printMissingCredentials(credentialsPath: string): void { + console.error(`Error: credentials.json not found at ${credentialsPath}\n`); + console.error("To create one:"); + console.error( + " 1. Enable the Gmail API:", + "https://console.cloud.google.com/apis/library/gmail.googleapis.com", + ); + console.error( + " 2. Create an OAuth client:", + "https://console.cloud.google.com/apis/credentials", + ); + console.error( + " → Create Credentials → OAuth client ID → Desktop app", + ); + console.error(` 3. Download the JSON and save it to the path above.`); +} -async function main(): Promise { - const alias = process.argv[2]; +/** + * Run the gmail-auth bootstrap. Returns the exit code; the dispatcher in + * `main.ts` calls `process.exit(code)` so this function stays testable. + */ +export async function runGmailAuth(argv: string[]): Promise { + const alias = argv[0]; if (!alias) { - console.error("Usage: bun scripts/gmail-auth.ts "); - console.error('Example: bun scripts/gmail-auth.ts personal'); - process.exit(1); + printUsage(); + return 1; } if (!/^[a-z0-9_-]+$/i.test(alias)) { console.error( "Error: alias must be alphanumeric with dashes/underscores only.", ); - process.exit(1); + return 1; } - if (!existsSync(GMAIL_DIR)) { - mkdirSync(GMAIL_DIR, { recursive: true }); + const solracHome = resolveSolracHome(process.env.SOLRAC_HOME); + const paths = resolveGmailPaths(solracHome); + + console.log(`solrac home: ${solracHome}`); + console.log(`gmail dir: ${paths.gmailDir}\n`); + + if (!existsSync(paths.gmailDir)) { + mkdirSync(paths.gmailDir, { recursive: true }); } - if (!existsSync(CREDENTIALS_PATH)) { - console.error(`Error: ${CREDENTIALS_PATH} not found.`); - console.error( - "Download from Google Cloud Console → APIs & Services → Credentials, " + - "and save the JSON file as ~/.solrac/gmail/credentials.json.", - ); - process.exit(1); + if (!existsSync(paths.credentialsPath)) { + printMissingCredentials(paths.credentialsPath); + return 1; } const credentials: CredentialsFile = JSON.parse( - readFileSync(CREDENTIALS_PATH, "utf8"), + readFileSync(paths.credentialsPath, "utf8"), ); const config = credentials.installed ?? credentials.web; if (!config) { console.error( "Error: invalid credentials.json — missing 'installed' or 'web' key.", ); - process.exit(1); + return 1; } const { client_id, client_secret } = config; + // googleapis / google-auth-library are optional deps; the gmail integration + // self-gates on their presence at boot. Lazy-load here so the bootstrap + // fails loud with an actionable hint instead of an import error. + let OAuth2Client: LooseAny; + let google: LooseAny; + try { + const [authLib, googleApis] = await Promise.all([ + import("google-auth-library"), + import("googleapis"), + ]); + OAuth2Client = (authLib as LooseAny).OAuth2Client; + google = (googleApis as LooseAny).google; + } catch (err) { + console.error( + "Error: googleapis + google-auth-library are not installed.\n", + ); + console.error( + "Run: npm install googleapis google-auth-library\n", + ); + console.error(`(import error: ${(err as Error).message})`); + return 1; + } + // Bind a random port in 3457-3556. Google accepts any localhost port // for OAuth callbacks even if not pre-registered. const port = 3457 + Math.floor(Math.random() * 100); @@ -116,7 +138,7 @@ async function main(): Promise { prompt: "consent", // force consent so we always get a refresh_token }); - console.log(`\nAuthenticating account: ${alias}`); + console.log(`Authenticating account: ${alias}`); console.log("Opening browser for Google sign-in..."); const code = await new Promise((resolve, reject) => { @@ -149,7 +171,7 @@ async function main(): Promise { }); server.listen(port, () => { - // macOS `open` opens default browser. Linux operators may need to + // macOS `open` opens the default browser. Linux operators may need to // copy-paste the URL — print it as a fallback. exec(`open "${authUrl}"`, (err) => { if (err) { @@ -158,8 +180,6 @@ async function main(): Promise { }); }); - // 5-minute timeout — give the operator time to consent without leaving - // the script hanging forever if they walk away. setTimeout( () => { server.close(); @@ -173,22 +193,24 @@ async function main(): Promise { const { tokens } = await oauth2Client.getToken(code); oauth2Client.setCredentials(tokens); - // Pull the email address so accounts.json carries human-readable info. const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client }); const userInfo = await oauth2.userinfo.get(); const email = userInfo.data.email; if (!email) { console.error("Error: could not retrieve email address from Google."); - process.exit(1); + return 1; } const tokenFile = `${alias}.json`; - writeFileSync(join(GMAIL_DIR, tokenFile), JSON.stringify(tokens, null, 2)); - console.log(`Token saved to: ~/.solrac/gmail/${tokenFile}`); + const tokenPath = join(paths.gmailDir, tokenFile); + writeFileSync(tokenPath, JSON.stringify(tokens, null, 2)); + console.log(`Token saved to: ${tokenPath}`); let accounts: AccountsConfig = {}; - if (existsSync(ACCOUNTS_PATH)) { - accounts = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8")) as AccountsConfig; + if (existsSync(paths.accountsPath)) { + accounts = JSON.parse( + readFileSync(paths.accountsPath, "utf8"), + ) as AccountsConfig; } accounts[alias] = { email, @@ -196,7 +218,7 @@ async function main(): Promise { scopes: SCOPES.filter((s) => !s.includes("userinfo")), createdAt: new Date().toISOString(), }; - writeFileSync(ACCOUNTS_PATH, JSON.stringify(accounts, null, 2)); + writeFileSync(paths.accountsPath, JSON.stringify(accounts, null, 2)); console.log(`Account registered: ${alias} → ${email}`); console.log(`\nāœ“ Authentication complete.`); @@ -204,9 +226,5 @@ async function main(): Promise { `\nRestart solrac to load the new account. Then via the agent:\n` + ` "search my ${alias} Gmail for unread emails"\n`, ); + return 0; } - -main().catch((err: unknown) => { - console.error("Authentication failed:", (err as Error).message); - process.exit(1); -}); diff --git a/src/integrations-builtin/gmail/client.test.ts b/src/integrations-builtin/gmail/client.test.ts new file mode 100644 index 0000000..f70b944 --- /dev/null +++ b/src/integrations-builtin/gmail/client.test.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Path-resolution tests for the gmail client factory. + * @purpose PNX-171 — verify the gmail integration honors a non-default + * SOLRAC_HOME instead of the legacy hardcoded `~/.solrac/gmail`. + * + * Scope: filesystem reads only (loadAccounts, isGmailConfigured, + * hasConfiguredAccounts, resolveAccount). The OAuth + Gmail API paths + * (getOAuth2Client / getGmailClient) depend on optional `googleapis` + + * `google-auth-library` deps and live Google endpoints — those are + * covered by the smoke harness, not this unit test. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createGmailClientApi, + resolveGmailDir, + resolveGmailPaths, +} from "./client.ts"; + +let testHome: string; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "solrac-gmail-test-")); +}); + +afterEach(() => { + rmSync(testHome, { recursive: true, force: true }); +}); + +describe("resolveGmailDir / resolveGmailPaths", () => { + test("roots under /integrations/gmail/", () => { + expect(resolveGmailDir("/var/solrac")).toBe( + "/var/solrac/integrations/gmail", + ); + }); + + test("paths object exposes credentials + accounts files", () => { + const paths = resolveGmailPaths("/var/solrac"); + expect(paths.gmailDir).toBe("/var/solrac/integrations/gmail"); + expect(paths.credentialsPath).toBe( + "/var/solrac/integrations/gmail/credentials.json", + ); + expect(paths.accountsPath).toBe( + "/var/solrac/integrations/gmail/accounts.json", + ); + }); +}); + +describe("createGmailClientApi with non-default SOLRAC_HOME", () => { + test("isGmailConfigured() is false when credentials.json is absent", () => { + const api = createGmailClientApi(testHome); + expect(api.isGmailConfigured()).toBe(false); + }); + + test("isGmailConfigured() is true after credentials.json lands under integrations/gmail/", () => { + const gmailDir = join(testHome, "integrations", "gmail"); + mkdirSync(gmailDir, { recursive: true }); + writeFileSync( + join(gmailDir, "credentials.json"), + JSON.stringify({ installed: { client_id: "x", client_secret: "y" } }), + ); + const api = createGmailClientApi(testHome); + expect(api.isGmailConfigured()).toBe(true); + expect(api.paths.credentialsPath).toBe( + join(testHome, "integrations", "gmail", "credentials.json"), + ); + }); + + test("hasConfiguredAccounts() is false when accounts.json is absent", () => { + const api = createGmailClientApi(testHome); + expect(api.hasConfiguredAccounts()).toBe(false); + expect(api.loadAccounts()).toEqual({}); + expect(api.getAvailableAccounts()).toEqual([]); + }); + + test("loadAccounts() reads accounts.json from the non-default home", () => { + const gmailDir = join(testHome, "integrations", "gmail"); + mkdirSync(gmailDir, { recursive: true }); + writeFileSync( + join(gmailDir, "accounts.json"), + JSON.stringify({ + ieee: { + email: "carlos.ieee@gmail.com", + tokenFile: "ieee.json", + scopes: ["https://www.googleapis.com/auth/gmail.readonly"], + createdAt: "2026-05-11T00:00:00.000Z", + }, + }), + ); + const api = createGmailClientApi(testHome); + expect(api.hasConfiguredAccounts()).toBe(true); + expect(api.getAvailableAccounts()).toEqual(["ieee"]); + expect(api.listAccounts()).toEqual([ + { alias: "ieee", email: "carlos.ieee@gmail.com" }, + ]); + }); + + test("resolveAccount() matches by alias and by email (case-insensitive)", () => { + const gmailDir = join(testHome, "integrations", "gmail"); + mkdirSync(gmailDir, { recursive: true }); + writeFileSync( + join(gmailDir, "accounts.json"), + JSON.stringify({ + work: { + email: "Carlos@Example.com", + tokenFile: "work.json", + scopes: [], + createdAt: "2026-05-11T00:00:00.000Z", + }, + }), + ); + const api = createGmailClientApi(testHome); + + const byAlias = api.resolveAccount("work"); + expect(byAlias?.alias).toBe("work"); + + const byEmail = api.resolveAccount("carlos@example.com"); + expect(byEmail?.alias).toBe("work"); + + expect(api.resolveAccount("nope")).toBeNull(); + }); + + test("two different SOLRAC_HOME values produce isolated state", () => { + const otherHome = mkdtempSync(join(tmpdir(), "solrac-gmail-test-other-")); + try { + const gmailDir = join(testHome, "integrations", "gmail"); + mkdirSync(gmailDir, { recursive: true }); + writeFileSync( + join(gmailDir, "accounts.json"), + JSON.stringify({ + a: { email: "a@x.com", tokenFile: "a.json", scopes: [], createdAt: "" }, + }), + ); + + const apiA = createGmailClientApi(testHome); + const apiB = createGmailClientApi(otherHome); + + expect(apiA.getAvailableAccounts()).toEqual(["a"]); + expect(apiB.getAvailableAccounts()).toEqual([]); + } finally { + rmSync(otherHome, { recursive: true, force: true }); + } + }); +}); diff --git a/src/integrations-builtin/gmail/client.ts b/src/integrations-builtin/gmail/client.ts index b3ab8b5..f32a294 100644 --- a/src/integrations-builtin/gmail/client.ts +++ b/src/integrations-builtin/gmail/client.ts @@ -4,10 +4,10 @@ * Adapted from `apps/utcp-tools/src/integrations/gmail/client.ts` with two * substantive changes: * - * 1. **Credential paths live in `~/.solrac/gmail/`** instead of the - * apps/utcp-tools source tree. Solrac is a self-contained deployment; - * operator OAuth state belongs in the operator's home dir, not next - * to the integration code (which is part of the solrac repo). + * 1. **Credential paths live under `$SOLRAC_HOME/integrations/gmail/`** — + * threaded in via `ctx.solracHome` rather than hardcoded to `homedir()`. + * Solrac is a self-contained deployment; operator OAuth state belongs in + * `$SOLRAC_HOME`, alongside the rest of solrac's on-disk state. * * 2. **`googleapis` + `google-auth-library` are lazy-loaded.** Solrac's * `package.json` does NOT depend on them. The integration's `setup` @@ -21,18 +21,17 @@ * googleapis' OAuth2Client, which expects a file-loader callback API and * writes refreshed tokens back via filesystem on its own schedule. Storing * tokens in solrac's sqlite would require a custom adapter; using the - * filesystem matches what `gmail-auth.ts` (operator bootstrap script) - * writes. One source of truth. + * filesystem matches what `solrac gmail-auth` (operator bootstrap) writes. + * One source of truth. * * Cross-references: - * - ./index.ts — the setup() function that gates this on credentials. - * - solrac/scripts/gmail-auth.ts — bootstrap script that writes the - * accounts.json + per-alias .json token files. - * - apps/utcp-tools/src/integrations/gmail/client.ts — original to diff. + * - ./index.ts — the setup() function that resolves paths and gates this + * on credentials. + * - src/integrations-builtin/gmail/auth-cli.ts — operator OAuth bootstrap + * surfaced as `solrac gmail-auth `. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; import { join } from "node:path"; // Type-only imports — at runtime we use the dynamic-imported module from @@ -50,9 +49,26 @@ type LooseAny = any; // narrow alias to avoid eslint-disable proliferation // Path resolution // --------------------------------------------------------------------------- -const GMAIL_DIR = join(homedir(), ".solrac", "gmail"); -const CREDENTIALS_PATH = join(GMAIL_DIR, "credentials.json"); -const ACCOUNTS_PATH = join(GMAIL_DIR, "accounts.json"); +/** Absolute path to the gmail integration's on-disk state, derived from + * `$SOLRAC_HOME/integrations/gmail/`. */ +export function resolveGmailDir(solracHome: string): string { + return join(solracHome, "integrations", "gmail"); +} + +export interface GmailPaths { + readonly gmailDir: string; + readonly credentialsPath: string; + readonly accountsPath: string; +} + +export function resolveGmailPaths(solracHome: string): GmailPaths { + const gmailDir = resolveGmailDir(solracHome); + return { + gmailDir, + credentialsPath: join(gmailDir, "credentials.json"), + accountsPath: join(gmailDir, "accounts.json"), + }; +} // --------------------------------------------------------------------------- // On-disk shapes @@ -132,180 +148,195 @@ export async function googleModulesAvailable(): Promise { } // --------------------------------------------------------------------------- -// Account discovery (filesystem; no Google API calls) +// Gmail client API factory // --------------------------------------------------------------------------- -export function loadAccounts(): AccountsConfig { - if (!existsSync(ACCOUNTS_PATH)) return {}; - return JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8")) as AccountsConfig; +export interface GmailClientApi { + readonly paths: GmailPaths; + loadAccounts(): AccountsConfig; + listAccounts(): Array<{ alias: string; email: string }>; + resolveAccount(account: string): { alias: string; info: AccountInfo } | null; + getAvailableAccounts(): string[]; + isGmailConfigured(): boolean; + hasConfiguredAccounts(): boolean; + getOAuth2Client( + account: string, + logEvent: (event: string, fields: Record) => void, + ): Promise; + getGmailClient( + account: string, + logEvent: (event: string, fields: Record) => void, + ): Promise; + clearCaches(): void; } -export function listAccounts(): Array<{ alias: string; email: string }> { - return Object.entries(loadAccounts()).map(([alias, info]) => ({ - alias, - email: info.email, - })); -} +export function createGmailClientApi(solracHome: string): GmailClientApi { + const paths = resolveGmailPaths(solracHome); + const oauthClientCache = new Map(); + const gmailClientCache = new Map(); -export function resolveAccount(account: string): { - alias: string; - info: AccountInfo; -} | null { - const accounts = loadAccounts(); - if (accounts[account]) return { alias: account, info: accounts[account] }; - for (const [alias, info] of Object.entries(accounts)) { - if (info.email.toLowerCase() === account.toLowerCase()) { - return { alias, info }; - } + function loadAccounts(): AccountsConfig { + if (!existsSync(paths.accountsPath)) return {}; + return JSON.parse(readFileSync(paths.accountsPath, "utf8")) as AccountsConfig; } - return null; -} -export function getAvailableAccounts(): string[] { - return Object.keys(loadAccounts()); -} + function listAccounts(): Array<{ alias: string; email: string }> { + return Object.entries(loadAccounts()).map(([alias, info]) => ({ + alias, + email: info.email, + })); + } -export function isGmailConfigured(): boolean { - return existsSync(CREDENTIALS_PATH); -} + function resolveAccount( + account: string, + ): { alias: string; info: AccountInfo } | null { + const accounts = loadAccounts(); + if (accounts[account]) return { alias: account, info: accounts[account] }; + for (const [alias, info] of Object.entries(accounts)) { + if (info.email.toLowerCase() === account.toLowerCase()) { + return { alias, info }; + } + } + return null; + } -export function hasConfiguredAccounts(): boolean { - return Object.keys(loadAccounts()).length > 0; -} + function getAvailableAccounts(): string[] { + return Object.keys(loadAccounts()); + } -// --------------------------------------------------------------------------- -// OAuth2 + Gmail client construction (cached per alias) -// --------------------------------------------------------------------------- + function isGmailConfigured(): boolean { + return existsSync(paths.credentialsPath); + } -const oauthClientCache = new Map(); -const gmailClientCache = new Map(); + function hasConfiguredAccounts(): boolean { + return Object.keys(loadAccounts()).length > 0; + } -function loadCredentials(): { client_id: string; client_secret: string } { - if (!existsSync(CREDENTIALS_PATH)) { - throw new Error( - `Gmail credentials not found at ${CREDENTIALS_PATH}. Download from ` + - "Google Cloud Console → APIs & Services → Credentials, save as " + - "credentials.json in ~/.solrac/gmail/.", + function loadCredentials(): { client_id: string; client_secret: string } { + if (!existsSync(paths.credentialsPath)) { + throw new Error( + `Gmail credentials not found at ${paths.credentialsPath}. Download from ` + + "Google Cloud Console → APIs & Services → Credentials, save as " + + `credentials.json in ${paths.gmailDir}.`, + ); + } + const raw: OAuthClientConfig = JSON.parse( + readFileSync(paths.credentialsPath, "utf8"), ); + const config = raw.installed ?? raw.web; + if (!config) { + throw new Error( + "Invalid credentials.json - missing 'installed' or 'web' key.", + ); + } + return config; } - const raw: OAuthClientConfig = JSON.parse( - readFileSync(CREDENTIALS_PATH, "utf8"), - ); - const config = raw.installed ?? raw.web; - if (!config) { - throw new Error( - "Invalid credentials.json - missing 'installed' or 'web' key.", - ); + + function loadToken(tokenFile: string): TokenData { + const tokenPath = join(paths.gmailDir, tokenFile); + if (!existsSync(tokenPath)) { + throw new Error( + `Token file not found: ${tokenFile}. Run: solrac gmail-auth `, + ); + } + return JSON.parse(readFileSync(tokenPath, "utf8")) as TokenData; } - return config; -} -function loadToken(tokenFile: string): TokenData { - const tokenPath = join(GMAIL_DIR, tokenFile); - if (!existsSync(tokenPath)) { - throw new Error( - `Token file not found: ${tokenFile}. Run: bun scripts/gmail-auth.ts `, + function saveToken(tokenFile: string, tokens: TokenData): void { + writeFileSync( + join(paths.gmailDir, tokenFile), + JSON.stringify(tokens, null, 2), ); } - return JSON.parse(readFileSync(tokenPath, "utf8")) as TokenData; -} -function saveToken(tokenFile: string, tokens: TokenData): void { - writeFileSync(join(GMAIL_DIR, tokenFile), JSON.stringify(tokens, null, 2)); -} + function toCredentials(t: TokenData): Record { + return { + access_token: t.access_token ?? undefined, + refresh_token: t.refresh_token ?? undefined, + scope: t.scope ?? undefined, + token_type: t.token_type ?? undefined, + expiry_date: t.expiry_date ?? undefined, + id_token: t.id_token ?? undefined, + }; + } -function toCredentials(t: TokenData): Record { - return { - access_token: t.access_token ?? undefined, - refresh_token: t.refresh_token ?? undefined, - scope: t.scope ?? undefined, - token_type: t.token_type ?? undefined, - expiry_date: t.expiry_date ?? undefined, - id_token: t.id_token ?? undefined, - }; -} + async function getOAuth2Client( + account: string, + logEvent: (event: string, fields: Record) => void, + ): Promise { + const resolved = resolveAccount(account); + if (!resolved) { + const available = getAvailableAccounts(); + throw new Error( + `Account "${account}" not found. ` + + (available.length > 0 + ? `Available: ${available.join(", ")}` + : "No accounts configured. Run: solrac gmail-auth "), + ); + } + const { alias, info } = resolved; + const cached = oauthClientCache.get(alias); + if (cached) return cached; + + const { OAuth2Client } = await loadGoogleModules(); + const { client_id, client_secret } = loadCredentials(); + const client = new OAuth2Client(client_id, client_secret); + + const tokens = loadToken(info.tokenFile); + client.setCredentials(toCredentials(tokens)); + + // googleapis fires `tokens` whenever it refreshes. Persist the new pair so + // future boots resume seamlessly. + client.on("tokens", (newTokens: LooseAny) => { + const current = loadToken(info.tokenFile); + saveToken(info.tokenFile, { ...current, ...newTokens }); + logEvent("integrations.gmail.token_refreshed", { alias }); + }); + + oauthClientCache.set(alias, client); + return client; + } -/** - * Get an authenticated OAuth2Client for `account` (alias or email). - * Auto-refreshes tokens (`tokens` event handler persists refreshed creds - * back to disk). - * - * `logEvent` is the integration's logger surface (`ctx.log`); used only for - * the token-refresh notice. Errors propagate to the caller — handlers wrap - * them in `{ success: false, error }` envelopes. - */ -export async function getOAuth2Client( - account: string, - logEvent: (event: string, fields: Record) => void, -): Promise { - const resolved = resolveAccount(account); - if (!resolved) { - const available = getAvailableAccounts(); - throw new Error( - `Account "${account}" not found. ` + - (available.length > 0 - ? `Available: ${available.join(", ")}` - : "No accounts configured. Run: bun scripts/gmail-auth.ts "), - ); + async function getGmailClient( + account: string, + logEvent: (event: string, fields: Record) => void, + ): Promise { + const resolved = resolveAccount(account); + if (!resolved) { + const available = getAvailableAccounts(); + throw new Error( + `Account "${account}" not found. ` + + (available.length > 0 + ? `Available: ${available.join(", ")}` + : "No accounts configured. Run: solrac gmail-auth "), + ); + } + const { alias } = resolved; + const cached = gmailClientCache.get(alias); + if (cached) return cached; + + const { google } = await loadGoogleModules(); + const auth = await getOAuth2Client(account, logEvent); + const gmail = google.gmail({ version: "v1", auth }); + gmailClientCache.set(alias, gmail); + return gmail; } - const { alias, info } = resolved; - const cached = oauthClientCache.get(alias); - if (cached) return cached; - - const { OAuth2Client } = await loadGoogleModules(); - const { client_id, client_secret } = loadCredentials(); - const client = new OAuth2Client(client_id, client_secret); - - const tokens = loadToken(info.tokenFile); - client.setCredentials(toCredentials(tokens)); - - // googleapis fires `tokens` whenever it refreshes. Persist the new pair so - // future boots resume seamlessly. NOT logged at info level on every - // refresh (would be noisy); use debug. - client.on("tokens", (newTokens: LooseAny) => { - const current = loadToken(info.tokenFile); - saveToken(info.tokenFile, { ...current, ...newTokens }); - logEvent("integrations.gmail.token_refreshed", { alias }); - }); - - oauthClientCache.set(alias, client); - return client; -} -/** - * Get an authenticated Gmail API client (`gmail_v1.Gmail`) for `account`. - * Reuses the OAuth2 client cache. - */ -export async function getGmailClient( - account: string, - logEvent: (event: string, fields: Record) => void, -): Promise { - const resolved = resolveAccount(account); - if (!resolved) { - const available = getAvailableAccounts(); - throw new Error( - `Account "${account}" not found. ` + - (available.length > 0 - ? `Available: ${available.join(", ")}` - : "No accounts configured. Run: bun scripts/gmail-auth.ts "), - ); + function clearCaches(): void { + oauthClientCache.clear(); + gmailClientCache.clear(); } - const { alias } = resolved; - const cached = gmailClientCache.get(alias); - if (cached) return cached; - - const { google } = await loadGoogleModules(); - const auth = await getOAuth2Client(account, logEvent); - const gmail = google.gmail({ version: "v1", auth }); - gmailClientCache.set(alias, gmail); - return gmail; -} -/** - * Clear the per-alias caches. Used by tests + by `gmail-auth.ts` after a - * fresh OAuth flow so the new credentials get picked up without restart. - */ -export function clearGmailCaches(): void { - oauthClientCache.clear(); - gmailClientCache.clear(); + return { + paths, + loadAccounts, + listAccounts, + resolveAccount, + getAvailableAccounts, + isGmailConfigured, + hasConfiguredAccounts, + getOAuth2Client, + getGmailClient, + clearCaches, + }; } diff --git a/src/integrations-builtin/gmail/index.ts b/src/integrations-builtin/gmail/index.ts index a47cc78..de909bb 100644 --- a/src/integrations-builtin/gmail/index.ts +++ b/src/integrations-builtin/gmail/index.ts @@ -26,11 +26,11 @@ * - Credential paths: `~/.solrac/gmail/` (NOT inside the source tree). * `accounts.json` enumerates aliases; `.json` holds OAuth * tokens; `credentials.json` holds the OAuth client_id+secret. Operator - * runs `bun scripts/gmail-auth.ts ` once per account to populate. + * runs `solrac gmail-auth ` once per account to populate. * * Cross-references: * - apps/utcp-tools/src/integrations/gmail/ — original (HTTP-server form) - * - solrac/scripts/gmail-auth.ts — operator OAuth bootstrap + * - src/integrations-builtin/gmail/auth-cli.ts — `solrac gmail-auth` bootstrap * - docs/USAGE.md#integrations — operator setup walkthrough */ @@ -39,13 +39,8 @@ import type { IntegrationModule, } from "../../integrations.ts"; import { - getAvailableAccounts, - getGmailClient, + createGmailClientApi, googleModulesAvailable, - hasConfiguredAccounts, - isGmailConfigured, - listAccounts, - resolveAccount, } from "./client.ts"; import { buildMimeMessage, @@ -100,7 +95,7 @@ function errorResult(err: unknown, account?: string): ToolResult { success: false, error: `Gmail authentication needs renewal for "${aliasHint}". ` + - `Run: bun scripts/gmail-auth.ts ${aliasHint}`, + `Run: solrac gmail-auth ${aliasHint}`, authRequired: true, }); } @@ -247,19 +242,22 @@ export default async function setup( }); return { apiVersion: 1, tools: [] }; } - if (!isGmailConfigured()) { + + const api = createGmailClientApi(ctx.solracHome); + + if (!api.isGmailConfigured()) { ctx.log.info("integrations.gmail.disabled", { reason: "credentials.json absent", - expectedAt: "~/.solrac/gmail/credentials.json", + expectedAt: api.paths.credentialsPath, hint: "Download OAuth client_id+secret from Google Cloud Console " + - "(APIs & Services → Credentials), save as ~/.solrac/gmail/credentials.json.", + `(APIs & Services → Credentials), save as ${api.paths.credentialsPath}.`, }); return { apiVersion: 1, tools: [] }; } - if (!hasConfiguredAccounts()) { + if (!api.hasConfiguredAccounts()) { ctx.log.info("integrations.gmail.no_accounts", { - hint: "Run `bun scripts/gmail-auth.ts ` once per account to authenticate.", + hint: "Run `solrac gmail-auth ` once per account to authenticate.", }); return { apiVersion: 1, tools: [] }; } @@ -270,7 +268,7 @@ export default async function setup( // Common: fetch a Gmail client by account, surfacing friendly errors. async function client(account: string): Promise { - return await getGmailClient(account, log); + return await api.getGmailClient(account, log); } // --------------------------------------------------------------------------- @@ -380,7 +378,7 @@ export default async function setup( {}, async (): Promise => { try { - const accounts = listAccounts(); + const accounts = api.listAccounts(); return jsonResult({ success: true, count: accounts.length, @@ -814,9 +812,9 @@ export default async function setup( error: "confirm must be exactly `true` to send.", }); } - const resolved = resolveAccount(args.account); + const resolved = api.resolveAccount(args.account); if (!resolved) { - const available = getAvailableAccounts(); + const available = api.getAvailableAccounts(); return jsonResult({ success: false, error: `Account "${args.account}" not found. Available: ${available.join(", ") || "(none)"}`, @@ -881,7 +879,7 @@ export default async function setup( ]; ctx.log.info("integrations.gmail.loaded", { - accountCount: getAvailableAccounts().length, + accountCount: api.getAvailableAccounts().length, toolCount: tools.length, }); diff --git a/src/integrations-builtin/notion/index.test.ts b/src/integrations-builtin/notion/index.test.ts index dbf366f..501b720 100644 --- a/src/integrations-builtin/notion/index.test.ts +++ b/src/integrations-builtin/notion/index.test.ts @@ -154,6 +154,7 @@ function makeCtx(): IntegrationContext { fetch: globalThis.fetch, log, env: process.env as Readonly>, + solracHome: "/tmp/solrac-test-home", }); } diff --git a/src/integrations.test.ts b/src/integrations.test.ts index 6dca657..242b62c 100644 --- a/src/integrations.test.ts +++ b/src/integrations.test.ts @@ -104,6 +104,7 @@ function makeCtx(): IntegrationContext { fetch: globalThis.fetch, log, env: process.env as Readonly>, + solracHome: "/tmp/solrac-test-home", }); } @@ -482,6 +483,7 @@ function makeBuiltinCtx(): IntegrationContext { fetch: globalThis.fetch, log, env: process.env as Readonly>, + solracHome: "/tmp/solrac-test-home", }); } diff --git a/src/integrations.ts b/src/integrations.ts index 34898b6..dbe463b 100644 --- a/src/integrations.ts +++ b/src/integrations.ts @@ -131,6 +131,13 @@ export interface IntegrationContext { * trusted operator code. */ readonly env: Readonly>; + /** + * Absolute path to `$SOLRAC_HOME`. Blessed integrations that persist + * credentials/state on disk MUST root them under + * `/integrations//`. Resolution rules live in + * `config.ts::resolveSolracHome`. + */ + readonly solracHome: string; } export interface IntegrationModule { @@ -605,12 +612,13 @@ export function logIntegrationLoadResult( // Build a fresh `IntegrationContext` for production use. Tests construct // their own (typically with a stub `fetch`). -export function createIntegrationContext(): IntegrationContext { +export function createIntegrationContext(solracHome: string): IntegrationContext { return Object.freeze({ z, tool, fetch: globalThis.fetch, log, env: process.env as Readonly>, + solracHome, }); } diff --git a/src/main.ts b/src/main.ts index c4cf146..89d6c81 100644 --- a/src/main.ts +++ b/src/main.ts @@ -613,7 +613,7 @@ async function main(): Promise { // `import()` (works in --compile because Bun's runtime handles it). // Builtins win on tool-name collisions; the merge logs each cross-source // collision with both source identifiers. - const ctx = createIntegrationContext(); + const ctx = createIntegrationContext(config.solracHome); const builtinResult = await loadBuiltinIntegrations(ctx); const operatorResult = await loadIntegrations([config.integrationsDir], ctx); const result = mergeIntegrationResults(builtinResult, operatorResult); @@ -1044,12 +1044,38 @@ async function main(): Promise { } } +// CLI subcommand dispatch. Subcommands run without loadConfig() so they work +// on a fresh install before TELEGRAM_BOT_TOKEN / ANTHROPIC_API_KEY are set. +// Each subcommand resolves its own paths (via resolveSolracHome) and returns +// a process exit code. +async function dispatchSubcommand(subcommand: string): Promise { + if (subcommand === "gmail-auth") { + const { runGmailAuth } = await import( + "./integrations-builtin/gmail/auth-cli.ts" + ); + return await runGmailAuth(process.argv.slice(3)); + } + console.error(`Unknown subcommand: ${subcommand}`); + console.error("Known subcommands: gmail-auth"); + return 1; +} + // Only run as an entry script. `import.meta.main` is `true` when this file is // the program entry, `false` when imported by a test. Without this guard the // boot sequence runs on every test file that pulls in an exported helper. if (import.meta.main) { - main().catch((err) => { - log.error("solrac.fatal", { error: (err as Error).message }); - process.exit(1); - }); + const subcommand = process.argv[2]; + if (subcommand !== undefined && !subcommand.startsWith("-")) { + dispatchSubcommand(subcommand) + .then((code) => process.exit(code)) + .catch((err: unknown) => { + console.error(`${subcommand} failed:`, (err as Error).message); + process.exit(1); + }); + } else { + main().catch((err) => { + log.error("solrac.fatal", { error: (err as Error).message }); + process.exit(1); + }); + } } diff --git a/test/smokes/integrations.ts b/test/smokes/integrations.ts index 0a31fa8..7a4f866 100644 --- a/test/smokes/integrations.ts +++ b/test/smokes/integrations.ts @@ -51,7 +51,9 @@ async function run(): Promise { // required — the smoke is a one-shot script. delete process.env.NOTION_API_KEY; - const ctx = createIntegrationContext(); + const ctx = createIntegrationContext( + process.env.SOLRAC_HOME ?? "/tmp/solrac-smoke-home", + ); const phases: Phase[] = []; // ------------------------------------------------------------------------- diff --git a/test/smokes/notion-smoke.ts b/test/smokes/notion-smoke.ts index d5b181a..fb39b48 100644 --- a/test/smokes/notion-smoke.ts +++ b/test/smokes/notion-smoke.ts @@ -46,6 +46,7 @@ function makeCtx(): IntegrationContext { fetch: globalThis.fetch, log, env: process.env as Readonly>, + solracHome: process.env.SOLRAC_HOME ?? "/tmp/solrac-smoke-home", }); } diff --git a/test/smokes/ollama.ts b/test/smokes/ollama.ts index f3d31d2..39aacae 100644 --- a/test/smokes/ollama.ts +++ b/test/smokes/ollama.ts @@ -255,7 +255,9 @@ async function main(): Promise { // ── Tools-on path (PR-A). Skipped unless `OLLAMA_TOOLS_ENABLED=true`. ── if (process.env.OLLAMA_TOOLS_ENABLED === "true") { // Load the built-in `time` integration the same way main.ts would. - const ctx = createIntegrationContext(); + const ctx = createIntegrationContext( + process.env.SOLRAC_HOME ?? "/tmp/solrac-smoke-home", + ); const timeMod = await timeIntegration(ctx); const tools: ReadonlyArray> = timeMod.tools; const toolTiers = new Map(