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