@geml/logseq-sync 2.0.9 → 2.2.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/LICENSE +27 -28
- package/README.md +396 -377
- package/core/src/bridge.mjs +8 -8
- package/core/src/discovery.mjs +204 -204
- package/core/src/sync-engine.mjs +623 -560
- package/docs/how-it-works.svg +42 -42
- package/package.json +58 -58
- package/watcher/bin/create-graph.mjs +51 -51
- package/watcher/bin/create_graph_headless.cljs +22 -22
- package/watcher/bin/live-roundtrip.mjs +131 -131
- package/watcher/bin/logseq-sync.mjs +994 -940
package/core/src/bridge.mjs
CHANGED
|
@@ -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";
|
package/core/src/discovery.mjs
CHANGED
|
@@ -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
|
+
}
|