@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.
- package/assets/helps/error-recovery.md +29 -16
- package/dist/google/apiClient.d.ts +18 -0
- package/dist/google/broker.d.ts +15 -0
- package/dist/google/calendar.d.ts +2 -2
- package/dist/google/clientSecret.d.ts +4 -3
- package/dist/google/driveFile.d.ts +31 -0
- package/dist/google/index.cjs +410 -59
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +6 -2
- package/dist/google/index.js +390 -60
- package/dist/google/index.js.map +1 -1
- package/dist/google/tasks.d.ts +35 -0
- package/dist/google/tokenStore.d.ts +11 -3
- package/package.json +1 -1
package/dist/google/index.cjs
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
|
|
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
|
|
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
|
|
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} —
|
|
101
|
-
if (rest.length > 0)
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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(
|
|
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
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
opts.
|
|
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/
|
|
398
|
-
var
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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(
|
|
573
|
+
return /* @__PURE__ */ new Error(`${apiLabel}: HTTP ${status}${hint}${detail}`);
|
|
425
574
|
};
|
|
426
|
-
|
|
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
|
-
...
|
|
429
|
-
timeoutMs:
|
|
578
|
+
...rest,
|
|
579
|
+
timeoutMs: GOOGLE_API_TIMEOUT_MS,
|
|
430
580
|
headers: {
|
|
431
581
|
Authorization: `Bearer ${accessToken}`,
|
|
432
|
-
"Content-Type":
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|