@geml/logseq-sync 2.0.8 → 2.0.9

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/LICENSE CHANGED
@@ -1,28 +1,28 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 GEML contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
22
-
23
- ---
24
-
25
- NOTE: This MIT license covers the *code* in this repository (geml-parser/,
26
- integrations/geml-viewer/, integrations/geml-check-action/, docs/examples/
27
- tooling). The *specification* documents are licensed separately under
28
- CC-BY-4.0 — see LICENSE-spec.md.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GEML contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ NOTE: This MIT license covers the *code* in this repository (geml-parser/,
26
+ integrations/geml-viewer/, integrations/geml-check-action/, docs/examples/
27
+ tooling). The *specification* documents are licensed separately under
28
+ CC-BY-4.0 — see LICENSE-spec.md.
package/README.md CHANGED
@@ -154,6 +154,7 @@ found and what is missing, and exits non-zero when the setup cannot sync:
154
154
  | `--two-way` | also import vault edits back, every cycle — conflicts held, deletions never imported (needs the app CLI) |
155
155
  | `--mirror` | delete vault files for pages removed from the graph |
156
156
  | `--markdown <dir>` | also write the graph there as an OG (file-version) graph the old app opens — lossy, one-way |
157
+ | `--overwrite-unmanaged` | overwrite files that were already there before the sync owned them (default: hold and name them) |
157
158
  | `--interval <seconds>` | heartbeat between signals (default 10) |
158
159
  | `--app-cli <path>` | a Logseq CLI the search did not find |
159
160
  | `--signal <file>` / `--no-signal` | the plugin bridge, or none |
@@ -262,8 +263,15 @@ the translation reverses exactly on the way back.
262
263
  deletions are **never** imported; and a graph backup is taken before the
263
264
  first import and every tenth after. The sync tells its own writes from
264
265
  yours by content hash, so nothing echoes.
265
- - Files the sync did not write are never touched: a manifest tracks what it
266
- owns, and `--mirror` only ever removes files from that list.
266
+ - **Files the sync did not write are never touched** not deleted, and not
267
+ overwritten either. A manifest per tree records what the sync wrote; a file
268
+ on disk that no manifest claims belongs to whoever put it there, so it is
269
+ held and named instead of replaced, and `--mirror` only ever removes files
270
+ from that list. This is what makes it safe to point a vault (or
271
+ `--markdown`) at a graph you already have: your pages survive the first
272
+ sync. A file already byte-identical to what the sync would write is adopted
273
+ rather than held — there is nothing of yours to lose. `--overwrite-unmanaged`
274
+ is how you say you meant it.
267
275
  - **The app's lock is the thing to know about.** A running Logseq holds
268
276
  `db.sqlite` exclusively, so the `@logseq/cli` export only works with the app
269
277
  closed (or on a graph it does not have open). Continuous sync therefore runs
@@ -1,8 +1,8 @@
1
- // The contract between the two halves of Sync Vault with GEML. The in-app plugin
2
- // writes SIGNAL_FILE through logseq.FileStorage; the watcher reacts to it and
3
- // writes STATUS_FILE back beside it. Both land in the plugin's storage
4
- // directory (<dotdir>/storages/<plugin-id>/) — the one disk location both
5
- // sides can reach. These names ARE the protocol: change them only together.
6
-
7
- export const SIGNAL_FILE = "geml-sync-dirty.json";
8
- export const STATUS_FILE = "geml-sync-status.json";
1
+ // The contract between the two halves of Sync Vault with GEML. The in-app plugin
2
+ // writes SIGNAL_FILE through logseq.FileStorage; the watcher reacts to it and
3
+ // writes STATUS_FILE back beside it. Both land in the plugin's storage
4
+ // directory (<dotdir>/storages/<plugin-id>/) — the one disk location both
5
+ // sides can reach. These names ARE the protocol: change them only together.
6
+
7
+ export const SIGNAL_FILE = "geml-sync-dirty.json";
8
+ export const STATUS_FILE = "geml-sync-status.json";
@@ -1,204 +1,204 @@
1
- // Everything the watcher can work out for itself. Each function takes a
2
- // `probe` — { platform, env, home, exists, read, listDir } — so the logic is
3
- // testable on any OS without a Logseq installation, and so `doctor` can report
4
- // exactly what was found and where.
5
- import { join } from "node:path";
6
- import { SIGNAL_FILE } from "./bridge.mjs";
7
-
8
- export const PLUGIN_ID = "logseq-plugin-sync-vault-with-geml";
9
-
10
- // The app bundle path is stable across macOS installs, and the shim the app
11
- // writes to ~/.local/bin is a two-line wrapper around exactly this pair.
12
- const MAC_APP = "/Applications/Logseq.app/Contents/MacOS/Logseq";
13
- const MAC_APP_CLI_JS = "/Applications/Logseq.app/Contents/Resources/app.asar/js/logseq-cli.js";
14
-
15
- /** Config, plugins and plugin storage — NOT where graphs live. */
16
- export function logseqDotDir(probe) {
17
- return probe.env.LOGSEQ_DOTDIR || join(probe.home, ".logseq");
18
- }
19
-
20
- /** Graph root: `<root>/graphs/<name>/db.sqlite`. The app CLI calls it --root-dir. */
21
- export function logseqRootDir(probe) {
22
- return probe.env.LOGSEQ_ROOT_DIR || join(probe.home, "logseq");
23
- }
24
-
25
- /** The file the in-app plugin touches to say "the graph changed". */
26
- export function signalFilePath(probe) {
27
- return join(logseqDotDir(probe), "storages", PLUGIN_ID, SIGNAL_FILE);
28
- }
29
-
30
- /**
31
- * What the user set in the plugin's own settings panel. Absent or half-written
32
- * settings are not an error — they just mean "nothing configured yet".
33
- */
34
- export function pluginSettings(probe) {
35
- const path = join(logseqDotDir(probe), "settings", `${PLUGIN_ID}.json`);
36
- try {
37
- const parsed = JSON.parse(probe.read(path));
38
- return parsed && typeof parsed === "object" ? parsed : {};
39
- } catch {
40
- return {};
41
- }
42
- }
43
-
44
- /**
45
- * The launcher the app writes to its CLI install directory is a two-line
46
- * wrapper it generates itself, and it stamps "logseq-cli-managed" into the
47
- * file. On Windows that launcher is a .cmd, which Node cannot exec without a
48
- * shell — but the two paths it names can be exec'd directly, which is all the
49
- * wrapper does anyway. So: read it, do not run it.
50
- * @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}|null}
51
- */
52
- export function parseManagedShim(probe, path) {
53
- let text;
54
- try {
55
- text = probe.read(path);
56
- } catch {
57
- return null;
58
- }
59
- if (!text.includes("logseq-cli-managed")) return null;
60
- const quoted = [...text.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
61
- const exe = quoted[0];
62
- const cliJs = quoted.find((q) => q.endsWith(".js"));
63
- if (!exe || !cliJs) return null;
64
- return {
65
- command: exe,
66
- argsPrefix: [cliJs],
67
- env: { ELECTRON_RUN_AS_NODE: "1" },
68
- how: `read from the launcher at ${path}`,
69
- };
70
- }
71
-
72
- /**
73
- * Every plausible way to reach the app's CLI, best first.
74
- *
75
- * The ranking matters more than the search: what the app itself installed is
76
- * something it chose, on a platform we may never have run on. Anything we
77
- * DEDUCE — a launcher we parse, a bundle path we hardcoded — ranks below it,
78
- * because those are our assumptions rather than the app's own answer.
79
- *
80
- * @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}[]}
81
- */
82
- export function appCliCandidates(probe) {
83
- const win = probe.platform === "win32";
84
- const out = [];
85
- const seen = new Set();
86
- const add = (c) => {
87
- if (c && !seen.has(c.command)) { seen.add(c.command); out.push(c); }
88
- };
89
-
90
- // A launcher, wherever we find it: exec it directly when we can, and read
91
- // the paths out of it when we cannot (Windows .cmd).
92
- const launcher = (path, how) => {
93
- if (!probe.exists(path)) return null;
94
- if (/\.(cmd|bat)$/i.test(path)) {
95
- const parsed = parseManagedShim(probe, path);
96
- return parsed && { ...parsed, how: `${how}, read rather than run` };
97
- }
98
- return { command: path, argsPrefix: [], env: {}, how };
99
- };
100
-
101
- // 1. On PATH — the app put it there for exactly this.
102
- const sep = win ? ";" : ":";
103
- for (const dir of (probe.env.PATH || "").split(sep).filter(Boolean)) {
104
- for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
105
- add(launcher(join(dir, name), "found on PATH"));
106
- }
107
- }
108
-
109
- // 2. The app's default CLI install directory, even when it is not on PATH.
110
- for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
111
- add(launcher(join(probe.home, ".local", "bin", name), "the launcher the app installs"));
112
- }
113
-
114
- // 3. Last: a path we hardcoded. Verified before use, never assumed.
115
- if (probe.platform === "darwin" && probe.exists(MAC_APP)) {
116
- add({
117
- command: MAC_APP,
118
- argsPrefix: [MAC_APP_CLI_JS],
119
- env: { ELECTRON_RUN_AS_NODE: "1" },
120
- how: "the Logseq.app bundle",
121
- });
122
- }
123
-
124
- return out;
125
- }
126
-
127
- /**
128
- * The CLI to use. Pass `verify` — a function that actually tries a candidate —
129
- * and the first one that works is returned, which beats any amount of guessing
130
- * about where things live on an OS or version we have not run on.
131
- * @param {(candidate: object) => boolean} [verify]
132
- */
133
- export function findAppCli(probe, verify) {
134
- const candidates = appCliCandidates(probe);
135
- if (!verify) return candidates[0] ?? null;
136
- for (const c of candidates) {
137
- if (verify(c)) return c;
138
- }
139
- return null;
140
- }
141
-
142
- /**
143
- * Which graph to sync.
144
- * @returns {{name: string, how: string}|{candidates: string[]}|null}
145
- * a name when it is unambiguous, a candidate list when the user must choose,
146
- * null when there are no graphs at all.
147
- */
148
- export function detectGraph(probe) {
149
- const graphsDir = join(logseqRootDir(probe), "graphs");
150
- const all = probe.listDir(graphsDir).filter((n) => !n.startsWith("."));
151
- if (all.length === 0) return null;
152
-
153
- // A db-worker lock says a worker exists, not that the app has the graph open:
154
- // every `logseq graph export` starts one of its own and leaves the file
155
- // behind. Only the desktop app stamps owner-source "electron", and that is
156
- // the graph whose sqlite the direct exporter cannot read.
157
- const open = all.filter((name) => {
158
- try {
159
- return JSON.parse(probe.read(join(graphsDir, name, "db-worker.lock")))["owner-source"] === "electron";
160
- } catch {
161
- return false;
162
- }
163
- });
164
- if (open.length === 1) return { name: open[0], how: "open in the app", graphs: all };
165
- if (all.length === 1) return { name: all[0], how: "the only graph", graphs: all };
166
-
167
- return { candidates: all, graphs: all };
168
- }
169
-
170
- /**
171
- * The same question as detectGraph, asked of the CLI instead of the filesystem.
172
- * `graph list` and `server list` are the app's own answers, so this holds on
173
- * any OS and for a graph root nobody left in the default place — and
174
- * owner-source comes from the app rather than from reading its lock files.
175
- *
176
- * @param {(args: string[]) => string} runCli returns stdout, or throws
177
- * @returns {{name: string, how: string, graphs: string[], rootDir: string|null}
178
- * |{candidates: string[], graphs: string[], rootDir: string|null}
179
- * |null} null means "could not ask" — the caller should fall back.
180
- */
181
- export function detectGraphViaCli(runCli) {
182
- let graphs;
183
- try {
184
- graphs = JSON.parse(runCli(["graph", "list", "-o", "json"]))?.data?.graphs;
185
- } catch {
186
- return null;
187
- }
188
- if (!Array.isArray(graphs) || graphs.length === 0) return null;
189
-
190
- // Servers are a bonus, not a requirement: with none running we still know
191
- // the graphs, we just cannot tell which one the app has open.
192
- let open = null;
193
- let rootDir = null;
194
- try {
195
- const servers = JSON.parse(runCli(["server", "list", "-o", "json"]))?.data?.servers ?? [];
196
- rootDir = servers[0]?.["root-dir"] ?? null;
197
- const appOwned = servers.filter((s) => s?.["owner-source"] === "electron");
198
- if (appOwned.length === 1) open = appOwned[0].graph;
199
- } catch {}
200
-
201
- if (open && graphs.includes(open)) return { name: open, how: "open in the app", graphs, rootDir };
202
- if (graphs.length === 1) return { name: graphs[0], how: "the only graph", graphs, rootDir };
203
- return { candidates: graphs, graphs, rootDir };
204
- }
1
+ // Everything the watcher can work out for itself. Each function takes a
2
+ // `probe` — { platform, env, home, exists, read, listDir } — so the logic is
3
+ // testable on any OS without a Logseq installation, and so `doctor` can report
4
+ // exactly what was found and where.
5
+ import { join } from "node:path";
6
+ import { SIGNAL_FILE } from "./bridge.mjs";
7
+
8
+ export const PLUGIN_ID = "logseq-plugin-sync-vault-with-geml";
9
+
10
+ // The app bundle path is stable across macOS installs, and the shim the app
11
+ // writes to ~/.local/bin is a two-line wrapper around exactly this pair.
12
+ const MAC_APP = "/Applications/Logseq.app/Contents/MacOS/Logseq";
13
+ const MAC_APP_CLI_JS = "/Applications/Logseq.app/Contents/Resources/app.asar/js/logseq-cli.js";
14
+
15
+ /** Config, plugins and plugin storage — NOT where graphs live. */
16
+ export function logseqDotDir(probe) {
17
+ return probe.env.LOGSEQ_DOTDIR || join(probe.home, ".logseq");
18
+ }
19
+
20
+ /** Graph root: `<root>/graphs/<name>/db.sqlite`. The app CLI calls it --root-dir. */
21
+ export function logseqRootDir(probe) {
22
+ return probe.env.LOGSEQ_ROOT_DIR || join(probe.home, "logseq");
23
+ }
24
+
25
+ /** The file the in-app plugin touches to say "the graph changed". */
26
+ export function signalFilePath(probe) {
27
+ return join(logseqDotDir(probe), "storages", PLUGIN_ID, SIGNAL_FILE);
28
+ }
29
+
30
+ /**
31
+ * What the user set in the plugin's own settings panel. Absent or half-written
32
+ * settings are not an error — they just mean "nothing configured yet".
33
+ */
34
+ export function pluginSettings(probe) {
35
+ const path = join(logseqDotDir(probe), "settings", `${PLUGIN_ID}.json`);
36
+ try {
37
+ const parsed = JSON.parse(probe.read(path));
38
+ return parsed && typeof parsed === "object" ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The launcher the app writes to its CLI install directory is a two-line
46
+ * wrapper it generates itself, and it stamps "logseq-cli-managed" into the
47
+ * file. On Windows that launcher is a .cmd, which Node cannot exec without a
48
+ * shell — but the two paths it names can be exec'd directly, which is all the
49
+ * wrapper does anyway. So: read it, do not run it.
50
+ * @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}|null}
51
+ */
52
+ export function parseManagedShim(probe, path) {
53
+ let text;
54
+ try {
55
+ text = probe.read(path);
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (!text.includes("logseq-cli-managed")) return null;
60
+ const quoted = [...text.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
61
+ const exe = quoted[0];
62
+ const cliJs = quoted.find((q) => q.endsWith(".js"));
63
+ if (!exe || !cliJs) return null;
64
+ return {
65
+ command: exe,
66
+ argsPrefix: [cliJs],
67
+ env: { ELECTRON_RUN_AS_NODE: "1" },
68
+ how: `read from the launcher at ${path}`,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Every plausible way to reach the app's CLI, best first.
74
+ *
75
+ * The ranking matters more than the search: what the app itself installed is
76
+ * something it chose, on a platform we may never have run on. Anything we
77
+ * DEDUCE — a launcher we parse, a bundle path we hardcoded — ranks below it,
78
+ * because those are our assumptions rather than the app's own answer.
79
+ *
80
+ * @returns {{command: string, argsPrefix: string[], env: Record<string,string>, how: string}[]}
81
+ */
82
+ export function appCliCandidates(probe) {
83
+ const win = probe.platform === "win32";
84
+ const out = [];
85
+ const seen = new Set();
86
+ const add = (c) => {
87
+ if (c && !seen.has(c.command)) { seen.add(c.command); out.push(c); }
88
+ };
89
+
90
+ // A launcher, wherever we find it: exec it directly when we can, and read
91
+ // the paths out of it when we cannot (Windows .cmd).
92
+ const launcher = (path, how) => {
93
+ if (!probe.exists(path)) return null;
94
+ if (/\.(cmd|bat)$/i.test(path)) {
95
+ const parsed = parseManagedShim(probe, path);
96
+ return parsed && { ...parsed, how: `${how}, read rather than run` };
97
+ }
98
+ return { command: path, argsPrefix: [], env: {}, how };
99
+ };
100
+
101
+ // 1. On PATH — the app put it there for exactly this.
102
+ const sep = win ? ";" : ":";
103
+ for (const dir of (probe.env.PATH || "").split(sep).filter(Boolean)) {
104
+ for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
105
+ add(launcher(join(dir, name), "found on PATH"));
106
+ }
107
+ }
108
+
109
+ // 2. The app's default CLI install directory, even when it is not on PATH.
110
+ for (const name of win ? ["logseq.exe", "logseq.cmd"] : ["logseq"]) {
111
+ add(launcher(join(probe.home, ".local", "bin", name), "the launcher the app installs"));
112
+ }
113
+
114
+ // 3. Last: a path we hardcoded. Verified before use, never assumed.
115
+ if (probe.platform === "darwin" && probe.exists(MAC_APP)) {
116
+ add({
117
+ command: MAC_APP,
118
+ argsPrefix: [MAC_APP_CLI_JS],
119
+ env: { ELECTRON_RUN_AS_NODE: "1" },
120
+ how: "the Logseq.app bundle",
121
+ });
122
+ }
123
+
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * The CLI to use. Pass `verify` — a function that actually tries a candidate —
129
+ * and the first one that works is returned, which beats any amount of guessing
130
+ * about where things live on an OS or version we have not run on.
131
+ * @param {(candidate: object) => boolean} [verify]
132
+ */
133
+ export function findAppCli(probe, verify) {
134
+ const candidates = appCliCandidates(probe);
135
+ if (!verify) return candidates[0] ?? null;
136
+ for (const c of candidates) {
137
+ if (verify(c)) return c;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Which graph to sync.
144
+ * @returns {{name: string, how: string}|{candidates: string[]}|null}
145
+ * a name when it is unambiguous, a candidate list when the user must choose,
146
+ * null when there are no graphs at all.
147
+ */
148
+ export function detectGraph(probe) {
149
+ const graphsDir = join(logseqRootDir(probe), "graphs");
150
+ const all = probe.listDir(graphsDir).filter((n) => !n.startsWith("."));
151
+ if (all.length === 0) return null;
152
+
153
+ // A db-worker lock says a worker exists, not that the app has the graph open:
154
+ // every `logseq graph export` starts one of its own and leaves the file
155
+ // behind. Only the desktop app stamps owner-source "electron", and that is
156
+ // the graph whose sqlite the direct exporter cannot read.
157
+ const open = all.filter((name) => {
158
+ try {
159
+ return JSON.parse(probe.read(join(graphsDir, name, "db-worker.lock")))["owner-source"] === "electron";
160
+ } catch {
161
+ return false;
162
+ }
163
+ });
164
+ if (open.length === 1) return { name: open[0], how: "open in the app", graphs: all };
165
+ if (all.length === 1) return { name: all[0], how: "the only graph", graphs: all };
166
+
167
+ return { candidates: all, graphs: all };
168
+ }
169
+
170
+ /**
171
+ * The same question as detectGraph, asked of the CLI instead of the filesystem.
172
+ * `graph list` and `server list` are the app's own answers, so this holds on
173
+ * any OS and for a graph root nobody left in the default place — and
174
+ * owner-source comes from the app rather than from reading its lock files.
175
+ *
176
+ * @param {(args: string[]) => string} runCli returns stdout, or throws
177
+ * @returns {{name: string, how: string, graphs: string[], rootDir: string|null}
178
+ * |{candidates: string[], graphs: string[], rootDir: string|null}
179
+ * |null} null means "could not ask" — the caller should fall back.
180
+ */
181
+ export function detectGraphViaCli(runCli) {
182
+ let graphs;
183
+ try {
184
+ graphs = JSON.parse(runCli(["graph", "list", "-o", "json"]))?.data?.graphs;
185
+ } catch {
186
+ return null;
187
+ }
188
+ if (!Array.isArray(graphs) || graphs.length === 0) return null;
189
+
190
+ // Servers are a bonus, not a requirement: with none running we still know
191
+ // the graphs, we just cannot tell which one the app has open.
192
+ let open = null;
193
+ let rootDir = null;
194
+ try {
195
+ const servers = JSON.parse(runCli(["server", "list", "-o", "json"]))?.data?.servers ?? [];
196
+ rootDir = servers[0]?.["root-dir"] ?? null;
197
+ const appOwned = servers.filter((s) => s?.["owner-source"] === "electron");
198
+ if (appOwned.length === 1) open = appOwned[0].graph;
199
+ } catch {}
200
+
201
+ if (open && graphs.includes(open)) return { name: open, how: "open in the app", graphs, rootDir };
202
+ if (graphs.length === 1) return { name: graphs[0], how: "the only graph", graphs, rootDir };
203
+ return { candidates: graphs, graphs, rootDir };
204
+ }