@pixlcore/xycli 1.0.7 → 1.0.9
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.
- package/README.md +2 -2
- package/docs/help.md +22 -3
- package/docs/sync.md +71 -9
- package/docs/two-way-git-tutorial.md +19 -3
- package/index.js +32 -5
- package/lib/apikey.js +1 -1
- package/lib/buckets.js +1 -5
- package/lib/marketplace.js +1 -1
- package/lib/sync-buckets.js +315 -0
- package/lib/sync-syshook.js +207 -0
- package/lib/sync.js +128 -43
- package/lib/transfer.js +3 -3
- package/lib/utils.js +8 -3
- package/package.json +2 -2
- package/test/README.md +5 -3
- package/test/help.test.js +56 -0
- package/test/sync-buckets-unit.test.js +188 -0
- package/test/sync-buckets.test.js +133 -0
- package/test/sync-syshook-unit.test.js +231 -0
- package/test/sync-unit.test.js +118 -2
- package/test/toast.test.js +3 -3
- package/test/utils-unit.test.js +50 -0
package/lib/sync.js
CHANGED
|
@@ -28,6 +28,10 @@ module.exports = {
|
|
|
28
28
|
// sync a filesystem of XYPDF files to xyOps resources
|
|
29
29
|
// xy sync --up all --dry
|
|
30
30
|
// xy sync --up "events,plugins" --delete plugins
|
|
31
|
+
// System Hook deletion is a separate, local-only command. Dispatch it
|
|
32
|
+
// before loading remote lists or interpreting ordinary sync directions.
|
|
33
|
+
if ('syshook' in this.args) return await this.runSyncSyshook();
|
|
34
|
+
|
|
31
35
|
await this.getMultiple();
|
|
32
36
|
|
|
33
37
|
// Both ordinary sync and setup completion commands report failures through
|
|
@@ -44,10 +48,11 @@ module.exports = {
|
|
|
44
48
|
|
|
45
49
|
println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
|
|
46
50
|
|
|
47
|
-
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(
|
|
51
|
+
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
48
52
|
|
|
49
53
|
// allow sync config in config files and/or cli args
|
|
50
54
|
var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
55
|
+
var ignore_missing = !!sconfig.ignore_missing;
|
|
51
56
|
if (!sconfig.up && !sconfig.down) {
|
|
52
57
|
this.die("Please enable sync up and/or down modes. Type 'xy help sync' for details.");
|
|
53
58
|
}
|
|
@@ -77,6 +82,13 @@ module.exports = {
|
|
|
77
82
|
this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
// Bucket content needs the replacement API and data timestamps introduced
|
|
86
|
+
// in xyOps 1.1.2. Check before any ordinary sync changes are applied.
|
|
87
|
+
if ((sconfig.up && sconfig.up.includes('buckets')) || (sconfig.down && sconfig.down.includes('buckets')) ||
|
|
88
|
+
(sconfig.delete && sconfig.delete.includes('buckets'))) {
|
|
89
|
+
this.requireBucketSyncVersion();
|
|
90
|
+
}
|
|
91
|
+
|
|
80
92
|
// scan all base directories, default to cwd
|
|
81
93
|
var files = [];
|
|
82
94
|
if (!this.args.other || !this.args.other.length) {
|
|
@@ -106,11 +118,15 @@ module.exports = {
|
|
|
106
118
|
// longest-first lets each external property file belong to the most specific
|
|
107
119
|
// source basename instead of also being claimed by every shorter prefix.
|
|
108
120
|
var source_stems_by_dir = new Map();
|
|
121
|
+
var bucket_extra_dirs = [];
|
|
109
122
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
110
123
|
var xypdf = null;
|
|
111
124
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
112
125
|
catch (err) { return; } // the normal scan below reports malformed JSON
|
|
113
126
|
if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
|
|
127
|
+
if (Array.isArray(xypdf.items) && xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
128
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
129
|
+
}
|
|
114
130
|
|
|
115
131
|
var dir = Path.dirname(file.path);
|
|
116
132
|
var stems = source_stems_by_dir.get(dir);
|
|
@@ -126,8 +142,9 @@ module.exports = {
|
|
|
126
142
|
|
|
127
143
|
// load all xypdf files
|
|
128
144
|
var items = [];
|
|
145
|
+
var seen_sources = new Map();
|
|
129
146
|
|
|
130
|
-
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
147
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
131
148
|
// file: { path, size, mtime }
|
|
132
149
|
var dir = Path.dirname(file.path);
|
|
133
150
|
var filename = Path.basename(file.path);
|
|
@@ -175,7 +192,7 @@ module.exports = {
|
|
|
175
192
|
this.logSyncWarning("Malformed item type in file: " + file.path);
|
|
176
193
|
return;
|
|
177
194
|
}
|
|
178
|
-
if (item.type.match(/^(
|
|
195
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
179
196
|
this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
180
197
|
return;
|
|
181
198
|
}
|
|
@@ -200,16 +217,14 @@ module.exports = {
|
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
219
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
|
|
220
|
+
// Check duplicate identities even when their remote definition is
|
|
221
|
+
// missing, so --ignore_missing only relaxes the missing-ID check.
|
|
222
|
+
var source_key = item.type + '\0' + item.data.id;
|
|
223
|
+
if (seen_sources.has(source_key)) {
|
|
210
224
|
this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
|
|
211
225
|
return;
|
|
212
226
|
}
|
|
227
|
+
seen_sources.set(source_key, file.path);
|
|
213
228
|
|
|
214
229
|
// locate any external neighbor prop files
|
|
215
230
|
var neighbor_error = false;
|
|
@@ -276,6 +291,19 @@ module.exports = {
|
|
|
276
291
|
// do not include item if neighbor loading or validation failed
|
|
277
292
|
if (neighbor_error) return;
|
|
278
293
|
|
|
294
|
+
var xy_item = Tools.findObject(list, { id: item.data.id });
|
|
295
|
+
if (!xy_item) {
|
|
296
|
+
// An opted-in Git sync can briefly retain a source after its xyOps
|
|
297
|
+
// definition is deleted. Skip only that source and keep scanning.
|
|
298
|
+
if (ignore_missing) {
|
|
299
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
279
307
|
// include effective mtime in item for two-way sync
|
|
280
308
|
item.mtime = file.mtime;
|
|
281
309
|
|
|
@@ -313,24 +341,8 @@ module.exports = {
|
|
|
313
341
|
var changes = this.diffSyncItems(xy_item, item.data);
|
|
314
342
|
if (!changes.some( change => change.added || change.removed )) continue; // identical
|
|
315
343
|
|
|
316
|
-
//
|
|
317
|
-
var diff =
|
|
318
|
-
changes.forEach( change => {
|
|
319
|
-
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
320
|
-
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
321
|
-
|
|
322
|
-
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
323
|
-
// reduce context lines to 1 above and 1 below, unless in verbose mode
|
|
324
|
-
var top = lines.shift();
|
|
325
|
-
var bottom = lines.pop();
|
|
326
|
-
lines = [ top, "...", bottom ];
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
lines.forEach( line => {
|
|
330
|
-
diff += prefix + line.trimEnd() + "\n";
|
|
331
|
-
} );
|
|
332
|
-
} );
|
|
333
|
-
diff += "```\n";
|
|
344
|
+
// Use the same highlighted, context-trimmed diff for every sync item.
|
|
345
|
+
var diff = this.formatSyncDiff(changes);
|
|
334
346
|
|
|
335
347
|
// figure out which direction to go
|
|
336
348
|
var direction = false;
|
|
@@ -358,7 +370,7 @@ module.exports = {
|
|
|
358
370
|
if (direction == 'up') {
|
|
359
371
|
// sync change up to xyops
|
|
360
372
|
try {
|
|
361
|
-
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
|
|
373
|
+
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true, sync: true });
|
|
362
374
|
|
|
363
375
|
// add flag to run optional command at completion
|
|
364
376
|
this.upSynced = true;
|
|
@@ -383,6 +395,12 @@ module.exports = {
|
|
|
383
395
|
|
|
384
396
|
// update state db
|
|
385
397
|
await this.syncState(state);
|
|
398
|
+
|
|
399
|
+
// Bucket metadata is handled above like every other XYPDF item. Its data
|
|
400
|
+
// and binary files have their own storage records and reconciliation pass.
|
|
401
|
+
if (sconfig.up && sconfig.up.includes('buckets') || sconfig.down && sconfig.down.includes('buckets')) {
|
|
402
|
+
await this.syncBucketContents(items.filter( item => item.type == 'bucket' ), sconfig);
|
|
403
|
+
}
|
|
386
404
|
|
|
387
405
|
// Deletion is the destructive final phase, so only begin it after every
|
|
388
406
|
// update and the sync-state write completed cleanly. Earlier successful
|
|
@@ -418,7 +436,7 @@ module.exports = {
|
|
|
418
436
|
println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
|
|
419
437
|
|
|
420
438
|
try {
|
|
421
|
-
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
|
|
439
|
+
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true, sync: true });
|
|
422
440
|
|
|
423
441
|
// add flag to run optional command at completion
|
|
424
442
|
this.upSynced = true;
|
|
@@ -434,11 +452,14 @@ module.exports = {
|
|
|
434
452
|
|
|
435
453
|
getSyncLockFile() {
|
|
436
454
|
// Allow a fixed path for service installations. Otherwise, serialize all
|
|
437
|
-
// local syncs targeting the exact same configured xyOps base URL.
|
|
455
|
+
// local syncs targeting the exact same configured xyOps base URL. Local
|
|
456
|
+
// System Hook deletions need no server config, so share a fixed lock key
|
|
457
|
+
// when no base URL is available.
|
|
438
458
|
var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
439
459
|
if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
|
|
440
460
|
|
|
441
|
-
var
|
|
461
|
+
var base_url = this.config.base_url || (('syshook' in this.args) ? 'syshook-local' : '');
|
|
462
|
+
var digest = Tools.digestHex( base_url, 'sha256', 16);
|
|
442
463
|
return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
|
|
443
464
|
},
|
|
444
465
|
|
|
@@ -743,6 +764,27 @@ module.exports = {
|
|
|
743
764
|
);
|
|
744
765
|
},
|
|
745
766
|
|
|
767
|
+
formatSyncDiff(changes) {
|
|
768
|
+
// Render a diff code block for the CLI Markdown highlighter. Keep long
|
|
769
|
+
// unchanged sections compact unless the user requested verbose output.
|
|
770
|
+
var diff = "```diff\n";
|
|
771
|
+
changes.forEach( change => {
|
|
772
|
+
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
773
|
+
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
774
|
+
|
|
775
|
+
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
776
|
+
var top = lines.shift();
|
|
777
|
+
var bottom = lines.pop();
|
|
778
|
+
lines = [ top, "...", bottom ];
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
lines.forEach( line => {
|
|
782
|
+
diff += prefix + line.trimEnd() + "\n";
|
|
783
|
+
} );
|
|
784
|
+
} );
|
|
785
|
+
return diff + "```\n";
|
|
786
|
+
},
|
|
787
|
+
|
|
746
788
|
logSyncWarning(msg) {
|
|
747
789
|
this.warnings.push(msg);
|
|
748
790
|
|
|
@@ -769,14 +811,14 @@ module.exports = {
|
|
|
769
811
|
// xy sync --setup events --new
|
|
770
812
|
// xy sync --setup all --marketplace --stock --force
|
|
771
813
|
if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
|
|
772
|
-
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(
|
|
814
|
+
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
773
815
|
}
|
|
774
816
|
|
|
775
817
|
if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
|
|
776
818
|
if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
|
|
777
819
|
|
|
778
|
-
if (Tools.includesAny(this.args.setup, ['
|
|
779
|
-
this.die("Sorry, the sync engine cannot process
|
|
820
|
+
if (Tools.includesAny(this.args.setup, ['secrets', 'users', 'roles'])) {
|
|
821
|
+
this.die("Sorry, the sync engine cannot process secrets, users, or roles.");
|
|
780
822
|
}
|
|
781
823
|
|
|
782
824
|
if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
|
|
@@ -784,6 +826,7 @@ module.exports = {
|
|
|
784
826
|
}
|
|
785
827
|
|
|
786
828
|
if (!this.args.setup.length) this.die("No item types specified to set up.");
|
|
829
|
+
if (this.args.setup.includes('buckets')) this.requireBucketSyncVersion();
|
|
787
830
|
|
|
788
831
|
if (this.args.file_props) {
|
|
789
832
|
if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
|
|
@@ -801,8 +844,9 @@ module.exports = {
|
|
|
801
844
|
}
|
|
802
845
|
if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
|
|
803
846
|
|
|
847
|
+
var ignore_missing = !!this.args.ignore_missing;
|
|
804
848
|
var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
|
|
805
|
-
var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
|
|
849
|
+
var existing_keys = new_only ? this.scanSyncSourceIndex(['.'], { ignore_missing }) : {};
|
|
806
850
|
|
|
807
851
|
this.printBoxList({
|
|
808
852
|
title: 'Sync Setup',
|
|
@@ -828,6 +872,13 @@ module.exports = {
|
|
|
828
872
|
force: !!this.args.force,
|
|
829
873
|
dry: !!this.dry
|
|
830
874
|
});
|
|
875
|
+
|
|
876
|
+
if (this.args.setup.includes('buckets')) {
|
|
877
|
+
await this.setupBucketContents(result.bucket_plans, {
|
|
878
|
+
force: !!this.args.force,
|
|
879
|
+
dry: !!this.dry
|
|
880
|
+
});
|
|
881
|
+
}
|
|
831
882
|
|
|
832
883
|
if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
|
|
833
884
|
|
|
@@ -859,7 +910,7 @@ module.exports = {
|
|
|
859
910
|
return ext;
|
|
860
911
|
},
|
|
861
912
|
|
|
862
|
-
scanSyncSourceIndex(base_dirs) {
|
|
913
|
+
scanSyncSourceIndex(base_dirs, opts = {}) {
|
|
863
914
|
// Build an identity index from an existing sync tree. Filenames and folders
|
|
864
915
|
// are deliberately ignored, because sync sources may be freely reorganized.
|
|
865
916
|
var files = [];
|
|
@@ -870,7 +921,19 @@ module.exports = {
|
|
|
870
921
|
});
|
|
871
922
|
|
|
872
923
|
var index = {};
|
|
924
|
+
var seen_sources = new Map();
|
|
925
|
+
var bucket_extra_dirs = [];
|
|
873
926
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
927
|
+
try {
|
|
928
|
+
var xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
|
|
929
|
+
if (xypdf && xypdf.type == 'xypdf' && Array.isArray(xypdf.items) &&
|
|
930
|
+
xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
931
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch (err) {;} // report malformed source files during the real scan
|
|
935
|
+
});
|
|
936
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
874
937
|
var xypdf = null;
|
|
875
938
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
876
939
|
catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
|
|
@@ -889,7 +952,7 @@ module.exports = {
|
|
|
889
952
|
if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
|
|
890
953
|
this.die("Malformed item type in file: " + file.path);
|
|
891
954
|
}
|
|
892
|
-
if (item.type.match(/^(
|
|
955
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
893
956
|
this.die("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
894
957
|
}
|
|
895
958
|
if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
|
|
@@ -900,20 +963,41 @@ module.exports = {
|
|
|
900
963
|
if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
|
|
901
964
|
var list = this[type_def.list];
|
|
902
965
|
if (!list) this.die("Cannot locate internal list: " + type_def.list);
|
|
966
|
+
|
|
967
|
+
// Keep duplicate detection independent of remote existence, including
|
|
968
|
+
// for sources intentionally skipped with --ignore_missing.
|
|
969
|
+
var key = item.type + '-' + item.data.id;
|
|
970
|
+
if (seen_sources.has(key)) {
|
|
971
|
+
this.die("Duplicate source item found: " + file.path + " (already found in " + seen_sources.get(key) + ")");
|
|
972
|
+
}
|
|
973
|
+
seen_sources.set(key, file.path);
|
|
974
|
+
|
|
903
975
|
if (!Tools.findObject(list, { id: item.data.id })) {
|
|
976
|
+
// New-only setup needs the same grace period as ordinary sync.
|
|
977
|
+
// A missing local source does not represent a new remote item.
|
|
978
|
+
if (opts.ignore_missing) {
|
|
979
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
904
983
|
this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
905
984
|
}
|
|
906
985
|
|
|
907
|
-
var key = item.type + '-' + item.data.id;
|
|
908
|
-
if (index[key]) {
|
|
909
|
-
this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
|
|
910
|
-
}
|
|
911
986
|
index[key] = { file: file.path, item: item };
|
|
912
987
|
});
|
|
913
988
|
|
|
914
989
|
return index;
|
|
915
990
|
},
|
|
916
991
|
|
|
992
|
+
isSyncBucketExtraFile(file, dirs) {
|
|
993
|
+
// Bucket data and uploaded files are opaque to the XYPDF parser, even if
|
|
994
|
+
// a user uploaded a .json file with non-JSON contents.
|
|
995
|
+
return dirs.some( dir => {
|
|
996
|
+
var relative = Path.relative(dir, file);
|
|
997
|
+
return relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
|
|
998
|
+
});
|
|
999
|
+
},
|
|
1000
|
+
|
|
917
1001
|
writeSyncSetupFiles(list_names, opts = {}) {
|
|
918
1002
|
// Write selected remote definitions using setup's suggested layout. Keeping
|
|
919
1003
|
// this operation separate allows a future downsync to append newly discovered
|
|
@@ -935,7 +1019,7 @@ module.exports = {
|
|
|
935
1019
|
if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
|
|
936
1020
|
});
|
|
937
1021
|
|
|
938
|
-
var result = { written: 0, skipped: 0 };
|
|
1022
|
+
var result = { written: 0, skipped: 0, bucket_plans: [] };
|
|
939
1023
|
var existing_keys = opts.existing_keys || {};
|
|
940
1024
|
var base_dir = opts.base_dir || '.';
|
|
941
1025
|
var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
|
|
@@ -1029,6 +1113,7 @@ module.exports = {
|
|
|
1029
1113
|
var title = plan.title;
|
|
1030
1114
|
var filename = title + '.json';
|
|
1031
1115
|
var file = Path.join(dir, filename);
|
|
1116
|
+
if (target.type == 'bucket') result.bucket_plans.push({ id: item.id, file: file });
|
|
1032
1117
|
|
|
1033
1118
|
if (!opts.dry) {
|
|
1034
1119
|
if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
|
package/lib/transfer.js
CHANGED
|
@@ -86,7 +86,7 @@ module.exports = {
|
|
|
86
86
|
if (this.format.match(/json/)) return this.jsonOutput({ code: 0, file: filename, count: items.length, warnings: warnings });
|
|
87
87
|
|
|
88
88
|
this.toast('✅', 'green', "Exported " + items.length + " item(s) to: " + filename);
|
|
89
|
-
warnings.forEach( warning =>
|
|
89
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
90
90
|
},
|
|
91
91
|
|
|
92
92
|
parseExportDependencies(value) {
|
|
@@ -316,7 +316,7 @@ module.exports = {
|
|
|
316
316
|
|
|
317
317
|
if (this.format.match(/json/)) return this.jsonOutput(report);
|
|
318
318
|
|
|
319
|
-
warnings.forEach( warning =>
|
|
319
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
320
320
|
|
|
321
321
|
this.printBoxTable({
|
|
322
322
|
title: 'Import Results',
|
|
@@ -324,7 +324,7 @@ module.exports = {
|
|
|
324
324
|
rows: results.map( item => [item.type, item.id || '(New)', item.title, item.status] )
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
if (error)
|
|
327
|
+
if (error) warnln("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
|
|
328
328
|
else this.printSuggestedCommands({
|
|
329
329
|
"List events": "xy events",
|
|
330
330
|
"List categories": "xy categories",
|
package/lib/utils.js
CHANGED
|
@@ -1011,7 +1011,7 @@ module.exports = {
|
|
|
1011
1011
|
// load client config from xyops and cache for a while
|
|
1012
1012
|
var { err, data } = await this.cacheAPI('config', { format: 'json' }, { cache_ttl: 3600 });
|
|
1013
1013
|
if (err) {
|
|
1014
|
-
|
|
1014
|
+
warnln( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
|
|
1015
1015
|
this.die(err);
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
@@ -1057,7 +1057,8 @@ module.exports = {
|
|
|
1057
1057
|
async callStandardAPI(name, req, opts = {}) {
|
|
1058
1058
|
// wrapper around APIs that make a change
|
|
1059
1059
|
// indeterminate progress w/text, handle dry run, verbose req/res, etc.
|
|
1060
|
-
this.validateStandardAPIRequest(name, req);
|
|
1060
|
+
if (!opts.sync) this.validateStandardAPIRequest(name, req);
|
|
1061
|
+
delete opts.sync;
|
|
1061
1062
|
|
|
1062
1063
|
// Sensitive callers may supply a redacted display copy while the original
|
|
1063
1064
|
// request continues through validation and transport unchanged.
|
|
@@ -1264,7 +1265,11 @@ module.exports = {
|
|
|
1264
1265
|
// cli.box performs word wrapping at this content width and never ellipsizes.
|
|
1265
1266
|
var available_width = Math.max( 1, (cli.width() || 80) - 7 );
|
|
1266
1267
|
var max_width = Math.min( available_width, cli.stringWidth(final_text) );
|
|
1267
|
-
|
|
1268
|
+
var message = "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] });
|
|
1269
|
+
|
|
1270
|
+
// Keep warning and error toasts visible when routine output is quiet.
|
|
1271
|
+
if (['⚠️', '🛑', '❌', '☢️'].includes(emoji)) warnln(message);
|
|
1272
|
+
else println(message);
|
|
1268
1273
|
},
|
|
1269
1274
|
|
|
1270
1275
|
printMutationSummary(opts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "A complete command-line interface for the xyOps workflow automation system.",
|
|
5
5
|
"author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
|
|
6
6
|
"homepage": "https://github.com/pixlcore/xycli",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "node test.js",
|
|
24
|
-
"test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
|
|
24
|
+
"test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-syshook-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
|
|
25
25
|
"changelog": "node tools/changelog.js"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
package/test/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
|
|
|
8
8
|
|
|
9
9
|
Start xyOps at `http://localhost:5522` and configure an administrator API key using your normal CLI config file or the `XYOPS_API_KEY` environment variable. The tests read the same configuration as `xy`, including `XYOPS_BASE_URL` overrides. They refuse to run against a different server address.
|
|
10
10
|
|
|
11
|
-
Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
|
|
11
|
+
Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The `sync-buckets` suite requires xyOps 1.1.2 or later. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
|
|
12
12
|
|
|
13
13
|
## Running Tests
|
|
14
14
|
|
|
@@ -42,10 +42,10 @@ node --test --test-reporter=spec test/monitors.test.js
|
|
|
42
42
|
|
|
43
43
|
The controller exits with a nonzero status if any suite fails. A failed lifecycle check stops the remaining dependent steps in that suite, runs cleanup, and allows the next suite to proceed.
|
|
44
44
|
|
|
45
|
-
The configuration and sync unit suites run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
|
|
45
|
+
The configuration and sync unit suites, including `sync-buckets-unit`, run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
|
-
node --test test/config.test.js test/sync-unit.test.js
|
|
48
|
+
node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.test.js
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
## Coverage
|
|
@@ -67,6 +67,8 @@ node --test test/config.test.js test/sync-unit.test.js
|
|
|
67
67
|
| `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
|
|
68
68
|
| `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
|
|
69
69
|
| `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
|
|
70
|
+
| `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download, and timestamp alignment on xyOps 1.1.2 or later |
|
|
71
|
+
| `sync-buckets-unit` | Offline Bucket version gating, content setup, upload and download, timestamp alignment, and pre-delete safety |
|
|
70
72
|
| `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
|
|
71
73
|
| `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
|
|
72
74
|
| `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
|
package/test/help.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { test } = require('node:test');
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
3
|
const Path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
4
5
|
const { xy } = require('./helpers/common.js');
|
|
5
6
|
|
|
6
7
|
// Deliberately shadow any developer-machine configuration. Help and command
|
|
@@ -51,3 +52,58 @@ test('unknown top-level command does not suggest an unrelated command', () => {
|
|
|
51
52
|
assert.match(output, /Unknown command: completely_unrelated/);
|
|
52
53
|
assert.doesNotMatch(output, /Did you mean/);
|
|
53
54
|
});
|
|
55
|
+
|
|
56
|
+
test('quiet mode prints sync diagnostics and bound warnings on stderr', () => {
|
|
57
|
+
// Run the real CLI startup so this checks the warn override and the global
|
|
58
|
+
// binding installed by cli.global(), without contacting a server.
|
|
59
|
+
const script = `
|
|
60
|
+
process.argv = [process.execPath, require.resolve('./index.js'), 'help', '--quiet'];
|
|
61
|
+
process.on('beforeExit', () => {
|
|
62
|
+
const cli = require('pixl-cli');
|
|
63
|
+
cli.warnln('Fixture cli warning');
|
|
64
|
+
warnln('Fixture warning');
|
|
65
|
+
warn('Fixture direct warning\\n');
|
|
66
|
+
const utils = require('./lib/utils.js');
|
|
67
|
+
const context = { colors: { orange: [255, 105, 0], red: [251, 44, 54], green: [0, 201, 80] }, color: utils.color };
|
|
68
|
+
utils.toast.call(context, '⚠️', 'orange', 'Fixture toast warning');
|
|
69
|
+
utils.toast.call(context, '🛑', 'red', 'Fixture toast error');
|
|
70
|
+
utils.toast.call(context, '✅', 'green', 'Fixture success');
|
|
71
|
+
const sync = require('./lib/sync.js');
|
|
72
|
+
sync.logSyncWarning.call({ warnings: [] }, 'Fixture sync warning');
|
|
73
|
+
sync.logSyncError.call({ errors: [] }, 'Fixture sync error');
|
|
74
|
+
println('Routine output');
|
|
75
|
+
});
|
|
76
|
+
require('./index.js');
|
|
77
|
+
`;
|
|
78
|
+
const result = spawnSync(process.execPath, ['-e', script], {
|
|
79
|
+
cwd: Path.join(__dirname, '..'),
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
env: { ...process.env, ...offlineEnv },
|
|
82
|
+
windowsHide: true
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.ifError(result.error);
|
|
86
|
+
assert.equal(result.status, 1);
|
|
87
|
+
assert.equal(result.stdout, '');
|
|
88
|
+
assert.match(result.stderr, /Fixture cli warning/);
|
|
89
|
+
assert.match(result.stderr, /Fixture warning/);
|
|
90
|
+
assert.match(result.stderr, /Fixture direct warning/);
|
|
91
|
+
assert.match(result.stderr, /Fixture toast warning/);
|
|
92
|
+
assert.match(result.stderr, /Fixture toast error/);
|
|
93
|
+
assert.match(result.stderr, /Fixture sync warning/);
|
|
94
|
+
assert.match(result.stderr, /Fixture sync error/);
|
|
95
|
+
assert.doesNotMatch(result.stderr, /Fixture success/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('quiet invalid usage reports an error on stderr', () => {
|
|
99
|
+
const result = spawnSync(process.execPath, [Path.join(__dirname, '..', 'index.js'), 'upgrade', 'unexpected', '--quiet'], {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
env: { ...process.env, ...offlineEnv },
|
|
102
|
+
windowsHide: true
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.ifError(result.error);
|
|
106
|
+
assert.equal(result.status, 1);
|
|
107
|
+
assert.equal(result.stdout, '');
|
|
108
|
+
assert.match(result.stderr, /Invalid usage.*xy help upgrade/);
|
|
109
|
+
});
|