@pixlcore/xycli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
package/lib/sync.js ADDED
@@ -0,0 +1,963 @@
1
+ // Sync Layer
2
+
3
+ const fs = require('fs');
4
+ const Path = require('path');
5
+ const os = require('os');
6
+ const cp = require('child_process');
7
+ const Diff = require('diff');
8
+ const cli = require('pixl-cli');
9
+ const Tools = cli.Tools;
10
+
11
+ module.exports = {
12
+
13
+ async cmd_sync() {
14
+ // Serialize every sync operation for this xyOps instance on this host.
15
+ // Keep the lock through completion commands and notifications, and always
16
+ // release it after the command settles. The process exit handler covers
17
+ // fatal errors and signals that call process.exit() before finally runs.
18
+ this.acquireSyncLock();
19
+ try {
20
+ return await this.runSyncCommand();
21
+ }
22
+ finally {
23
+ this.releaseSyncLock();
24
+ }
25
+ },
26
+
27
+ async runSyncCommand() {
28
+ // sync a filesystem of XYPDF files to xyOps resources
29
+ // xy sync --up all --dry
30
+ // xy sync --up "events,plugins" --delete plugins
31
+ await this.getMultiple();
32
+
33
+ // optionally setup local filesystem for sync
34
+ if (this.args.other && (this.args.other[0] === 'setup')) {
35
+ this.args.other.shift();
36
+ this.args.setup = this.args.other;
37
+ }
38
+ if (this.args.setup) return await this.setupSync();
39
+
40
+ println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
41
+
42
+ var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
43
+
44
+ // allow sync config in config files and/or cli args
45
+ var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
46
+ if (!sconfig.up && !sconfig.down) {
47
+ this.die("Please enable sync up and/or down modes. Type 'xy help sync' for details.");
48
+ }
49
+
50
+ // massage up, down, and delete sync lists
51
+ if ((sconfig.up === true) || (sconfig.up === "all")) sconfig.up = [ ...supported_lists ];
52
+ if (sconfig.up && (typeof(sconfig.up) == 'string')) sconfig.up = sconfig.up.split(/\,/);
53
+ if (sconfig.up && !Array.isArray(sconfig.up)) this.die("Invalid sync.up property (must be CSV string, array or true)");
54
+ if (sconfig.up && !Tools.includesAll(supported_lists, sconfig.up)) this.die("Invalid sync.up list: " + sconfig.up.join(', '));
55
+ if (sconfig.up && !sconfig.up.length) sconfig.up = false;
56
+
57
+ if ((sconfig.down === true) || (sconfig.down === "all")) sconfig.down = [ ...supported_lists ];
58
+ if (sconfig.down && (typeof(sconfig.down) == 'string')) sconfig.down = sconfig.down.split(/\,/);
59
+ if (sconfig.down && !Array.isArray(sconfig.down)) this.die("Invalid sync.down property (must be CSV string, array or true)");
60
+ if (sconfig.down && !Tools.includesAll(supported_lists, sconfig.down)) this.die("Invalid sync.down list: " + sconfig.down.join(', '));
61
+ if (sconfig.down && !sconfig.down.length) sconfig.down = false;
62
+
63
+ if ((sconfig.delete === true) || (sconfig.delete === "all")) sconfig.delete = [ ...supported_lists ];
64
+ if (sconfig.delete && (typeof(sconfig.delete) == 'string')) sconfig.delete = sconfig.delete.split(/\,/);
65
+ if (sconfig.delete && !Array.isArray(sconfig.delete)) this.die("Invalid sync.delete property (must be CSV string, array or true)");
66
+ if (sconfig.delete && !Tools.includesAll(supported_lists, sconfig.delete)) this.die("Invalid sync.delete list: " + sconfig.delete.join(', '));
67
+ if (sconfig.delete && !sconfig.delete.length) sconfig.delete = false;
68
+
69
+ // Deletion requires up-only sync, including directions inherited from config.
70
+ // Validate before scanning or applying any changes, even for a dry run.
71
+ if (sconfig.delete && (!sconfig.up || sconfig.down)) {
72
+ this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
73
+ }
74
+
75
+ this.warnings = [];
76
+ this.errors = [];
77
+
78
+ // scan all base directories, default to cwd
79
+ var files = [];
80
+ if (!this.args.other || !this.args.other.length) {
81
+ this.args.other = sconfig.base_dirs || ['.'];
82
+ }
83
+
84
+ println( "\n " + green.bold("Scanning base directories...") );
85
+
86
+ this.args.other.forEach( dir => {
87
+ dir = Path.normalize(dir).replace(/[\\/]+$/, '');
88
+ if (!fs.existsSync(dir)) {
89
+ this.logSyncError("Sync base dir does not exist: " + dir);
90
+ return;
91
+ }
92
+ println( " " + cli.emoji('🔎') + " Scanning directory: " + dir + "/" );
93
+ files = files.concat( Tools.findFilesSync(dir, { stats: true }) );
94
+ } );
95
+
96
+ // exit now if any dir errors occurred
97
+ if (this.errors.length) return await this.completeSync();
98
+
99
+ println( " " + Tools.commify(files.length) + " files found." );
100
+
101
+ // load all xypdf files
102
+ var items = [];
103
+
104
+ files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
105
+ // file: { path, size, mtime }
106
+ var dir = Path.dirname(file.path);
107
+ var filename = Path.basename(file.path);
108
+ var stem = filename.replace(/\.\w+$/, '');
109
+
110
+ // try to parse JSON
111
+ var xypdf = null;
112
+ try {
113
+ xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
114
+ }
115
+ catch (err) {
116
+ this.logSyncWarning("Failed to parse JSON file: " + file.path + ": " + err);
117
+ return;
118
+ }
119
+
120
+ // make sure we have a real xypdf file here
121
+ // skip silently if file is clearly not xypdf
122
+ if (!xypdf || !Tools.isaHash(xypdf) || !xypdf.type || (xypdf.type !== 'xypdf')) return;
123
+
124
+ if (!xypdf.version || (xypdf.version !== '1.0')) {
125
+ this.logSyncWarning("File is not a compatible version of XYPDF: " + file.path);
126
+ return;
127
+ }
128
+ if (!xypdf.items || !Array.isArray(xypdf.items) || !xypdf.items.length) {
129
+ this.logSyncWarning("XYPDF file has an empty, malformed or missing items array: " + file.path);
130
+ return;
131
+ }
132
+
133
+ // xyops minimum version
134
+ if (xypdf.xyops !== undefined && this.compareVersions(xypdf.xyops, this.xyopsVersion) > 0) {
135
+ this.logSyncWarning("Item requires xyOps v" + xypdf.xyops + " or higher: " + file.path);
136
+ return;
137
+ }
138
+
139
+ // file must have exactly one item
140
+ if (xypdf.items.length > 1) {
141
+ this.logSyncWarning("File with multiple items cannot be a sync source: " + file.path);
142
+ return;
143
+ }
144
+
145
+ var item = xypdf.items.shift();
146
+
147
+ // basic validation
148
+ if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
149
+ this.logSyncWarning("Malformed item type in file: " + file.path);
150
+ return;
151
+ }
152
+ if (item.type.match(/^(bucket|secret|user|role)$/)) {
153
+ this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
154
+ return;
155
+ }
156
+ if (!item.data || !Tools.isaHash(item.data)) {
157
+ this.logSyncWarning("Malformed item data in file: " + file.path);
158
+ return;
159
+ }
160
+ if (!item.data.id || (typeof(item.data.id) != 'string')) {
161
+ this.logSyncWarning("Malformed or missing item ID in file: " + file.path);
162
+ return;
163
+ }
164
+
165
+ var type_def = this.config.ui.data_types[item.type];
166
+ if (!type_def) {
167
+ this.logSyncWarning("Cannot locate internal type definition: " + item.type);
168
+ return;
169
+ }
170
+
171
+ var list = this[ type_def.list ];
172
+ if (!list) {
173
+ this.logSyncWarning("Cannot locate internal list: " + type_def.list);
174
+ return;
175
+ }
176
+
177
+ var xy_item = Tools.findObject(list, { id: item.data.id });
178
+ if (!xy_item) {
179
+ this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
180
+ return;
181
+ }
182
+
183
+ if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
184
+ this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
185
+ return;
186
+ }
187
+
188
+ // locate any external neighbor prop files
189
+ var neighbor_error = false;
190
+
191
+ files.forEach( neighbor => {
192
+ if (Path.dirname(neighbor.path) != dir) return; // must be in same dir
193
+
194
+ var nfilename = Path.basename(neighbor.path);
195
+ var nstem = nfilename.replace(/\.\w+$/, '');
196
+ if (nstem.length <= stem.length) return; // neighbor stem too short
197
+ if (!nstem.startsWith(stem)) return; // no shared stem
198
+
199
+ var suffix = nstem.substring(stem.length);
200
+ if (!suffix.match(/\-([\w\.\-]+)$/)) return; // not correct format
201
+ var prop_path = RegExp.$1;
202
+
203
+ var content = '';
204
+ try {
205
+ content = fs.readFileSync(neighbor.path, 'utf8');
206
+ }
207
+ catch (err) {
208
+ this.logSyncError("Failed to load neighbor property file: " + neighbor.path + ": " + err);
209
+ neighbor_error = true;
210
+ return;
211
+ }
212
+
213
+ // A neighboring JSON file may be another freely-named sync source whose
214
+ // stem happens to begin with this source's stem. XYPDF files always stand
215
+ // on their own and must never be interpreted as external property content.
216
+ if (neighbor.path.match(/\.json$/i)) {
217
+ try {
218
+ var neighbor_json = Tools.parseJSON(content);
219
+ if (neighbor_json && Tools.isaHash(neighbor_json) && (neighbor_json.type === 'xypdf')) return;
220
+ }
221
+ catch (err) {;} // leave non-JSON content for normal neighbor handling
222
+ }
223
+
224
+ // External files may only replace string properties that already exist in
225
+ // the definition. Tools.setPath() creates missing keys, so validate first.
226
+ if (typeof(Tools.getPath(item.data, prop_path)) != 'string') {
227
+ this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
228
+ neighbor_error = true;
229
+ return;
230
+ }
231
+ if (!Tools.setPath(item.data, prop_path, content)) {
232
+ this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
233
+ neighbor_error = true;
234
+ return;
235
+ }
236
+
237
+ // compute effective mtime of item including all included neighbors
238
+ if (neighbor.mtime > file.mtime) file.mtime = neighbor.mtime;
239
+
240
+ // include neighbor metadata for downsync
241
+ if (!item.neighbors) item.neighbors = {};
242
+ item.neighbors[ prop_path ] = neighbor.path;
243
+ } ); // foreach neighbor
244
+
245
+ // do not include item if neighbor loading or validation failed
246
+ if (neighbor_error) return;
247
+
248
+ // include effective mtime in item for two-way sync
249
+ item.mtime = file.mtime;
250
+
251
+ // include list name for convenience below
252
+ item.list = type_def.list;
253
+
254
+ // include file for downsync
255
+ item.file = file.path;
256
+
257
+ // add to sync list
258
+ items.push(item);
259
+ } ); // foreach json file
260
+
261
+ // exit before sync starts if any warnings or errors occurred
262
+ if (this.errors.length || this.warnings.length) return await this.completeSync();
263
+
264
+ println( "\n " + this.color('theme').bold("Starting sync...") );
265
+
266
+ // build state object of all sync flags (for sync up only)
267
+ // the xyops UI uses these flags to display edit warnings
268
+ var state = {};
269
+
270
+ // iterate over all items
271
+ for (const item of items) {
272
+ var list = this[ item.list ];
273
+ var xy_item = Tools.findObject( list, { id: item.data.id } );
274
+
275
+ var up_enabled = sconfig.up && sconfig.up.includes(item.list);
276
+ var down_enabled = sconfig.down && sconfig.down.includes(item.list);
277
+ if (!up_enabled && !down_enabled) continue; // no sync direction enabled for this item
278
+
279
+ // add item id to state for sync up solo mode
280
+ if (up_enabled && !down_enabled) state[ item.type + '-' + item.data.id ] = true;
281
+
282
+ var changes = this.diffSyncItems(xy_item, item.data);
283
+ if (!changes.some( change => change.added || change.removed )) continue; // identical
284
+
285
+ // render diff
286
+ var diff = "```diff\n";
287
+ changes.forEach( change => {
288
+ var prefix = change.added ? '+' : change.removed ? '-' : ' ';
289
+ var lines = change.value.replace(/\n$/, '').split(/\n/);
290
+
291
+ if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
292
+ // reduce context lines to 1 above and 1 below, unless in verbose mode
293
+ var top = lines.shift();
294
+ var bottom = lines.pop();
295
+ lines = [ top, "...", bottom ];
296
+ }
297
+
298
+ lines.forEach( line => {
299
+ diff += prefix + line.trimEnd() + "\n";
300
+ } );
301
+ } );
302
+ diff += "```\n";
303
+
304
+ // figure out which direction to go
305
+ var direction = false;
306
+ var icon = '';
307
+
308
+ if (up_enabled && down_enabled) {
309
+ // both directions enabled: use mod date to see which wins
310
+ if (xy_item.modified > item.mtime) { icon = '⬇️'; direction = 'down'; }
311
+ else { icon = '⬆️'; direction = 'up'; }
312
+ }
313
+ else if (up_enabled) {
314
+ // up only
315
+ icon = '⬆️';
316
+ direction = 'up';
317
+ }
318
+ else if (down_enabled) {
319
+ // down only
320
+ icon = '⬇️';
321
+ direction = 'down';
322
+ }
323
+
324
+ println( "\n " + cli.emoji(icon) + ' ' + green.bold("Updating " + item.type) + ": " + cyan.bold(item.data.title) + gray(" (" + item.data.id + ")") );
325
+ println( "\n" + this.markdown(diff) );
326
+
327
+ if (direction == 'up') {
328
+ // sync change up to xyops
329
+ try {
330
+ await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
331
+
332
+ // add flag to run optional command at completion
333
+ this.upSynced = true;
334
+ }
335
+ catch (err) {
336
+ this.logSyncError('' + err);
337
+ }
338
+ }
339
+ else if (direction == 'down') {
340
+ // sync change down to filesystem
341
+ try {
342
+ this.downSyncItem(xy_item, item);
343
+
344
+ // add flag to run optional command at completion
345
+ this.downSynced = true;
346
+ }
347
+ catch (err) {
348
+ this.logSyncError('' + err);
349
+ }
350
+ }
351
+ } // foreach item
352
+
353
+ // update state db
354
+ await this.syncState(state);
355
+
356
+ // now handle deletes
357
+ if (!sconfig.delete) return await this.completeSync();
358
+
359
+ // (deletes always happen upward, i.e. in xyops)
360
+ var list_data_types = [];
361
+ for (var id in this.config.ui.data_types) {
362
+ var type_def = this.config.ui.data_types[id];
363
+ list_data_types.push({ ...type_def, id: id });
364
+ }
365
+
366
+ for (const list_name of sconfig.delete) {
367
+ var list = this[list_name];
368
+ if (!list) continue; // sanity
369
+
370
+ for (const xy_item of list) {
371
+ // Stock and Marketplace objects are never deletion candidates. Check the
372
+ // remote object's markers so missing local exports cannot remove them.
373
+ if (Object.hasOwn(xy_item, 'stock') || Object.hasOwn(xy_item, 'marketplace')) continue;
374
+
375
+ var type_def = Tools.findObject( list_data_types, { list: list_name } );
376
+ var item = Tools.findObjectDeep( items, { type: type_def.id, 'data.id': xy_item.id } );
377
+ if (item) continue; // still exists
378
+
379
+ println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
380
+
381
+ try {
382
+ await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
383
+
384
+ // add flag to run optional command at completion
385
+ this.upSynced = true;
386
+ }
387
+ catch (err) {
388
+ this.logSyncError('' + err);
389
+ }
390
+ }; // foreach item
391
+ }; // foreach delete
392
+
393
+ await this.completeSync();
394
+ },
395
+
396
+ getSyncLockFile() {
397
+ // Allow a fixed path for service installations. Otherwise, serialize all
398
+ // local syncs targeting the exact same configured xyOps base URL.
399
+ var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
400
+ if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
401
+
402
+ var digest = Tools.digestHex( this.config.base_url, 'sha256', 16);
403
+ return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
404
+ },
405
+
406
+ acquireSyncLock() {
407
+ // Atomically create a PID lock. If a prior process was killed without
408
+ // cleanup, replace its stale lock and retry acquisition.
409
+ var lock_file = this.getSyncLockFile();
410
+ var token = Tools.generateUniqueID(32);
411
+ var payload = {
412
+ pid: process.pid,
413
+ started: (new Date()).toISOString(),
414
+ base_url: this.config.base_url || '',
415
+ token: token
416
+ };
417
+
418
+ for (var attempt = 0; attempt < 3; attempt++) {
419
+ var fd = null;
420
+ try {
421
+ // The file contains no credentials. Keep it readable so another local
422
+ // account can inspect the PID when a shared lock path is configured.
423
+ fd = fs.openSync(lock_file, 'wx', 0o644);
424
+ fs.writeFileSync(fd, JSON.stringify(payload, null, "\t") + "\n");
425
+ fs.closeSync(fd);
426
+ fd = null;
427
+
428
+ this.syncLock = { file: lock_file, token: token };
429
+ this.syncLockExitHandler = () => { this.releaseSyncLock(); };
430
+ process.once('exit', this.syncLockExitHandler);
431
+ return;
432
+ }
433
+ catch (err) {
434
+ if (fd !== null) {
435
+ try { fs.closeSync(fd); } catch (close_err) {;}
436
+ try { fs.unlinkSync(lock_file); } catch (unlink_err) {;}
437
+ }
438
+ if (err.code !== 'EEXIST') {
439
+ this.die("Failed to create sync lock file: " + lock_file + ": " + err.message);
440
+ }
441
+ }
442
+
443
+ var raw = '';
444
+ try {
445
+ raw = fs.readFileSync(lock_file, 'utf8').trim();
446
+ }
447
+ catch (err) {
448
+ // The owner may have finished between our open and read attempts.
449
+ if (err.code === 'ENOENT') continue;
450
+ this.die("Failed to read existing sync lock file: " + lock_file + ": " + err.message);
451
+ }
452
+
453
+ var old_lock = null;
454
+ try { old_lock = raw.match(/^\d+$/) ? { pid: parseInt(raw) } : JSON.parse(raw); }
455
+ catch (err) { this.die("Invalid sync lock file: " + lock_file + ". Verify no sync is running before removing it."); }
456
+
457
+ var old_pid = old_lock && Number(old_lock.pid);
458
+ if (!Number.isInteger(old_pid) || (old_pid < 1)) {
459
+ this.die("Invalid sync lock PID in: " + lock_file + ". Verify no sync is running before removing it.");
460
+ }
461
+ var alive = false;
462
+ try {
463
+ process.kill(old_pid, 0);
464
+ alive = true;
465
+ }
466
+ catch (err) {
467
+ // EPERM means the process exists but belongs to another account.
468
+ if (err.code === 'EPERM') alive = true;
469
+ else if (err.code !== 'ESRCH') this.die("Failed to inspect sync lock PID " + old_pid + ": " + err.message);
470
+ }
471
+
472
+ if (alive) {
473
+ this.die("Another sync process is already running (PID " + old_pid + "). Lock file: " + lock_file);
474
+ }
475
+
476
+ // Serialize stale-file recovery too. Without this second atomic claim,
477
+ // two starters could both inspect the old PID and one could unlink the
478
+ // fresh lock created by the other.
479
+ var recovery_file = lock_file + '.recover';
480
+ var recovery_fd = null;
481
+ try {
482
+ recovery_fd = fs.openSync(recovery_file, 'wx', 0o644);
483
+ fs.writeFileSync(recovery_fd, '' + process.pid + "\n");
484
+ }
485
+ catch (err) {
486
+ if (recovery_fd !== null) {
487
+ try { fs.closeSync(recovery_fd); } catch (close_err) {;}
488
+ try { fs.unlinkSync(recovery_file); } catch (unlink_err) {;}
489
+ }
490
+ if (err.code === 'EEXIST') {
491
+ this.die("Another process is recovering a stale sync lock. Please retry. Lock file: " + lock_file);
492
+ }
493
+ this.die("Failed to claim stale sync lock file: " + lock_file + ": " + err.message);
494
+ }
495
+
496
+ var recovery_error = null;
497
+ var retry = false;
498
+ try {
499
+ // Re-read after winning recovery. If another process already replaced
500
+ // the file, leave its new lock untouched and retry normally.
501
+ var current_raw = fs.readFileSync(lock_file, 'utf8').trim();
502
+ if (current_raw !== raw) retry = true;
503
+ else fs.unlinkSync(lock_file);
504
+ }
505
+ catch (err) {
506
+ if (err.code === 'ENOENT') retry = true;
507
+ else recovery_error = err;
508
+ }
509
+ finally {
510
+ if (recovery_fd !== null) try { fs.closeSync(recovery_fd); } catch (close_err) {;}
511
+ try { fs.unlinkSync(recovery_file); } catch (unlink_err) {;}
512
+ }
513
+ if (recovery_error) {
514
+ this.die("Failed to remove stale sync lock file: " + lock_file + ": " + recovery_error.message);
515
+ }
516
+ if (retry) continue;
517
+ } // attempt
518
+
519
+ this.die("Failed to acquire sync lock file after multiple attempts: " + lock_file);
520
+ },
521
+
522
+ releaseSyncLock() {
523
+ // Only the process that created the current lock may remove it. This
524
+ // prevents late cleanup from deleting a newer process's replacement lock.
525
+ if (!this.syncLock) return;
526
+ var lock = this.syncLock;
527
+ this.syncLock = null;
528
+
529
+ if (this.syncLockExitHandler) {
530
+ process.removeListener('exit', this.syncLockExitHandler);
531
+ this.syncLockExitHandler = null;
532
+ }
533
+
534
+ try {
535
+ var current = JSON.parse(fs.readFileSync(lock.file, 'utf8'));
536
+ if (current.token === lock.token) fs.unlinkSync(lock.file);
537
+ }
538
+ catch (err) {
539
+ if (err.code !== 'ENOENT') warnln("Failed to remove sync lock file: " + lock.file + ": " + err.message);
540
+ }
541
+ },
542
+
543
+ async syncState(new_state) {
544
+ // sync state only if it differs
545
+ var old_state = this.state.sync || {};
546
+
547
+ if (Tools.stableStringify(new_state) !== Tools.stableStringify(old_state)) {
548
+ try {
549
+ await this.callStandardAPI( 'update_global_state', { sync: new_state }, { text: "Updating state...", throw: true } );
550
+ }
551
+ catch (err) {
552
+ this.logSyncError('' + err);
553
+ }
554
+ }
555
+ },
556
+
557
+ async completeSync() {
558
+ // send email if we have warnings or errors
559
+ var sconfig = this.sconfig;
560
+
561
+ println( "\n " + this.color('theme').bold("Sync complete.") );
562
+
563
+ if (this.dry) return;
564
+
565
+ // optional shell commands if we synced anything
566
+ if (this.upSynced && sconfig.up_cmd) {
567
+ this.syncFinishCmd(sconfig.up_cmd);
568
+ }
569
+ if (this.downSynced && sconfig.down_cmd) {
570
+ this.syncFinishCmd(sconfig.down_cmd);
571
+ }
572
+
573
+ if (!this.warnings.length && !this.errors.length) return;
574
+
575
+ if (sconfig.error_email) {
576
+ // send email on warning / error
577
+ var md = '';
578
+ md += "The sync operation generated the following output:\n";
579
+
580
+ if (this.errors.length) {
581
+ md += "\n### Errors\n\n";
582
+ this.errors.forEach( error => { md += "- " + error + "\n"; } );
583
+ }
584
+ if (this.warnings.length) {
585
+ md += "\n### Warnings\n\n";
586
+ this.warnings.forEach( warning => { md += "- " + warning + "\n"; } );
587
+ }
588
+
589
+ md += "\n### Other Information\n\n";
590
+ md += "- **Date/Time:** " + (new Date()).toString() + "\n";
591
+ md += "- **Sync Server:** " + os.hostname() + "\n";
592
+ md += "- **Sync Platform:** " + process.platform + "\n";
593
+ md += "- **Node.js Version:** " + process.version + "\n";
594
+ md += "- **xyCLI Version:** " + this.version + "\n";
595
+ if (process.platform != 'win32') md += "- **Sync User/Group:** " + process.getuid() + "/" + process.getgid() + "\n";
596
+ // md += "- **Sync Command:** `" + process.argv.join(' ') + "`\n";
597
+
598
+ md += "\n### Sync Command\n";
599
+ md += "\n```\n" + process.argv.join(' ') + "\n```\n";
600
+
601
+ md += "\n### Sync Configuration\n";
602
+ md += "\n```\n" + JSON.stringify(sconfig, null, "\t") + "\n```\n";
603
+
604
+ var req = {
605
+ to: sconfig.error_email,
606
+ subject: 'xyOps CLI Sync Results',
607
+ title: 'Sync Results',
608
+ body: md
609
+ };
610
+
611
+ await this.callStandardAPI('sendEmail', req, { text: "Sending email..." });
612
+ } // email
613
+
614
+ if (sconfig.error_event) {
615
+ // run event on warning / error
616
+ var req = {
617
+ id: sconfig.error_event,
618
+ input: {
619
+ data: {
620
+ errors: this.errors,
621
+ warnings: this.warnings
622
+ }
623
+ }
624
+ };
625
+
626
+ await this.callStandardAPI('runEvent', req, { text: "Running event..." });
627
+ } // run_event
628
+ },
629
+
630
+ syncFinishCmd(cmd) {
631
+ // spawn command and log output
632
+ var sconfig = this.sconfig;
633
+ var cwd = this.args.other[0]; // use first base dir as cwd for shell commands
634
+
635
+ println( "\n " + green.bold("Running completion command: ") + cyan.bold(cmd) );
636
+
637
+ try {
638
+ const output = cp.execSync(cmd, {
639
+ cwd: cwd,
640
+ timeout: (sconfig.cmd_timeout || 30) * 1000,
641
+ encoding: 'utf8',
642
+ stdio: ['ignore', 'pipe', 'pipe']
643
+ });
644
+
645
+ if (output.match(/\S/)) {
646
+ println( "\n" + this.markdown("```\n" + output.trim() + "\n```\n") );
647
+ }
648
+ }
649
+ catch (err) {
650
+ this.logSyncError('Command failed: ' + cmd + ": " + err.message);
651
+ if (err.stdout) console.error('STDOUT:', err.stdout);
652
+ if (err.stderr) console.error('STDERR:', err.stderr);
653
+ }
654
+ },
655
+
656
+ downSyncItem(xy_item, item) {
657
+ // write changed item back to disk
658
+ var data = Tools.copyHash(xy_item, true);
659
+
660
+ // process and write out neighbor files
661
+ if (item.neighbors) {
662
+ for (var path in item.neighbors) {
663
+ var file = item.neighbors[path];
664
+ var value = Tools.getPath( data, path ) ?? "";
665
+
666
+ if (!Tools.setPath( data, path, "(External)" )) {
667
+ throw "External neighbor file path not found: " + file + ": " + path;
668
+ }
669
+
670
+ if (!this.dry) {
671
+ try { Tools.writeFileAtomicSync(file, value); }
672
+ catch (err) {
673
+ throw "Failed to write local file: " + file + ": " + err;
674
+ }
675
+ }
676
+ }
677
+ } // neighbors
678
+
679
+ var xypdf = this.getItemExportData(item.type, data);
680
+
681
+ if (this.dry || this.verbose) {
682
+ println( "\n " + this.color('theme').bold("WRITING FILE:") );
683
+ this.jsonOutput(xypdf);
684
+ }
685
+
686
+ if (!this.dry) {
687
+ try { Tools.writeFileAtomicSync( item.file, JSON.stringify(xypdf, null, "\t") + "\n" ); }
688
+ catch (err) {
689
+ throw "Failed to write local file: " + item.file + ": " + err;
690
+ }
691
+ }
692
+ },
693
+
694
+ diffSyncItems(a, b) {
695
+ // diff items sans metadata
696
+ var remove_keys = { created: 1, modified: 1, revision: 1, sort_order: 1, username: 1 };
697
+
698
+ return Diff.diffLines(
699
+ Tools.stablePrettyStringify( Tools.copyHashRemoveKeys(a, remove_keys) ),
700
+ Tools.stablePrettyStringify( Tools.copyHashRemoveKeys(b, remove_keys) )
701
+ );
702
+ },
703
+
704
+ logSyncWarning(msg) {
705
+ this.warnings.push(msg);
706
+
707
+ // Scan warnings prevent sync from applying changes, so report failure.
708
+ // Let notifications finish before exiting, including in quiet or dry mode.
709
+ process.exitCode = 1;
710
+
711
+ warnln( "\n " + cli.emoji('⚠️') + " " + yellow.bold("WARNING: ") + " " + msg );
712
+ },
713
+
714
+ logSyncError(msg) {
715
+ this.errors.push(msg);
716
+
717
+ // Report failure to scripts without interrupting completion or notifications.
718
+ // This also covers dry-run errors and failures in completion commands.
719
+ process.exitCode = 1;
720
+
721
+ warnln( "\n " + cli.emoji('🛑') + " " + red.bold("ERROR: ") + " " + msg );
722
+ },
723
+
724
+ async setupSync() {
725
+ // export select resources to local XYPDF files in suggested layout
726
+ // xy sync --setup all --file_props "script,params.script"
727
+ // xy sync --setup all --marketplace --stock --force
728
+ var data_types = this.config.ui.data_types;
729
+ var list_data_types = Object.keys(data_types).map( key => {
730
+ return { ...data_types[key], id: key };
731
+ } );
732
+
733
+ if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
734
+ this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
735
+ }
736
+
737
+ if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
738
+ if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
739
+
740
+ if (Tools.includesAny(this.args.setup, ['buckets', 'secrets', 'users', 'roles'])) {
741
+ this.die("Sorry, the sync engine cannot process buckets, secrets, users, or roles.");
742
+ }
743
+
744
+ if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
745
+ this.die("One or more specified item types are unknown: " + this.args.setup.join(', ') );
746
+ }
747
+
748
+ if (!this.args.setup.length) this.die("No item types specified to set up.");
749
+
750
+ if (this.args.file_props) {
751
+ if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
752
+ if (!Array.isArray(this.args.file_props)) this.dieUsage('sync setup');
753
+ }
754
+
755
+ this.printBoxList({
756
+ title: 'Sync Setup',
757
+ rows: [
758
+ ['Item Types', this.args.setup.join(', ')],
759
+ ['File Props', this.args.file_props ? this.args.file_props.join(', ') : '(None)'],
760
+ this.dry ? ['Dry Run', 'No changes will be made.'] : null
761
+ ]
762
+ });
763
+
764
+ println( "\n " + this.color('theme').bold("WRITING FILES:") );
765
+
766
+ // split up workflows into their own folder
767
+ if (this.args.setup.includes('events')) {
768
+ this.workflows = this.events.filter( event => event.type == 'workflow' );
769
+ this.events = this.events.filter( event => event.type != 'workflow' );
770
+ this.args.setup.push('workflows');
771
+ list_data_types.push( { id: "event", "list": "workflows" } );
772
+ }
773
+
774
+ this.args.setup.forEach( (list_name) => {
775
+ var list = this[list_name];
776
+ if (!list || !list.length) return;
777
+
778
+ var type_def = Tools.findObject( list_data_types, { list: list_name } );
779
+ if (!type_def) return; // sanity
780
+
781
+ if (!this.dry) Tools.mkdirp.sync( list_name );
782
+ println( "\n " + cli.emoji('📂') + " " + bold.cyan(list_name + '/') );
783
+
784
+ list.forEach( (item) => {
785
+ // exclude marketplace and stock items
786
+ if (item.marketplace && !this.args.marketplace) return;
787
+ if (item.stock && !this.args.stock) return;
788
+
789
+ // extract extras first
790
+ var extras = [];
791
+ (this.args.file_props || []).forEach( (path) => {
792
+ var extra = Tools.getPath(item, path);
793
+ if ((typeof(extra) == 'string') && extra.match(/\S/)) {
794
+ extras.push({ path, content: extra });
795
+
796
+ // we do not need to check the return value of setPath here,
797
+ // because this path is guaranteed to exist at this point
798
+ Tools.setPath( item, path, "(External)" );
799
+ }
800
+ } );
801
+
802
+ var data = this.getItemExportData(type_def.id, item);
803
+ var title = item.title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
804
+ var filename = title + '.json';
805
+ var file = Path.join(list_name, filename);
806
+
807
+ if (!this.dry) {
808
+ if (!this.args.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
809
+ Tools.writeFileAtomicSync( file, JSON.stringify(data, null, "\t") + "\n" );
810
+ }
811
+ println( " " + cli.emoji('📄') + " " + bold.green(filename) );
812
+
813
+ // now handle extras
814
+ extras.forEach( (extra) => {
815
+ var command = item.command;
816
+ if (!command && extra.content.match(/^\s*\#\!(.+?)\n/)) command = RegExp.$1;
817
+
818
+ var ext = 'txt';
819
+ if (command) ext = this.getExtFromBinary(command);
820
+ else if (extra.content.trim().match(/^\{[\S\s]*\}$/) || extra.content.trim().match(/^\[[\S\s]*\]$/)) ext = 'json';
821
+
822
+ var extra_filename = title + '-' + extra.path + '.' + ext;
823
+ var extra_file = Path.join(list_name, extra_filename);
824
+
825
+ if (!this.dry) {
826
+ if (!this.args.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
827
+ Tools.writeFileAtomicSync( extra_file, extra.content );
828
+ }
829
+
830
+ println( " " + cli.emoji('📜') + " " + bold.yellow(extra_filename) );
831
+ } );
832
+ } ); // foreach item
833
+ } ); // foreach list
834
+
835
+ println( "\n " + this.color('theme').bold("Setup complete!") );
836
+ },
837
+
838
+ getItemExportData(type, item) {
839
+ // generate XYPDF for serialization to JSON for export
840
+ var trimmed_item = { ...item };
841
+
842
+ delete trimmed_item.created;
843
+ delete trimmed_item.modified;
844
+ delete trimmed_item.revision;
845
+ delete trimmed_item.sort_order;
846
+ delete trimmed_item.username;
847
+
848
+ var data = {
849
+ type: 'xypdf',
850
+ description: "xyOps Portable Data Object",
851
+ version: "1.0",
852
+ xyops: this.xyopsVersion,
853
+ items: [{
854
+ type: type,
855
+ data: trimmed_item
856
+ }]
857
+ };
858
+
859
+ return data;
860
+ },
861
+
862
+ getExtFromBinary(bin) {
863
+ // sniff file ext from binary path, e.g. `/bin/sh`
864
+ if ((typeof(bin) != 'string') || !bin.trim()) return 'txt';
865
+
866
+ // normalize Windows paths, optional arguments and common shebang-style
867
+ // env wrappers before matching the interpreter name
868
+ var cmdline = bin.trim().replace(/\\/g, '/');
869
+ var cmd = cmdline;
870
+ if (cmd.match(/^(?:.*\/)?env(?:\.exe)?\s+(?:\-S\s+)?(\S+)/i)) cmd = RegExp.$1;
871
+ else if (cmd.match(/^"([^"]+)"/)) cmd = RegExp.$1;
872
+ else if (cmd.match(/^'([^']+)'/)) cmd = RegExp.$1;
873
+ else cmd = cmd.replace(/\s+.+$/, '');
874
+ cmd = Path.basename(cmd);
875
+
876
+ // allow Windows executables and versioned names such as python3.12
877
+ cmd = cmd.replace(/\.exe$/i, '').replace(/\d+(?:\.\d+)*$/, '').toLowerCase();
878
+ var ext = 'txt';
879
+
880
+ switch (cmd) {
881
+ case 'sh':
882
+ case 'csh':
883
+ case 'ksh':
884
+ case 'tcsh':
885
+ case 'fish':
886
+ case 'zsh':
887
+ case 'bash':
888
+ ext = 'sh';
889
+ break;
890
+
891
+ case 'node':
892
+ case 'nodejs':
893
+ case 'deno':
894
+ case 'bun':
895
+ ext = 'js';
896
+ break;
897
+
898
+ case 'ts-node':
899
+ case 'ts-node-esm':
900
+ case 'tsx':
901
+ ext = 'ts';
902
+ break;
903
+
904
+ case 'pwsh':
905
+ case 'powershell':
906
+ ext = 'ps1';
907
+ break;
908
+
909
+ case 'cmd':
910
+ ext = 'bat';
911
+ break;
912
+
913
+ case 'py':
914
+ case 'python':
915
+ case 'pythonw':
916
+ case 'pypy':
917
+ ext = 'py';
918
+ break;
919
+
920
+ case 'perl':
921
+ ext = 'pl';
922
+ break;
923
+
924
+ case 'ruby':
925
+ ext = 'rb';
926
+ break;
927
+
928
+ case 'php':
929
+ ext = 'php';
930
+ break;
931
+
932
+ case 'lua':
933
+ case 'luajit':
934
+ ext = 'lua';
935
+ break;
936
+
937
+ case 'r':
938
+ case 'rscript':
939
+ ext = 'R';
940
+ break;
941
+
942
+ case 'tclsh':
943
+ case 'wish':
944
+ ext = 'tcl';
945
+ break;
946
+
947
+ case 'julia':
948
+ ext = 'jl';
949
+ break;
950
+
951
+ case 'groovy':
952
+ ext = 'groovy';
953
+ break;
954
+
955
+ case 'elixir':
956
+ ext = 'exs';
957
+ break;
958
+ }
959
+
960
+ return ext;
961
+ }
962
+
963
+ };