@geml/logseq-sync 2.0.0 → 2.0.2

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