granola-toolkit 0.28.0 → 0.30.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 +13 -0
  2. package/dist/cli.js +374 -63
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -208,6 +208,7 @@ The machine-readable `export` command includes:
208
208
  The initial server API includes:
209
209
 
210
210
  - `GET /health`
211
+ - `GET /server/info`
211
212
  - `POST /auth/unlock` for password-protected servers
212
213
  - `POST /auth/lock` to clear the browser/API unlock cookie
213
214
  - `GET /auth/status`
@@ -272,6 +273,16 @@ Use it when you want:
272
273
 
273
274
  The attach flow uses the existing local HTTP API plus `GET /events` for live state updates.
274
275
 
276
+ ### Runtime Boundaries
277
+
278
+ The toolkit now keeps its local persistence and transport contracts explicit:
279
+
280
+ - one shared local data directory for export jobs, meeting index data, and any file-backed session state
281
+ - one versioned local HTTP transport contract, exposed by `GET /server/info`
282
+ - one remote client handshake that validates the transport protocol before attaching
283
+
284
+ That keeps the current single-package repo simple, while making a future split into separate server/client packages or remote-hosted clients much less invasive.
285
+
275
286
  ### TUI
276
287
 
277
288
  `tui` starts a full-screen terminal workspace on the shared app core, without requiring the local server or browser client. Use `attach` when you want the same workspace against an existing shared server instance instead.
@@ -280,6 +291,7 @@ The initial terminal workspace includes:
280
291
 
281
292
  - a meeting list pane with keyboard navigation
282
293
  - a detail pane with notes, transcript, metadata, and raw views
294
+ - an auth session overlay for import, refresh, source switching, and sign-out
283
295
  - a footer with app state and key hints
284
296
  - a quick-open overlay for jumping by title, id, or tag
285
297
 
@@ -287,6 +299,7 @@ The main keyboard controls are:
287
299
 
288
300
  - `j` / `k` or arrow keys to move between meetings
289
301
  - `/` or `Ctrl+P` to open quick open
302
+ - `a` to open auth session actions
290
303
  - `1`-`4` to switch detail tabs
291
304
  - `PageUp` / `PageDown` to scroll the detail pane
292
305
  - `r` to refresh from live Granola data
package/dist/cli.js CHANGED
@@ -9,6 +9,60 @@ import { NodeHtmlMarkdown } from "node-html-markdown";
9
9
  import { execFile } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
11
  import { createServer } from "node:http";
12
+ //#region src/transport.ts
13
+ const granolaTransportPaths = {
14
+ authLock: "/auth/lock",
15
+ authLogin: "/auth/login",
16
+ authLogout: "/auth/logout",
17
+ authMode: "/auth/mode",
18
+ authRefresh: "/auth/refresh",
19
+ authStatus: "/auth/status",
20
+ authUnlock: "/auth/unlock",
21
+ events: "/events",
22
+ exportJobs: "/exports/jobs",
23
+ exportNotes: "/exports/notes",
24
+ exportTranscripts: "/exports/transcripts",
25
+ health: "/health",
26
+ meetingResolve: "/meetings/resolve",
27
+ meetings: "/meetings",
28
+ root: "/",
29
+ serverInfo: "/server/info",
30
+ state: "/state"
31
+ };
32
+ function appendSearchParams(path, params) {
33
+ const url = new URL(path, "http://localhost");
34
+ for (const [key, value] of Object.entries(params)) {
35
+ if (value === void 0 || value === false || value === "") continue;
36
+ url.searchParams.set(key, String(value));
37
+ }
38
+ return `${url.pathname}${url.search}`;
39
+ }
40
+ function granolaMeetingPath(id) {
41
+ return `${granolaTransportPaths.meetings}/${encodeURIComponent(id)}`;
42
+ }
43
+ function granolaMeetingResolvePath(query, options = {}) {
44
+ return appendSearchParams(granolaTransportPaths.meetingResolve, {
45
+ includeTranscript: options.includeTranscript ? "true" : void 0,
46
+ q: query
47
+ });
48
+ }
49
+ function granolaMeetingsPath(options = {}) {
50
+ return appendSearchParams(granolaTransportPaths.meetings, {
51
+ limit: options.limit,
52
+ refresh: options.forceRefresh ? "true" : void 0,
53
+ search: options.search,
54
+ sort: options.sort,
55
+ updatedFrom: options.updatedFrom,
56
+ updatedTo: options.updatedTo
57
+ });
58
+ }
59
+ function granolaExportJobsPath(options = {}) {
60
+ return appendSearchParams(granolaTransportPaths.exportJobs, { limit: options.limit });
61
+ }
62
+ function granolaExportJobRerunPath(id) {
63
+ return `${granolaTransportPaths.exportJobs}/${encodeURIComponent(id)}/rerun`;
64
+ }
65
+ //#endregion
12
66
  //#region src/server/client.ts
13
67
  function cloneValue(value) {
14
68
  return structuredClone(value);
@@ -24,14 +78,6 @@ function normaliseServerUrl(serverUrl) {
24
78
  parsed.hash = "";
25
79
  return parsed;
26
80
  }
27
- function appendSearchParams(path, params) {
28
- const url = new URL(path, "http://localhost");
29
- for (const [key, value] of Object.entries(params)) {
30
- if (value === void 0 || value === false || value === "") continue;
31
- url.searchParams.set(key, String(value));
32
- }
33
- return `${url.pathname}${url.search}`;
34
- }
35
81
  function mergeHeaders(...values) {
36
82
  const headers = new Headers();
37
83
  for (const value of values) {
@@ -66,22 +112,32 @@ var GranolaServerClient = class GranolaServerClient {
66
112
  #fetchImpl;
67
113
  #password;
68
114
  #reconnectDelayMs;
115
+ info;
69
116
  #streamAbortController;
70
- constructor(url, initialState, options = {}) {
117
+ constructor(info, url, initialState, options = {}) {
71
118
  this.url = url;
72
119
  this.#fetchImpl = options.fetchImpl ?? fetch;
120
+ this.info = cloneValue(info);
73
121
  this.#password = options.password?.trim() || void 0;
74
122
  this.#reconnectDelayMs = options.reconnectDelayMs ?? 1e3;
75
123
  this.#state = cloneValue(initialState);
76
124
  }
77
125
  static async connect(serverUrl, options = {}) {
78
126
  const url = normaliseServerUrl(serverUrl);
79
- const response = await (options.fetchImpl ?? fetch)(new URL("/state", url), { headers: mergeHeaders({
127
+ const fetchImpl = options.fetchImpl ?? fetch;
128
+ const infoResponse = await fetchImpl(new URL(granolaTransportPaths.serverInfo, url), { headers: mergeHeaders({
129
+ ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
130
+ accept: "application/json"
131
+ }) });
132
+ if (!infoResponse.ok) throw await responseError(infoResponse);
133
+ const info = await infoResponse.json();
134
+ if (info.protocolVersion !== 1) throw new Error(`unsupported Granola transport protocol: expected 1, got ${info.protocolVersion}`);
135
+ const response = await fetchImpl(new URL(granolaTransportPaths.state, url), { headers: mergeHeaders({
80
136
  ...options.password?.trim() ? { "x-granola-password": options.password.trim() } : {},
81
137
  accept: "application/json"
82
138
  }) });
83
139
  if (!response.ok) throw await responseError(response);
84
- const client = new GranolaServerClient(url, await response.json(), options);
140
+ const client = new GranolaServerClient(info, url, await response.json(), options);
85
141
  client.startEvents();
86
142
  return client;
87
143
  }
@@ -103,66 +159,56 @@ var GranolaServerClient = class GranolaServerClient {
103
159
  };
104
160
  }
105
161
  async inspectAuth() {
106
- return await this.requestJson("/auth/status");
162
+ return await this.requestJson(granolaTransportPaths.authStatus);
107
163
  }
108
164
  async loginAuth(options = {}) {
109
- return await this.requestJson("/auth/login", {
165
+ return await this.requestJson(granolaTransportPaths.authLogin, {
110
166
  body: JSON.stringify(options),
111
167
  headers: { "content-type": "application/json" },
112
168
  method: "POST"
113
169
  });
114
170
  }
115
171
  async logoutAuth() {
116
- return await this.requestJson("/auth/logout", { method: "POST" });
172
+ return await this.requestJson(granolaTransportPaths.authLogout, { method: "POST" });
117
173
  }
118
174
  async refreshAuth() {
119
- return await this.requestJson("/auth/refresh", { method: "POST" });
175
+ return await this.requestJson(granolaTransportPaths.authRefresh, { method: "POST" });
120
176
  }
121
177
  async switchAuthMode(mode) {
122
- return await this.requestJson("/auth/mode", {
178
+ return await this.requestJson(granolaTransportPaths.authMode, {
123
179
  body: JSON.stringify({ mode }),
124
180
  headers: { "content-type": "application/json" },
125
181
  method: "POST"
126
182
  });
127
183
  }
128
184
  async listMeetings(options = {}) {
129
- return await this.requestJson(appendSearchParams("/meetings", {
130
- limit: options.limit,
131
- refresh: options.forceRefresh ? "true" : void 0,
132
- search: options.search,
133
- sort: options.sort,
134
- updatedFrom: options.updatedFrom,
135
- updatedTo: options.updatedTo
136
- }));
185
+ return await this.requestJson(granolaMeetingsPath(options));
137
186
  }
138
187
  async getMeeting(id, options = {}) {
139
- return await this.requestJson(appendSearchParams(`/meetings/${encodeURIComponent(id)}`, { includeTranscript: options.requireCache ? "true" : void 0 }));
188
+ return await this.requestJson(`${granolaMeetingPath(id)}${options.requireCache ? "?includeTranscript=true" : ""}`);
140
189
  }
141
190
  async findMeeting(query, options = {}) {
142
- return await this.requestJson(appendSearchParams("/meetings/resolve", {
143
- includeTranscript: options.requireCache ? "true" : void 0,
144
- q: query
145
- }));
191
+ return await this.requestJson(granolaMeetingResolvePath(query, { includeTranscript: options.requireCache }));
146
192
  }
147
193
  async listExportJobs(options = {}) {
148
- return await this.requestJson(appendSearchParams("/exports/jobs", { limit: options.limit }));
194
+ return await this.requestJson(granolaExportJobsPath(options));
149
195
  }
150
196
  async exportNotes(format = "markdown") {
151
- return await this.requestJson("/exports/notes", {
197
+ return await this.requestJson(granolaTransportPaths.exportNotes, {
152
198
  body: JSON.stringify({ format }),
153
199
  headers: { "content-type": "application/json" },
154
200
  method: "POST"
155
201
  });
156
202
  }
157
203
  async exportTranscripts(format = "text") {
158
- return await this.requestJson("/exports/transcripts", {
204
+ return await this.requestJson(granolaTransportPaths.exportTranscripts, {
159
205
  body: JSON.stringify({ format }),
160
206
  headers: { "content-type": "application/json" },
161
207
  method: "POST"
162
208
  });
163
209
  }
164
210
  async rerunExportJob(id) {
165
- return await this.requestJson(`/exports/jobs/${encodeURIComponent(id)}/rerun`, { method: "POST" });
211
+ return await this.requestJson(granolaExportJobRerunPath(id), { method: "POST" });
166
212
  }
167
213
  async request(path, init = {}) {
168
214
  const response = await this.#fetchImpl(new URL(path, this.url), {
@@ -192,7 +238,7 @@ var GranolaServerClient = class GranolaServerClient {
192
238
  const controller = new AbortController();
193
239
  this.#streamAbortController = controller;
194
240
  try {
195
- const response = await this.request("/events", {
241
+ const response = await this.request(granolaTransportPaths.events, {
196
242
  headers: { accept: "text/event-stream" },
197
243
  signal: controller.signal
198
244
  });
@@ -1305,6 +1351,151 @@ const granolaTuiTheme = {
1305
1351
  }
1306
1352
  };
1307
1353
  //#endregion
1354
+ //#region src/tui/auth.ts
1355
+ function padLine$2(text, width) {
1356
+ const clipped = truncateToWidth(text, width, "");
1357
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
1358
+ }
1359
+ function frameLine$1(text, width) {
1360
+ return `| ${padLine$2(text, Math.max(1, width - 4))} |`;
1361
+ }
1362
+ function actionDisabledReason(auth, actionId) {
1363
+ switch (actionId) {
1364
+ case "login": return auth.supabaseAvailable ? "" : "supabase.json unavailable";
1365
+ case "refresh":
1366
+ if (!auth.storedSessionAvailable) return "stored session missing";
1367
+ return auth.refreshAvailable ? "" : "refresh unavailable";
1368
+ case "use-stored":
1369
+ if (!auth.storedSessionAvailable) return "stored session missing";
1370
+ return auth.mode === "stored-session" ? "already active" : "";
1371
+ case "use-supabase":
1372
+ if (!auth.supabaseAvailable) return "supabase.json unavailable";
1373
+ return auth.mode === "supabase-file" ? "already active" : "";
1374
+ case "logout": return auth.storedSessionAvailable ? "" : "stored session missing";
1375
+ }
1376
+ }
1377
+ function buildGranolaTuiAuthActions(auth) {
1378
+ return [
1379
+ {
1380
+ description: "Import the Granola desktop session from supabase.json",
1381
+ id: "login",
1382
+ key: "1",
1383
+ label: "Import desktop session"
1384
+ },
1385
+ {
1386
+ description: "Refresh the stored Granola session",
1387
+ id: "refresh",
1388
+ key: "2",
1389
+ label: "Refresh stored session"
1390
+ },
1391
+ {
1392
+ description: "Switch the active auth source to the stored session",
1393
+ id: "use-stored",
1394
+ key: "3",
1395
+ label: "Use stored session"
1396
+ },
1397
+ {
1398
+ description: "Switch the active auth source to supabase.json",
1399
+ id: "use-supabase",
1400
+ key: "4",
1401
+ label: "Use supabase.json"
1402
+ },
1403
+ {
1404
+ description: "Delete the stored session and fall back to supabase.json",
1405
+ id: "logout",
1406
+ key: "5",
1407
+ label: "Sign out"
1408
+ }
1409
+ ].map((action) => {
1410
+ const disabledReason = actionDisabledReason(auth, action.id);
1411
+ return {
1412
+ ...action,
1413
+ disabled: disabledReason.length > 0,
1414
+ disabledReason: disabledReason || void 0
1415
+ };
1416
+ });
1417
+ }
1418
+ function renderGranolaTuiAuthState(auth) {
1419
+ const lines = [
1420
+ `Active source: ${auth.mode === "stored-session" ? "Stored session" : "supabase.json"}`,
1421
+ `Stored session: ${auth.storedSessionAvailable ? "available" : "missing"}`,
1422
+ `supabase.json: ${auth.supabaseAvailable ? "available" : "missing"}`,
1423
+ `Refresh: ${auth.refreshAvailable ? "available" : "missing"}`
1424
+ ];
1425
+ if (auth.clientId) lines.push(`Client ID: ${auth.clientId}`);
1426
+ if (auth.signInMethod) lines.push(`Sign-in method: ${auth.signInMethod}`);
1427
+ if (auth.supabasePath) lines.push(`supabase path: ${auth.supabasePath}`);
1428
+ if (auth.lastError) lines.push(`Last error: ${auth.lastError}`);
1429
+ return lines.join("\n");
1430
+ }
1431
+ function nextEnabledIndex(actions, startIndex, delta) {
1432
+ if (actions.length === 0) return -1;
1433
+ for (let attempts = 0; attempts < actions.length; attempts += 1) {
1434
+ const nextIndex = (startIndex + delta * (attempts + 1) + actions.length) % actions.length;
1435
+ if (!actions[nextIndex]?.disabled) return nextIndex;
1436
+ }
1437
+ return Math.max(0, Math.min(actions.length - 1, startIndex));
1438
+ }
1439
+ var GranolaTuiAuthOverlay = class {
1440
+ focused = false;
1441
+ #actions;
1442
+ #selectedIndex;
1443
+ constructor(options) {
1444
+ this.options = options;
1445
+ this.#actions = buildGranolaTuiAuthActions(this.options.auth);
1446
+ const firstEnabledIndex = this.#actions.findIndex((action) => !action.disabled);
1447
+ this.#selectedIndex = firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
1448
+ }
1449
+ invalidate() {}
1450
+ async runAction(actionId) {
1451
+ const action = this.#actions.find((candidate) => candidate.id === actionId);
1452
+ if (!action || action.disabled) return;
1453
+ await this.options.onRun(action.id);
1454
+ }
1455
+ handleInput(data) {
1456
+ if (matchesKey(data, "esc")) {
1457
+ this.options.onCancel();
1458
+ return;
1459
+ }
1460
+ if (matchesKey(data, "up")) {
1461
+ this.#selectedIndex = nextEnabledIndex(this.#actions, this.#selectedIndex, -1);
1462
+ return;
1463
+ }
1464
+ if (matchesKey(data, "down")) {
1465
+ this.#selectedIndex = nextEnabledIndex(this.#actions, this.#selectedIndex, 1);
1466
+ return;
1467
+ }
1468
+ const selected = this.#actions[this.#selectedIndex];
1469
+ if (matchesKey(data, "enter")) {
1470
+ if (selected && !selected.disabled) this.runAction(selected.id);
1471
+ return;
1472
+ }
1473
+ const hotkeyAction = this.#actions.find((action) => action.key === data);
1474
+ if (hotkeyAction && !hotkeyAction.disabled) this.runAction(hotkeyAction.id);
1475
+ }
1476
+ render(width) {
1477
+ const lines = [];
1478
+ const bodyWidth = Math.max(48, width);
1479
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1480
+ lines.push(frameLine$1(granolaTuiTheme.strong("Auth Session"), bodyWidth));
1481
+ lines.push(frameLine$1("", bodyWidth));
1482
+ for (const detailLine of renderGranolaTuiAuthState(this.options.auth).split("\n")) lines.push(frameLine$1(detailLine, bodyWidth));
1483
+ lines.push(frameLine$1("", bodyWidth));
1484
+ for (const action of this.#actions) {
1485
+ const selected = this.#actions[this.#selectedIndex]?.id === action.id;
1486
+ const label = `${action.key}. ${action.label}`;
1487
+ const titleLine = action.disabled ? granolaTuiTheme.dim(label) : selected ? granolaTuiTheme.selected(label) : label;
1488
+ lines.push(frameLine$1(titleLine, bodyWidth));
1489
+ const detail = action.disabled ? granolaTuiTheme.warning(action.disabledReason ?? "Unavailable") : granolaTuiTheme.dim(action.description);
1490
+ for (const wrapped of wrapTextWithAnsi(detail, Math.max(1, bodyWidth - 6))) lines.push(frameLine$1(` ${wrapped}`, bodyWidth));
1491
+ }
1492
+ lines.push(frameLine$1("", bodyWidth));
1493
+ lines.push(frameLine$1(granolaTuiTheme.dim("Enter to run, Esc to cancel, arrows to move"), bodyWidth));
1494
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
1495
+ return lines;
1496
+ }
1497
+ };
1498
+ //#endregion
1308
1499
  //#region src/tui/palette.ts
1309
1500
  function padLine$1(text, width) {
1310
1501
  const clipped = truncateToWidth(text, width, "");
@@ -1597,6 +1788,85 @@ var GranolaTuiWorkspace = class {
1597
1788
  this.#detailScroll = 0;
1598
1789
  this.tui.requestRender();
1599
1790
  }
1791
+ async reloadAfterAuthChange() {
1792
+ const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
1793
+ try {
1794
+ await this.loadMeetings({
1795
+ forceRefresh: true,
1796
+ preferredMeetingId,
1797
+ setStatus: false
1798
+ });
1799
+ if (this.#selectedMeetingId) {
1800
+ await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1801
+ return;
1802
+ }
1803
+ this.#selectedMeeting = void 0;
1804
+ this.#detailError = "";
1805
+ this.#detailScroll = 0;
1806
+ this.tui.requestRender();
1807
+ } catch {}
1808
+ }
1809
+ async runAuthAction(actionId) {
1810
+ let successMessage = "";
1811
+ try {
1812
+ switch (actionId) {
1813
+ case "login":
1814
+ this.setStatus("Importing desktop session…");
1815
+ await this.app.loginAuth();
1816
+ successMessage = "Stored session imported";
1817
+ break;
1818
+ case "refresh":
1819
+ this.setStatus("Refreshing stored session…");
1820
+ await this.app.refreshAuth();
1821
+ successMessage = "Stored session refreshed";
1822
+ break;
1823
+ case "use-stored":
1824
+ this.setStatus("Switching to stored session…");
1825
+ await this.app.switchAuthMode("stored-session");
1826
+ successMessage = "Using stored session";
1827
+ break;
1828
+ case "use-supabase":
1829
+ this.setStatus("Switching to supabase.json…");
1830
+ await this.app.switchAuthMode("supabase-file");
1831
+ successMessage = "Using supabase.json";
1832
+ break;
1833
+ case "logout":
1834
+ this.setStatus("Signing out…");
1835
+ await this.app.logoutAuth();
1836
+ successMessage = "Stored session removed";
1837
+ break;
1838
+ }
1839
+ await this.reloadAfterAuthChange();
1840
+ this.setStatus(successMessage);
1841
+ } catch (error) {
1842
+ const message = error instanceof Error ? error.message : String(error);
1843
+ this.setStatus(message, "error");
1844
+ }
1845
+ }
1846
+ openAuthPanel(auth = this.#appState.auth) {
1847
+ if (this.#overlay) return;
1848
+ const closeOverlay = () => {
1849
+ this.#overlay?.hide();
1850
+ this.#overlay = void 0;
1851
+ this.tui.setFocus(this);
1852
+ this.tui.requestRender();
1853
+ };
1854
+ const overlay = new GranolaTuiAuthOverlay({
1855
+ auth,
1856
+ onCancel: closeOverlay,
1857
+ onRun: async (actionId) => {
1858
+ closeOverlay();
1859
+ await this.runAuthAction(actionId);
1860
+ }
1861
+ });
1862
+ this.#overlay = this.tui.showOverlay(overlay, {
1863
+ anchor: "center",
1864
+ maxHeight: "70%",
1865
+ minWidth: 52,
1866
+ width: "72%"
1867
+ });
1868
+ this.setStatus("Auth session");
1869
+ }
1600
1870
  openQuickOpen() {
1601
1871
  if (this.#overlay) return;
1602
1872
  const closeOverlay = () => {
@@ -1641,6 +1911,10 @@ var GranolaTuiWorkspace = class {
1641
1911
  this.openQuickOpen();
1642
1912
  return;
1643
1913
  }
1914
+ if (matchesKey(data, "a")) {
1915
+ this.openAuthPanel();
1916
+ return;
1917
+ }
1644
1918
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
1645
1919
  this.moveSelection(-1);
1646
1920
  return;
@@ -1779,7 +2053,7 @@ var GranolaTuiWorkspace = class {
1779
2053
  const bodyLines = [];
1780
2054
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
1781
2055
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
1782
- const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2056
+ const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
1783
2057
  return [
1784
2058
  headerTitle,
1785
2059
  headerSummary,
@@ -1903,6 +2177,22 @@ function parseCacheContents(contents) {
1903
2177
  };
1904
2178
  }
1905
2179
  //#endregion
2180
+ //#region src/persistence/layout.ts
2181
+ function defaultGranolaToolkitDataDirectory(targetPlatform = platform(), homeDirectory = homedir()) {
2182
+ return targetPlatform === "darwin" ? join(homeDirectory, "Library", "Application Support", "granola-toolkit") : join(homeDirectory, ".config", "granola-toolkit");
2183
+ }
2184
+ function defaultGranolaToolkitPersistenceLayout(options = {}) {
2185
+ const targetPlatform = options.platform ?? platform();
2186
+ const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2187
+ return {
2188
+ dataDirectory,
2189
+ exportJobsFile: join(dataDirectory, "export-jobs.json"),
2190
+ meetingIndexFile: join(dataDirectory, "meeting-index.json"),
2191
+ sessionFile: join(dataDirectory, "session.json"),
2192
+ sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file"
2193
+ };
2194
+ }
2195
+ //#endregion
1906
2196
  //#region src/client/auth.ts
1907
2197
  const execFileAsync$1 = promisify(execFile);
1908
2198
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
@@ -2123,11 +2413,10 @@ async function refreshGranolaSession(session, fetchImpl = fetch) {
2123
2413
  };
2124
2414
  }
2125
2415
  function defaultSessionFilePath() {
2126
- const home = homedir();
2127
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "session.json") : join(home, ".config", "granola-toolkit", "session.json");
2416
+ return defaultGranolaToolkitPersistenceLayout().sessionFile;
2128
2417
  }
2129
2418
  function createDefaultSessionStore() {
2130
- return platform() === "darwin" ? new KeychainSessionStore() : new FileSessionStore();
2419
+ return defaultGranolaToolkitPersistenceLayout().sessionStoreKind === "keychain" ? new KeychainSessionStore() : new FileSessionStore();
2131
2420
  }
2132
2421
  //#endregion
2133
2422
  //#region src/client/default-auth.ts
@@ -2508,8 +2797,7 @@ function createExportJobId(kind) {
2508
2797
  return `${kind}-${randomUUID()}`;
2509
2798
  }
2510
2799
  function defaultExportJobsFilePath() {
2511
- const home = homedir();
2512
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "export-jobs.json") : join(home, ".config", "granola-toolkit", "export-jobs.json");
2800
+ return defaultGranolaToolkitPersistenceLayout().exportJobsFile;
2513
2801
  }
2514
2802
  var FileExportJobStore = class {
2515
2803
  constructor(filePath = defaultExportJobsFilePath()) {
@@ -2573,8 +2861,7 @@ var FileMeetingIndexStore = class {
2573
2861
  }
2574
2862
  };
2575
2863
  function defaultMeetingIndexFilePath() {
2576
- const home = homedir();
2577
- return platform() === "darwin" ? join(home, "Library", "Application Support", "granola-toolkit", "meeting-index.json") : join(home, ".config", "granola-toolkit", "meeting-index.json");
2864
+ return defaultGranolaToolkitPersistenceLayout().meetingIndexFile;
2578
2865
  }
2579
2866
  function createDefaultMeetingIndexStore() {
2580
2867
  return new FileMeetingIndexStore();
@@ -5127,7 +5414,7 @@ function isPasswordAuthenticated(request, password) {
5127
5414
  return parseCookies(request)[PASSWORD_COOKIE_NAME] === password;
5128
5415
  }
5129
5416
  function publicRoute(path, enableWebClient) {
5130
- return path === "/health" || path === "/auth/unlock" || enableWebClient && path === "/";
5417
+ return path === granolaTransportPaths.health || path === granolaTransportPaths.serverInfo || path === granolaTransportPaths.authUnlock || enableWebClient && path === granolaTransportPaths.root;
5131
5418
  }
5132
5419
  async function startGranolaServer(app, options = {}) {
5133
5420
  const enableWebClient = options.enableWebClient ?? false;
@@ -5137,6 +5424,24 @@ async function startGranolaServer(app, options = {}) {
5137
5424
  password: options.security?.password?.trim() || void 0,
5138
5425
  trustedOrigins: (options.security?.trustedOrigins ?? []).map((origin) => origin.trim()).filter(Boolean)
5139
5426
  };
5427
+ const serverInfo = {
5428
+ capabilities: {
5429
+ attach: true,
5430
+ auth: true,
5431
+ events: true,
5432
+ exports: true,
5433
+ meetingOpen: true,
5434
+ webClient: enableWebClient
5435
+ },
5436
+ persistence: {
5437
+ exportJobs: true,
5438
+ meetingIndex: true,
5439
+ sessionStore: defaultGranolaToolkitPersistenceLayout().sessionStoreKind
5440
+ },
5441
+ product: "granola-toolkit",
5442
+ protocolVersion: 1,
5443
+ transport: "local-http"
5444
+ };
5140
5445
  const server = createServer(async (request, response) => {
5141
5446
  const method = request.method ?? "GET";
5142
5447
  const url = new URL(request.url ?? "/", `http://${hostname}`);
@@ -5164,11 +5469,11 @@ async function startGranolaServer(app, options = {}) {
5164
5469
  sendNoContent(response, 204, originHeaders);
5165
5470
  return;
5166
5471
  }
5167
- if (method === "GET" && path === "/" && enableWebClient) {
5472
+ if (method === "GET" && path === granolaTransportPaths.root && enableWebClient) {
5168
5473
  sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
5169
5474
  return;
5170
5475
  }
5171
- if (method === "GET" && path === "/health") {
5476
+ if (method === "GET" && path === granolaTransportPaths.health) {
5172
5477
  sendJson(response, {
5173
5478
  ok: true,
5174
5479
  service: "granola-toolkit",
@@ -5176,7 +5481,11 @@ async function startGranolaServer(app, options = {}) {
5176
5481
  }, { headers: originHeaders });
5177
5482
  return;
5178
5483
  }
5179
- if (method === "POST" && path === "/auth/unlock") {
5484
+ if (method === "GET" && path === granolaTransportPaths.serverInfo) {
5485
+ sendJson(response, serverInfo, { headers: originHeaders });
5486
+ return;
5487
+ }
5488
+ if (method === "POST" && path === granolaTransportPaths.authUnlock) {
5180
5489
  if (!security.password) {
5181
5490
  sendJson(response, {
5182
5491
  ok: true,
@@ -5215,22 +5524,22 @@ async function startGranolaServer(app, options = {}) {
5215
5524
  });
5216
5525
  return;
5217
5526
  }
5218
- if (method === "GET" && path === "/state") {
5527
+ if (method === "GET" && path === granolaTransportPaths.state) {
5219
5528
  sendJson(response, app.getState(), { headers: originHeaders });
5220
5529
  return;
5221
5530
  }
5222
- if (method === "GET" && path === "/auth/status") {
5531
+ if (method === "GET" && path === granolaTransportPaths.authStatus) {
5223
5532
  sendJson(response, await app.inspectAuth(), { headers: originHeaders });
5224
5533
  return;
5225
5534
  }
5226
- if (method === "POST" && path === "/auth/lock") {
5535
+ if (method === "POST" && path === granolaTransportPaths.authLock) {
5227
5536
  sendJson(response, { ok: true }, { headers: {
5228
5537
  ...originHeaders,
5229
5538
  "set-cookie": clearPasswordCookieHeader()
5230
5539
  } });
5231
5540
  return;
5232
5541
  }
5233
- if (method === "GET" && path === "/events") {
5542
+ if (method === "GET" && path === granolaTransportPaths.events) {
5234
5543
  response.writeHead(200, {
5235
5544
  "cache-control": "no-cache, no-transform",
5236
5545
  connection: "keep-alive",
@@ -5251,7 +5560,7 @@ async function startGranolaServer(app, options = {}) {
5251
5560
  });
5252
5561
  return;
5253
5562
  }
5254
- if (method === "GET" && path === "/meetings") {
5563
+ if (method === "GET" && path === granolaTransportPaths.meetings) {
5255
5564
  const limit = parseInteger(url.searchParams.get("limit"));
5256
5565
  const refresh = url.searchParams.get("refresh") === "true";
5257
5566
  const search = url.searchParams.get("search")?.trim() || void 0;
@@ -5277,38 +5586,38 @@ async function startGranolaServer(app, options = {}) {
5277
5586
  }, { headers: originHeaders });
5278
5587
  return;
5279
5588
  }
5280
- if (method === "GET" && path === "/meetings/resolve") {
5589
+ if (method === "GET" && path === granolaTransportPaths.meetingResolve) {
5281
5590
  const query = url.searchParams.get("q")?.trim();
5282
5591
  if (!query) throw new Error("meeting query is required");
5283
5592
  sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5284
5593
  return;
5285
5594
  }
5286
- if (method === "GET" && path.startsWith("/meetings/")) {
5287
- const id = decodeURIComponent(path.slice(10));
5595
+ if (method === "GET" && path.startsWith(`${granolaTransportPaths.meetings}/`) && path !== granolaTransportPaths.meetingResolve) {
5596
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.meetings}/`.length));
5288
5597
  if (!id) throw new Error("meeting id is required");
5289
5598
  sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
5290
5599
  return;
5291
5600
  }
5292
- if (method === "POST" && path === "/auth/login") {
5601
+ if (method === "POST" && path === granolaTransportPaths.authLogin) {
5293
5602
  const body = await readJsonBody(request);
5294
5603
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
5295
5604
  sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
5296
5605
  return;
5297
5606
  }
5298
- if (method === "POST" && path === "/auth/logout") {
5607
+ if (method === "POST" && path === granolaTransportPaths.authLogout) {
5299
5608
  sendJson(response, await app.logoutAuth(), { headers: originHeaders });
5300
5609
  return;
5301
5610
  }
5302
- if (method === "POST" && path === "/auth/refresh") {
5611
+ if (method === "POST" && path === granolaTransportPaths.authRefresh) {
5303
5612
  sendJson(response, await app.refreshAuth(), { headers: originHeaders });
5304
5613
  return;
5305
5614
  }
5306
- if (method === "POST" && path === "/auth/mode") {
5615
+ if (method === "POST" && path === granolaTransportPaths.authMode) {
5307
5616
  const body = await readJsonBody(request);
5308
5617
  sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
5309
5618
  return;
5310
5619
  }
5311
- if (method === "POST" && path === "/exports/notes") {
5620
+ if (method === "POST" && path === granolaTransportPaths.exportNotes) {
5312
5621
  const body = await readJsonBody(request);
5313
5622
  sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
5314
5623
  headers: originHeaders,
@@ -5316,13 +5625,13 @@ async function startGranolaServer(app, options = {}) {
5316
5625
  });
5317
5626
  return;
5318
5627
  }
5319
- if (method === "GET" && path === "/exports/jobs") {
5628
+ if (method === "GET" && path === granolaTransportPaths.exportJobs) {
5320
5629
  const limit = parseInteger(url.searchParams.get("limit"));
5321
5630
  sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
5322
5631
  return;
5323
5632
  }
5324
- if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
5325
- const id = decodeURIComponent(path.slice(14, -6));
5633
+ if (method === "POST" && path.startsWith(`${granolaTransportPaths.exportJobs}/`) && path.endsWith("/rerun")) {
5634
+ const id = decodeURIComponent(path.slice(`${granolaTransportPaths.exportJobs}/`.length, -6));
5326
5635
  if (!id) throw new Error("export job id is required");
5327
5636
  sendJson(response, await app.rerunExportJob(id), {
5328
5637
  headers: originHeaders,
@@ -5330,7 +5639,7 @@ async function startGranolaServer(app, options = {}) {
5330
5639
  });
5331
5640
  return;
5332
5641
  }
5333
- if (method === "POST" && path === "/exports/transcripts") {
5642
+ if (method === "POST" && path === granolaTransportPaths.exportTranscripts) {
5334
5643
  const body = await readJsonBody(request);
5335
5644
  sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
5336
5645
  headers: originHeaders,
@@ -5402,6 +5711,7 @@ function printWebRoutes() {
5402
5711
  console.log("Routes:");
5403
5712
  console.log(" GET /");
5404
5713
  console.log(" GET /health");
5714
+ console.log(" GET /server/info");
5405
5715
  console.log(" POST /auth/unlock");
5406
5716
  console.log(" POST /auth/lock");
5407
5717
  console.log(" GET /auth/status");
@@ -5814,6 +6124,7 @@ const serveCommand = {
5814
6124
  if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
5815
6125
  console.log("Endpoints:");
5816
6126
  console.log(" GET /health");
6127
+ console.log(" GET /server/info");
5817
6128
  console.log(" POST /auth/unlock");
5818
6129
  console.log(" POST /auth/lock");
5819
6130
  console.log(" GET /auth/status");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",