granola-toolkit 0.24.0 → 0.26.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 -0
  2. package/dist/cli.js +1006 -51
  3. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { createServer } from "node:http";
11
+ import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
11
12
  //#region src/utils.ts
12
13
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
13
14
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -2355,6 +2356,22 @@ function parsePort(value) {
2355
2356
  function pickHostname(value, fallback = "127.0.0.1") {
2356
2357
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
2357
2358
  }
2359
+ function parseNetworkMode(value, fallback = "local") {
2360
+ switch (value) {
2361
+ case void 0: return fallback;
2362
+ case "lan":
2363
+ case "local": return value;
2364
+ default: throw new Error("invalid network mode: expected local or lan");
2365
+ }
2366
+ }
2367
+ function resolveServerHostname(networkMode, hostnameFlag) {
2368
+ if (hostnameFlag !== void 0) return pickHostname(hostnameFlag, networkMode === "lan" ? "0.0.0.0" : "127.0.0.1");
2369
+ return networkMode === "lan" ? "0.0.0.0" : "127.0.0.1";
2370
+ }
2371
+ function parseTrustedOrigins(value) {
2372
+ if (typeof value !== "string" || !value.trim()) return [];
2373
+ return value.split(",").map((origin) => origin.trim()).filter(Boolean);
2374
+ }
2358
2375
  async function waitForShutdown(close) {
2359
2376
  await new Promise((resolve, reject) => {
2360
2377
  let closing = false;
@@ -2845,6 +2862,8 @@ function resolveNoteFormat(value) {
2845
2862
  //#endregion
2846
2863
  //#region src/web/client-script.ts
2847
2864
  const granolaWebClientScript = String.raw`
2865
+ const serverConfig = window.__GRANOLA_SERVER__ || { passwordRequired: false };
2866
+
2848
2867
  const state = {
2849
2868
  appState: null,
2850
2869
  detailError: "",
@@ -2856,6 +2875,7 @@ const state = {
2856
2875
  selectedMeetingBundle: null,
2857
2876
  selectedMeetingId: null,
2858
2877
  meetingSource: "live",
2878
+ serverLocked: Boolean(serverConfig.passwordRequired),
2859
2879
  sort: "updated-desc",
2860
2880
  updatedFrom: "",
2861
2881
  updatedTo: "",
@@ -2875,9 +2895,13 @@ const els = {
2875
2895
  quickOpenButton: document.querySelector("[data-quick-open-button]"),
2876
2896
  refreshButton: document.querySelector("[data-refresh]"),
2877
2897
  search: document.querySelector("[data-search]"),
2898
+ securityPanel: document.querySelector("[data-security-panel]"),
2899
+ serverPassword: document.querySelector("[data-server-password]"),
2900
+ lockServerButton: document.querySelector("[data-lock-server]"),
2878
2901
  sort: document.querySelector("[data-sort]"),
2879
2902
  stateBadge: document.querySelector("[data-state-badge]"),
2880
2903
  transcriptButton: document.querySelector("[data-export-transcripts]"),
2904
+ unlockServerButton: document.querySelector("[data-unlock-server]"),
2881
2905
  updatedFrom: document.querySelector("[data-updated-from]"),
2882
2906
  updatedTo: document.querySelector("[data-updated-to]"),
2883
2907
  workspaceTabs: document.querySelectorAll("[data-workspace-tab]"),
@@ -2932,6 +2956,7 @@ function renderAppState() {
2932
2956
  if (!state.appState) {
2933
2957
  els.appState.innerHTML = "<p>Waiting for server state…</p>";
2934
2958
  els.authPanel.innerHTML = "<p>Waiting for auth state…</p>";
2959
+ renderSecurityPanel();
2935
2960
  return;
2936
2961
  }
2937
2962
 
@@ -2960,10 +2985,15 @@ function renderAppState() {
2960
2985
  "</div>",
2961
2986
  ].join("");
2962
2987
 
2988
+ renderSecurityPanel();
2963
2989
  renderAuthPanel();
2964
2990
  renderExportJobs();
2965
2991
  }
2966
2992
 
2993
+ function renderSecurityPanel() {
2994
+ els.securityPanel.hidden = !state.serverLocked;
2995
+ }
2996
+
2967
2997
  function authActionButton(label, action, disabled) {
2968
2998
  return (
2969
2999
  '<button class="button button--secondary" data-auth-action="' +
@@ -3181,7 +3211,14 @@ async function fetchJson(path, init) {
3181
3211
  const response = await fetch(path, init);
3182
3212
  const payload = await response.json().catch(() => ({}));
3183
3213
  if (!response.ok) {
3184
- throw new Error(payload.error || response.statusText || "Request failed");
3214
+ if (payload.authRequired) {
3215
+ state.serverLocked = true;
3216
+ renderSecurityPanel();
3217
+ }
3218
+
3219
+ const error = new Error(payload.error || response.statusText || "Request failed");
3220
+ error.authRequired = Boolean(payload.authRequired);
3221
+ throw error;
3185
3222
  }
3186
3223
  return payload;
3187
3224
  }
@@ -3289,17 +3326,28 @@ async function quickOpenMeeting() {
3289
3326
 
3290
3327
  async function refreshAll(forceLiveMeetings = false) {
3291
3328
  setStatus("Refreshing…", "busy");
3292
- const [appState, authState] = await Promise.all([
3293
- fetchJson("/state"),
3294
- fetchJson("/auth/status"),
3295
- loadMeetings({ refresh: forceLiveMeetings }),
3296
- ]);
3297
- state.appState = {
3298
- ...appState,
3299
- auth: authState,
3300
- };
3301
- renderAppState();
3302
- setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
3329
+ try {
3330
+ const [appState, authState] = await Promise.all([
3331
+ fetchJson("/state"),
3332
+ fetchJson("/auth/status"),
3333
+ loadMeetings({ refresh: forceLiveMeetings }),
3334
+ ]);
3335
+ state.serverLocked = false;
3336
+ state.appState = {
3337
+ ...appState,
3338
+ auth: authState,
3339
+ };
3340
+ renderAppState();
3341
+ setStatus(forceLiveMeetings ? "Live data refreshed" : state.meetingSource === "index" ? "Loaded from index" : "Connected", "ok");
3342
+ } catch (error) {
3343
+ if (error.authRequired) {
3344
+ setStatus("Server locked", "error");
3345
+ renderSecurityPanel();
3346
+ return;
3347
+ }
3348
+
3349
+ throw error;
3350
+ }
3303
3351
  }
3304
3352
 
3305
3353
  async function syncAuthState() {
@@ -3401,6 +3449,50 @@ async function switchAuthMode(mode) {
3401
3449
  }
3402
3450
  }
3403
3451
 
3452
+ async function unlockServer() {
3453
+ const password = els.serverPassword.value;
3454
+ if (!password.trim()) {
3455
+ setStatus("Enter the server password", "error");
3456
+ return;
3457
+ }
3458
+
3459
+ setStatus("Unlocking server…", "busy");
3460
+ try {
3461
+ await fetchJson("/auth/unlock", {
3462
+ body: JSON.stringify({ password }),
3463
+ headers: { "content-type": "application/json" },
3464
+ method: "POST",
3465
+ });
3466
+ els.serverPassword.value = "";
3467
+ state.serverLocked = false;
3468
+ await refreshAll(true);
3469
+ } catch (error) {
3470
+ setStatus("Unlock failed", "error");
3471
+ state.detailError = error instanceof Error ? error.message : String(error);
3472
+ renderMeetingDetail();
3473
+ }
3474
+ }
3475
+
3476
+ async function lockServer() {
3477
+ try {
3478
+ await fetchJson("/auth/lock", {
3479
+ method: "POST",
3480
+ });
3481
+ } catch {}
3482
+
3483
+ state.serverLocked = true;
3484
+ state.appState = null;
3485
+ state.meetings = [];
3486
+ state.selectedMeeting = null;
3487
+ state.selectedMeetingBundle = null;
3488
+ state.detailError = "";
3489
+ els.serverPassword.value = "";
3490
+ renderSecurityPanel();
3491
+ renderMeetingList();
3492
+ renderMeetingDetail();
3493
+ setStatus("Server locked", "error");
3494
+ }
3495
+
3404
3496
  els.list.addEventListener("click", (event) => {
3405
3497
  if (!(event.target instanceof Element)) {
3406
3498
  return;
@@ -3454,6 +3546,25 @@ els.authPanel.addEventListener("click", (event) => {
3454
3546
  void switchAuthMode(modeButton.dataset.authMode);
3455
3547
  });
3456
3548
 
3549
+ els.unlockServerButton.addEventListener("click", () => {
3550
+ void unlockServer();
3551
+ });
3552
+
3553
+ els.lockServerButton.addEventListener("click", () => {
3554
+ void lockServer();
3555
+ });
3556
+
3557
+ els.serverPassword.addEventListener("keydown", (event) => {
3558
+ if (!(event.target instanceof HTMLInputElement)) {
3559
+ return;
3560
+ }
3561
+
3562
+ if (event.key === "Enter") {
3563
+ event.preventDefault();
3564
+ void unlockServer();
3565
+ }
3566
+ });
3567
+
3457
3568
  els.refreshButton.addEventListener("click", () => {
3458
3569
  void refreshAll(true);
3459
3570
  });
@@ -3600,6 +3711,7 @@ events.addEventListener("error", () => {
3600
3711
  });
3601
3712
 
3602
3713
  syncFilterInputs();
3714
+ renderSecurityPanel();
3603
3715
 
3604
3716
  void refreshAll().catch((error) => {
3605
3717
  setStatus("Error", "error");
@@ -3663,6 +3775,19 @@ const granolaWebMarkup = String.raw`
3663
3775
  </div>
3664
3776
  <p>Initial beta web client. It speaks to the same local API that future TUI and attach flows will use.</p>
3665
3777
  </section>
3778
+ <section class="security-panel" data-security-panel hidden>
3779
+ <div class="security-panel__head">
3780
+ <h3>Server Access</h3>
3781
+ <p>This server is locked with a password. Unlock it to load meetings and live state.</p>
3782
+ </div>
3783
+ <div class="security-panel__body">
3784
+ <input class="field-input" data-server-password type="password" placeholder="Server password" />
3785
+ <div class="toolbar-actions">
3786
+ <button class="button button--primary" data-unlock-server>Unlock</button>
3787
+ <button class="button button--secondary" data-lock-server>Lock</button>
3788
+ </div>
3789
+ </div>
3790
+ </section>
3666
3791
  <section class="auth-panel">
3667
3792
  <div class="auth-panel__head">
3668
3793
  <h3>Auth Session</h3>
@@ -3900,10 +4025,12 @@ body {
3900
4025
  }
3901
4026
 
3902
4027
  .auth-panel,
4028
+ .security-panel,
3903
4029
  .jobs-panel {
3904
4030
  padding: 0 24px 18px;
3905
4031
  }
3906
4032
 
4033
+ .security-panel__head h3,
3907
4034
  .auth-panel__head h3,
3908
4035
  .jobs-panel__head h3 {
3909
4036
  margin: 0;
@@ -3912,6 +4039,7 @@ body {
3912
4039
  text-transform: uppercase;
3913
4040
  }
3914
4041
 
4042
+ .security-panel__head p,
3915
4043
  .auth-panel__head p,
3916
4044
  .jobs-panel__head p {
3917
4045
  margin: 6px 0 0;
@@ -3919,6 +4047,7 @@ body {
3919
4047
  font-size: 0.9rem;
3920
4048
  }
3921
4049
 
4050
+ .security-panel__body,
3922
4051
  .auth-panel__body {
3923
4052
  display: grid;
3924
4053
  gap: 12px;
@@ -4169,7 +4298,7 @@ body {
4169
4298
  `;
4170
4299
  //#endregion
4171
4300
  //#region src/server/web.ts
4172
- function renderGranolaWebPage() {
4301
+ function renderGranolaWebPage(options = {}) {
4173
4302
  return `<!doctype html>
4174
4303
  <html lang="en">
4175
4304
  <head>
@@ -4183,6 +4312,7 @@ ${granolaWebStyles}
4183
4312
  <body>
4184
4313
  ${granolaWebMarkup}
4185
4314
  <script type="module">
4315
+ window.__GRANOLA_SERVER__ = ${JSON.stringify({ passwordRequired: options.serverPasswordRequired ?? false })};
4186
4316
  ${granolaWebClientScript}
4187
4317
  <\/script>
4188
4318
  </body>
@@ -4190,6 +4320,7 @@ ${granolaWebClientScript}
4190
4320
  }
4191
4321
  //#endregion
4192
4322
  //#region src/server/http.ts
4323
+ const PASSWORD_COOKIE_NAME = "granola_toolkit_password";
4193
4324
  function parseInteger(value) {
4194
4325
  if (!value?.trim()) return;
4195
4326
  if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
@@ -4219,24 +4350,31 @@ function sendJson(response, body, init = {}) {
4219
4350
  const payload = `${JSON.stringify(body, null, 2)}\n`;
4220
4351
  response.writeHead(init.status ?? 200, {
4221
4352
  "content-length": Buffer.byteLength(payload),
4222
- "content-type": "application/json; charset=utf-8"
4353
+ "content-type": "application/json; charset=utf-8",
4354
+ ...init.headers
4223
4355
  });
4224
4356
  response.end(payload);
4225
4357
  }
4226
- function sendText(response, body, status = 200) {
4358
+ function sendText(response, body, status = 200, headers = {}) {
4227
4359
  response.writeHead(status, {
4228
4360
  "content-length": Buffer.byteLength(body),
4229
- "content-type": "text/plain; charset=utf-8"
4361
+ "content-type": "text/plain; charset=utf-8",
4362
+ ...headers
4230
4363
  });
4231
4364
  response.end(body);
4232
4365
  }
4233
- function sendHtml(response, body, status = 200) {
4366
+ function sendHtml(response, body, status = 200, headers = {}) {
4234
4367
  response.writeHead(status, {
4235
4368
  "content-length": Buffer.byteLength(body),
4236
- "content-type": "text/html; charset=utf-8"
4369
+ "content-type": "text/html; charset=utf-8",
4370
+ ...headers
4237
4371
  });
4238
4372
  response.end(body);
4239
4373
  }
4374
+ function sendNoContent(response, status = 204, headers = {}) {
4375
+ response.writeHead(status, headers);
4376
+ response.end();
4377
+ }
4240
4378
  async function readJsonBody(request) {
4241
4379
  const chunks = [];
4242
4380
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -4272,17 +4410,90 @@ function transcriptFormatFromBody(value) {
4272
4410
  default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
4273
4411
  }
4274
4412
  }
4413
+ function parseCookies(request) {
4414
+ const header = request.headers.cookie;
4415
+ if (!header) return {};
4416
+ const cookies = {};
4417
+ for (const chunk of header.split(";")) {
4418
+ const [name, ...valueParts] = chunk.trim().split("=");
4419
+ if (!name) continue;
4420
+ cookies[name] = decodeURIComponent(valueParts.join("="));
4421
+ }
4422
+ return cookies;
4423
+ }
4424
+ function passwordCookieHeader(password) {
4425
+ return `${PASSWORD_COOKIE_NAME}=${encodeURIComponent(password)}; HttpOnly; Path=/; SameSite=Strict`;
4426
+ }
4427
+ function clearPasswordCookieHeader() {
4428
+ return `${PASSWORD_COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict`;
4429
+ }
4430
+ function allowedOriginHeaders(origin) {
4431
+ return {
4432
+ "access-control-allow-credentials": "true",
4433
+ "access-control-allow-headers": "content-type, x-granola-password",
4434
+ "access-control-allow-methods": "GET, POST, OPTIONS",
4435
+ "access-control-allow-origin": origin,
4436
+ vary: "Origin"
4437
+ };
4438
+ }
4439
+ function isTrustedOrigin(origin, request, trustedOrigins) {
4440
+ if (!origin) return true;
4441
+ try {
4442
+ const parsed = new URL(origin);
4443
+ const host = request.headers.host;
4444
+ if (host && parsed.host === host) return true;
4445
+ } catch {
4446
+ return false;
4447
+ }
4448
+ return trustedOrigins.includes(origin);
4449
+ }
4450
+ function isPasswordAuthenticated(request, password) {
4451
+ const headerPassword = request.headers["x-granola-password"];
4452
+ if (typeof headerPassword === "string" && headerPassword === password) return true;
4453
+ const authorization = request.headers.authorization;
4454
+ if (authorization?.startsWith("Bearer ")) return authorization.slice(7) === password;
4455
+ return parseCookies(request)[PASSWORD_COOKIE_NAME] === password;
4456
+ }
4457
+ function publicRoute(path, enableWebClient) {
4458
+ return path === "/health" || path === "/auth/unlock" || enableWebClient && path === "/";
4459
+ }
4275
4460
  async function startGranolaServer(app, options = {}) {
4276
4461
  const enableWebClient = options.enableWebClient ?? false;
4277
4462
  const hostname = options.hostname ?? "127.0.0.1";
4278
4463
  const port = options.port ?? 0;
4464
+ const security = {
4465
+ password: options.security?.password?.trim() || void 0,
4466
+ trustedOrigins: (options.security?.trustedOrigins ?? []).map((origin) => origin.trim()).filter(Boolean)
4467
+ };
4279
4468
  const server = createServer(async (request, response) => {
4280
4469
  const method = request.method ?? "GET";
4281
4470
  const url = new URL(request.url ?? "/", `http://${hostname}`);
4282
4471
  const path = url.pathname;
4472
+ const origin = request.headers.origin?.trim();
4473
+ const trustedOrigin = isTrustedOrigin(origin, request, security.trustedOrigins);
4474
+ const originHeaders = origin && trustedOrigin ? allowedOriginHeaders(origin) : {};
4283
4475
  try {
4476
+ if (origin && !trustedOrigin) {
4477
+ sendJson(response, { error: `origin not trusted: ${origin}` }, {
4478
+ headers: originHeaders,
4479
+ status: 403
4480
+ });
4481
+ return;
4482
+ }
4483
+ if (method === "OPTIONS") {
4484
+ if (!origin) {
4485
+ sendNoContent(response, 204);
4486
+ return;
4487
+ }
4488
+ if (!trustedOrigin) {
4489
+ sendNoContent(response, 403);
4490
+ return;
4491
+ }
4492
+ sendNoContent(response, 204, originHeaders);
4493
+ return;
4494
+ }
4284
4495
  if (method === "GET" && path === "/" && enableWebClient) {
4285
- sendHtml(response, renderGranolaWebPage());
4496
+ sendHtml(response, renderGranolaWebPage({ serverPasswordRequired: Boolean(security.password) }), 200, originHeaders);
4286
4497
  return;
4287
4498
  }
4288
4499
  if (method === "GET" && path === "/health") {
@@ -4290,22 +4501,69 @@ async function startGranolaServer(app, options = {}) {
4290
4501
  ok: true,
4291
4502
  service: "granola-toolkit",
4292
4503
  version: app.config ? void 0 : void 0
4504
+ }, { headers: originHeaders });
4505
+ return;
4506
+ }
4507
+ if (method === "POST" && path === "/auth/unlock") {
4508
+ if (!security.password) {
4509
+ sendJson(response, {
4510
+ ok: true,
4511
+ passwordRequired: false
4512
+ }, { headers: originHeaders });
4513
+ return;
4514
+ }
4515
+ const body = await readJsonBody(request);
4516
+ const password = typeof body.password === "string" && body.password.trim() ? body.password : void 0;
4517
+ if (!password || password !== security.password) {
4518
+ sendJson(response, {
4519
+ authRequired: true,
4520
+ error: "invalid server password"
4521
+ }, {
4522
+ headers: originHeaders,
4523
+ status: 401
4524
+ });
4525
+ return;
4526
+ }
4527
+ sendJson(response, {
4528
+ ok: true,
4529
+ passwordRequired: true
4530
+ }, { headers: {
4531
+ ...originHeaders,
4532
+ "set-cookie": passwordCookieHeader(security.password)
4533
+ } });
4534
+ return;
4535
+ }
4536
+ if (security.password && !publicRoute(path, enableWebClient) && !isPasswordAuthenticated(request, security.password)) {
4537
+ sendJson(response, {
4538
+ authRequired: true,
4539
+ error: "server password required"
4540
+ }, {
4541
+ headers: originHeaders,
4542
+ status: 401
4293
4543
  });
4294
4544
  return;
4295
4545
  }
4296
4546
  if (method === "GET" && path === "/state") {
4297
- sendJson(response, app.getState());
4547
+ sendJson(response, app.getState(), { headers: originHeaders });
4298
4548
  return;
4299
4549
  }
4300
4550
  if (method === "GET" && path === "/auth/status") {
4301
- sendJson(response, await app.inspectAuth());
4551
+ sendJson(response, await app.inspectAuth(), { headers: originHeaders });
4552
+ return;
4553
+ }
4554
+ if (method === "POST" && path === "/auth/lock") {
4555
+ sendJson(response, { ok: true }, { headers: {
4556
+ ...originHeaders,
4557
+ "set-cookie": clearPasswordCookieHeader()
4558
+ } });
4302
4559
  return;
4303
4560
  }
4304
4561
  if (method === "GET" && path === "/events") {
4305
4562
  response.writeHead(200, {
4306
4563
  "cache-control": "no-cache, no-transform",
4307
4564
  connection: "keep-alive",
4308
- "content-type": "text/event-stream; charset=utf-8"
4565
+ "content-type": "text/event-stream; charset=utf-8",
4566
+ ...originHeaders
4309
4567
  });
4310
4568
  response.write(formatSseEvent({
4311
4569
  state: app.getState(),
@@ -4344,64 +4602,76 @@ async function startGranolaServer(app, options = {}) {
4344
4602
  sort,
4345
4603
  updatedFrom,
4346
4604
  updatedTo
4347
- });
4605
+ }, { headers: originHeaders });
4348
4606
  return;
4349
4607
  }
4350
4608
  if (method === "GET" && path === "/meetings/resolve") {
4351
4609
  const query = url.searchParams.get("q")?.trim();
4352
4610
  if (!query) throw new Error("meeting query is required");
4353
- sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
4611
+ sendJson(response, await app.findMeeting(query, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
4354
4612
  return;
4355
4613
  }
4356
4614
  if (method === "GET" && path.startsWith("/meetings/")) {
4357
4615
  const id = decodeURIComponent(path.slice(10));
4358
4616
  if (!id) throw new Error("meeting id is required");
4359
- sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
4617
+ sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }), { headers: originHeaders });
4360
4618
  return;
4361
4619
  }
4362
4620
  if (method === "POST" && path === "/auth/login") {
4363
4621
  const body = await readJsonBody(request);
4364
4622
  const supabasePath = typeof body.supabasePath === "string" && body.supabasePath.trim() ? body.supabasePath.trim() : void 0;
4365
- sendJson(response, await app.loginAuth({ supabasePath }));
4623
+ sendJson(response, await app.loginAuth({ supabasePath }), { headers: originHeaders });
4366
4624
  return;
4367
4625
  }
4368
4626
  if (method === "POST" && path === "/auth/logout") {
4369
- sendJson(response, await app.logoutAuth());
4627
+ sendJson(response, await app.logoutAuth(), { headers: originHeaders });
4370
4628
  return;
4371
4629
  }
4372
4630
  if (method === "POST" && path === "/auth/refresh") {
4373
- sendJson(response, await app.refreshAuth());
4631
+ sendJson(response, await app.refreshAuth(), { headers: originHeaders });
4374
4632
  return;
4375
4633
  }
4376
4634
  if (method === "POST" && path === "/auth/mode") {
4377
4635
  const body = await readJsonBody(request);
4378
- sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)));
4636
+ sendJson(response, await app.switchAuthMode(parseAuthMode(body.mode)), { headers: originHeaders });
4379
4637
  return;
4380
4638
  }
4381
4639
  if (method === "POST" && path === "/exports/notes") {
4382
4640
  const body = await readJsonBody(request);
4383
- sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
4641
+ sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), {
4642
+ headers: originHeaders,
4643
+ status: 202
4644
+ });
4384
4645
  return;
4385
4646
  }
4386
4647
  if (method === "GET" && path === "/exports/jobs") {
4387
4648
  const limit = parseInteger(url.searchParams.get("limit"));
4388
- sendJson(response, await app.listExportJobs({ limit }));
4649
+ sendJson(response, await app.listExportJobs({ limit }), { headers: originHeaders });
4389
4650
  return;
4390
4651
  }
4391
4652
  if (method === "POST" && path.startsWith("/exports/jobs/") && path.endsWith("/rerun")) {
4392
4653
  const id = decodeURIComponent(path.slice(14, -6));
4393
4654
  if (!id) throw new Error("export job id is required");
4394
- sendJson(response, await app.rerunExportJob(id), { status: 202 });
4655
+ sendJson(response, await app.rerunExportJob(id), {
4656
+ headers: originHeaders,
4657
+ status: 202
4658
+ });
4395
4659
  return;
4396
4660
  }
4397
4661
  if (method === "POST" && path === "/exports/transcripts") {
4398
4662
  const body = await readJsonBody(request);
4399
- sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
4663
+ sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), {
4664
+ headers: originHeaders,
4665
+ status: 202
4666
+ });
4400
4667
  return;
4401
4668
  }
4402
- sendText(response, "Not found\n", 404);
4669
+ sendText(response, "Not found\n", 404, originHeaders);
4403
4670
  } catch (error) {
4404
- sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
4671
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, {
4672
+ headers: originHeaders,
4673
+ status: 400
4674
+ });
4405
4675
  }
4406
4676
  });
4407
4677
  await new Promise((resolve, reject) => {
@@ -4443,14 +4713,17 @@ Usage:
4443
4713
  granola serve [options]
4444
4714
 
4445
4715
  Options:
4446
- --hostname <value> Hostname to bind (default: 127.0.0.1)
4447
- --port <value> Port to bind (default: 0 for any available port)
4448
- --cache <path> Path to Granola cache JSON
4449
- --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4450
- --supabase <path> Path to supabase.json
4451
- --debug Enable debug logging
4452
- --config <path> Path to .granola.toml
4453
- -h, --help Show help
4716
+ --network <mode> Network mode: local or lan (default: local)
4717
+ --hostname <value> Hostname to bind (overrides network default)
4718
+ --port <value> Port to bind (default: 0 for any available port)
4719
+ --password <value> Optional server password for API and browser access
4720
+ --trusted-origins <v> Comma-separated extra browser origins to trust
4721
+ --cache <path> Path to Granola cache JSON
4722
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4723
+ --supabase <path> Path to supabase.json
4724
+ --debug Enable debug logging
4725
+ --config <path> Path to .granola.toml
4726
+ -h, --help Show help
4454
4727
  `;
4455
4728
  }
4456
4729
  const serveCommand = {
@@ -4459,8 +4732,11 @@ const serveCommand = {
4459
4732
  cache: { type: "string" },
4460
4733
  help: { type: "boolean" },
4461
4734
  hostname: { type: "string" },
4735
+ network: { type: "string" },
4736
+ password: { type: "string" },
4462
4737
  port: { type: "string" },
4463
- timeout: { type: "string" }
4738
+ timeout: { type: "string" },
4739
+ "trusted-origins": { type: "string" }
4464
4740
  },
4465
4741
  help: serveHelp,
4466
4742
  name: "serve",
@@ -4473,13 +4749,29 @@ const serveCommand = {
4473
4749
  debug(config.debug, "supabase", config.supabase);
4474
4750
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4475
4751
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4476
- const server = await startGranolaServer(await createGranolaApp(config, { surface: "server" }), {
4477
- hostname: pickHostname(commandFlags.hostname),
4478
- port: parsePort(commandFlags.port)
4752
+ const app = await createGranolaApp(config, { surface: "server" });
4753
+ const networkMode = parseNetworkMode(commandFlags.network);
4754
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
4755
+ const port = parsePort(commandFlags.port);
4756
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
4757
+ const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
4758
+ const server = await startGranolaServer(app, {
4759
+ hostname,
4760
+ port,
4761
+ security: {
4762
+ password,
4763
+ trustedOrigins
4764
+ }
4479
4765
  });
4480
4766
  console.log(`Granola server listening on ${server.url.href}`);
4767
+ console.log(`Network mode: ${networkMode}`);
4768
+ if (password) console.log("Server password protection: enabled");
4769
+ else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
4770
+ if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
4481
4771
  console.log("Endpoints:");
4482
4772
  console.log(" GET /health");
4773
+ console.log(" POST /auth/unlock");
4774
+ console.log(" POST /auth/lock");
4483
4775
  console.log(" GET /auth/status");
4484
4776
  console.log(" GET /state");
4485
4777
  console.log(" GET /events");
@@ -4498,6 +4790,649 @@ const serveCommand = {
4498
4790
  }
4499
4791
  };
4500
4792
  //#endregion
4793
+ //#region src/tui/helpers.ts
4794
+ function splitQuery(query) {
4795
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
4796
+ }
4797
+ function scoreMeetingTerm(meeting, term) {
4798
+ const title = meeting.title.toLowerCase();
4799
+ const id = meeting.id.toLowerCase();
4800
+ const tags = meeting.tags.map((tag) => tag.toLowerCase());
4801
+ if (title === term || id === term) return 0;
4802
+ if (title.startsWith(term)) return 1;
4803
+ if (id.startsWith(term)) return 2;
4804
+ if (title.includes(term)) return 3;
4805
+ if (id.includes(term)) return 4;
4806
+ if (tags.some((tag) => tag.includes(term))) return 5;
4807
+ }
4808
+ function buildGranolaTuiQuickOpenItems(meetings, query) {
4809
+ const terms = splitQuery(query);
4810
+ return meetings.map((meeting) => {
4811
+ const score = terms.reduce((current, term) => {
4812
+ const termScore = scoreMeetingTerm(meeting, term);
4813
+ if (termScore === void 0) return;
4814
+ return (current ?? 0) + termScore;
4815
+ }, 0);
4816
+ if (terms.length > 0 && score === void 0) return;
4817
+ const tags = meeting.tags.length > 0 ? meeting.tags.map((tag) => `#${tag}`).join(" ") : "untagged";
4818
+ return {
4819
+ description: `${meeting.updatedAt.slice(0, 10)} | ${tags} | ${meeting.id}`,
4820
+ id: meeting.id,
4821
+ label: meeting.title || meeting.id,
4822
+ score: score ?? 99
4823
+ };
4824
+ }).filter((item) => item !== void 0).sort((left, right) => {
4825
+ if (left.score !== right.score) return left.score - right.score;
4826
+ if (left.description !== right.description) return right.description.localeCompare(left.description);
4827
+ return left.label.localeCompare(right.label);
4828
+ });
4829
+ }
4830
+ function renderGranolaTuiMeetingTab(bundle, tab) {
4831
+ const summary = bundle.meeting.meeting;
4832
+ switch (tab) {
4833
+ case "metadata": return [
4834
+ `Title: ${summary.title || summary.id}`,
4835
+ `ID: ${summary.id}`,
4836
+ `Created: ${summary.createdAt}`,
4837
+ `Updated: ${summary.updatedAt}`,
4838
+ `Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
4839
+ `Notes source: ${summary.noteContentSource}`,
4840
+ `Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
4841
+ `Transcript segments: ${summary.transcriptSegmentCount}`
4842
+ ].join("\n");
4843
+ case "raw": return JSON.stringify(bundle, null, 2);
4844
+ case "transcript": {
4845
+ const transcript = renderMeetingTranscript(bundle.document, bundle.cacheData, "text").trim();
4846
+ if (transcript) return transcript;
4847
+ return bundle.cacheData ? "(Transcript unavailable)" : "(Granola cache not loaded)";
4848
+ }
4849
+ default: return renderMeetingNotes(bundle.document, "markdown").trim();
4850
+ }
4851
+ }
4852
+ function buildGranolaTuiSummary(state, meetingSource) {
4853
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
4854
+ }
4855
+ //#endregion
4856
+ //#region src/tui/theme.ts
4857
+ const RESET = "\x1B[0m";
4858
+ function colour(code, text) {
4859
+ return `\x1b[${code}m${text}${RESET}`;
4860
+ }
4861
+ const granolaTuiTheme = {
4862
+ accent(text) {
4863
+ return colour("36", text);
4864
+ },
4865
+ dim(text) {
4866
+ return colour("2", text);
4867
+ },
4868
+ error(text) {
4869
+ return colour("31", text);
4870
+ },
4871
+ info(text) {
4872
+ return colour("32", text);
4873
+ },
4874
+ selected(text) {
4875
+ return colour("7", text);
4876
+ },
4877
+ strong(text) {
4878
+ return colour("1", text);
4879
+ },
4880
+ warning(text) {
4881
+ return colour("33", text);
4882
+ }
4883
+ };
4884
+ //#endregion
4885
+ //#region src/tui/palette.ts
4886
+ function padLine$1(text, width) {
4887
+ const clipped = truncateToWidth(text, width, "");
4888
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
4889
+ }
4890
+ function frameLine(text, width) {
4891
+ return `| ${padLine$1(text, Math.max(1, width - 4))} |`;
4892
+ }
4893
+ var GranolaTuiQuickOpenPalette = class {
4894
+ focused = false;
4895
+ #input = new Input();
4896
+ #matches;
4897
+ #selectedIndex = 0;
4898
+ constructor(options) {
4899
+ this.options = options;
4900
+ this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, "");
4901
+ this.#input.onEscape = () => {
4902
+ this.options.onCancel();
4903
+ };
4904
+ this.#input.onSubmit = () => {
4905
+ this.chooseSelection();
4906
+ };
4907
+ }
4908
+ get query() {
4909
+ return this.#input.getValue();
4910
+ }
4911
+ updateMatches() {
4912
+ this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, this.query);
4913
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#matches.length - 1));
4914
+ }
4915
+ async chooseSelection() {
4916
+ const selected = this.#matches[this.#selectedIndex];
4917
+ if (selected) {
4918
+ await this.options.onPick(selected.id);
4919
+ return;
4920
+ }
4921
+ if (this.query.trim()) {
4922
+ await this.options.onResolveQuery(this.query.trim());
4923
+ return;
4924
+ }
4925
+ this.options.onCancel();
4926
+ }
4927
+ invalidate() {}
4928
+ handleInput(data) {
4929
+ if (matchesKey(data, "up")) {
4930
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
4931
+ return;
4932
+ }
4933
+ if (matchesKey(data, "down")) {
4934
+ this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 1);
4935
+ return;
4936
+ }
4937
+ if (matchesKey(data, "pageUp")) {
4938
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - 5);
4939
+ return;
4940
+ }
4941
+ if (matchesKey(data, "pageDown")) {
4942
+ this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 5);
4943
+ return;
4944
+ }
4945
+ const before = this.query;
4946
+ this.#input.focused = this.focused;
4947
+ this.#input.handleInput(data);
4948
+ if (before !== this.query) {
4949
+ this.#selectedIndex = 0;
4950
+ this.updateMatches();
4951
+ }
4952
+ }
4953
+ render(width) {
4954
+ const lines = [];
4955
+ const bodyWidth = Math.max(32, width);
4956
+ const visibleMatches = this.#matches.slice(0, 8);
4957
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
4958
+ lines.push(frameLine(granolaTuiTheme.strong("Quick Open") + granolaTuiTheme.dim(" title, id, or tag"), bodyWidth));
4959
+ lines.push(frameLine("", bodyWidth));
4960
+ for (const inputLine of this.#input.render(Math.max(1, bodyWidth - 4))) lines.push(frameLine(inputLine, bodyWidth));
4961
+ for (const hintLine of wrapTextWithAnsi(granolaTuiTheme.dim("Enter to open, Esc to cancel, arrows to move"), Math.max(1, bodyWidth - 4))) lines.push(frameLine(hintLine, bodyWidth));
4962
+ lines.push(frameLine("", bodyWidth));
4963
+ if (visibleMatches.length === 0) lines.push(frameLine(granolaTuiTheme.warning("No matching meetings"), bodyWidth));
4964
+ else for (const [index, item] of visibleMatches.entries()) {
4965
+ const selected = index === this.#selectedIndex;
4966
+ const title = `${selected ? "> " : " "}${item.label}`;
4967
+ const titleLine = selected ? granolaTuiTheme.selected(title) : title;
4968
+ const detailLine = granolaTuiTheme.dim(` ${item.description}`);
4969
+ lines.push(frameLine(titleLine, bodyWidth));
4970
+ lines.push(frameLine(detailLine, bodyWidth));
4971
+ }
4972
+ lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
4973
+ return lines;
4974
+ }
4975
+ };
4976
+ //#endregion
4977
+ //#region src/tui/workspace.ts
4978
+ function padLine(text, width) {
4979
+ const clipped = truncateToWidth(text, width, "");
4980
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
4981
+ }
4982
+ function wrapBlock(text, width) {
4983
+ const lines = [];
4984
+ for (const line of text.split("\n")) {
4985
+ const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
4986
+ if (wrapped.length === 0) {
4987
+ lines.push("");
4988
+ continue;
4989
+ }
4990
+ lines.push(...wrapped);
4991
+ }
4992
+ return lines;
4993
+ }
4994
+ function toneText(tone, text) {
4995
+ switch (tone) {
4996
+ case "error": return granolaTuiTheme.error(text);
4997
+ case "warning": return granolaTuiTheme.warning(text);
4998
+ default: return granolaTuiTheme.info(text);
4999
+ }
5000
+ }
5001
+ var GranolaTuiWorkspace = class {
5002
+ focused = false;
5003
+ #maxMeetings;
5004
+ #appState;
5005
+ #detailError = "";
5006
+ #detailScroll = 0;
5007
+ #detailToken = 0;
5008
+ #listError = "";
5009
+ #listToken = 0;
5010
+ #loadingDetail = false;
5011
+ #loadingMeetings = false;
5012
+ #meetingSource = "live";
5013
+ #meetings = [];
5014
+ #overlay;
5015
+ #selectedMeeting;
5016
+ #selectedMeetingId;
5017
+ #statusMessage = "Loading meetings…";
5018
+ #statusTone = "info";
5019
+ #tab = "notes";
5020
+ #unsubscribe;
5021
+ constructor(tui, app, options) {
5022
+ this.tui = tui;
5023
+ this.app = app;
5024
+ this.options = options;
5025
+ this.#appState = app.getState();
5026
+ this.#maxMeetings = options.maxMeetings ?? 200;
5027
+ }
5028
+ async initialise() {
5029
+ this.#unsubscribe = this.app.subscribe((event) => {
5030
+ this.handleAppUpdate(event);
5031
+ });
5032
+ await this.loadMeetings({
5033
+ preferredMeetingId: this.options.initialMeetingId,
5034
+ setStatus: true
5035
+ });
5036
+ if (this.options.initialMeetingId) await this.loadMeeting(this.options.initialMeetingId, { ensureMeetingVisible: true });
5037
+ else if (this.#selectedMeetingId) this.loadMeeting(this.#selectedMeetingId);
5038
+ }
5039
+ dispose() {
5040
+ this.#unsubscribe?.();
5041
+ this.#unsubscribe = void 0;
5042
+ }
5043
+ invalidate() {}
5044
+ handleAppUpdate(event) {
5045
+ const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
5046
+ this.#appState = event.state;
5047
+ if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
5048
+ this.tui.requestRender();
5049
+ }
5050
+ setStatus(message, tone = "info") {
5051
+ this.#statusMessage = message;
5052
+ this.#statusTone = tone;
5053
+ this.tui.requestRender();
5054
+ }
5055
+ normaliseSelectedIndex() {
5056
+ if (this.#meetings.length === 0) return -1;
5057
+ const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
5058
+ return selectedIndex >= 0 ? selectedIndex : 0;
5059
+ }
5060
+ ensureMeetingVisible(meeting) {
5061
+ const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
5062
+ if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
5063
+ else this.#meetings.push(meeting);
5064
+ this.#meetings.sort((left, right) => {
5065
+ if (left.updatedAt !== right.updatedAt) return right.updatedAt.localeCompare(left.updatedAt);
5066
+ return left.title.localeCompare(right.title);
5067
+ });
5068
+ }
5069
+ async loadMeetings(options = {}) {
5070
+ const token = ++this.#listToken;
5071
+ this.#loadingMeetings = true;
5072
+ this.#listError = "";
5073
+ if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
5074
+ try {
5075
+ const result = await this.app.listMeetings({
5076
+ forceRefresh: options.forceRefresh,
5077
+ limit: this.#maxMeetings,
5078
+ preferIndex: true
5079
+ });
5080
+ if (token !== this.#listToken) return;
5081
+ this.#meetings = result.meetings;
5082
+ this.#meetingSource = result.source;
5083
+ this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
5084
+ this.#listError = "";
5085
+ this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
5086
+ } catch (error) {
5087
+ if (token !== this.#listToken) return;
5088
+ const message = error instanceof Error ? error.message : String(error);
5089
+ this.#listError = message;
5090
+ this.setStatus(message, "error");
5091
+ throw error;
5092
+ } finally {
5093
+ if (token === this.#listToken) {
5094
+ this.#loadingMeetings = false;
5095
+ this.tui.requestRender();
5096
+ }
5097
+ }
5098
+ }
5099
+ async loadMeeting(meetingId, options = {}) {
5100
+ const token = ++this.#detailToken;
5101
+ this.#loadingDetail = true;
5102
+ this.#detailError = "";
5103
+ this.#selectedMeetingId = meetingId;
5104
+ this.#detailScroll = 0;
5105
+ this.setStatus(`Opening ${meetingId}…`);
5106
+ try {
5107
+ const bundle = options.resolveQuery ? await this.app.findMeeting(meetingId) : await this.app.getMeeting(meetingId);
5108
+ if (token !== this.#detailToken) return;
5109
+ this.#selectedMeeting = bundle;
5110
+ this.#selectedMeetingId = bundle.document.id;
5111
+ if (options.ensureMeetingVisible) this.ensureMeetingVisible(bundle.meeting.meeting);
5112
+ this.setStatus(`Opened ${bundle.meeting.meeting.title || bundle.meeting.meeting.id}`);
5113
+ } catch (error) {
5114
+ if (token !== this.#detailToken) return;
5115
+ const message = error instanceof Error ? error.message : String(error);
5116
+ this.#selectedMeeting = void 0;
5117
+ this.#detailError = message;
5118
+ this.setStatus(message, "error");
5119
+ } finally {
5120
+ if (token === this.#detailToken) {
5121
+ this.#loadingDetail = false;
5122
+ this.tui.requestRender();
5123
+ }
5124
+ }
5125
+ }
5126
+ async refresh(forceRefresh) {
5127
+ try {
5128
+ await this.loadMeetings({
5129
+ forceRefresh,
5130
+ preferredMeetingId: this.#selectedMeetingId
5131
+ });
5132
+ if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
5133
+ } catch {}
5134
+ }
5135
+ async moveSelection(delta) {
5136
+ if (this.#meetings.length === 0) return;
5137
+ const currentIndex = this.normaliseSelectedIndex();
5138
+ const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
5139
+ const nextMeeting = this.#meetings[nextIndex];
5140
+ if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
5141
+ await this.loadMeeting(nextMeeting.id);
5142
+ }
5143
+ currentDetailBody(width) {
5144
+ if (this.#detailError) return wrapBlock(this.#detailError, width);
5145
+ if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
5146
+ if (!this.#selectedMeeting) return wrapBlock("Select a meeting to inspect its notes, transcript, and metadata.", width);
5147
+ return wrapBlock(renderGranolaTuiMeetingTab(this.#selectedMeeting, this.#tab), width);
5148
+ }
5149
+ detailScrollStep(width, height) {
5150
+ const bodyHeight = Math.max(1, height - 2);
5151
+ const totalLines = this.currentDetailBody(width).length;
5152
+ if (totalLines <= bodyHeight) return 0;
5153
+ return Math.max(1, Math.min(bodyHeight - 1, totalLines - bodyHeight));
5154
+ }
5155
+ scrollDetail(delta) {
5156
+ const totalWidth = this.tui.terminal.columns;
5157
+ const totalHeight = this.tui.terminal.rows;
5158
+ const { detailWidth } = this.resolveLayout(totalWidth);
5159
+ const bodyHeight = Math.max(1, totalHeight - 6);
5160
+ const detailLines = this.currentDetailBody(Math.max(1, detailWidth - 2));
5161
+ const visibleBodyLines = Math.max(1, bodyHeight - 2);
5162
+ const maxScroll = Math.max(0, detailLines.length - visibleBodyLines);
5163
+ this.#detailScroll = Math.max(0, Math.min(maxScroll, this.#detailScroll + delta));
5164
+ this.tui.requestRender();
5165
+ }
5166
+ cycleTab(delta) {
5167
+ const tabs = [
5168
+ "notes",
5169
+ "transcript",
5170
+ "metadata",
5171
+ "raw"
5172
+ ];
5173
+ this.#tab = tabs[(tabs.indexOf(this.#tab) + delta + tabs.length) % tabs.length] ?? "notes";
5174
+ this.#detailScroll = 0;
5175
+ this.tui.requestRender();
5176
+ }
5177
+ openQuickOpen() {
5178
+ if (this.#overlay) return;
5179
+ const closeOverlay = () => {
5180
+ this.#overlay?.hide();
5181
+ this.#overlay = void 0;
5182
+ this.tui.setFocus(this);
5183
+ this.tui.requestRender();
5184
+ };
5185
+ const palette = new GranolaTuiQuickOpenPalette({
5186
+ meetings: this.#meetings,
5187
+ onCancel: closeOverlay,
5188
+ onPick: async (meetingId) => {
5189
+ closeOverlay();
5190
+ await this.loadMeeting(meetingId, { ensureMeetingVisible: true });
5191
+ },
5192
+ onResolveQuery: async (query) => {
5193
+ closeOverlay();
5194
+ await this.loadMeeting(query, {
5195
+ ensureMeetingVisible: true,
5196
+ resolveQuery: true
5197
+ });
5198
+ }
5199
+ });
5200
+ this.#overlay = this.tui.showOverlay(palette, {
5201
+ anchor: "center",
5202
+ maxHeight: "60%",
5203
+ minWidth: 48,
5204
+ width: "70%"
5205
+ });
5206
+ this.setStatus("Quick open");
5207
+ }
5208
+ handleInput(data) {
5209
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
5210
+ this.options.onExit();
5211
+ return;
5212
+ }
5213
+ if (matchesKey(data, "r")) {
5214
+ this.refresh(true);
5215
+ return;
5216
+ }
5217
+ if (matchesKey(data, "/") || matchesKey(data, "ctrl+p")) {
5218
+ this.openQuickOpen();
5219
+ return;
5220
+ }
5221
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
5222
+ this.moveSelection(-1);
5223
+ return;
5224
+ }
5225
+ if (matchesKey(data, "down") || matchesKey(data, "j")) {
5226
+ this.moveSelection(1);
5227
+ return;
5228
+ }
5229
+ if (matchesKey(data, "pageUp")) {
5230
+ this.scrollDetail(-Math.max(1, this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows)));
5231
+ return;
5232
+ }
5233
+ if (matchesKey(data, "pageDown")) {
5234
+ this.scrollDetail(this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows));
5235
+ return;
5236
+ }
5237
+ if (matchesKey(data, "1")) {
5238
+ this.#tab = "notes";
5239
+ this.#detailScroll = 0;
5240
+ this.tui.requestRender();
5241
+ return;
5242
+ }
5243
+ if (matchesKey(data, "2")) {
5244
+ this.#tab = "transcript";
5245
+ this.#detailScroll = 0;
5246
+ this.tui.requestRender();
5247
+ return;
5248
+ }
5249
+ if (matchesKey(data, "3")) {
5250
+ this.#tab = "metadata";
5251
+ this.#detailScroll = 0;
5252
+ this.tui.requestRender();
5253
+ return;
5254
+ }
5255
+ if (matchesKey(data, "4")) {
5256
+ this.#tab = "raw";
5257
+ this.#detailScroll = 0;
5258
+ this.tui.requestRender();
5259
+ return;
5260
+ }
5261
+ if (matchesKey(data, "]")) {
5262
+ this.cycleTab(1);
5263
+ return;
5264
+ }
5265
+ if (matchesKey(data, "[")) this.cycleTab(-1);
5266
+ }
5267
+ resolveLayout(width) {
5268
+ const minimumDetailWidth = 24;
5269
+ const minimumListWidth = 24;
5270
+ const available = Math.max(1, width - 3);
5271
+ let listWidth = Math.max(minimumListWidth, Math.min(42, Math.floor(available * .34)));
5272
+ let detailWidth = available - listWidth;
5273
+ if (detailWidth < minimumDetailWidth) {
5274
+ detailWidth = minimumDetailWidth;
5275
+ listWidth = Math.max(minimumListWidth, available - detailWidth);
5276
+ }
5277
+ if (listWidth + detailWidth > available) detailWidth = Math.max(minimumDetailWidth, available - listWidth);
5278
+ return {
5279
+ detailWidth,
5280
+ listWidth
5281
+ };
5282
+ }
5283
+ renderListPane(width, height) {
5284
+ const lines = [];
5285
+ const innerWidth = Math.max(1, width - 2);
5286
+ const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
5287
+ lines.push(padLine(header, innerWidth));
5288
+ if (this.#listError) {
5289
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
5290
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
5291
+ return lines;
5292
+ }
5293
+ if (this.#meetings.length === 0) {
5294
+ lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
5295
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
5296
+ return lines;
5297
+ }
5298
+ const selectedIndex = this.normaliseSelectedIndex();
5299
+ const windowSize = Math.max(1, height - 1);
5300
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
5301
+ const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
5302
+ for (const [offset, meeting] of visibleMeetings.entries()) {
5303
+ const selected = startIndex + offset === selectedIndex;
5304
+ const dateLabel = meeting.updatedAt.slice(0, 10);
5305
+ const prefix = selected ? "> " : " ";
5306
+ const maxTitleWidth = Math.max(6, innerWidth - visibleWidth(prefix) - dateLabel.length - 1);
5307
+ const titleBlock = `${prefix}${truncateToWidth(meeting.title || meeting.id, maxTitleWidth, "")}`;
5308
+ const line = `${titleBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(titleBlock) - visibleWidth(dateLabel)))}${granolaTuiTheme.dim(dateLabel)}`;
5309
+ lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
5310
+ }
5311
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
5312
+ return lines;
5313
+ }
5314
+ renderDetailPane(width, height) {
5315
+ const lines = [];
5316
+ const innerWidth = Math.max(1, width - 2);
5317
+ const tabs = [
5318
+ {
5319
+ id: "notes",
5320
+ label: "1 Notes"
5321
+ },
5322
+ {
5323
+ id: "transcript",
5324
+ label: "2 Transcript"
5325
+ },
5326
+ {
5327
+ id: "metadata",
5328
+ label: "3 Metadata"
5329
+ },
5330
+ {
5331
+ id: "raw",
5332
+ label: "4 Raw"
5333
+ }
5334
+ ];
5335
+ const title = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "Meeting";
5336
+ const titleLine = `${granolaTuiTheme.strong(title)} ${granolaTuiTheme.dim(this.#selectedMeeting ? this.#selectedMeeting.meeting.meeting.id : "")}`.trim();
5337
+ lines.push(padLine(titleLine, innerWidth));
5338
+ const tabLine = tabs.map((tab) => tab.id === this.#tab ? granolaTuiTheme.selected(` ${tab.label} `) : ` ${tab.label} `).join(" ");
5339
+ lines.push(padLine(tabLine, innerWidth));
5340
+ const bodyLines = this.currentDetailBody(innerWidth);
5341
+ const bodyHeight = Math.max(1, height - 2);
5342
+ const visibleBody = bodyLines.slice(this.#detailScroll, this.#detailScroll + bodyHeight);
5343
+ lines.push(...visibleBody.map((line) => padLine(line, innerWidth)));
5344
+ while (lines.length < height) lines.push(" ".repeat(innerWidth));
5345
+ return lines;
5346
+ }
5347
+ render(width) {
5348
+ const totalHeight = Math.max(12, this.tui.terminal.rows);
5349
+ const { detailWidth, listWidth } = this.resolveLayout(width);
5350
+ const bodyHeight = Math.max(6, totalHeight - 2 - 2);
5351
+ const selectedLabel = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "none";
5352
+ const headerTitle = padLine(`${granolaTuiTheme.accent("Granola Toolkit TUI")} ${granolaTuiTheme.dim(this.#loadingMeetings ? "loading…" : selectedLabel)}`, width);
5353
+ const headerSummary = padLine(granolaTuiTheme.dim(buildGranolaTuiSummary(this.#appState, this.#meetingSource)), width);
5354
+ const listLines = this.renderListPane(listWidth, bodyHeight);
5355
+ const detailLines = this.renderDetailPane(detailWidth, bodyHeight);
5356
+ const bodyLines = [];
5357
+ for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
5358
+ const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
5359
+ const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
5360
+ return [
5361
+ headerTitle,
5362
+ headerSummary,
5363
+ ...bodyLines,
5364
+ footerStatus,
5365
+ footerHints
5366
+ ];
5367
+ }
5368
+ };
5369
+ async function runGranolaTui(app, options = {}) {
5370
+ const tui = new TUI(new ProcessTerminal());
5371
+ return await new Promise((resolve, reject) => {
5372
+ const workspace = new GranolaTuiWorkspace(tui, app, {
5373
+ initialMeetingId: options.initialMeetingId,
5374
+ onExit: () => {
5375
+ workspace.dispose();
5376
+ tui.stop();
5377
+ resolve(0);
5378
+ }
5379
+ });
5380
+ (async () => {
5381
+ try {
5382
+ await workspace.initialise();
5383
+ } catch (error) {
5384
+ workspace.dispose();
5385
+ reject(error);
5386
+ return;
5387
+ }
5388
+ tui.addChild(workspace);
5389
+ tui.setFocus(workspace);
5390
+ tui.start();
5391
+ tui.requestRender(true);
5392
+ })();
5393
+ });
5394
+ }
5395
+ //#endregion
5396
+ //#region src/commands/tui.ts
5397
+ function tuiHelp() {
5398
+ return `Granola tui
5399
+
5400
+ Usage:
5401
+ granola tui [options]
5402
+
5403
+ Options:
5404
+ --meeting <id> Open the workspace focused on a specific meeting
5405
+ --cache <path> Path to Granola cache JSON
5406
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
5407
+ --supabase <path> Path to supabase.json
5408
+ --debug Enable debug logging
5409
+ --config <path> Path to .granola.toml
5410
+ -h, --help Show help
5411
+ `;
5412
+ }
5413
+ const tuiCommand = {
5414
+ description: "Start the Granola Toolkit terminal workspace",
5415
+ flags: {
5416
+ cache: { type: "string" },
5417
+ help: { type: "boolean" },
5418
+ meeting: { type: "string" },
5419
+ timeout: { type: "string" }
5420
+ },
5421
+ help: tuiHelp,
5422
+ name: "tui",
5423
+ async run({ commandFlags, globalFlags }) {
5424
+ const config = await loadConfig({
5425
+ globalFlags,
5426
+ subcommandFlags: commandFlags
5427
+ });
5428
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
5429
+ debug(config.debug, "supabase", config.supabase);
5430
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
5431
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
5432
+ return await runGranolaTui(await createGranolaApp(config, { surface: "tui" }), { initialMeetingId: typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0 });
5433
+ }
5434
+ };
5435
+ //#endregion
4501
5436
  //#region src/commands/transcripts.ts
4502
5437
  function transcriptsHelp() {
4503
5438
  return `Granola transcripts
@@ -4592,8 +5527,11 @@ Usage:
4592
5527
  granola web [options]
4593
5528
 
4594
5529
  Options:
4595
- --hostname <value> Hostname to bind (default: 127.0.0.1)
5530
+ --network <mode> Network mode: local or lan (default: local)
5531
+ --hostname <value> Hostname to bind (overrides network default)
4596
5532
  --port <value> Port to bind (default: 0 for any available port)
5533
+ --password <value> Optional server password for API and browser access
5534
+ --trusted-origins <v> Comma-separated extra browser origins to trust
4597
5535
  --cache <path> Path to Granola cache JSON
4598
5536
  --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
4599
5537
  --supabase <path> Path to supabase.json
@@ -4611,6 +5549,7 @@ const commands = [
4611
5549
  meetingCommand,
4612
5550
  notesCommand,
4613
5551
  serveCommand,
5552
+ tuiCommand,
4614
5553
  transcriptsCommand,
4615
5554
  {
4616
5555
  description: "Start the Granola Toolkit web workspace",
@@ -4618,9 +5557,12 @@ const commands = [
4618
5557
  cache: { type: "string" },
4619
5558
  help: { type: "boolean" },
4620
5559
  hostname: { type: "string" },
5560
+ network: { type: "string" },
4621
5561
  open: { type: "boolean" },
5562
+ password: { type: "string" },
4622
5563
  port: { type: "string" },
4623
- timeout: { type: "string" }
5564
+ timeout: { type: "string" },
5565
+ "trusted-origins": { type: "string" }
4624
5566
  },
4625
5567
  help: webHelp,
4626
5568
  name: "web",
@@ -4634,18 +5576,31 @@ const commands = [
4634
5576
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
4635
5577
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
4636
5578
  const app = await createGranolaApp(config, { surface: "web" });
4637
- const hostname = pickHostname(commandFlags.hostname);
5579
+ const networkMode = parseNetworkMode(commandFlags.network);
5580
+ const hostname = resolveServerHostname(networkMode, commandFlags.hostname);
4638
5581
  const port = parsePort(commandFlags.port);
4639
5582
  const openBrowser = commandFlags.open !== false;
5583
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password : void 0;
5584
+ const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
4640
5585
  const server = await startGranolaServer(app, {
4641
5586
  enableWebClient: true,
4642
5587
  hostname,
4643
- port
5588
+ port,
5589
+ security: {
5590
+ password,
5591
+ trustedOrigins
5592
+ }
4644
5593
  });
4645
5594
  console.log(`Granola Toolkit web workspace listening on ${server.url.href}`);
5595
+ console.log(`Network mode: ${networkMode}`);
5596
+ if (password) console.log("Server password protection: enabled");
5597
+ else if (networkMode === "lan") console.log("Warning: LAN mode is enabled without a server password");
5598
+ if (trustedOrigins.length > 0) console.log(`Trusted origins: ${trustedOrigins.join(", ")}`);
4646
5599
  console.log("Routes:");
4647
5600
  console.log(" GET /");
4648
5601
  console.log(" GET /health");
5602
+ console.log(" POST /auth/unlock");
5603
+ console.log(" POST /auth/lock");
4649
5604
  console.log(" GET /auth/status");
4650
5605
  console.log(" GET /state");
4651
5606
  console.log(" GET /events");