granola-toolkit 0.22.0 → 0.24.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 +34 -1
  2. package/dist/cli.js +838 -151
  3. package/package.json +1 -1
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
  }
@@ -1370,6 +1419,20 @@ function compareMeetingDocumentsBySort(left, right, sort) {
1370
1419
  default: return compareMeetingDocuments(left, right);
1371
1420
  }
1372
1421
  }
1422
+ function compareMeetingSummariesByUpdated(left, right) {
1423
+ return compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareTimestampsDescending(left.createdAt, right.createdAt) || compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id);
1424
+ }
1425
+ function compareMeetingSummariesByTitle(left, right) {
1426
+ return compareStrings(left.title || left.id, right.title || right.id) || compareTimestampsDescending(left.updatedAt, right.updatedAt) || compareStrings(left.id, right.id);
1427
+ }
1428
+ function compareMeetingSummariesBySort(left, right, sort) {
1429
+ switch (sort) {
1430
+ case "title-asc": return compareMeetingSummariesByTitle(left, right);
1431
+ case "title-desc": return -compareMeetingSummariesByTitle(left, right);
1432
+ case "updated-asc": return -compareMeetingSummariesByUpdated(left, right);
1433
+ default: return compareMeetingSummariesByUpdated(left, right);
1434
+ }
1435
+ }
1373
1436
  function serialiseNote(note) {
1374
1437
  return {
1375
1438
  content: note.content,
@@ -1433,6 +1496,15 @@ function matchesMeetingSearch(document, search) {
1433
1496
  ...document.tags
1434
1497
  ].some((value) => value.toLowerCase().includes(query));
1435
1498
  }
1499
+ function matchesMeetingSummarySearch(meeting, search) {
1500
+ const query = search.trim().toLowerCase();
1501
+ if (!query) return true;
1502
+ return [
1503
+ meeting.id,
1504
+ meeting.title,
1505
+ ...meeting.tags
1506
+ ].some((value) => value.toLowerCase().includes(query));
1507
+ }
1436
1508
  function parseDateFilter(value, label) {
1437
1509
  const trimmed = value?.trim();
1438
1510
  if (!trimmed) return;
@@ -1450,6 +1522,15 @@ function matchesUpdatedRange(document, updatedFrom, updatedTo) {
1450
1522
  if (to != null && updatedAt > to) return false;
1451
1523
  return true;
1452
1524
  }
1525
+ function matchesMeetingSummaryUpdatedRange(meeting, updatedFrom, updatedTo) {
1526
+ const from = parseDateFilter(updatedFrom, "updatedFrom");
1527
+ const to = parseDateFilter(updatedTo, "updatedTo");
1528
+ const updatedAt = parseTimestamp(meeting.updatedAt || meeting.createdAt);
1529
+ if (updatedAt == null) return from == null && to == null;
1530
+ if (from != null && updatedAt < from) return false;
1531
+ if (to != null && updatedAt > to) return false;
1532
+ return true;
1533
+ }
1453
1534
  function truncate(value, width) {
1454
1535
  if (value.length <= width) return value.padEnd(width);
1455
1536
  return `${value.slice(0, Math.max(0, width - 1))}…`;
@@ -1505,6 +1586,14 @@ function listMeetings(documents, options = {}) {
1505
1586
  const sort = options.sort ?? "updated-desc";
1506
1587
  return documents.filter((document) => options.search ? matchesMeetingSearch(document, options.search) : true).filter((document) => matchesUpdatedRange(document, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingDocumentsBySort(left, right, sort)).slice(0, limit).map((document) => buildMeetingSummary(document, options.cacheData));
1507
1588
  }
1589
+ function filterMeetingSummaries(meetings, options = {}) {
1590
+ const limit = options.limit ?? 20;
1591
+ const sort = options.sort ?? "updated-desc";
1592
+ return meetings.filter((meeting) => options.search ? matchesMeetingSummarySearch(meeting, options.search) : true).filter((meeting) => matchesMeetingSummaryUpdatedRange(meeting, options.updatedFrom, options.updatedTo)).sort((left, right) => compareMeetingSummariesBySort(left, right, sort)).slice(0, limit).map((meeting) => ({
1593
+ ...meeting,
1594
+ tags: [...meeting.tags]
1595
+ }));
1596
+ }
1508
1597
  function resolveMeetingQuery(documents, query) {
1509
1598
  const trimmed = query.trim();
1510
1599
  if (!trimmed) throw new Error("meeting query is required");
@@ -1591,6 +1680,48 @@ function renderMeetingTranscript(document, cacheData, format = "text") {
1591
1680
  return renderTranscriptExport(transcript, format);
1592
1681
  }
1593
1682
  //#endregion
1683
+ //#region src/meeting-index.ts
1684
+ const MEETING_INDEX_VERSION = 1;
1685
+ var FileMeetingIndexStore = class {
1686
+ constructor(filePath = defaultMeetingIndexFilePath()) {
1687
+ this.filePath = filePath;
1688
+ }
1689
+ async readIndex() {
1690
+ try {
1691
+ const parsed = parseJsonString(await readFile(this.filePath, "utf8"));
1692
+ if (!parsed || parsed.version !== MEETING_INDEX_VERSION || !Array.isArray(parsed.meetings)) return [];
1693
+ return parsed.meetings.map((meeting) => ({
1694
+ ...meeting,
1695
+ tags: [...meeting.tags]
1696
+ }));
1697
+ } catch {
1698
+ return [];
1699
+ }
1700
+ }
1701
+ async writeIndex(meetings) {
1702
+ await mkdir(dirname(this.filePath), { recursive: true });
1703
+ const payload = {
1704
+ meetings: meetings.map((meeting) => ({
1705
+ ...meeting,
1706
+ tags: [...meeting.tags]
1707
+ })),
1708
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1709
+ version: MEETING_INDEX_VERSION
1710
+ };
1711
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
1712
+ encoding: "utf8",
1713
+ mode: 384
1714
+ });
1715
+ }
1716
+ };
1717
+ function defaultMeetingIndexFilePath() {
1718
+ const home = homedir();
1719
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "meeting-index.json") : join(home, ".config", "granola-toolkit", "meeting-index.json");
1720
+ }
1721
+ function createDefaultMeetingIndexStore() {
1722
+ return new FileMeetingIndexStore();
1723
+ }
1724
+ //#endregion
1594
1725
  //#region src/app/core.ts
1595
1726
  function transcriptCount(cacheData) {
1596
1727
  return Object.values(cacheData.transcripts).filter((segments) => segments.length > 0).length;
@@ -1616,6 +1747,7 @@ function cloneState(state) {
1616
1747
  notes: cloneExportState(state.exports.notes),
1617
1748
  transcripts: cloneExportState(state.exports.transcripts)
1618
1749
  },
1750
+ index: { ...state.index },
1619
1751
  ui: { ...state.ui }
1620
1752
  };
1621
1753
  }
@@ -1639,6 +1771,12 @@ function defaultState(config, auth, surface) {
1639
1771
  loaded: false
1640
1772
  },
1641
1773
  exports: { jobs: [] },
1774
+ index: {
1775
+ available: false,
1776
+ filePath: defaultMeetingIndexFilePath(),
1777
+ loaded: false,
1778
+ meetingCount: 0
1779
+ },
1642
1780
  ui: {
1643
1781
  surface,
1644
1782
  view: "idle"
@@ -1650,13 +1788,26 @@ var GranolaApp = class {
1650
1788
  #cacheResolved = false;
1651
1789
  #granolaClient;
1652
1790
  #documents;
1791
+ #meetingIndex;
1653
1792
  #listeners = /* @__PURE__ */ new Set();
1793
+ #refreshingMeetingIndex;
1654
1794
  #state;
1655
1795
  constructor(config, deps, options = {}) {
1656
1796
  this.config = config;
1657
1797
  this.deps = deps;
1658
1798
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
1659
1799
  this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
1800
+ this.#meetingIndex = (deps.meetingIndex ?? []).map((meeting) => ({
1801
+ ...meeting,
1802
+ tags: [...meeting.tags]
1803
+ }));
1804
+ this.#state.index = {
1805
+ available: this.#meetingIndex.length > 0,
1806
+ filePath: defaultMeetingIndexFilePath(),
1807
+ loaded: this.#meetingIndex.length > 0,
1808
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1809
+ meetingCount: this.#meetingIndex.length
1810
+ };
1660
1811
  }
1661
1812
  getState() {
1662
1813
  return cloneState(this.#state);
@@ -1675,6 +1826,59 @@ var GranolaApp = class {
1675
1826
  this.emitStateUpdate();
1676
1827
  return this.getState();
1677
1828
  }
1829
+ resetDocumentsState() {
1830
+ this.#granolaClient = void 0;
1831
+ this.#documents = void 0;
1832
+ this.#state.documents = {
1833
+ count: 0,
1834
+ loaded: false
1835
+ };
1836
+ }
1837
+ applyAuthState(auth, options = {}) {
1838
+ if (options.resetDocuments) this.resetDocumentsState();
1839
+ this.#state.auth = { ...auth };
1840
+ if (options.view) this.#state.ui = {
1841
+ ...this.#state.ui,
1842
+ view: options.view
1843
+ };
1844
+ this.emitStateUpdate();
1845
+ return { ...auth };
1846
+ }
1847
+ async persistMeetingIndex(meetings) {
1848
+ this.#meetingIndex = meetings.map((meeting) => ({
1849
+ ...meeting,
1850
+ tags: [...meeting.tags]
1851
+ }));
1852
+ this.#state.index = {
1853
+ available: this.#meetingIndex.length > 0,
1854
+ filePath: this.#state.index.filePath,
1855
+ loaded: this.#meetingIndex.length > 0,
1856
+ loadedAt: this.#meetingIndex.length > 0 ? this.nowIso() : void 0,
1857
+ meetingCount: this.#meetingIndex.length
1858
+ };
1859
+ if (this.deps.meetingIndexStore) await this.deps.meetingIndexStore.writeIndex(this.#meetingIndex);
1860
+ this.emitStateUpdate();
1861
+ }
1862
+ async refreshMeetingIndexFromLiveData() {
1863
+ const cacheData = await this.loadCache();
1864
+ const documents = await this.listDocuments();
1865
+ const meetings = listMeetings(documents, {
1866
+ cacheData,
1867
+ limit: documents.length || 1,
1868
+ sort: "updated-desc"
1869
+ });
1870
+ await this.persistMeetingIndex(meetings);
1871
+ }
1872
+ triggerMeetingIndexRefresh() {
1873
+ if (this.#refreshingMeetingIndex) return;
1874
+ this.#refreshingMeetingIndex = (async () => {
1875
+ try {
1876
+ await this.refreshMeetingIndexFromLiveData();
1877
+ } catch {} finally {
1878
+ this.#refreshingMeetingIndex = void 0;
1879
+ }
1880
+ })();
1881
+ }
1678
1882
  nowIso() {
1679
1883
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1680
1884
  }
@@ -1693,10 +1897,9 @@ var GranolaApp = class {
1693
1897
  return this.#granolaClient;
1694
1898
  }
1695
1899
  if (!this.deps.createGranolaClient) throw new Error("Granola API client is not configured");
1696
- const runtime = await this.deps.createGranolaClient();
1900
+ const runtime = await this.deps.createGranolaClient(this.#state.auth.mode);
1697
1901
  this.#granolaClient = runtime.client;
1698
- this.#state.auth = { ...runtime.auth };
1699
- this.emitStateUpdate();
1902
+ this.applyAuthState(runtime.auth);
1700
1903
  return this.#granolaClient;
1701
1904
  }
1702
1905
  missingCacheError() {
@@ -1751,7 +1954,74 @@ var GranolaApp = class {
1751
1954
  written: patch.written
1752
1955
  });
1753
1956
  }
1754
- async listDocuments() {
1957
+ requireAuthController() {
1958
+ if (!this.deps.authController) throw new Error("Granola auth control is not configured");
1959
+ return this.deps.authController;
1960
+ }
1961
+ async inspectAuth() {
1962
+ if (!this.deps.authController) return { ...this.#state.auth };
1963
+ const auth = await this.deps.authController.inspect();
1964
+ return this.applyAuthState(auth, { view: "auth" });
1965
+ }
1966
+ async loginAuth(options = {}) {
1967
+ const controller = this.requireAuthController();
1968
+ try {
1969
+ const auth = await controller.login(options);
1970
+ return this.applyAuthState(auth, {
1971
+ resetDocuments: true,
1972
+ view: "auth"
1973
+ });
1974
+ } catch (error) {
1975
+ const auth = await controller.inspect();
1976
+ this.applyAuthState(auth, { view: "auth" });
1977
+ throw error;
1978
+ }
1979
+ }
1980
+ async logoutAuth() {
1981
+ const auth = await this.requireAuthController().logout();
1982
+ return this.applyAuthState(auth, {
1983
+ resetDocuments: true,
1984
+ view: "auth"
1985
+ });
1986
+ }
1987
+ async refreshAuth() {
1988
+ const controller = this.requireAuthController();
1989
+ try {
1990
+ const auth = await controller.refresh();
1991
+ return this.applyAuthState(auth, {
1992
+ resetDocuments: true,
1993
+ view: "auth"
1994
+ });
1995
+ } catch (error) {
1996
+ const auth = await controller.inspect();
1997
+ this.applyAuthState(auth, { view: "auth" });
1998
+ throw error;
1999
+ }
2000
+ }
2001
+ async switchAuthMode(mode) {
2002
+ const controller = this.requireAuthController();
2003
+ try {
2004
+ const auth = await controller.switchMode(mode);
2005
+ return this.applyAuthState(auth, {
2006
+ resetDocuments: true,
2007
+ view: "auth"
2008
+ });
2009
+ } catch (error) {
2010
+ const auth = await controller.inspect();
2011
+ this.applyAuthState(auth, { view: "auth" });
2012
+ throw error;
2013
+ }
2014
+ }
2015
+ async listDocuments(options = {}) {
2016
+ if (options.forceRefresh) {
2017
+ this.#granolaClient = void 0;
2018
+ this.#documents = void 0;
2019
+ this.#state.documents = {
2020
+ count: 0,
2021
+ loaded: false
2022
+ };
2023
+ this.emitStateUpdate();
2024
+ }
1755
2025
  if (this.#documents) return this.#documents;
1756
2026
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
1757
2027
  this.#documents = documents;
@@ -1791,15 +2061,41 @@ var GranolaApp = class {
1791
2061
  return cacheData;
1792
2062
  }
1793
2063
  async listMeetings(options = {}) {
1794
- const meetings = listMeetings(await this.listDocuments(), {
1795
- cacheData: await this.loadCache(),
2064
+ const preferIndex = options.preferIndex ?? (this.#state.ui.surface === "web" || this.#state.ui.surface === "server");
2065
+ if (!options.forceRefresh && preferIndex && !this.#documents && this.#meetingIndex.length > 0) {
2066
+ const meetings = filterMeetingSummaries(this.#meetingIndex, options);
2067
+ this.setUiState({
2068
+ meetingListSource: "index",
2069
+ meetingSearch: options.search,
2070
+ meetingSort: options.sort,
2071
+ meetingUpdatedFrom: options.updatedFrom,
2072
+ meetingUpdatedTo: options.updatedTo,
2073
+ selectedMeetingId: void 0,
2074
+ view: "meeting-list"
2075
+ });
2076
+ this.triggerMeetingIndexRefresh();
2077
+ return {
2078
+ meetings,
2079
+ source: "index"
2080
+ };
2081
+ }
2082
+ const cacheData = await this.loadCache();
2083
+ const documents = await this.listDocuments({ forceRefresh: options.forceRefresh });
2084
+ const meetings = listMeetings(documents, {
2085
+ cacheData,
1796
2086
  limit: options.limit,
1797
2087
  search: options.search,
1798
2088
  sort: options.sort,
1799
2089
  updatedFrom: options.updatedFrom,
1800
2090
  updatedTo: options.updatedTo
1801
2091
  });
2092
+ await this.persistMeetingIndex(listMeetings(documents, {
2093
+ cacheData,
2094
+ limit: Math.max(documents.length, 1),
2095
+ sort: "updated-desc"
2096
+ }));
1802
2097
  this.setUiState({
2098
+ meetingListSource: "live",
1803
2099
  meetingSearch: options.search,
1804
2100
  meetingSort: options.sort,
1805
2101
  meetingUpdatedFrom: options.updatedFrom,
@@ -1807,7 +2103,10 @@ var GranolaApp = class {
1807
2103
  selectedMeetingId: void 0,
1808
2104
  view: "meeting-list"
1809
2105
  });
1810
- return meetings;
2106
+ return {
2107
+ meetings,
2108
+ source: "live"
2109
+ };
1811
2110
  }
1812
2111
  async getMeeting(id, options = {}) {
1813
2112
  const documents = await this.listDocuments();
@@ -1950,13 +2249,19 @@ var GranolaApp = class {
1950
2249
  };
1951
2250
  async function createGranolaApp(config, options = {}) {
1952
2251
  const auth = await inspectDefaultGranolaAuth(config);
2252
+ const authController = createDefaultGranolaAuthController(config);
1953
2253
  const exportJobStore = createDefaultExportJobStore();
2254
+ const exportJobs = await exportJobStore.readJobs();
2255
+ const meetingIndexStore = createDefaultMeetingIndexStore();
1954
2256
  return new GranolaApp(config, {
1955
2257
  auth,
2258
+ authController,
1956
2259
  cacheLoader: loadOptionalGranolaCache,
1957
- createGranolaClient: async () => await createDefaultGranolaRuntime(config, options.logger),
1958
- exportJobs: await exportJobStore.readJobs(),
2260
+ createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
2261
+ exportJobs,
1959
2262
  exportJobStore,
2263
+ meetingIndex: await meetingIndexStore.readIndex(),
2264
+ meetingIndexStore,
1960
2265
  now: options.now
1961
2266
  }, { surface: options.surface });
1962
2267
  }
@@ -2076,6 +2381,103 @@ async function waitForShutdown(close) {
2076
2381
  });
2077
2382
  }
2078
2383
  //#endregion
2384
+ //#region src/commands/auth.ts
2385
+ function authHelp() {
2386
+ return `Granola auth
2387
+
2388
+ Usage:
2389
+ granola auth <login|status|logout|refresh|use> [options]
2390
+
2391
+ Subcommands:
2392
+ login Import credentials from the Granola desktop app
2393
+ status Show the current Granola auth state
2394
+ logout Delete the stored Granola session
2395
+ refresh Refresh the stored Granola session
2396
+ use <stored|supabase>
2397
+ Switch the active auth source for this toolkit instance
2398
+
2399
+ Options:
2400
+ --supabase <path> Path to supabase.json for auth login
2401
+ --config <path> Path to .granola.toml
2402
+ --debug Enable debug logging
2403
+ -h, --help Show help
2404
+ `;
2405
+ }
2406
+ function formatAuthSource(mode) {
2407
+ return mode === "stored-session" ? "stored session" : "supabase.json";
2408
+ }
2409
+ function printAuthState(state) {
2410
+ console.log(`Active source: ${formatAuthSource(state.mode)}`);
2411
+ console.log(`Stored session: ${state.storedSessionAvailable ? "available" : "missing"}`);
2412
+ console.log(`supabase.json: ${state.supabaseAvailable ? "available" : "missing"}`);
2413
+ if (state.supabasePath) console.log(`supabase path: ${state.supabasePath}`);
2414
+ if (state.clientId) console.log(`Client ID: ${state.clientId}`);
2415
+ console.log(`Refresh token: ${state.refreshAvailable ? "available" : "missing"}`);
2416
+ if (state.signInMethod) console.log(`Sign-in method: ${state.signInMethod}`);
2417
+ if (state.lastError) console.log(`Last error: ${state.lastError}`);
2418
+ }
2419
+ const authCommand = {
2420
+ description: "Manage stored Granola sessions",
2421
+ flags: { help: { type: "boolean" } },
2422
+ help: authHelp,
2423
+ name: "auth",
2424
+ async run({ commandArgs, globalFlags }) {
2425
+ const [action, value] = commandArgs;
2426
+ const config = await loadConfig({
2427
+ globalFlags,
2428
+ subcommandFlags: {}
2429
+ });
2430
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2431
+ debug(config.debug, "supabase", config.supabase);
2432
+ const app = await createGranolaApp(config);
2433
+ switch (action) {
2434
+ case "login": {
2435
+ const state = await app.loginAuth();
2436
+ console.log(`Imported Granola session from ${state.supabasePath ?? "desktop app defaults"}`);
2437
+ printAuthState(state);
2438
+ return 0;
2439
+ }
2440
+ case "logout": {
2441
+ const state = await app.logoutAuth();
2442
+ console.log("Stored Granola session deleted");
2443
+ printAuthState(state);
2444
+ return 0;
2445
+ }
2446
+ case "refresh": {
2447
+ const state = await app.refreshAuth();
2448
+ console.log("Stored Granola session refreshed");
2449
+ printAuthState(state);
2450
+ return 0;
2451
+ }
2452
+ case "status": {
2453
+ const state = await app.inspectAuth();
2454
+ printAuthState(state);
2455
+ return state.storedSessionAvailable ? 0 : 1;
2456
+ }
2457
+ case "use": {
2458
+ const mode = resolveAuthMode(value);
2459
+ const state = await app.switchAuthMode(mode);
2460
+ console.log(`Switched auth source to ${formatAuthSource(state.mode)}`);
2461
+ printAuthState(state);
2462
+ return 0;
2463
+ }
2464
+ case void 0:
2465
+ console.log(authHelp());
2466
+ return 1;
2467
+ default: throw new Error("invalid auth command: expected login, status, logout, refresh, or use");
2468
+ }
2469
+ }
2470
+ };
2471
+ function resolveAuthMode(value) {
2472
+ switch (value) {
2473
+ case "stored":
2474
+ case "stored-session": return "stored-session";
2475
+ case "supabase":
2476
+ case "supabase-file": return "supabase-file";
2477
+ default: throw new Error("invalid auth mode: expected stored or supabase");
2478
+ }
2479
+ }
2480
+ //#endregion
2079
2481
  //#region src/commands/exports.ts
2080
2482
  function exportsHelp() {
2081
2483
  return `Granola exports
@@ -2304,12 +2706,13 @@ async function list(commandFlags, globalFlags) {
2304
2706
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2305
2707
  const app = await createGranolaApp(config);
2306
2708
  debug(config.debug, "authMode", app.getState().auth.mode);
2307
- console.log("Fetching meetings from Granola API...");
2308
- const meetings = await app.listMeetings({
2709
+ console.log("Loading meetings...");
2710
+ const result = await app.listMeetings({
2309
2711
  limit,
2310
2712
  search
2311
2713
  });
2312
- console.log(renderMeetingList(meetings, format).trimEnd());
2714
+ console.log(result.source === "index" ? "Loaded meetings from the local index" : "Fetched meetings from Granola API");
2715
+ console.log(renderMeetingList(result.meetings, format).trimEnd());
2313
2716
  return 0;
2314
2717
  }
2315
2718
  async function view(id, commandFlags, globalFlags) {
@@ -2452,6 +2855,7 @@ const state = {
2452
2855
  selectedMeeting: null,
2453
2856
  selectedMeetingBundle: null,
2454
2857
  selectedMeetingId: null,
2858
+ meetingSource: "live",
2455
2859
  sort: "updated-desc",
2456
2860
  updatedFrom: "",
2457
2861
  updatedTo: "",
@@ -2460,6 +2864,7 @@ const state = {
2460
2864
 
2461
2865
  const els = {
2462
2866
  appState: document.querySelector("[data-app-state]"),
2867
+ authPanel: document.querySelector("[data-auth-panel]"),
2463
2868
  detailBody: document.querySelector("[data-detail-body]"),
2464
2869
  detailMeta: document.querySelector("[data-detail-meta]"),
2465
2870
  empty: document.querySelector("[data-empty]"),
@@ -2526,6 +2931,7 @@ function renderWorkspaceTabs() {
2526
2931
  function renderAppState() {
2527
2932
  if (!state.appState) {
2528
2933
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2934
+ els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2529
2935
  return;
2530
2936
  }
2531
2937
 
@@ -2537,6 +2943,11 @@ function renderAppState() {
2537
2943
  : appState.cache.configured
2538
2944
  ? "configured"
2539
2945
  : "not configured";
2946
+ const indexStatus = appState.index.loaded
2947
+ ? appState.index.meetingCount + " meetings"
2948
+ : appState.index.available
2949
+ ? "available"
2950
+ : "not built";
2540
2951
 
2541
2952
  els.appState.innerHTML = [
2542
2953
  '<div class="status-grid">',
@@ -2545,12 +2956,79 @@ function renderAppState() {
2545
2956
  '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2546
2957
  '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2547
2958
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2959
+ '<div><span class="status-label">Index</span><strong>' + escapeHtml(indexStatus) + "</strong></div>",
2548
2960
  "</div>",
2549
2961
  ].join("");
2550
2962
 
2963
+ renderAuthPanel();
2551
2964
  renderExportJobs();
2552
2965
  }
2553
2966
 
2967
+ function authActionButton(label, action, disabled) {
2968
+ return (
2969
+ '<button class="button button--secondary" data-auth-action="' +
2970
+ escapeHtml(action) +
2971
+ '"' +
2972
+ (disabled ? " disabled" : "") +
2973
+ ">" +
2974
+ escapeHtml(label) +
2975
+ "</button>"
2976
+ );
2977
+ }
2978
+
2979
+ function authModeButton(label, mode, disabled) {
2980
+ return (
2981
+ '<button class="button button--secondary" data-auth-mode="' +
2982
+ escapeHtml(mode) +
2983
+ '"' +
2984
+ (disabled ? " disabled" : "") +
2985
+ ">" +
2986
+ escapeHtml(label) +
2987
+ "</button>"
2988
+ );
2989
+ }
2990
+
2991
+ function renderAuthPanel() {
2992
+ const auth = state.appState?.auth;
2993
+ if (!auth) {
2994
+ els.authPanel.innerHTML = '<div class="auth-card"><div class="auth-card__meta">Auth state unavailable.</div></div>';
2995
+ return;
2996
+ }
2997
+
2998
+ const activeSource = auth.mode === "stored-session" ? "Stored session" : "supabase.json";
2999
+ const lastError = auth.lastError
3000
+ ? '<div class="auth-card__meta auth-card__error">' + escapeHtml(auth.lastError) + "</div>"
3001
+ : "";
3002
+
3003
+ els.authPanel.innerHTML = [
3004
+ '<div class="auth-card">',
3005
+ '<div class="status-grid">',
3006
+ '<div><span class="status-label">Active</span><strong>' + escapeHtml(activeSource) + "</strong></div>",
3007
+ '<div><span class="status-label">Stored</span><strong>' + escapeHtml(auth.storedSessionAvailable ? "available" : "missing") + "</strong></div>",
3008
+ '<div><span class="status-label">supabase.json</span><strong>' + escapeHtml(auth.supabaseAvailable ? "available" : "missing") + "</strong></div>",
3009
+ '<div><span class="status-label">Refresh</span><strong>' + escapeHtml(auth.refreshAvailable ? "available" : "missing") + "</strong></div>",
3010
+ "</div>",
3011
+ auth.clientId
3012
+ ? '<div class="auth-card__meta">Client ID: ' + escapeHtml(auth.clientId) + "</div>"
3013
+ : "",
3014
+ auth.signInMethod
3015
+ ? '<div class="auth-card__meta">Sign-in method: ' + escapeHtml(auth.signInMethod) + "</div>"
3016
+ : "",
3017
+ auth.supabasePath
3018
+ ? '<div class="auth-card__meta">supabase path: ' + escapeHtml(auth.supabasePath) + "</div>"
3019
+ : "",
3020
+ lastError,
3021
+ '<div class="auth-card__actions">',
3022
+ authActionButton("Import desktop session", "login", !auth.supabaseAvailable),
3023
+ authActionButton("Refresh stored session", "refresh", !auth.storedSessionAvailable || !auth.refreshAvailable),
3024
+ authModeButton("Use stored session", "stored-session", !auth.storedSessionAvailable || auth.mode === "stored-session"),
3025
+ authModeButton("Use supabase.json", "supabase-file", !auth.supabaseAvailable || auth.mode === "supabase-file"),
3026
+ authActionButton("Sign out", "logout", !auth.storedSessionAvailable),
3027
+ "</div>",
3028
+ "</div>",
3029
+ ].join("");
3030
+ }
3031
+
2554
3032
  function renderMeetingList() {
2555
3033
  if (state.listError) {
2556
3034
  els.list.innerHTML =
@@ -2708,7 +3186,7 @@ async function fetchJson(path, init) {
2708
3186
  return payload;
2709
3187
  }
2710
3188
 
2711
- function buildMeetingsQuery(limit = 100) {
3189
+ function buildMeetingsQuery(limit = 100, refresh = false) {
2712
3190
  const params = new URLSearchParams();
2713
3191
  params.set("limit", String(limit));
2714
3192
  params.set("sort", state.sort);
@@ -2725,16 +3203,22 @@ function buildMeetingsQuery(limit = 100) {
2725
3203
  params.set("updatedTo", state.updatedTo);
2726
3204
  }
2727
3205
 
3206
+ if (refresh) {
3207
+ params.set("refresh", "true");
3208
+ }
3209
+
2728
3210
  return "?" + params.toString();
2729
3211
  }
2730
3212
 
2731
3213
  async function loadMeetings(options = {}) {
2732
3214
  const preferredMeetingId = options.preferredMeetingId || state.selectedMeetingId;
3215
+ const refresh = options.refresh === true;
2733
3216
 
2734
3217
  try {
2735
3218
  state.listError = "";
2736
- const payload = await fetchJson("/meetings" + buildMeetingsQuery());
3219
+ const payload = await fetchJson("/meetings" + buildMeetingsQuery(100, refresh));
2737
3220
  state.meetings = payload.meetings || [];
3221
+ state.meetingSource = payload.source || "live";
2738
3222
 
2739
3223
  if (preferredMeetingId && state.meetings.some((meeting) => meeting.id === preferredMeetingId)) {
2740
3224
  state.selectedMeetingId = preferredMeetingId;
@@ -2803,12 +3287,28 @@ async function quickOpenMeeting() {
2803
3287
  }
2804
3288
  }
2805
3289
 
2806
- async function refreshAll() {
3290
+ async function refreshAll(forceLiveMeetings = false) {
2807
3291
  setStatus("Refreshing…", "busy");
2808
- const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2809
- state.appState = appState;
3292
+ const [appState, authState] = await Promise.all([
3293
+ fetchJson("/state"),
3294
+ fetchJson("/auth/status"),
3295
+ loadMeetings({ refresh: forceLiveMeetings }),
3296
+ ]);
3297
+ state.appState = {
3298
+ ...appState,
3299
+ auth: authState,
3300
+ };
3301
+ renderAppState();
3302
+ setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
3303
+ }
3304
+
3305
+ async function syncAuthState() {
3306
+ const [appState, authState] = await Promise.all([fetchJson("/state"), fetchJson("/auth/status")]);
3307
+ state.appState = {
3308
+ ...appState,
3309
+ auth: authState,
3310
+ };
2810
3311
  renderAppState();
2811
- setStatus("Connected", "ok");
2812
3312
  }
2813
3313
 
2814
3314
  async function exportNotes() {
@@ -2845,6 +3345,62 @@ async function rerunJob(id) {
2845
3345
  }
2846
3346
  }
2847
3347
 
3348
+ async function loginAuth() {
3349
+ setStatus("Importing desktop session…", "busy");
3350
+ try {
3351
+ await fetchJson("/auth/login", { method: "POST" });
3352
+ await refreshAll();
3353
+ } catch (error) {
3354
+ await syncAuthState();
3355
+ setStatus("Auth import failed", "error");
3356
+ state.detailError = error instanceof Error ? error.message : String(error);
3357
+ renderMeetingDetail();
3358
+ }
3359
+ }
3360
+
3361
+ async function logoutAuth() {
3362
+ setStatus("Signing out…", "busy");
3363
+ try {
3364
+ await fetchJson("/auth/logout", { method: "POST" });
3365
+ await refreshAll();
3366
+ } catch (error) {
3367
+ await syncAuthState();
3368
+ setStatus("Sign out failed", "error");
3369
+ state.detailError = error instanceof Error ? error.message : String(error);
3370
+ renderMeetingDetail();
3371
+ }
3372
+ }
3373
+
3374
+ async function refreshAuth() {
3375
+ setStatus("Refreshing session…", "busy");
3376
+ try {
3377
+ await fetchJson("/auth/refresh", { method: "POST" });
3378
+ await refreshAll();
3379
+ } catch (error) {
3380
+ await syncAuthState();
3381
+ setStatus("Refresh failed", "error");
3382
+ state.detailError = error instanceof Error ? error.message : String(error);
3383
+ renderMeetingDetail();
3384
+ }
3385
+ }
3386
+
3387
+ async function switchAuthMode(mode) {
3388
+ setStatus("Switching auth source…", "busy");
3389
+ try {
3390
+ await fetchJson("/auth/mode", {
3391
+ body: JSON.stringify({ mode }),
3392
+ headers: { "content-type": "application/json" },
3393
+ method: "POST",
3394
+ });
3395
+ await refreshAll();
3396
+ } catch (error) {
3397
+ await syncAuthState();
3398
+ setStatus("Switch failed", "error");
3399
+ state.detailError = error instanceof Error ? error.message : String(error);
3400
+ renderMeetingDetail();
3401
+ }
3402
+ }
3403
+
2848
3404
  els.list.addEventListener("click", (event) => {
2849
3405
  if (!(event.target instanceof Element)) {
2850
3406
  return;
@@ -2868,8 +3424,38 @@ els.jobsList.addEventListener("click", (event) => {
2868
3424
  void rerunJob(button.dataset.rerunJobId);
2869
3425
  });
2870
3426
 
3427
+ els.authPanel.addEventListener("click", (event) => {
3428
+ if (!(event.target instanceof Element)) {
3429
+ return;
3430
+ }
3431
+
3432
+ const actionButton = event.target.closest("[data-auth-action]");
3433
+ if (actionButton) {
3434
+ switch (actionButton.dataset.authAction) {
3435
+ case "login":
3436
+ void loginAuth();
3437
+ return;
3438
+ case "logout":
3439
+ void logoutAuth();
3440
+ return;
3441
+ case "refresh":
3442
+ void refreshAuth();
3443
+ return;
3444
+ default:
3445
+ return;
3446
+ }
3447
+ }
3448
+
3449
+ const modeButton = event.target.closest("[data-auth-mode]");
3450
+ if (!modeButton) {
3451
+ return;
3452
+ }
3453
+
3454
+ void switchAuthMode(modeButton.dataset.authMode);
3455
+ });
3456
+
2871
3457
  els.refreshButton.addEventListener("click", () => {
2872
- void refreshAll();
3458
+ void refreshAll(true);
2873
3459
  });
2874
3460
 
2875
3461
  els.noteButton.addEventListener("click", () => {
@@ -2994,9 +3580,20 @@ document.addEventListener("keydown", (event) => {
2994
3580
 
2995
3581
  const events = new EventSource("/events");
2996
3582
  events.addEventListener("state.updated", (event) => {
3583
+ const previousLoadedAt = state.appState?.documents?.loadedAt;
2997
3584
  const payload = JSON.parse(event.data);
2998
3585
  state.appState = payload.state;
2999
3586
  renderAppState();
3587
+
3588
+ if (
3589
+ state.meetingSource === "index" &&
3590
+ payload.state.documents?.loadedAt &&
3591
+ payload.state.documents.loadedAt !== previousLoadedAt
3592
+ ) {
3593
+ void loadMeetings({
3594
+ preferredMeetingId: state.selectedMeetingId,
3595
+ });
3596
+ }
3000
3597
  });
3001
3598
  events.addEventListener("error", () => {
3002
3599
  setStatus("Disconnected", "error");
@@ -3066,6 +3663,13 @@ const granolaWebMarkup = String.raw`
3066
3663
  </div>
3067
3664
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3068
3665
  </section>
3666
+ <section class="auth-panel">
3667
+ <div class="auth-panel__head">
3668
+ <h3>Auth Session</h3>
3669
+ <p>Inspect, refresh, and switch between stored session and <code>supabase.json</code>.</p>
3670
+ </div>
3671
+ <div class="auth-panel__body" data-auth-panel></div>
3672
+ </section>
3069
3673
  <section class="jobs-panel">
3070
3674
  <div class="jobs-panel__head">
3071
3675
  <h3>Recent Export Jobs</h3>
@@ -3295,10 +3899,12 @@ body {
3295
3899
  width: min(440px, 100%);
3296
3900
  }
3297
3901
 
3902
+ .auth-panel,
3298
3903
  .jobs-panel {
3299
3904
  padding: 0 24px 18px;
3300
3905
  }
3301
3906
 
3907
+ .auth-panel__head h3,
3302
3908
  .jobs-panel__head h3 {
3303
3909
  margin: 0;
3304
3910
  font-size: 0.92rem;
@@ -3306,12 +3912,43 @@ body {
3306
3912
  text-transform: uppercase;
3307
3913
  }
3308
3914
 
3915
+ .auth-panel__head p,
3309
3916
  .jobs-panel__head p {
3310
3917
  margin: 6px 0 0;
3311
3918
  color: var(--muted);
3312
3919
  font-size: 0.9rem;
3313
3920
  }
3314
3921
 
3922
+ .auth-panel__body {
3923
+ display: grid;
3924
+ gap: 12px;
3925
+ margin-top: 14px;
3926
+ }
3927
+
3928
+ .auth-card {
3929
+ display: grid;
3930
+ gap: 12px;
3931
+ padding: 14px 16px;
3932
+ border: 1px solid var(--line);
3933
+ border-radius: 18px;
3934
+ background: rgba(255, 255, 255, 0.72);
3935
+ }
3936
+
3937
+ .auth-card__meta {
3938
+ color: var(--muted);
3939
+ font-size: 0.9rem;
3940
+ }
3941
+
3942
+ .auth-card__actions {
3943
+ display: flex;
3944
+ flex-wrap: wrap;
3945
+ gap: 8px;
3946
+ }
3947
+
3948
+ .auth-card__error {
3949
+ color: var(--error);
3950
+ }
3951
+
3315
3952
  .jobs-list {
3316
3953
  display: grid;
3317
3954
  gap: 10px;
@@ -3419,6 +4056,11 @@ body {
3419
4056
  cursor: pointer;
3420
4057
  }
3421
4058
 
4059
+ .button:disabled {
4060
+ cursor: not-allowed;
4061
+ opacity: 0.56;
4062
+ }
4063
+
3422
4064
  .button--primary {
3423
4065
  background: var(--ink);
3424
4066
  color: white;
@@ -3566,6 +4208,13 @@ function parseMeetingSort(value) {
3566
4208
  default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
3567
4209
  }
3568
4210
  }
4211
+ function parseAuthMode(value) {
4212
+ switch (value) {
4213
+ case "stored-session":
4214
+ case "supabase-file": return value;
4215
+ default: throw new Error("invalid auth mode: expected stored-session or supabase-file");
4216
+ }
4217
+ }
3569
4218
  function sendJson(response, body, init = {}) {
3570
4219
  const payload = `${JSON.stringify(body, null, 2)}\n`;
3571
4220
  response.writeHead(init.status ?? 200, {
@@ -3648,6 +4297,10 @@ async function startGranolaServer(app, options = {}) {
3648
4297
  sendJson(response, app.getState());
3649
4298
  return;
3650
4299
  }
4300
+ if (method === "GET" && path === "/auth/status") {
4301
+ sendJson(response, await app.inspectAuth());
4302
+ return;
4303
+ }
3651
4304
  if (method === "GET" && path === "/events") {
3652
4305
  response.writeHead(200, {
3653
4306
  "cache-control": "no-cache, no-transform",
@@ -3670,19 +4323,24 @@ async function startGranolaServer(app, options = {}) {
3670
4323
  }
3671
4324
  if (method === "GET" && path === "/meetings") {
3672
4325
  const limit = parseInteger(url.searchParams.get("limit"));
4326
+ const refresh = url.searchParams.get("refresh") === "true";
3673
4327
  const search = url.searchParams.get("search")?.trim() || void 0;
3674
4328
  const sort = parseMeetingSort(url.searchParams.get("sort"));
3675
4329
  const updatedFrom = url.searchParams.get("updatedFrom")?.trim() || void 0;
3676
4330
  const updatedTo = url.searchParams.get("updatedTo")?.trim() || void 0;
4331
+ const result = await app.listMeetings({
4332
+ forceRefresh: refresh,
4333
+ limit,
4334
+ search,
4335
+ sort,
4336
+ updatedFrom,
4337
+ updatedTo
4338
+ });
3677
4339
  sendJson(response, {
3678
- meetings: await app.listMeetings({
3679
- limit,
3680
- search,
3681
- sort,
3682
- updatedFrom,
3683
- updatedTo
3684
- }),
4340
+ meetings: result.meetings,
4341
+ refresh,
3685
4342
  search,
4343
+ source: result.source,
3686
4344
  sort,
3687
4345
  updatedFrom,
3688
4346
  updatedTo
@@ -3701,6 +4359,25 @@ async function startGranolaServer(app, options = {}) {
3701
4359
  sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
3702
4360
  return;
3703
4361
  }
4362
+ if (method === "POST" && path === "/auth/login") {
4363
+ const body = await readJsonBody(request);
4364
+ const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
4365
+ sendJson(response, await app.loginAuth({ supabasePath }));
4366
+ return;
4367
+ }
4368
+ if (method === "POST" && path === "/auth/logout") {
4369
+ sendJson(response, await app.logoutAuth());
4370
+ return;
4371
+ }
4372
+ if (method === "POST" && path === "/auth/refresh") {
4373
+ sendJson(response, await app.refreshAuth());
4374
+ return;
4375
+ }
4376
+ if (method === "POST" && path === "/auth/mode") {
4377
+ const body = await readJsonBody(request);
4378
+ sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
4379
+ return;
4380
+ }
3704
4381
  if (method === "POST" && path === "/exports/notes") {
3705
4382
  const body = await readJsonBody(request);
3706
4383
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
@@ -3803,11 +4480,16 @@ const serveCommand = {
3803
4480
  console.log(`Granola server listening on ${server.url.href}`);
3804
4481
  console.log("Endpoints:");
3805
4482
  console.log(" GET /health");
4483
+ console.log(" GET /auth/status");
3806
4484
  console.log(" GET /state");
3807
4485
  console.log(" GET /events");
3808
4486
  console.log(" GET /meetings");
3809
4487
  console.log(" GET /meetings/:id");
3810
4488
  console.log(" GET /exports/jobs");
4489
+ console.log(" POST /auth/login");
4490
+ console.log(" POST /auth/logout");
4491
+ console.log(" POST /auth/mode");
4492
+ console.log(" POST /auth/refresh");
3811
4493
  console.log(" POST /exports/notes");
3812
4494
  console.log(" POST /exports/jobs/:id/rerun");
3813
4495
  console.log(" POST /exports/transcripts");
@@ -3964,11 +4646,16 @@ const commands = [
3964
4646
  console.log("Routes:");
3965
4647
  console.log(" GET /");
3966
4648
  console.log(" GET /health");
4649
+ console.log(" GET /auth/status");
3967
4650
  console.log(" GET /state");
3968
4651
  console.log(" GET /events");
3969
4652
  console.log(" GET /meetings");
3970
4653
  console.log(" GET /meetings/:id");
3971
4654
  console.log(" GET /exports/jobs");
4655
+ console.log(" POST /auth/login");
4656
+ console.log(" POST /auth/logout");
4657
+ console.log(" POST /auth/mode");
4658
+ console.log(" POST /auth/refresh");
3972
4659
  console.log(" POST /exports/notes");
3973
4660
  console.log(" POST /exports/jobs/:id/rerun");
3974
4661
  console.log(" POST /exports/transcripts");