granola-toolkit 0.21.1 → 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 +38 -1
  2. package/dist/cli.js +1108 -151
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
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";
9
- import { createHash } from "node:crypto";
7
+ import { execFile } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ import { createHash, randomUUID } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
11
  //#region src/utils.ts
12
12
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
@@ -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
  }
@@ -718,6 +767,81 @@ async function loadOptionalGranolaCache(cacheFile) {
718
767
  return parseCacheContents(await readFile(cacheFile, "utf8"));
719
768
  }
720
769
  //#endregion
770
+ //#region src/export-jobs.ts
771
+ const EXPORT_JOBS_VERSION = 1;
772
+ const MAX_EXPORT_JOBS = 100;
773
+ function normaliseJob(value) {
774
+ const record = asRecord(value);
775
+ if (!record) return;
776
+ const id = stringValue(record.id);
777
+ const kind = stringValue(record.kind);
778
+ const status = stringValue(record.status);
779
+ const format = stringValue(record.format);
780
+ const outputDir = stringValue(record.outputDir);
781
+ const startedAt = stringValue(record.startedAt);
782
+ const itemCount = typeof record.itemCount === "number" && Number.isFinite(record.itemCount) ? record.itemCount : 0;
783
+ const written = typeof record.written === "number" && Number.isFinite(record.written) ? record.written : 0;
784
+ const completedCount = typeof record.completedCount === "number" && Number.isFinite(record.completedCount) ? record.completedCount : written;
785
+ if (!id || !format || !outputDir || !startedAt || kind !== "notes" && kind !== "transcripts" || status !== "running" && status !== "completed" && status !== "failed") return;
786
+ return {
787
+ completedCount,
788
+ error: stringValue(record.error) || void 0,
789
+ finishedAt: stringValue(record.finishedAt) || void 0,
790
+ format,
791
+ id,
792
+ itemCount,
793
+ kind,
794
+ outputDir,
795
+ startedAt,
796
+ status,
797
+ written
798
+ };
799
+ }
800
+ function normaliseJobsFile(parsed) {
801
+ const record = asRecord(parsed);
802
+ if (!record || record.version !== EXPORT_JOBS_VERSION || !Array.isArray(record.jobs)) return {
803
+ jobs: [],
804
+ version: EXPORT_JOBS_VERSION
805
+ };
806
+ return {
807
+ jobs: record.jobs.map((job) => normaliseJob(job)).filter((job) => Boolean(job)).slice(0, MAX_EXPORT_JOBS),
808
+ version: EXPORT_JOBS_VERSION
809
+ };
810
+ }
811
+ function createExportJobId(kind) {
812
+ return `${kind}-${randomUUID()}`;
813
+ }
814
+ function defaultExportJobsFilePath() {
815
+ const home = homedir();
816
+ return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
817
+ }
818
+ var FileExportJobStore = class {
819
+ constructor(filePath = defaultExportJobsFilePath()) {
820
+ this.filePath = filePath;
821
+ }
822
+ async readJobs() {
823
+ try {
824
+ return normaliseJobsFile(parseJsonString(await readFile(this.filePath, "utf8"))).jobs;
825
+ } catch {
826
+ return [];
827
+ }
828
+ }
829
+ async writeJobs(jobs) {
830
+ const payload = {
831
+ jobs: jobs.slice(0, MAX_EXPORT_JOBS),
832
+ version: EXPORT_JOBS_VERSION
833
+ };
834
+ await mkdir(dirname(this.filePath), { recursive: true });
835
+ await writeFile(this.filePath, `${JSON.stringify(payload, null, 2)}\n`, {
836
+ encoding: "utf8",
837
+ mode: 384
838
+ });
839
+ }
840
+ };
841
+ function createDefaultExportJobStore() {
842
+ return new FileExportJobStore();
843
+ }
844
+ //#endregion
721
845
  //#region src/export-state.ts
722
846
  const EXPORT_STATE_VERSION = 1;
723
847
  function exportStatePath(outputDir, kind) {
@@ -783,7 +907,7 @@ function entryChanged(left, right) {
783
907
  if (!left) return true;
784
908
  return left.contentHash !== right.contentHash || left.exportedAt !== right.exportedAt || left.fileName !== right.fileName || left.fileStem !== right.fileStem || left.sourceUpdatedAt !== right.sourceUpdatedAt;
785
909
  }
786
- async function syncManagedExports({ items, kind, outputDir }) {
910
+ async function syncManagedExports({ items, kind, onProgress, outputDir }) {
787
911
  await ensureDirectory(outputDir);
788
912
  const previousEntries = (await loadExportState(outputDir, kind)).entries;
789
913
  const used = /* @__PURE__ */ new Map();
@@ -804,6 +928,7 @@ async function syncManagedExports({ items, kind, outputDir }) {
804
928
  const activeFileNames = new Set(plans.map((plan) => plan.fileName));
805
929
  const exportedAt = (/* @__PURE__ */ new Date()).toISOString();
806
930
  const nextEntries = {};
931
+ let completed = 0;
807
932
  let written = 0;
808
933
  let stateChanged = false;
809
934
  for (const plan of plans) {
@@ -822,6 +947,12 @@ async function syncManagedExports({ items, kind, outputDir }) {
822
947
  };
823
948
  nextEntries[plan.id] = nextEntry;
824
949
  stateChanged = stateChanged || entryChanged(plan.existing, nextEntry);
950
+ completed += 1;
951
+ if (onProgress) await onProgress({
952
+ completed,
953
+ total: plans.length,
954
+ written
955
+ });
825
956
  }
826
957
  for (const plan of plans) {
827
958
  const previousFileName = plan.existing?.fileName;
@@ -1106,7 +1237,7 @@ function noteFileExtension(format) {
1106
1237
  case "markdown": return ".md";
1107
1238
  }
1108
1239
  }
1109
- async function writeNotes(documents, outputDir, format = "markdown") {
1240
+ async function writeNotes(documents, outputDir, format = "markdown", options = {}) {
1110
1241
  return await syncManagedExports({
1111
1242
  items: [...documents].sort((left, right) => compareStrings(left.title || left.id, right.title || right.id) || compareStrings(left.id, right.id)).map((document) => {
1112
1243
  const note = buildNoteExport(document);
@@ -1119,6 +1250,7 @@ async function writeNotes(documents, outputDir, format = "markdown") {
1119
1250
  };
1120
1251
  }),
1121
1252
  kind: "notes",
1253
+ onProgress: options.onProgress,
1122
1254
  outputDir
1123
1255
  });
1124
1256
  }
@@ -1230,7 +1362,7 @@ function transcriptFileExtension(format) {
1230
1362
  case "yaml": return ".yaml";
1231
1363
  }
1232
1364
  }
1233
- async function writeTranscripts(cacheData, outputDir, format = "text") {
1365
+ async function writeTranscripts(cacheData, outputDir, format = "text", options = {}) {
1234
1366
  return await syncManagedExports({
1235
1367
  items: Object.entries(cacheData.transcripts).filter(([, segments]) => segments.length > 0).sort(([leftId], [rightId]) => {
1236
1368
  const leftDocument = cacheData.documents[leftId];
@@ -1254,6 +1386,7 @@ async function writeTranscripts(cacheData, outputDir, format = "text") {
1254
1386
  }];
1255
1387
  }),
1256
1388
  kind: "transcripts",
1389
+ onProgress: options.onProgress,
1257
1390
  outputDir
1258
1391
  });
1259
1392
  }
@@ -1514,6 +1647,9 @@ function transcriptCount(cacheData) {
1514
1647
  function cloneExportState(state) {
1515
1648
  return state ? { ...state } : void 0;
1516
1649
  }
1650
+ function cloneExportJob(job) {
1651
+ return { ...job };
1652
+ }
1517
1653
  function cloneState(state) {
1518
1654
  return {
1519
1655
  auth: { ...state.auth },
@@ -1525,6 +1661,7 @@ function cloneState(state) {
1525
1661
  },
1526
1662
  documents: { ...state.documents },
1527
1663
  exports: {
1664
+ jobs: state.exports.jobs.map((job) => cloneExportJob(job)),
1528
1665
  notes: cloneExportState(state.exports.notes),
1529
1666
  transcripts: cloneExportState(state.exports.transcripts)
1530
1667
  },
@@ -1550,7 +1687,7 @@ function defaultState(config, auth, surface) {
1550
1687
  count: 0,
1551
1688
  loaded: false
1552
1689
  },
1553
- exports: {},
1690
+ exports: { jobs: [] },
1554
1691
  ui: {
1555
1692
  surface,
1556
1693
  view: "idle"
@@ -1568,6 +1705,7 @@ var GranolaApp = class {
1568
1705
  this.config = config;
1569
1706
  this.deps = deps;
1570
1707
  this.#state = defaultState(config, deps.auth, options.surface ?? "cli");
1708
+ this.#state.exports.jobs = (deps.exportJobs ?? []).map((job) => cloneExportJob(job));
1571
1709
  }
1572
1710
  getState() {
1573
1711
  return cloneState(this.#state);
@@ -1586,6 +1724,24 @@ var GranolaApp = class {
1586
1724
  this.emitStateUpdate();
1587
1725
  return this.getState();
1588
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
+ }
1589
1745
  nowIso() {
1590
1746
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1591
1747
  }
@@ -1604,15 +1760,121 @@ var GranolaApp = class {
1604
1760
  return this.#granolaClient;
1605
1761
  }
1606
1762
  if (!this.deps.createGranolaClient) throw new Error("Granola API client is not configured");
1607
- const runtime = await this.deps.createGranolaClient();
1763
+ const runtime = await this.deps.createGranolaClient(this.#state.auth.mode);
1608
1764
  this.#granolaClient = runtime.client;
1609
- this.#state.auth = { ...runtime.auth };
1610
- this.emitStateUpdate();
1765
+ this.applyAuthState(runtime.auth);
1611
1766
  return this.#granolaClient;
1612
1767
  }
1613
1768
  missingCacheError() {
1614
1769
  return /* @__PURE__ */ new Error(`Granola cache file not found. Pass --cache or create .granola.toml. Expected locations include: ${granolaCacheCandidates().join(", ")}`);
1615
1770
  }
1771
+ async persistExportJobs() {
1772
+ if (!this.deps.exportJobStore) return;
1773
+ await this.deps.exportJobStore.writeJobs(this.#state.exports.jobs);
1774
+ }
1775
+ async updateExportJob(job) {
1776
+ const nextJobs = [cloneExportJob(job), ...this.#state.exports.jobs.filter((candidate) => candidate.id !== job.id).map(cloneExportJob)].slice(0, 100);
1777
+ this.#state.exports.jobs = nextJobs;
1778
+ await this.persistExportJobs();
1779
+ this.emitStateUpdate();
1780
+ return cloneExportJob(job);
1781
+ }
1782
+ async startExportJob(kind, format, itemCount, outputDir) {
1783
+ return await this.updateExportJob({
1784
+ completedCount: 0,
1785
+ format,
1786
+ id: createExportJobId(kind),
1787
+ itemCount,
1788
+ kind,
1789
+ outputDir,
1790
+ startedAt: this.nowIso(),
1791
+ status: "running",
1792
+ written: 0
1793
+ });
1794
+ }
1795
+ async completeExportJob(job, patch) {
1796
+ return await this.updateExportJob({
1797
+ ...job,
1798
+ completedCount: patch.completedCount,
1799
+ finishedAt: this.nowIso(),
1800
+ status: "completed",
1801
+ written: patch.written
1802
+ });
1803
+ }
1804
+ async failExportJob(job, error) {
1805
+ const message = error instanceof Error ? error.message : String(error);
1806
+ return await this.updateExportJob({
1807
+ ...job,
1808
+ error: message,
1809
+ finishedAt: this.nowIso(),
1810
+ status: "failed"
1811
+ });
1812
+ }
1813
+ async setExportJobProgress(job, patch) {
1814
+ return await this.updateExportJob({
1815
+ ...job,
1816
+ completedCount: patch.completedCount,
1817
+ written: patch.written
1818
+ });
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
+ }
1616
1878
  async listDocuments() {
1617
1879
  if (this.#documents) return this.#documents;
1618
1880
  const documents = await (await this.getGranolaClient()).listDocuments({ timeoutMs: this.config.notes.timeoutMs });
@@ -1701,13 +1963,42 @@ var GranolaApp = class {
1701
1963
  meeting
1702
1964
  };
1703
1965
  }
1966
+ async listExportJobs(options = {}) {
1967
+ const limit = options.limit ?? 20;
1968
+ const jobs = this.#state.exports.jobs.slice(0, limit).map((job) => cloneExportJob(job));
1969
+ this.setUiState({ view: "exports-history" });
1970
+ return { jobs };
1971
+ }
1704
1972
  async exportNotes(format = "markdown") {
1973
+ return await this.runNotesExport({
1974
+ format,
1975
+ outputDir: this.config.notes.output
1976
+ });
1977
+ }
1978
+ async runNotesExport(options) {
1705
1979
  const documents = await this.listDocuments();
1706
- const written = await writeNotes(documents, this.config.notes.output, format);
1980
+ let job = await this.startExportJob("notes", options.format, documents.length, options.outputDir);
1981
+ let written = 0;
1982
+ try {
1983
+ written = await writeNotes(documents, options.outputDir, options.format, { onProgress: async (progress) => {
1984
+ job = await this.setExportJobProgress(job, {
1985
+ completedCount: progress.completed,
1986
+ written: progress.written
1987
+ });
1988
+ } });
1989
+ job = await this.completeExportJob(job, {
1990
+ completedCount: documents.length,
1991
+ written
1992
+ });
1993
+ } catch (error) {
1994
+ await this.failExportJob(job, error);
1995
+ throw error;
1996
+ }
1707
1997
  this.#state.exports.notes = {
1708
- format,
1998
+ format: options.format,
1709
1999
  itemCount: documents.length,
1710
- outputDir: this.config.notes.output,
2000
+ jobId: job.id,
2001
+ outputDir: options.outputDir,
1711
2002
  ranAt: this.nowIso(),
1712
2003
  written
1713
2004
  };
@@ -1716,20 +2007,44 @@ var GranolaApp = class {
1716
2007
  return {
1717
2008
  documentCount: documents.length,
1718
2009
  documents,
1719
- format,
1720
- outputDir: this.config.notes.output,
2010
+ format: options.format,
2011
+ job,
2012
+ outputDir: options.outputDir,
1721
2013
  written
1722
2014
  };
1723
2015
  }
1724
2016
  async exportTranscripts(format = "text") {
1725
- const cacheData = await this.loadCache({ required: true });
1726
- if (!cacheData) throw this.missingCacheError();
1727
- const written = await writeTranscripts(cacheData, this.config.transcripts.output, format);
2017
+ return await this.runTranscriptsExport({
2018
+ format,
2019
+ outputDir: this.config.transcripts.output
2020
+ });
2021
+ }
2022
+ async runTranscriptsExport(options) {
2023
+ const cacheData = await this.loadCache({ required: true });
2024
+ if (!cacheData) throw this.missingCacheError();
1728
2025
  const count = transcriptCount(cacheData);
2026
+ let job = await this.startExportJob("transcripts", options.format, count, options.outputDir);
2027
+ let written = 0;
2028
+ try {
2029
+ written = await writeTranscripts(cacheData, options.outputDir, options.format, { onProgress: async (progress) => {
2030
+ job = await this.setExportJobProgress(job, {
2031
+ completedCount: progress.completed,
2032
+ written: progress.written
2033
+ });
2034
+ } });
2035
+ job = await this.completeExportJob(job, {
2036
+ completedCount: count,
2037
+ written
2038
+ });
2039
+ } catch (error) {
2040
+ await this.failExportJob(job, error);
2041
+ throw error;
2042
+ }
1729
2043
  this.#state.exports.transcripts = {
1730
- format,
2044
+ format: options.format,
1731
2045
  itemCount: count,
1732
- outputDir: this.config.transcripts.output,
2046
+ jobId: job.id,
2047
+ outputDir: options.outputDir,
1733
2048
  ranAt: this.nowIso(),
1734
2049
  written
1735
2050
  };
@@ -1737,18 +2052,37 @@ var GranolaApp = class {
1737
2052
  this.setUiState({ view: "transcripts-export" });
1738
2053
  return {
1739
2054
  cacheData,
1740
- format,
1741
- outputDir: this.config.transcripts.output,
2055
+ format: options.format,
2056
+ job,
2057
+ outputDir: options.outputDir,
1742
2058
  transcriptCount: count,
1743
2059
  written
1744
2060
  };
1745
2061
  }
2062
+ async rerunExportJob(id) {
2063
+ const job = this.#state.exports.jobs.find((candidate) => candidate.id === id);
2064
+ if (!job) throw new Error(`export job not found: ${id}`);
2065
+ if (job.kind === "notes") return await this.runNotesExport({
2066
+ format: job.format,
2067
+ outputDir: job.outputDir
2068
+ });
2069
+ return await this.runTranscriptsExport({
2070
+ format: job.format,
2071
+ outputDir: job.outputDir
2072
+ });
2073
+ }
1746
2074
  };
1747
2075
  async function createGranolaApp(config, options = {}) {
2076
+ const auth = await inspectDefaultGranolaAuth(config);
2077
+ const authController = createDefaultGranolaAuthController(config);
2078
+ const exportJobStore = createDefaultExportJobStore();
1748
2079
  return new GranolaApp(config, {
1749
- auth: await inspectDefaultGranolaAuth(config),
2080
+ auth,
2081
+ authController,
1750
2082
  cacheLoader: loadOptionalGranolaCache,
1751
- createGranolaClient: async () => await createDefaultGranolaRuntime(config, options.logger),
2083
+ createGranolaClient: async (mode) => await createDefaultGranolaRuntime(config, options.logger, { preferredMode: mode }),
2084
+ exportJobs: await exportJobStore.readJobs(),
2085
+ exportJobStore,
1752
2086
  now: options.now
1753
2087
  }, { surface: options.surface });
1754
2088
  }
@@ -1868,6 +2202,203 @@ async function waitForShutdown(close) {
1868
2202
  });
1869
2203
  }
1870
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
2302
+ //#region src/commands/exports.ts
2303
+ function exportsHelp() {
2304
+ return `Granola exports
2305
+
2306
+ Usage:
2307
+ granola exports <list|rerun> [options]
2308
+
2309
+ Subcommands:
2310
+ list Show recent export jobs
2311
+ rerun <job-id> Rerun a previous notes or transcripts export job
2312
+
2313
+ Options:
2314
+ --cache <path> Path to Granola cache JSON
2315
+ --format <value> list output format: text, json, yaml (default: text)
2316
+ --limit <n> Number of jobs to show for list (default: 20)
2317
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2318
+ --supabase <path> Path to supabase.json
2319
+ --debug Enable debug logging
2320
+ --config <path> Path to .granola.toml
2321
+ -h, --help Show help
2322
+ `;
2323
+ }
2324
+ function resolveListFormat$1(value) {
2325
+ switch (value) {
2326
+ case void 0: return "text";
2327
+ case "json":
2328
+ case "text":
2329
+ case "yaml": return value;
2330
+ default: throw new Error("invalid exports format: expected text, json, or yaml");
2331
+ }
2332
+ }
2333
+ function parseLimit$1(value) {
2334
+ if (value === void 0) return 20;
2335
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid exports limit: expected a positive integer");
2336
+ const limit = Number(value);
2337
+ if (!Number.isInteger(limit) || limit < 1) throw new Error("invalid exports limit: expected a positive integer");
2338
+ return limit;
2339
+ }
2340
+ function renderExportJobs(jobs, format) {
2341
+ if (format === "json") return toJson({ jobs });
2342
+ if (format === "yaml") return toYaml({ jobs });
2343
+ if (jobs.length === 0) return "No export jobs\n";
2344
+ return `${["ID KIND STATUS FORMAT ITEMS WRITTEN STARTED", ...jobs.map((job) => {
2345
+ return `${job.id.padEnd(28).slice(0, 28)} ${job.kind.padEnd(12)} ${job.status.padEnd(11)} ${job.format.padEnd(11)} ${String(job.itemCount).padEnd(7)} ${String(job.written).padEnd(8)} ${job.startedAt.slice(0, 19)}`;
2346
+ })].join("\n")}\n`;
2347
+ }
2348
+ const exportsCommand = {
2349
+ description: "List and rerun tracked export jobs",
2350
+ flags: {
2351
+ cache: { type: "string" },
2352
+ format: { type: "string" },
2353
+ help: { type: "boolean" },
2354
+ limit: { type: "string" },
2355
+ timeout: { type: "string" }
2356
+ },
2357
+ help: exportsHelp,
2358
+ name: "exports",
2359
+ async run({ commandArgs, commandFlags, globalFlags }) {
2360
+ const [action, id] = commandArgs;
2361
+ switch (action) {
2362
+ case "list": return await list$1(commandFlags, globalFlags);
2363
+ case "rerun":
2364
+ if (!id) throw new Error("exports rerun requires a job id");
2365
+ return await rerun(id, commandFlags, globalFlags);
2366
+ case void 0:
2367
+ console.log(exportsHelp());
2368
+ return 1;
2369
+ default: throw new Error("invalid exports command: expected list or rerun");
2370
+ }
2371
+ }
2372
+ };
2373
+ async function list$1(commandFlags, globalFlags) {
2374
+ const format = resolveListFormat$1(commandFlags.format);
2375
+ const limit = parseLimit$1(commandFlags.limit);
2376
+ const config = await loadConfig({
2377
+ globalFlags,
2378
+ subcommandFlags: commandFlags
2379
+ });
2380
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2381
+ const result = await (await createGranolaApp(config)).listExportJobs({ limit });
2382
+ console.log(renderExportJobs(result.jobs, format).trimEnd());
2383
+ return 0;
2384
+ }
2385
+ async function rerun(id, commandFlags, globalFlags) {
2386
+ const config = await loadConfig({
2387
+ globalFlags,
2388
+ subcommandFlags: commandFlags
2389
+ });
2390
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2391
+ debug(config.debug, "supabase", config.supabase);
2392
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2393
+ const result = await (await createGranolaApp(config)).rerunExportJob(id);
2394
+ if ("documentCount" in result) {
2395
+ console.log(`✓ Reran notes export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.documentCount} written)`);
2396
+ return 0;
2397
+ }
2398
+ console.log(`✓ Reran transcripts export job ${result.job.id} to ${result.outputDir} (${result.written}/${result.transcriptCount} written)`);
2399
+ return 0;
2400
+ }
2401
+ //#endregion
1871
2402
  //#region src/commands/meeting.ts
1872
2403
  function meetingHelp() {
1873
2404
  return `Granola meeting
@@ -2116,7 +2647,7 @@ const notesCommand = {
2116
2647
  const app = await createGranolaApp(config);
2117
2648
  debug(config.debug, "authMode", app.getState().auth.mode);
2118
2649
  const result = await app.exportNotes(format);
2119
- console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir}`);
2650
+ console.log(`✓ Exported ${result.documentCount} notes to ${result.outputDir} (job ${result.job.id})`);
2120
2651
  debug(config.debug, "notes written", result.written);
2121
2652
  return 0;
2122
2653
  }
@@ -2152,9 +2683,11 @@ const state = {
2152
2683
 
2153
2684
  const els = {
2154
2685
  appState: document.querySelector("[data-app-state]"),
2686
+ authPanel: document.querySelector("[data-auth-panel]"),
2155
2687
  detailBody: document.querySelector("[data-detail-body]"),
2156
2688
  detailMeta: document.querySelector("[data-detail-meta]"),
2157
2689
  empty: document.querySelector("[data-empty]"),
2690
+ jobsList: document.querySelector("[data-jobs-list]"),
2158
2691
  list: document.querySelector("[data-meeting-list]"),
2159
2692
  noteButton: document.querySelector("[data-export-notes]"),
2160
2693
  quickOpen: document.querySelector("[data-quick-open]"),
@@ -2217,6 +2750,7 @@ function renderWorkspaceTabs() {
2217
2750
  function renderAppState() {
2218
2751
  if (!state.appState) {
2219
2752
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2753
+ els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2220
2754
  return;
2221
2755
  }
2222
2756
 
@@ -2238,6 +2772,74 @@ function renderAppState() {
2238
2772
  '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2239
2773
  "</div>",
2240
2774
  ].join("");
2775
+
2776
+ renderAuthPanel();
2777
+ renderExportJobs();
2778
+ }
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("");
2241
2843
  }
2242
2844
 
2243
2845
  function renderMeetingList() {
@@ -2350,6 +2952,44 @@ function renderMeetingDetail() {
2350
2952
  ].join("");
2351
2953
  }
2352
2954
 
2955
+ function renderExportJobs() {
2956
+ const jobs = state.appState?.exports?.jobs || [];
2957
+ if (jobs.length === 0) {
2958
+ els.jobsList.innerHTML = '<div class="job-empty">No export jobs yet.</div>';
2959
+ return;
2960
+ }
2961
+
2962
+ els.jobsList.innerHTML = jobs
2963
+ .slice(0, 6)
2964
+ .map((job) => {
2965
+ const progress = job.itemCount > 0
2966
+ ? job.completedCount + "/" + job.itemCount + " items"
2967
+ : "0 items";
2968
+ const error = job.error ? '<div class="job-card__meta">' + escapeHtml(job.error) + "</div>" : "";
2969
+ const rerunButton =
2970
+ job.status === "running"
2971
+ ? ""
2972
+ : '<button class="button button--secondary" data-rerun-job-id="' + escapeHtml(job.id) + '">Rerun</button>';
2973
+
2974
+ return [
2975
+ '<article class="job-card">',
2976
+ '<div class="job-card__head">',
2977
+ '<div>',
2978
+ '<div class="job-card__title">' + escapeHtml(job.kind) + " export</div>",
2979
+ '<div class="job-card__meta">' + escapeHtml(job.id) + "</div>",
2980
+ "</div>",
2981
+ '<div class="job-card__status" data-status="' + escapeHtml(job.status) + '">' + escapeHtml(job.status) + "</div>",
2982
+ "</div>",
2983
+ '<div class="job-card__meta">Format: ' + escapeHtml(job.format) + " • " + escapeHtml(progress) + " • Written: " + escapeHtml(String(job.written)) + "</div>",
2984
+ '<div class="job-card__meta">Started: ' + escapeHtml(job.startedAt.slice(0, 19)) + "</div>",
2985
+ error,
2986
+ '<div class="job-card__actions">' + rerunButton + "</div>",
2987
+ "</article>",
2988
+ ].join("");
2989
+ })
2990
+ .join("");
2991
+ }
2992
+
2353
2993
  async function fetchJson(path, init) {
2354
2994
  const response = await fetch(path, init);
2355
2995
  const payload = await response.json().catch(() => ({}));
@@ -2456,12 +3096,24 @@ async function quickOpenMeeting() {
2456
3096
 
2457
3097
  async function refreshAll() {
2458
3098
  setStatus("Refreshing…", "busy");
2459
- const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2460
- 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
+ };
2461
3104
  renderAppState();
2462
3105
  setStatus("Connected", "ok");
2463
3106
  }
2464
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
+
2465
3117
  async function exportNotes() {
2466
3118
  setStatus("Exporting notes…", "busy");
2467
3119
  await fetchJson("/exports/notes", {
@@ -2482,6 +3134,76 @@ async function exportTranscripts() {
2482
3134
  await refreshAll();
2483
3135
  }
2484
3136
 
3137
+ async function rerunJob(id) {
3138
+ setStatus("Rerunning export…", "busy");
3139
+ try {
3140
+ await fetchJson("/exports/jobs/" + encodeURIComponent(id) + "/rerun", {
3141
+ method: "POST",
3142
+ });
3143
+ await refreshAll();
3144
+ } catch (error) {
3145
+ setStatus("Rerun failed", "error");
3146
+ state.detailError = error instanceof Error ? error.message : String(error);
3147
+ renderMeetingDetail();
3148
+ }
3149
+ }
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
+
2485
3207
  els.list.addEventListener("click", (event) => {
2486
3208
  if (!(event.target instanceof Element)) {
2487
3209
  return;
@@ -2492,6 +3214,49 @@ els.list.addEventListener("click", (event) => {
2492
3214
  void loadMeeting(button.dataset.meetingId);
2493
3215
  });
2494
3216
 
3217
+ els.jobsList.addEventListener("click", (event) => {
3218
+ if (!(event.target instanceof Element)) {
3219
+ return;
3220
+ }
3221
+
3222
+ const button = event.target.closest("[data-rerun-job-id]");
3223
+ if (!button) {
3224
+ return;
3225
+ }
3226
+
3227
+ void rerunJob(button.dataset.rerunJobId);
3228
+ });
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
+
2495
3260
  els.refreshButton.addEventListener("click", () => {
2496
3261
  void refreshAll();
2497
3262
  });
@@ -2690,6 +3455,20 @@ const granolaWebMarkup = String.raw`
2690
3455
  </div>
2691
3456
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2692
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>
3465
+ <section class="jobs-panel">
3466
+ <div class="jobs-panel__head">
3467
+ <h3>Recent Export Jobs</h3>
3468
+ <p>Tracked across CLI and web runs.</p>
3469
+ </div>
3470
+ <div class="jobs-list" data-jobs-list></div>
3471
+ </section>
2693
3472
  <nav class="workspace-tabs">
2694
3473
  <button class="workspace-tab" data-workspace-tab="notes">Notes</button>
2695
3474
  <button class="workspace-tab" data-workspace-tab="transcript">Transcript</button>
@@ -2912,6 +3691,123 @@ body {
2912
3691
  width: min(440px, 100%);
2913
3692
  }
2914
3693
 
3694
+ .auth-panel,
3695
+ .jobs-panel {
3696
+ padding: 0 24px 18px;
3697
+ }
3698
+
3699
+ .auth-panel__head h3,
3700
+ .jobs-panel__head h3 {
3701
+ margin: 0;
3702
+ font-size: 0.92rem;
3703
+ letter-spacing: 0.08em;
3704
+ text-transform: uppercase;
3705
+ }
3706
+
3707
+ .auth-panel__head p,
3708
+ .jobs-panel__head p {
3709
+ margin: 6px 0 0;
3710
+ color: var(--muted);
3711
+ font-size: 0.9rem;
3712
+ }
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
+
3744
+ .jobs-list {
3745
+ display: grid;
3746
+ gap: 10px;
3747
+ margin-top: 14px;
3748
+ }
3749
+
3750
+ .job-card {
3751
+ display: grid;
3752
+ gap: 10px;
3753
+ padding: 14px 16px;
3754
+ border: 1px solid var(--line);
3755
+ border-radius: 18px;
3756
+ background: rgba(255, 255, 255, 0.72);
3757
+ }
3758
+
3759
+ .job-card__head {
3760
+ display: flex;
3761
+ flex-wrap: wrap;
3762
+ align-items: center;
3763
+ justify-content: space-between;
3764
+ gap: 10px;
3765
+ }
3766
+
3767
+ .job-card__title {
3768
+ font-weight: 700;
3769
+ }
3770
+
3771
+ .job-card__meta {
3772
+ color: var(--muted);
3773
+ font-size: 0.9rem;
3774
+ }
3775
+
3776
+ .job-card__status {
3777
+ padding: 6px 10px;
3778
+ border-radius: 999px;
3779
+ background: var(--accent-soft);
3780
+ color: var(--accent);
3781
+ font-size: 0.82rem;
3782
+ font-weight: 700;
3783
+ }
3784
+
3785
+ .job-card__status[data-status="running"] {
3786
+ background: rgba(163, 79, 47, 0.12);
3787
+ color: var(--warm);
3788
+ }
3789
+
3790
+ .job-card__status[data-status="failed"] {
3791
+ background: rgba(157, 44, 44, 0.12);
3792
+ color: var(--error);
3793
+ }
3794
+
3795
+ .job-card__status[data-status="completed"] {
3796
+ background: rgba(36, 107, 79, 0.12);
3797
+ color: var(--ok);
3798
+ }
3799
+
3800
+ .job-card__actions {
3801
+ display: flex;
3802
+ flex-wrap: wrap;
3803
+ gap: 8px;
3804
+ }
3805
+
3806
+ .job-empty {
3807
+ color: var(--muted);
3808
+ font-size: 0.92rem;
3809
+ }
3810
+
2915
3811
  .workspace-tabs {
2916
3812
  display: flex;
2917
3813
  flex-wrap: wrap;
@@ -2952,6 +3848,11 @@ body {
2952
3848
  cursor: pointer;
2953
3849
  }
2954
3850
 
3851
+ .button:disabled {
3852
+ cursor: not-allowed;
3853
+ opacity: 0.56;
3854
+ }
3855
+
2955
3856
  .button--primary {
2956
3857
  background: var(--ink);
2957
3858
  color: white;
@@ -3099,6 +4000,13 @@ function parseMeetingSort(value) {
3099
4000
  default: throw new Error("invalid sort: expected updated-desc, updated-asc, title-asc, or title-desc");
3100
4001
  }
3101
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
+ }
3102
4010
  function sendJson(response, body, init = {}) {
3103
4011
  const payload = `${JSON.stringify(body, null, 2)}\n`;
3104
4012
  response.writeHead(init.status ?? 200, {
@@ -3181,6 +4089,10 @@ async function startGranolaServer(app, options = {}) {
3181
4089
  sendJson(response, app.getState());
3182
4090
  return;
3183
4091
  }
4092
+ if (method === "GET" && path === "/auth/status") {
4093
+ sendJson(response, await app.inspectAuth());
4094
+ return;
4095
+ }
3184
4096
  if (method === "GET" && path === "/events") {
3185
4097
  response.writeHead(200, {
3186
4098
  "cache-control": "no-cache, no-transform",
@@ -3234,11 +4146,41 @@ async function startGranolaServer(app, options = {}) {
3234
4146
  sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
3235
4147
  return;
3236
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
+ }
3237
4168
  if (method === "POST" && path === "/exports/notes") {
3238
4169
  const body = await readJsonBody(request);
3239
4170
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
3240
4171
  return;
3241
4172
  }
4173
+ if (method === "GET" && path === "/exports/jobs") {
4174
+ const limit = parseInteger(url.searchParams.get("limit"));
4175
+ sendJson(response, await app.listExportJobs({ limit }));
4176
+ return;
4177
+ }
4178
+ if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
4179
+ const id = decodeURIComponent(path.slice(14, -6));
4180
+ if (!id) throw new Error("export job id is required");
4181
+ sendJson(response, await app.rerunExportJob(id), { status: 202 });
4182
+ return;
4183
+ }
3242
4184
  if (method === "POST" && path === "/exports/transcripts") {
3243
4185
  const body = await readJsonBody(request);
3244
4186
  sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
@@ -3325,11 +4267,18 @@ const serveCommand = {
3325
4267
  console.log(`Granola server listening on ${server.url.href}`);
3326
4268
  console.log("Endpoints:");
3327
4269
  console.log(" GET /health");
4270
+ console.log(" GET /auth/status");
3328
4271
  console.log(" GET /state");
3329
4272
  console.log(" GET /events");
3330
4273
  console.log(" GET /meetings");
3331
4274
  console.log(" GET /meetings/:id");
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");
3332
4280
  console.log(" POST /exports/notes");
4281
+ console.log(" POST /exports/jobs/:id/rerun");
3333
4282
  console.log(" POST /exports/transcripts");
3334
4283
  await waitForShutdown(async () => await server.close());
3335
4284
  return 0;
@@ -3375,7 +4324,7 @@ const transcriptsCommand = {
3375
4324
  const app = await createGranolaApp(config);
3376
4325
  debug(config.debug, "authMode", app.getState().auth.mode);
3377
4326
  const result = await app.exportTranscripts(format);
3378
- console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir}`);
4327
+ console.log(`✓ Exported ${result.transcriptCount} transcripts to ${result.outputDir} (job ${result.job.id})`);
3379
4328
  debug(config.debug, "transcripts written", result.written);
3380
4329
  return 0;
3381
4330
  }
@@ -3445,6 +4394,7 @@ Options:
3445
4394
  //#region src/commands/index.ts
3446
4395
  const commands = [
3447
4396
  authCommand,
4397
+ exportsCommand,
3448
4398
  meetingCommand,
3449
4399
  notesCommand,
3450
4400
  serveCommand,
@@ -3483,11 +4433,18 @@ const commands = [
3483
4433
  console.log("Routes:");
3484
4434
  console.log(" GET /");
3485
4435
  console.log(" GET /health");
4436
+ console.log(" GET /auth/status");
3486
4437
  console.log(" GET /state");
3487
4438
  console.log(" GET /events");
3488
4439
  console.log(" GET /meetings");
3489
4440
  console.log(" GET /meetings/:id");
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");
3490
4446
  console.log(" POST /exports/notes");
4447
+ console.log(" POST /exports/jobs/:id/rerun");
3491
4448
  console.log(" POST /exports/transcripts");
3492
4449
  if (openBrowser) try {
3493
4450
  await openExternalUrl(server.url);