@geml/logseq-sync 2.0.0 → 2.0.3

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 +0,0 @@
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
- });