@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 +28 -28
- package/README.md +10 -2
- package/core/src/bridge.mjs +8 -8
- package/core/src/discovery.mjs +204 -204
- package/core/src/sync-engine.mjs +111 -11
- package/docs/how-it-works.svg +42 -42
- package/package.json +58 -54
- 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 +23 -1
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
|
|
266
|
-
|
|
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
|
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
|
+
}
|