granola-toolkit 0.17.0 → 0.18.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 +26 -0
  2. package/dist/cli.js +267 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,6 +35,7 @@ granola --help
35
35
  granola auth login
36
36
  granola meeting --help
37
37
  granola notes --help
38
+ granola serve --help
38
39
  granola transcripts --help
39
40
  ```
40
41
 
@@ -47,6 +48,7 @@ vp pack
47
48
  node dist/cli.js --help
48
49
  node dist/cli.js meeting --help
49
50
  node dist/cli.js notes --help
51
+ node dist/cli.js serve --help
50
52
  node dist/cli.js transcripts --help
51
53
  ```
52
54
 
@@ -89,6 +91,14 @@ granola meeting transcript 1234abcd --format json
89
91
  granola meeting export 1234abcd --format yaml
90
92
  ```
91
93
 
94
+ Run the local API server:
95
+
96
+ ```bash
97
+ granola serve
98
+ granola serve --port 4096
99
+ granola serve --hostname 0.0.0.0 --port 4096
100
+ ```
101
+
92
102
  ## How It Works
93
103
 
94
104
  ### Notes
@@ -167,6 +177,22 @@ The machine-readable `export` command includes:
167
177
  - structured note data plus rendered Markdown
168
178
  - structured transcript data plus rendered transcript text when available
169
179
 
180
+ ### Server
181
+
182
+ `serve` starts a long-lived local `Granola Toolkit` server on one shared app instance.
183
+
184
+ The initial server API includes:
185
+
186
+ - `GET /health`
187
+ - `GET /state`
188
+ - `GET /events` for server-sent state updates
189
+ - `GET /meetings`
190
+ - `GET /meetings/:id`
191
+ - `POST /exports/notes`
192
+ - `POST /exports/transcripts`
193
+
194
+ This is the foundation for the future `granola web` client and any attachable TUI flows.
195
+
170
196
  ## Auth
171
197
 
172
198
  If you do not want to keep passing `--supabase`, import the desktop app session once:
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import { promisify } from "node:util";
8
8
  import { NodeHtmlMarkdown } from "node-html-markdown";
9
9
  import { createHash } from "node:crypto";
10
+ import { createServer } from "node:http";
10
11
  //#region src/utils.ts
11
12
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
12
13
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -1517,6 +1518,7 @@ var GranolaApp = class {
1517
1518
  #cacheResolved = false;
1518
1519
  #granolaClient;
1519
1520
  #documents;
1521
+ #listeners = /* @__PURE__ */ new Set();
1520
1522
  #state;
1521
1523
  constructor(config, deps, options = {}) {
1522
1524
  this.config = config;
@@ -1526,16 +1528,31 @@ var GranolaApp = class {
1526
1528
  getState() {
1527
1529
  return cloneState(this.#state);
1528
1530
  }
1531
+ subscribe(listener) {
1532
+ this.#listeners.add(listener);
1533
+ return () => {
1534
+ this.#listeners.delete(listener);
1535
+ };
1536
+ }
1529
1537
  setUiState(patch) {
1530
1538
  this.#state.ui = {
1531
1539
  ...this.#state.ui,
1532
1540
  ...patch
1533
1541
  };
1542
+ this.emitStateUpdate();
1534
1543
  return this.getState();
1535
1544
  }
1536
1545
  nowIso() {
1537
1546
  return (this.deps.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
1538
1547
  }
1548
+ emitStateUpdate() {
1549
+ const event = {
1550
+ state: this.getState(),
1551
+ timestamp: this.nowIso(),
1552
+ type: "state.updated"
1553
+ };
1554
+ for (const listener of this.#listeners) listener(event);
1555
+ }
1539
1556
  async getGranolaClient() {
1540
1557
  if (this.#granolaClient) return this.#granolaClient;
1541
1558
  if (this.deps.granolaClient) {
@@ -1546,6 +1563,7 @@ var GranolaApp = class {
1546
1563
  const runtime = await this.deps.createGranolaClient();
1547
1564
  this.#granolaClient = runtime.client;
1548
1565
  this.#state.auth = { ...runtime.auth };
1566
+ this.emitStateUpdate();
1549
1567
  return this.#granolaClient;
1550
1568
  }
1551
1569
  missingCacheError() {
@@ -1560,6 +1578,7 @@ var GranolaApp = class {
1560
1578
  loaded: true,
1561
1579
  loadedAt: this.nowIso()
1562
1580
  };
1581
+ this.emitStateUpdate();
1563
1582
  return documents;
1564
1583
  }
1565
1584
  async loadCache(options = {}) {
@@ -1585,6 +1604,7 @@ var GranolaApp = class {
1585
1604
  loadedAt: cacheData ? this.nowIso() : void 0,
1586
1605
  transcriptCount: cacheData ? transcriptCount(cacheData) : 0
1587
1606
  };
1607
+ this.emitStateUpdate();
1588
1608
  if (options.required && !cacheData) throw this.missingCacheError();
1589
1609
  return cacheData;
1590
1610
  }
@@ -1626,6 +1646,7 @@ var GranolaApp = class {
1626
1646
  ranAt: this.nowIso(),
1627
1647
  written
1628
1648
  };
1649
+ this.emitStateUpdate();
1629
1650
  this.setUiState({ view: "notes-export" });
1630
1651
  return {
1631
1652
  documentCount: documents.length,
@@ -1647,6 +1668,7 @@ var GranolaApp = class {
1647
1668
  ranAt: this.nowIso(),
1648
1669
  written
1649
1670
  };
1671
+ this.emitStateUpdate();
1650
1672
  this.setUiState({ view: "transcripts-export" });
1651
1673
  return {
1652
1674
  cacheData,
@@ -2010,6 +2032,250 @@ function resolveNoteFormat(value) {
2010
2032
  }
2011
2033
  }
2012
2034
  //#endregion
2035
+ //#region src/server/http.ts
2036
+ function parseInteger(value) {
2037
+ if (!value?.trim()) return;
2038
+ if (!/^\d+$/.test(value)) throw new Error("invalid limit: expected a positive integer");
2039
+ const parsed = Number(value);
2040
+ if (!Number.isInteger(parsed) || parsed < 1) throw new Error("invalid limit: expected a positive integer");
2041
+ return parsed;
2042
+ }
2043
+ function sendJson(response, body, init = {}) {
2044
+ const payload = `${JSON.stringify(body, null, 2)}\n`;
2045
+ response.writeHead(init.status ?? 200, {
2046
+ "content-length": Buffer.byteLength(payload),
2047
+ "content-type": "application/json; charset=utf-8"
2048
+ });
2049
+ response.end(payload);
2050
+ }
2051
+ function sendText(response, body, status = 200) {
2052
+ response.writeHead(status, {
2053
+ "content-length": Buffer.byteLength(body),
2054
+ "content-type": "text/plain; charset=utf-8"
2055
+ });
2056
+ response.end(body);
2057
+ }
2058
+ async function readJsonBody(request) {
2059
+ const chunks = [];
2060
+ for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2061
+ if (chunks.length === 0) return {};
2062
+ try {
2063
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2064
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
2065
+ return parsed;
2066
+ } catch (error) {
2067
+ throw new Error(error instanceof Error ? error.message : "failed to parse JSON body");
2068
+ }
2069
+ }
2070
+ function formatSseEvent(event) {
2071
+ return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
2072
+ }
2073
+ function noteFormatFromBody(value) {
2074
+ switch (value) {
2075
+ case void 0:
2076
+ case "markdown": return "markdown";
2077
+ case "json":
2078
+ case "raw":
2079
+ case "yaml": return value;
2080
+ default: throw new Error("invalid notes format: expected markdown, json, yaml, or raw");
2081
+ }
2082
+ }
2083
+ function transcriptFormatFromBody(value) {
2084
+ switch (value) {
2085
+ case void 0:
2086
+ case "text": return "text";
2087
+ case "json":
2088
+ case "raw":
2089
+ case "yaml": return value;
2090
+ default: throw new Error("invalid transcript format: expected text, json, yaml, or raw");
2091
+ }
2092
+ }
2093
+ async function startGranolaServer(app, options = {}) {
2094
+ const hostname = options.hostname ?? "127.0.0.1";
2095
+ const port = options.port ?? 0;
2096
+ const server = createServer(async (request, response) => {
2097
+ const method = request.method ?? "GET";
2098
+ const url = new URL(request.url ?? "/", `http://${hostname}`);
2099
+ const path = url.pathname;
2100
+ try {
2101
+ if (method === "GET" && path === "/health") {
2102
+ sendJson(response, {
2103
+ ok: true,
2104
+ service: "granola-toolkit",
2105
+ version: app.config ? void 0 : void 0
2106
+ });
2107
+ return;
2108
+ }
2109
+ if (method === "GET" && path === "/state") {
2110
+ sendJson(response, app.getState());
2111
+ return;
2112
+ }
2113
+ if (method === "GET" && path === "/events") {
2114
+ response.writeHead(200, {
2115
+ "cache-control": "no-cache, no-transform",
2116
+ connection: "keep-alive",
2117
+ "content-type": "text/event-stream; charset=utf-8"
2118
+ });
2119
+ response.write(formatSseEvent({
2120
+ state: app.getState(),
2121
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2122
+ type: "state.updated"
2123
+ }));
2124
+ const unsubscribe = app.subscribe((event) => {
2125
+ response.write(formatSseEvent(event));
2126
+ });
2127
+ request.on("close", () => {
2128
+ unsubscribe();
2129
+ response.end();
2130
+ });
2131
+ return;
2132
+ }
2133
+ if (method === "GET" && path === "/meetings") {
2134
+ const limit = parseInteger(url.searchParams.get("limit"));
2135
+ const search = url.searchParams.get("search")?.trim() || void 0;
2136
+ sendJson(response, {
2137
+ meetings: await app.listMeetings({
2138
+ limit,
2139
+ search
2140
+ }),
2141
+ search
2142
+ });
2143
+ return;
2144
+ }
2145
+ if (method === "GET" && path.startsWith("/meetings/")) {
2146
+ const id = decodeURIComponent(path.slice(10));
2147
+ if (!id) throw new Error("meeting id is required");
2148
+ sendJson(response, await app.getMeeting(id, { requireCache: url.searchParams.get("includeTranscript") === "true" }));
2149
+ return;
2150
+ }
2151
+ if (method === "POST" && path === "/exports/notes") {
2152
+ const body = await readJsonBody(request);
2153
+ sendJson(response, await app.exportNotes(noteFormatFromBody(body.format)), { status: 202 });
2154
+ return;
2155
+ }
2156
+ if (method === "POST" && path === "/exports/transcripts") {
2157
+ const body = await readJsonBody(request);
2158
+ sendJson(response, await app.exportTranscripts(transcriptFormatFromBody(body.format)), { status: 202 });
2159
+ return;
2160
+ }
2161
+ sendText(response, "Not found\n", 404);
2162
+ } catch (error) {
2163
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, { status: 400 });
2164
+ }
2165
+ });
2166
+ await new Promise((resolve, reject) => {
2167
+ server.once("error", reject);
2168
+ server.listen(port, hostname, () => {
2169
+ server.off("error", reject);
2170
+ resolve();
2171
+ });
2172
+ });
2173
+ const address = server.address();
2174
+ if (!address || typeof address === "string") throw new Error("failed to resolve server address");
2175
+ const resolved = address;
2176
+ const url = new URL(`http://${hostname}:${resolved.port}`);
2177
+ return {
2178
+ app,
2179
+ async close() {
2180
+ await new Promise((resolve, reject) => {
2181
+ server.close((error) => {
2182
+ if (error) {
2183
+ reject(error);
2184
+ return;
2185
+ }
2186
+ resolve();
2187
+ });
2188
+ });
2189
+ },
2190
+ hostname,
2191
+ port: resolved.port,
2192
+ server,
2193
+ url
2194
+ };
2195
+ }
2196
+ //#endregion
2197
+ //#region src/commands/serve.ts
2198
+ function serveHelp() {
2199
+ return `Granola serve
2200
+
2201
+ Usage:
2202
+ granola serve [options]
2203
+
2204
+ Options:
2205
+ --hostname <value> Hostname to bind (default: 127.0.0.1)
2206
+ --port <value> Port to bind (default: 0 for any available port)
2207
+ --cache <path> Path to Granola cache JSON
2208
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
2209
+ --supabase <path> Path to supabase.json
2210
+ --debug Enable debug logging
2211
+ --config <path> Path to .granola.toml
2212
+ -h, --help Show help
2213
+ `;
2214
+ }
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
+ const serveCommand = {
2223
+ description: "Start a local Granola API server",
2224
+ flags: {
2225
+ cache: { type: "string" },
2226
+ help: { type: "boolean" },
2227
+ hostname: { type: "string" },
2228
+ port: { type: "string" },
2229
+ timeout: { type: "string" }
2230
+ },
2231
+ help: serveHelp,
2232
+ name: "serve",
2233
+ async run({ commandFlags, globalFlags }) {
2234
+ const config = await loadConfig({
2235
+ globalFlags,
2236
+ subcommandFlags: commandFlags
2237
+ });
2238
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
2239
+ debug(config.debug, "supabase", config.supabase);
2240
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
2241
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
2242
+ 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",
2244
+ port: parsePort(commandFlags.port)
2245
+ });
2246
+ console.log(`Granola server listening on ${server.url.href}`);
2247
+ console.log("Endpoints:");
2248
+ console.log(" GET /health");
2249
+ console.log(" GET /state");
2250
+ console.log(" GET /events");
2251
+ console.log(" GET /meetings");
2252
+ console.log(" GET /meetings/:id");
2253
+ console.log(" POST /exports/notes");
2254
+ 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
+ });
2275
+ return 0;
2276
+ }
2277
+ };
2278
+ //#endregion
2013
2279
  //#region src/commands/transcripts.ts
2014
2280
  function transcriptsHelp() {
2015
2281
  return `Granola transcripts
@@ -2070,6 +2336,7 @@ const commands = [
2070
2336
  authCommand,
2071
2337
  meetingCommand,
2072
2338
  notesCommand,
2339
+ serveCommand,
2073
2340
  transcriptsCommand
2074
2341
  ];
2075
2342
  const commandMap = new Map(commands.map((command) => [command.name, command]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",