granola-toolkit 0.22.0 → 0.23.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 +20 -1
  2. package/dist/cli.js +605 -131
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -193,12 +193,17 @@ The machine-readable `export` command includes:
193
193
  The initial server API includes:
194
194
 
195
195
  - `GET /health`
196
+ - `GET /auth/status`
196
197
  - `GET /state`
197
198
  - `GET /events` for server-sent state updates
198
199
  - `GET /meetings`
199
200
  - `GET /meetings/resolve?q=<query>`
200
201
  - `GET /meetings/:id`
201
202
  - `GET /exports/jobs`
203
+ - `POST /auth/login`
204
+ - `POST /auth/logout`
205
+ - `POST /auth/mode`
206
+ - `POST /auth/refresh`
202
207
  - `POST /exports/notes`
203
208
  - `POST /exports/jobs/:id/rerun`
204
209
  - `POST /exports/transcripts`
@@ -217,6 +222,7 @@ The initial browser client includes:
217
222
  - a focused meeting workspace with notes, transcript, metadata, and raw tabs
218
223
  - keyboard-first workspace switching with `1`-`4`, `[` and `]`
219
224
  - app-state status from the shared core
225
+ - an auth session panel for login, refresh, source switching, and sign-out
220
226
  - note and transcript export actions backed by the same local API
221
227
  - a recent export-jobs panel with rerun actions
222
228
  - stronger empty and error states for list/detail failures
@@ -239,9 +245,22 @@ If you do not want to keep passing `--supabase`, import the desktop app session
239
245
  ```bash
240
246
  granola auth login
241
247
  granola auth status
248
+ granola auth refresh
249
+ granola auth use stored
250
+ granola auth use supabase
242
251
  ```
243
252
 
244
- That stores a reusable Granola session locally and lets `granola notes` use it directly. `granola auth logout` deletes the stored session.
253
+ That stores a reusable Granola session locally and lets `granola notes` use it directly.
254
+
255
+ `granola auth` now supports:
256
+
257
+ - `login` to import the desktop app session into the toolkit store
258
+ - `status` to inspect the active source, stored-session availability, refresh support, and any last auth error
259
+ - `refresh` to refresh the stored session explicitly
260
+ - `use stored` or `use supabase` to switch the active auth source
261
+ - `logout` to delete the stored session
262
+
263
+ The same auth actions are also available from the web workspace.
245
264
 
246
265
  ### Incremental Writes
247
266
 
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from "node:fs";
3
- import { execFile } from "node:child_process";
4
3
  import { mkdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
5
4
  import { homedir, platform } from "node:os";
6
5
  import { dirname, join } from "node:path";
7
- import { promisify } from "node:util";
8
6
  import { NodeHtmlMarkdown } from "node-html-markdown";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
11
  //#region src/utils.ts
@@ -153,6 +153,60 @@ function transcriptSpeakerLabel(segment) {
153
153
  return segment.source === "microphone" ? "You" : "System";
154
154
  }
155
155
  //#endregion
156
+ //#region src/cache.ts
157
+ function parseCacheDocument(id, value) {
158
+ const record = asRecord(value);
159
+ if (!record) return;
160
+ return {
161
+ createdAt: stringValue(record.created_at),
162
+ id,
163
+ title: stringValue(record.title),
164
+ updatedAt: stringValue(record.updated_at)
165
+ };
166
+ }
167
+ function parseTranscriptSegments(value) {
168
+ if (!Array.isArray(value)) return;
169
+ return value.flatMap((segment) => {
170
+ const record = asRecord(segment);
171
+ if (!record) return [];
172
+ return [{
173
+ documentId: stringValue(record.document_id),
174
+ endTimestamp: stringValue(record.end_timestamp),
175
+ id: stringValue(record.id),
176
+ isFinal: Boolean(record.is_final),
177
+ source: stringValue(record.source),
178
+ startTimestamp: stringValue(record.start_timestamp),
179
+ text: stringValue(record.text)
180
+ }];
181
+ });
182
+ }
183
+ function parseCacheContents(contents) {
184
+ const outer = parseJsonString(contents);
185
+ if (!outer) throw new Error("failed to parse cache JSON");
186
+ const rawCache = outer.cache;
187
+ let cachePayload;
188
+ if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
189
+ else cachePayload = asRecord(rawCache);
190
+ const state = cachePayload ? asRecord(cachePayload.state) : void 0;
191
+ if (!state) throw new Error("failed to parse cache state");
192
+ const rawDocuments = asRecord(state.documents) ?? {};
193
+ const rawTranscripts = asRecord(state.transcripts) ?? {};
194
+ const documents = {};
195
+ for (const [id, rawDocument] of Object.entries(rawDocuments)) {
196
+ const document = parseCacheDocument(id, rawDocument);
197
+ if (document) documents[id] = document;
198
+ }
199
+ const transcripts = {};
200
+ for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
201
+ const segments = parseTranscriptSegments(rawTranscript);
202
+ if (segments) transcripts[id] = segments;
203
+ }
204
+ return {
205
+ documents,
206
+ transcripts
207
+ };
208
+ }
209
+ //#endregion
156
210
  //#region src/client/auth.ts
157
211
  const execFileAsync$1 = promisify(execFile);
158
212
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
@@ -380,121 +434,126 @@ function createDefaultSessionStore() {
380
434
  return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
381
435
  }
382
436
  //#endregion
383
- //#region src/commands/auth.ts
384
- function authHelp() {
385
- return `Granola auth
386
-
387
- Usage:
388
- granola auth <login|status|logout>
389
-
390
- Subcommands:
391
- login Import credentials from the Granola desktop app
392
- status Show whether a stored Granola session is available
393
- logout Delete the stored Granola session
394
-
395
- Options:
396
- --supabase <path> Path to supabase.json for auth login
397
- -h, --help Show help
398
- `;
399
- }
400
- const authCommand = {
401
- description: "Manage stored Granola sessions",
402
- flags: { help: { type: "boolean" } },
403
- help: authHelp,
404
- name: "auth",
405
- async run({ commandArgs, globalFlags }) {
406
- const [action] = commandArgs;
407
- switch (action) {
408
- case "login": return await login(globalFlags.supabase);
409
- case "logout": return await logout();
410
- case "status": return await status();
411
- case void 0:
412
- console.log(authHelp());
413
- return 1;
414
- default: throw new Error("invalid auth command: expected login, status, or logout");
415
- }
416
- }
417
- };
418
- async function login(supabaseFlag) {
419
- const supabasePath = typeof supabaseFlag === "string" && supabaseFlag.trim() || firstExistingPath(granolaSupabaseCandidates());
420
- if (!supabasePath) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
421
- if (!existsSync(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
422
- const sessionStore = createDefaultSessionStore();
423
- const session = await new SupabaseFileSessionSource(supabasePath).loadSession();
424
- await sessionStore.writeSession(session);
425
- console.log(`Imported Granola session from ${supabasePath}`);
426
- return 0;
427
- }
428
- async function status() {
429
- const session = await createDefaultSessionStore().readSession();
430
- if (!session) {
431
- console.log("No stored Granola session");
432
- return 1;
433
- }
434
- console.log("Stored Granola session");
435
- console.log(`Client ID: ${session.clientId}`);
436
- console.log(`Refresh token: ${session.refreshToken ? "available" : "missing"}`);
437
- console.log(`Sign-in method: ${session.signInMethod ?? "unknown"}`);
438
- return 0;
439
- }
440
- async function logout() {
441
- await createDefaultSessionStore().clearSession();
442
- console.log("Stored Granola session deleted");
443
- return 0;
444
- }
445
- //#endregion
446
- //#region src/cache.ts
447
- function parseCacheDocument(id, value) {
448
- const record = asRecord(value);
449
- if (!record) return;
437
+ //#region src/client/default-auth.ts
438
+ function hasStoredSession(session) {
439
+ return Boolean(session?.accessToken.trim());
440
+ }
441
+ function resolveActiveMode(options) {
442
+ if (options.preferredMode === "stored-session" && options.storedSessionAvailable) return "stored-session";
443
+ if (options.preferredMode === "supabase-file" && options.supabaseAvailable) return "supabase-file";
444
+ if (options.storedSessionAvailable) return "stored-session";
445
+ return "supabase-file";
446
+ }
447
+ function missingSupabaseError() {
448
+ return /* @__PURE__ */ new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
449
+ }
450
+ function buildDefaultGranolaAuthInfo(config, options = {}) {
451
+ const existsSyncImpl = options.existsSyncImpl ?? existsSync;
452
+ const session = options.session;
453
+ const storedSessionAvailable = hasStoredSession(session);
454
+ const supabasePath = config.supabase || void 0;
455
+ const supabaseAvailable = Boolean(supabasePath && existsSyncImpl(supabasePath));
450
456
  return {
451
- createdAt: stringValue(record.created_at),
452
- id,
453
- title: stringValue(record.title),
454
- updatedAt: stringValue(record.updated_at)
457
+ clientId: session?.clientId,
458
+ lastError: options.lastError,
459
+ mode: resolveActiveMode({
460
+ preferredMode: options.preferredMode,
461
+ storedSessionAvailable,
462
+ supabaseAvailable
463
+ }),
464
+ refreshAvailable: Boolean(session?.refreshToken?.trim()),
465
+ signInMethod: session?.signInMethod,
466
+ storedSessionAvailable,
467
+ supabaseAvailable,
468
+ supabasePath
455
469
  };
456
470
  }
457
- function parseTranscriptSegments(value) {
458
- if (!Array.isArray(value)) return;
459
- return value.flatMap((segment) => {
460
- const record = asRecord(segment);
461
- if (!record) return [];
462
- return [{
463
- documentId: stringValue(record.document_id),
464
- endTimestamp: stringValue(record.end_timestamp),
465
- id: stringValue(record.id),
466
- isFinal: Boolean(record.is_final),
467
- source: stringValue(record.source),
468
- startTimestamp: stringValue(record.start_timestamp),
469
- text: stringValue(record.text)
470
- }];
471
+ async function inspectDefaultGranolaAuth(config, options = {}) {
472
+ const sessionStore = options.sessionStore ?? createDefaultSessionStore();
473
+ const session = options.session ?? await sessionStore.readSession();
474
+ return buildDefaultGranolaAuthInfo(config, {
475
+ existsSyncImpl: options.existsSyncImpl,
476
+ lastError: options.lastError,
477
+ preferredMode: options.preferredMode,
478
+ session
471
479
  });
472
480
  }
473
- function parseCacheContents(contents) {
474
- const outer = parseJsonString(contents);
475
- if (!outer) throw new Error("failed to parse cache JSON");
476
- const rawCache = outer.cache;
477
- let cachePayload;
478
- if (typeof rawCache === "string") cachePayload = parseJsonString(rawCache);
479
- else cachePayload = asRecord(rawCache);
480
- const state = cachePayload ? asRecord(cachePayload.state) : void 0;
481
- if (!state) throw new Error("failed to parse cache state");
482
- const rawDocuments = asRecord(state.documents) ?? {};
483
- const rawTranscripts = asRecord(state.transcripts) ?? {};
484
- const documents = {};
485
- for (const [id, rawDocument] of Object.entries(rawDocuments)) {
486
- const document = parseCacheDocument(id, rawDocument);
487
- if (document) documents[id] = document;
481
+ var DefaultAuthController = class {
482
+ #lastError;
483
+ #preferredMode;
484
+ constructor(config, options = {}) {
485
+ this.config = config;
486
+ this.options = options;
488
487
  }
489
- const transcripts = {};
490
- for (const [id, rawTranscript] of Object.entries(rawTranscripts)) {
491
- const segments = parseTranscriptSegments(rawTranscript);
492
- if (segments) transcripts[id] = segments;
488
+ sessionStore() {
489
+ return this.options.sessionStore ?? createDefaultSessionStore();
493
490
  }
494
- return {
495
- documents,
496
- transcripts
497
- };
491
+ readSession() {
492
+ return this.sessionStore().readSession();
493
+ }
494
+ resolveSupabasePath(overridePath) {
495
+ const supabasePath = overridePath?.trim() || this.config.supabase || "";
496
+ if (!supabasePath) throw missingSupabaseError();
497
+ if (!(this.options.existsSyncImpl ?? existsSync)(supabasePath)) throw new Error(`supabase.json not found: ${supabasePath}`);
498
+ return supabasePath;
499
+ }
500
+ sessionSource(supabasePath) {
501
+ return this.options.sessionSourceFactory?.(supabasePath) ?? new SupabaseFileSessionSource(supabasePath);
502
+ }
503
+ async inspect() {
504
+ const session = await this.readSession();
505
+ return buildDefaultGranolaAuthInfo(this.config, {
506
+ existsSyncImpl: this.options.existsSyncImpl,
507
+ lastError: this.#lastError,
508
+ preferredMode: this.#preferredMode,
509
+ session
510
+ });
511
+ }
512
+ async login(options = {}) {
513
+ const supabasePath = this.resolveSupabasePath(options.supabasePath);
514
+ const session = await this.sessionSource(supabasePath).loadSession();
515
+ await this.sessionStore().writeSession(session);
516
+ this.#lastError = void 0;
517
+ this.#preferredMode = "stored-session";
518
+ return await this.inspect();
519
+ }
520
+ async logout() {
521
+ await this.sessionStore().clearSession();
522
+ this.#lastError = void 0;
523
+ this.#preferredMode = void 0;
524
+ return await this.inspect();
525
+ }
526
+ async refresh() {
527
+ const session = await this.readSession();
528
+ if (!hasStoredSession(session)) {
529
+ this.#lastError = "no stored Granola session found";
530
+ throw new Error(this.#lastError);
531
+ }
532
+ try {
533
+ const refreshed = await refreshGranolaSession(session, this.options.fetchImpl);
534
+ await this.sessionStore().writeSession(refreshed);
535
+ this.#lastError = void 0;
536
+ this.#preferredMode = "stored-session";
537
+ return await this.inspect();
538
+ } catch (error) {
539
+ this.#lastError = error instanceof Error ? error.message : String(error);
540
+ throw error;
541
+ }
542
+ }
543
+ async switchMode(mode) {
544
+ const state = await this.inspect();
545
+ if (mode === "stored-session" && !state.storedSessionAvailable) {
546
+ this.#lastError = "no stored Granola session found";
547
+ throw new Error(this.#lastError);
548
+ }
549
+ if (mode === "supabase-file") this.resolveSupabasePath();
550
+ this.#lastError = void 0;
551
+ this.#preferredMode = mode;
552
+ return await this.inspect();
553
+ }
554
+ };
555
+ function createDefaultGranolaAuthController(config, options = {}) {
556
+ return new DefaultAuthController(config, options);
498
557
  }
499
558
  //#endregion
500
559
  //#region src/client/parsers.ts
@@ -690,26 +749,16 @@ var AuthenticatedHttpClient = class {
690
749
  };
691
750
  //#endregion
692
751
  //#region src/client/default.ts
693
- async function inspectDefaultGranolaAuth(config) {
694
- const storedSession = await createDefaultSessionStore().readSession();
695
- const hasStoredSession = Boolean(storedSession?.accessToken.trim());
696
- return {
697
- mode: hasStoredSession ? "stored-session" : "supabase-file",
698
- storedSessionAvailable: hasStoredSession,
699
- supabasePath: config.supabase || void 0
700
- };
701
- }
702
- async function createDefaultGranolaRuntime(config, logger = console) {
752
+ async function createDefaultGranolaRuntime(config, logger = console, options = {}) {
753
+ const auth = await inspectDefaultGranolaAuth(config, { preferredMode: options.preferredMode });
754
+ if (!auth.storedSessionAvailable && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
755
+ if (!auth.storedSessionAvailable && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
703
756
  const sessionStore = createDefaultSessionStore();
704
- const auth = await inspectDefaultGranolaAuth(config);
705
- const hasStoredSession = auth.storedSessionAvailable;
706
- if (!hasStoredSession && !config.supabase) throw new Error(`supabase.json not found. Pass --supabase or create .granola.toml. Expected locations include: ${granolaSupabaseCandidates().join(", ")}`);
707
- if (!hasStoredSession && config.supabase && !existsSync(config.supabase)) throw new Error(`supabase.json not found: ${config.supabase}`);
708
757
  return {
709
758
  auth,
710
759
  client: new GranolaApiClient(new AuthenticatedHttpClient({
711
760
  logger,
712
- tokenProvider: hasStoredSession ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
761
+ tokenProvider: auth.mode === "stored-session" ? new StoredSessionTokenProvider(sessionStore, { source: config.supabase && existsSync(config.supabase) ? new SupabaseFileSessionSource(config.supabase) : void 0 }) : new CachedTokenProvider(new SupabaseFileTokenSource(config.supabase), new NoopTokenStore())
713
762
  }))
714
763
  };
715
764
  }
@@ -1675,6 +1724,24 @@ var GranolaApp = class {
1675
1724
  this.emitStateUpdate();
1676
1725
  return this.getState();
1677
1726
  }
1727
+ resetDocumentsState() {
1728
+ this.#granolaClient = void 0;
1729
+ this.#documents = void 0;
1730
+ this.#state.documents = {
1731
+ count: 0,
1732
+ loaded: false
1733
+ };
1734
+ }
1735
+ applyAuthState(auth, options = {}) {
1736
+ if (options.resetDocuments) this.resetDocumentsState();
1737
+ this.#state.auth = { ...auth };
1738
+ if (options.view) this.#state.ui = {
1739
+ ...this.#state.ui,
1740
+ view: options.view
1741
+ };
1742
+ this.emitStateUpdate();
1743
+ return { ...auth };
1744
+ }
1678
1745
  nowIso() {
1679
1746
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1680
1747
  }
@@ -1693,10 +1760,9 @@ var GranolaApp = class {
1693
1760
  return this.#granolaClient;
1694
1761
  }
1695
1762
  if (!this.deps.createGranolaClient) throw new Error("Granola API client is not configured");
1696
- const runtime = await this.deps.createGranolaClient();
1763
+ const runtime = await this.deps.createGranolaClient(this.#state.auth.mode);
1697
1764
  this.#granolaClient = runtime.client;
1698
- this.#state.auth = { ...runtime.auth };
1699
- this.emitStateUpdate();
1765
+ this.applyAuthState(runtime.auth);
1700
1766
  return this.#granolaClient;
1701
1767
  }
1702
1768
  missingCacheError() {
@@ -1751,6 +1817,64 @@ var GranolaApp = class {
1751
1817
  written: patch.written
1752
1818
  });
1753
1819
  }
1820
+ requireAuthController() {
1821
+ if (!this.deps.authController) throw new Error("Granola auth control is not configured");
1822
+ return this.deps.authController;
1823
+ }
1824
+ async inspectAuth() {
1825
+ if (!this.deps.authController) return { ...this.#state.auth };
1826
+ const auth = await this.deps.authController.inspect();
1827
+ return this.applyAuthState(auth, { view: "auth" });
1828
+ }
1829
+ async loginAuth(options = {}) {
1830
+ const controller = this.requireAuthController();
1831
+ try {
1832
+ const auth = await controller.login(options);
1833
+ return this.applyAuthState(auth, {
1834
+ resetDocuments: true,
1835
+ view: "auth"
1836
+ });
1837
+ } catch (error) {
1838
+ const auth = await controller.inspect();
1839
+ this.applyAuthState(auth, { view: "auth" });
1840
+ throw error;
1841
+ }
1842
+ }
1843
+ async logoutAuth() {
1844
+ const auth = await this.requireAuthController().logout();
1845
+ return this.applyAuthState(auth, {
1846
+ resetDocuments: true,
1847
+ view: "auth"
1848
+ });
1849
+ }
1850
+ async refreshAuth() {
1851
+ const controller = this.requireAuthController();
1852
+ try {
1853
+ const auth = await controller.refresh();
1854
+ return this.applyAuthState(auth, {
1855
+ resetDocuments: true,
1856
+ view: "auth"
1857
+ });
1858
+ } catch (error) {
1859
+ const auth = await controller.inspect();
1860
+ this.applyAuthState(auth, { view: "auth" });
1861
+ throw error;
1862
+ }
1863
+ }
1864
+ async switchAuthMode(mode) {
1865
+ const controller = this.requireAuthController();
1866
+ try {
1867
+ const auth = await controller.switchMode(mode);
1868
+ return this.applyAuthState(auth, {
1869
+ resetDocuments: true,
1870
+ view: "auth"
1871
+ });
1872
+ } catch (error) {
1873
+ const auth = await controller.inspect();
1874
+ this.applyAuthState(auth, { view: "auth" });
1875
+ throw error;
1876
+ }
1877
+ }
1754
1878
  async listDocuments() {
1755
1879
  if (this.#documents) return this.#documents;
1756
1880
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
@@ -1950,11 +2074,13 @@ var GranolaApp = class {
1950
2074
  };
1951
2075
  async function createGranolaApp(config, options = {}) {
1952
2076
  const auth = await inspectDefaultGranolaAuth(config);
2077
+ const authController = createDefaultGranolaAuthController(config);
1953
2078
  const exportJobStore = createDefaultExportJobStore();
1954
2079
  return new GranolaApp(config, {
1955
2080
  auth,
2081
+ authController,
1956
2082
  cacheLoader: loadOptionalGranolaCache,
1957
- createGranolaClient: async () => await createDefaultGranolaRuntime(config, options.logger),
2083
+ createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
1958
2084
  exportJobs: await exportJobStore.readJobs(),
1959
2085
  exportJobStore,
1960
2086
  now: options.now
@@ -2076,6 +2202,103 @@ async function waitForShutdown(close) {
2076
2202
  });
2077
2203
  }
2078
2204
  //#endregion
2205
+ //#region src/commands/auth.ts
2206
+ function authHelp() {
2207
+ return `Granola auth
2208
+
2209
+ Usage:
2210
+ granola auth <login|status|logout|refresh|use> [options]
2211
+
2212
+ Subcommands:
2213
+ login Import credentials from the Granola desktop app
2214
+ status Show the current Granola auth state
2215
+ logout Delete the stored Granola session
2216
+ refresh Refresh the stored Granola session
2217
+ use <stored|supabase>
2218
+ Switch the active auth source for this toolkit instance
2219
+
2220
+ Options:
2221
+ --supabase <path> Path to supabase.json for auth login
2222
+ --config <path> Path to .granola.toml
2223
+ --debug Enable debug logging
2224
+ -h, --help Show help
2225
+ `;
2226
+ }
2227
+ function formatAuthSource(mode) {
2228
+ return mode === "stored-session" ? "stored session" : "supabase.json";
2229
+ }
2230
+ function printAuthState(state) {
2231
+ console.log(`Active source: ${formatAuthSource(state.mode)}`);
2232
+ console.log(`Stored session: ${state.storedSessionAvailable ? "available" : "missing"}`);
2233
+ console.log(`supabase.json: ${state.supabaseAvailable ? "available" : "missing"}`);
2234
+ if (state.supabasePath) console.log(`supabase path: ${state.supabasePath}`);
2235
+ if (state.clientId) console.log(`Client ID: ${state.clientId}`);
2236
+ console.log(`Refresh token: ${state.refreshAvailable ? "available" : "missing"}`);
2237
+ if (state.signInMethod) console.log(`Sign-in method: ${state.signInMethod}`);
2238
+ if (state.lastError) console.log(`Last error: ${state.lastError}`);
2239
+ }
2240
+ const authCommand = {
2241
+ description: "Manage stored Granola sessions",
2242
+ flags: { help: { type: "boolean" } },
2243
+ help: authHelp,
2244
+ name: "auth",
2245
+ async run({ commandArgs, globalFlags }) {
2246
+ const [action, value] = commandArgs;
2247
+ const config = await loadConfig({
2248
+ globalFlags,
2249
+ subcommandFlags: {}
2250
+ });
2251
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2252
+ debug(config.debug, "supabase", config.supabase);
2253
+ const app = await createGranolaApp(config);
2254
+ switch (action) {
2255
+ case "login": {
2256
+ const state = await app.loginAuth();
2257
+ console.log(`Imported Granola session from ${state.supabasePath ?? "desktop app defaults"}`);
2258
+ printAuthState(state);
2259
+ return 0;
2260
+ }
2261
+ case "logout": {
2262
+ const state = await app.logoutAuth();
2263
+ console.log("Stored Granola session deleted");
2264
+ printAuthState(state);
2265
+ return 0;
2266
+ }
2267
+ case "refresh": {
2268
+ const state = await app.refreshAuth();
2269
+ console.log("Stored Granola session refreshed");
2270
+ printAuthState(state);
2271
+ return 0;
2272
+ }
2273
+ case "status": {
2274
+ const state = await app.inspectAuth();
2275
+ printAuthState(state);
2276
+ return state.storedSessionAvailable ? 0 : 1;
2277
+ }
2278
+ case "use": {
2279
+ const mode = resolveAuthMode(value);
2280
+ const state = await app.switchAuthMode(mode);
2281
+ console.log(`Switched auth source to ${formatAuthSource(state.mode)}`);
2282
+ printAuthState(state);
2283
+ return 0;
2284
+ }
2285
+ case void 0:
2286
+ console.log(authHelp());
2287
+ return 1;
2288
+ default: throw new Error("invalid auth command: expected login, status, logout, refresh, or use");
2289
+ }
2290
+ }
2291
+ };
2292
+ function resolveAuthMode(value) {
2293
+ switch (value) {
2294
+ case "stored":
2295
+ case "stored-session": return "stored-session";
2296
+ case "supabase":
2297
+ case "supabase-file": return "supabase-file";
2298
+ default: throw new Error("invalid auth mode: expected stored or supabase");
2299
+ }
2300
+ }
2301
+ //#endregion
2079
2302
  //#region src/commands/exports.ts
2080
2303
  function exportsHelp() {
2081
2304
  return `Granola exports
@@ -2460,6 +2683,7 @@ const state = {
2460
2683
 
2461
2684
  const els = {
2462
2685
  appState: document.querySelector("[data-app-state]"),
2686
+ authPanel: document.querySelector("[data-auth-panel]"),
2463
2687
  detailBody: document.querySelector("[data-detail-body]"),
2464
2688
  detailMeta: document.querySelector("[data-detail-meta]"),
2465
2689
  empty: document.querySelector("[data-empty]"),
@@ -2526,6 +2750,7 @@ function renderWorkspaceTabs() {
2526
2750
  function renderAppState() {
2527
2751
  if (!state.appState) {
2528
2752
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2753
+ els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2529
2754
  return;
2530
2755
  }
2531
2756
 
@@ -2548,9 +2773,75 @@ function renderAppState() {
2548
2773
  "</div>",
2549
2774
  ].join("");
2550
2775
 
2776
+ renderAuthPanel();
2551
2777
  renderExportJobs();
2552
2778
  }
2553
2779
 
2780
+ function authActionButton(label, action, disabled) {
2781
+ return (
2782
+ '<button class="button button--secondary" data-auth-action="' +
2783
+ escapeHtml(action) +
2784
+ '"' +
2785
+ (disabled ? " disabled" : "") +
2786
+ ">" +
2787
+ escapeHtml(label) +
2788
+ "</button>"
2789
+ );
2790
+ }
2791
+
2792
+ function authModeButton(label, mode, disabled) {
2793
+ return (
2794
+ '<button class="button button--secondary" data-auth-mode="' +
2795
+ escapeHtml(mode) +
2796
+ '"' +
2797
+ (disabled ? " disabled" : "") +
2798
+ ">" +
2799
+ escapeHtml(label) +
2800
+ "</button>"
2801
+ );
2802
+ }
2803
+
2804
+ function renderAuthPanel() {
2805
+ const auth = state.appState?.auth;
2806
+ if (!auth) {
2807
+ els.authPanel.innerHTML = '<div class="auth-card"><div class="auth-card__meta">Auth state unavailable.</div></div>';
2808
+ return;
2809
+ }
2810
+
2811
+ const activeSource = auth.mode === "stored-session" ? "Stored session" : "supabase.json";
2812
+ const lastError = auth.lastError
2813
+ ? '<div class="auth-card__meta auth-card__error">' + escapeHtml(auth.lastError) + "</div>"
2814
+ : "";
2815
+
2816
+ els.authPanel.innerHTML = [
2817
+ '<div class="auth-card">',
2818
+ '<div class="status-grid">',
2819
+ '<div><span class="status-label">Active</span><strong>' + escapeHtml(activeSource) + "</strong></div>",
2820
+ '<div><span class="status-label">Stored</span><strong>' + escapeHtml(auth.storedSessionAvailable ? "available" : "missing") + "</strong></div>",
2821
+ '<div><span class="status-label">supabase.json</span><strong>' + escapeHtml(auth.supabaseAvailable ? "available" : "missing") + "</strong></div>",
2822
+ '<div><span class="status-label">Refresh</span><strong>' + escapeHtml(auth.refreshAvailable ? "available" : "missing") + "</strong></div>",
2823
+ "</div>",
2824
+ auth.clientId
2825
+ ? '<div class="auth-card__meta">Client ID: ' + escapeHtml(auth.clientId) + "</div>"
2826
+ : "",
2827
+ auth.signInMethod
2828
+ ? '<div class="auth-card__meta">Sign-in method: ' + escapeHtml(auth.signInMethod) + "</div>"
2829
+ : "",
2830
+ auth.supabasePath
2831
+ ? '<div class="auth-card__meta">supabase path: ' + escapeHtml(auth.supabasePath) + "</div>"
2832
+ : "",
2833
+ lastError,
2834
+ '<div class="auth-card__actions">',
2835
+ authActionButton("Import desktop session", "login", !auth.supabaseAvailable),
2836
+ authActionButton("Refresh stored session", "refresh", !auth.storedSessionAvailable || !auth.refreshAvailable),
2837
+ authModeButton("Use stored session", "stored-session", !auth.storedSessionAvailable || auth.mode === "stored-session"),
2838
+ authModeButton("Use supabase.json", "supabase-file", !auth.supabaseAvailable || auth.mode === "supabase-file"),
2839
+ authActionButton("Sign out", "logout", !auth.storedSessionAvailable),
2840
+ "</div>",
2841
+ "</div>",
2842
+ ].join("");
2843
+ }
2844
+
2554
2845
  function renderMeetingList() {
2555
2846
  if (state.listError) {
2556
2847
  els.list.innerHTML =
@@ -2805,12 +3096,24 @@ async function quickOpenMeeting() {
2805
3096
 
2806
3097
  async function refreshAll() {
2807
3098
  setStatus("Refreshing…", "busy");
2808
- const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2809
- state.appState = appState;
3099
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status"), loadMeetings()]);
3100
+ state.appState = {
3101
+ ...appState,
3102
+ auth: authState,
3103
+ };
2810
3104
  renderAppState();
2811
3105
  setStatus("Connected", "ok");
2812
3106
  }
2813
3107
 
3108
+ async function syncAuthState() {
3109
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
3110
+ state.appState = {
3111
+ ...appState,
3112
+ auth: authState,
3113
+ };
3114
+ renderAppState();
3115
+ }
3116
+
2814
3117
  async function exportNotes() {
2815
3118
  setStatus("Exporting notes…", "busy");
2816
3119
  await fetchJson("/exports/notes", {
@@ -2845,6 +3148,62 @@ async function rerunJob(id) {
2845
3148
  }
2846
3149
  }
2847
3150
 
3151
+ async function loginAuth() {
3152
+ setStatus("Importing desktop session…", "busy");
3153
+ try {
3154
+ await fetchJson("/auth/login", { method: "POST" });
3155
+ await refreshAll();
3156
+ } catch (error) {
3157
+ await syncAuthState();
3158
+ setStatus("Auth import failed", "error");
3159
+ state.detailError = error instanceof Error ? error.message : String(error);
3160
+ renderMeetingDetail();
3161
+ }
3162
+ }
3163
+
3164
+ async function logoutAuth() {
3165
+ setStatus("Signing out…", "busy");
3166
+ try {
3167
+ await fetchJson("/auth/logout", { method: "POST" });
3168
+ await refreshAll();
3169
+ } catch (error) {
3170
+ await syncAuthState();
3171
+ setStatus("Sign out failed", "error");
3172
+ state.detailError = error instanceof Error ? error.message : String(error);
3173
+ renderMeetingDetail();
3174
+ }
3175
+ }
3176
+
3177
+ async function refreshAuth() {
3178
+ setStatus("Refreshing session…", "busy");
3179
+ try {
3180
+ await fetchJson("/auth/refresh", { method: "POST" });
3181
+ await refreshAll();
3182
+ } catch (error) {
3183
+ await syncAuthState();
3184
+ setStatus("Refresh failed", "error");
3185
+ state.detailError = error instanceof Error ? error.message : String(error);
3186
+ renderMeetingDetail();
3187
+ }
3188
+ }
3189
+
3190
+ async function switchAuthMode(mode) {
3191
+ setStatus("Switching auth source…", "busy");
3192
+ try {
3193
+ await fetchJson("/auth/mode", {
3194
+ body: JSON.stringify({ mode }),
3195
+ headers: { "content-type": "application/json" },
3196
+ method: "POST",
3197
+ });
3198
+ await refreshAll();
3199
+ } catch (error) {
3200
+ await syncAuthState();
3201
+ setStatus("Switch failed", "error");
3202
+ state.detailError = error instanceof Error ? error.message : String(error);
3203
+ renderMeetingDetail();
3204
+ }
3205
+ }
3206
+
2848
3207
  els.list.addEventListener("click", (event) => {
2849
3208
  if (!(event.target instanceof Element)) {
2850
3209
  return;
@@ -2868,6 +3227,36 @@ els.jobsList.addEventListener("click", (event) => {
2868
3227
  void rerunJob(button.dataset.rerunJobId);
2869
3228
  });
2870
3229
 
3230
+ els.authPanel.addEventListener("click", (event) => {
3231
+ if (!(event.target instanceof Element)) {
3232
+ return;
3233
+ }
3234
+
3235
+ const actionButton = event.target.closest("[data-auth-action]");
3236
+ if (actionButton) {
3237
+ switch (actionButton.dataset.authAction) {
3238
+ case "login":
3239
+ void loginAuth();
3240
+ return;
3241
+ case "logout":
3242
+ void logoutAuth();
3243
+ return;
3244
+ case "refresh":
3245
+ void refreshAuth();
3246
+ return;
3247
+ default:
3248
+ return;
3249
+ }
3250
+ }
3251
+
3252
+ const modeButton = event.target.closest("[data-auth-mode]");
3253
+ if (!modeButton) {
3254
+ return;
3255
+ }
3256
+
3257
+ void switchAuthMode(modeButton.dataset.authMode);
3258
+ });
3259
+
2871
3260
  els.refreshButton.addEventListener("click", () => {
2872
3261
  void refreshAll();
2873
3262
  });
@@ -3066,6 +3455,13 @@ const granolaWebMarkup = String.raw`
3066
3455
  </div>
3067
3456
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3068
3457
  </section>
3458
+ <section class="auth-panel">
3459
+ <div class="auth-panel__head">
3460
+ <h3>Auth Session</h3>
3461
+ <p>Inspect, refresh, and switch between stored session and <code>supabase.json</code>.</p>
3462
+ </div>
3463
+ <div class="auth-panel__body" data-auth-panel></div>
3464
+ </section>
3069
3465
  <section class="jobs-panel">
3070
3466
  <div class="jobs-panel__head">
3071
3467
  <h3>Recent Export Jobs</h3>
@@ -3295,10 +3691,12 @@ body {
3295
3691
  width: min(440px, 100%);
3296
3692
  }
3297
3693
 
3694
+ .auth-panel,
3298
3695
  .jobs-panel {
3299
3696
  padding: 0 24px 18px;
3300
3697
  }
3301
3698
 
3699
+ .auth-panel__head h3,
3302
3700
  .jobs-panel__head h3 {
3303
3701
  margin: 0;
3304
3702
  font-size: 0.92rem;
@@ -3306,12 +3704,43 @@ body {
3306
3704
  text-transform: uppercase;
3307
3705
  }
3308
3706
 
3707
+ .auth-panel__head p,
3309
3708
  .jobs-panel__head p {
3310
3709
  margin: 6px 0 0;
3311
3710
  color: var(--muted);
3312
3711
  font-size: 0.9rem;
3313
3712
  }
3314
3713
 
3714
+ .auth-panel__body {
3715
+ display: grid;
3716
+ gap: 12px;
3717
+ margin-top: 14px;
3718
+ }
3719
+
3720
+ .auth-card {
3721
+ display: grid;
3722
+ gap: 12px;
3723
+ padding: 14px 16px;
3724
+ border: 1px solid var(--line);
3725
+ border-radius: 18px;
3726
+ background: rgba(255, 255, 255, 0.72);
3727
+ }
3728
+
3729
+ .auth-card__meta {
3730
+ color: var(--muted);
3731
+ font-size: 0.9rem;
3732
+ }
3733
+
3734
+ .auth-card__actions {
3735
+ display: flex;
3736
+ flex-wrap: wrap;
3737
+ gap: 8px;
3738
+ }
3739
+
3740
+ .auth-card__error {
3741
+ color: var(--error);
3742
+ }
3743
+
3315
3744
  .jobs-list {
3316
3745
  display: grid;
3317
3746
  gap: 10px;
@@ -3419,6 +3848,11 @@ body {
3419
3848
  cursor: pointer;
3420
3849
  }
3421
3850
 
3851
+ .button:disabled {
3852
+ cursor: not-allowed;
3853
+ opacity: 0.56;
3854
+ }
3855
+
3422
3856
  .button--primary {
3423
3857
  background: var(--ink);
3424
3858
  color: white;
@@ -3566,6 +4000,13 @@ function parseMeetingSort(value) {
3566
4000
  default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
3567
4001
  }
3568
4002
  }
4003
+ function parseAuthMode(value) {
4004
+ switch (value) {
4005
+ case "stored-session":
4006
+ case "supabase-file": return value;
4007
+ default: throw new Error("invalid auth mode: expected stored-session or supabase-file");
4008
+ }
4009
+ }
3569
4010
  function sendJson(response, body, init = {}) {
3570
4011
  const payload = `${JSON.stringify(body, null, 2)}\n`;
3571
4012
  response.writeHead(init.status ?? 200, {
@@ -3648,6 +4089,10 @@ async function startGranolaServer(app, options = {}) {
3648
4089
  sendJson(response, app.getState());
3649
4090
  return;
3650
4091
  }
4092
+ if (method === "GET" && path === "/auth/status") {
4093
+ sendJson(response, await app.inspectAuth());
4094
+ return;
4095
+ }
3651
4096
  if (method === "GET" && path === "/events") {
3652
4097
  response.writeHead(200, {
3653
4098
  "cache-control": "no-cache, no-transform",
@@ -3701,6 +4146,25 @@ async function startGranolaServer(app, options = {}) {
3701
4146
  sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
3702
4147
  return;
3703
4148
  }
4149
+ if (method === "POST" && path === "/auth/login") {
4150
+ const body = await readJsonBody(request);
4151
+ const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
4152
+ sendJson(response, await app.loginAuth({ supabasePath }));
4153
+ return;
4154
+ }
4155
+ if (method === "POST" && path === "/auth/logout") {
4156
+ sendJson(response, await app.logoutAuth());
4157
+ return;
4158
+ }
4159
+ if (method === "POST" && path === "/auth/refresh") {
4160
+ sendJson(response, await app.refreshAuth());
4161
+ return;
4162
+ }
4163
+ if (method === "POST" && path === "/auth/mode") {
4164
+ const body = await readJsonBody(request);
4165
+ sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
4166
+ return;
4167
+ }
3704
4168
  if (method === "POST" && path === "/exports/notes") {
3705
4169
  const body = await readJsonBody(request);
3706
4170
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
@@ -3803,11 +4267,16 @@ const serveCommand = {
3803
4267
  console.log(`Granola server listening on ${server.url.href}`);
3804
4268
  console.log("Endpoints:");
3805
4269
  console.log(" GET /health");
4270
+ console.log(" GET /auth/status");
3806
4271
  console.log(" GET /state");
3807
4272
  console.log(" GET /events");
3808
4273
  console.log(" GET /meetings");
3809
4274
  console.log(" GET /meetings/:id");
3810
4275
  console.log(" GET /exports/jobs");
4276
+ console.log(" POST /auth/login");
4277
+ console.log(" POST /auth/logout");
4278
+ console.log(" POST /auth/mode");
4279
+ console.log(" POST /auth/refresh");
3811
4280
  console.log(" POST /exports/notes");
3812
4281
  console.log(" POST /exports/jobs/:id/rerun");
3813
4282
  console.log(" POST /exports/transcripts");
@@ -3964,11 +4433,16 @@ const commands = [
3964
4433
  console.log("Routes:");
3965
4434
  console.log(" GET /");
3966
4435
  console.log(" GET /health");
4436
+ console.log(" GET /auth/status");
3967
4437
  console.log(" GET /state");
3968
4438
  console.log(" GET /events");
3969
4439
  console.log(" GET /meetings");
3970
4440
  console.log(" GET /meetings/:id");
3971
4441
  console.log(" GET /exports/jobs");
4442
+ console.log(" POST /auth/login");
4443
+ console.log(" POST /auth/logout");
4444
+ console.log(" POST /auth/mode");
4445
+ console.log(" POST /auth/refresh");
3972
4446
  console.log(" POST /exports/notes");
3973
4447
  console.log(" POST /exports/jobs/:id/rerun");
3974
4448
  console.log(" POST /exports/transcripts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",