@mulmoclaude/core 0.19.1 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/error-recovery.md +33 -0
- package/dist/google/auth.d.ts +26 -0
- package/dist/google/authFlow.d.ts +13 -0
- package/dist/google/calendar.d.ts +24 -0
- package/dist/google/clientSecret.d.ts +11 -0
- package/dist/google/datetime.d.ts +1 -0
- package/dist/google/fetch.d.ts +8 -0
- package/dist/google/fsJson.d.ts +4 -0
- package/dist/google/host.d.ts +10 -0
- package/dist/google/index.cjs +493 -0
- package/dist/google/index.cjs.map +1 -0
- package/dist/google/index.d.ts +8 -0
- package/dist/google/index.js +461 -0
- package/dist/google/index.js.map +1 -0
- package/dist/google/paths.d.ts +5 -0
- package/dist/google/tokenStore.d.ts +5 -0
- package/dist/google/util.d.ts +7 -0
- package/package.json +10 -3
|
@@ -296,3 +296,36 @@ minutes rather than stacking them on the same one.
|
|
|
296
296
|
container from a Windows host (WSL2 + native `dockerd`), with the same mounts / env / argv the
|
|
297
297
|
shipped builders produce, and asserts `handlePermission` comes back over the MCP handshake. See
|
|
298
298
|
`docs/windows-docker-ci.md`.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Google tool — link / credential / API errors
|
|
303
|
+
|
|
304
|
+
### Symptoms
|
|
305
|
+
|
|
306
|
+
- The `google` tool (or a `google.calendar.*` remote command) fails with **"Google account not linked on this host"**.
|
|
307
|
+
- Errors mentioning **client_secret**: "no client_secret_*.json found" or "multiple client_secret_*.json files found".
|
|
308
|
+
- **"Google Calendar API: HTTP 403"** with a hint about enabling the API.
|
|
309
|
+
- **"could not obtain a Google access token"** (grant revoked).
|
|
310
|
+
|
|
311
|
+
### Cause
|
|
312
|
+
|
|
313
|
+
The `google` tool runs against a Google account linked **locally on this machine** — a refresh
|
|
314
|
+
token stored at `~/.config/mulmo/google-token.json` (mode 600), obtained through a desktop
|
|
315
|
+
OAuth (loopback + PKCE) consent using the client credentials in `~/.secrets/client_secret_*.json`.
|
|
316
|
+
This is independent of claude.ai Google connectors. Each failure names its missing piece.
|
|
317
|
+
|
|
318
|
+
### Fix
|
|
319
|
+
|
|
320
|
+
- **Not linked / grant revoked** — ask the user to link (or re-link) the account: Settings →
|
|
321
|
+
Plugins → Google → "Link Google account", or run `yarn google:auth` in the repo. Then retry the
|
|
322
|
+
original call. Do NOT try to create or edit the token file yourself.
|
|
323
|
+
- **No client secret** — the user must download the OAuth *desktop-app* client JSON from the
|
|
324
|
+
Google Cloud Console (APIs & Services → Credentials) and place it in `~/.secrets/` keeping the
|
|
325
|
+
`client_secret_*.json` filename (chmod 600).
|
|
326
|
+
- **Multiple client secrets** — ask the user to keep exactly one `client_secret_*.json` in
|
|
327
|
+
`~/.secrets/`; the stored refresh token pairs with one OAuth client, so duplicates are refused
|
|
328
|
+
rather than guessed at.
|
|
329
|
+
- **HTTP 403 from a Google API** — the API is not enabled for the user's Cloud project. Ask them
|
|
330
|
+
to enable it in the Cloud Console (APIs & Services → Library — e.g. "Google Calendar API"),
|
|
331
|
+
then retry. No re-link needed.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { default as http } from 'node:http';
|
|
2
|
+
import { Credentials } from 'google-auth-library';
|
|
3
|
+
import { fetchWithTimeout } from './fetch.js';
|
|
4
|
+
export declare const GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
|
5
|
+
export declare const GOOGLE_TASKS_SCOPE = "https://www.googleapis.com/auth/tasks";
|
|
6
|
+
export declare const GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
7
|
+
/** Requested at consent as one set — matches the scopes registered on the
|
|
8
|
+
* OAuth consent screen, so a single re-link covers every supported API
|
|
9
|
+
* (Calendar now; Tasks / Drive tools ride the same grant later). */
|
|
10
|
+
export declare const GOOGLE_SCOPES: string[];
|
|
11
|
+
export interface AuthorizeGoogleOptions {
|
|
12
|
+
home?: string;
|
|
13
|
+
/** Called with the consent URL; open it in a browser (and/or print it). */
|
|
14
|
+
onAuthUrl?: (url: string) => void;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function getGoogleAccessToken(home?: string): Promise<string>;
|
|
18
|
+
/** The revoke POST, injectable for tests. */
|
|
19
|
+
export type RevokeFetch = typeof fetchWithTimeout;
|
|
20
|
+
/** Revoke the grant at Google (best-effort) and delete the local token file.
|
|
21
|
+
* Revoke failures are logged but never block the local delete — Google may
|
|
22
|
+
* already consider the token invalid, and keeping the file would leave the
|
|
23
|
+
* user unable to unlink. */
|
|
24
|
+
export declare function unlinkGoogle(home?: string, revokeFetch?: RevokeFetch): Promise<void>;
|
|
25
|
+
export declare const waitForAuthCode: (server: http.Server, expectedState: string, timeoutMs: number) => Promise<string>;
|
|
26
|
+
export declare function authorizeGoogle(opts?: AuthorizeGoogleOptions): Promise<Credentials>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { authorizeGoogle } from './auth.js';
|
|
2
|
+
export interface GoogleAuthFlowStatus {
|
|
3
|
+
pending: boolean;
|
|
4
|
+
lastError: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface GoogleAuthFlow {
|
|
7
|
+
start: () => Promise<{
|
|
8
|
+
authUrl: string;
|
|
9
|
+
}>;
|
|
10
|
+
status: () => GoogleAuthFlowStatus;
|
|
11
|
+
}
|
|
12
|
+
export declare const createGoogleAuthFlow: (authorize: typeof authorizeGoogle) => GoogleAuthFlow;
|
|
13
|
+
export declare const googleAuthFlow: GoogleAuthFlow;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const DEFAULT_LIST_MAX_RESULTS = 10;
|
|
2
|
+
export declare const MAX_LIST_RESULTS = 50;
|
|
3
|
+
export interface CalendarEventInput {
|
|
4
|
+
summary: string;
|
|
5
|
+
startDateTime: string;
|
|
6
|
+
endDateTime: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ListEventsInput {
|
|
10
|
+
timeMin?: string;
|
|
11
|
+
maxResults?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CalendarEventSummary {
|
|
14
|
+
id: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
start: string;
|
|
17
|
+
end: string;
|
|
18
|
+
htmlLink: string;
|
|
19
|
+
status: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const toEventSummary: (value: unknown) => CalendarEventSummary;
|
|
22
|
+
export declare const calendarApiError: (status: number, body: string) => Error;
|
|
23
|
+
export declare function createCalendarEvent(accessToken: string, input: CalendarEventInput): Promise<CalendarEventSummary>;
|
|
24
|
+
export declare function listCalendarEvents(accessToken: string, input?: ListEventsInput): Promise<CalendarEventSummary[]>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface InstalledClientSecret {
|
|
2
|
+
client_id: string;
|
|
3
|
+
client_secret: string;
|
|
4
|
+
}
|
|
5
|
+
/** "ambiguous" (2+ files) is distinct from "missing" — the fixes differ
|
|
6
|
+
* (remove duplicates vs download credentials), so the UI must not conflate
|
|
7
|
+
* them. */
|
|
8
|
+
export type ClientSecretPresence = "found" | "missing" | "ambiguous";
|
|
9
|
+
export declare function clientSecretPresence(home?: string): Promise<ClientSecretPresence>;
|
|
10
|
+
export declare function findClientSecretPath(home?: string): Promise<string>;
|
|
11
|
+
export declare function loadClientSecret(home?: string): Promise<InstalledClientSecret>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isIsoDateTimeWithOffset: (value: string) => boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS: number;
|
|
2
|
+
export type FetchWithTimeoutInit = Parameters<typeof fetch>[1] & {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
};
|
|
5
|
+
/** `fetch` with a finite timeout. Rejects with a `TimeoutError` once
|
|
6
|
+
* `timeoutMs` elapses. Composes with a caller-supplied `signal` so external
|
|
7
|
+
* cancellation still works. */
|
|
8
|
+
export declare function fetchWithTimeout(url: string | URL, init?: FetchWithTimeoutInit): Promise<Response>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function readJsonOrNull<T>(filePath: string): Promise<T | null>;
|
|
2
|
+
/** tmp-write + rename so readers never see a half-written file; `mode`
|
|
3
|
+
* applies to the tmp file and survives the rename. */
|
|
4
|
+
export declare function writeJsonAtomicWithMode(filePath: string, data: unknown, mode: number): Promise<void>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface GoogleLogger {
|
|
2
|
+
error: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
3
|
+
warn: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
4
|
+
info: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
5
|
+
debug: (prefix: string, message: string, data?: Record<string, unknown>) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function configureGoogleHost(binding: {
|
|
8
|
+
log: GoogleLogger;
|
|
9
|
+
}): void;
|
|
10
|
+
export declare const log: GoogleLogger;
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
|
|
3
|
+
let node_path = require("node:path");
|
|
4
|
+
node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
5
|
+
let node_fs = require("node:fs");
|
|
6
|
+
let node_fs_promises = require("node:fs/promises");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
8
|
+
let node_os = require("node:os");
|
|
9
|
+
let node_http = require("node:http");
|
|
10
|
+
node_http = require_rolldown_runtime.__toESM(node_http, 1);
|
|
11
|
+
let google_auth_library = require("google-auth-library");
|
|
12
|
+
//#region src/google/host.ts
|
|
13
|
+
var hostLog = {
|
|
14
|
+
error: () => void 0,
|
|
15
|
+
warn: () => void 0,
|
|
16
|
+
info: () => void 0,
|
|
17
|
+
debug: () => void 0
|
|
18
|
+
};
|
|
19
|
+
function configureGoogleHost(binding) {
|
|
20
|
+
hostLog = binding.log;
|
|
21
|
+
}
|
|
22
|
+
var log = {
|
|
23
|
+
error: (prefix, message, data) => hostLog.error(prefix, message, data),
|
|
24
|
+
warn: (prefix, message, data) => hostLog.warn(prefix, message, data),
|
|
25
|
+
info: (prefix, message, data) => hostLog.info(prefix, message, data),
|
|
26
|
+
debug: (prefix, message, data) => hostLog.debug(prefix, message, data)
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/google/datetime.ts
|
|
30
|
+
var ISO_DATE_TIME_WITH_OFFSET_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:Z|[+-](\d{2}):(\d{2}))$/;
|
|
31
|
+
var FRACTIONAL_SECONDS_RE = /\.\d+(?=Z|[+-])/;
|
|
32
|
+
var MAX_HOUR = 23;
|
|
33
|
+
var MAX_MINUTE = 59;
|
|
34
|
+
var MAX_SECOND = 59;
|
|
35
|
+
var isRealCalendarDate = (year, month, day) => {
|
|
36
|
+
const roundTrip = new Date(Date.UTC(year, month - 1, day));
|
|
37
|
+
return roundTrip.getUTCFullYear() === year && roundTrip.getUTCMonth() === month - 1 && roundTrip.getUTCDate() === day;
|
|
38
|
+
};
|
|
39
|
+
var isIsoDateTimeWithOffset = (value) => {
|
|
40
|
+
const match = ISO_DATE_TIME_WITH_OFFSET_RE.exec(value.replace(FRACTIONAL_SECONDS_RE, ""));
|
|
41
|
+
if (!match) return false;
|
|
42
|
+
const [year = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number);
|
|
43
|
+
const timeInRange = hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND;
|
|
44
|
+
const offsetInRange = match[7] === void 0 || Number(match[7]) <= MAX_HOUR && Number(match[8]) <= MAX_MINUTE;
|
|
45
|
+
return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/google/paths.ts
|
|
49
|
+
function googleConfigDir(home) {
|
|
50
|
+
return (0, node_path.join)(home ?? (0, node_os.homedir)(), ".config", "mulmo");
|
|
51
|
+
}
|
|
52
|
+
/** Pre-0.20.1 token dir (mulmoclaude-branded); reads migrate away from it. */
|
|
53
|
+
function legacyGoogleTokenPath(home) {
|
|
54
|
+
return (0, node_path.join)(home ?? (0, node_os.homedir)(), ".config", "mulmoclaude", "google-token.json");
|
|
55
|
+
}
|
|
56
|
+
function googleTokenPath(home) {
|
|
57
|
+
return (0, node_path.join)(googleConfigDir(home), "google-token.json");
|
|
58
|
+
}
|
|
59
|
+
function googleSecretsDir(home) {
|
|
60
|
+
return (0, node_path.join)(home ?? (0, node_os.homedir)(), ".secrets");
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/google/util.ts
|
|
64
|
+
var ONE_SECOND_MS = 1e3;
|
|
65
|
+
var ONE_MINUTE_MS = 6e4;
|
|
66
|
+
function errorMessage(err, fallback = "unknown error") {
|
|
67
|
+
if (err instanceof Error) return err.message || fallback;
|
|
68
|
+
const text = String(err);
|
|
69
|
+
return text === "" || text === "[object Object]" ? fallback : text;
|
|
70
|
+
}
|
|
71
|
+
function isRecord(value) {
|
|
72
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
/** Clip a string to at most `max` chars; the ellipsis is included in the
|
|
75
|
+
* budget so output never exceeds `max`. */
|
|
76
|
+
function truncate(text, max, ellipsis = "…") {
|
|
77
|
+
if (max <= 0) return "";
|
|
78
|
+
if (text.length <= max) return text;
|
|
79
|
+
return `${text.slice(0, Math.max(0, max - ellipsis.length))}${ellipsis}`;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/google/clientSecret.ts
|
|
83
|
+
var isInstalledClientSecret = (value) => {
|
|
84
|
+
if (!isRecord(value) || !isRecord(value.installed)) return false;
|
|
85
|
+
return typeof value.installed.client_id === "string" && typeof value.installed.client_secret === "string";
|
|
86
|
+
};
|
|
87
|
+
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();
|
|
90
|
+
}
|
|
91
|
+
async function clientSecretPresence(home) {
|
|
92
|
+
const matches = await listClientSecretFiles(home);
|
|
93
|
+
if (matches.length === 0) return "missing";
|
|
94
|
+
return matches.length === 1 ? "found" : "ambiguous";
|
|
95
|
+
}
|
|
96
|
+
async function findClientSecretPath(home) {
|
|
97
|
+
const dir = googleSecretsDir(home);
|
|
98
|
+
const matches = await listClientSecretFiles(home);
|
|
99
|
+
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);
|
|
103
|
+
}
|
|
104
|
+
async function loadClientSecret(home) {
|
|
105
|
+
const filePath = await findClientSecretPath(home);
|
|
106
|
+
const raw = await (0, node_fs_promises.readFile)(filePath, "utf-8");
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
if (!isInstalledClientSecret(parsed)) throw new Error(`${filePath} is not a desktop-app OAuth client secret (missing "installed" with client_id / client_secret)`);
|
|
109
|
+
return {
|
|
110
|
+
client_id: parsed.installed.client_id,
|
|
111
|
+
client_secret: parsed.installed.client_secret
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
//#region src/google/fsJson.ts
|
|
116
|
+
async function readJsonOrNull(filePath) {
|
|
117
|
+
try {
|
|
118
|
+
const raw = await node_fs.promises.readFile(filePath, "utf-8");
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
var IS_WINDOWS = process.platform === "win32";
|
|
125
|
+
var RENAME_RETRY_DELAYS_MS = [
|
|
126
|
+
30,
|
|
127
|
+
100,
|
|
128
|
+
300
|
|
129
|
+
];
|
|
130
|
+
var hasErrnoCode = (err) => typeof err === "object" && err !== null && "code" in err && typeof err.code === "string";
|
|
131
|
+
var isTransientRenameError = (err) => IS_WINDOWS && hasErrnoCode(err) && (err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES");
|
|
132
|
+
async function renameWithWindowsRetry(fromPath, toPath) {
|
|
133
|
+
for (const delayMs of RENAME_RETRY_DELAYS_MS) try {
|
|
134
|
+
await node_fs.promises.rename(fromPath, toPath);
|
|
135
|
+
return;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (!isTransientRenameError(err)) throw err;
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
139
|
+
}
|
|
140
|
+
await node_fs.promises.rename(fromPath, toPath);
|
|
141
|
+
}
|
|
142
|
+
/** tmp-write + rename so readers never see a half-written file; `mode`
|
|
143
|
+
* applies to the tmp file and survives the rename. */
|
|
144
|
+
async function writeJsonAtomicWithMode(filePath, data, mode) {
|
|
145
|
+
const tmp = `${filePath}.tmp`;
|
|
146
|
+
await node_fs.promises.mkdir(node_path.default.dirname(filePath), { recursive: true });
|
|
147
|
+
try {
|
|
148
|
+
await node_fs.promises.writeFile(tmp, JSON.stringify(data, null, 2), {
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
mode
|
|
151
|
+
});
|
|
152
|
+
await renameWithWindowsRetry(tmp, filePath);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
await node_fs.promises.unlink(tmp).catch(() => void 0);
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/google/tokenStore.ts
|
|
160
|
+
var TOKEN_FILE_MODE = 384;
|
|
161
|
+
function mergeGoogleTokens(existing, incoming) {
|
|
162
|
+
const merged = {
|
|
163
|
+
...existing,
|
|
164
|
+
...incoming
|
|
165
|
+
};
|
|
166
|
+
if (!incoming.refresh_token && existing?.refresh_token) merged.refresh_token = existing.refresh_token;
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
var fileExists = async (filePath) => await (0, node_fs_promises.stat)(filePath).then(() => true, () => false);
|
|
170
|
+
async function migrateLegacyTokenFile(home) {
|
|
171
|
+
const current = googleTokenPath(home);
|
|
172
|
+
const legacy = legacyGoogleTokenPath(home);
|
|
173
|
+
if (!await fileExists(legacy)) return;
|
|
174
|
+
await (0, node_fs_promises.mkdir)(node_path.default.dirname(current), { recursive: true });
|
|
175
|
+
try {
|
|
176
|
+
await (0, node_fs_promises.copyFile)(legacy, current, node_fs_promises.constants.COPYFILE_EXCL);
|
|
177
|
+
} catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await (0, node_fs_promises.rm)(legacy, { force: true });
|
|
181
|
+
}
|
|
182
|
+
async function loadGoogleTokens(home) {
|
|
183
|
+
await migrateLegacyTokenFile(home).catch(() => void 0);
|
|
184
|
+
const current = await readJsonOrNull(googleTokenPath(home));
|
|
185
|
+
if (current) return current;
|
|
186
|
+
return await readJsonOrNull(legacyGoogleTokenPath(home));
|
|
187
|
+
}
|
|
188
|
+
async function saveGoogleTokens(incoming, home) {
|
|
189
|
+
const merged = mergeGoogleTokens(await loadGoogleTokens(home), incoming);
|
|
190
|
+
await writeJsonAtomicWithMode(googleTokenPath(home), merged, TOKEN_FILE_MODE);
|
|
191
|
+
return merged;
|
|
192
|
+
}
|
|
193
|
+
async function deleteGoogleTokens(home) {
|
|
194
|
+
await (0, node_fs_promises.rm)(googleTokenPath(home), { force: true });
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/google/fetch.ts
|
|
198
|
+
var DEFAULT_FETCH_TIMEOUT_MS = 10 * ONE_SECOND_MS;
|
|
199
|
+
/** `fetch` with a finite timeout. Rejects with a `TimeoutError` once
|
|
200
|
+
* `timeoutMs` elapses. Composes with a caller-supplied `signal` so external
|
|
201
|
+
* cancellation still works. */
|
|
202
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
203
|
+
const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, signal: callerSignal, ...rest } = init;
|
|
204
|
+
if (callerSignal?.aborted) throw callerSignal.reason ?? new DOMException("Aborted", "AbortError");
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
controller.abort(new DOMException(`fetch timed out after ${timeoutMs}ms`, "TimeoutError"));
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
const unsubscribeCaller = bridgeExternalSignal(callerSignal, controller);
|
|
210
|
+
try {
|
|
211
|
+
return await fetch(url, {
|
|
212
|
+
...rest,
|
|
213
|
+
signal: controller.signal
|
|
214
|
+
});
|
|
215
|
+
} finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
unsubscribeCaller?.();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function bridgeExternalSignal(external, controller) {
|
|
221
|
+
if (!external) return null;
|
|
222
|
+
const onAbort = () => controller.abort(external.reason);
|
|
223
|
+
external.addEventListener("abort", onAbort, { once: true });
|
|
224
|
+
return () => external.removeEventListener("abort", onAbort);
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/google/auth.ts
|
|
228
|
+
var GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.events";
|
|
229
|
+
var GOOGLE_TASKS_SCOPE = "https://www.googleapis.com/auth/tasks";
|
|
230
|
+
var GOOGLE_DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
231
|
+
/** Requested at consent as one set — matches the scopes registered on the
|
|
232
|
+
* OAuth consent screen, so a single re-link covers every supported API
|
|
233
|
+
* (Calendar now; Tasks / Drive tools ride the same grant later). */
|
|
234
|
+
var GOOGLE_SCOPES = [
|
|
235
|
+
GOOGLE_CALENDAR_SCOPE,
|
|
236
|
+
GOOGLE_TASKS_SCOPE,
|
|
237
|
+
GOOGLE_DRIVE_FILE_SCOPE
|
|
238
|
+
];
|
|
239
|
+
var CALLBACK_PATH = "/oauth2callback";
|
|
240
|
+
var AUTH_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
|
|
241
|
+
var STATE_BYTES = 16;
|
|
242
|
+
var createClient = (secret, redirectUri) => new google_auth_library.OAuth2Client({
|
|
243
|
+
clientId: secret.client_id,
|
|
244
|
+
clientSecret: secret.client_secret,
|
|
245
|
+
redirectUri
|
|
246
|
+
});
|
|
247
|
+
var persistRotatedTokens = (client, home) => {
|
|
248
|
+
client.on("tokens", (tokens) => {
|
|
249
|
+
saveGoogleTokens(tokens, home).catch((err) => {
|
|
250
|
+
log.error("google", "failed to persist rotated tokens", { error: String(err) });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
};
|
|
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 — link it in Settings → Plugins → Google, or run `yarn google:auth`");
|
|
257
|
+
const client = createClient(await loadClientSecret(home));
|
|
258
|
+
client.setCredentials(saved);
|
|
259
|
+
persistRotatedTokens(client, home);
|
|
260
|
+
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");
|
|
262
|
+
return token;
|
|
263
|
+
}
|
|
264
|
+
var REVOKE_URL = "https://oauth2.googleapis.com/revoke";
|
|
265
|
+
/** Revoke the grant at Google (best-effort) and delete the local token file.
|
|
266
|
+
* Revoke failures are logged but never block the local delete — Google may
|
|
267
|
+
* already consider the token invalid, and keeping the file would leave the
|
|
268
|
+
* user unable to unlink. */
|
|
269
|
+
async function unlinkGoogle(home, revokeFetch = fetchWithTimeout) {
|
|
270
|
+
const saved = await loadGoogleTokens(home);
|
|
271
|
+
const token = saved?.refresh_token ?? saved?.access_token;
|
|
272
|
+
if (token) try {
|
|
273
|
+
const response = await revokeFetch(REVOKE_URL, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
276
|
+
body: new URLSearchParams({ token }).toString()
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) log.warn("google", "token revoke returned non-ok", { status: response.status });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log.warn("google", "token revoke failed, deleting local tokens anyway", { error: errorMessage(err) });
|
|
281
|
+
}
|
|
282
|
+
await deleteGoogleTokens(home);
|
|
283
|
+
}
|
|
284
|
+
var startLoopbackServer = () => new Promise((resolve, reject) => {
|
|
285
|
+
const server = node_http.default.createServer();
|
|
286
|
+
server.once("error", reject);
|
|
287
|
+
server.listen(0, "127.0.0.1", () => {
|
|
288
|
+
const address = server.address();
|
|
289
|
+
if (address === null || typeof address === "string") {
|
|
290
|
+
reject(/* @__PURE__ */ new Error("loopback server has no port"));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
resolve({
|
|
294
|
+
server,
|
|
295
|
+
port: address.port
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
var authCodeFromCallback = (url, expectedState) => {
|
|
300
|
+
if (url.searchParams.get("state") !== expectedState) throw new Error("OAuth state mismatch — possible CSRF, aborting");
|
|
301
|
+
const error = url.searchParams.get("error");
|
|
302
|
+
if (error) throw new Error(`Google authorization failed: ${error}`);
|
|
303
|
+
const code = url.searchParams.get("code");
|
|
304
|
+
if (!code) throw new Error("authorization callback carried no code");
|
|
305
|
+
return code;
|
|
306
|
+
};
|
|
307
|
+
var respondHtml = (res, status, message) => {
|
|
308
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
309
|
+
res.end(`<html><body><h3>${message}</h3></body></html>`);
|
|
310
|
+
};
|
|
311
|
+
var waitForAuthCode = (server, expectedState, timeoutMs) => new Promise((resolve, reject) => {
|
|
312
|
+
const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`authorization timed out after ${timeoutMs / ONE_SECOND_MS}s`)), timeoutMs);
|
|
313
|
+
server.on("request", (req, res) => {
|
|
314
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
315
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
316
|
+
res.writeHead(404);
|
|
317
|
+
res.end();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
321
|
+
respondHtml(res, 400, "Invalid authorization callback. You can close this tab.");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
try {
|
|
326
|
+
const code = authCodeFromCallback(url, expectedState);
|
|
327
|
+
respondHtml(res, 200, "Authorization complete — you can close this tab.");
|
|
328
|
+
resolve(code);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
respondHtml(res, 400, "Authorization failed — see the terminal for details. You can close this tab.");
|
|
331
|
+
reject(err);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
var buildConsentUrl = (client, codeChallenge, state) => client.generateAuthUrl({
|
|
336
|
+
access_type: "offline",
|
|
337
|
+
prompt: "consent",
|
|
338
|
+
scope: GOOGLE_SCOPES,
|
|
339
|
+
code_challenge_method: google_auth_library.CodeChallengeMethod.S256,
|
|
340
|
+
code_challenge: codeChallenge,
|
|
341
|
+
state
|
|
342
|
+
});
|
|
343
|
+
async function authorizeGoogle(opts = {}) {
|
|
344
|
+
const secret = await loadClientSecret(opts.home);
|
|
345
|
+
const { server, port } = await startLoopbackServer();
|
|
346
|
+
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);
|
|
359
|
+
} finally {
|
|
360
|
+
server.close();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/google/authFlow.ts
|
|
365
|
+
var createGoogleAuthFlow = (authorize) => {
|
|
366
|
+
let inFlightStart = null;
|
|
367
|
+
let flowRunning = false;
|
|
368
|
+
let lastError = null;
|
|
369
|
+
const launchFlow = () => new Promise((resolve, reject) => {
|
|
370
|
+
flowRunning = true;
|
|
371
|
+
authorize({ onAuthUrl: (url) => resolve({ authUrl: url }) }).then(() => log.info("google", "authorize flow completed")).catch((err) => {
|
|
372
|
+
lastError = errorMessage(err);
|
|
373
|
+
log.warn("google", "authorize flow failed", { error: lastError });
|
|
374
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
375
|
+
}).finally(() => {
|
|
376
|
+
flowRunning = false;
|
|
377
|
+
inFlightStart = null;
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
const start = () => {
|
|
381
|
+
if (inFlightStart) return inFlightStart;
|
|
382
|
+
lastError = null;
|
|
383
|
+
inFlightStart = launchFlow();
|
|
384
|
+
return inFlightStart;
|
|
385
|
+
};
|
|
386
|
+
const status = () => ({
|
|
387
|
+
pending: flowRunning,
|
|
388
|
+
lastError
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
start,
|
|
392
|
+
status
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
var googleAuthFlow = createGoogleAuthFlow(authorizeGoogle);
|
|
396
|
+
//#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;
|
|
400
|
+
var ERROR_BODY_MAX_CHARS = 300;
|
|
401
|
+
var HTTP_FORBIDDEN = 403;
|
|
402
|
+
var DEFAULT_LIST_MAX_RESULTS = 10;
|
|
403
|
+
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?)" : "";
|
|
423
|
+
const detail = body ? ` — ${truncate(body, ERROR_BODY_MAX_CHARS)}` : "";
|
|
424
|
+
return /* @__PURE__ */ new Error(`Google Calendar API: HTTP ${status}${hint}${detail}`);
|
|
425
|
+
};
|
|
426
|
+
var calendarRequest = async (accessToken, url, init = {}) => {
|
|
427
|
+
const response = await fetchWithTimeout(url, {
|
|
428
|
+
...init,
|
|
429
|
+
timeoutMs: CALENDAR_TIMEOUT_MS,
|
|
430
|
+
headers: {
|
|
431
|
+
Authorization: `Bearer ${accessToken}`,
|
|
432
|
+
"Content-Type": "application/json"
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
if (!response.ok) {
|
|
436
|
+
const body = await response.text().catch((err) => errorMessage(err));
|
|
437
|
+
throw calendarApiError(response.status, body);
|
|
438
|
+
}
|
|
439
|
+
return await response.json();
|
|
440
|
+
};
|
|
441
|
+
async function createCalendarEvent(accessToken, input) {
|
|
442
|
+
const body = {
|
|
443
|
+
summary: input.summary,
|
|
444
|
+
description: input.description,
|
|
445
|
+
start: { dateTime: input.startDateTime },
|
|
446
|
+
end: { dateTime: input.endDateTime }
|
|
447
|
+
};
|
|
448
|
+
return toEventSummary(await calendarRequest(accessToken, CALENDAR_EVENTS_URL, {
|
|
449
|
+
method: "POST",
|
|
450
|
+
body: JSON.stringify(body)
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
async function listCalendarEvents(accessToken, input = {}) {
|
|
454
|
+
const listed = await calendarRequest(accessToken, `${CALENDAR_EVENTS_URL}?${new URLSearchParams({
|
|
455
|
+
timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
456
|
+
maxResults: String(input.maxResults ?? 10),
|
|
457
|
+
singleEvents: "true",
|
|
458
|
+
orderBy: "startTime"
|
|
459
|
+
}).toString()}`);
|
|
460
|
+
return (isRecord(listed) && Array.isArray(listed.items) ? listed.items : []).map(toEventSummary);
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
exports.DEFAULT_LIST_MAX_RESULTS = DEFAULT_LIST_MAX_RESULTS;
|
|
464
|
+
exports.GOOGLE_CALENDAR_SCOPE = GOOGLE_CALENDAR_SCOPE;
|
|
465
|
+
exports.GOOGLE_DRIVE_FILE_SCOPE = GOOGLE_DRIVE_FILE_SCOPE;
|
|
466
|
+
exports.GOOGLE_SCOPES = GOOGLE_SCOPES;
|
|
467
|
+
exports.GOOGLE_TASKS_SCOPE = GOOGLE_TASKS_SCOPE;
|
|
468
|
+
exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
|
|
469
|
+
exports.authorizeGoogle = authorizeGoogle;
|
|
470
|
+
exports.calendarApiError = calendarApiError;
|
|
471
|
+
exports.clientSecretPresence = clientSecretPresence;
|
|
472
|
+
exports.configureGoogleHost = configureGoogleHost;
|
|
473
|
+
exports.createCalendarEvent = createCalendarEvent;
|
|
474
|
+
exports.createGoogleAuthFlow = createGoogleAuthFlow;
|
|
475
|
+
exports.deleteGoogleTokens = deleteGoogleTokens;
|
|
476
|
+
exports.findClientSecretPath = findClientSecretPath;
|
|
477
|
+
exports.getGoogleAccessToken = getGoogleAccessToken;
|
|
478
|
+
exports.googleAuthFlow = googleAuthFlow;
|
|
479
|
+
exports.googleConfigDir = googleConfigDir;
|
|
480
|
+
exports.googleSecretsDir = googleSecretsDir;
|
|
481
|
+
exports.googleTokenPath = googleTokenPath;
|
|
482
|
+
exports.isIsoDateTimeWithOffset = isIsoDateTimeWithOffset;
|
|
483
|
+
exports.legacyGoogleTokenPath = legacyGoogleTokenPath;
|
|
484
|
+
exports.listCalendarEvents = listCalendarEvents;
|
|
485
|
+
exports.loadClientSecret = loadClientSecret;
|
|
486
|
+
exports.loadGoogleTokens = loadGoogleTokens;
|
|
487
|
+
exports.mergeGoogleTokens = mergeGoogleTokens;
|
|
488
|
+
exports.saveGoogleTokens = saveGoogleTokens;
|
|
489
|
+
exports.toEventSummary = toEventSummary;
|
|
490
|
+
exports.unlinkGoogle = unlinkGoogle;
|
|
491
|
+
exports.waitForAuthCode = waitForAuthCode;
|
|
492
|
+
|
|
493
|
+
//# sourceMappingURL=index.cjs.map
|