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.
- package/README.md +5 -2
- package/dist/cli.js +443 -37
- 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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
2985
|
-
if (!serverUrl?.trim())
|
|
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
|
-
|
|
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
|
-
|
|
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"}"
|