@geml/logseq-sync 2.0.7 → 2.0.8

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