@mulmoclaude/core 0.20.2 → 0.22.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.
@@ -76,31 +76,44 @@ function truncate(text, max, ellipsis = "…") {
76
76
  }
77
77
  //#endregion
78
78
  //#region src/google/clientSecret.ts
79
+ var isNonEmptyString = (value) => typeof value === "string" && value !== "";
79
80
  var isInstalledClientSecret = (value) => {
80
81
  if (!isRecord(value) || !isRecord(value.installed)) return false;
81
- return typeof value.installed.client_id === "string" && typeof value.installed.client_secret === "string";
82
+ return isNonEmptyString(value.installed.client_id) && isNonEmptyString(value.installed.client_secret);
82
83
  };
83
84
  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();
85
+ var readIfDesktopClient = async (filePath) => {
86
+ try {
87
+ return isInstalledClientSecret(JSON.parse(await readFile(filePath, "utf-8"))) ? filePath : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ /** Absolute paths of every desktop-app client JSON in `~/.secrets/`. */
93
+ async function listDesktopClientSecretFiles(home) {
94
+ const dir = googleSecretsDir(home);
95
+ const candidates = (await readdir(dir).catch(() => [])).filter(isClientSecretFileName).sort();
96
+ return (await Promise.all(candidates.map((name) => readIfDesktopClient(join(dir, name))))).filter((path) => path !== null);
86
97
  }
87
98
  async function clientSecretPresence(home) {
88
- const matches = await listClientSecretFiles(home);
99
+ const matches = await listDesktopClientSecretFiles(home);
89
100
  if (matches.length === 0) return "missing";
90
101
  return matches.length === 1 ? "found" : "ambiguous";
91
102
  }
92
103
  async function findClientSecretPath(home) {
93
104
  const dir = googleSecretsDir(home);
94
- const matches = await listClientSecretFiles(home);
105
+ const matches = await listDesktopClientSecretFiles(home);
95
106
  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);
107
+ if (!first) throw new Error(`no desktop-app client_secret_*.json found in ${dir} — this host links through the sign-in service instead`);
108
+ if (rest.length > 0) {
109
+ const names = matches.map((path) => path.slice(dir.length + 1)).join(", ");
110
+ throw new Error(`multiple desktop-app client_secret_*.json files found in ${dir} (${names}) — keep exactly one`);
111
+ }
112
+ return first;
99
113
  }
100
114
  async function loadClientSecret(home) {
101
115
  const filePath = await findClientSecretPath(home);
102
- const raw = await readFile(filePath, "utf-8");
103
- const parsed = JSON.parse(raw);
116
+ const parsed = JSON.parse(await readFile(filePath, "utf-8"));
104
117
  if (!isInstalledClientSecret(parsed)) throw new Error(`${filePath} is not a desktop-app OAuth client secret (missing "installed" with client_id / client_secret)`);
105
118
  return {
106
119
  client_id: parsed.installed.client_id,
@@ -160,6 +173,7 @@ function mergeGoogleTokens(existing, incoming) {
160
173
  ...incoming
161
174
  };
162
175
  if (!incoming.refresh_token && existing?.refresh_token) merged.refresh_token = existing.refresh_token;
176
+ if (!incoming.issuedVia && existing?.issuedVia) merged.issuedVia = existing.issuedVia;
163
177
  return merged;
164
178
  }
165
179
  var fileExists = async (filePath) => await stat(filePath).then(() => true, () => false);
@@ -220,6 +234,113 @@ function bridgeExternalSignal(external, controller) {
220
234
  return () => external.removeEventListener("abort", onAbort);
221
235
  }
222
236
  //#endregion
237
+ //#region src/google/broker.ts
238
+ var DEFAULT_BROKER_BASE_URL = "https://asia-northeast1-mulmoserver.cloudfunctions.net";
239
+ var BROKER_TIMEOUT_MS = 20 * ONE_SECOND_MS;
240
+ var withoutTrailingSlashes = (url) => {
241
+ let end = url.length;
242
+ while (end > 0 && url[end - 1] === "/") end -= 1;
243
+ return url.slice(0, end);
244
+ };
245
+ /** `MULMO_GOOGLE_BROKER_URL` lets a fork / staging deploy point elsewhere
246
+ * without a code change; unset means the shipped broker. */
247
+ var brokerBaseUrl = (override = process.env.MULMO_GOOGLE_BROKER_URL) => withoutTrailingSlashes(override ?? DEFAULT_BROKER_BASE_URL);
248
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
249
+ "127.0.0.1",
250
+ "localhost",
251
+ "::1",
252
+ "[::1]"
253
+ ]);
254
+ /** Codes, PKCE verifiers and refresh tokens travel to the broker, so cleartext
255
+ * is refused outright — an override pointed at `http://…` would put them on
256
+ * the wire. Loopback stays allowed: that is where tests stub the broker, and
257
+ * it never leaves the machine. */
258
+ var assertSecureBrokerUrl = (url) => {
259
+ let parsed;
260
+ try {
261
+ parsed = new URL(url);
262
+ } catch {
263
+ throw new Error(`invalid Google sign-in service URL: ${url}`);
264
+ }
265
+ if (parsed.protocol === "https:") return;
266
+ if (parsed.protocol === "http:" && LOOPBACK_HOSTNAMES.has(parsed.hostname)) return;
267
+ throw new Error(`the Google sign-in service must be reached over HTTPS (got ${parsed.protocol}//${parsed.host})`);
268
+ };
269
+ var brokerFetch = async (url, init = {}) => {
270
+ assertSecureBrokerUrl(url);
271
+ let response;
272
+ try {
273
+ response = await fetchWithTimeout(url, {
274
+ ...init,
275
+ timeoutMs: BROKER_TIMEOUT_MS,
276
+ headers: { "Content-Type": "application/json" }
277
+ });
278
+ } catch (err) {
279
+ throw new Error(`Google sign-in service unreachable — check the network connection and retry (${errorMessage(err)})`);
280
+ }
281
+ if (!response.ok) throw new Error(`Google sign-in service returned HTTP ${response.status}`);
282
+ try {
283
+ return await response.json();
284
+ } catch {
285
+ throw new Error("Google sign-in service returned a malformed response");
286
+ }
287
+ };
288
+ var GOOGLE_CONSENT_HOST = "accounts.google.com";
289
+ /** The returned URL is opened in the user's browser, so it must be Google's
290
+ * own consent page and nothing else — a mis-pointed or hostile broker (the
291
+ * base URL is overridable) could otherwise hand back a phishing page wearing
292
+ * a sign-in flow. Cheap to assert, and Google's endpoint host is fixed. */
293
+ var assertGoogleConsentUrl = (authUrl) => {
294
+ let parsed;
295
+ try {
296
+ parsed = new URL(authUrl);
297
+ } catch {
298
+ throw new Error("Google sign-in service returned an unusable authorization URL");
299
+ }
300
+ if (parsed.protocol !== "https:" || parsed.hostname !== GOOGLE_CONSENT_HOST) throw new Error(`Google sign-in service returned an authorization URL that is not Google's consent page (${parsed.protocol}//${parsed.host})`);
301
+ };
302
+ async function brokerStart(port, codeChallenge, baseUrl = brokerBaseUrl()) {
303
+ const payload = await brokerFetch(`${baseUrl}/googleOAuthStart?${new URLSearchParams({
304
+ port: String(port),
305
+ code_challenge: codeChallenge
306
+ }).toString()}`);
307
+ const record = isRecord(payload) ? payload : {};
308
+ if (typeof record.auth_url !== "string" || typeof record.state !== "string") throw new Error("Google sign-in service returned an unexpected response (missing auth_url / state)");
309
+ assertGoogleConsentUrl(record.auth_url);
310
+ return {
311
+ authUrl: record.auth_url,
312
+ state: record.state
313
+ };
314
+ }
315
+ var toCredentials = (payload, existingRefreshToken) => {
316
+ const record = isRecord(payload) ? payload : {};
317
+ if (typeof record.access_token !== "string") throw new Error("Google sign-in service returned no access token");
318
+ const refreshToken = typeof record.refresh_token === "string" ? record.refresh_token : existingRefreshToken;
319
+ return {
320
+ access_token: record.access_token,
321
+ ...refreshToken ? { refresh_token: refreshToken } : {},
322
+ ...typeof record.expiry_date === "number" ? { expiry_date: record.expiry_date } : {}
323
+ };
324
+ };
325
+ async function brokerExchange(input, baseUrl = brokerBaseUrl()) {
326
+ const credentials = toCredentials(await brokerFetch(`${baseUrl}/googleOAuthExchange`, {
327
+ method: "POST",
328
+ body: JSON.stringify({
329
+ code: input.code,
330
+ state: input.state,
331
+ code_verifier: input.codeVerifier
332
+ })
333
+ }));
334
+ if (!credentials.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
335
+ return credentials;
336
+ }
337
+ async function brokerRefresh(refreshToken, baseUrl = brokerBaseUrl()) {
338
+ return toCredentials(await brokerFetch(`${baseUrl}/googleOAuthRefresh`, {
339
+ method: "POST",
340
+ body: JSON.stringify({ refresh_token: refreshToken })
341
+ }), refreshToken);
342
+ }
343
+ //#endregion
223
344
  //#region src/google/auth.ts
224
345
  var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
225
346
  var GOOGLE_TASKS_SCOPE = "https://www.googleapis.com/auth/tasks";
@@ -235,6 +356,9 @@ var GOOGLE_SCOPES = [
235
356
  var CALLBACK_PATH = "/oauth2callback";
236
357
  var AUTH_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
237
358
  var STATE_BYTES = 16;
359
+ /** Renew a minute early so a call can't start with a token that expires
360
+ * mid-flight. */
361
+ var EXPIRY_MARGIN_MS = ONE_MINUTE_MS;
238
362
  var createClient = (secret, redirectUri) => new OAuth2Client({
239
363
  clientId: secret.client_id,
240
364
  clientSecret: secret.client_secret,
@@ -247,15 +371,29 @@ var persistRotatedTokens = (client, home) => {
247
371
  });
248
372
  });
249
373
  };
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 — ask the user to link their Google account in this app's settings, then retry");
374
+ var REVOKED_GRANT_MESSAGE = "could not obtain a Google access token — the grant may have been revoked; re-link the account";
375
+ var localAccessToken = async (saved, home) => {
376
+ const presence = await clientSecretPresence(home);
377
+ if (presence === "ambiguous") await loadClientSecret(home);
378
+ if (presence === "missing") throw new Error("the saved Google link was created with an OAuth client that is no longer configured on this host — ask the user to link their Google account again in this app's settings");
253
379
  const client = createClient(await loadClientSecret(home));
254
380
  client.setCredentials(saved);
255
381
  persistRotatedTokens(client, home);
256
382
  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");
383
+ if (!token) throw new Error(REVOKED_GRANT_MESSAGE);
258
384
  return token;
385
+ };
386
+ var brokerAccessToken = async (saved, home) => {
387
+ if (typeof saved.expiry_date === "number" && saved.access_token && saved.expiry_date - EXPIRY_MARGIN_MS > Date.now()) return saved.access_token;
388
+ const refreshed = await brokerRefresh(saved.refresh_token ?? "");
389
+ if (!refreshed.access_token) throw new Error(REVOKED_GRANT_MESSAGE);
390
+ await saveGoogleTokens(refreshed, home);
391
+ return refreshed.access_token;
392
+ };
393
+ async function getGoogleAccessToken(home) {
394
+ const saved = await loadGoogleTokens(home);
395
+ if (!saved?.refresh_token) throw new Error("Google account not linked on this host — ask the user to link their Google account in this app's settings, then retry");
396
+ return saved.issuedVia === "broker" ? await brokerAccessToken(saved, home) : await localAccessToken(saved, home);
259
397
  }
260
398
  var REVOKE_URL = "https://oauth2.googleapis.com/revoke";
261
399
  /** Revoke the grant at Google (best-effort) and delete the local token file.
@@ -336,22 +474,49 @@ var buildConsentUrl = (client, codeChallenge, state) => client.generateAuthUrl({
336
474
  code_challenge: codeChallenge,
337
475
  state
338
476
  });
477
+ var generatePkce = async () => {
478
+ const { codeVerifier, codeChallenge } = await new OAuth2Client().generateCodeVerifierAsync();
479
+ if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
480
+ return {
481
+ codeVerifier,
482
+ codeChallenge
483
+ };
484
+ };
485
+ var authorizeWithLocalClient = async (secret, server, port, opts) => {
486
+ const client = createClient(secret, `http://127.0.0.1:${port}${CALLBACK_PATH}`);
487
+ const { codeVerifier, codeChallenge } = await client.generateCodeVerifierAsync();
488
+ if (!codeChallenge) throw new Error("failed to derive a PKCE code challenge");
489
+ const state = randomBytes(STATE_BYTES).toString("hex");
490
+ opts.onAuthUrl?.(buildConsentUrl(client, codeChallenge, state));
491
+ const code = await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
492
+ const { tokens } = await client.getToken({
493
+ code,
494
+ codeVerifier
495
+ });
496
+ if (!tokens.refresh_token) throw new Error("Google returned no refresh token — remove this app under Google Account → Security → Third-party access, then retry");
497
+ return tokens;
498
+ };
499
+ var authorizeWithBroker = async (server, port, opts) => {
500
+ const { codeVerifier, codeChallenge } = await generatePkce();
501
+ const { authUrl, state } = await brokerStart(port, codeChallenge);
502
+ opts.onAuthUrl?.(authUrl);
503
+ return await brokerExchange({
504
+ code: await waitForAuthCode(server, state, opts.timeoutMs ?? AUTH_TIMEOUT_MS),
505
+ state,
506
+ codeVerifier
507
+ });
508
+ };
339
509
  async function authorizeGoogle(opts = {}) {
340
- const secret = await loadClientSecret(opts.home);
510
+ const presence = await clientSecretPresence(opts.home);
511
+ if (presence === "ambiguous") await loadClientSecret(opts.home);
512
+ const useLocalClient = presence === "found";
341
513
  const { server, port } = await startLoopbackServer();
342
514
  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);
515
+ const issuedVia = useLocalClient ? "local" : "broker";
516
+ return await saveGoogleTokens({
517
+ ...useLocalClient ? await authorizeWithLocalClient(await loadClientSecret(opts.home), server, port, opts) : await authorizeWithBroker(server, port, opts),
518
+ issuedVia
519
+ }, opts.home);
355
520
  } finally {
356
521
  server.close();
357
522
  }
@@ -390,50 +555,67 @@ var createGoogleAuthFlow = (authorize) => {
390
555
  };
391
556
  var googleAuthFlow = createGoogleAuthFlow(authorizeGoogle);
392
557
  //#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;
558
+ //#region src/google/apiClient.ts
559
+ var GOOGLE_API_TIMEOUT_MS = 30 * ONE_SECOND_MS;
396
560
  var ERROR_BODY_MAX_CHARS = 300;
397
561
  var HTTP_FORBIDDEN = 403;
398
562
  var DEFAULT_LIST_MAX_RESULTS = 10;
399
563
  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?)" : "";
564
+ /** 403 usually means the API is not enabled for the user's Cloud project —
565
+ * name the API so the agent's recovery guidance can be specific. */
566
+ var googleApiError = (apiLabel, status, body) => {
567
+ const hint = status === HTTP_FORBIDDEN ? ` (is the ${apiLabel} enabled for the Cloud project?)` : "";
419
568
  const detail = body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : "";
420
- return /* @__PURE__ */ new Error(`Google Calendar API: HTTP ${status}${hint}${detail}`);
569
+ return /* @__PURE__ */ new Error(`${apiLabel}: HTTP ${status}${hint}${detail}`);
421
570
  };
422
- var calendarRequest = async (accessToken, url, init = {}) => {
571
+ async function googleRequest(apiLabel, accessToken, url, init = {}) {
572
+ const { contentType = "application/json", expectText = false, ...rest } = init;
423
573
  const response = await fetchWithTimeout(url, {
424
- ...init,
425
- timeoutMs: CALENDAR_TIMEOUT_MS,
574
+ ...rest,
575
+ timeoutMs: GOOGLE_API_TIMEOUT_MS,
426
576
  headers: {
427
577
  Authorization: `Bearer ${accessToken}`,
428
- "Content-Type": "application/json"
578
+ "Content-Type": contentType
429
579
  }
430
580
  });
431
581
  if (!response.ok) {
432
582
  const body = await response.text().catch((err) => errorMessage(err));
433
- throw calendarApiError(response.status, body);
583
+ throw googleApiError(apiLabel, response.status, body);
434
584
  }
585
+ if (expectText) return await response.text();
586
+ if (response.status === 204) return {};
435
587
  return await response.json();
588
+ }
589
+ var stringField = (record, key) => typeof record[key] === "string" ? record[key] : "";
590
+ var asRecord = (value) => isRecord(value) ? value : {};
591
+ var itemsOf = (value) => {
592
+ const record = asRecord(value);
593
+ return Array.isArray(record.items) ? record.items : [];
594
+ };
595
+ //#endregion
596
+ //#region src/google/calendar.ts
597
+ var CALENDAR_EVENTS_URL = "https://www.googleapis.com/calendar/v3/calendars/primary/events";
598
+ var CALENDAR_API_LABEL = "Google Calendar API";
599
+ var eventTime = (value) => {
600
+ if (!isRecord(value)) return "";
601
+ if (typeof value.dateTime === "string") return value.dateTime;
602
+ if (typeof value.date === "string") return value.date;
603
+ return "";
604
+ };
605
+ var toEventSummary = (value) => {
606
+ const record = asRecord(value);
607
+ return {
608
+ id: stringField(record, "id"),
609
+ summary: stringField(record, "summary"),
610
+ start: eventTime(record.start),
611
+ end: eventTime(record.end),
612
+ htmlLink: stringField(record, "htmlLink"),
613
+ status: stringField(record, "status")
614
+ };
436
615
  };
616
+ /** Kept as a named export for the existing unit tests / callers; the shared
617
+ * helper now carries the wording. */
618
+ var calendarApiError = (status, body) => googleApiError(CALENDAR_API_LABEL, status, body);
437
619
  async function createCalendarEvent(accessToken, input) {
438
620
  const body = {
439
621
  summary: input.summary,
@@ -441,21 +623,169 @@ async function createCalendarEvent(accessToken, input) {
441
623
  start: { dateTime: input.startDateTime },
442
624
  end: { dateTime: input.endDateTime }
443
625
  };
444
- return toEventSummary(await calendarRequest(accessToken, CALENDAR_EVENTS_URL, {
626
+ return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, CALENDAR_EVENTS_URL, {
445
627
  method: "POST",
446
628
  body: JSON.stringify(body)
447
629
  }));
448
630
  }
449
631
  async function listCalendarEvents(accessToken, input = {}) {
450
- const listed = await calendarRequest(accessToken, `${CALENDAR_EVENTS_URL}?${new URLSearchParams({
632
+ const record = asRecord(await googleRequest(CALENDAR_API_LABEL, accessToken, `${CALENDAR_EVENTS_URL}?${new URLSearchParams({
451
633
  timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
452
634
  maxResults: String(input.maxResults ?? 10),
453
635
  singleEvents: "true",
454
636
  orderBy: "startTime"
455
- }).toString()}`);
456
- return (isRecord(listed) && Array.isArray(listed.items) ? listed.items : []).map(toEventSummary);
637
+ }).toString()}`));
638
+ return (Array.isArray(record.items) ? record.items : []).map(toEventSummary);
639
+ }
640
+ //#endregion
641
+ //#region src/google/tasks.ts
642
+ var TASKS_BASE_URL = "https://tasks.googleapis.com/tasks/v1";
643
+ var TASKS_API_LABEL = "Google Tasks API";
644
+ var DEFAULT_TASK_LIST_ID = "@default";
645
+ var TASK_STATUS_COMPLETED = "completed";
646
+ var MAX_TASK_LISTS = 50;
647
+ var toTaskListSummary = (value) => {
648
+ const record = asRecord(value);
649
+ return {
650
+ id: stringField(record, "id"),
651
+ title: stringField(record, "title")
652
+ };
653
+ };
654
+ var toTaskSummary = (value) => {
655
+ const record = asRecord(value);
656
+ return {
657
+ id: stringField(record, "id"),
658
+ title: stringField(record, "title"),
659
+ status: stringField(record, "status"),
660
+ due: stringField(record, "due"),
661
+ notes: stringField(record, "notes")
662
+ };
663
+ };
664
+ var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(taskListId ?? DEFAULT_TASK_LIST_ID)}/tasks${suffix}`;
665
+ async function listTaskLists(accessToken) {
666
+ return itemsOf(await googleRequest(TASKS_API_LABEL, accessToken, `${TASKS_BASE_URL}/users/@me/lists?maxResults=${MAX_TASK_LISTS}`)).map(toTaskListSummary);
667
+ }
668
+ async function listTasks(accessToken, input = {}) {
669
+ const params = new URLSearchParams({
670
+ maxResults: String(input.maxResults ?? 10),
671
+ showCompleted: String(input.showCompleted ?? false)
672
+ });
673
+ return itemsOf(await googleRequest(TASKS_API_LABEL, accessToken, `${tasksUrl(input.taskListId)}?${params.toString()}`)).map(toTaskSummary);
674
+ }
675
+ async function createTask(accessToken, input) {
676
+ const body = {
677
+ title: input.title,
678
+ notes: input.notes,
679
+ due: input.due
680
+ };
681
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId), {
682
+ method: "POST",
683
+ body: JSON.stringify(body)
684
+ }));
685
+ }
686
+ async function completeTask(accessToken, input) {
687
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
688
+ method: "PATCH",
689
+ body: JSON.stringify({ status: TASK_STATUS_COMPLETED })
690
+ }));
691
+ }
692
+ async function deleteTask(accessToken, input) {
693
+ await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), { method: "DELETE" });
694
+ }
695
+ //#endregion
696
+ //#region src/google/driveFile.ts
697
+ var DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files";
698
+ var DRIVE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files";
699
+ var DRIVE_API_LABEL = "Google Drive API";
700
+ var DEFAULT_MIME_TYPE = "text/plain";
701
+ var FILE_FIELDS = "id,name,mimeType,webViewLink,modifiedTime";
702
+ var MAX_READ_CHARS = 1e5;
703
+ var TEXT_MIME_PREFIXES = [
704
+ "text/",
705
+ "application/json",
706
+ "application/xml",
707
+ "application/javascript"
708
+ ];
709
+ var toDriveFileSummary = (value) => {
710
+ const record = asRecord(value);
711
+ return {
712
+ id: stringField(record, "id"),
713
+ name: stringField(record, "name"),
714
+ mimeType: stringField(record, "mimeType"),
715
+ webViewLink: stringField(record, "webViewLink"),
716
+ modifiedTime: stringField(record, "modifiedTime")
717
+ };
718
+ };
719
+ var isTextMimeType = (mimeType) => TEXT_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix));
720
+ async function listDriveFiles(accessToken, input = {}) {
721
+ const record = asRecord(await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}?${new URLSearchParams({
722
+ pageSize: String(input.maxResults ?? 10),
723
+ fields: `files(${FILE_FIELDS})`,
724
+ orderBy: "modifiedTime desc"
725
+ }).toString()}`));
726
+ return (Array.isArray(record.files) ? record.files : []).map(toDriveFileSummary);
727
+ }
728
+ var BOUNDARY_BYTES = 16;
729
+ /** Fresh per request: a fixed boundary appearing inside file content would
730
+ * split the payload at the wrong place, so Drive would store a truncated or
731
+ * mangled file. Random 128 bits makes an accidental — or crafted — collision
732
+ * infeasible; `newMultipartBoundary` is re-derived until it is absent from
733
+ * the body it must delimit. */
734
+ var newMultipartBoundary = () => `mulmo-drive-${randomBytes(BOUNDARY_BYTES).toString("hex")}`;
735
+ var MIME_TYPE_RE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/;
736
+ var assertSafeMimeType = (mimeType) => {
737
+ if (!MIME_TYPE_RE.test(mimeType)) throw new Error(`Google Drive API: invalid mimeType '${mimeType}' — expected a plain type/subtype such as text/plain`);
738
+ return mimeType;
739
+ };
740
+ var buildMultipartBody = (metadata, content, mimeType, boundary) => [
741
+ `--${boundary}`,
742
+ "Content-Type: application/json; charset=UTF-8",
743
+ "",
744
+ JSON.stringify(metadata),
745
+ `--${boundary}`,
746
+ `Content-Type: ${mimeType}`,
747
+ "",
748
+ content,
749
+ `--${boundary}--`,
750
+ ""
751
+ ].join("\r\n");
752
+ /** A boundary that appears nowhere in the parts it delimits. */
753
+ var pickBoundary = (parts, generate = newMultipartBoundary) => {
754
+ const collides = (candidate) => parts.some((part) => part.includes(candidate));
755
+ let boundary = generate();
756
+ while (collides(boundary)) boundary = generate();
757
+ return boundary;
758
+ };
759
+ async function createDriveFile(accessToken, input) {
760
+ const mimeType = assertSafeMimeType(input.mimeType ?? DEFAULT_MIME_TYPE);
761
+ const metadata = {
762
+ name: input.name,
763
+ mimeType
764
+ };
765
+ const boundary = pickBoundary([input.content, JSON.stringify(metadata)]);
766
+ return toDriveFileSummary(await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_UPLOAD_URL}?${new URLSearchParams({
767
+ uploadType: "multipart",
768
+ fields: FILE_FIELDS
769
+ }).toString()}`, {
770
+ method: "POST",
771
+ contentType: `multipart/related; boundary=${boundary}`,
772
+ body: buildMultipartBody(metadata, input.content, mimeType, boundary)
773
+ }));
774
+ }
775
+ async function readDriveFile(accessToken, input) {
776
+ const fileId = encodeURIComponent(input.fileId);
777
+ const file = toDriveFileSummary(await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${fileId}?fields=${FILE_FIELDS}`));
778
+ if (!isTextMimeType(file.mimeType)) throw new Error(`Google Drive API: '${file.name}' is ${file.mimeType || "a binary file"} — only text files can be read as content`);
779
+ const raw = await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${fileId}?alt=media`, { expectText: true });
780
+ return {
781
+ file,
782
+ content: typeof raw === "string" ? raw.slice(0, MAX_READ_CHARS) : ""
783
+ };
784
+ }
785
+ async function deleteDriveFile(accessToken, input) {
786
+ await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
457
787
  }
458
788
  //#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 };
789
+ export { DEFAULT_LIST_MAX_RESULTS, GOOGLE_CALENDAR_SCOPE, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, MAX_LIST_RESULTS, assertSafeMimeType, authorizeGoogle, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildMultipartBody, calendarApiError, clientSecretPresence, completeTask, configureGoogleHost, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteDriveFile, deleteGoogleTokens, deleteTask, findClientSecretPath, getGoogleAccessToken, googleApiError, googleAuthFlow, googleConfigDir, googleSecretsDir, googleTokenPath, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listDriveFiles, listTaskLists, listTasks, loadClientSecret, loadGoogleTokens, mergeGoogleTokens, pickBoundary, readDriveFile, saveGoogleTokens, toDriveFileSummary, toEventSummary, toTaskListSummary, toTaskSummary, unlinkGoogle, waitForAuthCode };
460
790
 
461
791
  //# sourceMappingURL=index.js.map