granola-toolkit 0.59.0 → 0.60.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 +5 -2
  2. package/dist/cli.js +443 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,8 +26,7 @@ macOS arm64, Linux x64, and Windows x64. Extract the archive and run `granola` (
26
26
  ```bash
27
27
  granola init --provider openrouter
28
28
  granola auth login --api-key grn_...
29
- granola sync
30
- granola sync --watch
29
+ granola service start
31
30
  granola web
32
31
  ```
33
32
 
@@ -39,6 +38,10 @@ If you start with `granola web`, the browser now walks you through the same firs
39
38
  enter a Granola API key, import your meetings, choose an agent provider, and land in a workspace
40
39
  with a starter reviewable notes pipeline already configured.
41
40
 
41
+ `granola service start` is the new long-running background mode. It keeps the local sync loop warm,
42
+ serves the browser workspace, and lets `granola attach` discover the running service without making
43
+ you keep a foreground terminal open.
44
+
42
45
  If you prefer to reuse the desktop app session instead, `granola auth login` still imports it from
43
46
  `supabase.json`.
44
47
 
package/dist/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
3
- import { existsSync } from "node:fs";
2
+ import { closeSync, existsSync, openSync } from "node:fs";
4
3
  import { access, appendFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { execFile, spawn } from "node:child_process";
5
5
  import { homedir, platform, tmpdir } from "node:os";
6
6
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
7
  import { NodeHtmlMarkdown } from "node-html-markdown";
8
+ import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
9
  import { createHash, randomUUID } from "node:crypto";
9
- import { execFile, spawn } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
11
  import { createServer } from "node:http";
12
12
  //#region src/transport.ts
@@ -453,6 +453,34 @@ async function createGranolaServerClient(serverUrl, options = {}) {
453
453
  return await GranolaServerClient.connect(serverUrl, options);
454
454
  }
455
455
  //#endregion
456
+ //#region src/persistence/layout.ts
457
+ function defaultGranolaToolkitDataDirectory(targetPlatform = platform(), homeDirectory = homedir()) {
458
+ return targetPlatform === "darwin" ? join(homeDirectory, "Library", "Application Support", "granola-toolkit") : join(homeDirectory, ".config", "granola-toolkit");
459
+ }
460
+ function defaultGranolaToolkitPersistenceLayout(options = {}) {
461
+ const targetPlatform = options.platform ?? platform();
462
+ const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
463
+ return {
464
+ agentHarnessesFile: join(dataDirectory, "agent-harnesses.json"),
465
+ automationArtefactsFile: join(dataDirectory, "automation-artefacts.json"),
466
+ automationMatchesFile: join(dataDirectory, "automation-matches.jsonl"),
467
+ automationRulesFile: join(dataDirectory, "automation-rules.json"),
468
+ automationRunsFile: join(dataDirectory, "automation-runs.jsonl"),
469
+ apiKeyFile: join(dataDirectory, "api-key.txt"),
470
+ dataDirectory,
471
+ exportJobsFile: join(dataDirectory, "export-jobs.json"),
472
+ meetingIndexFile: join(dataDirectory, "meeting-index.json"),
473
+ pkmTargetsFile: join(dataDirectory, "pkm-targets.json"),
474
+ searchIndexFile: join(dataDirectory, "search-index.json"),
475
+ serviceLogFile: join(dataDirectory, "service.log"),
476
+ serviceStateFile: join(dataDirectory, "service.json"),
477
+ sessionFile: join(dataDirectory, "session.json"),
478
+ sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
479
+ syncEventsFile: join(dataDirectory, "sync-events.jsonl"),
480
+ syncStateFile: join(dataDirectory, "sync-state.json")
481
+ };
482
+ }
483
+ //#endregion
456
484
  //#region src/utils.ts
457
485
  const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
458
486
  const CONTROL_CHARACTERS = /\p{Cc}/gu;
@@ -602,6 +630,159 @@ function transcriptSpeakerLabel(segment) {
602
630
  return source;
603
631
  }
604
632
  //#endregion
633
+ //#region src/service.ts
634
+ function defaultServiceStateFilePath() {
635
+ return defaultGranolaToolkitPersistenceLayout().serviceStateFile;
636
+ }
637
+ function defaultServiceLogFilePath() {
638
+ return defaultGranolaToolkitPersistenceLayout().serviceLogFile;
639
+ }
640
+ function createTimeoutSignal(timeoutMs) {
641
+ if (timeoutMs <= 0 || typeof AbortSignal.timeout !== "function") return;
642
+ return AbortSignal.timeout(timeoutMs);
643
+ }
644
+ function parseServiceRecord(value) {
645
+ if (!value || typeof value !== "object") return;
646
+ const candidate = value;
647
+ if (typeof candidate.hostname !== "string" || typeof candidate.logFile !== "string" || typeof candidate.passwordProtected !== "boolean" || typeof candidate.pid !== "number" || typeof candidate.port !== "number" || typeof candidate.protocolVersion !== "number" || typeof candidate.startedAt !== "string" || typeof candidate.syncEnabled !== "boolean" || typeof candidate.syncIntervalMs !== "number" || typeof candidate.url !== "string") return;
648
+ return candidate;
649
+ }
650
+ async function fetchServerInfo(serviceUrl, options = {}) {
651
+ const response = await (options.fetchImpl ?? globalThis.fetch)(new URL(granolaTransportPaths.serverInfo, serviceUrl), {
652
+ headers: { accept: "application/json" },
653
+ signal: createTimeoutSignal(options.timeoutMs ?? 1500)
654
+ });
655
+ if (!response.ok) throw new Error(`service responded with ${response.status} ${response.statusText}`.trim());
656
+ const info = await response.json();
657
+ if (info.product !== "granola-toolkit" || info.protocolVersion !== 2) throw new Error("service metadata did not match the expected Granola Toolkit protocol");
658
+ return info;
659
+ }
660
+ function currentGranolaCliInvocation(argv = process.argv, execPath = process.execPath) {
661
+ const entrypoint = argv[1];
662
+ return {
663
+ args: typeof entrypoint === "string" && /\.(?:[cm]?js|mjs|cjs|ts)$/.test(entrypoint) ? [entrypoint] : [],
664
+ file: execPath
665
+ };
666
+ }
667
+ function defaultGranolaServiceRecord() {
668
+ return {
669
+ logFile: defaultServiceLogFilePath(),
670
+ serviceStateFile: defaultServiceStateFilePath()
671
+ };
672
+ }
673
+ function isGranolaServiceProcessRunning(pid) {
674
+ if (!Number.isInteger(pid) || pid < 1) return false;
675
+ try {
676
+ process.kill(pid, 0);
677
+ return true;
678
+ } catch (error) {
679
+ if (error && typeof error === "object" && "code" in error && error.code === "EPERM") return true;
680
+ return false;
681
+ }
682
+ }
683
+ async function readGranolaServiceRecord(serviceStateFile = defaultServiceStateFilePath()) {
684
+ try {
685
+ const raw = await readUtf8(serviceStateFile);
686
+ return parseServiceRecord(JSON.parse(raw));
687
+ } catch {
688
+ return;
689
+ }
690
+ }
691
+ async function writeGranolaServiceRecord(record, serviceStateFile = defaultServiceStateFilePath()) {
692
+ await writeTextFile$1(serviceStateFile, `${JSON.stringify(record, null, 2)}\n`);
693
+ }
694
+ async function removeGranolaServiceRecord(serviceStateFile = defaultServiceStateFilePath()) {
695
+ await rm(serviceStateFile, { force: true });
696
+ }
697
+ async function inspectGranolaService(options = {}) {
698
+ const record = await readGranolaServiceRecord(options.serviceStateFile);
699
+ if (!record) return { kind: "missing" };
700
+ if (!(options.isProcessRunning ?? isGranolaServiceProcessRunning)(record.pid)) {
701
+ if (options.cleanupStale !== false) await removeGranolaServiceRecord(options.serviceStateFile);
702
+ return {
703
+ kind: "stale",
704
+ record
705
+ };
706
+ }
707
+ try {
708
+ return {
709
+ info: await fetchServerInfo(record.url, {
710
+ fetchImpl: options.fetchImpl,
711
+ timeoutMs: options.timeoutMs
712
+ }),
713
+ kind: "running",
714
+ record
715
+ };
716
+ } catch (error) {
717
+ return {
718
+ error: error instanceof Error ? error : new Error(String(error)),
719
+ kind: "unreachable",
720
+ record
721
+ };
722
+ }
723
+ }
724
+ async function discoverGranolaService(options = {}) {
725
+ const status = await inspectGranolaService(options);
726
+ return status.kind === "running" ? status.record : void 0;
727
+ }
728
+ async function waitForGranolaService(options = {}) {
729
+ const intervalMs = Math.max(100, options.intervalMs ?? 200);
730
+ const timeoutMs = Math.max(intervalMs, options.timeoutMs ?? 1e4);
731
+ const startedAt = Date.now();
732
+ let lastStatus = { kind: "missing" };
733
+ while (Date.now() - startedAt <= timeoutMs) {
734
+ lastStatus = await inspectGranolaService({
735
+ cleanupStale: false,
736
+ fetchImpl: options.fetchImpl,
737
+ isProcessRunning: options.isProcessRunning,
738
+ serviceStateFile: options.serviceStateFile,
739
+ timeoutMs: options.timeoutMs
740
+ });
741
+ if (lastStatus.kind === "running") return lastStatus;
742
+ await new Promise((resolve) => {
743
+ setTimeout(resolve, intervalMs);
744
+ });
745
+ }
746
+ return lastStatus;
747
+ }
748
+ async function readGranolaServiceLogTail(options = {}) {
749
+ const logFile = options.logFile ?? defaultServiceLogFilePath();
750
+ try {
751
+ await access(logFile);
752
+ } catch {
753
+ return;
754
+ }
755
+ const raw = await readFile(logFile, "utf8");
756
+ const byteLimit = Math.max(256, options.bytes ?? 4e3);
757
+ return raw.slice(-byteLimit).trim() || void 0;
758
+ }
759
+ async function spawnGranolaServiceProcess(options = {}) {
760
+ const cliInvocation = options.cliInvocation ?? currentGranolaCliInvocation();
761
+ const stdoutFd = openSync(options.logFile ?? defaultServiceLogFilePath(), "a");
762
+ const spawnImpl = options.spawnImpl ?? spawn;
763
+ try {
764
+ const child = spawnImpl(cliInvocation.file, [
765
+ ...cliInvocation.args,
766
+ "service",
767
+ "run",
768
+ ...options.commandArgs ?? []
769
+ ], {
770
+ cwd: options.cwd ?? process.cwd(),
771
+ detached: true,
772
+ env: options.env ?? process.env,
773
+ stdio: [
774
+ "ignore",
775
+ stdoutFd,
776
+ stdoutFd
777
+ ]
778
+ });
779
+ child.unref();
780
+ return child.pid ?? 0;
781
+ } finally {
782
+ closeSync(stdoutFd);
783
+ }
784
+ }
785
+ //#endregion
605
786
  //#region src/meeting-roles.ts
606
787
  function normaliseKey(value) {
607
788
  return (value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
@@ -2963,7 +3144,7 @@ function attachHelp() {
2963
3144
  return `Granola attach
2964
3145
 
2965
3146
  Usage:
2966
- granola attach <url> [options]
3147
+ granola attach [url] [options]
2967
3148
 
2968
3149
  Options:
2969
3150
  --meeting <id> Open the workspace focused on a specific meeting
@@ -2981,39 +3162,19 @@ const attachCommand = {
2981
3162
  help: attachHelp,
2982
3163
  name: "attach",
2983
3164
  async run({ commandArgs, commandFlags }) {
2984
- const serverUrl = commandArgs[0];
2985
- if (!serverUrl?.trim()) throw new Error("attach requires a server URL, for example http://127.0.0.1:4123");
3165
+ let serverUrl = commandArgs[0];
3166
+ if (!serverUrl?.trim()) {
3167
+ const discovered = await discoverGranolaService();
3168
+ if (!discovered) throw new Error("attach requires a server URL or a running background service. Start one with `granola service start`.");
3169
+ serverUrl = discovered.url;
3170
+ console.log(`Attaching to ${serverUrl}`);
3171
+ }
2986
3172
  const initialMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
2987
- return await runGranolaTui(await createGranolaServerClient(serverUrl, { password: typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0 }), { initialMeetingId });
3173
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0;
3174
+ return await runGranolaTui(await createGranolaServerClient(serverUrl, { password }), { initialMeetingId });
2988
3175
  }
2989
3176
  };
2990
3177
  //#endregion
2991
- //#region src/persistence/layout.ts
2992
- function defaultGranolaToolkitDataDirectory(targetPlatform = platform(), homeDirectory = homedir()) {
2993
- return targetPlatform === "darwin" ? join(homeDirectory, "Library", "Application Support", "granola-toolkit") : join(homeDirectory, ".config", "granola-toolkit");
2994
- }
2995
- function defaultGranolaToolkitPersistenceLayout(options = {}) {
2996
- const targetPlatform = options.platform ?? platform();
2997
- const dataDirectory = defaultGranolaToolkitDataDirectory(targetPlatform, options.homeDirectory ?? homedir());
2998
- return {
2999
- agentHarnessesFile: join(dataDirectory, "agent-harnesses.json"),
3000
- automationArtefactsFile: join(dataDirectory, "automation-artefacts.json"),
3001
- automationMatchesFile: join(dataDirectory, "automation-matches.jsonl"),
3002
- automationRulesFile: join(dataDirectory, "automation-rules.json"),
3003
- automationRunsFile: join(dataDirectory, "automation-runs.jsonl"),
3004
- apiKeyFile: join(dataDirectory, "api-key.txt"),
3005
- dataDirectory,
3006
- exportJobsFile: join(dataDirectory, "export-jobs.json"),
3007
- meetingIndexFile: join(dataDirectory, "meeting-index.json"),
3008
- pkmTargetsFile: join(dataDirectory, "pkm-targets.json"),
3009
- searchIndexFile: join(dataDirectory, "search-index.json"),
3010
- sessionFile: join(dataDirectory, "session.json"),
3011
- sessionStoreKind: targetPlatform === "darwin" ? "keychain" : "file",
3012
- syncEventsFile: join(dataDirectory, "sync-events.jsonl"),
3013
- syncStateFile: join(dataDirectory, "sync-state.json")
3014
- };
3015
- }
3016
- //#endregion
3017
3178
  //#region src/agent-harnesses.ts
3018
3179
  function stringArray$1(value) {
3019
3180
  if (!Array.isArray(value)) return;
@@ -10787,6 +10948,229 @@ const searchCommand = {
10787
10948
  }
10788
10949
  };
10789
10950
  //#endregion
10951
+ //#region src/commands/service.ts
10952
+ function serviceHelp() {
10953
+ return `Granola service
10954
+
10955
+ Usage:
10956
+ granola service start [options]
10957
+ granola service status
10958
+ granola service stop
10959
+
10960
+ Options:
10961
+ --network <mode> Network mode: local or lan (default: local)
10962
+ --hostname <value> Hostname to bind (overrides network default)
10963
+ --port <value> Port to bind (default: 0 for any available port)
10964
+ --password <value> Optional server password for API and browser access
10965
+ --sync-interval <value> Background sync interval, e.g. 60s or 5m (default: 60s)
10966
+ --no-sync Disable the background sync loop
10967
+ --trusted-origins <v> Comma-separated extra browser origins to trust
10968
+ --cache <path> Path to Granola cache JSON
10969
+ --timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
10970
+ --supabase <path> Path to supabase.json
10971
+ --debug Enable debug logging
10972
+ --config <path> Path to .granola.toml
10973
+ -h, --help Show help
10974
+ `;
10975
+ }
10976
+ function appendFlag(args, name, value) {
10977
+ if (value === void 0 || value === false) return;
10978
+ args.push(`--${name}`);
10979
+ if (typeof value === "string") args.push(value);
10980
+ }
10981
+ function serialiseServiceFlags(commandFlags, globalFlags) {
10982
+ const args = [];
10983
+ appendFlag(args, "network", commandFlags.network);
10984
+ appendFlag(args, "hostname", commandFlags.hostname);
10985
+ appendFlag(args, "port", commandFlags.port);
10986
+ appendFlag(args, "password", commandFlags.password);
10987
+ appendFlag(args, "sync-interval", commandFlags["sync-interval"]);
10988
+ appendFlag(args, "no-sync", commandFlags["no-sync"]);
10989
+ appendFlag(args, "trusted-origins", commandFlags["trusted-origins"]);
10990
+ appendFlag(args, "cache", commandFlags.cache);
10991
+ appendFlag(args, "timeout", commandFlags.timeout);
10992
+ appendFlag(args, "config", globalFlags.config);
10993
+ appendFlag(args, "rules", globalFlags.rules);
10994
+ appendFlag(args, "supabase", globalFlags.supabase);
10995
+ appendFlag(args, "debug", globalFlags.debug);
10996
+ const env = { ...process.env };
10997
+ if (typeof globalFlags["api-key"] === "string" && globalFlags["api-key"].trim()) env.GRANOLA_API_KEY = globalFlags["api-key"].trim();
10998
+ return {
10999
+ args,
11000
+ env
11001
+ };
11002
+ }
11003
+ function printServiceStatus(status) {
11004
+ switch (status.kind) {
11005
+ case "running":
11006
+ console.log(`Granola Toolkit service is running on ${status.record?.url}`);
11007
+ console.log(`PID: ${status.record?.pid}`);
11008
+ console.log(`Log: ${status.record?.logFile}`);
11009
+ console.log(status.record?.syncEnabled ? `Background sync: enabled (${status.record.syncIntervalMs}ms)` : "Background sync: disabled");
11010
+ if (status.record?.passwordProtected) console.log("Password protection: enabled");
11011
+ return;
11012
+ case "stale":
11013
+ console.log("Granola Toolkit service metadata exists, but the process is not running.");
11014
+ return;
11015
+ case "unreachable":
11016
+ console.log("Granola Toolkit service metadata exists, but the server did not respond.");
11017
+ if (status.record?.url) console.log(`Last known URL: ${status.record.url}`);
11018
+ if (status.error) console.log(`Health check: ${status.error.message}`);
11019
+ return;
11020
+ case "invalid":
11021
+ console.log("Granola Toolkit service metadata is invalid.");
11022
+ return;
11023
+ default: console.log("Granola Toolkit service is not running.");
11024
+ }
11025
+ }
11026
+ function printServiceRunBanner(record) {
11027
+ console.log(`Granola Toolkit background service listening on ${record.url}`);
11028
+ console.log(record.syncEnabled ? `Background sync: enabled (${record.syncIntervalMs}ms)` : "Background sync: disabled");
11029
+ if (record.passwordProtected) console.log("Password protection: enabled");
11030
+ }
11031
+ async function runServiceProcess(config, commandFlags) {
11032
+ const hostname = resolveServerHostname(parseNetworkMode(commandFlags.network), commandFlags.hostname);
11033
+ const port = parsePort(commandFlags.port);
11034
+ const password = typeof commandFlags.password === "string" && commandFlags.password.trim() ? commandFlags.password.trim() : void 0;
11035
+ const syncEnabledForService = syncEnabled(commandFlags);
11036
+ const syncIntervalMs = parseSyncInterval(commandFlags["sync-interval"]);
11037
+ const trustedOrigins = parseTrustedOrigins(commandFlags["trusted-origins"]);
11038
+ const { logFile, serviceStateFile } = defaultGranolaServiceRecord();
11039
+ const app = await createGranolaApp(config, { surface: "server" });
11040
+ const server = await startGranolaServer(app, {
11041
+ enableWebClient: true,
11042
+ hostname,
11043
+ port,
11044
+ security: {
11045
+ password,
11046
+ trustedOrigins
11047
+ }
11048
+ });
11049
+ const syncLoop = syncEnabledForService ? createGranolaSyncLoop({
11050
+ app,
11051
+ intervalMs: syncIntervalMs,
11052
+ logger: console
11053
+ }) : void 0;
11054
+ const record = {
11055
+ hostname: server.hostname,
11056
+ logFile,
11057
+ passwordProtected: Boolean(password),
11058
+ pid: process.pid,
11059
+ port: server.port,
11060
+ protocolVersion: 2,
11061
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
11062
+ syncEnabled: syncEnabledForService,
11063
+ syncIntervalMs,
11064
+ url: server.url.href
11065
+ };
11066
+ await writeGranolaServiceRecord(record, serviceStateFile);
11067
+ syncLoop?.start();
11068
+ printServiceRunBanner(record);
11069
+ await waitForShutdown(async () => {
11070
+ await syncLoop?.stop();
11071
+ await server.close();
11072
+ await removeGranolaServiceRecord(serviceStateFile);
11073
+ });
11074
+ return 0;
11075
+ }
11076
+ const serviceCommand = {
11077
+ description: "Run and manage the Granola Toolkit background service",
11078
+ flags: {
11079
+ cache: { type: "string" },
11080
+ help: { type: "boolean" },
11081
+ hostname: { type: "string" },
11082
+ network: { type: "string" },
11083
+ "no-sync": { type: "boolean" },
11084
+ password: { type: "string" },
11085
+ port: { type: "string" },
11086
+ "sync-interval": { type: "string" },
11087
+ timeout: { type: "string" },
11088
+ "trusted-origins": { type: "string" }
11089
+ },
11090
+ help: serviceHelp,
11091
+ name: "service",
11092
+ async run({ commandArgs, commandFlags, globalFlags }) {
11093
+ const action = commandArgs[0];
11094
+ if (!action) {
11095
+ console.log(serviceHelp());
11096
+ return 1;
11097
+ }
11098
+ if (action === "status") {
11099
+ const status = await inspectGranolaService();
11100
+ printServiceStatus(status);
11101
+ return status.kind === "running" ? 0 : 1;
11102
+ }
11103
+ if (action === "stop") {
11104
+ const status = await inspectGranolaService({ cleanupStale: false });
11105
+ if (status.kind === "missing" || status.kind === "stale") {
11106
+ printServiceStatus(status);
11107
+ await removeGranolaServiceRecord(defaultGranolaServiceRecord().serviceStateFile);
11108
+ return 0;
11109
+ }
11110
+ if (status.kind !== "running" || !status.record) {
11111
+ printServiceStatus(status);
11112
+ return 1;
11113
+ }
11114
+ process.kill(status.record.pid, "SIGTERM");
11115
+ const startedAt = Date.now();
11116
+ while (Date.now() - startedAt <= 1e4) {
11117
+ const nextStatus = await inspectGranolaService();
11118
+ if (nextStatus.kind === "missing" || nextStatus.kind === "stale") {
11119
+ console.log("Granola Toolkit service stopped.");
11120
+ return 0;
11121
+ }
11122
+ await new Promise((resolve) => {
11123
+ setTimeout(resolve, 200);
11124
+ });
11125
+ }
11126
+ throw new Error("service did not stop within 10s");
11127
+ }
11128
+ if (action === "run") {
11129
+ const config = await loadConfig({
11130
+ globalFlags,
11131
+ subcommandFlags: commandFlags
11132
+ });
11133
+ debug(config.debug, "using config", config.configFileUsed ?? "(none)");
11134
+ debug(config.debug, "supabase", config.supabase);
11135
+ debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
11136
+ debug(config.debug, "timeoutMs", config.notes.timeoutMs);
11137
+ return await runServiceProcess(config, commandFlags);
11138
+ }
11139
+ if (action === "start") {
11140
+ const existing = await discoverGranolaService();
11141
+ if (existing) {
11142
+ console.log(`Granola Toolkit service is already running on ${existing.url}`);
11143
+ console.log(`PID: ${existing.pid}`);
11144
+ return 0;
11145
+ }
11146
+ await loadConfig({
11147
+ env: typeof globalFlags["api-key"] === "string" && globalFlags["api-key"].trim() ? {
11148
+ ...process.env,
11149
+ GRANOLA_API_KEY: globalFlags["api-key"].trim()
11150
+ } : process.env,
11151
+ globalFlags,
11152
+ subcommandFlags: commandFlags
11153
+ });
11154
+ const { args, env } = serialiseServiceFlags(commandFlags, globalFlags);
11155
+ await spawnGranolaServiceProcess({
11156
+ commandArgs: args,
11157
+ env,
11158
+ logFile: defaultGranolaServiceRecord().logFile
11159
+ });
11160
+ const status = await waitForGranolaService();
11161
+ if (status.kind !== "running" || !status.record) {
11162
+ const logTail = await readGranolaServiceLogTail();
11163
+ throw new Error(logTail ? `service failed to start cleanly:\n${logTail}` : "service failed to start cleanly");
11164
+ }
11165
+ console.log(`Granola Toolkit service started on ${status.record.url}`);
11166
+ console.log(`PID: ${status.record.pid}`);
11167
+ console.log(`Log: ${status.record.logFile}`);
11168
+ return 0;
11169
+ }
11170
+ throw new Error(`unknown service command: ${action}`);
11171
+ }
11172
+ };
11173
+ //#endregion
10790
11174
  //#region src/commands/serve.ts
10791
11175
  function serveHelp() {
10792
11176
  return `Granola serve
@@ -11131,6 +11515,11 @@ Options:
11131
11515
  -h, --help Show help
11132
11516
  `;
11133
11517
  }
11518
+ function canReuseRunningService(commandFlags, globalFlags) {
11519
+ const hasRuntimeOverride = commandFlags.cache !== void 0 || commandFlags.hostname !== void 0 || commandFlags.network !== void 0 || commandFlags["no-sync"] !== void 0 || commandFlags.password !== void 0 || commandFlags.port !== void 0 || commandFlags["sync-interval"] !== void 0 || commandFlags.timeout !== void 0 || commandFlags["trusted-origins"] !== void 0;
11520
+ const hasGlobalOverride = globalFlags["api-key"] !== void 0 || globalFlags.config !== void 0 || globalFlags.rules !== void 0 || globalFlags.supabase !== void 0;
11521
+ return !hasRuntimeOverride && !hasGlobalOverride;
11522
+ }
11134
11523
  //#endregion
11135
11524
  //#region src/commands/index.ts
11136
11525
  const commands = [
@@ -11143,6 +11532,7 @@ const commands = [
11143
11532
  meetingCommand,
11144
11533
  notesCommand,
11145
11534
  searchCommand,
11535
+ serviceCommand,
11146
11536
  serveCommand,
11147
11537
  syncCommand,
11148
11538
  tuiCommand,
@@ -11166,6 +11556,24 @@ const commands = [
11166
11556
  help: webHelp,
11167
11557
  name: "web",
11168
11558
  async run({ commandFlags, globalFlags }) {
11559
+ const options = resolveGranolaWebWorkspaceOptions(commandFlags);
11560
+ const targetMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
11561
+ if (canReuseRunningService(commandFlags, globalFlags)) {
11562
+ const runningService = await discoverGranolaService();
11563
+ if (runningService) {
11564
+ const targetUrl = targetMeetingId ? buildGranolaMeetingUrl(new URL(runningService.url), targetMeetingId) : new URL(runningService.url);
11565
+ console.log(`Granola Toolkit web workspace already running on ${runningService.url}`);
11566
+ if (targetUrl.href !== runningService.url) console.log(`Focused meeting URL: ${targetUrl.href}`);
11567
+ if (options.openBrowser) try {
11568
+ await openExternalUrl(targetUrl);
11569
+ } catch (error) {
11570
+ const message = error instanceof Error ? error.message : String(error);
11571
+ console.error(`failed to open browser automatically: ${message}`);
11572
+ console.error(`open ${targetUrl.href} manually`);
11573
+ }
11574
+ return 0;
11575
+ }
11576
+ }
11169
11577
  const config = await loadConfig({
11170
11578
  globalFlags,
11171
11579
  subcommandFlags: commandFlags
@@ -11174,10 +11582,7 @@ const commands = [
11174
11582
  debug(config.debug, "supabase", config.supabase);
11175
11583
  debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
11176
11584
  debug(config.debug, "timeoutMs", config.notes.timeoutMs);
11177
- const app = await createGranolaApp(config, { surface: "web" });
11178
- const options = resolveGranolaWebWorkspaceOptions(commandFlags);
11179
- const targetMeetingId = typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0;
11180
- return await runGranolaWebWorkspace(app, {
11585
+ return await runGranolaWebWorkspace(await createGranolaApp(config, { surface: "web" }), {
11181
11586
  ...options,
11182
11587
  targetMeetingId
11183
11588
  });
@@ -11276,6 +11681,7 @@ Examples:
11276
11681
  granola attach http://127.0.0.1:4123
11277
11682
  granola folder list
11278
11683
  granola init --provider openrouter
11684
+ granola service start
11279
11685
  granola sync
11280
11686
  granola notes --supabase "${granolaSupabaseCandidates()[0] ?? "/path/to/supabase.json"}"
11281
11687
  granola transcripts --cache "${granolaCacheCandidates()[0] ?? "/path/to/cache-v3.json"}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",