@mulmoclaude/core 0.19.1 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../src/google/host.ts","../../src/google/datetime.ts","../../src/google/paths.ts","../../src/google/util.ts","../../src/google/clientSecret.ts","../../src/google/fsJson.ts","../../src/google/tokenStore.ts","../../src/google/fetch.ts","../../src/google/auth.ts","../../src/google/authFlow.ts","../../src/google/calendar.ts"],"sourcesContent":["// Host binding for the Google engine. The engine logs through the host's\n// logger, but a package-level import of the host logger would be an uphill\n// dependency — so the host injects it once at startup (same pattern as\n// `collection/server/host.ts`). The default is silent so the engine works\n// unconfigured in unit tests.\n\nexport interface GoogleLogger {\n error: (prefix: string, message: string, data?: Record<string, unknown>) => void;\n warn: (prefix: string, message: string, data?: Record<string, unknown>) => void;\n info: (prefix: string, message: string, data?: Record<string, unknown>) => void;\n debug: (prefix: string, message: string, data?: Record<string, unknown>) => void;\n}\n\nconst silentLogger: GoogleLogger = {\n error: () => undefined,\n warn: () => undefined,\n info: () => undefined,\n debug: () => undefined,\n};\n\nlet hostLog: GoogleLogger = silentLogger;\n\nexport function configureGoogleHost(binding: { log: GoogleLogger }): void {\n hostLog = binding.log;\n}\n\nexport const log: GoogleLogger = {\n error: (prefix, message, data) => hostLog.error(prefix, message, data),\n warn: (prefix, message, data) => hostLog.warn(prefix, message, data),\n info: (prefix, message, data) => hostLog.info(prefix, message, data),\n debug: (prefix, message, data) => hostLog.debug(prefix, message, data),\n};\n","// Strict RFC3339 date-time validation shared by every surface that feeds\n// Calendar `dateTime`/`timeMin` values (agent tool args, remote-host command\n// params). Calendar rejects date-only / offset-less values with an opaque\n// 400, so callers validate here and return an actionable message instead.\n\n// Fractional seconds are normalized away first — an optional `(\\.\\d+)?`\n// group inside the main pattern trips security/detect-unsafe-regex.\nconst ISO_DATE_TIME_WITH_OFFSET_RE = /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:Z|[+-](\\d{2}):(\\d{2}))$/;\nconst FRACTIONAL_SECONDS_RE = /\\.\\d+(?=Z|[+-])/;\n\nconst MAX_HOUR = 23;\nconst MAX_MINUTE = 59;\nconst MAX_SECOND = 59;\n\n// JS Date normalizes overflowed components (2026-02-31 parses as\n// 2026-03-03), so `new Date(value)` alone cannot reject impossible dates —\n// a UTC round-trip of the raw components can.\nconst isRealCalendarDate = (year: number, month: number, day: number): boolean => {\n const roundTrip = new Date(Date.UTC(year, month - 1, day));\n return roundTrip.getUTCFullYear() === year && roundTrip.getUTCMonth() === month - 1 && roundTrip.getUTCDate() === day;\n};\n\nexport const isIsoDateTimeWithOffset = (value: string): boolean => {\n const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE, \"\"));\n if (!match) return false;\n const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number);\n const timeInRange = hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND;\n // RFC3339 bounds the offset to 00-23:59; groups 7/8 are undefined for \"Z\".\n const offsetInRange = match[7] === undefined || (Number(match[7]) <= MAX_HOUR && Number(match[8]) <= MAX_MINUTE);\n return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;\n};\n","// Google OAuth material lives OUTSIDE the workspace: the client secret is\n// machine-only (never synced) and the refresh token must survive workspace\n// resets — same reasoning as the gcloud / gh CLI model. The dir is the\n// host-NEUTRAL `~/.config/mulmo` because this engine is shared by both\n// MulmoClaude and MulmoTerminal, which deliberately share one grant per\n// machine. The `home` parameter exists so tests can thread a fake home.\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\n\nexport function googleConfigDir(home?: string): string {\n return join(home ?? homedir(), \".config\", \"mulmo\");\n}\n\n/** Pre-0.20.1 token dir (mulmoclaude-branded); reads migrate away from it. */\nexport function legacyGoogleTokenPath(home?: string): string {\n return join(home ?? homedir(), \".config\", \"mulmoclaude\", \"google-token.json\");\n}\n\nexport function googleTokenPath(home?: string): string {\n return join(googleConfigDir(home), \"google-token.json\");\n}\n\nexport function googleSecretsDir(home?: string): string {\n return join(home ?? homedir(), \".secrets\");\n}\n","// Small helpers ported from the host (`server/utils/{errors,text,time,types}.ts`)\n// so the Google engine carries no dependency on host utils — same convention\n// as `collection/registry/server/fetch.ts`.\n\nexport const ONE_SECOND_MS = 1_000;\nexport const ONE_MINUTE_MS = 60_000;\n\nexport function errorMessage(err: unknown, fallback = \"unknown error\"): string {\n if (err instanceof Error) return err.message || fallback;\n const text = String(err);\n return text === \"\" || text === \"[object Object]\" ? fallback : text;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/** Clip a string to at most `max` chars; the ellipsis is included in the\n * budget so output never exceeds `max`. */\nexport function truncate(text: string, max: number, ellipsis = \"…\"): string {\n if (max <= 0) return \"\";\n if (text.length <= max) return text;\n return `${text.slice(0, Math.max(0, max - ellipsis.length))}${ellipsis}`;\n}\n","// Loads the Google OAuth desktop-app client credentials the user downloaded\n// from the Cloud Console into `~/.secrets/client_secret_*.json`. The file is\n// discovered by prefix so the user doesn't have to rename Google's long\n// default filename.\nimport { readdir, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { isRecord } from \"./util.js\";\nimport { googleSecretsDir } from \"./paths.js\";\n\nexport interface InstalledClientSecret {\n client_id: string;\n client_secret: string;\n}\n\nconst isInstalledClientSecret = (value: unknown): value is { installed: InstalledClientSecret } => {\n if (!isRecord(value) || !isRecord(value.installed)) return false;\n return typeof value.installed.client_id === \"string\" && typeof value.installed.client_secret === \"string\";\n};\n\nconst isClientSecretFileName = (name: string): boolean => name.startsWith(\"client_secret_\") && name.endsWith(\".json\");\n\nasync function listClientSecretFiles(home?: string): Promise<string[]> {\n const entries = await readdir(googleSecretsDir(home)).catch((): string[] => []);\n return entries.filter(isClientSecretFileName).sort();\n}\n\n/** \"ambiguous\" (2+ files) is distinct from \"missing\" — the fixes differ\n * (remove duplicates vs download credentials), so the UI must not conflate\n * them. */\nexport type ClientSecretPresence = \"found\" | \"missing\" | \"ambiguous\";\n\nexport async function clientSecretPresence(home?: string): Promise<ClientSecretPresence> {\n const matches = await listClientSecretFiles(home);\n if (matches.length === 0) return \"missing\";\n return matches.length === 1 ? \"found\" : \"ambiguous\";\n}\n\nexport async function findClientSecretPath(home?: string): Promise<string> {\n const dir = googleSecretsDir(home);\n const matches = await listClientSecretFiles(home);\n const [first, ...rest] = matches;\n if (!first) {\n throw new Error(\n `no client_secret_*.json found in ${dir} — download the OAuth desktop-app credentials JSON from the Google Cloud Console and place it there (mode 600)`,\n );\n }\n if (rest.length > 0) {\n // The stored refresh token is bound to one client_id; silently picking one\n // of several files could pair the token with the wrong client\n // (invalid_grant), so ambiguity is an error the user must resolve.\n throw new Error(`multiple client_secret_*.json files found in ${dir} (${matches.join(\", \")}) — keep exactly one`);\n }\n return join(dir, first);\n}\n\nexport async function loadClientSecret(home?: string): Promise<InstalledClientSecret> {\n const filePath = await findClientSecretPath(home);\n const raw = await readFile(filePath, \"utf-8\");\n const parsed: unknown = JSON.parse(raw);\n if (!isInstalledClientSecret(parsed)) {\n throw new Error(`${filePath} is not a desktop-app OAuth client secret (missing \"installed\" with client_id / client_secret)`);\n }\n return { client_id: parsed.installed.client_id, client_secret: parsed.installed.client_secret };\n}\n","// Minimal JSON file I/O for the token store. Unlike the host's\n// `writeJsonAtomic` (and core's collection `atomic.ts`), this one supports a\n// file mode — the token file must be 0600.\nimport { promises as fsp } from \"node:fs\";\nimport path from \"node:path\";\n\nexport async function readJsonOrNull<T>(filePath: string): Promise<T | null> {\n try {\n const raw = await fsp.readFile(filePath, \"utf-8\");\n const parsed: T = JSON.parse(raw);\n return parsed;\n } catch {\n return null;\n }\n}\n\nconst IS_WINDOWS = process.platform === \"win32\";\nconst RENAME_RETRY_DELAYS_MS = [30, 100, 300];\n\nconst hasErrnoCode = (err: unknown): err is { code: string } =>\n typeof err === \"object\" && err !== null && \"code\" in err && typeof (err as { code: unknown }).code === \"string\";\n\n// On Windows, AV / Search Indexer / Defender briefly hold handles and rename\n// trips EPERM/EBUSY/EACCES. The retry is gated to Windows because POSIX EPERM\n// means a real permission problem — retrying would just delay the throw.\n// Ported from the host's server/utils/files/atomic.ts.\nconst isTransientRenameError = (err: unknown): boolean =>\n IS_WINDOWS && hasErrnoCode(err) && (err.code === \"EPERM\" || err.code === \"EBUSY\" || err.code === \"EACCES\");\n\nasync function renameWithWindowsRetry(fromPath: string, toPath: string): Promise<void> {\n for (const delayMs of RENAME_RETRY_DELAYS_MS) {\n try {\n await fsp.rename(fromPath, toPath);\n return;\n } catch (err) {\n if (!isTransientRenameError(err)) throw err;\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n }\n await fsp.rename(fromPath, toPath);\n}\n\n/** tmp-write + rename so readers never see a half-written file; `mode`\n * applies to the tmp file and survives the rename. */\nexport async function writeJsonAtomicWithMode(filePath: string, data: unknown, mode: number): Promise<void> {\n const tmp = `${filePath}.tmp`;\n await fsp.mkdir(path.dirname(filePath), { recursive: true });\n try {\n await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: \"utf-8\", mode });\n await renameWithWindowsRetry(tmp, filePath);\n } catch (err) {\n await fsp.unlink(tmp).catch(() => undefined);\n throw err;\n }\n}\n","// Persistence for the Google OAuth tokens (refresh + access) at\n// `~/.config/mulmo/google-token.json`, mode 600. Google omits\n// `refresh_token` from refresh responses, so merges must preserve the one we\n// already hold — losing it forces the user through the browser consent again.\nimport { constants as fsConstants, copyFile, mkdir, rm, stat } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { Credentials } from \"google-auth-library\";\nimport { readJsonOrNull, writeJsonAtomicWithMode } from \"./fsJson.js\";\nimport { googleTokenPath, legacyGoogleTokenPath } from \"./paths.js\";\n\nconst TOKEN_FILE_MODE = 0o600;\n\nexport function mergeGoogleTokens(existing: Credentials | null, incoming: Credentials): Credentials {\n const merged = { ...existing, ...incoming };\n if (!incoming.refresh_token && existing?.refresh_token) merged.refresh_token = existing.refresh_token;\n return merged;\n}\n\nconst fileExists = async (filePath: string): Promise<boolean> =>\n await stat(filePath).then(\n () => true,\n () => false,\n );\n\n// Tokens written before 0.20.1 live under the mulmoclaude-branded dir; move\n// them once. COPYFILE_EXCL makes the create atomic-and-non-clobbering — an\n// exists+rename sequence could overwrite a token a concurrent process wrote\n// to the new path in between (TOCTOU). The legacy file is deleted only after\n// a successful copy; on EEXIST (new path won a race, or both files already\n// exist) it is left for any older install still reading it. copyFile\n// preserves the 600 mode.\nasync function migrateLegacyTokenFile(home?: string): Promise<void> {\n const current = googleTokenPath(home);\n const legacy = legacyGoogleTokenPath(home);\n if (!(await fileExists(legacy))) return;\n await mkdir(path.dirname(current), { recursive: true });\n try {\n await copyFile(legacy, current, fsConstants.COPYFILE_EXCL);\n } catch {\n return;\n }\n await rm(legacy, { force: true });\n}\n\nexport async function loadGoogleTokens(home?: string): Promise<Credentials | null> {\n await migrateLegacyTokenFile(home).catch(() => undefined);\n const current = await readJsonOrNull<Credentials>(googleTokenPath(home));\n if (current) return current;\n // Migration is best-effort — a valid legacy token must still count as\n // linked even when the move failed (permissions, read-only fs, …).\n return await readJsonOrNull<Credentials>(legacyGoogleTokenPath(home));\n}\n\nexport async function saveGoogleTokens(incoming: Credentials, home?: string): Promise<Credentials> {\n const merged = mergeGoogleTokens(await loadGoogleTokens(home), incoming);\n await writeJsonAtomicWithMode(googleTokenPath(home), merged, TOKEN_FILE_MODE);\n return merged;\n}\n\nexport async function deleteGoogleTokens(home?: string): Promise<void> {\n await rm(googleTokenPath(home), { force: true });\n}\n","// `fetch` with a finite timeout, ported from the host (`server/utils/fetch.ts`)\n// so the Google engine carries no dependency on host utils (same convention as\n// `collection/registry/server/fetch.ts`).\n\nimport { ONE_SECOND_MS } from \"./util.js\";\n\nexport const DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;\n\n// `Parameters<typeof fetch>[1]` avoids referencing the ambient `RequestInit`\n// type, which ESLint's `no-undef` rule trips over in the server config.\nexport type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & { timeoutMs?: number };\n\n/** `fetch` with a finite timeout. Rejects with a `TimeoutError` once\n * `timeoutMs` elapses. Composes with a caller-supplied `signal` so external\n * cancellation still works. */\nexport async function fetchWithTimeout(url: string | URL, init: FetchWithTimeoutInit = {}): Promise<Response> {\n const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;\n\n if (callerSignal?.aborted) {\n throw callerSignal.reason ?? new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const controller = new AbortController();\n const timer = setTimeout(() => {\n controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, \"TimeoutError\"));\n }, timeoutMs);\n\n const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);\n\n try {\n return await fetch(url, { ...rest, signal: controller.signal });\n } finally {\n clearTimeout(timer);\n unsubscribeCaller?.();\n }\n}\n\nfunction bridgeExternalSignal(external: AbortSignal | null | undefined, controller: AbortController): (() => void) | null {\n if (!external) return null;\n const onAbort = () => controller.abort(external.reason);\n external.addEventListener(\"abort\", onAbort, { once: true });\n return () => external.removeEventListener(\"abort\", onAbort);\n}\n","// Google OAuth for the host machine, independent of Firebase Auth (which\n// discards refresh tokens). Entry points:\n// - authorizeGoogle(): one-shot loopback + PKCE browser consent flow\n// (desktop-app clients may redirect to any 127.0.0.1 port), storing the\n// refresh token locally via tokenStore.\n// - getGoogleAccessToken(): mints a fresh access token from the stored\n// refresh token; the OAuth2Client \"tokens\" event persists rotations.\n// - unlinkGoogle(): best-effort revoke at Google + local token delete.\nimport { randomBytes } from \"node:crypto\";\nimport http from \"node:http\";\nimport { CodeChallengeMethod, OAuth2Client, type Credentials } from \"google-auth-library\";\nimport { log } from \"./host.js\";\nimport { errorMessage, ONE_MINUTE_MS, ONE_SECOND_MS } from \"./util.js\";\nimport { fetchWithTimeout } from \"./fetch.js\";\nimport { loadClientSecret, type InstalledClientSecret } from \"./clientSecret.js\";\nimport { deleteGoogleTokens, loadGoogleTokens, saveGoogleTokens } from \"./tokenStore.js\";\n\nexport const GOOGLE_CALENDAR_SCOPE = \"https://www.googleapis.com/auth/calendar.events\";\nexport const GOOGLE_TASKS_SCOPE = \"https://www.googleapis.com/auth/tasks\";\nexport const GOOGLE_DRIVE_FILE_SCOPE = \"https://www.googleapis.com/auth/drive.file\";\n/** Requested at consent as one set — matches the scopes registered on the\n * OAuth consent screen, so a single re-link covers every supported API\n * (Calendar now; Tasks / Drive tools ride the same grant later). */\nexport const GOOGLE_SCOPES = [GOOGLE_CALENDAR_SCOPE, GOOGLE_TASKS_SCOPE, GOOGLE_DRIVE_FILE_SCOPE];\nconst CALLBACK_PATH = \"/oauth2callback\";\nconst AUTH_TIMEOUT_MS = 5 * ONE_MINUTE_MS;\nconst STATE_BYTES = 16;\n\nexport interface AuthorizeGoogleOptions {\n home?: string;\n /** Called with the consent URL; open it in a browser (and/or print it). */\n onAuthUrl?: (url: string) => void;\n timeoutMs?: number;\n}\n\nconst createClient = (secret: InstalledClientSecret, redirectUri?: string): OAuth2Client =>\n new OAuth2Client({ clientId: secret.client_id, clientSecret: secret.client_secret, redirectUri });\n\nconst persistRotatedTokens = (client: OAuth2Client, home?: string): void => {\n client.on(\"tokens\", (tokens) => {\n saveGoogleTokens(tokens, home).catch((err: unknown) => {\n log.error(\"google\", \"failed to persist rotated tokens\", { error: String(err) });\n });\n });\n};\n\nexport async function getGoogleAccessToken(home?: string): Promise<string> {\n const saved = await loadGoogleTokens(home);\n if (!saved?.refresh_token) {\n throw new Error(\"Google account not linked on this host — link it in Settings → Plugins → Google, or run `yarn google:auth`\");\n }\n const client = createClient(await loadClientSecret(home));\n client.setCredentials(saved);\n persistRotatedTokens(client, home);\n const { token } = await client.getAccessToken();\n if (!token) {\n throw new Error(\"could not obtain a Google access token — the grant may have been revoked; re-link the account\");\n }\n return token;\n}\n\nconst REVOKE_URL = \"https://oauth2.googleapis.com/revoke\";\n\n/** The revoke POST, injectable for tests. */\nexport type RevokeFetch = typeof fetchWithTimeout;\n\n/** Revoke the grant at Google (best-effort) and delete the local token file.\n * Revoke failures are logged but never block the local delete — Google may\n * already consider the token invalid, and keeping the file would leave the\n * user unable to unlink. */\nexport async function unlinkGoogle(home?: string, revokeFetch: RevokeFetch = fetchWithTimeout): Promise<void> {\n const saved = await loadGoogleTokens(home);\n const token = saved?.refresh_token ?? saved?.access_token;\n if (token) {\n try {\n const response = await revokeFetch(REVOKE_URL, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({ token }).toString(),\n });\n if (!response.ok) log.warn(\"google\", \"token revoke returned non-ok\", { status: response.status });\n } catch (err) {\n log.warn(\"google\", \"token revoke failed, deleting local tokens anyway\", { error: errorMessage(err) });\n }\n }\n await deleteGoogleTokens(home);\n}\n\nconst startLoopbackServer = (): Promise<{ server: http.Server; port: number }> =>\n new Promise((resolve, reject) => {\n const server = http.createServer();\n server.once(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const address = server.address();\n if (address === null || typeof address === \"string\") {\n reject(new Error(\"loopback server has no port\"));\n return;\n }\n resolve({ server, port: address.port });\n });\n });\n\n// State is validated before error/code — a callback that can't prove it\n// belongs to this flow must not influence it (its `error` text would\n// otherwise reach the terminal attacker-controlled).\nconst authCodeFromCallback = (url: URL, expectedState: string): string => {\n if (url.searchParams.get(\"state\") !== expectedState) throw new Error(\"OAuth state mismatch — possible CSRF, aborting\");\n const error = url.searchParams.get(\"error\");\n if (error) throw new Error(`Google authorization failed: ${error}`);\n const code = url.searchParams.get(\"code\");\n if (!code) throw new Error(\"authorization callback carried no code\");\n return code;\n};\n\nconst respondHtml = (res: http.ServerResponse, status: number, message: string): void => {\n res.writeHead(status, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(`<html><body><h3>${message}</h3></body></html>`);\n};\n\nexport const waitForAuthCode = (server: http.Server, expectedState: string, timeoutMs: number): Promise<string> =>\n new Promise((resolve, reject) => {\n const timer = setTimeout(() => reject(new Error(`authorization timed out after ${timeoutMs / ONE_SECOND_MS}s`)), timeoutMs);\n server.on(\"request\", (req, res) => {\n const url = new URL(req.url ?? \"/\", \"http://127.0.0.1\");\n if (url.pathname !== CALLBACK_PATH) {\n res.writeHead(404);\n res.end();\n return;\n }\n // A wrong-state request is not our callback (drive-by localhost probe\n // or stale tab) — answer it but keep waiting for the real redirect, so\n // an unauthenticated request can't abort the pending flow.\n if (url.searchParams.get(\"state\") !== expectedState) {\n respondHtml(res, 400, \"Invalid authorization callback. You can close this tab.\");\n return;\n }\n clearTimeout(timer);\n try {\n const code = authCodeFromCallback(url, expectedState);\n respondHtml(res, 200, \"Authorization complete — you can close this tab.\");\n resolve(code);\n } catch (err) {\n // Static text only — the failure detail echoes query-string content,\n // which must not be reflected into HTML. The CLI prints the detail.\n respondHtml(res, 400, \"Authorization failed — see the terminal for details. You can close this tab.\");\n reject(err);\n }\n });\n });\n\n// `access_type: offline` + `prompt: consent` force Google to return a refresh\n// token on every run (repeat consents otherwise omit it).\nconst buildConsentUrl = (client: OAuth2Client, codeChallenge: string, state: string): string =>\n client.generateAuthUrl({\n access_type: \"offline\",\n prompt: \"consent\",\n scope: GOOGLE_SCOPES,\n code_challenge_method: CodeChallengeMethod.S256,\n code_challenge: codeChallenge,\n state,\n });\n\nexport async function authorizeGoogle(opts: AuthorizeGoogleOptions = {}): Promise<Credentials> {\n const secret = await loadClientSecret(opts.home);\n const { server, port } = await startLoopbackServer();\n try {\n const client = createClient(secret, `http://127.0.0.1:${port}${CALLBACK_PATH}`);\n const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();\n if (!codeChallenge) throw new Error(\"failed to derive a PKCE code challenge\");\n const state = randomBytes(STATE_BYTES).toString(\"hex\");\n opts.onAuthUrl?.(buildConsentUrl(client, codeChallenge, state));\n const code = await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS);\n const { tokens } = await client.getToken({ code, codeVerifier });\n if (!tokens.refresh_token) {\n throw new Error(\"Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry\");\n }\n return await saveGoogleTokens(tokens, opts.home);\n } finally {\n server.close();\n }\n}\n","// In-flight manager for the settings-UI OAuth flow. authorizeGoogle()\n// resolves only after the user finishes the browser consent, so the HTTP\n// layer starts it in the background, returns the consent URL immediately,\n// and reports progress via status polling. One flow at a time — the guard\n// is the in-flight start promise itself (set synchronously before any\n// await), so concurrent authorize requests share one flow instead of\n// spawning parallel loopback listeners.\nimport { log } from \"./host.js\";\nimport { errorMessage } from \"./util.js\";\nimport { authorizeGoogle } from \"./auth.js\";\n\nexport interface GoogleAuthFlowStatus {\n pending: boolean;\n lastError: string | null;\n}\n\nexport interface GoogleAuthFlow {\n start: () => Promise<{ authUrl: string }>;\n status: () => GoogleAuthFlowStatus;\n}\n\nexport const createGoogleAuthFlow = (authorize: typeof authorizeGoogle): GoogleAuthFlow => {\n let inFlightStart: Promise<{ authUrl: string }> | null = null;\n let flowRunning = false;\n let lastError: string | null = null;\n\n const launchFlow = (): Promise<{ authUrl: string }> =>\n new Promise((resolve, reject) => {\n flowRunning = true;\n authorize({\n onAuthUrl: (url) => resolve({ authUrl: url }),\n })\n .then(() => log.info(\"google\", \"authorize flow completed\"))\n .catch((err: unknown) => {\n lastError = errorMessage(err);\n log.warn(\"google\", \"authorize flow failed\", { error: lastError });\n // No-op when the URL already resolved; covers pre-URL failures\n // (missing client secret, port bind error).\n reject(err instanceof Error ? err : new Error(String(err)));\n })\n .finally(() => {\n flowRunning = false;\n inFlightStart = null;\n });\n });\n\n const start = (): Promise<{ authUrl: string }> => {\n if (inFlightStart) return inFlightStart;\n lastError = null;\n inFlightStart = launchFlow();\n return inFlightStart;\n };\n\n const status = (): GoogleAuthFlowStatus => ({ pending: flowRunning, lastError });\n\n return { start, status };\n};\n\nexport const googleAuthFlow = createGoogleAuthFlow(authorizeGoogle);\n","// Google Calendar v3 REST calls against the user's primary calendar. Plain\n// fetch instead of the `googleapis` SDK — two endpoints don't justify the\n// dependency (see plans/done/feat-google-oauth-calendar.md).\nimport { errorMessage, isRecord, ONE_SECOND_MS, truncate } from \"./util.js\";\nimport { fetchWithTimeout } from \"./fetch.js\";\n\nconst CALENDAR_EVENTS_URL = \"https://www.googleapis.com/calendar/v3/calendars/primary/events\";\nconst CALENDAR_TIMEOUT_MS = 30 * ONE_SECOND_MS;\nconst ERROR_BODY_MAX_CHARS = 300;\nconst HTTP_FORBIDDEN = 403;\nexport const DEFAULT_LIST_MAX_RESULTS = 10;\nexport const MAX_LIST_RESULTS = 50;\n\nexport interface CalendarEventInput {\n summary: string;\n startDateTime: string;\n endDateTime: string;\n description?: string;\n}\n\nexport interface ListEventsInput {\n timeMin?: string;\n maxResults?: number;\n}\n\nexport interface CalendarEventSummary {\n id: string;\n summary: string;\n start: string;\n end: string;\n htmlLink: string;\n status: string;\n}\n\n// All-day events carry `date`, timed events carry `dateTime`.\nconst eventTime = (value: unknown): string => {\n if (!isRecord(value)) return \"\";\n if (typeof value.dateTime === \"string\") return value.dateTime;\n if (typeof value.date === \"string\") return value.date;\n return \"\";\n};\n\nexport const toEventSummary = (value: unknown): CalendarEventSummary => {\n const record: Record<string, unknown> = isRecord(value) ? value : {};\n return {\n id: typeof record.id === \"string\" ? record.id : \"\",\n summary: typeof record.summary === \"string\" ? record.summary : \"\",\n start: eventTime(record.start),\n end: eventTime(record.end),\n htmlLink: typeof record.htmlLink === \"string\" ? record.htmlLink : \"\",\n status: typeof record.status === \"string\" ? record.status : \"\",\n };\n};\n\nexport const calendarApiError = (status: number, body: string): Error => {\n const hint = status === HTTP_FORBIDDEN ? \" (is the Google Calendar API enabled for the Cloud project?)\" : \"\";\n const detail = body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : \"\";\n return new Error(`Google Calendar API: HTTP ${status}${hint}${detail}`);\n};\n\nconst calendarRequest = async (accessToken: string, url: string, init: { method?: string; body?: string } = {}): Promise<unknown> => {\n const response = await fetchWithTimeout(url, {\n ...init,\n timeoutMs: CALENDAR_TIMEOUT_MS,\n headers: { Authorization: `Bearer ${accessToken}`, \"Content-Type\": \"application/json\" },\n });\n if (!response.ok) {\n const body = await response.text().catch((err: unknown) => errorMessage(err));\n throw calendarApiError(response.status, body);\n }\n return await response.json();\n};\n\nexport async function createCalendarEvent(accessToken: string, input: CalendarEventInput): Promise<CalendarEventSummary> {\n const body = {\n summary: input.summary,\n description: input.description,\n start: { dateTime: input.startDateTime },\n end: { dateTime: input.endDateTime },\n };\n const created = await calendarRequest(accessToken, CALENDAR_EVENTS_URL, { method: \"POST\", body: JSON.stringify(body) });\n return toEventSummary(created);\n}\n\nexport async function listCalendarEvents(accessToken: string, input: ListEventsInput = {}): Promise<CalendarEventSummary[]> {\n const params = new URLSearchParams({\n timeMin: input.timeMin ?? new Date().toISOString(),\n maxResults: String(input.maxResults ?? DEFAULT_LIST_MAX_RESULTS),\n singleEvents: \"true\",\n orderBy: \"startTime\",\n });\n const listed = await calendarRequest(accessToken, `${CALENDAR_EVENTS_URL}?${params.toString()}`);\n const items = isRecord(listed) && Array.isArray(listed.items) ? listed.items : [];\n return items.map(toEventSummary);\n}\n"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ export { configureGoogleHost, type GoogleLogger } from './host.js';
2
+ export { isIsoDateTimeWithOffset } from './datetime.js';
3
+ export { googleConfigDir, googleSecretsDir, googleTokenPath, legacyGoogleTokenPath } from './paths.js';
4
+ export { clientSecretPresence, findClientSecretPath, loadClientSecret, type ClientSecretPresence, type InstalledClientSecret } from './clientSecret.js';
5
+ export { deleteGoogleTokens, loadGoogleTokens, mergeGoogleTokens, saveGoogleTokens } from './tokenStore.js';
6
+ export { authorizeGoogle, getGoogleAccessToken, unlinkGoogle, waitForAuthCode, GOOGLE_CALENDAR_SCOPE, GOOGLE_TASKS_SCOPE, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, type AuthorizeGoogleOptions, type RevokeFetch, } from './auth.js';
7
+ export { createGoogleAuthFlow, googleAuthFlow, type GoogleAuthFlow, type GoogleAuthFlowStatus } from './authFlow.js';
8
+ export { calendarApiError, createCalendarEvent, listCalendarEvents, toEventSummary, DEFAULT_LIST_MAX_RESULTS, MAX_LIST_RESULTS, type CalendarEventInput, type CalendarEventSummary, type ListEventsInput, } from './calendar.js';
@@ -0,0 +1,461 @@
1
+ import path, { join } from "node:path";
2
+ import { promises } from "node:fs";
3
+ import { constants, copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
4
+ import { randomBytes } from "node:crypto";
5
+ import { homedir } from "node:os";
6
+ import http from "node:http";
7
+ import { CodeChallengeMethod, OAuth2Client } from "google-auth-library";
8
+ //#region src/google/host.ts
9
+ var hostLog = {
10
+ error: () => void 0,
11
+ warn: () => void 0,
12
+ info: () => void 0,
13
+ debug: () => void 0
14
+ };
15
+ function configureGoogleHost(binding) {
16
+ hostLog = binding.log;
17
+ }
18
+ var log = {
19
+ error: (prefix, message, data) => hostLog.error(prefix, message, data),
20
+ warn: (prefix, message, data) => hostLog.warn(prefix, message, data),
21
+ info: (prefix, message, data) => hostLog.info(prefix, message, data),
22
+ debug: (prefix, message, data) => hostLog.debug(prefix, message, data)
23
+ };
24
+ //#endregion
25
+ //#region src/google/datetime.ts
26
+ var ISO_DATE_TIME_WITH_OFFSET_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:Z|[+-](\d{2}):(\d{2}))$/;
27
+ var FRACTIONAL_SECONDS_RE = /\.\d+(?=Z|[+-])/;
28
+ var MAX_HOUR = 23;
29
+ var MAX_MINUTE = 59;
30
+ var MAX_SECOND = 59;
31
+ var isRealCalendarDate = (year, month, day) => {
32
+ const roundTrip = new Date(Date.UTC(year, month - 1, day));
33
+ return roundTrip.getUTCFullYear() === year && roundTrip.getUTCMonth() === month - 1 && roundTrip.getUTCDate() === day;
34
+ };
35
+ var isIsoDateTimeWithOffset = (value) => {
36
+ const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE, ""));
37
+ if (!match) return false;
38
+ const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number);
39
+ const timeInRange = hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND;
40
+ const offsetInRange = match[7] === void 0 || Number(match[7]) <= MAX_HOUR && Number(match[8]) <= MAX_MINUTE;
41
+ return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;
42
+ };
43
+ //#endregion
44
+ //#region src/google/paths.ts
45
+ function googleConfigDir(home) {
46
+ return join(home ?? homedir(), ".config", "mulmo");
47
+ }
48
+ /** Pre-0.20.1 token dir (mulmoclaude-branded); reads migrate away from it. */
49
+ function legacyGoogleTokenPath(home) {
50
+ return join(home ?? homedir(), ".config", "mulmoclaude", "google-token.json");
51
+ }
52
+ function googleTokenPath(home) {
53
+ return join(googleConfigDir(home), "google-token.json");
54
+ }
55
+ function googleSecretsDir(home) {
56
+ return join(home ?? homedir(), ".secrets");
57
+ }
58
+ //#endregion
59
+ //#region src/google/util.ts
60
+ var ONE_SECOND_MS = 1e3;
61
+ var ONE_MINUTE_MS = 6e4;
62
+ function errorMessage(err, fallback = "unknown error") {
63
+ if (err instanceof Error) return err.message || fallback;
64
+ const text = String(err);
65
+ return text === "" || text === "[object Object]" ? fallback : text;
66
+ }
67
+ function isRecord(value) {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+ /** Clip a string to at most `max` chars; the ellipsis is included in the
71
+ * budget so output never exceeds `max`. */
72
+ function truncate(text, max, ellipsis = "…") {
73
+ if (max <= 0) return "";
74
+ if (text.length <= max) return text;
75
+ return `${text.slice(0, Math.max(0, max - ellipsis.length))}${ellipsis}`;
76
+ }
77
+ //#endregion
78
+ //#region src/google/clientSecret.ts
79
+ var isInstalledClientSecret = (value) => {
80
+ if (!isRecord(value) || !isRecord(value.installed)) return false;
81
+ return typeof value.installed.client_id === "string" && typeof value.installed.client_secret === "string";
82
+ };
83
+ var isClientSecretFileName = (name) => name.startsWith("client_secret_") && name.endsWith(".json");
84
+ async function listClientSecretFiles(home) {
85
+ return (await readdir(googleSecretsDir(home)).catch(() => [])).filter(isClientSecretFileName).sort();
86
+ }
87
+ async function clientSecretPresence(home) {
88
+ const matches = await listClientSecretFiles(home);
89
+ if (matches.length === 0) return "missing";
90
+ return matches.length === 1 ? "found" : "ambiguous";
91
+ }
92
+ async function findClientSecretPath(home) {
93
+ const dir = googleSecretsDir(home);
94
+ const matches = await listClientSecretFiles(home);
95
+ const [first, ...rest] = matches;
96
+ if (!first) throw new Error(`no client_secret_*.json found in ${dir} — download the OAuth desktop-app credentials JSON from the Google Cloud Console and place it there (mode 600)`);
97
+ if (rest.length > 0) throw new Error(`multiple client_secret_*.json files found in ${dir} (${matches.join(", ")}) — keep exactly one`);
98
+ return join(dir, first);
99
+ }
100
+ async function loadClientSecret(home) {
101
+ const filePath = await findClientSecretPath(home);
102
+ const raw = await readFile(filePath, "utf-8");
103
+ const parsed = JSON.parse(raw);
104
+ if (!isInstalledClientSecret(parsed)) throw new Error(`${filePath} is not a desktop-app OAuth client secret (missing "installed" with client_id / client_secret)`);
105
+ return {
106
+ client_id: parsed.installed.client_id,
107
+ client_secret: parsed.installed.client_secret
108
+ };
109
+ }
110
+ //#endregion
111
+ //#region src/google/fsJson.ts
112
+ async function readJsonOrNull(filePath) {
113
+ try {
114
+ const raw = await promises.readFile(filePath, "utf-8");
115
+ return JSON.parse(raw);
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+ var IS_WINDOWS = process.platform === "win32";
121
+ var RENAME_RETRY_DELAYS_MS = [
122
+ 30,
123
+ 100,
124
+ 300
125
+ ];
126
+ var hasErrnoCode = (err) => typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
127
+ var isTransientRenameError = (err) => IS_WINDOWS && hasErrnoCode(err) && (err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES");
128
+ async function renameWithWindowsRetry(fromPath, toPath) {
129
+ for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
130
+ await promises.rename(fromPath, toPath);
131
+ return;
132
+ } catch (err) {
133
+ if (!isTransientRenameError(err)) throw err;
134
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
135
+ }
136
+ await promises.rename(fromPath, toPath);
137
+ }
138
+ /** tmp-write + rename so readers never see a half-written file; `mode`
139
+ * applies to the tmp file and survives the rename. */
140
+ async function writeJsonAtomicWithMode(filePath, data, mode) {
141
+ const tmp = `${filePath}.tmp`;
142
+ await promises.mkdir(path.dirname(filePath), { recursive: true });
143
+ try {
144
+ await promises.writeFile(tmp, JSON.stringify(data, null, 2), {
145
+ encoding: "utf-8",
146
+ mode
147
+ });
148
+ await renameWithWindowsRetry(tmp, filePath);
149
+ } catch (err) {
150
+ await promises.unlink(tmp).catch(() => void 0);
151
+ throw err;
152
+ }
153
+ }
154
+ //#endregion
155
+ //#region src/google/tokenStore.ts
156
+ var TOKEN_FILE_MODE = 384;
157
+ function mergeGoogleTokens(existing, incoming) {
158
+ const merged = {
159
+ ...existing,
160
+ ...incoming
161
+ };
162
+ if (!incoming.refresh_token && existing?.refresh_token) merged.refresh_token = existing.refresh_token;
163
+ return merged;
164
+ }
165
+ var fileExists = async (filePath) => await stat(filePath).then(() => true, () => false);
166
+ async function migrateLegacyTokenFile(home) {
167
+ const current = googleTokenPath(home);
168
+ const legacy = legacyGoogleTokenPath(home);
169
+ if (!await fileExists(legacy)) return;
170
+ await mkdir(path.dirname(current), { recursive: true });
171
+ try {
172
+ await copyFile(legacy, current, constants.COPYFILE_EXCL);
173
+ } catch {
174
+ return;
175
+ }
176
+ await rm(legacy, { force: true });
177
+ }
178
+ async function loadGoogleTokens(home) {
179
+ await migrateLegacyTokenFile(home).catch(() => void 0);
180
+ const current = await readJsonOrNull(googleTokenPath(home));
181
+ if (current) return current;
182
+ return await readJsonOrNull(legacyGoogleTokenPath(home));
183
+ }
184
+ async function saveGoogleTokens(incoming, home) {
185
+ const merged = mergeGoogleTokens(await loadGoogleTokens(home), incoming);
186
+ await writeJsonAtomicWithMode(googleTokenPath(home), merged, TOKEN_FILE_MODE);
187
+ return merged;
188
+ }
189
+ async function deleteGoogleTokens(home) {
190
+ await rm(googleTokenPath(home), { force: true });
191
+ }
192
+ //#endregion
193
+ //#region src/google/fetch.ts
194
+ var DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;
195
+ /** `fetch` with a finite timeout. Rejects with a `TimeoutError` once
196
+ * `timeoutMs` elapses. Composes with a caller-supplied `signal` so external
197
+ * cancellation still works. */
198
+ async function fetchWithTimeout(url, init = {}) {
199
+ const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;
200
+ if (callerSignal?.aborted) throw callerSignal.reason ?? new DOMException("Aborted", "AbortError");
201
+ const controller = new AbortController();
202
+ const timer = setTimeout(() => {
203
+ controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, "TimeoutError"));
204
+ }, timeoutMs);
205
+ const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);
206
+ try {
207
+ return await fetch(url, {
208
+ ...rest,
209
+ signal: controller.signal
210
+ });
211
+ } finally {
212
+ clearTimeout(timer);
213
+ unsubscribeCaller?.();
214
+ }
215
+ }
216
+ function bridgeExternalSignal(external, controller) {
217
+ if (!external) return null;
218
+ const onAbort = () => controller.abort(external.reason);
219
+ external.addEventListener("abort", onAbort, { once: true });
220
+ return () => external.removeEventListener("abort", onAbort);
221
+ }
222
+ //#endregion
223
+ //#region src/google/auth.ts
224
+ var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
225
+ var GOOGLE_TASKS_SCOPE = "https://www.googleapis.com/auth/tasks";
226
+ var GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
227
+ /** Requested at consent as one set — matches the scopes registered on the
228
+ * OAuth consent screen, so a single re-link covers every supported API
229
+ * (Calendar now; Tasks / Drive tools ride the same grant later). */
230
+ var GOOGLE_SCOPES = [
231
+ GOOGLE_CALENDAR_SCOPE,
232
+ GOOGLE_TASKS_SCOPE,
233
+ GOOGLE_DRIVE_FILE_SCOPE
234
+ ];
235
+ var CALLBACK_PATH = "/oauth2callback";
236
+ var AUTH_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
237
+ var STATE_BYTES = 16;
238
+ var createClient = (secret, redirectUri) => new OAuth2Client({
239
+ clientId: secret.client_id,
240
+ clientSecret: secret.client_secret,
241
+ redirectUri
242
+ });
243
+ var persistRotatedTokens = (client, home) => {
244
+ client.on("tokens", (tokens) => {
245
+ saveGoogleTokens(tokens, home).catch((err) => {
246
+ log.error("google", "failed to persist rotated tokens", { error: String(err) });
247
+ });
248
+ });
249
+ };
250
+ async function getGoogleAccessToken(home) {
251
+ const saved = await loadGoogleTokens(home);
252
+ if (!saved?.refresh_token) throw new Error("Google account not linked on this host — link it in Settings → Plugins → Google, or run `yarn google:auth`");
253
+ const client = createClient(await loadClientSecret(home));
254
+ client.setCredentials(saved);
255
+ persistRotatedTokens(client, home);
256
+ const { token } = await client.getAccessToken();
257
+ if (!token) throw new Error("could not obtain a Google access token — the grant may have been revoked; re-link the account");
258
+ return token;
259
+ }
260
+ var REVOKE_URL = "https://oauth2.googleapis.com/revoke";
261
+ /** Revoke the grant at Google (best-effort) and delete the local token file.
262
+ * Revoke failures are logged but never block the local delete — Google may
263
+ * already consider the token invalid, and keeping the file would leave the
264
+ * user unable to unlink. */
265
+ async function unlinkGoogle(home, revokeFetch = fetchWithTimeout) {
266
+ const saved = await loadGoogleTokens(home);
267
+ const token = saved?.refresh_token ?? saved?.access_token;
268
+ if (token) try {
269
+ const response = await revokeFetch(REVOKE_URL, {
270
+ method: "POST",
271
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
272
+ body: new URLSearchParams({ token }).toString()
273
+ });
274
+ if (!response.ok) log.warn("google", "token revoke returned non-ok", { status: response.status });
275
+ } catch (err) {
276
+ log.warn("google", "token revoke failed, deleting local tokens anyway", { error: errorMessage(err) });
277
+ }
278
+ await deleteGoogleTokens(home);
279
+ }
280
+ var startLoopbackServer = () => new Promise((resolve, reject) => {
281
+ const server = http.createServer();
282
+ server.once("error", reject);
283
+ server.listen(0, "127.0.0.1", () => {
284
+ const address = server.address();
285
+ if (address === null || typeof address === "string") {
286
+ reject(/* @__PURE__ */ new Error("loopback server has no port"));
287
+ return;
288
+ }
289
+ resolve({
290
+ server,
291
+ port: address.port
292
+ });
293
+ });
294
+ });
295
+ var authCodeFromCallback = (url, expectedState) => {
296
+ if (url.searchParams.get("state") !== expectedState) throw new Error("OAuth state mismatch — possible CSRF, aborting");
297
+ const error = url.searchParams.get("error");
298
+ if (error) throw new Error(`Google authorization failed: ${error}`);
299
+ const code = url.searchParams.get("code");
300
+ if (!code) throw new Error("authorization callback carried no code");
301
+ return code;
302
+ };
303
+ var respondHtml = (res, status, message) => {
304
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
305
+ res.end(`<html><body><h3>${message}</h3></body></html>`);
306
+ };
307
+ var waitForAuthCode = (server, expectedState, timeoutMs) => new Promise((resolve, reject) => {
308
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`authorization timed out after ${timeoutMs / ONE_SECOND_MS}s`)), timeoutMs);
309
+ server.on("request", (req, res) => {
310
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
311
+ if (url.pathname !== CALLBACK_PATH) {
312
+ res.writeHead(404);
313
+ res.end();
314
+ return;
315
+ }
316
+ if (url.searchParams.get("state") !== expectedState) {
317
+ respondHtml(res, 400, "Invalid authorization callback. You can close this tab.");
318
+ return;
319
+ }
320
+ clearTimeout(timer);
321
+ try {
322
+ const code = authCodeFromCallback(url, expectedState);
323
+ respondHtml(res, 200, "Authorization complete — you can close this tab.");
324
+ resolve(code);
325
+ } catch (err) {
326
+ respondHtml(res, 400, "Authorization failed — see the terminal for details. You can close this tab.");
327
+ reject(err);
328
+ }
329
+ });
330
+ });
331
+ var buildConsentUrl = (client, codeChallenge, state) => client.generateAuthUrl({
332
+ access_type: "offline",
333
+ prompt: "consent",
334
+ scope: GOOGLE_SCOPES,
335
+ code_challenge_method: CodeChallengeMethod.S256,
336
+ code_challenge: codeChallenge,
337
+ state
338
+ });
339
+ async function authorizeGoogle(opts = {}) {
340
+ const secret = await loadClientSecret(opts.home);
341
+ const { server, port } = await startLoopbackServer();
342
+ try {
343
+ const client = createClient(secret, `http://127.0.0.1:${port}${CALLBACK_PATH}`);
344
+ const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();
345
+ if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
346
+ const state = randomBytes(STATE_BYTES).toString("hex");
347
+ opts.onAuthUrl?.(buildConsentUrl(client, codeChallenge, state));
348
+ const code = await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
349
+ const { tokens } = await client.getToken({
350
+ code,
351
+ codeVerifier
352
+ });
353
+ if (!tokens.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
354
+ return await saveGoogleTokens(tokens, opts.home);
355
+ } finally {
356
+ server.close();
357
+ }
358
+ }
359
+ //#endregion
360
+ //#region src/google/authFlow.ts
361
+ var createGoogleAuthFlow = (authorize) => {
362
+ let inFlightStart = null;
363
+ let flowRunning = false;
364
+ let lastError = null;
365
+ const launchFlow = () => new Promise((resolve, reject) => {
366
+ flowRunning = true;
367
+ authorize({ onAuthUrl: (url) => resolve({ authUrl: url }) }).then(() => log.info("google", "authorize flow completed")).catch((err) => {
368
+ lastError = errorMessage(err);
369
+ log.warn("google", "authorize flow failed", { error: lastError });
370
+ reject(err instanceof Error ? err : new Error(String(err)));
371
+ }).finally(() => {
372
+ flowRunning = false;
373
+ inFlightStart = null;
374
+ });
375
+ });
376
+ const start = () => {
377
+ if (inFlightStart) return inFlightStart;
378
+ lastError = null;
379
+ inFlightStart = launchFlow();
380
+ return inFlightStart;
381
+ };
382
+ const status = () => ({
383
+ pending: flowRunning,
384
+ lastError
385
+ });
386
+ return {
387
+ start,
388
+ status
389
+ };
390
+ };
391
+ var googleAuthFlow = createGoogleAuthFlow(authorizeGoogle);
392
+ //#endregion
393
+ //#region src/google/calendar.ts
394
+ var CALENDAR_EVENTS_URL = "https://www.googleapis.com/calendar/v3/calendars/primary/events";
395
+ var CALENDAR_TIMEOUT_MS = 30 * ONE_SECOND_MS;
396
+ var ERROR_BODY_MAX_CHARS = 300;
397
+ var HTTP_FORBIDDEN = 403;
398
+ var DEFAULT_LIST_MAX_RESULTS = 10;
399
+ var MAX_LIST_RESULTS = 50;
400
+ var eventTime = (value) => {
401
+ if (!isRecord(value)) return "";
402
+ if (typeof value.dateTime === "string") return value.dateTime;
403
+ if (typeof value.date === "string") return value.date;
404
+ return "";
405
+ };
406
+ var toEventSummary = (value) => {
407
+ const record = isRecord(value) ? value : {};
408
+ return {
409
+ id: typeof record.id === "string" ? record.id : "",
410
+ summary: typeof record.summary === "string" ? record.summary : "",
411
+ start: eventTime(record.start),
412
+ end: eventTime(record.end),
413
+ htmlLink: typeof record.htmlLink === "string" ? record.htmlLink : "",
414
+ status: typeof record.status === "string" ? record.status : ""
415
+ };
416
+ };
417
+ var calendarApiError = (status, body) => {
418
+ const hint = status === HTTP_FORBIDDEN ? " (is the Google Calendar API enabled for the Cloud project?)" : "";
419
+ const detail = body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : "";
420
+ return /* @__PURE__ */ new Error(`Google Calendar API: HTTP ${status}${hint}${detail}`);
421
+ };
422
+ var calendarRequest = async (accessToken, url, init = {}) => {
423
+ const response = await fetchWithTimeout(url, {
424
+ ...init,
425
+ timeoutMs: CALENDAR_TIMEOUT_MS,
426
+ headers: {
427
+ Authorization: `Bearer ${accessToken}`,
428
+ "Content-Type": "application/json"
429
+ }
430
+ });
431
+ if (!response.ok) {
432
+ const body = await response.text().catch((err) => errorMessage(err));
433
+ throw calendarApiError(response.status, body);
434
+ }
435
+ return await response.json();
436
+ };
437
+ async function createCalendarEvent(accessToken, input) {
438
+ const body = {
439
+ summary: input.summary,
440
+ description: input.description,
441
+ start: { dateTime: input.startDateTime },
442
+ end: { dateTime: input.endDateTime }
443
+ };
444
+ return toEventSummary(await calendarRequest(accessToken, CALENDAR_EVENTS_URL, {
445
+ method: "POST",
446
+ body: JSON.stringify(body)
447
+ }));
448
+ }
449
+ async function listCalendarEvents(accessToken, input = {}) {
450
+ const listed = await calendarRequest(accessToken, `${CALENDAR_EVENTS_URL}?${new URLSearchParams({
451
+ timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
452
+ maxResults: String(input.maxResults ?? 10),
453
+ singleEvents: "true",
454
+ orderBy: "startTime"
455
+ }).toString()}`);
456
+ return (isRecord(listed) && Array.isArray(listed.items) ? listed.items : []).map(toEventSummary);
457
+ }
458
+ //#endregion
459
+ export { DEFAULT_LIST_MAX_RESULTS, GOOGLE_CALENDAR_SCOPE, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, MAX_LIST_RESULTS, authorizeGoogle, calendarApiError, clientSecretPresence, configureGoogleHost, createCalendarEvent, createGoogleAuthFlow, deleteGoogleTokens, findClientSecretPath, getGoogleAccessToken, googleAuthFlow, googleConfigDir, googleSecretsDir, googleTokenPath, isIsoDateTimeWithOffset, legacyGoogleTokenPath, listCalendarEvents, loadClientSecret, loadGoogleTokens, mergeGoogleTokens, saveGoogleTokens, toEventSummary, unlinkGoogle, waitForAuthCode };
460
+
461
+ //# sourceMappingURL=index.js.map