jerob 1.0.0

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.
Files changed (69) hide show
  1. package/CLI/cli.ts +42 -0
  2. package/README.md +137 -0
  3. package/SETUP.md +584 -0
  4. package/agent/action-tracker.ts +45 -0
  5. package/agent/agent-tools.ts +111 -0
  6. package/agent/approval.ts +137 -0
  7. package/agent/diff-view.ts +26 -0
  8. package/agent/orchestrator.ts +186 -0
  9. package/agent/tool-executor.ts +463 -0
  10. package/agent/types.ts +69 -0
  11. package/ask/orchestrator.ts +244 -0
  12. package/auth/auth.ts +567 -0
  13. package/auth/config-store.ts +77 -0
  14. package/auth/crypto.ts +51 -0
  15. package/auth/env-writer.ts +82 -0
  16. package/bin/jerob.js +28 -0
  17. package/config/ai.config.ts +163 -0
  18. package/email_ops/email-tools.ts +178 -0
  19. package/email_ops/email_functions.ts +443 -0
  20. package/email_ops/email_init.ts +92 -0
  21. package/email_ops/email_pass_store.ts +61 -0
  22. package/email_ops/email_server.ts +29 -0
  23. package/email_ops/types.ts +88 -0
  24. package/index.ts +176 -0
  25. package/package.json +88 -0
  26. package/plan/browser-agent/README.md +118 -0
  27. package/plan/browser-agent/USAGE.md +308 -0
  28. package/plan/browser-agent/evaluator.ts +353 -0
  29. package/plan/browser-agent/executor.ts +372 -0
  30. package/plan/browser-agent/index.ts +13 -0
  31. package/plan/browser-agent/orchestrator.ts +323 -0
  32. package/plan/browser-agent/planner.ts +200 -0
  33. package/plan/browser-agent/types.ts +62 -0
  34. package/plan/browser-tool.ts +128 -0
  35. package/plan/index.ts +12 -0
  36. package/plan/orchestrator.ts +214 -0
  37. package/plan/planner.ts +183 -0
  38. package/plan/selection.ts +50 -0
  39. package/plan/types.ts +13 -0
  40. package/plan/web-tools.ts +119 -0
  41. package/scheduler/ARCHITECTURE.md +263 -0
  42. package/scheduler/README.md +200 -0
  43. package/scheduler/SETUP-READY.sql +84 -0
  44. package/scheduler/check-status.sql +124 -0
  45. package/scheduler/config-sync.ts +91 -0
  46. package/scheduler/db-migrate.ts +271 -0
  47. package/scheduler/db.ts +162 -0
  48. package/scheduler/debug.ts +184 -0
  49. package/scheduler/orchestrator.ts +438 -0
  50. package/scheduler/planner.ts +170 -0
  51. package/scheduler/update-task-email.ts +70 -0
  52. package/supabase/.temp/cli-latest +1 -0
  53. package/supabase/.temp/gotrue-version +1 -0
  54. package/supabase/.temp/linked-project.json +1 -0
  55. package/supabase/.temp/pooler-url +1 -0
  56. package/supabase/.temp/postgres-version +1 -0
  57. package/supabase/.temp/project-ref +1 -0
  58. package/supabase/.temp/rest-version +1 -0
  59. package/supabase/.temp/storage-migration +1 -0
  60. package/supabase/.temp/storage-version +1 -0
  61. package/supabase/deploy.ps1 +50 -0
  62. package/supabase/functions/scheduler-tick/index.ts +496 -0
  63. package/supabase/supabase/.temp/linked-project.json +1 -0
  64. package/tsconfig.json +33 -0
  65. package/tui/spinner.ts +33 -0
  66. package/tui/spinup.ts +67 -0
  67. package/tui/terminal-render.ts +16 -0
  68. package/utils/llm-error.ts +185 -0
  69. package/utils/model-validator.ts +247 -0
@@ -0,0 +1,443 @@
1
+ import { isAuth, isAccessTokenFresh, removeConfig } from "./email_pass_store";
2
+ import { oauth2Client } from "./email_server";
3
+ import { authenticate } from "./email_init";
4
+ import { google } from "googleapis";
5
+ import chalk from "chalk";
6
+ import { generateText } from "ai";
7
+ import { getAgentModel } from "../config/ai.config";
8
+ import { withLLMRetry } from "../utils/llm-error";
9
+ import type {
10
+ SendMailInput,
11
+ ReadMailInput,
12
+ SearchMailInput,
13
+ ReplyMailInput,
14
+ DraftMailInput,
15
+ DeleteMailInput,
16
+ ArchiveMailInput,
17
+ LabelMailInput,
18
+ WatchInput,
19
+ ClassifyInput,
20
+ ExtractTasksInput,
21
+ BulkActionInput,
22
+ DigestInput,
23
+ ScheduleSendInput,
24
+ ThreadInput,
25
+ EmailMessage,
26
+ } from "./types";
27
+
28
+ // ─── Gmail client ────────────────────────────────────────────────────────────
29
+
30
+ /** Returns a token string, triggering OAuth if not stored. */
31
+ const getRefreshToken = async (): Promise<string> => {
32
+ let ref = isAuth();
33
+ if (ref === null) {
34
+ console.log(chalk.green.bold("Gmail not authenticated — starting OAuth flow..."));
35
+ try {
36
+ const result = (await authenticate()) as any;
37
+ ref = result?.refresh_token ?? result?.tokens?.refresh_token ?? null;
38
+ } catch (err) {
39
+ throw new Error(
40
+ `Gmail OAuth failed: ${err instanceof Error ? err.message : String(err)}. ` +
41
+ `Make sure GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set in .env.`
42
+ );
43
+ }
44
+ if (!ref)
45
+ throw new Error(
46
+ "Gmail OAuth completed but no refresh token was returned. Try revoking access at myaccount.google.com/permissions and re-authenticating."
47
+ );
48
+ }
49
+ return ref;
50
+ };
51
+
52
+ const getGmailClient = async () => {
53
+ const ref = await getRefreshToken();
54
+ oauth2Client.setCredentials({ refresh_token: ref });
55
+
56
+ // Skip the network probe if the stored access token is still fresh
57
+ if (!isAccessTokenFresh()) {
58
+ try {
59
+ await oauth2Client.getAccessToken();
60
+ } catch (err: any) {
61
+ const msg: string = err?.message ?? String(err);
62
+ const isRevoked =
63
+ msg.includes("invalid_grant") ||
64
+ msg.includes("Token has been expired") ||
65
+ msg.includes("revoked");
66
+
67
+ if (isRevoked) {
68
+ console.log(chalk.yellow("⚠ Refresh token rejected by Google — clearing stored credentials and re-authenticating..."));
69
+ removeConfig();
70
+ try {
71
+ const result = (await authenticate()) as any;
72
+ const newRef = result?.refresh_token ?? null;
73
+ if (!newRef) throw new Error("No refresh token returned after re-authentication.");
74
+ oauth2Client.setCredentials({ refresh_token: newRef });
75
+ } catch (authErr) {
76
+ throw new Error(
77
+ `Re-authentication failed: ${authErr instanceof Error ? authErr.message : String(authErr)}`
78
+ );
79
+ }
80
+ } else {
81
+ // Network error or transient failure — don't wipe credentials
82
+ throw new Error(`Gmail token refresh failed: ${msg}`);
83
+ }
84
+ }
85
+ }
86
+
87
+ return google.gmail({ version: "v1", auth: oauth2Client });
88
+ };
89
+
90
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
91
+
92
+ /** Base64url-encode a raw RFC 2822 message string */
93
+ function encodeMessage(raw: string): string {
94
+ return Buffer.from(raw)
95
+ .toString("base64")
96
+ .replace(/\+/g, "-")
97
+ .replace(/\//g, "_")
98
+ .replace(/=+$/, "");
99
+ }
100
+
101
+ /** Build a raw RFC 2822 message */
102
+ function buildRawMessage(
103
+ to: string | string[],
104
+ subject: string,
105
+ body: string,
106
+ extra: { cc?: string[]; bcc?: string[]; replyTo?: string; inReplyTo?: string; references?: string } = {}
107
+ ): string {
108
+ const lines: string[] = [];
109
+ lines.push(`To: ${Array.isArray(to) ? to.join(", ") : to}`);
110
+ if (extra.cc?.length) lines.push(`Cc: ${extra.cc.join(", ")}`);
111
+ if (extra.bcc?.length) lines.push(`Bcc: ${extra.bcc.join(", ")}`);
112
+ if (extra.inReplyTo) lines.push(`In-Reply-To: ${extra.inReplyTo}`);
113
+ if (extra.references) lines.push(`References: ${extra.references}`);
114
+ lines.push(`Subject: ${subject}`);
115
+ lines.push("Content-Type: text/plain; charset=utf-8");
116
+ lines.push("");
117
+ lines.push(body);
118
+ return lines.join("\n");
119
+ }
120
+
121
+ /** Decode a Gmail message payload into plain text */
122
+ function decodeMessageBody(payload: any): string {
123
+ if (!payload) return "";
124
+ const tryDecode = (data: string) =>
125
+ Buffer.from(data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
126
+
127
+ if (payload.mimeType === "text/plain" && payload.body?.data) {
128
+ return tryDecode(payload.body.data);
129
+ }
130
+ if (payload.parts) {
131
+ for (const part of payload.parts) {
132
+ if (part.mimeType === "text/plain" && part.body?.data) {
133
+ return tryDecode(part.body.data);
134
+ }
135
+ }
136
+ // fallback: first part with data
137
+ for (const part of payload.parts) {
138
+ if (part.body?.data) return tryDecode(part.body.data);
139
+ }
140
+ }
141
+ return "";
142
+ }
143
+
144
+ /** Extract a header value from a message */
145
+ function getHeader(headers: any[], name: string): string {
146
+ return headers?.find((h: any) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
147
+ }
148
+
149
+ /** Map a Gmail message resource to our EmailMessage type */
150
+ function mapMessage(msg: any): EmailMessage {
151
+ const headers = msg.payload?.headers ?? [];
152
+ return {
153
+ id: msg.id,
154
+ threadId: msg.threadId,
155
+ from: getHeader(headers, "from"),
156
+ to: getHeader(headers, "to"),
157
+ subject: getHeader(headers, "subject"),
158
+ body: decodeMessageBody(msg.payload),
159
+ date: getHeader(headers, "date"),
160
+ labelIds: msg.labelIds ?? [],
161
+ };
162
+ }
163
+
164
+ // ─── Functions ───────────────────────────────────────────────────────────────
165
+
166
+ /** Send an email */
167
+ export async function sendMail(input: SendMailInput) {
168
+ const gmail = await getGmailClient();
169
+ const raw = buildRawMessage(input.to, input.subject, input.body, {
170
+ cc: input.cc,
171
+ bcc: input.bcc,
172
+ });
173
+ try {
174
+ const res = await gmail.users.messages.send({
175
+ userId: "me",
176
+ requestBody: { raw: encodeMessage(raw) },
177
+ });
178
+ return res.data;
179
+ } catch (e: any) {
180
+ const status = e?.response?.status ?? e?.code;
181
+ const detail = e?.response?.data?.error?.message ?? e?.message ?? String(e);
182
+ if (status === 401 || status === 403) {
183
+ throw new Error(`Gmail auth error (${status}): ${detail}. Re-authenticate by running an email operation again.`);
184
+ }
185
+ if (status === 429) {
186
+ throw new Error(`Gmail rate limit hit. Wait a moment and try again.`);
187
+ }
188
+ throw new Error(`sendMail failed: ${detail}`);
189
+ }
190
+ }
191
+
192
+ /** Read a single message by ID */
193
+ export async function readMail(input: ReadMailInput): Promise<EmailMessage> {
194
+ const gmail = await getGmailClient();
195
+ const res = await gmail.users.messages.get({
196
+ userId: "me",
197
+ id: input.messageId,
198
+ format: "full",
199
+ });
200
+ return mapMessage(res.data);
201
+ }
202
+
203
+ /** Search messages using Gmail query syntax */
204
+ export async function searchMail(input: SearchMailInput): Promise<EmailMessage[]> {
205
+ const gmail = await getGmailClient();
206
+ const listRes = await gmail.users.messages.list({
207
+ userId: "me",
208
+ q: input.query,
209
+ maxResults: input.maxResults ?? 10,
210
+ });
211
+
212
+ const ids = listRes.data.messages ?? [];
213
+ const messages = await Promise.all(
214
+ ids.map((m) =>
215
+ gmail.users.messages.get({ userId: "me", id: m.id!, format: "full" }).then((r) => mapMessage(r.data))
216
+ )
217
+ );
218
+ return messages;
219
+ }
220
+
221
+ /** Summarize a message using LLM */
222
+ export async function summarizeMail(input: ReadMailInput): Promise<string> {
223
+ const msg = await readMail(input);
224
+ const { text } = await withLLMRetry(
225
+ () => generateText({
226
+ model: getAgentModel(),
227
+ prompt: `Summarize this email concisely in 2-3 sentences:\n\nFrom: ${msg.from}\nSubject: ${msg.subject}\n\n${msg.body}`,
228
+ }),
229
+ { maxRetries: 2, context: "summarizeMail" }
230
+ );
231
+ return text;
232
+ }
233
+
234
+ /** Reply to a message */
235
+ export async function replyMail(input: ReplyMailInput) {
236
+ const gmail = await getGmailClient();
237
+ const original = await readMail({ messageId: input.messageId });
238
+
239
+ // Get the original message resource for Message-ID header
240
+ const msgRes = await gmail.users.messages.get({ userId: "me", id: input.messageId, format: "metadata" });
241
+ const messageIdHeader = getHeader(msgRes.data.payload?.headers ?? [], "message-id");
242
+
243
+ const raw = buildRawMessage(
244
+ original.from,
245
+ original.subject.startsWith("Re:") ? original.subject : `Re: ${original.subject}`,
246
+ input.body,
247
+ { inReplyTo: messageIdHeader, references: messageIdHeader }
248
+ );
249
+
250
+ const res = await gmail.users.messages.send({
251
+ userId: "me",
252
+ requestBody: {
253
+ raw: encodeMessage(raw),
254
+ threadId: original.threadId,
255
+ },
256
+ });
257
+ return res.data;
258
+ }
259
+
260
+ /** Save a draft */
261
+ export async function draftMail(input: DraftMailInput) {
262
+ const gmail = await getGmailClient();
263
+ const raw = buildRawMessage(input.to, input.subject, input.body, {
264
+ cc: input.cc,
265
+ bcc: input.bcc,
266
+ });
267
+ const res = await gmail.users.drafts.create({
268
+ userId: "me",
269
+ requestBody: { message: { raw: encodeMessage(raw) } },
270
+ });
271
+ return res.data;
272
+ }
273
+
274
+ /** Permanently delete a message */
275
+ export async function deleteMail(input: DeleteMailInput) {
276
+ const gmail = await getGmailClient();
277
+ await gmail.users.messages.delete({ userId: "me", id: input.messageId });
278
+ return { deleted: input.messageId };
279
+ }
280
+
281
+ /** Archive a message (remove INBOX label) */
282
+ export async function archiveMail(input: ArchiveMailInput) {
283
+ const gmail = await getGmailClient();
284
+ const res = await gmail.users.messages.modify({
285
+ userId: "me",
286
+ id: input.messageId,
287
+ requestBody: { removeLabelIds: ["INBOX"] },
288
+ });
289
+ return res.data;
290
+ }
291
+
292
+ /** Add/remove labels on a message */
293
+ export async function labelMail(input: LabelMailInput) {
294
+ const gmail = await getGmailClient();
295
+ const res = await gmail.users.messages.modify({
296
+ userId: "me",
297
+ id: input.messageId,
298
+ requestBody: {
299
+ addLabelIds: input.labelIds,
300
+ removeLabelIds: input.removeLabelIds ?? [],
301
+ },
302
+ });
303
+ return res.data;
304
+ }
305
+
306
+ /** Set up Gmail push notifications via Pub/Sub */
307
+ export async function watchMail(input: WatchInput) {
308
+ const gmail = await getGmailClient();
309
+ const res = await gmail.users.watch({
310
+ userId: "me",
311
+ requestBody: {
312
+ topicName: input.topicName,
313
+ labelIds: input.labelIds ?? ["INBOX"],
314
+ },
315
+ });
316
+ return res.data;
317
+ }
318
+
319
+ /** Classify a message into a category using LLM */
320
+ export async function classifyMail(input: ClassifyInput): Promise<string> {
321
+ const msg = await readMail({ messageId: input.messageId });
322
+ const { text } = await withLLMRetry(
323
+ () => generateText({
324
+ model: getAgentModel(),
325
+ prompt: `Classify this email into exactly one category from: [work, personal, newsletter, spam, finance, travel, support, social, other].
326
+ Reply with only the category name.
327
+
328
+ From: ${msg.from}
329
+ Subject: ${msg.subject}
330
+ Body: ${msg.body.slice(0, 500)}`,
331
+ }),
332
+ { maxRetries: 2, context: "classifyMail" }
333
+ );
334
+ return text.trim().toLowerCase();
335
+ }
336
+
337
+ /** Extract action items / tasks from a message using LLM */
338
+ export async function extractTasks(input: ExtractTasksInput): Promise<string[]> {
339
+ const msg = await readMail({ messageId: input.messageId });
340
+ const { text } = await withLLMRetry(
341
+ () => generateText({
342
+ model: getAgentModel(),
343
+ prompt: `Extract all action items or tasks from this email. Return one task per line, no numbering or bullets.
344
+ If there are no tasks, return "NONE".
345
+
346
+ From: ${msg.from}
347
+ Subject: ${msg.subject}
348
+ Body: ${msg.body}`,
349
+ }),
350
+ { maxRetries: 2, context: "extractTasks" }
351
+ );
352
+ const lines = text
353
+ .split("\n")
354
+ .map((l) => l.trim())
355
+ .filter((l) => l && l !== "NONE");
356
+ return lines;
357
+ }
358
+
359
+ /** Perform an action on multiple messages */
360
+ export async function bulkAction(input: BulkActionInput) {
361
+ const results = await Promise.allSettled(
362
+ input.messageIds.map((id) => {
363
+ switch (input.action) {
364
+ case "delete":
365
+ return deleteMail({ messageId: id });
366
+ case "archive":
367
+ return archiveMail({ messageId: id });
368
+ case "markRead":
369
+ return labelMail({ messageId: id, labelIds: [], removeLabelIds: ["UNREAD"] });
370
+ case "markUnread":
371
+ return labelMail({ messageId: id, labelIds: ["UNREAD"] });
372
+ case "label":
373
+ return labelMail({ messageId: id, labelIds: input.labelIds ?? [] });
374
+ }
375
+ })
376
+ );
377
+ return results.map((r, i) => ({
378
+ messageId: input.messageIds[i],
379
+ status: r.status,
380
+ ...(r.status === "rejected" ? { error: String((r as any).reason) } : {}),
381
+ }));
382
+ }
383
+
384
+ /** Generate a digest summary of recent/queried emails using LLM */
385
+ export async function digest(input: DigestInput): Promise<string> {
386
+ const messages = await searchMail({
387
+ query: input.query ?? "is:unread in:inbox",
388
+ maxResults: input.maxResults ?? 10,
389
+ });
390
+
391
+ if (messages.length === 0) return "No messages found.";
392
+
393
+ const summary = messages
394
+ .map((m, i) => `${i + 1}. From: ${m.from} | Subject: ${m.subject} | ${m.body.slice(0, 100)}`)
395
+ .join("\n");
396
+
397
+ const { text } = await withLLMRetry(
398
+ () => generateText({
399
+ model: getAgentModel(),
400
+ prompt: `Create a brief digest of these emails. Group by topic where relevant, highlight anything urgent.\n\n${summary}`,
401
+ }),
402
+ { maxRetries: 2, context: "digest" }
403
+ );
404
+ return text;
405
+ }
406
+
407
+ /** Schedule a send (stores draft + returns a reminder — true scheduling needs a job queue) */
408
+ export async function scheduleSend(input: ScheduleSendInput): Promise<{ draftId: string; scheduledFor: string }> {
409
+ // Gmail API doesn't natively support scheduled send via REST yet.
410
+ // We save a draft and return the scheduled time so the caller can handle the trigger.
411
+ const draft = await draftMail({
412
+ to: input.to,
413
+ cc: input.cc,
414
+ bcc: input.bcc,
415
+ subject: input.subject,
416
+ body: input.body,
417
+ });
418
+ console.log(
419
+ chalk.yellow(`[scheduleSend] Draft saved. Trigger sendDraft("${draft.id}") at ${input.sendAt.toISOString()}`)
420
+ );
421
+ return { draftId: draft.id!, scheduledFor: input.sendAt.toISOString() };
422
+ }
423
+
424
+ /** Send a saved draft by draft ID */
425
+ export async function sendDraft(draftId: string) {
426
+ const gmail = await getGmailClient();
427
+ const res = await gmail.users.drafts.send({
428
+ userId: "me",
429
+ requestBody: { id: draftId },
430
+ });
431
+ return res.data;
432
+ }
433
+
434
+ /** Get all messages in a thread */
435
+ export async function getThread(input: ThreadInput): Promise<EmailMessage[]> {
436
+ const gmail = await getGmailClient();
437
+ const res = await gmail.users.threads.get({
438
+ userId: "me",
439
+ id: input.threadId,
440
+ format: "full",
441
+ });
442
+ return (res.data.messages ?? []).map(mapMessage);
443
+ }
@@ -0,0 +1,92 @@
1
+ import app, { oauth2Client } from "./email_server";
2
+ import chalk from "chalk";
3
+ import open from "open";
4
+ import { saveConfig, loadConfig, type GoogleConfig } from "./email_pass_store";
5
+ import { syncGoogleRefreshToken } from "../scheduler/config-sync";
6
+
7
+
8
+ export const authenticate = async () => {
9
+ return await new Promise((resolve, reject) => {
10
+ let server: ReturnType<typeof app.listen>;
11
+
12
+ try {
13
+ server = app.listen(8787, async () => {
14
+ console.log(chalk.green.bold("✅ OAuth Started — opening browser..."));
15
+ try {
16
+ await open("http://localhost:8787/auth/google");
17
+ } catch {
18
+ console.log(chalk.yellow("Could not open browser automatically. Visit: http://localhost:8787/auth/google"));
19
+ }
20
+ });
21
+
22
+ server.on("error", (err: NodeJS.ErrnoException) => {
23
+ if (err.code === "EADDRINUSE") {
24
+ reject(new Error(`Port 8787 is already in use. Stop any other process using it and try again.`));
25
+ } else {
26
+ reject(new Error(`OAuth server failed to start: ${err.message}`));
27
+ }
28
+ });
29
+ } catch (err) {
30
+ reject(err);
31
+ return;
32
+ }
33
+
34
+ app.get("/auth/google/callback", async (req, res) => {
35
+ try {
36
+ const code = req.query.code as string;
37
+ if (!code) {
38
+ res.status(400).send("Missing authorization code");
39
+ reject(new Error("OAuth callback received no authorization code"));
40
+ server.close();
41
+ return;
42
+ }
43
+
44
+ const { tokens } = await oauth2Client.getToken(code);
45
+
46
+ // In production Google only sends refresh_token on first-ever authorization.
47
+ // Fall back to the previously stored token if one exists.
48
+ const storedConfig = loadConfig();
49
+ const refreshToken = tokens.refresh_token ?? storedConfig?.refresh_token;
50
+
51
+ if (!refreshToken) {
52
+ res.status(400).send("No refresh token available. Please revoke app access in your Google account and re-authorize.");
53
+ reject(new Error("No refresh token returned and none stored. User must revoke and re-authorize."));
54
+ server.close();
55
+ return;
56
+ }
57
+
58
+ if (!tokens.refresh_token) {
59
+ console.log(chalk.yellow("⚠ No refresh token returned by Google — reusing stored token."));
60
+ }
61
+
62
+ const googleConfig: GoogleConfig = {
63
+ access_token: tokens.access_token ?? undefined,
64
+ refresh_token: refreshToken,
65
+ scope: tokens.scope!,
66
+ token_type: tokens.token_type!,
67
+ expiry_date: tokens.expiry_date ?? undefined,
68
+ createdAt: storedConfig?.createdAt ?? Date.now(),
69
+ };
70
+
71
+ saveConfig(googleConfig);
72
+
73
+ // Auto-sync to Supabase — non-fatal if it fails
74
+ syncGoogleRefreshToken(refreshToken).catch(() => {});
75
+
76
+ res.send(`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Auth</title>
77
+ <style>body{font-family:Arial,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0;background:#f4f4f9}</style>
78
+ </head><body><h1>✅ Authentication Successful</h1><p>Google Connected. You can close this window.</p>
79
+ <script>window.open('','_self','');window.close();</script></body></html>`);
80
+
81
+ resolve(tokens);
82
+ server.close();
83
+ } catch (error) {
84
+ const msg = error instanceof Error ? error.message : String(error);
85
+ console.error(chalk.red(`OAuth callback error: ${msg}`));
86
+ res.status(500).send(`Authentication failed: ${msg}`);
87
+ reject(error);
88
+ server.close();
89
+ }
90
+ });
91
+ });
92
+ };
@@ -0,0 +1,61 @@
1
+ import { homedir } from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+
5
+ const GOOGLE_CONFIG_DIR = path.join(homedir(), ".cccontrol", "/googleAuth");
6
+ const GOOGLE_CONFIG_FILE = path.join(GOOGLE_CONFIG_DIR, "google_config.json");
7
+
8
+ export type GoogleConfig = {
9
+ access_token?: string;
10
+ refresh_token: string;
11
+ scope: string;
12
+ token_type: string;
13
+ /** Access token expiry as ms epoch timestamp (from Google's expiry_date field) */
14
+ expiry_date?: number;
15
+ createdAt: number;
16
+ };
17
+
18
+ export function ensureConfigDir(): void {
19
+ if (!fs.existsSync(GOOGLE_CONFIG_DIR)) {
20
+ fs.mkdirSync(GOOGLE_CONFIG_DIR, { recursive: true });
21
+ }
22
+ }
23
+
24
+ export function saveConfig(config: GoogleConfig): void {
25
+ ensureConfigDir();
26
+ fs.writeFileSync(GOOGLE_CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
27
+ fs.chmodSync(GOOGLE_CONFIG_FILE, 0o600);
28
+ }
29
+
30
+ export function loadConfig(): GoogleConfig | null {
31
+ ensureConfigDir();
32
+ if (!fs.existsSync(GOOGLE_CONFIG_FILE)) return null;
33
+ try {
34
+ const raw = fs.readFileSync(GOOGLE_CONFIG_FILE, "utf8");
35
+ return JSON.parse(raw) as GoogleConfig;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Returns the refresh token if auth looks valid, null if re-auth is needed. */
42
+ export const isAuth = (): string | null => {
43
+ const config = loadConfig();
44
+ if (!config?.refresh_token) return null;
45
+ // Standard prod: Google doesn't expose refresh token expiry — rely on invalid_grant at use time
46
+ return config.refresh_token;
47
+ };
48
+
49
+ /** Returns true if the stored access token is still valid (saves a network call). */
50
+ export const isAccessTokenFresh = (): boolean => {
51
+ const config = loadConfig();
52
+ if (!config?.access_token || !config.expiry_date) return false;
53
+ // Give a 60s buffer before actual expiry
54
+ return config.expiry_date > Date.now() + 60_000;
55
+ };
56
+
57
+ export function removeConfig(): void {
58
+ if (fs.existsSync(GOOGLE_CONFIG_FILE)) {
59
+ fs.unlinkSync(GOOGLE_CONFIG_FILE);
60
+ }
61
+ }
@@ -0,0 +1,29 @@
1
+ import express from "express";
2
+ import { google } from "googleapis";
3
+
4
+ const CLIENT_ID = process.env.GOOGLE_CLIENT_ID!;
5
+ const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET!;
6
+
7
+ const REDIRECT_URI = "http://localhost:8787/auth/google/callback";
8
+
9
+ export const oauth2Client = new google.auth.OAuth2(
10
+ CLIENT_ID,
11
+ CLIENT_SECRET,
12
+ REDIRECT_URI
13
+ );
14
+
15
+ const app = express();
16
+
17
+ app.get("/auth/google", async (req, res) => {
18
+ const authUrl = oauth2Client.generateAuthUrl({
19
+ access_type: "offline",
20
+ prompt: "consent",
21
+ scope: [
22
+ "https://www.googleapis.com/auth/gmail.readonly",
23
+ "https://www.googleapis.com/auth/gmail.send",
24
+ ],
25
+ });
26
+ res.redirect(authUrl);
27
+ });
28
+
29
+ export default app;