@geml/logseq-sync 2.0.9 → 2.1.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.
@@ -1,940 +1,994 @@
1
- #!/usr/bin/env node
2
- // logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
3
- // The full usage is the USAGE constant below, printed by `logseq-sync --help`.
4
-
5
- import { execFileSync } from "node:child_process";
6
- import {
7
- readFileSync, unlinkSync, existsSync, statSync, mkdirSync, readdirSync, watch,
8
- } from "node:fs";
9
- import { join, resolve, dirname, basename, sep } from "node:path";
10
- import { tmpdir, homedir } from "node:os";
11
- import { randomUUID, createHash } from "node:crypto";
12
- import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
13
- import { ednToGemlFiles } from "../../core/src/mapping.mjs";
14
- import { STATUS_FILE } from "../../core/src/bridge.mjs";
15
- import { parse as parseGeml, addressedUnits, sliceUnit } from "@geml/geml";
16
-
17
- // The engine takes the parser injected, so core keeps its single dependency.
18
- const gemlLib = { parse: parseGeml, addressedUnits, sliceUnit };
19
- import {
20
- PLUGIN_ID, logseqDotDir, logseqRootDir, signalFilePath, pluginSettings,
21
- findAppCli, appCliCandidates, detectGraph, detectGraphViaCli, parseManagedShim,
22
- } from "../../core/src/discovery.mjs";
23
-
24
- const PLUGIN_TITLE = "Sync Vault with GEML";
25
-
26
- const USAGE = `logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
27
-
28
- Usage:
29
- logseq-sync [vault-dir] [flags] vault-dir defaults to the plugin's setting
30
- logseq-sync <graph> <vault-dir> [flags] explicit form, when you have several graphs
31
- logseq-sync doctor report what was detected and what is missing
32
- logseq-sync restore [vault-dir] vault ➔ graph. Rehearses; --yes performs it,
33
- taking a graph backup first (--no-backup to skip)
34
-
35
- Whatever can be worked out, is: the CLI that ships inside the desktop app,
36
- which graph the app currently has open, where the plugin's signal file lives,
37
- and where you told the plugin to put the vault. Every one of them has a flag
38
- to override it.
39
-
40
- Flags:
41
- --once Sync once and exit (default: keep watching)
42
- --two-way Also import vault edits back into the graph, checked
43
- on every cycle. A file changed on BOTH sides is a
44
- conflict: neither imported nor overwritten, reported
45
- until you merge it. Deletions are never imported.
46
- Takes a graph backup before the first import and
47
- every 10th after. Needs the app CLI.
48
- --git-commit Commit, creating the vault repository if there is none
49
- (default: commit only when the vault ALREADY is a repository)
50
- --no-git-commit Never touch git
51
- --mirror Delete vault files for pages removed from the graph
52
- (default: keep them, and report the divergence)
53
- --overwrite-unmanaged Overwrite files that were already there when the sync
54
- first ran (default: hold them and name them — a file
55
- no manifest claims was written by someone else)
56
- --markdown <dir> Also write the graph there as an OG (file-version)
57
- Logseq graph: bullets, id:: lines, ((uuid)) refs a
58
- directory the old app opens. Lossy and one-way
59
- (properties, tags and data blocks have no OG shape);
60
- the GEML tree stays the one that round-trips, and
61
- restore never reads this.
62
- --graph <name> Graph to export (default: the one the app has open)
63
- --app-cli <path> The desktop app's CLI (default: found on PATH, or the app bundle)
64
- --no-app-cli Force the @logseq/cli fallback, which cannot read an open graph
65
- --signal <file> Plugin bridge file (default: found in the plugin's storage dir)
66
- --no-signal Ignore the bridge; poll on the interval only
67
- --interval <seconds> Poll interval for watch mode (positive integer, default: 10)
68
- --message <text> Custom git commit message
69
- --api-server-token <token>
70
- Route the @logseq/cli fallback through the app's HTTP API
71
- server. Prefer LOGSEQ_API_SERVER_TOKEN — a token in argv is
72
- readable by every process on the machine via \`ps\`.
73
- --help, -h This text`;
74
-
75
- const args = process.argv.slice(2);
76
- const positional = [];
77
- const flags = {
78
- once: false,
79
- twoWay: false,
80
- gitCommit: "auto",
81
- mirror: false,
82
- overwriteUnmanaged: false,
83
- markdown: null,
84
- yes: false,
85
- backup: true,
86
- interval: 10,
87
- message: null,
88
- signal: undefined, // undefined = auto, null = disabled, string = explicit
89
- appCli: undefined, // undefined = auto, null = disabled, string = explicit
90
- apiServerToken: null,
91
- graph: null,
92
- };
93
-
94
- function needValue(i, name) {
95
- if (i + 1 >= args.length) {
96
- console.error(`Error: ${name} requires a value.`);
97
- process.exit(2);
98
- }
99
- }
100
-
101
- let subcommand = null;
102
- for (let i = 0; i < args.length; i++) {
103
- const arg = args[i];
104
- if (arg === "--help" || arg === "-h" || (subcommand === null && positional.length === 0 && arg === "help")) {
105
- console.log(USAGE);
106
- process.exit(0);
107
- } else if (arg === "--watch") {
108
- // Watch is the default now; the flag stays so old command lines keep working.
109
- } else if (arg === "--once") {
110
- flags.once = true;
111
- } else if (arg === "--git-commit") {
112
- flags.gitCommit = true;
113
- } else if (arg === "--no-git-commit") {
114
- flags.gitCommit = false;
115
- } else if (arg === "--yes") {
116
- flags.yes = true;
117
- } else if (arg === "--no-backup") {
118
- flags.backup = false;
119
- } else if (arg === "--two-way") {
120
- flags.twoWay = true;
121
- } else if (arg === "--mirror") {
122
- flags.mirror = true;
123
- } else if (arg === "--overwrite-unmanaged") {
124
- flags.overwriteUnmanaged = true;
125
- } else if (arg === "--markdown") {
126
- needValue(i, "--markdown");
127
- flags.markdown = args[++i];
128
- } else if (arg === "--no-signal") {
129
- flags.signal = null;
130
- } else if (arg === "--no-app-cli") {
131
- flags.appCli = null;
132
- } else if (arg === "--interval") {
133
- needValue(i, "--interval");
134
- const rawVal = args[++i];
135
- const val = Number(rawVal);
136
- if (!Number.isInteger(val) || val <= 0) {
137
- console.error(`Error: --interval must be a positive integer >= 1 (got "${rawVal}").`);
138
- process.exit(2);
139
- }
140
- flags.interval = val;
141
- } else if (arg === "--message") {
142
- needValue(i, "--message");
143
- flags.message = args[++i];
144
- } else if (arg === "--signal") {
145
- needValue(i, "--signal");
146
- flags.signal = args[++i];
147
- } else if (arg === "--app-cli") {
148
- needValue(i, "--app-cli");
149
- flags.appCli = args[++i];
150
- } else if (arg === "--graph") {
151
- needValue(i, "--graph");
152
- flags.graph = args[++i];
153
- } else if (arg === "--api-server-token") {
154
- needValue(i, "--api-server-token");
155
- flags.apiServerToken = args[++i];
156
- } else if (arg.startsWith("-")) {
157
- // One dash included: "-graph demo" once sailed through as a graph literally
158
- // named "-graph" and a vault named "demo" — a typo must stop, not sync.
159
- console.error(`Error: Unknown flag "${arg}". Run \`logseq-sync --help\` for usage.`);
160
- process.exit(2);
161
- } else if (subcommand === null && positional.length === 0 && (arg === "doctor" || arg === "restore")) {
162
- subcommand = arg;
163
- } else {
164
- positional.push(arg);
165
- }
166
- }
167
-
168
- const probe = {
169
- platform: process.platform,
170
- env: process.env,
171
- home: process.env.HOME || process.env.USERPROFILE || homedir(),
172
- exists: existsSync,
173
- read: (p) => readFileSync(p, "utf8"),
174
- listDir: (p) => {
175
- try {
176
- return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
177
- } catch {
178
- return [];
179
- }
180
- },
181
- };
182
-
183
- const settings = pluginSettings(probe);
184
-
185
- // A shell expands ~ before the watcher ever sees it; a text field in Logseq's
186
- // settings panel does not, and resolve("~/vault") would quietly create a
187
- // directory literally named "~" beside the working directory.
188
- function expandHome(p) {
189
- if (!p) return p;
190
- if (p === "~") return probe.home;
191
- if (p.startsWith("~/") || p.startsWith("~\\")) return join(probe.home, p.slice(2));
192
- return p;
193
- }
194
-
195
- // --- how to export --------------------------------------------------------
196
- const apiServerToken = (flags.apiServerToken || process.env.LOGSEQ_API_SERVER_TOKEN || "").trim() || null;
197
-
198
- function resolveAppCli() {
199
- if (flags.appCli === null) return null; // --no-app-cli
200
- const explicit = flags.appCli || (process.env.LOGSEQ_APP_CLI || "").trim() || null;
201
- if (explicit) {
202
- if (/\.(cmd|bat)$/i.test(explicit)) {
203
- // If it is the launcher the app generated, read the paths out of it
204
- // rather than refusing something that is perfectly usable.
205
- const parsed = parseManagedShim(probe, explicit);
206
- if (parsed) {
207
- if (apiServerToken) {
208
- console.error(
209
- "Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
210
- );
211
- process.exit(2);
212
- }
213
- return parsed;
214
- }
215
- console.error(
216
- `Error: --app-cli "${explicit}" is a .cmd/.bat shim; Node cannot run one without a shell. ` +
217
- `Point --app-cli at the Logseq executable itself.`
218
- );
219
- process.exit(2);
220
- }
221
- if (apiServerToken) {
222
- console.error(
223
- "Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
224
- );
225
- process.exit(2);
226
- }
227
- return { command: explicit, argsPrefix: [], env: {}, how: "given with --app-cli" };
228
- }
229
- // Auto-detection happens at the call site, where a candidate can be verified
230
- // by actually using it. An explicit token selects the fallback transport, so
231
- // there is nothing to detect.
232
- return null;
233
- }
234
-
235
- function runVia(candidate, cmdArgs) {
236
- return execFileSync(candidate.command, [...candidate.argsPrefix, ...cmdArgs], {
237
- encoding: "utf8",
238
- shell: false,
239
- maxBuffer: 1 << 24,
240
- env: { ...process.env, ...candidate.env },
241
- });
242
- }
243
-
244
- // Finding the CLI and asking it which graph to sync are the same step: a
245
- // candidate that answers `graph list` is, by that fact, the working one. So we
246
- // verify by doing the work rather than by trusting a path — which is the only
247
- // honest way to behave on an OS or Logseq version this has never run on. The
248
- // filesystem heuristics stay as the fallback for when there is no CLI at all.
249
- let appCli = resolveAppCli();
250
- let detected = null;
251
-
252
- if (appCli) {
253
- detected = detectGraphViaCli((cmdArgs) => runVia(appCli, cmdArgs));
254
- } else if (flags.appCli !== null && !apiServerToken) {
255
- const candidates = appCliCandidates(probe);
256
- for (const candidate of candidates) {
257
- const answer = detectGraphViaCli((cmdArgs) => runVia(candidate, cmdArgs));
258
- if (answer) {
259
- appCli = candidate;
260
- detected = answer;
261
- break;
262
- }
263
- }
264
- // None answered: keep the best-ranked one anyway, so the export fails with
265
- // that CLI's own error instead of a vague "no CLI found".
266
- if (!appCli) appCli = candidates[0] ?? null;
267
- }
268
-
269
- if (!detected) detected = detectGraph(probe);
270
- const knownGraphs = detected?.graphs ?? [];
271
- let graphName = flags.graph;
272
- let vaultRaw = null;
273
-
274
- if (positional.length >= 2) {
275
- if (!graphName) graphName = positional[0];
276
- vaultRaw = positional[1];
277
- } else if (positional.length === 1) {
278
- const only = positional[0];
279
- const looksLikePath = only.includes("/") || only.includes(sep) || only.startsWith(".") || only.startsWith("~");
280
- const isGraphName =
281
- !looksLikePath &&
282
- (detected?.name === only || (detected?.candidates ?? []).includes(only));
283
- if (isGraphName) {
284
- console.error(
285
- `Error: "${only}" is the name of a graph, not a vault directory. ` +
286
- `Write the destination too — logseq-sync ${only} <vault-dir> — or select it with --graph ${only}.`
287
- );
288
- process.exit(2);
289
- }
290
- vaultRaw = only;
291
- } else {
292
- vaultRaw = settings.vaultPath || null;
293
- }
294
-
295
- function resolveGraphOrExit() {
296
- if (graphName) return graphName;
297
- if (detected?.name) return detected.name;
298
- if (detected?.candidates) {
299
- console.error(
300
- `Error: several graphs and none open in the app — ${detected.candidates.join(", ")}. ` +
301
- `Pick one with --graph <name>. Run \`logseq-sync doctor\` for the full picture.`
302
- );
303
- process.exit(2);
304
- }
305
- console.error(
306
- `Error: no Logseq graphs found under ${join(logseqRootDir(probe), "graphs")}. ` +
307
- `Open a graph in Logseq first. Run \`logseq-sync doctor\` for the full picture.`
308
- );
309
- process.exit(2);
310
- }
311
-
312
- function resolveVaultOrExit() {
313
- if (vaultRaw) return resolve(expandHome(vaultRaw));
314
- console.error(
315
- `Error: no vault directory. Set it in Logseq — Settings → Plugins → ${PLUGIN_TITLE} → ` +
316
- `"Vault folder" — or pass one: logseq-sync <vault-dir>. ` +
317
- `Run \`logseq-sync doctor\` for the full picture.`
318
- );
319
- process.exit(2);
320
- }
321
-
322
-
323
-
324
- function resolveSignalPath() {
325
- if (flags.signal === null) return null; // --no-signal
326
- if (typeof flags.signal === "string") return resolve(expandHome(flags.signal));
327
- const auto = signalFilePath(probe);
328
- // The storage directory only appears once the plugin has written something,
329
- // so its absence proves nothing. Gate on the dotdir instead: if Logseq is
330
- // installed at all, writing status there is right — and it is already there
331
- // for the plugin to read the moment it loads.
332
- return existsSync(logseqDotDir(probe)) ? auto : null;
333
- }
334
-
335
- function redact(text) {
336
- const str = String(text ?? "");
337
- return apiServerToken ? str.split(apiServerToken).join("***") : str;
338
- }
339
-
340
- // --- doctor ---------------------------------------------------------------
341
- function doctor() {
342
- const dotDir = logseqDotDir(probe);
343
- const rows = [];
344
- let blocked = false;
345
-
346
- const mark = (ok, label, detail) => {
347
- rows.push(`${ok ? " ok " : " MISS "} ${label.padEnd(14)} ${detail}`);
348
- if (!ok) blocked = true;
349
- };
350
-
351
- mark(existsSync(dotDir), "Logseq dotdir", dotDir);
352
-
353
- const storage = join(dotDir, "storages", PLUGIN_ID);
354
- const pluginInstalled = existsSync(storage);
355
- rows.push(
356
- `${pluginInstalled ? " ok " : " note " } ${"plugin".padEnd(14)} ` +
357
- (pluginInstalled
358
- ? storage
359
- : `not installed yet (no ${storage}) — sync still works, the toolbar just will not update`)
360
- );
361
-
362
- if (appCli) {
363
- mark(true, "app CLI", `${appCli.command} (${appCli.how})`);
364
- } else if (apiServerToken) {
365
- rows.push(` ok ${"export".padEnd(14)} @logseq/cli through the app's API server`);
366
- } else {
367
- mark(false, "app CLI", "no Logseq CLI found on PATH or in the app bundle — install Logseq, or pass --app-cli <path>");
368
- }
369
-
370
- if (graphName) mark(true, "graph", `${graphName} (given)`);
371
- else if (detected?.name) mark(true, "graph", `${detected.name} (${detected.how})`);
372
- else if (detected?.candidates) mark(false, "graph", `ambiguous: ${detected.candidates.join(", ")} — pick one with --graph`);
373
- else mark(false, "graph", `none found under ${join(logseqRootDir(probe), "graphs")}`);
374
-
375
- if (vaultRaw) mark(true, "vault", resolve(expandHome(vaultRaw)));
376
- else mark(false, "vault", `unset — Settings → Plugins → ${PLUGIN_TITLE} → "Vault folder", or pass one as an argument`);
377
-
378
- // Only a run that will actually commit needs an author.
379
- const wouldCommit =
380
- flags.gitCommit === true ||
381
- (flags.gitCommit !== false && vaultRaw && existsSync(resolve(expandHome(vaultRaw))) && isGitRepo(resolve(expandHome(vaultRaw))));
382
- if (wouldCommit) {
383
- try {
384
- // Probe where the commit will actually run: identity can come from the
385
- // vault's own repo config, and asking from anywhere else (say, a source
386
- // checkout that has one) answers a different question.
387
- const where = vaultRaw && existsSync(resolve(expandHome(vaultRaw))) ? resolve(expandHome(vaultRaw)) : tmpdir();
388
- execFileSync("git", ["var", "GIT_AUTHOR_IDENT"], { cwd: where, stdio: "ignore", shell: false });
389
- mark(true, "git identity", "configured");
390
- } catch {
391
- mark(
392
- false,
393
- "git identity",
394
- 'git has no author configured, so commits will fail — `git config --global user.name "..."` ' +
395
- "and user.email, or run with --no-git-commit"
396
- );
397
- }
398
- }
399
-
400
- const sig = resolveSignalPath();
401
- rows.push(`${sig ? " ok " : " note "} ${"bridge".padEnd(14)} ${sig ?? "no plugin storage dir; interval polling only"}`);
402
-
403
- console.log(`${PLUGIN_TITLE} — logseq-sync doctor\n`);
404
- console.log(rows.join("\n"));
405
- console.log(
406
- blocked
407
- ? "\nNot ready: fix the MISS lines above."
408
- : "\nReady. Run `logseq-sync` with no arguments to start syncing."
409
- );
410
- process.exit(blocked ? 1 : 0);
411
- }
412
-
413
- if (subcommand === "doctor") doctor();
414
-
415
- // --- resolved, from here on -----------------------------------------------
416
- graphName = resolveGraphOrExit();
417
-
418
- // Validate graph name to prevent command/path injection
419
- // The first character must not be a dash or a dot: the name travels as argv
420
- // into the exporting CLI, where a leading dash reads as a flag ("-graph"
421
- // arrived here as a real user typo for --graph), and "." / ".." read as paths.
422
- if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(graphName)) {
423
- console.error(`Error: Invalid graph name "${graphName}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
424
- process.exit(2);
425
- }
426
-
427
- // `logseq graph export --graph <name>` does not fail on an unknown name it
428
- // CREATES that graph and exports the empty result. A typo would then sync
429
- // emptiness over the vault and commit it. Refuse names we cannot see,
430
- // INCLUDING when we see none at all: a bare run calls zero graphs a hard
431
- // error, and naming one does not make graphs exist — treating the same state
432
- // as "cannot verify, proceed" is how `--graph demo` once sailed past this
433
- // check straight into the vault error, and reads as a contradiction. The
434
- // escape hatches are real, not hypothetical: LOGSEQ_ROOT_DIR when the graphs
435
- // live elsewhere, and the API-server route, which exports whatever graph the
436
- // app has open and ignores local directories entirely.
437
- if (!apiServerToken && !knownGraphs.includes(graphName)) {
438
- console.error(
439
- knownGraphs.length > 0
440
- ? `Error: no graph named "${graphName}" found ${knownGraphs.join(", ")}. ` +
441
- `(The app CLI would silently create "${graphName}" rather than fail.) ` +
442
- `Run \`logseq-sync doctor\` for the full picture.`
443
- : `Error: no graph named "${graphName}" — no graphs found under ` +
444
- `${join(logseqRootDir(probe), "graphs")} at all, and the app CLI would silently ` +
445
- `create "${graphName}" and sync emptiness. If your graphs live elsewhere, set ` +
446
- `LOGSEQ_ROOT_DIR. Run \`logseq-sync doctor\` for the full picture.`
447
- );
448
- process.exit(2);
449
- }
450
-
451
- const targetDir = resolveVaultOrExit();
452
- const cliCwd = process.env.LOGSEQ_CLI_DIR ?? process.cwd();
453
- const signalPath = resolveSignalPath();
454
- const watchMode = !flags.once;
455
- const gitCommit = subcommand === "restore" ? false : resolveGitCommit();
456
-
457
- if (flags.twoWay && !appCli) {
458
- console.error(
459
- "Error: --two-way needs the Logseq desktop app's CLI (it performs the imports). " +
460
- "Install Logseq, or pass --app-cli <path>. Run `logseq-sync doctor` for the full picture."
461
- );
462
- process.exit(2);
463
- }
464
-
465
- function isGitRepo(dir) {
466
- try {
467
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
468
- cwd: dir, stdio: "ignore", shell: false,
469
- });
470
- return true;
471
- } catch {
472
- return false;
473
- }
474
- }
475
-
476
- /**
477
- * Whether this run commits. A vault can be somebody's Dropbox or iCloud folder;
478
- * turning it into a git repository is not a decision to make on their behalf.
479
- * So: commit into a repository that already exists, create one only when asked.
480
- */
481
- function resolveGitCommit() {
482
- if (flags.gitCommit === false) return false;
483
- mkdirSync(targetDir, { recursive: true });
484
- if (isGitRepo(targetDir)) return true;
485
- if (flags.gitCommit === true) {
486
- try {
487
- execFileSync("git", ["init", "-q"], { cwd: targetDir, stdio: "ignore", shell: false });
488
- console.log(`Initialised a git repository in ${targetDir}`);
489
- return true;
490
- } catch (err) {
491
- console.error(`Could not initialise a git repository in ${targetDir}: ${redact(err.message)}`);
492
- return false;
493
- }
494
- }
495
- return false;
496
- }
497
-
498
- // The status file lands beside the signal file — the plugin's storage
499
- // directory — the one place logseq.FileStorage.getItem can read it back from.
500
- function writeStatus(status) {
501
- if (!signalPath) return;
502
- try {
503
- mkdirSync(dirname(signalPath), { recursive: true });
504
- atomicWriteFileSync(join(dirname(signalPath), STATUS_FILE), JSON.stringify(status, null, 1) + "\n");
505
- } catch (err) {
506
- console.error(`Could not write status file: ${redact(err.message)}`);
507
- }
508
- }
509
-
510
- // Find @logseq/cli entry point or run via npx without shell: true
511
- function runLogseqCli(...cmdArgs) {
512
- const directCliPath = resolve(cliCwd, "node_modules", "@logseq", "cli", "cli.mjs");
513
- if (existsSync(directCliPath)) {
514
- return execFileSync(process.execPath, [directCliPath, ...cmdArgs], {
515
- cwd: cliCwd,
516
- encoding: "utf8",
517
- shell: false,
518
- maxBuffer: 1 << 28,
519
- });
520
- }
521
-
522
- // On Windows the npx fallback is an instruction, not a spawn: Node refuses
523
- // to run a .cmd without a shell (CVE-2024-27980), and routing a user-typed
524
- // graph name through cmd.exe to get around that is how injection happens.
525
- // Every prior attempt died as `spawnSync npx.cmd EINVAL`, once every poll.
526
- if (process.platform === "win32") {
527
- throw new Error(
528
- "@logseq/cli is not installed where I can see it. Install it once\n" +
529
- " (mkdir logseq-cli && cd logseq-cli && npm init -y && npm i @logseq/cli)\n" +
530
- "and point LOGSEQ_CLI_DIR at that directory or pass --app-cli <path>\n" +
531
- "to the desktop app's CLI."
532
- );
533
- }
534
- return execFileSync("npx", ["-y", "@logseq/cli", ...cmdArgs], {
535
- cwd: cliCwd,
536
- encoding: "utf8",
537
- shell: false,
538
- maxBuffer: 1 << 28,
539
- });
540
- }
541
-
542
- // 2.0 renamed the human-readable graph export: :graph now means the datoms
543
- // dump, and :graph-human is the {:pages-and-blocks ...} shape this converter
544
- // reads. Verified against the 2.0.1 app bundle.
545
- function appCliRun(...cmdArgs) {
546
- return execFileSync(
547
- appCli.command,
548
- [...appCli.argsPrefix, ...cmdArgs],
549
- { encoding: "utf8", shell: false, maxBuffer: 1 << 28, env: { ...process.env, ...appCli.env } }
550
- );
551
- }
552
-
553
- function runAppCli(outFile) {
554
- return appCliRun(
555
- "graph", "export", "--graph", graphName, "--type", "edn", "--file", outFile,
556
- "-e", "{:export-type :graph-human}"
557
- );
558
- }
559
-
560
- /** How many pages a directory holds the only sanity check worth running before an import. */
561
- function countVaultPages(dir) {
562
- let n = 0;
563
- for (const sub of ["pages", "journals"]) {
564
- try {
565
- n += readdirSync(join(dir, sub)).filter((f) => f.endsWith(".geml")).length;
566
- } catch {}
567
- }
568
- return n;
569
- }
570
-
571
- /**
572
- * Vault ➔ graph. The one direction that writes into somebody's notes, so it
573
- * rehearses by default and takes the app's own backup before it commits to
574
- * anything.
575
- */
576
- async function restore() {
577
- const pages = countVaultPages(targetDir);
578
- if (pages === 0) {
579
- console.error(
580
- `Error: no pages found in ${targetDir} expected .geml files under pages/ or journals/. Not a vault this can restore from.`
581
- );
582
- process.exit(2);
583
- }
584
- if (!appCli) {
585
- console.error(
586
- "Error: restore needs the Logseq desktop app's CLI (it performs the import). Install Logseq, or pass --app-cli <path>."
587
- );
588
- process.exit(2);
589
- }
590
-
591
- console.log(`Restore: ${targetDir} (${pages} pages) graph "${graphName}"`);
592
-
593
- if (!flags.yes) {
594
- console.log(
595
- `\nThis is a rehearsal — nothing has been written.\n` +
596
- `Re-run with --yes to import, which will:\n` +
597
- (flags.backup ? ` 1. take a Logseq backup of "${graphName}"\n 2. ` : " 1. ") +
598
- `import ${pages} pages into "${graphName}", merging by block uuid.`
599
- );
600
- return;
601
- }
602
-
603
- if (flags.backup) {
604
- try {
605
- appCliRun("graph", "backup", "create", "--graph", graphName);
606
- console.log(` Backed up "${graphName}" first.`);
607
- } catch (err) {
608
- console.error(`Error: backup failed, so the import was NOT attempted: ${redact(err.message)}`);
609
- process.exit(1);
610
- }
611
- }
612
-
613
- const tmpEdn = join(tmpdir(), `geml-restore-${process.pid}-${randomUUID()}.edn`);
614
- try {
615
- atomicWriteFileSync(tmpEdn, syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }));
616
- appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
617
- console.log(` Imported ${pages} pages into "${graphName}".`);
618
- } catch (err) {
619
- console.error(`Restore failed: ${redact(err.message)}`);
620
- process.exit(1);
621
- } finally {
622
- if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
623
- }
624
- }
625
-
626
- let lastEdnHash = null;
627
-
628
- // ⑤'s bookkeeping: a graph backup before the session's first import, then
629
- // every BACKUP_EVERY imports after — enough that an import gone wrong always
630
- // has a recent restore point, without one backup per keystroke.
631
- let sessionBackupTaken = false;
632
- let importsSinceBackup = 0;
633
- const BACKUP_EVERY = 10;
634
-
635
- // Export the graph as EDN into tempPath — the one exporter, used once per
636
- // cycle, twice when a two-way import changed the graph mid-cycle.
637
- // With a token the CLI goes through the running app's HTTP API server and
638
- // exports whatever graph the app has OPEN — the graph name is not part of
639
- // that request, so -a REPLACES -g rather than joining it. Without a token
640
- // the CLI opens the named graph's sqlite directly, which only works while
641
- // the app does not hold the lock on it.
642
- function exportGraphEdn(tempPath) {
643
- if (appCli) {
644
- runAppCli(tempPath);
645
- } else {
646
- const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
647
- runLogseqCli("export-edn", ...exportSource, "-f", tempPath);
648
- }
649
- }
650
-
651
- // The import half of --two-way, run before the export lands on disk: whatever
652
- // a person or agent changed in the vault goes back into the graph first, so
653
- // the write that follows holds the merged state and re-baselines the
654
- // manifest. Deletions are reported, never imported (the vault's stance, now
655
- // in both directions); a file changed on BOTH sides since the last sync is a
656
- // conflict — importing it would clobber the graph's edit, exporting over it
657
- // would clobber the person's, so two-way does neither and says so until a
658
- // person merges.
659
- async function importExternalEdits(ednText) {
660
- const graphFiles = ednToGemlFiles(ednText);
661
- const edits = detectExternalEdits(targetDir, { graphFiles });
662
- if (!edits.baselineKnown) {
663
- // A v1 manifest (or none) has no content baseline the sync about to run
664
- // writes one, and the NEXT cycle can start importing.
665
- return { imported: 0, conflicts: [], missing: [] };
666
- }
667
- const importable = [...edits.modified, ...edits.added];
668
- const result = { imported: 0, conflicts: edits.conflicts, missing: edits.missing };
669
- if (edits.missing.length > 0) {
670
- console.log(
671
- ` two-way: ${edits.missing.length} vault file(s) deleted on diskdeletions are never imported; ` +
672
- `delete the page in Logseq if you mean it.`
673
- );
674
- }
675
- if (importable.length === 0) return result;
676
-
677
- if (!sessionBackupTaken || importsSinceBackup >= BACKUP_EVERY) {
678
- appCliRun("graph", "backup", "create", "--graph", graphName);
679
- sessionBackupTaken = true;
680
- importsSinceBackup = 0;
681
- }
682
-
683
- const tmpEdn = join(tmpdir(), `geml-twoway-${process.pid}-${randomUUID()}.edn`);
684
- try {
685
- atomicWriteFileSync(
686
- tmpEdn,
687
- syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }, { exclude: edits.conflicts })
688
- );
689
- appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
690
- } finally {
691
- if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
692
- }
693
- importsSinceBackup += 1;
694
- result.imported = importable.length;
695
- console.log(
696
- `[${new Date().toLocaleTimeString()}] two-way: imported ${importable.length} vault edit(s) into "${graphName}"` +
697
- (edits.conflicts.length ? `; ${edits.conflicts.length} conflict(s) held` : "") +
698
- `.`
699
- );
700
- return result;
701
- }
702
-
703
- async function performSync() {
704
- const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
705
- try {
706
- // 1. Export from Logseq DB via official CLI.
707
- exportGraphEdn(tempEdnPath);
708
-
709
- if (!existsSync(tempEdnPath)) {
710
- throw new Error(`Export failed: ${tempEdnPath} was not created.`);
711
- }
712
-
713
- const stat = statSync(tempEdnPath);
714
- if (stat.size === 0) {
715
- throw new Error(`Export produced an empty (0 byte) EDN file.`);
716
- }
717
-
718
- let ednText = readFileSync(tempEdnPath, "utf8");
719
-
720
- // 1.5 Two-way import, BEFORE the unchanged-export short-circuit below:
721
- // the graph being unchanged says nothing about the vault.
722
- let twoWay = null;
723
- if (flags.twoWay) {
724
- twoWay = await importExternalEdits(ednText);
725
- if (twoWay.imported > 0) {
726
- // The graph just absorbed the vault edits — export again, so the disk
727
- // write and the manifest baseline hold the merged state.
728
- exportGraphEdn(tempEdnPath);
729
- ednText = readFileSync(tempEdnPath, "utf8");
730
- }
731
- }
732
- const twoWayActivity =
733
- twoWay !== null && (twoWay.imported > 0 || twoWay.conflicts.length > 0 || twoWay.missing.length > 0);
734
-
735
- // 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
736
- const currentHash = createHash("sha256").update(ednText).digest("hex");
737
- if (watchMode && currentHash === lastEdnHash && !twoWayActivity) {
738
- return;
739
- }
740
-
741
- // 3. Incremental sync to disk
742
- const res = await syncEdnToDisk(ednText, targetDir, {
743
- autoCommit: gitCommit,
744
- deleteOrphans: flags.mirror,
745
- overwriteUnmanaged: flags.overwriteUnmanaged,
746
- preserve: twoWay?.conflicts ?? [],
747
- markdownDir: flags.markdown ? resolve(expandHome(flags.markdown)) : null,
748
- lib: gemlLib,
749
- commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
750
- });
751
-
752
- // Files that were on disk before this sync ever ran. Named, never counted
753
- // as written: silence here is how a person's own graph gets eaten.
754
- const heldBack = [...(res.unmanaged ?? []), ...(res.markdownUnmanaged ?? [])];
755
-
756
- lastEdnHash = currentHash;
757
- writeStatus({
758
- ok: true,
759
- at: new Date().toISOString(),
760
- graph: graphName,
761
- written: res.written.length,
762
- unchanged: res.unchanged.length,
763
- orphaned: res.orphaned.length,
764
- deleted: res.deleted.length,
765
- imported: twoWay?.imported ?? 0,
766
- conflicts: twoWay?.conflicts ?? [],
767
- held: heldBack,
768
- });
769
-
770
- const timestamp = new Date().toLocaleTimeString();
771
- const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
772
- if (heldBack.length > 0) {
773
- parts.push(`${heldBack.length} held (not ours to overwrite)`);
774
- }
775
- if (twoWay && twoWay.imported > 0) {
776
- parts.unshift(`${twoWay.imported} imported`);
777
- }
778
- if (res.orphaned && res.orphaned.length > 0) {
779
- parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
780
- }
781
- if (res.deleted && res.deleted.length > 0) {
782
- parts.push(`${res.deleted.length} deleted`);
783
- }
784
- if (twoWay && twoWay.conflicts.length > 0) {
785
- console.error(
786
- ` ⚠ conflict(s), changed in BOTH the vault and the graph since the last sync — ` +
787
- `held as you left them, not imported, not overwritten: ${twoWay.conflicts.join(", ")}`
788
- );
789
- }
790
- if (heldBack.length > 0) {
791
- console.error(
792
- ` ⚠ ${heldBack.length} file(s) were already here before this sync owned them and differ from the graph ` +
793
- `left exactly as you wrote them: ${heldBack.join(", ")}. ` +
794
- `Pass --overwrite-unmanaged to replace them with the graph's version.`
795
- );
796
- }
797
-
798
- if (res.written.length > 0 || res.deleted.length > 0 || heldBack.length > 0 || twoWayActivity) {
799
- console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
800
- if (res.gitResult && res.gitResult.committed) {
801
- console.log(` Git: ${res.gitResult.output}`);
802
- } else if (res.gitResult && res.gitResult.changes) {
803
- // The files are on disk, but the commit this run promised did not
804
- // happen. Saying only "Synced" here would be a lie of omission.
805
- console.error(` Git: NOT COMMITTED — ${redact(res.gitResult.output)}`);
806
- }
807
- } else if (!watchMode) {
808
- console.log(`[${timestamp}] Graph is up-to-date (${parts.join(", ")}).`);
809
- }
810
- } catch (err) {
811
- writeStatus({ ok: false, at: new Date().toISOString(), graph: graphName, error: redact(err.message) });
812
- throw err;
813
- } finally {
814
- if (existsSync(tempEdnPath)) {
815
- try { unlinkSync(tempEdnPath); } catch {}
816
- }
817
- }
818
- }
819
-
820
- async function main() {
821
- if (subcommand === "restore") return await restore();
822
-
823
- // Print the resolved plan, not the flags that produced it — most of these
824
- // were detected, and a wrong detection has to be visible at a glance.
825
- // Never echo the token itself; these logs get pasted into bug reports.
826
- console.log(`${PLUGIN_TITLE}: graph "${graphName}" ➔ ${targetDir}`);
827
- if (appCli) {
828
- console.log(` export via ${appCli.command} (${appCli.how}) — works with the graph open`);
829
- } else if (apiServerToken) {
830
- console.log(" export via @logseq/cli through the app's API server");
831
- } else {
832
- console.log(" export via @logseq/cli, opening the graph file directly — close the graph in Logseq first");
833
- }
834
- if (signalPath) console.log(` bridge ${signalPath}`);
835
- if (gitCommit) {
836
- console.log(" git auto-commit on, scoped to the vault");
837
- } else if (flags.gitCommit !== false) {
838
- console.log(` git off — ${targetDir} is not a repository (\`git init\` there, or pass --git-commit)`);
839
- }
840
- if (flags.markdown) {
841
- console.log(` markdown also writing a lossy Markdown copy to ${resolve(flags.markdown)}`);
842
- }
843
- if (flags.mirror) {
844
- console.log(" mirror pages removed from the graph WILL be deleted here");
845
- }
846
-
847
- if (!watchMode) {
848
- // One-shot mode: fail loudly with non-zero exit code if sync fails
849
- try {
850
- await performSync();
851
- } catch (err) {
852
- console.error(`[${new Date().toLocaleTimeString()}] Sync failed:`, redact(err.message));
853
- process.exit(1);
854
- }
855
- return;
856
- }
857
-
858
- // Watch mode: sequential non-overlapping syncs. The interval loop is the
859
- // heartbeat; a --signal file, when given, triggers a sync the moment the
860
- // in-app plugin reports a change, instead of waiting out the interval.
861
- console.log(`Watch mode active (polling every ${flags.interval}s). Press Ctrl+C to stop.`);
862
-
863
- let running = true;
864
- let timer = null;
865
- let isSyncing = false;
866
- let queued = false;
867
- let fsWatcher = null;
868
- let signalTimer = null;
869
-
870
- const cleanup = () => {
871
- running = false;
872
- if (timer) clearTimeout(timer);
873
- if (signalTimer) clearTimeout(signalTimer);
874
- if (fsWatcher) fsWatcher.close();
875
- console.log("\nWatch mode stopped.");
876
- process.exit(0);
877
- };
878
-
879
- process.on("SIGINT", cleanup);
880
- process.on("SIGTERM", cleanup);
881
-
882
- async function requestSync() {
883
- if (!running) return;
884
- if (isSyncing) {
885
- // A change arrived mid-sync: run once more when this one finishes,
886
- // rather than dropping it or overlapping exports.
887
- queued = true;
888
- return;
889
- }
890
- isSyncing = true;
891
- try {
892
- await performSync();
893
- } catch (err) {
894
- console.error(`[${new Date().toLocaleTimeString()}] Sync error:`, redact(err.message));
895
- } finally {
896
- isSyncing = false;
897
- }
898
- if (queued) {
899
- queued = false;
900
- await requestSync();
901
- }
902
- }
903
-
904
- function scheduleNext() {
905
- if (!running) return;
906
- timer = setTimeout(async () => {
907
- await requestSync();
908
- scheduleNext();
909
- }, flags.interval * 1000);
910
- }
911
-
912
- if (signalPath) {
913
- const signalDir = dirname(signalPath);
914
- mkdirSync(signalDir, { recursive: true });
915
- try {
916
- // Watch the directory, not the file: the plugin's storage write may
917
- // replace the file, and a watch pinned to the old inode goes silent.
918
- fsWatcher = watch(signalDir, (eventType, filename) => {
919
- // A null filename is legal on some platforms; treat it as a hit.
920
- if (filename && filename !== basename(signalPath)) return;
921
- if (signalTimer) clearTimeout(signalTimer);
922
- signalTimer = setTimeout(() => {
923
- signalTimer = null;
924
- requestSync();
925
- }, 300);
926
- });
927
- console.log(`Signal file watched: ${signalPath}`);
928
- } catch (err) {
929
- console.error(`Signal watch failed (${redact(err.message)}); interval polling only.`);
930
- }
931
- }
932
-
933
- await requestSync();
934
- scheduleNext();
935
- }
936
-
937
- main().catch((err) => {
938
- console.error("Fatal:", err);
939
- process.exit(1);
940
- });
1
+ #!/usr/bin/env node
2
+ // logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
3
+ // The full usage is the USAGE constant below, printed by `logseq-sync --help`.
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import {
7
+ readFileSync, unlinkSync, existsSync, statSync, mkdirSync, readdirSync, watch,
8
+ } from "node:fs";
9
+ import { join, resolve, dirname, basename, sep } from "node:path";
10
+ import { tmpdir, homedir } from "node:os";
11
+ import { randomUUID, createHash } from "node:crypto";
12
+ import { syncEdnToDisk, syncDiskToEdn, atomicWriteFileSync, detectExternalEdits } from "../../core/src/sync-engine.mjs";
13
+ import { ednToGemlFiles } from "../../core/src/mapping.mjs";
14
+ import { STATUS_FILE } from "../../core/src/bridge.mjs";
15
+ import { parse as parseGeml, addressedUnits, sliceUnit } from "@geml/geml";
16
+
17
+ // The engine takes the parser injected, so core keeps its single dependency.
18
+ const gemlLib = { parse: parseGeml, addressedUnits, sliceUnit };
19
+ import {
20
+ PLUGIN_ID, logseqDotDir, logseqRootDir, signalFilePath, pluginSettings,
21
+ findAppCli, appCliCandidates, detectGraph, detectGraphViaCli, parseManagedShim,
22
+ } from "../../core/src/discovery.mjs";
23
+
24
+ const PLUGIN_TITLE = "Sync Vault with GEML";
25
+
26
+ const USAGE = `logseq-sync — a Logseq DB graph ➔ a Git-friendly folder of readable GEML files.
27
+
28
+ Usage:
29
+ logseq-sync [vault-dir] [flags] vault-dir defaults to the plugin's setting
30
+ logseq-sync <graph> <vault-dir> [flags] explicit form, when you have several graphs
31
+ logseq-sync doctor report what was detected and what is missing
32
+ logseq-sync restore [vault-dir] vault ➔ graph. Rehearses; --yes performs it,
33
+ taking a graph backup first (--no-backup to skip)
34
+
35
+ Whatever can be worked out, is: the CLI that ships inside the desktop app,
36
+ which graph the app currently has open, where the plugin's signal file lives,
37
+ and where you told the plugin to put the vault. Every one of them has a flag
38
+ to override it.
39
+
40
+ Flags:
41
+ --once Sync once and exit (default: keep watching)
42
+ --two-way Also import vault edits back into the graph, checked
43
+ on every cycle. A file changed on BOTH sides is a
44
+ conflict: neither imported nor overwritten, reported
45
+ until you merge it. Deletions are never imported.
46
+ Takes a graph backup before the first import and
47
+ every 10th after. Needs the app CLI.
48
+ --git-commit Commit, creating the vault repository if there is none
49
+ (default: commit only when the vault ALREADY is a repository)
50
+ --no-git-commit Never touch git
51
+ --mirror Delete vault files for pages removed from the graph
52
+ (default: keep them, and report the divergence)
53
+ --overwrite-unmanaged Overwrite files that were already there when the sync
54
+ first ran (default: hold them and name them — a file
55
+ no manifest claims was written by someone else)
56
+ --markdown <dir> Write the OG (file-version) Markdown graph SOMEWHERE
57
+ ELSE. By default it goes to the vault root, which is
58
+ what makes the vault a folder Logseq opens: bullets,
59
+ id:: lines, ((uuid)) refs. Lossy and one-way
60
+ (properties, tags and data blocks have no OG shape);
61
+ the GEML tree under .logseq-sync-vault-with-geml/ stays
62
+ the one that round-trips, and restore never reads this.
63
+ --no-markdown Write no Markdown at all GEML tree only.
64
+ --graph <name> Graph to export (default: the one the app has open)
65
+ --app-cli <path> The desktop app's CLI (default: found on PATH, or the app bundle)
66
+ --no-app-cli Force the @logseq/cli fallback, which cannot read an open graph
67
+ --signal <file> Plugin bridge file (default: found in the plugin's storage dir)
68
+ --no-signal Ignore the bridge; poll on the interval only
69
+ --interval <seconds> Poll interval for watch mode (positive integer, default: 10)
70
+ --message <text> Custom git commit message
71
+ --api-server-token <token>
72
+ Route the @logseq/cli fallback through the app's HTTP API
73
+ server. Prefer LOGSEQ_API_SERVER_TOKEN — a token in argv is
74
+ readable by every process on the machine via \`ps\`.
75
+ --help, -h This text`;
76
+
77
+ const args = process.argv.slice(2);
78
+ const positional = [];
79
+ const flags = {
80
+ once: false,
81
+ twoWay: false,
82
+ gitCommit: "auto",
83
+ mirror: false,
84
+ overwriteUnmanaged: undefined, // undefined = fall through to the plugin setting
85
+ markdown: null,
86
+ yes: false,
87
+ backup: true,
88
+ interval: 10,
89
+ message: null,
90
+ signal: undefined, // undefined = auto, null = disabled, string = explicit
91
+ appCli: undefined, // undefined = auto, null = disabled, string = explicit
92
+ apiServerToken: null,
93
+ graph: null,
94
+ };
95
+
96
+ function needValue(i, name) {
97
+ if (i + 1 >= args.length) {
98
+ console.error(`Error: ${name} requires a value.`);
99
+ process.exit(2);
100
+ }
101
+ }
102
+
103
+ let subcommand = null;
104
+ for (let i = 0; i < args.length; i++) {
105
+ const arg = args[i];
106
+ if (arg === "--help" || arg === "-h" || (subcommand === null && positional.length === 0 && arg === "help")) {
107
+ console.log(USAGE);
108
+ process.exit(0);
109
+ } else if (arg === "--watch") {
110
+ // Watch is the default now; the flag stays so old command lines keep working.
111
+ } else if (arg === "--once") {
112
+ flags.once = true;
113
+ } else if (arg === "--git-commit") {
114
+ flags.gitCommit = true;
115
+ } else if (arg === "--no-git-commit") {
116
+ flags.gitCommit = false;
117
+ } else if (arg === "--yes") {
118
+ flags.yes = true;
119
+ } else if (arg === "--no-backup") {
120
+ flags.backup = false;
121
+ } else if (arg === "--two-way") {
122
+ flags.twoWay = true;
123
+ } else if (arg === "--mirror") {
124
+ flags.mirror = true;
125
+ } else if (arg === "--overwrite-unmanaged") {
126
+ flags.overwriteUnmanaged = true;
127
+ } else if (arg === "--markdown") {
128
+ needValue(i, "--markdown");
129
+ flags.markdown = args[++i];
130
+ } else if (arg === "--no-markdown") {
131
+ // `false`, not `null`: null is "not specified", which now MEANS the vault
132
+ // root. Off has to be sayable separately from unsaid.
133
+ flags.markdown = false;
134
+ } else if (arg === "--no-signal") {
135
+ flags.signal = null;
136
+ } else if (arg === "--no-app-cli") {
137
+ flags.appCli = null;
138
+ } else if (arg === "--interval") {
139
+ needValue(i, "--interval");
140
+ const rawVal = args[++i];
141
+ const val = Number(rawVal);
142
+ if (!Number.isInteger(val) || val <= 0) {
143
+ console.error(`Error: --interval must be a positive integer >= 1 (got "${rawVal}").`);
144
+ process.exit(2);
145
+ }
146
+ flags.interval = val;
147
+ } else if (arg === "--message") {
148
+ needValue(i, "--message");
149
+ flags.message = args[++i];
150
+ } else if (arg === "--signal") {
151
+ needValue(i, "--signal");
152
+ flags.signal = args[++i];
153
+ } else if (arg === "--app-cli") {
154
+ needValue(i, "--app-cli");
155
+ flags.appCli = args[++i];
156
+ } else if (arg === "--graph") {
157
+ needValue(i, "--graph");
158
+ flags.graph = args[++i];
159
+ } else if (arg === "--api-server-token") {
160
+ needValue(i, "--api-server-token");
161
+ flags.apiServerToken = args[++i];
162
+ } else if (arg.startsWith("-")) {
163
+ // One dash included: "-graph demo" once sailed through as a graph literally
164
+ // named "-graph" and a vault named "demo" — a typo must stop, not sync.
165
+ console.error(`Error: Unknown flag "${arg}". Run \`logseq-sync --help\` for usage.`);
166
+ process.exit(2);
167
+ } else if (subcommand === null && positional.length === 0 && (arg === "doctor" || arg === "restore")) {
168
+ subcommand = arg;
169
+ } else {
170
+ positional.push(arg);
171
+ }
172
+ }
173
+
174
+ const probe = {
175
+ platform: process.platform,
176
+ env: process.env,
177
+ home: process.env.HOME || process.env.USERPROFILE || homedir(),
178
+ exists: existsSync,
179
+ read: (p) => readFileSync(p, "utf8"),
180
+ listDir: (p) => {
181
+ try {
182
+ return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
183
+ } catch {
184
+ return [];
185
+ }
186
+ },
187
+ };
188
+
189
+ const settings = pluginSettings(probe);
190
+
191
+ // A shell expands ~ before the watcher ever sees it; a text field in Logseq's
192
+ // settings panel does not, and resolve("~/vault") would quietly create a
193
+ // directory literally named "~" beside the working directory.
194
+ function expandHome(p) {
195
+ if (!p) return p;
196
+ if (p === "~") return probe.home;
197
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(probe.home, p.slice(2));
198
+ return p;
199
+ }
200
+
201
+ // --- how to export --------------------------------------------------------
202
+ const apiServerToken = (flags.apiServerToken || process.env.LOGSEQ_API_SERVER_TOKEN || "").trim() || null;
203
+
204
+ function resolveAppCli() {
205
+ if (flags.appCli === null) return null; // --no-app-cli
206
+ const explicit = flags.appCli || (process.env.LOGSEQ_APP_CLI || "").trim() || null;
207
+ if (explicit) {
208
+ if (/\.(cmd|bat)$/i.test(explicit)) {
209
+ // If it is the launcher the app generated, read the paths out of it
210
+ // rather than refusing something that is perfectly usable.
211
+ const parsed = parseManagedShim(probe, explicit);
212
+ if (parsed) {
213
+ if (apiServerToken) {
214
+ console.error(
215
+ "Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
216
+ );
217
+ process.exit(2);
218
+ }
219
+ return parsed;
220
+ }
221
+ console.error(
222
+ `Error: --app-cli "${explicit}" is a .cmd/.bat shim; Node cannot run one without a shell. ` +
223
+ `Point --app-cli at the Logseq executable itself.`
224
+ );
225
+ process.exit(2);
226
+ }
227
+ if (apiServerToken) {
228
+ console.error(
229
+ "Error: --app-cli and --api-server-token are mutually exclusive — the app CLI reaches the running app directly, so it needs no token."
230
+ );
231
+ process.exit(2);
232
+ }
233
+ return { command: explicit, argsPrefix: [], env: {}, how: "given with --app-cli" };
234
+ }
235
+ // Auto-detection happens at the call site, where a candidate can be verified
236
+ // by actually using it. An explicit token selects the fallback transport, so
237
+ // there is nothing to detect.
238
+ return null;
239
+ }
240
+
241
+ function runVia(candidate, cmdArgs) {
242
+ return execFileSync(candidate.command, [...candidate.argsPrefix, ...cmdArgs], {
243
+ encoding: "utf8",
244
+ shell: false,
245
+ maxBuffer: 1 << 24,
246
+ env: { ...process.env, ...candidate.env },
247
+ });
248
+ }
249
+
250
+ // Finding the CLI and asking it which graph to sync are the same step: a
251
+ // candidate that answers `graph list` is, by that fact, the working one. So we
252
+ // verify by doing the work rather than by trusting a path — which is the only
253
+ // honest way to behave on an OS or Logseq version this has never run on. The
254
+ // filesystem heuristics stay as the fallback for when there is no CLI at all.
255
+ let appCli = resolveAppCli();
256
+ let detected = null;
257
+
258
+ if (appCli) {
259
+ detected = detectGraphViaCli((cmdArgs) => runVia(appCli, cmdArgs));
260
+ } else if (flags.appCli !== null && !apiServerToken) {
261
+ const candidates = appCliCandidates(probe);
262
+ for (const candidate of candidates) {
263
+ const answer = detectGraphViaCli((cmdArgs) => runVia(candidate, cmdArgs));
264
+ if (answer) {
265
+ appCli = candidate;
266
+ detected = answer;
267
+ break;
268
+ }
269
+ }
270
+ // None answered: keep the best-ranked one anyway, so the export fails with
271
+ // that CLI's own error instead of a vague "no CLI found".
272
+ if (!appCli) appCli = candidates[0] ?? null;
273
+ }
274
+
275
+ if (!detected) detected = detectGraph(probe);
276
+ const knownGraphs = detected?.graphs ?? [];
277
+ let graphName = flags.graph;
278
+ let vaultRaw = null;
279
+
280
+ if (positional.length >= 2) {
281
+ if (!graphName) graphName = positional[0];
282
+ vaultRaw = positional[1];
283
+ } else if (positional.length === 1) {
284
+ const only = positional[0];
285
+ const looksLikePath = only.includes("/") || only.includes(sep) || only.startsWith(".") || only.startsWith("~");
286
+ const isGraphName =
287
+ !looksLikePath &&
288
+ (detected?.name === only || (detected?.candidates ?? []).includes(only));
289
+ if (isGraphName) {
290
+ console.error(
291
+ `Error: "${only}" is the name of a graph, not a vault directory. ` +
292
+ `Write the destination too — logseq-sync ${only} <vault-dir> — or select it with --graph ${only}.`
293
+ );
294
+ process.exit(2);
295
+ }
296
+ vaultRaw = only;
297
+ } else {
298
+ vaultRaw = settings.vaultPath || null;
299
+ }
300
+
301
+ function resolveGraphOrExit() {
302
+ if (graphName) return graphName;
303
+ if (detected?.name) return detected.name;
304
+ if (detected?.candidates) {
305
+ console.error(
306
+ `Error: several graphs and none open in the app — ${detected.candidates.join(", ")}. ` +
307
+ `Pick one with --graph <name>. Run \`logseq-sync doctor\` for the full picture.`
308
+ );
309
+ process.exit(2);
310
+ }
311
+ console.error(
312
+ `Error: no Logseq graphs found under ${join(logseqRootDir(probe), "graphs")}. ` +
313
+ `Open a graph in Logseq first. Run \`logseq-sync doctor\` for the full picture.`
314
+ );
315
+ process.exit(2);
316
+ }
317
+
318
+ function resolveVaultOrExit() {
319
+ if (vaultRaw) return resolve(expandHome(vaultRaw));
320
+ console.error(
321
+ `Error: no vault directory. Set it in Logseq — Settings → Plugins → ${PLUGIN_TITLE} → ` +
322
+ `"Vault folder" — or pass one: logseq-sync <vault-dir>. ` +
323
+ `Run \`logseq-sync doctor\` for the full picture.`
324
+ );
325
+ process.exit(2);
326
+ }
327
+
328
+
329
+
330
+ function resolveSignalPath() {
331
+ if (flags.signal === null) return null; // --no-signal
332
+ if (typeof flags.signal === "string") return resolve(expandHome(flags.signal));
333
+ const auto = signalFilePath(probe);
334
+ // The storage directory only appears once the plugin has written something,
335
+ // so its absence proves nothing. Gate on the dotdir instead: if Logseq is
336
+ // installed at all, writing status there is right — and it is already there
337
+ // for the plugin to read the moment it loads.
338
+ return existsSync(logseqDotDir(probe)) ? auto : null;
339
+ }
340
+
341
+ function redact(text) {
342
+ const str = String(text ?? "");
343
+ return apiServerToken ? str.split(apiServerToken).join("***") : str;
344
+ }
345
+
346
+ // --- doctor ---------------------------------------------------------------
347
+ function doctor() {
348
+ const dotDir = logseqDotDir(probe);
349
+ const rows = [];
350
+ let blocked = false;
351
+
352
+ const mark = (ok, label, detail) => {
353
+ rows.push(`${ok ? " ok " : " MISS "} ${label.padEnd(14)} ${detail}`);
354
+ if (!ok) blocked = true;
355
+ };
356
+
357
+ mark(existsSync(dotDir), "Logseq dotdir", dotDir);
358
+
359
+ const storage = join(dotDir, "storages", PLUGIN_ID);
360
+ const pluginInstalled = existsSync(storage);
361
+ rows.push(
362
+ `${pluginInstalled ? " ok " : " note " } ${"plugin".padEnd(14)} ` +
363
+ (pluginInstalled
364
+ ? storage
365
+ : `not installed yet (no ${storage}) sync still works, the toolbar just will not update`)
366
+ );
367
+
368
+ if (appCli) {
369
+ mark(true, "app CLI", `${appCli.command} (${appCli.how})`);
370
+ } else if (apiServerToken) {
371
+ rows.push(` ok ${"export".padEnd(14)} @logseq/cli through the app's API server`);
372
+ } else {
373
+ mark(false, "app CLI", "no Logseq CLI found on PATH or in the app bundle — install Logseq, or pass --app-cli <path>");
374
+ }
375
+
376
+ if (graphName) mark(true, "graph", `${graphName} (given)`);
377
+ else if (detected?.name) mark(true, "graph", `${detected.name} (${detected.how})`);
378
+ else if (detected?.candidates) mark(false, "graph", `ambiguous: ${detected.candidates.join(", ")} pick one with --graph`);
379
+ else mark(false, "graph", `none found under ${join(logseqRootDir(probe), "graphs")}`);
380
+
381
+ if (vaultRaw) mark(true, "vault", resolve(expandHome(vaultRaw)));
382
+ else mark(false, "vault", `unset — Settings → Plugins → ${PLUGIN_TITLE} → "Vault folder", or pass one as an argument`);
383
+
384
+ // Only a run that will actually commit needs an author.
385
+ const wouldCommit =
386
+ flags.gitCommit === true ||
387
+ (flags.gitCommit !== false && vaultRaw && existsSync(resolve(expandHome(vaultRaw))) && isGitRepo(resolve(expandHome(vaultRaw))));
388
+ if (wouldCommit) {
389
+ try {
390
+ // Probe where the commit will actually run: identity can come from the
391
+ // vault's own repo config, and asking from anywhere else (say, a source
392
+ // checkout that has one) answers a different question.
393
+ const where = vaultRaw && existsSync(resolve(expandHome(vaultRaw))) ? resolve(expandHome(vaultRaw)) : tmpdir();
394
+ execFileSync("git", ["var", "GIT_AUTHOR_IDENT"], { cwd: where, stdio: "ignore", shell: false });
395
+ mark(true, "git identity", "configured");
396
+ } catch {
397
+ mark(
398
+ false,
399
+ "git identity",
400
+ 'git has no author configured, so commits will fail — `git config --global user.name "..."` ' +
401
+ "and user.email, or run with --no-git-commit"
402
+ );
403
+ }
404
+ }
405
+
406
+ const sig = resolveSignalPath();
407
+ rows.push(`${sig ? " ok " : " note "} ${"bridge".padEnd(14)} ${sig ?? "no plugin storage dir; interval polling only"}`);
408
+
409
+ console.log(`${PLUGIN_TITLE} — logseq-sync doctor\n`);
410
+ console.log(rows.join("\n"));
411
+ console.log(
412
+ blocked
413
+ ? "\nNot ready: fix the MISS lines above."
414
+ : "\nReady. Run `logseq-sync` with no arguments to start syncing."
415
+ );
416
+ process.exit(blocked ? 1 : 0);
417
+ }
418
+
419
+ if (subcommand === "doctor") doctor();
420
+
421
+ // --- resolved, from here on -----------------------------------------------
422
+ graphName = resolveGraphOrExit();
423
+
424
+ // Validate graph name to prevent command/path injection
425
+ // The first character must not be a dash or a dot: the name travels as argv
426
+ // into the exporting CLI, where a leading dash reads as a flag ("-graph"
427
+ // arrived here as a real user typo for --graph), and "." / ".." read as paths.
428
+ if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(graphName)) {
429
+ console.error(`Error: Invalid graph name "${graphName}". Only alphanumeric characters, hyphens, and underscores are allowed.`);
430
+ process.exit(2);
431
+ }
432
+
433
+ // `logseq graph export --graph <name>` does not fail on an unknown name — it
434
+ // CREATES that graph and exports the empty result. A typo would then sync
435
+ // emptiness over the vault and commit it. Refuse names we cannot see,
436
+ // INCLUDING when we see none at all: a bare run calls zero graphs a hard
437
+ // error, and naming one does not make graphs exist — treating the same state
438
+ // as "cannot verify, proceed" is how `--graph demo` once sailed past this
439
+ // check straight into the vault error, and reads as a contradiction. The
440
+ // escape hatches are real, not hypothetical: LOGSEQ_ROOT_DIR when the graphs
441
+ // live elsewhere, and the API-server route, which exports whatever graph the
442
+ // app has open and ignores local directories entirely.
443
+ if (!apiServerToken && !knownGraphs.includes(graphName)) {
444
+ console.error(
445
+ knownGraphs.length > 0
446
+ ? `Error: no graph named "${graphName}" found ${knownGraphs.join(", ")}. ` +
447
+ `(The app CLI would silently create "${graphName}" rather than fail.) ` +
448
+ `Run \`logseq-sync doctor\` for the full picture.`
449
+ : `Error: no graph named "${graphName}" — no graphs found under ` +
450
+ `${join(logseqRootDir(probe), "graphs")} at all, and the app CLI would silently ` +
451
+ `create "${graphName}" and sync emptiness. If your graphs live elsewhere, set ` +
452
+ `LOGSEQ_ROOT_DIR. Run \`logseq-sync doctor\` for the full picture.`
453
+ );
454
+ process.exit(2);
455
+ }
456
+
457
+ // The vault is what the person chose and what Logseq opens; the GEML tree lives
458
+ // in a dot directory beneath it. Dot-prefixed so Logseq's file-graph indexer
459
+ // walks past it, named after the plugin so a directory listing says who owns it.
460
+ // Before this, the vault WAS the GEML tree, and the first person to set it up
461
+ // asked "why is it all geml and no markdown?" — the layout taught the wrong model.
462
+ const GEML_DIR = ".logseq-sync-vault-with-geml";
463
+ const vaultDir = resolveVaultOrExit();
464
+ const targetDir = join(vaultDir, GEML_DIR);
465
+ // Absent flag = the vault root: Markdown is what "Vault folder" promises. The
466
+ // flag's meaning moved from "turn this on" to "write it somewhere else", and
467
+ // `--no-markdown` is the off switch it never had.
468
+ const markdownDir =
469
+ flags.markdown === false ? null
470
+ : flags.markdown ? resolve(expandHome(flags.markdown))
471
+ : vaultDir;
472
+ const cliCwd = process.env.LOGSEQ_CLI_DIR ?? process.cwd();
473
+ // The flag wins over the setting for this run, the same precedence a vault path
474
+ // passed as an argument already has. The setting is a sentence, not a boolean,
475
+ // because it is read by a person in a settings panel — only the overwrite
476
+ // choice is matched, so an unrecognised value keeps the safe behaviour.
477
+ const overwriteUnmanaged =
478
+ flags.overwriteUnmanaged !== undefined
479
+ ? flags.overwriteUnmanaged
480
+ : /^overwrite/i.test(String(settings.unmanagedFiles ?? ""));
481
+ const signalPath = resolveSignalPath();
482
+ const watchMode = !flags.once;
483
+ const gitCommit = subcommand === "restore" ? false : resolveGitCommit();
484
+
485
+ if (flags.twoWay && !appCli) {
486
+ console.error(
487
+ "Error: --two-way needs the Logseq desktop app's CLI (it performs the imports). " +
488
+ "Install Logseq, or pass --app-cli <path>. Run `logseq-sync doctor` for the full picture."
489
+ );
490
+ process.exit(2);
491
+ }
492
+
493
+ function isGitRepo(dir) {
494
+ try {
495
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
496
+ cwd: dir, stdio: "ignore", shell: false,
497
+ });
498
+ return true;
499
+ } catch {
500
+ return false;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Whether this run commits. A vault can be somebody's Dropbox or iCloud folder;
506
+ * turning it into a git repository is not a decision to make on their behalf.
507
+ * So: commit into a repository that already exists, create one only when asked.
508
+ */
509
+ function resolveGitCommit() {
510
+ if (flags.gitCommit === false) return false;
511
+ // The VAULT is the repository, not the GEML tree inside it: a repo rooted at
512
+ // the dot directory would version the source of truth and leave every
513
+ // Markdown page — the half a person reads and edits — untracked.
514
+ mkdirSync(vaultDir, { recursive: true });
515
+ if (isGitRepo(vaultDir)) return true;
516
+ if (flags.gitCommit === true) {
517
+ try {
518
+ execFileSync("git", ["init", "-q"], { cwd: vaultDir, stdio: "ignore", shell: false });
519
+ console.log(`Initialised a git repository in ${vaultDir}`);
520
+ return true;
521
+ } catch (err) {
522
+ console.error(`Could not initialise a git repository in ${vaultDir}: ${redact(err.message)}`);
523
+ return false;
524
+ }
525
+ }
526
+ return false;
527
+ }
528
+
529
+ // The status file lands beside the signal file the plugin's storage
530
+ // directory the one place logseq.FileStorage.getItem can read it back from.
531
+ function writeStatus(status) {
532
+ if (!signalPath) return;
533
+ try {
534
+ mkdirSync(dirname(signalPath), { recursive: true });
535
+ atomicWriteFileSync(join(dirname(signalPath), STATUS_FILE), JSON.stringify(status, null, 1) + "\n");
536
+ } catch (err) {
537
+ console.error(`Could not write status file: ${redact(err.message)}`);
538
+ }
539
+ }
540
+
541
+ // Find @logseq/cli entry point or run via npx without shell: true
542
+ function runLogseqCli(...cmdArgs) {
543
+ const directCliPath = resolve(cliCwd, "node_modules", "@logseq", "cli", "cli.mjs");
544
+ if (existsSync(directCliPath)) {
545
+ return execFileSync(process.execPath, [directCliPath, ...cmdArgs], {
546
+ cwd: cliCwd,
547
+ encoding: "utf8",
548
+ shell: false,
549
+ maxBuffer: 1 << 28,
550
+ });
551
+ }
552
+
553
+ // On Windows the npx fallback is an instruction, not a spawn: Node refuses
554
+ // to run a .cmd without a shell (CVE-2024-27980), and routing a user-typed
555
+ // graph name through cmd.exe to get around that is how injection happens.
556
+ // Every prior attempt died as `spawnSync npx.cmd EINVAL`, once every poll.
557
+ if (process.platform === "win32") {
558
+ throw new Error(
559
+ "@logseq/cli is not installed where I can see it. Install it once\n" +
560
+ " (mkdir logseq-cli && cd logseq-cli && npm init -y && npm i @logseq/cli)\n" +
561
+ "and point LOGSEQ_CLI_DIR at that directory — or pass --app-cli <path>\n" +
562
+ "to the desktop app's CLI."
563
+ );
564
+ }
565
+ return execFileSync("npx", ["-y", "@logseq/cli", ...cmdArgs], {
566
+ cwd: cliCwd,
567
+ encoding: "utf8",
568
+ shell: false,
569
+ maxBuffer: 1 << 28,
570
+ });
571
+ }
572
+
573
+ // 2.0 renamed the human-readable graph export: :graph now means the datoms
574
+ // dump, and :graph-human is the {:pages-and-blocks ...} shape this converter
575
+ // reads. Verified against the 2.0.1 app bundle.
576
+ function appCliRun(...cmdArgs) {
577
+ return execFileSync(
578
+ appCli.command,
579
+ [...appCli.argsPrefix, ...cmdArgs],
580
+ { encoding: "utf8", shell: false, maxBuffer: 1 << 28, env: { ...process.env, ...appCli.env } }
581
+ );
582
+ }
583
+
584
+ function runAppCli(outFile) {
585
+ return appCliRun(
586
+ "graph", "export", "--graph", graphName, "--type", "edn", "--file", outFile,
587
+ "-e", "{:export-type :graph-human}"
588
+ );
589
+ }
590
+
591
+ /** How many pages a directory holds — the only sanity check worth running before an import. */
592
+ function countVaultPages(dir) {
593
+ let n = 0;
594
+ for (const sub of ["pages", "journals"]) {
595
+ try {
596
+ n += readdirSync(join(dir, sub)).filter((f) => f.endsWith(".geml")).length;
597
+ } catch {}
598
+ }
599
+ return n;
600
+ }
601
+
602
+ /**
603
+ * Vault ➔ graph. The one direction that writes into somebody's notes, so it
604
+ * rehearses by default and takes the app's own backup before it commits to
605
+ * anything.
606
+ */
607
+ async function restore() {
608
+ const pages = countVaultPages(targetDir);
609
+ if (pages === 0) {
610
+ console.error(
611
+ `Error: no pages found in ${targetDir} — expected .geml files under pages/ or journals/ ` +
612
+ `inside ${GEML_DIR}/. That directory is the source of truth; the Markdown at the vault ` +
613
+ `root is a one-way copy and restore never reads it. Not a vault this can restore from.`
614
+ );
615
+ process.exit(2);
616
+ }
617
+ if (!appCli) {
618
+ console.error(
619
+ "Error: restore needs the Logseq desktop app's CLI (it performs the import). Install Logseq, or pass --app-cli <path>."
620
+ );
621
+ process.exit(2);
622
+ }
623
+
624
+ console.log(`Restore: ${targetDir} (${pages} pages) ➔ graph "${graphName}"`);
625
+
626
+ if (!flags.yes) {
627
+ console.log(
628
+ `\nThis is a rehearsal nothing has been written.\n` +
629
+ `Re-run with --yes to import, which will:\n` +
630
+ (flags.backup ? ` 1. take a Logseq backup of "${graphName}"\n 2. ` : " 1. ") +
631
+ `import ${pages} pages into "${graphName}", merging by block uuid.`
632
+ );
633
+ return;
634
+ }
635
+
636
+ if (flags.backup) {
637
+ try {
638
+ appCliRun("graph", "backup", "create", "--graph", graphName);
639
+ console.log(` Backed up "${graphName}" first.`);
640
+ } catch (err) {
641
+ console.error(`Error: backup failed, so the import was NOT attempted: ${redact(err.message)}`);
642
+ process.exit(1);
643
+ }
644
+ }
645
+
646
+ const tmpEdn = join(tmpdir(), `geml-restore-${process.pid}-${randomUUID()}.edn`);
647
+ try {
648
+ atomicWriteFileSync(tmpEdn, syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }));
649
+ appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
650
+ console.log(` Imported ${pages} pages into "${graphName}".`);
651
+ } catch (err) {
652
+ console.error(`Restore failed: ${redact(err.message)}`);
653
+ process.exit(1);
654
+ } finally {
655
+ if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
656
+ }
657
+ }
658
+
659
+ let lastEdnHash = null;
660
+
661
+ // ⑤'s bookkeeping: a graph backup before the session's first import, then
662
+ // every BACKUP_EVERY imports after — enough that an import gone wrong always
663
+ // has a recent restore point, without one backup per keystroke.
664
+ let sessionBackupTaken = false;
665
+ let importsSinceBackup = 0;
666
+ const BACKUP_EVERY = 10;
667
+
668
+ // Export the graph as EDN into tempPath — the one exporter, used once per
669
+ // cycle, twice when a two-way import changed the graph mid-cycle.
670
+ // With a token the CLI goes through the running app's HTTP API server and
671
+ // exports whatever graph the app has OPEN the graph name is not part of
672
+ // that request, so -a REPLACES -g rather than joining it. Without a token
673
+ // the CLI opens the named graph's sqlite directly, which only works while
674
+ // the app does not hold the lock on it.
675
+ function exportGraphEdn(tempPath) {
676
+ if (appCli) {
677
+ runAppCli(tempPath);
678
+ } else {
679
+ const exportSource = apiServerToken ? ["-a", apiServerToken] : ["-g", graphName];
680
+ runLogseqCli("export-edn", ...exportSource, "-f", tempPath);
681
+ }
682
+ }
683
+
684
+ // The import half of --two-way, run before the export lands on disk: whatever
685
+ // a person or agent changed in the vault goes back into the graph first, so
686
+ // the write that follows holds the merged state and re-baselines the
687
+ // manifest. Deletions are reported, never imported (the vault's stance, now
688
+ // in both directions); a file changed on BOTH sides since the last sync is a
689
+ // conflict — importing it would clobber the graph's edit, exporting over it
690
+ // would clobber the person's, so two-way does neither and says so until a
691
+ // person merges.
692
+ async function importExternalEdits(ednText) {
693
+ const graphFiles = ednToGemlFiles(ednText);
694
+ const edits = detectExternalEdits(targetDir, { graphFiles });
695
+ if (!edits.baselineKnown) {
696
+ // A v1 manifest (or none) has no content baseline the sync about to run
697
+ // writes one, and the NEXT cycle can start importing.
698
+ return { imported: 0, conflicts: [], missing: [] };
699
+ }
700
+ const importable = [...edits.modified, ...edits.added];
701
+ const result = { imported: 0, conflicts: edits.conflicts, missing: edits.missing };
702
+ if (edits.missing.length > 0) {
703
+ console.log(
704
+ ` two-way: ${edits.missing.length} vault file(s) deleted on disk — deletions are never imported; ` +
705
+ `delete the page in Logseq if you mean it.`
706
+ );
707
+ }
708
+ if (importable.length === 0) return result;
709
+
710
+ if (!sessionBackupTaken || importsSinceBackup >= BACKUP_EVERY) {
711
+ appCliRun("graph", "backup", "create", "--graph", graphName);
712
+ sessionBackupTaken = true;
713
+ importsSinceBackup = 0;
714
+ }
715
+
716
+ const tmpEdn = join(tmpdir(), `geml-twoway-${process.pid}-${randomUUID()}.edn`);
717
+ try {
718
+ atomicWriteFileSync(
719
+ tmpEdn,
720
+ syncDiskToEdn(targetDir, { parse: parseGeml, addressedUnits, sliceUnit }, { exclude: edits.conflicts })
721
+ );
722
+ appCliRun("graph", "import", "--graph", graphName, "--type", "edn", "--input", tmpEdn);
723
+ } finally {
724
+ if (existsSync(tmpEdn)) { try { unlinkSync(tmpEdn); } catch {} }
725
+ }
726
+ importsSinceBackup += 1;
727
+ result.imported = importable.length;
728
+ console.log(
729
+ `[${new Date().toLocaleTimeString()}] two-way: imported ${importable.length} vault edit(s) into "${graphName}"` +
730
+ (edits.conflicts.length ? `; ${edits.conflicts.length} conflict(s) held` : "") +
731
+ `.`
732
+ );
733
+ return result;
734
+ }
735
+
736
+ async function performSync() {
737
+ const tempEdnPath = join(tmpdir(), `logseq-export-${process.pid}-${Date.now()}-${randomUUID()}.edn`);
738
+ try {
739
+ // 1. Export from Logseq DB via official CLI.
740
+ exportGraphEdn(tempEdnPath);
741
+
742
+ if (!existsSync(tempEdnPath)) {
743
+ throw new Error(`Export failed: ${tempEdnPath} was not created.`);
744
+ }
745
+
746
+ const stat = statSync(tempEdnPath);
747
+ if (stat.size === 0) {
748
+ throw new Error(`Export produced an empty (0 byte) EDN file.`);
749
+ }
750
+
751
+ let ednText = readFileSync(tempEdnPath, "utf8");
752
+
753
+ // 1.5 Two-way import, BEFORE the unchanged-export short-circuit below:
754
+ // the graph being unchanged says nothing about the vault.
755
+ let twoWay = null;
756
+ if (flags.twoWay) {
757
+ twoWay = await importExternalEdits(ednText);
758
+ if (twoWay.imported > 0) {
759
+ // The graph just absorbed the vault edits — export again, so the disk
760
+ // write and the manifest baseline hold the merged state.
761
+ exportGraphEdn(tempEdnPath);
762
+ ednText = readFileSync(tempEdnPath, "utf8");
763
+ }
764
+ }
765
+ const twoWayActivity =
766
+ twoWay !== null && (twoWay.imported > 0 || twoWay.conflicts.length > 0 || twoWay.missing.length > 0);
767
+
768
+ // 2. Efficiency: In watch mode, skip disk scanning if export content is bit-for-bit identical
769
+ const currentHash = createHash("sha256").update(ednText).digest("hex");
770
+ if (watchMode && currentHash === lastEdnHash && !twoWayActivity) {
771
+ return;
772
+ }
773
+
774
+ // 3. Incremental sync to disk
775
+ const res = await syncEdnToDisk(ednText, targetDir, {
776
+ autoCommit: gitCommit,
777
+ deleteOrphans: flags.mirror,
778
+ overwriteUnmanaged,
779
+ preserve: twoWay?.conflicts ?? [],
780
+ markdownDir,
781
+ // The repo is the vault, so a commit carries both trees (see sync-engine).
782
+ gitDir: vaultDir,
783
+ lib: gemlLib,
784
+ commitMessage: flags.message || `logseq-geml: sync graph "${graphName}" (${new Date().toISOString()})`,
785
+ });
786
+
787
+ // Files that were on disk before this sync ever ran. Named, never counted
788
+ // as written: silence here is how a person's own graph gets eaten.
789
+ const heldBack = [...(res.unmanaged ?? []), ...(res.markdownUnmanaged ?? [])];
790
+ // The other half of the same choice. Overwriting is allowed; doing it
791
+ // quietly is not — the list of files somebody's edit just left is the input
792
+ // their next step needs, and it exists only if it is printed.
793
+ const takenOver = [...(res.overwritten ?? []), ...(res.markdownOverwritten ?? [])];
794
+
795
+ lastEdnHash = currentHash;
796
+ writeStatus({
797
+ ok: true,
798
+ at: new Date().toISOString(),
799
+ graph: graphName,
800
+ written: res.written.length,
801
+ unchanged: res.unchanged.length,
802
+ orphaned: res.orphaned.length,
803
+ deleted: res.deleted.length,
804
+ imported: twoWay?.imported ?? 0,
805
+ conflicts: twoWay?.conflicts ?? [],
806
+ held: heldBack,
807
+ overwritten: takenOver,
808
+ });
809
+
810
+ const timestamp = new Date().toLocaleTimeString();
811
+ const parts = [`${res.written.length} written`, `${res.unchanged.length} unchanged`];
812
+ if (heldBack.length > 0) {
813
+ parts.push(`${heldBack.length} held (not ours to overwrite)`);
814
+ }
815
+ if (takenOver.length > 0) {
816
+ parts.push(`${takenOver.length} overwritten`);
817
+ }
818
+ if (twoWay && twoWay.imported > 0) {
819
+ parts.unshift(`${twoWay.imported} imported`);
820
+ }
821
+ if (res.orphaned && res.orphaned.length > 0) {
822
+ parts.push(`${res.orphaned.length} orphaned/absent from export (preserved safely)`);
823
+ }
824
+ if (res.deleted && res.deleted.length > 0) {
825
+ parts.push(`${res.deleted.length} deleted`);
826
+ }
827
+ if (twoWay && twoWay.conflicts.length > 0) {
828
+ console.error(
829
+ ` conflict(s), changed in BOTH the vault and the graph since the last sync — ` +
830
+ `held as you left them, not imported, not overwritten: ${twoWay.conflicts.join(", ")}`
831
+ );
832
+ }
833
+ if (takenOver.length > 0) {
834
+ console.error(
835
+ ` ${takenOver.length} file(s) you had edited were REPLACED with the graph's version ` +
836
+ `(--overwrite-unmanaged, or the settings panel): ${takenOver.join(", ")}`
837
+ );
838
+ }
839
+ if (heldBack.length > 0) {
840
+ console.error(
841
+ ` ${heldBack.length} file(s) were already here before this sync owned them and differ from the graph — ` +
842
+ `left exactly as you wrote them: ${heldBack.join(", ")}. ` +
843
+ `Pass --overwrite-unmanaged to replace them with the graph's version.`
844
+ );
845
+ }
846
+
847
+ if (res.written.length > 0 || res.deleted.length > 0 || heldBack.length > 0 || twoWayActivity) {
848
+ console.log(`[${timestamp}] Synced: ${parts.join(", ")}.`);
849
+ if (res.gitResult && res.gitResult.committed) {
850
+ console.log(` Git: ${res.gitResult.output}`);
851
+ } else if (res.gitResult && res.gitResult.changes) {
852
+ // The files are on disk, but the commit this run promised did not
853
+ // happen. Saying only "Synced" here would be a lie of omission.
854
+ console.error(` Git: NOT COMMITTED — ${redact(res.gitResult.output)}`);
855
+ }
856
+ } else if (!watchMode) {
857
+ console.log(`[${timestamp}] Graph is up-to-date (${parts.join(", ")}).`);
858
+ }
859
+ } catch (err) {
860
+ writeStatus({ ok: false, at: new Date().toISOString(), graph: graphName, error: redact(err.message) });
861
+ throw err;
862
+ } finally {
863
+ if (existsSync(tempEdnPath)) {
864
+ try { unlinkSync(tempEdnPath); } catch {}
865
+ }
866
+ }
867
+ }
868
+
869
+ async function main() {
870
+ if (subcommand === "restore") return await restore();
871
+
872
+ // Print the resolved plan, not the flags that produced it — most of these
873
+ // were detected, and a wrong detection has to be visible at a glance.
874
+ // Never echo the token itself; these logs get pasted into bug reports.
875
+ console.log(`${PLUGIN_TITLE}: graph "${graphName}" ${vaultDir}`);
876
+ console.log(` geml ${GEML_DIR}/ — the source of truth; restore and --two-way read only this`);
877
+ console.log(
878
+ markdownDir === null
879
+ ? " markdown off (--no-markdown)"
880
+ : markdownDir === vaultDir
881
+ ? " markdown the vault root — open it in Logseq (file version); lossy and one-way"
882
+ : ` markdown ${markdownDir} lossy and one-way`
883
+ );
884
+ if (appCli) {
885
+ console.log(` export via ${appCli.command} (${appCli.how}) works with the graph open`);
886
+ } else if (apiServerToken) {
887
+ console.log(" export via @logseq/cli through the app's API server");
888
+ } else {
889
+ console.log(" export via @logseq/cli, opening the graph file directly — close the graph in Logseq first");
890
+ }
891
+ if (signalPath) console.log(` bridge ${signalPath}`);
892
+ if (gitCommit) {
893
+ console.log(" git auto-commit on, scoped to the vault");
894
+ } else if (flags.gitCommit !== false) {
895
+ console.log(` git off — ${vaultDir} is not a repository (\`git init\` there, or pass --git-commit)`);
896
+ }
897
+ if (flags.mirror) {
898
+ console.log(" mirror pages removed from the graph WILL be deleted here");
899
+ }
900
+
901
+ if (!watchMode) {
902
+ // One-shot mode: fail loudly with non-zero exit code if sync fails
903
+ try {
904
+ await performSync();
905
+ } catch (err) {
906
+ console.error(`[${new Date().toLocaleTimeString()}] Sync failed:`, redact(err.message));
907
+ process.exit(1);
908
+ }
909
+ return;
910
+ }
911
+
912
+ // Watch mode: sequential non-overlapping syncs. The interval loop is the
913
+ // heartbeat; a --signal file, when given, triggers a sync the moment the
914
+ // in-app plugin reports a change, instead of waiting out the interval.
915
+ console.log(`Watch mode active (polling every ${flags.interval}s). Press Ctrl+C to stop.`);
916
+
917
+ let running = true;
918
+ let timer = null;
919
+ let isSyncing = false;
920
+ let queued = false;
921
+ let fsWatcher = null;
922
+ let signalTimer = null;
923
+
924
+ const cleanup = () => {
925
+ running = false;
926
+ if (timer) clearTimeout(timer);
927
+ if (signalTimer) clearTimeout(signalTimer);
928
+ if (fsWatcher) fsWatcher.close();
929
+ console.log("\nWatch mode stopped.");
930
+ process.exit(0);
931
+ };
932
+
933
+ process.on("SIGINT", cleanup);
934
+ process.on("SIGTERM", cleanup);
935
+
936
+ async function requestSync() {
937
+ if (!running) return;
938
+ if (isSyncing) {
939
+ // A change arrived mid-sync: run once more when this one finishes,
940
+ // rather than dropping it or overlapping exports.
941
+ queued = true;
942
+ return;
943
+ }
944
+ isSyncing = true;
945
+ try {
946
+ await performSync();
947
+ } catch (err) {
948
+ console.error(`[${new Date().toLocaleTimeString()}] Sync error:`, redact(err.message));
949
+ } finally {
950
+ isSyncing = false;
951
+ }
952
+ if (queued) {
953
+ queued = false;
954
+ await requestSync();
955
+ }
956
+ }
957
+
958
+ function scheduleNext() {
959
+ if (!running) return;
960
+ timer = setTimeout(async () => {
961
+ await requestSync();
962
+ scheduleNext();
963
+ }, flags.interval * 1000);
964
+ }
965
+
966
+ if (signalPath) {
967
+ const signalDir = dirname(signalPath);
968
+ mkdirSync(signalDir, { recursive: true });
969
+ try {
970
+ // Watch the directory, not the file: the plugin's storage write may
971
+ // replace the file, and a watch pinned to the old inode goes silent.
972
+ fsWatcher = watch(signalDir, (eventType, filename) => {
973
+ // A null filename is legal on some platforms; treat it as a hit.
974
+ if (filename && filename !== basename(signalPath)) return;
975
+ if (signalTimer) clearTimeout(signalTimer);
976
+ signalTimer = setTimeout(() => {
977
+ signalTimer = null;
978
+ requestSync();
979
+ }, 300);
980
+ });
981
+ console.log(`Signal file watched: ${signalPath}`);
982
+ } catch (err) {
983
+ console.error(`Signal watch failed (${redact(err.message)}); interval polling only.`);
984
+ }
985
+ }
986
+
987
+ await requestSync();
988
+ scheduleNext();
989
+ }
990
+
991
+ main().catch((err) => {
992
+ console.error("Fatal:", err);
993
+ process.exit(1);
994
+ });