granola-toolkit 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/dist/cli.js +698 -33
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,6 +37,7 @@ granola meeting --help
37
37
  granola notes --help
38
38
  granola serve --help
39
39
  granola transcripts --help
40
+ granola web --help
40
41
  ```
41
42
 
42
43
  The published package exposes both `granola` and `granola-toolkit` as executable names.
@@ -50,6 +51,7 @@ node dist/cli.js meeting --help
50
51
  node dist/cli.js notes --help
51
52
  node dist/cli.js serve --help
52
53
  node dist/cli.js transcripts --help
54
+ node dist/cli.js web --help
53
55
  ```
54
56
 
55
57
  You can also use the package scripts:
@@ -97,6 +99,9 @@ Run the local API server:
97
99
  granola serve
98
100
  granola serve --port 4096
99
101
  granola serve --hostname 0.0.0.0 --port 4096
102
+
103
+ granola web
104
+ granola web --open=false --port 4096
100
105
  ```
101
106
 
102
107
  ## How It Works
@@ -193,6 +198,17 @@ The initial server API includes:
193
198
 
194
199
  This is the foundation for the future `granola web` client and any attachable TUI flows.
195
200
 
201
+ ### Web
202
+
203
+ `web` starts the same local server as `serve`, enables the browser client at `/`, and opens that workspace in your default browser unless you pass `--open=false`.
204
+
205
+ The initial browser client includes:
206
+
207
+ - a searchable meeting list
208
+ - a meeting detail view with notes and transcript panes
209
+ - app-state status from the shared core
210
+ - note and transcript export actions backed by the same local API
211
+
196
212
  ## Auth
197
213
 
198
214
  If you do not want to keep passing `--supabase`, import the desktop app session once:
package/dist/cli.js CHANGED
@@ -154,7 +154,7 @@ function transcriptSpeakerLabel(segment) {
154
154
  }
155
155
  //#endregion
156
156
  //#region src/client/auth.ts
157
- const execFileAsync = promisify(execFile);
157
+ const execFileAsync$1 = promisify(execFile);
158
158
  const DEFAULT_CLIENT_ID = "client_GranolaMac";
159
159
  const KEYCHAIN_SERVICE_NAME = "com.granola.toolkit";
160
160
  const KEYCHAIN_ACCOUNT_NAME = "session";
@@ -244,7 +244,7 @@ var FileSessionStore = class {
244
244
  var KeychainSessionStore = class {
245
245
  async clearSession() {
246
246
  try {
247
- await execFileAsync("security", [
247
+ await execFileAsync$1("security", [
248
248
  "delete-generic-password",
249
249
  "-s",
250
250
  KEYCHAIN_SERVICE_NAME,
@@ -255,7 +255,7 @@ var KeychainSessionStore = class {
255
255
  }
256
256
  async readSession() {
257
257
  try {
258
- const { stdout } = await execFileAsync("security", [
258
+ const { stdout } = await execFileAsync$1("security", [
259
259
  "find-generic-password",
260
260
  "-s",
261
261
  KEYCHAIN_SERVICE_NAME,
@@ -270,7 +270,7 @@ var KeychainSessionStore = class {
270
270
  }
271
271
  }
272
272
  async writeSession(session) {
273
- await execFileAsync("security", [
273
+ await execFileAsync$1("security", [
274
274
  "add-generic-password",
275
275
  "-U",
276
276
  "-s",
@@ -1767,6 +1767,41 @@ async function loadConfig(options) {
1767
1767
  function debug(enabled, ...values) {
1768
1768
  if (enabled) console.error("[debug]", ...values);
1769
1769
  }
1770
+ function parsePort(value) {
1771
+ if (value === void 0) return;
1772
+ if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
1773
+ const port = Number(value);
1774
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
1775
+ return port;
1776
+ }
1777
+ function pickHostname(value, fallback = "127.0.0.1") {
1778
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
1779
+ }
1780
+ async function waitForShutdown(close) {
1781
+ await new Promise((resolve, reject) => {
1782
+ let closing = false;
1783
+ const cleanup = () => {
1784
+ process.off("SIGINT", handleSignal);
1785
+ process.off("SIGTERM", handleSignal);
1786
+ };
1787
+ const finish = async () => {
1788
+ if (closing) return;
1789
+ closing = true;
1790
+ cleanup();
1791
+ try {
1792
+ await close();
1793
+ resolve();
1794
+ } catch (error) {
1795
+ reject(error);
1796
+ }
1797
+ };
1798
+ const handleSignal = () => {
1799
+ finish();
1800
+ };
1801
+ process.on("SIGINT", handleSignal);
1802
+ process.on("SIGTERM", handleSignal);
1803
+ });
1804
+ }
1770
1805
  //#endregion
1771
1806
  //#region src/commands/meeting.ts
1772
1807
  function meetingHelp() {
@@ -2032,6 +2067,548 @@ function resolveNoteFormat(value) {
2032
2067
  }
2033
2068
  }
2034
2069
  //#endregion
2070
+ //#region src/server/web.ts
2071
+ function renderGranolaWebPage() {
2072
+ return `<!doctype html>
2073
+ <html lang="en">
2074
+ <head>
2075
+ <meta charset="utf-8" />
2076
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2077
+ <title>Granola Toolkit</title>
2078
+ <style>
2079
+ :root {
2080
+ --bg: #f2ede2;
2081
+ --panel: rgba(255, 252, 247, 0.86);
2082
+ --panel-strong: #fffaf2;
2083
+ --line: rgba(36, 39, 44, 0.12);
2084
+ --ink: #1d242c;
2085
+ --muted: #5d6b77;
2086
+ --accent: #0d6a6d;
2087
+ --accent-soft: rgba(13, 106, 109, 0.12);
2088
+ --warm: #a34f2f;
2089
+ --ok: #246b4f;
2090
+ --error: #9d2c2c;
2091
+ --shadow: 0 24px 80px rgba(40, 32, 16, 0.12);
2092
+ --radius: 24px;
2093
+ --mono: "SF Mono", "IBM Plex Mono", "Cascadia Code", monospace;
2094
+ --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
2095
+ --sans: "Avenir Next", "Segoe UI", sans-serif;
2096
+ }
2097
+
2098
+ * { box-sizing: border-box; }
2099
+
2100
+ body {
2101
+ margin: 0;
2102
+ min-height: 100vh;
2103
+ font-family: var(--sans);
2104
+ color: var(--ink);
2105
+ background:
2106
+ radial-gradient(circle at top left, rgba(163, 79, 47, 0.18), transparent 32%),
2107
+ radial-gradient(circle at right 12%, rgba(13, 106, 109, 0.16), transparent 28%),
2108
+ linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%);
2109
+ }
2110
+
2111
+ .shell {
2112
+ display: grid;
2113
+ grid-template-columns: 320px minmax(0, 1fr);
2114
+ gap: 18px;
2115
+ min-height: 100vh;
2116
+ padding: 24px;
2117
+ }
2118
+
2119
+ .pane {
2120
+ background: var(--panel);
2121
+ backdrop-filter: blur(18px);
2122
+ border: 1px solid var(--line);
2123
+ border-radius: var(--radius);
2124
+ box-shadow: var(--shadow);
2125
+ }
2126
+
2127
+ .sidebar {
2128
+ display: grid;
2129
+ grid-template-rows: auto auto 1fr;
2130
+ overflow: hidden;
2131
+ }
2132
+
2133
+ .hero, .toolbar, .detail-head {
2134
+ padding: 22px 24px;
2135
+ border-bottom: 1px solid var(--line);
2136
+ }
2137
+
2138
+ .hero h1 {
2139
+ margin: 0;
2140
+ font-family: var(--serif);
2141
+ font-size: clamp(2rem, 3vw, 2.8rem);
2142
+ font-weight: 600;
2143
+ letter-spacing: -0.04em;
2144
+ }
2145
+
2146
+ .hero p, .toolbar p {
2147
+ margin: 8px 0 0;
2148
+ color: var(--muted);
2149
+ line-height: 1.5;
2150
+ }
2151
+
2152
+ .search {
2153
+ width: 100%;
2154
+ margin-top: 16px;
2155
+ padding: 12px 14px;
2156
+ border: 1px solid var(--line);
2157
+ border-radius: 999px;
2158
+ background: rgba(255, 255, 255, 0.7);
2159
+ color: var(--ink);
2160
+ font: inherit;
2161
+ }
2162
+
2163
+ .meeting-list {
2164
+ padding: 14px;
2165
+ overflow: auto;
2166
+ }
2167
+
2168
+ .meeting-row {
2169
+ width: 100%;
2170
+ display: grid;
2171
+ gap: 4px;
2172
+ text-align: left;
2173
+ margin: 0 0 10px;
2174
+ padding: 14px 16px;
2175
+ border: 1px solid transparent;
2176
+ border-radius: 18px;
2177
+ background: rgba(255, 255, 255, 0.72);
2178
+ color: inherit;
2179
+ cursor: pointer;
2180
+ transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
2181
+ }
2182
+
2183
+ .meeting-row:hover,
2184
+ .meeting-row[data-selected="true"] {
2185
+ transform: translateY(-1px);
2186
+ border-color: rgba(13, 106, 109, 0.25);
2187
+ background: var(--panel-strong);
2188
+ }
2189
+
2190
+ .meeting-row__title {
2191
+ font-weight: 600;
2192
+ }
2193
+
2194
+ .meeting-row__meta {
2195
+ color: var(--muted);
2196
+ font-size: 0.92rem;
2197
+ }
2198
+
2199
+ .meeting-empty {
2200
+ padding: 18px;
2201
+ color: var(--muted);
2202
+ }
2203
+
2204
+ .detail {
2205
+ display: grid;
2206
+ grid-template-rows: auto auto 1fr;
2207
+ min-width: 0;
2208
+ }
2209
+
2210
+ .detail-head {
2211
+ display: flex;
2212
+ align-items: center;
2213
+ justify-content: space-between;
2214
+ gap: 18px;
2215
+ }
2216
+
2217
+ .detail-head h2 {
2218
+ margin: 0;
2219
+ font-family: var(--serif);
2220
+ font-size: clamp(1.8rem, 2.4vw, 2.4rem);
2221
+ font-weight: 600;
2222
+ }
2223
+
2224
+ .state-badge {
2225
+ padding: 10px 14px;
2226
+ border-radius: 999px;
2227
+ background: var(--accent-soft);
2228
+ color: var(--accent);
2229
+ font-size: 0.92rem;
2230
+ font-weight: 700;
2231
+ }
2232
+
2233
+ .state-badge[data-tone="busy"] { color: var(--warm); background: rgba(163, 79, 47, 0.12); }
2234
+ .state-badge[data-tone="error"] { color: var(--error); background: rgba(157, 44, 44, 0.12); }
2235
+ .state-badge[data-tone="ok"] { color: var(--ok); background: rgba(36, 107, 79, 0.12); }
2236
+
2237
+ .toolbar {
2238
+ display: flex;
2239
+ flex-wrap: wrap;
2240
+ align-items: center;
2241
+ justify-content: space-between;
2242
+ gap: 14px;
2243
+ }
2244
+
2245
+ .toolbar-actions {
2246
+ display: flex;
2247
+ flex-wrap: wrap;
2248
+ gap: 10px;
2249
+ }
2250
+
2251
+ .button {
2252
+ border: 0;
2253
+ border-radius: 999px;
2254
+ padding: 12px 16px;
2255
+ font: inherit;
2256
+ font-weight: 700;
2257
+ cursor: pointer;
2258
+ }
2259
+
2260
+ .button--primary {
2261
+ background: var(--ink);
2262
+ color: white;
2263
+ }
2264
+
2265
+ .button--secondary {
2266
+ background: rgba(255, 255, 255, 0.72);
2267
+ color: var(--ink);
2268
+ border: 1px solid var(--line);
2269
+ }
2270
+
2271
+ .status-grid {
2272
+ display: grid;
2273
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2274
+ gap: 14px;
2275
+ }
2276
+
2277
+ .status-label {
2278
+ display: block;
2279
+ margin-bottom: 6px;
2280
+ color: var(--muted);
2281
+ font-size: 0.78rem;
2282
+ letter-spacing: 0.08em;
2283
+ text-transform: uppercase;
2284
+ }
2285
+
2286
+ .detail-meta {
2287
+ display: flex;
2288
+ flex-wrap: wrap;
2289
+ gap: 10px;
2290
+ padding: 0 24px 18px;
2291
+ }
2292
+
2293
+ .detail-chip {
2294
+ padding: 10px 12px;
2295
+ border-radius: 999px;
2296
+ background: rgba(255, 255, 255, 0.72);
2297
+ border: 1px solid var(--line);
2298
+ color: var(--muted);
2299
+ font-size: 0.88rem;
2300
+ }
2301
+
2302
+ .detail-body {
2303
+ padding: 0 24px 24px;
2304
+ overflow: auto;
2305
+ }
2306
+
2307
+ .detail-section {
2308
+ margin-bottom: 20px;
2309
+ padding: 20px;
2310
+ background: rgba(255, 255, 255, 0.72);
2311
+ border: 1px solid var(--line);
2312
+ border-radius: 20px;
2313
+ }
2314
+
2315
+ .detail-section h2 {
2316
+ margin: 0 0 14px;
2317
+ font-size: 1rem;
2318
+ letter-spacing: 0.08em;
2319
+ text-transform: uppercase;
2320
+ }
2321
+
2322
+ .detail-pre {
2323
+ margin: 0;
2324
+ white-space: pre-wrap;
2325
+ word-break: break-word;
2326
+ font-family: var(--mono);
2327
+ line-height: 1.55;
2328
+ }
2329
+
2330
+ .empty {
2331
+ margin: 24px;
2332
+ padding: 24px;
2333
+ border-radius: 20px;
2334
+ background: rgba(255, 255, 255, 0.72);
2335
+ border: 1px dashed rgba(36, 39, 44, 0.2);
2336
+ color: var(--muted);
2337
+ }
2338
+
2339
+ @media (max-width: 900px) {
2340
+ .shell {
2341
+ grid-template-columns: 1fr;
2342
+ }
2343
+ }
2344
+ </style>
2345
+ </head>
2346
+ <body>
2347
+ <div class="shell">
2348
+ <aside class="pane sidebar">
2349
+ <section class="hero">
2350
+ <h1>Granola Toolkit</h1>
2351
+ <p>Browser workspace for meetings, notes, transcripts, and export flows on top of one local server instance.</p>
2352
+ <input class="search" data-search placeholder="Search meetings, ids, or tags" />
2353
+ </section>
2354
+ <section class="toolbar">
2355
+ <p>Meetings are loaded from the shared server state so this view can later coexist with the terminal UI.</p>
2356
+ </section>
2357
+ <section class="meeting-list" data-meeting-list></section>
2358
+ </aside>
2359
+ <main class="pane detail">
2360
+ <section class="detail-head">
2361
+ <div>
2362
+ <h2>Meeting Workspace</h2>
2363
+ <div data-app-state></div>
2364
+ </div>
2365
+ <div class="state-badge" data-state-badge data-tone="idle">Connecting…</div>
2366
+ </section>
2367
+ <section class="toolbar">
2368
+ <div class="toolbar-actions">
2369
+ <button class="button button--primary" data-refresh>Refresh</button>
2370
+ <button class="button button--secondary" data-export-notes>Export Notes</button>
2371
+ <button class="button button--secondary" data-export-transcripts>Export Transcripts</button>
2372
+ </div>
2373
+ <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
2374
+ </section>
2375
+ <div class="detail-meta" data-detail-meta></div>
2376
+ <div class="detail-body" data-detail-body>
2377
+ <div class="empty" data-empty>Select a meeting to inspect its notes and transcript.</div>
2378
+ </div>
2379
+ </main>
2380
+ </div>
2381
+ <script type="module">
2382
+ ${String.raw`
2383
+ const state = {
2384
+ meetings: [],
2385
+ selectedMeetingId: null,
2386
+ selectedMeeting: null,
2387
+ appState: null,
2388
+ search: "",
2389
+ };
2390
+
2391
+ const els = {
2392
+ appState: document.querySelector("[data-app-state]"),
2393
+ detailBody: document.querySelector("[data-detail-body]"),
2394
+ detailMeta: document.querySelector("[data-detail-meta]"),
2395
+ empty: document.querySelector("[data-empty]"),
2396
+ list: document.querySelector("[data-meeting-list]"),
2397
+ noteButton: document.querySelector("[data-export-notes]"),
2398
+ refreshButton: document.querySelector("[data-refresh]"),
2399
+ search: document.querySelector("[data-search]"),
2400
+ stateBadge: document.querySelector("[data-state-badge]"),
2401
+ transcriptButton: document.querySelector("[data-export-transcripts]"),
2402
+ };
2403
+
2404
+ function escapeHtml(value) {
2405
+ return value
2406
+ .replaceAll("&", "&amp;")
2407
+ .replaceAll("<", "&lt;")
2408
+ .replaceAll(">", "&gt;")
2409
+ .replaceAll('"', "&quot;");
2410
+ }
2411
+
2412
+ function setStatus(label, tone = "idle") {
2413
+ els.stateBadge.textContent = label;
2414
+ els.stateBadge.dataset.tone = tone;
2415
+ }
2416
+
2417
+ function renderAppState() {
2418
+ if (!state.appState) {
2419
+ els.appState.innerHTML = "<p>Waiting for server state…</p>";
2420
+ return;
2421
+ }
2422
+
2423
+ const appState = state.appState;
2424
+ const authMode = appState.auth.mode === "stored-session" ? "Stored session" : "supabase.json";
2425
+ const docs = appState.documents.loaded ? String(appState.documents.count) : "not loaded";
2426
+ const cache = appState.cache.loaded
2427
+ ? appState.cache.transcriptCount + " transcript sets"
2428
+ : appState.cache.configured
2429
+ ? "configured"
2430
+ : "not configured";
2431
+
2432
+ els.appState.innerHTML = [
2433
+ '<div class="status-grid">',
2434
+ '<div><span class="status-label">Surface</span><strong>' + escapeHtml(appState.ui.surface) + "</strong></div>",
2435
+ '<div><span class="status-label">View</span><strong>' + escapeHtml(appState.ui.view) + "</strong></div>",
2436
+ '<div><span class="status-label">Auth</span><strong>' + escapeHtml(authMode) + "</strong></div>",
2437
+ '<div><span class="status-label">Documents</span><strong>' + escapeHtml(docs) + "</strong></div>",
2438
+ '<div><span class="status-label">Cache</span><strong>' + escapeHtml(cache) + "</strong></div>",
2439
+ "</div>",
2440
+ ].join("");
2441
+ }
2442
+
2443
+ function renderMeetingList() {
2444
+ if (state.meetings.length === 0) {
2445
+ state.selectedMeetingId = null;
2446
+ state.selectedMeeting = null;
2447
+ els.list.innerHTML = '<div class="meeting-empty">No meetings yet. Try Refresh.</div>';
2448
+ renderMeetingDetail();
2449
+ return;
2450
+ }
2451
+
2452
+ const visibleIds = new Set(state.meetings.map((meeting) => meeting.id));
2453
+ if (!state.selectedMeetingId || !visibleIds.has(state.selectedMeetingId)) {
2454
+ state.selectedMeetingId = state.meetings[0]?.id || null;
2455
+ }
2456
+
2457
+ els.list.innerHTML = state.meetings
2458
+ .map((meeting) => {
2459
+ const selected = meeting.id === state.selectedMeetingId ? ' data-selected="true"' : "";
2460
+ const tags = meeting.tags.length ? meeting.tags.map((tag) => "#" + tag).join(" ") : "untagged";
2461
+ return [
2462
+ '<button class="meeting-row"' + selected + ' data-meeting-id="' + escapeHtml(meeting.id) + '">',
2463
+ '<span class="meeting-row__title">' + escapeHtml(meeting.title || meeting.id) + "</span>",
2464
+ '<span class="meeting-row__meta">' + escapeHtml(tags) + "</span>",
2465
+ '<span class="meeting-row__meta">' + escapeHtml(meeting.updatedAt.slice(0, 10) || "unknown") + "</span>",
2466
+ "</button>",
2467
+ ].join("");
2468
+ })
2469
+ .join("");
2470
+ }
2471
+
2472
+ function renderMeetingDetail() {
2473
+ const record = state.selectedMeeting;
2474
+ if (!record) {
2475
+ els.empty.hidden = false;
2476
+ els.detailMeta.innerHTML = "";
2477
+ els.detailBody.innerHTML = "";
2478
+ return;
2479
+ }
2480
+
2481
+ els.empty.hidden = true;
2482
+ els.detailMeta.innerHTML = [
2483
+ '<div class="detail-chip">ID: ' + escapeHtml(record.meeting.id) + "</div>",
2484
+ '<div class="detail-chip">Source: ' + escapeHtml(record.meeting.noteContentSource) + "</div>",
2485
+ '<div class="detail-chip">Transcript: ' + escapeHtml(String(record.meeting.transcriptSegmentCount)) + " segments</div>",
2486
+ ].join("");
2487
+
2488
+ els.detailBody.innerHTML = [
2489
+ '<section class="detail-section">',
2490
+ "<h2>Notes</h2>",
2491
+ '<pre class="detail-pre">' + escapeHtml(record.noteMarkdown || "") + "</pre>",
2492
+ "</section>",
2493
+ '<section class="detail-section">',
2494
+ "<h2>Transcript</h2>",
2495
+ '<pre class="detail-pre">' + escapeHtml(record.transcriptText || "(Transcript unavailable)") + "</pre>",
2496
+ "</section>",
2497
+ ].join("");
2498
+ }
2499
+
2500
+ async function fetchJson(path, init) {
2501
+ const response = await fetch(path, init);
2502
+ const payload = await response.json().catch(() => ({}));
2503
+ if (!response.ok) {
2504
+ throw new Error(payload.error || response.statusText || "Request failed");
2505
+ }
2506
+ return payload;
2507
+ }
2508
+
2509
+ async function loadMeetings() {
2510
+ const query = state.search ? "?search=" + encodeURIComponent(state.search) + "&limit=50" : "?limit=50";
2511
+ const payload = await fetchJson("/meetings" + query);
2512
+ state.meetings = payload.meetings || [];
2513
+ if (!state.selectedMeetingId && state.meetings[0]) {
2514
+ state.selectedMeetingId = state.meetings[0].id;
2515
+ }
2516
+ renderMeetingList();
2517
+ if (state.selectedMeetingId) {
2518
+ await loadMeeting(state.selectedMeetingId);
2519
+ } else {
2520
+ renderMeetingDetail();
2521
+ }
2522
+ }
2523
+
2524
+ async function loadMeeting(id) {
2525
+ state.selectedMeetingId = id;
2526
+ renderMeetingList();
2527
+ const payload = await fetchJson("/meetings/" + encodeURIComponent(id));
2528
+ state.selectedMeeting = payload.meeting || null;
2529
+ renderMeetingDetail();
2530
+ }
2531
+
2532
+ async function refreshAll() {
2533
+ setStatus("Refreshing…", "busy");
2534
+ const [appState] = await Promise.all([fetchJson("/state"), loadMeetings()]);
2535
+ state.appState = appState;
2536
+ renderAppState();
2537
+ setStatus("Connected", "ok");
2538
+ }
2539
+
2540
+ async function exportNotes() {
2541
+ setStatus("Exporting notes…", "busy");
2542
+ await fetchJson("/exports/notes", {
2543
+ body: JSON.stringify({ format: "markdown" }),
2544
+ headers: { "content-type": "application/json" },
2545
+ method: "POST",
2546
+ });
2547
+ await refreshAll();
2548
+ }
2549
+
2550
+ async function exportTranscripts() {
2551
+ setStatus("Exporting transcripts…", "busy");
2552
+ await fetchJson("/exports/transcripts", {
2553
+ body: JSON.stringify({ format: "text" }),
2554
+ headers: { "content-type": "application/json" },
2555
+ method: "POST",
2556
+ });
2557
+ await refreshAll();
2558
+ }
2559
+
2560
+ els.list.addEventListener("click", (event) => {
2561
+ if (!(event.target instanceof Element)) {
2562
+ return;
2563
+ }
2564
+
2565
+ const button = event.target.closest("[data-meeting-id]");
2566
+ if (!button) return;
2567
+ void loadMeeting(button.dataset.meetingId);
2568
+ });
2569
+
2570
+ els.refreshButton.addEventListener("click", () => {
2571
+ void refreshAll();
2572
+ });
2573
+
2574
+ els.noteButton.addEventListener("click", () => {
2575
+ void exportNotes();
2576
+ });
2577
+
2578
+ els.transcriptButton.addEventListener("click", () => {
2579
+ void exportTranscripts();
2580
+ });
2581
+
2582
+ els.search.addEventListener("input", (event) => {
2583
+ if (!(event.target instanceof HTMLInputElement)) {
2584
+ return;
2585
+ }
2586
+
2587
+ state.search = event.target.value.trim();
2588
+ void loadMeetings();
2589
+ });
2590
+
2591
+ const events = new EventSource("/events");
2592
+ events.addEventListener("state.updated", (event) => {
2593
+ const payload = JSON.parse(event.data);
2594
+ state.appState = payload.state;
2595
+ renderAppState();
2596
+ });
2597
+ events.addEventListener("error", () => {
2598
+ setStatus("Disconnected", "error");
2599
+ });
2600
+
2601
+ void refreshAll().catch((error) => {
2602
+ setStatus("Error", "error");
2603
+ els.empty.hidden = false;
2604
+ els.empty.textContent = error.message;
2605
+ });
2606
+ `}
2607
+ <\/script>
2608
+ </body>
2609
+ </html>`;
2610
+ }
2611
+ //#endregion
2035
2612
  //#region src/server/http.ts
2036
2613
  function parseInteger(value) {
2037
2614
  if (!value?.trim()) return;
@@ -2055,6 +2632,13 @@ function sendText(response, body, status = 200) {
2055
2632
  });
2056
2633
  response.end(body);
2057
2634
  }
2635
+ function sendHtml(response, body, status = 200) {
2636
+ response.writeHead(status, {
2637
+ "content-length": Buffer.byteLength(body),
2638
+ "content-type": "text/html; charset=utf-8"
2639
+ });
2640
+ response.end(body);
2641
+ }
2058
2642
  async function readJsonBody(request) {
2059
2643
  const chunks = [];
2060
2644
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -2091,6 +2675,7 @@ function transcriptFormatFromBody(value) {
2091
2675
  }
2092
2676
  }
2093
2677
  async function startGranolaServer(app, options = {}) {
2678
+ const enableWebClient = options.enableWebClient ?? false;
2094
2679
  const hostname = options.hostname ?? "127.0.0.1";
2095
2680
  const port = options.port ?? 0;
2096
2681
  const server = createServer(async (request, response) => {
@@ -2098,6 +2683,10 @@ async function startGranolaServer(app, options = {}) {
2098
2683
  const url = new URL(request.url ?? "/", `http://${hostname}`);
2099
2684
  const path = url.pathname;
2100
2685
  try {
2686
+ if (method === "GET" && path === "/" && enableWebClient) {
2687
+ sendHtml(response, renderGranolaWebPage());
2688
+ return;
2689
+ }
2101
2690
  if (method === "GET" && path === "/health") {
2102
2691
  sendJson(response, {
2103
2692
  ok: true,
@@ -2212,13 +2801,6 @@ Options:
2212
2801
  -h, --help Show help
2213
2802
  `;
2214
2803
  }
2215
- function parsePort(value) {
2216
- if (value === void 0) return;
2217
- if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("invalid port: expected a non-negative integer");
2218
- const port = Number(value);
2219
- if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid port: expected a value between 0 and 65535");
2220
- return port;
2221
- }
2222
2804
  const serveCommand = {
2223
2805
  description: "Start a local Granola API server",
2224
2806
  flags: {
@@ -2240,7 +2822,7 @@ const serveCommand = {
2240
2822
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2241
2823
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2242
2824
  const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
2243
- hostname: typeof commandFlags.hostname === "string" && commandFlags.hostname.trim() ? commandFlags.hostname.trim() : "127.0.0.1",
2825
+ hostname: pickHostname(commandFlags.hostname),
2244
2826
  port: parsePort(commandFlags.port)
2245
2827
  });
2246
2828
  console.log(`Granola server listening on ${server.url.href}`);
@@ -2252,26 +2834,7 @@ const serveCommand = {
2252
2834
  console.log(" GET /meetings/:id");
2253
2835
  console.log(" POST /exports/notes");
2254
2836
  console.log(" POST /exports/transcripts");
2255
- await new Promise((resolve, reject) => {
2256
- let closing = false;
2257
- const close = async () => {
2258
- if (closing) return;
2259
- closing = true;
2260
- process.off("SIGINT", handleSignal);
2261
- process.off("SIGTERM", handleSignal);
2262
- try {
2263
- await server.close();
2264
- resolve();
2265
- } catch (error) {
2266
- reject(error);
2267
- }
2268
- };
2269
- const handleSignal = () => {
2270
- close();
2271
- };
2272
- process.on("SIGINT", handleSignal);
2273
- process.on("SIGTERM", handleSignal);
2274
- });
2837
+ await waitForShutdown(async () => await server.close());
2275
2838
  return 0;
2276
2839
  }
2277
2840
  };
@@ -2331,13 +2894,115 @@ function resolveTranscriptFormat(value) {
2331
2894
  }
2332
2895
  }
2333
2896
  //#endregion
2897
+ //#region src/browser.ts
2898
+ const execFileAsync = promisify(execFile);
2899
+ function getBrowserOpenCommand(url, platform = process.platform) {
2900
+ const href = String(url);
2901
+ switch (platform) {
2902
+ case "darwin": return {
2903
+ args: [href],
2904
+ file: "open"
2905
+ };
2906
+ case "win32": return {
2907
+ args: [
2908
+ "/c",
2909
+ "start",
2910
+ "",
2911
+ href
2912
+ ],
2913
+ file: "cmd"
2914
+ };
2915
+ default: return {
2916
+ args: [href],
2917
+ file: "xdg-open"
2918
+ };
2919
+ }
2920
+ }
2921
+ async function openExternalUrl(url, options = {}) {
2922
+ const command = getBrowserOpenCommand(url, options.platform);
2923
+ await (options.run ?? (async (file, args) => {
2924
+ await execFileAsync(file, args);
2925
+ }))(command.file, command.args);
2926
+ }
2927
+ //#endregion
2928
+ //#region src/commands/web.ts
2929
+ function webHelp() {
2930
+ return `Granola web
2931
+
2932
+ Usage:
2933
+ granola web [options]
2934
+
2935
+ Options:
2936
+ --hostname <value> Hostname to bind (default: 127.0.0.1)
2937
+ --port <value> Port to bind (default: 0 for any available port)
2938
+ --cache <path> Path to Granola cache JSON
2939
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2940
+ --supabase <path> Path to supabase.json
2941
+ --open[=true|false] Open the browser automatically (default: true)
2942
+ --debug Enable debug logging
2943
+ --config <path> Path to .granola.toml
2944
+ -h, --help Show help
2945
+ `;
2946
+ }
2947
+ //#endregion
2334
2948
  //#region src/commands/index.ts
2335
2949
  const commands = [
2336
2950
  authCommand,
2337
2951
  meetingCommand,
2338
2952
  notesCommand,
2339
2953
  serveCommand,
2340
- transcriptsCommand
2954
+ transcriptsCommand,
2955
+ {
2956
+ description: "Start the Granola Toolkit web workspace",
2957
+ flags: {
2958
+ cache: { type: "string" },
2959
+ help: { type: "boolean" },
2960
+ hostname: { type: "string" },
2961
+ open: { type: "boolean" },
2962
+ port: { type: "string" },
2963
+ timeout: { type: "string" }
2964
+ },
2965
+ help: webHelp,
2966
+ name: "web",
2967
+ async run({ commandFlags, globalFlags }) {
2968
+ const config = await loadConfig({
2969
+ globalFlags,
2970
+ subcommandFlags: commandFlags
2971
+ });
2972
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2973
+ debug(config.debug, "supabase", config.supabase);
2974
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2975
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2976
+ const app = await createGranolaApp(config, { surface: "web" });
2977
+ const hostname = pickHostname(commandFlags.hostname);
2978
+ const port = parsePort(commandFlags.port);
2979
+ const openBrowser = commandFlags.open !== false;
2980
+ const server = await startGranolaServer(app, {
2981
+ enableWebClient: true,
2982
+ hostname,
2983
+ port
2984
+ });
2985
+ console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
2986
+ console.log("Routes:");
2987
+ console.log(" GET /");
2988
+ console.log(" GET /health");
2989
+ console.log(" GET /state");
2990
+ console.log(" GET /events");
2991
+ console.log(" GET /meetings");
2992
+ console.log(" GET /meetings/:id");
2993
+ console.log(" POST /exports/notes");
2994
+ console.log(" POST /exports/transcripts");
2995
+ if (openBrowser) try {
2996
+ await openExternalUrl(server.url);
2997
+ } catch (error) {
2998
+ const message = error instanceof Error ? error.message : String(error);
2999
+ console.error(`failed to open browser automatically: ${message}`);
3000
+ console.error(`open ${server.url.href} manually`);
3001
+ }
3002
+ await waitForShutdown(async () => await server.close());
3003
+ return 0;
3004
+ }
3005
+ }
2341
3006
  ];
2342
3007
  const commandMap = new Map(commands.map((command) => [command.name, command]));
2343
3008
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",