granola-toolkit 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -1
  2. package/dist/cli.js +256 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,7 @@ Installed CLI:
31
31
 
32
32
  ```bash
33
33
  granola --help
34
+ granola auth login
34
35
  granola notes --help
35
36
  granola transcripts --help
36
37
  ```
@@ -57,6 +58,9 @@ npm run transcripts -- --help
57
58
  Export notes:
58
59
 
59
60
  ```bash
61
+ granola auth login
62
+ granola notes
63
+
60
64
  node dist/cli.js notes --supabase "$HOME/Library/Application Support/Granola/supabase.json"
61
65
  node dist/cli.js notes --format json --output ./notes-json
62
66
  ```
@@ -76,7 +80,7 @@ node dist/cli.js transcripts --format yaml --output ./transcripts-yaml
76
80
 
77
81
  The flow is:
78
82
 
79
- 1. read your local `supabase.json`
83
+ 1. read a stored Granola session, or fall back to your local `supabase.json`
80
84
  2. extract the WorkOS access token from it
81
85
  3. call Granola's paginated documents API
82
86
  4. normalise each document into a structured note export
@@ -117,6 +121,17 @@ Speaker labels are currently normalised to:
117
121
 
118
122
  Structured output formats are useful when you want to post-process exports in scripts instead of reading the default human-oriented Markdown or text files.
119
123
 
124
+ ## Auth
125
+
126
+ If you do not want to keep passing `--supabase`, import the desktop app session once:
127
+
128
+ ```bash
129
+ granola auth login
130
+ granola auth status
131
+ ```
132
+
133
+ That stores a reusable Granola session locally and lets `granola notes` use it directly. `granola auth logout` deletes the stored session.
134
+
120
135
  ### Incremental Writes
121
136
 
122
137
  Both commands keep a small hidden state file in the output directory to track:
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
3
+ import { execFile } from "node:child_process";
4
+ import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir, platform } from "node:os";
5
6
  import { dirname, join } from "node:path";
7
+ import { promisify } from "node:util";
6
8
  import { createHash } from "node:crypto";
7
9
  //#region src/utils.ts
8
10
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
@@ -162,16 +164,46 @@ function transcriptSpeakerLabel(segment) {
162
164
  }
163
165
  //#endregion
164
166
  //#region src/client/auth.ts
165
- function getAccessTokenFromSupabaseContents(supabaseContents) {
167
+ const execFileAsync = promisify(execFile);
168
+ const DEFAULT_CLIENT_ID = "client_GranolaMac";
169
+ const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
170
+ const KEYCHAIN_ACCOUNT_NAME = "session";
171
+ const WORKOS_AUTH_URL = "https://api.workos.com/user_management/authenticate";
172
+ function numberValue(value) {
173
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
174
+ }
175
+ function parseSessionRecord(record) {
176
+ const accessToken = stringValue(record.access_token);
177
+ if (!accessToken.trim()) return;
178
+ return {
179
+ accessToken,
180
+ clientId: stringValue(record.client_id) || DEFAULT_CLIENT_ID,
181
+ expiresIn: numberValue(record.expires_in),
182
+ externalId: stringValue(record.external_id) || void 0,
183
+ obtainedAt: stringValue(record.obtained_at) || void 0,
184
+ refreshToken: stringValue(record.refresh_token) || void 0,
185
+ sessionId: stringValue(record.session_id) || void 0,
186
+ signInMethod: stringValue(record.sign_in_method) || void 0,
187
+ tokenType: stringValue(record.token_type) || void 0
188
+ };
189
+ }
190
+ function parseNestedRecord(value) {
191
+ if (typeof value === "string") return parseJsonString(value);
192
+ return asRecord(value);
193
+ }
194
+ function getSessionFromSupabaseContents(supabaseContents) {
166
195
  const wrapper = parseJsonString(supabaseContents);
167
196
  if (!wrapper) throw new Error("failed to parse supabase.json");
168
- const workosTokens = wrapper.workos_tokens;
169
- let tokenPayload;
170
- if (typeof workosTokens === "string") tokenPayload = parseJsonString(workosTokens);
171
- else tokenPayload = asRecord(workosTokens);
172
- const accessToken = tokenPayload ? stringValue(tokenPayload.access_token) : "";
173
- if (!accessToken.trim()) throw new Error("access token not found in supabase.json");
174
- return accessToken;
197
+ const workOsSession = parseSessionRecord(parseNestedRecord(wrapper.workos_tokens) ?? {});
198
+ if (workOsSession) return workOsSession;
199
+ const cognitoSession = parseSessionRecord(parseNestedRecord(wrapper.cognito_tokens) ?? {});
200
+ if (cognitoSession) return cognitoSession;
201
+ const legacySession = parseSessionRecord(wrapper);
202
+ if (legacySession) return legacySession;
203
+ throw new Error("access token not found in supabase.json");
204
+ }
205
+ function getAccessTokenFromSupabaseContents(supabaseContents) {
206
+ return getSessionFromSupabaseContents(supabaseContents).accessToken;
175
207
  }
176
208
  var SupabaseFileTokenSource = class {
177
209
  constructor(filePath) {
@@ -181,11 +213,85 @@ var SupabaseFileTokenSource = class {
181
213
  return getAccessTokenFromSupabaseContents(await readFile(this.filePath, "utf8"));
182
214
  }
183
215
  };
216
+ var SupabaseFileSessionSource = class {
217
+ constructor(filePath) {
218
+ this.filePath = filePath;
219
+ }
220
+ async loadSession() {
221
+ return getSessionFromSupabaseContents(await readFile(this.filePath, "utf8"));
222
+ }
223
+ };
184
224
  var NoopTokenStore = class {
185
225
  async clearToken() {}
186
226
  async readToken() {}
187
227
  async writeToken(_token) {}
188
228
  };
229
+ var FileSessionStore = class {
230
+ constructor(filePath = defaultSessionFilePath()) {
231
+ this.filePath = filePath;
232
+ }
233
+ async clearSession() {
234
+ try {
235
+ await unlink(this.filePath);
236
+ } catch {}
237
+ }
238
+ async readSession() {
239
+ try {
240
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
241
+ return parsed?.accessToken ? parsed : void 0;
242
+ } catch {
243
+ return;
244
+ }
245
+ }
246
+ async writeSession(session) {
247
+ await mkdir(dirname(this.filePath), { recursive: true });
248
+ await writeFile(this.filePath, `${JSON.stringify(session, null, 2)}\n`, {
249
+ encoding: "utf8",
250
+ mode: 384
251
+ });
252
+ }
253
+ };
254
+ var KeychainSessionStore = class {
255
+ async clearSession() {
256
+ try {
257
+ await execFileAsync("security", [
258
+ "delete-generic-password",
259
+ "-s",
260
+ KEYCHAIN_SERVICE_NAME,
261
+ "-a",
262
+ KEYCHAIN_ACCOUNT_NAME
263
+ ]);
264
+ } catch {}
265
+ }
266
+ async readSession() {
267
+ try {
268
+ const { stdout } = await execFileAsync("security", [
269
+ "find-generic-password",
270
+ "-s",
271
+ KEYCHAIN_SERVICE_NAME,
272
+ "-a",
273
+ KEYCHAIN_ACCOUNT_NAME,
274
+ "-w"
275
+ ]);
276
+ const parsed = parseJsonString(stdout.trim());
277
+ return parsed?.accessToken ? parsed : void 0;
278
+ } catch {
279
+ return;
280
+ }
281
+ }
282
+ async writeSession(session) {
283
+ await execFileAsync("security", [
284
+ "add-generic-password",
285
+ "-U",
286
+ "-s",
287
+ KEYCHAIN_SERVICE_NAME,
288
+ "-a",
289
+ KEYCHAIN_ACCOUNT_NAME,
290
+ "-w",
291
+ JSON.stringify(session)
292
+ ]);
293
+ }
294
+ };
189
295
  var CachedTokenProvider = class {
190
296
  #token;
191
297
  constructor(source, store = new NoopTokenStore()) {
@@ -209,6 +315,135 @@ var CachedTokenProvider = class {
209
315
  await this.store.clearToken();
210
316
  }
211
317
  };
318
+ var StoredSessionTokenProvider = class {
319
+ #session;
320
+ constructor(store, options = {}) {
321
+ this.store = store;
322
+ this.options = options;
323
+ }
324
+ async loadSession() {
325
+ if (this.#session) return this.#session;
326
+ const storedSession = await this.store.readSession();
327
+ if (storedSession?.accessToken.trim()) {
328
+ this.#session = storedSession;
329
+ return storedSession;
330
+ }
331
+ if (!this.options.source) throw new Error("no stored Granola session found");
332
+ const sourcedSession = await this.options.source.loadSession();
333
+ this.#session = sourcedSession;
334
+ return sourcedSession;
335
+ }
336
+ async getAccessToken() {
337
+ return (await this.loadSession()).accessToken;
338
+ }
339
+ async invalidate() {
340
+ const session = await this.loadSession().catch(() => void 0);
341
+ if (session?.refreshToken && session.clientId) {
342
+ const refreshedSession = await refreshGranolaSession(session, this.options.fetchImpl);
343
+ this.#session = refreshedSession;
344
+ await this.store.writeSession(refreshedSession);
345
+ return;
346
+ }
347
+ if (this.options.source) {
348
+ this.#session = await this.options.source.loadSession();
349
+ return;
350
+ }
351
+ this.#session = void 0;
352
+ await this.store.clearSession();
353
+ }
354
+ };
355
+ async function refreshGranolaSession(session, fetchImpl = fetch) {
356
+ if (!session.refreshToken?.trim()) throw new Error("refresh token not available");
357
+ const response = await fetchImpl(WORKOS_AUTH_URL, {
358
+ body: JSON.stringify({
359
+ client_id: session.clientId,
360
+ grant_type: "refresh_token",
361
+ refresh_token: session.refreshToken
362
+ }),
363
+ headers: { "Content-Type": "application/json" },
364
+ method: "POST"
365
+ });
366
+ if (!response.ok) throw new Error(`failed to refresh session: ${response.status} ${response.statusText}`);
367
+ const refreshed = parseSessionRecord(await response.json());
368
+ if (!refreshed) throw new Error("failed to parse refreshed session");
369
+ return {
370
+ ...session,
371
+ ...refreshed,
372
+ clientId: refreshed.clientId || session.clientId,
373
+ obtainedAt: refreshed.obtainedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
374
+ refreshToken: refreshed.refreshToken ?? session.refreshToken
375
+ };
376
+ }
377
+ function defaultSessionFilePath() {
378
+ const home = homedir();
379
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
380
+ }
381
+ function createDefaultSessionStore() {
382
+ return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
383
+ }
384
+ //#endregion
385
+ //#region src/commands/auth.ts
386
+ function authHelp() {
387
+ return `Granola auth
388
+
389
+ Usage:
390
+ granola auth <login|status|logout>
391
+
392
+ Subcommands:
393
+ login Import credentials from the Granola desktop app
394
+ status Show whether a stored Granola session is available
395
+ logout Delete the stored Granola session
396
+
397
+ Options:
398
+ --supabase <path> Path to supabase.json for auth login
399
+ -h, --help Show help
400
+ `;
401
+ }
402
+ const authCommand = {
403
+ description: "Manage stored Granola sessions",
404
+ flags: { help: { type: "boolean" } },
405
+ help: authHelp,
406
+ name: "auth",
407
+ async run({ commandArgs, globalFlags }) {
408
+ const [action] = commandArgs;
409
+ switch (action) {
410
+ case "login": return await login(globalFlags.supabase);
411
+ case "logout": return await logout();
412
+ case "status": return await status();
413
+ case void 0:
414
+ console.log(authHelp());
415
+ return 1;
416
+ default: throw new Error("invalid auth command: expected login, status, or logout");
417
+ }
418
+ }
419
+ };
420
+ async function login(supabaseFlag) {
421
+ const supabasePath = typeof supabaseFlag === "string" && supabaseFlag.trim() || firstExistingPath(granolaSupabaseCandidates());
422
+ if (!supabasePath) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
423
+ if (!existsSync(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
424
+ const sessionStore = createDefaultSessionStore();
425
+ const session = await new SupabaseFileSessionSource(supabasePath).loadSession();
426
+ await sessionStore.writeSession(session);
427
+ console.log(`Imported Granola session from ${supabasePath}`);
428
+ return 0;
429
+ }
430
+ async function status() {
431
+ const session = await createDefaultSessionStore().readSession();
432
+ if (!session) {
433
+ console.log("No stored Granola session");
434
+ return 1;
435
+ }
436
+ console.log("Stored Granola session");
437
+ console.log(`Client ID: ${session.clientId}`);
438
+ console.log(`Refresh token: ${session.refreshToken ? "available" : "missing"}`);
439
+ console.log(`Sign-in method: ${session.signInMethod ?? "unknown"}`);
440
+ return 0;
441
+ }
442
+ async function logout() {
443
+ await createDefaultSessionStore().clearSession();
444
+ console.log("Stored Granola session deleted");
445
+ return 0;
446
+ }
212
447
  //#endregion
213
448
  //#region src/client/parsers.ts
214
449
  function parseProseMirrorDoc(value, options = {}) {
@@ -819,8 +1054,10 @@ const notesCommand = {
819
1054
  globalFlags,
820
1055
  subcommandFlags: commandFlags
821
1056
  });
822
- if (!config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
823
- if (!existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
1057
+ const sessionStore = createDefaultSessionStore();
1058
+ const storedSession = await sessionStore.readSession();
1059
+ if (!storedSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
1060
+ if (!storedSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
824
1061
  debug(config.debug, "using config", config.configFileUsed ?? "(none)");
825
1062
  debug(config.debug, "supabase", config.supabase);
826
1063
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
@@ -828,7 +1065,7 @@ const notesCommand = {
828
1065
  const format = resolveNoteFormat(commandFlags.format);
829
1066
  debug(config.debug, "format", format);
830
1067
  console.log("Fetching documents from Granola API...");
831
- const tokenProvider = new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
1068
+ const tokenProvider = storedSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore());
832
1069
  const documents = await new GranolaApiClient(new AuthenticatedHttpClient({
833
1070
  logger: console,
834
1071
  tokenProvider
@@ -1098,7 +1335,11 @@ function resolveTranscriptFormat(value) {
1098
1335
  }
1099
1336
  //#endregion
1100
1337
  //#region src/commands/index.ts
1101
- const commands = [notesCommand, transcriptsCommand];
1338
+ const commands = [
1339
+ authCommand,
1340
+ notesCommand,
1341
+ transcriptsCommand
1342
+ ];
1102
1343
  const commandMap = new Map(commands.map((command) => [command.name, command]));
1103
1344
  //#endregion
1104
1345
  //#region src/flags.ts
@@ -1213,6 +1454,7 @@ async function runCli(argv) {
1213
1454
  return 0;
1214
1455
  }
1215
1456
  return await command.run({
1457
+ commandArgs: subcommand.rest,
1216
1458
  commandFlags: subcommand.values,
1217
1459
  globalFlags: global.values
1218
1460
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "CLI toolkit for exporting and working with Granola notes and transcripts",
5
5
  "keywords": [
6
6
  "cli",