@pixlcore/xycli 1.0.6 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/docs/help.md +10 -1
- package/docs/sync.md +28 -3
- package/docs/two-way-git-tutorial.md +15 -3
- package/index.js +10 -4
- package/lib/buckets.js +1 -5
- package/lib/jobs.js +2 -2
- package/lib/sync-buckets.js +315 -0
- package/lib/sync.js +134 -35
- package/lib/utils.js +2 -1
- package/package.json +2 -2
- package/test/README.md +5 -3
- package/test/sync-buckets-unit.test.js +188 -0
- package/test/sync-buckets.test.js +133 -0
- package/test/sync-unit.test.js +179 -2
- package/test/utils-unit.test.js +50 -0
package/lib/sync.js
CHANGED
|
@@ -44,7 +44,7 @@ module.exports = {
|
|
|
44
44
|
|
|
45
45
|
println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
|
|
46
46
|
|
|
47
|
-
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(
|
|
47
|
+
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
48
48
|
|
|
49
49
|
// allow sync config in config files and/or cli args
|
|
50
50
|
var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
@@ -77,6 +77,13 @@ module.exports = {
|
|
|
77
77
|
this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Bucket content needs the replacement API and data timestamps introduced
|
|
81
|
+
// in xyOps 1.1.2. Check before any ordinary sync changes are applied.
|
|
82
|
+
if ((sconfig.up && sconfig.up.includes('buckets')) || (sconfig.down && sconfig.down.includes('buckets')) ||
|
|
83
|
+
(sconfig.delete && sconfig.delete.includes('buckets'))) {
|
|
84
|
+
this.requireBucketSyncVersion();
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
// scan all base directories, default to cwd
|
|
81
88
|
var files = [];
|
|
82
89
|
if (!this.args.other || !this.args.other.length) {
|
|
@@ -106,11 +113,15 @@ module.exports = {
|
|
|
106
113
|
// longest-first lets each external property file belong to the most specific
|
|
107
114
|
// source basename instead of also being claimed by every shorter prefix.
|
|
108
115
|
var source_stems_by_dir = new Map();
|
|
116
|
+
var bucket_extra_dirs = [];
|
|
109
117
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
110
118
|
var xypdf = null;
|
|
111
119
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
112
120
|
catch (err) { return; } // the normal scan below reports malformed JSON
|
|
113
121
|
if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
|
|
122
|
+
if (Array.isArray(xypdf.items) && xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
123
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
124
|
+
}
|
|
114
125
|
|
|
115
126
|
var dir = Path.dirname(file.path);
|
|
116
127
|
var stems = source_stems_by_dir.get(dir);
|
|
@@ -127,7 +138,7 @@ module.exports = {
|
|
|
127
138
|
// load all xypdf files
|
|
128
139
|
var items = [];
|
|
129
140
|
|
|
130
|
-
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
141
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
131
142
|
// file: { path, size, mtime }
|
|
132
143
|
var dir = Path.dirname(file.path);
|
|
133
144
|
var filename = Path.basename(file.path);
|
|
@@ -175,7 +186,7 @@ module.exports = {
|
|
|
175
186
|
this.logSyncWarning("Malformed item type in file: " + file.path);
|
|
176
187
|
return;
|
|
177
188
|
}
|
|
178
|
-
if (item.type.match(/^(
|
|
189
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
179
190
|
this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
180
191
|
return;
|
|
181
192
|
}
|
|
@@ -253,13 +264,13 @@ module.exports = {
|
|
|
253
264
|
}
|
|
254
265
|
|
|
255
266
|
// External files may only replace string properties that already exist in
|
|
256
|
-
// the definition.
|
|
257
|
-
if (typeof(
|
|
267
|
+
// the definition. setSyncPropByPath() creates missing keys, so validate first.
|
|
268
|
+
if (typeof(this.getSyncPropByPath(item.data, prop_path)) != 'string') {
|
|
258
269
|
this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
|
|
259
270
|
neighbor_error = true;
|
|
260
271
|
return;
|
|
261
272
|
}
|
|
262
|
-
if (!
|
|
273
|
+
if (!this.setSyncPropByPath(item.data, prop_path, content)) {
|
|
263
274
|
this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
|
|
264
275
|
neighbor_error = true;
|
|
265
276
|
return;
|
|
@@ -313,24 +324,8 @@ module.exports = {
|
|
|
313
324
|
var changes = this.diffSyncItems(xy_item, item.data);
|
|
314
325
|
if (!changes.some( change => change.added || change.removed )) continue; // identical
|
|
315
326
|
|
|
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";
|
|
327
|
+
// Use the same highlighted, context-trimmed diff for every sync item.
|
|
328
|
+
var diff = this.formatSyncDiff(changes);
|
|
334
329
|
|
|
335
330
|
// figure out which direction to go
|
|
336
331
|
var direction = false;
|
|
@@ -358,7 +353,7 @@ module.exports = {
|
|
|
358
353
|
if (direction == 'up') {
|
|
359
354
|
// sync change up to xyops
|
|
360
355
|
try {
|
|
361
|
-
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
|
|
356
|
+
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true, sync: true });
|
|
362
357
|
|
|
363
358
|
// add flag to run optional command at completion
|
|
364
359
|
this.upSynced = true;
|
|
@@ -383,6 +378,12 @@ module.exports = {
|
|
|
383
378
|
|
|
384
379
|
// update state db
|
|
385
380
|
await this.syncState(state);
|
|
381
|
+
|
|
382
|
+
// Bucket metadata is handled above like every other XYPDF item. Its data
|
|
383
|
+
// and binary files have their own storage records and reconciliation pass.
|
|
384
|
+
if (sconfig.up && sconfig.up.includes('buckets') || sconfig.down && sconfig.down.includes('buckets')) {
|
|
385
|
+
await this.syncBucketContents(items.filter( item => item.type == 'bucket' ), sconfig);
|
|
386
|
+
}
|
|
386
387
|
|
|
387
388
|
// Deletion is the destructive final phase, so only begin it after every
|
|
388
389
|
// update and the sync-state write completed cleanly. Earlier successful
|
|
@@ -418,7 +419,7 @@ module.exports = {
|
|
|
418
419
|
println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
|
|
419
420
|
|
|
420
421
|
try {
|
|
421
|
-
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
|
|
422
|
+
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true, sync: true });
|
|
422
423
|
|
|
423
424
|
// add flag to run optional command at completion
|
|
424
425
|
this.upSynced = true;
|
|
@@ -703,9 +704,9 @@ module.exports = {
|
|
|
703
704
|
if (item.neighbors) {
|
|
704
705
|
for (var path in item.neighbors) {
|
|
705
706
|
var file = item.neighbors[path];
|
|
706
|
-
var value =
|
|
707
|
+
var value = this.getSyncPropByPath( data, path ) ?? "";
|
|
707
708
|
|
|
708
|
-
if (!
|
|
709
|
+
if (!this.setSyncPropByPath( data, path, "(External)" )) {
|
|
709
710
|
throw "External neighbor file path not found: " + file + ": " + path;
|
|
710
711
|
}
|
|
711
712
|
|
|
@@ -743,6 +744,27 @@ module.exports = {
|
|
|
743
744
|
);
|
|
744
745
|
},
|
|
745
746
|
|
|
747
|
+
formatSyncDiff(changes) {
|
|
748
|
+
// Render a diff code block for the CLI Markdown highlighter. Keep long
|
|
749
|
+
// unchanged sections compact unless the user requested verbose output.
|
|
750
|
+
var diff = "```diff\n";
|
|
751
|
+
changes.forEach( change => {
|
|
752
|
+
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
753
|
+
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
754
|
+
|
|
755
|
+
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
756
|
+
var top = lines.shift();
|
|
757
|
+
var bottom = lines.pop();
|
|
758
|
+
lines = [ top, "...", bottom ];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
lines.forEach( line => {
|
|
762
|
+
diff += prefix + line.trimEnd() + "\n";
|
|
763
|
+
} );
|
|
764
|
+
} );
|
|
765
|
+
return diff + "```\n";
|
|
766
|
+
},
|
|
767
|
+
|
|
746
768
|
logSyncWarning(msg) {
|
|
747
769
|
this.warnings.push(msg);
|
|
748
770
|
|
|
@@ -769,14 +791,14 @@ module.exports = {
|
|
|
769
791
|
// xy sync --setup events --new
|
|
770
792
|
// xy sync --setup all --marketplace --stock --force
|
|
771
793
|
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(/^(
|
|
794
|
+
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
773
795
|
}
|
|
774
796
|
|
|
775
797
|
if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
|
|
776
798
|
if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
|
|
777
799
|
|
|
778
|
-
if (Tools.includesAny(this.args.setup, ['
|
|
779
|
-
this.die("Sorry, the sync engine cannot process
|
|
800
|
+
if (Tools.includesAny(this.args.setup, ['secrets', 'users', 'roles'])) {
|
|
801
|
+
this.die("Sorry, the sync engine cannot process secrets, users, or roles.");
|
|
780
802
|
}
|
|
781
803
|
|
|
782
804
|
if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
|
|
@@ -784,6 +806,7 @@ module.exports = {
|
|
|
784
806
|
}
|
|
785
807
|
|
|
786
808
|
if (!this.args.setup.length) this.die("No item types specified to set up.");
|
|
809
|
+
if (this.args.setup.includes('buckets')) this.requireBucketSyncVersion();
|
|
787
810
|
|
|
788
811
|
if (this.args.file_props) {
|
|
789
812
|
if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
|
|
@@ -828,6 +851,13 @@ module.exports = {
|
|
|
828
851
|
force: !!this.args.force,
|
|
829
852
|
dry: !!this.dry
|
|
830
853
|
});
|
|
854
|
+
|
|
855
|
+
if (this.args.setup.includes('buckets')) {
|
|
856
|
+
await this.setupBucketContents(result.bucket_plans, {
|
|
857
|
+
force: !!this.args.force,
|
|
858
|
+
dry: !!this.dry
|
|
859
|
+
});
|
|
860
|
+
}
|
|
831
861
|
|
|
832
862
|
if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
|
|
833
863
|
|
|
@@ -870,7 +900,18 @@ module.exports = {
|
|
|
870
900
|
});
|
|
871
901
|
|
|
872
902
|
var index = {};
|
|
903
|
+
var bucket_extra_dirs = [];
|
|
873
904
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
905
|
+
try {
|
|
906
|
+
var xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
|
|
907
|
+
if (xypdf && xypdf.type == 'xypdf' && Array.isArray(xypdf.items) &&
|
|
908
|
+
xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
909
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch (err) {;} // report malformed source files during the real scan
|
|
913
|
+
});
|
|
914
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
874
915
|
var xypdf = null;
|
|
875
916
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
876
917
|
catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
|
|
@@ -889,7 +930,7 @@ module.exports = {
|
|
|
889
930
|
if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
|
|
890
931
|
this.die("Malformed item type in file: " + file.path);
|
|
891
932
|
}
|
|
892
|
-
if (item.type.match(/^(
|
|
933
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
893
934
|
this.die("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
894
935
|
}
|
|
895
936
|
if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
|
|
@@ -914,6 +955,15 @@ module.exports = {
|
|
|
914
955
|
return index;
|
|
915
956
|
},
|
|
916
957
|
|
|
958
|
+
isSyncBucketExtraFile(file, dirs) {
|
|
959
|
+
// Bucket data and uploaded files are opaque to the XYPDF parser, even if
|
|
960
|
+
// a user uploaded a .json file with non-JSON contents.
|
|
961
|
+
return dirs.some( dir => {
|
|
962
|
+
var relative = Path.relative(dir, file);
|
|
963
|
+
return relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
|
|
964
|
+
});
|
|
965
|
+
},
|
|
966
|
+
|
|
917
967
|
writeSyncSetupFiles(list_names, opts = {}) {
|
|
918
968
|
// Write selected remote definitions using setup's suggested layout. Keeping
|
|
919
969
|
// this operation separate allows a future downsync to append newly discovered
|
|
@@ -935,7 +985,7 @@ module.exports = {
|
|
|
935
985
|
if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
|
|
936
986
|
});
|
|
937
987
|
|
|
938
|
-
var result = { written: 0, skipped: 0 };
|
|
988
|
+
var result = { written: 0, skipped: 0, bucket_plans: [] };
|
|
939
989
|
var existing_keys = opts.existing_keys || {};
|
|
940
990
|
var base_dir = opts.base_dir || '.';
|
|
941
991
|
var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
|
|
@@ -1006,10 +1056,22 @@ module.exports = {
|
|
|
1006
1056
|
var item = Tools.copyHash(plan.source_item, true);
|
|
1007
1057
|
var extras = [];
|
|
1008
1058
|
(opts.file_props || []).forEach( path => {
|
|
1009
|
-
var extra =
|
|
1059
|
+
var extra = this.getSyncPropByPath(item, path);
|
|
1010
1060
|
if ((typeof(extra) == 'string') && extra.match(/\S/)) {
|
|
1011
1061
|
extras.push({ path, content: extra });
|
|
1012
|
-
|
|
1062
|
+
this.setSyncPropByPath( item, path, "(External)" );
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// handle workflow nodes separately
|
|
1066
|
+
if (item.workflow && item.workflow.nodes && Array.isArray(item.workflow.nodes)) {
|
|
1067
|
+
item.workflow.nodes.forEach( node => {
|
|
1068
|
+
if (!node.data || !Tools.isaHash(node.data)) return; // sanity
|
|
1069
|
+
var extra = this.getSyncPropByPath(node.data, path);
|
|
1070
|
+
if ((typeof(extra) == 'string') && extra.match(/\S/)) {
|
|
1071
|
+
extras.push({ path: `workflow.${node.id}.${path}`, content: extra });
|
|
1072
|
+
this.setSyncPropByPath( node.data, path, "(External)" );
|
|
1073
|
+
}
|
|
1074
|
+
} );
|
|
1013
1075
|
}
|
|
1014
1076
|
});
|
|
1015
1077
|
|
|
@@ -1017,6 +1079,7 @@ module.exports = {
|
|
|
1017
1079
|
var title = plan.title;
|
|
1018
1080
|
var filename = title + '.json';
|
|
1019
1081
|
var file = Path.join(dir, filename);
|
|
1082
|
+
if (target.type == 'bucket') result.bucket_plans.push({ id: item.id, file: file });
|
|
1020
1083
|
|
|
1021
1084
|
if (!opts.dry) {
|
|
1022
1085
|
if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
|
|
@@ -1188,6 +1251,42 @@ module.exports = {
|
|
|
1188
1251
|
}
|
|
1189
1252
|
|
|
1190
1253
|
return ext;
|
|
1254
|
+
},
|
|
1255
|
+
|
|
1256
|
+
getSyncPropByPath(data, path) {
|
|
1257
|
+
// special-case Tools.getPath for handling shorthand for named workflow nodes
|
|
1258
|
+
// e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
|
|
1259
|
+
if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
|
|
1260
|
+
var node_id = RegExp.$1;
|
|
1261
|
+
var sub_path = RegExp.$2;
|
|
1262
|
+
if (node_id === 'nodes') return Tools.getPath(data, path);
|
|
1263
|
+
|
|
1264
|
+
var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
|
|
1265
|
+
if (!node || !node.data) return undefined;
|
|
1266
|
+
|
|
1267
|
+
data = node.data;
|
|
1268
|
+
path = sub_path;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return Tools.getPath(data, path);
|
|
1272
|
+
},
|
|
1273
|
+
|
|
1274
|
+
setSyncPropByPath(data, path, value) {
|
|
1275
|
+
// special-case Tools.setPath for handling shorthand for named workflow nodes
|
|
1276
|
+
// e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
|
|
1277
|
+
if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
|
|
1278
|
+
var node_id = RegExp.$1;
|
|
1279
|
+
var sub_path = RegExp.$2;
|
|
1280
|
+
if (node_id === 'nodes') return Tools.setPath(data, path, value);
|
|
1281
|
+
|
|
1282
|
+
var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
|
|
1283
|
+
if (!node || !node.data) return undefined;
|
|
1284
|
+
|
|
1285
|
+
data = node.data;
|
|
1286
|
+
path = sub_path;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
return Tools.setPath(data, path, value);
|
|
1191
1290
|
}
|
|
1192
1291
|
|
|
1193
1292
|
};
|
package/lib/utils.js
CHANGED
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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-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 |
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const Path = require('node:path');
|
|
6
|
+
const bucketSync = require('../lib/sync-buckets.js');
|
|
7
|
+
const sync = require('../lib/sync.js');
|
|
8
|
+
const utils = require('../lib/utils.js');
|
|
9
|
+
|
|
10
|
+
// The bucket pass uses the ordinary sync logging functions, whose output is
|
|
11
|
+
// provided by pixl-cli in production. Keep the test output quiet and local.
|
|
12
|
+
global.println = () => {};
|
|
13
|
+
|
|
14
|
+
function fixture(t, options = {}) {
|
|
15
|
+
const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-bucket-sync-unit-'));
|
|
16
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
const item = { type: 'bucket', data: { id: 'bucket_unit', title: 'Changed Title' }, file: Path.join(dir, 'custom', 'Original-Name.json') };
|
|
18
|
+
const paths = bucketSync.getSyncBucketPaths(item);
|
|
19
|
+
fs.mkdirSync(paths.files, { recursive: true });
|
|
20
|
+
fs.writeFileSync(item.file, '{}');
|
|
21
|
+
fs.writeFileSync(paths.data, JSON.stringify(options.localData || { keep: true, removed: true }));
|
|
22
|
+
fs.utimesSync(paths.data, 100, options.localDataTime || 100);
|
|
23
|
+
|
|
24
|
+
const remote = {
|
|
25
|
+
bucket: { id: item.data.id, title: item.data.title },
|
|
26
|
+
data: options.remoteData || { keep: false, server: true },
|
|
27
|
+
meta: { mod: options.remoteDataTime || 90 },
|
|
28
|
+
files: options.remoteFiles || []
|
|
29
|
+
};
|
|
30
|
+
const bytes = options.remoteBytes || {};
|
|
31
|
+
const calls = [];
|
|
32
|
+
const app = {
|
|
33
|
+
...bucketSync,
|
|
34
|
+
...sync,
|
|
35
|
+
compareVersions: utils.compareVersions,
|
|
36
|
+
xyopsVersion: options.version || '1.1.2',
|
|
37
|
+
dry: !!options.dry,
|
|
38
|
+
errors: [],
|
|
39
|
+
warnings: [],
|
|
40
|
+
api: {
|
|
41
|
+
async getBucket() { return { data: structuredClone(remote) }; },
|
|
42
|
+
async file(request, settings) {
|
|
43
|
+
const path = 'files/' + request.path;
|
|
44
|
+
if (!Object.hasOwn(bytes, path)) return { err: new Error('Missing remote bytes') };
|
|
45
|
+
fs.writeFileSync(settings.download, bytes[path]);
|
|
46
|
+
return { data: {} };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async callStandardAPI(method, request, settings = {}) {
|
|
50
|
+
calls.push({ method, request, settings });
|
|
51
|
+
if (method == 'writeBucketData') {
|
|
52
|
+
remote.data = structuredClone(request.data);
|
|
53
|
+
remote.meta.mod = 120;
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
if (method == 'uploadBucketFiles') {
|
|
57
|
+
settings.files.forEach(path => {
|
|
58
|
+
const name = app.cleanSyncBucketFilename(Path.basename(path));
|
|
59
|
+
let file = remote.files.find(entry => entry.filename == name);
|
|
60
|
+
if (!file) {
|
|
61
|
+
file = { filename: name, path: 'files/bucket/bucket_unit/new/' + name };
|
|
62
|
+
remote.files.push(file);
|
|
63
|
+
}
|
|
64
|
+
file.size = fs.statSync(path).size;
|
|
65
|
+
file.date = 130;
|
|
66
|
+
});
|
|
67
|
+
return { files: structuredClone(remote.files) };
|
|
68
|
+
}
|
|
69
|
+
if (method == 'deleteBucketFile') {
|
|
70
|
+
remote.files = remote.files.filter(file => file.path != request.path);
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
throw new Error('Unexpected API: ' + method);
|
|
74
|
+
},
|
|
75
|
+
logSyncError(message) { this.errors.push(message); },
|
|
76
|
+
logSyncWarning(message) { this.warnings.push(message); },
|
|
77
|
+
markdown(text) { return text; },
|
|
78
|
+
die(message) { throw new Error(message); }
|
|
79
|
+
};
|
|
80
|
+
return { dir, item, paths, remote, calls, app };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('bucket sync rejects xyOps versions older than 1.1.2', t => {
|
|
84
|
+
const current = fixture(t);
|
|
85
|
+
assert.doesNotThrow(() => current.app.requireBucketSyncVersion());
|
|
86
|
+
current.app.xyopsVersion = '1.1.1';
|
|
87
|
+
assert.throws(() => current.app.requireBucketSyncVersion(), /v1\.1\.2 or later/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('bucket setup writes data and binary files with server mtimes beside the XYPDF source', async t => {
|
|
91
|
+
const file = { filename: 'Sample.JSON', path: 'files/bucket/bucket_unit/key/Sample.JSON', size: 8, date: 80 };
|
|
92
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'not json' } });
|
|
93
|
+
fs.rmSync(env.paths.dir, { recursive: true, force: true });
|
|
94
|
+
await env.app.setupBucketContents([{ id: env.item.data.id, file: env.item.file }], { dry: false, force: false });
|
|
95
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
96
|
+
assert.equal(fs.readFileSync(Path.join(env.paths.files, 'Sample.JSON'), 'utf8'), 'not json');
|
|
97
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 90);
|
|
98
|
+
assert.equal(fs.statSync(Path.join(env.paths.files, 'Sample.JSON')).mtimeMs / 1000, 80);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('upsync replaces data, normalizes manifest names, batches uploads, and deletes missing files last', async t => {
|
|
102
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
103
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
104
|
+
// Semicolon needs server filename normalization but remains a regular file on Windows.
|
|
105
|
+
const upload = Path.join(env.paths.files, 'New; File.txt');
|
|
106
|
+
fs.writeFileSync(upload, 'new');
|
|
107
|
+
fs.utimesSync(upload, 110, 110);
|
|
108
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
109
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData', 'uploadBucketFiles', 'deleteBucketFile']);
|
|
110
|
+
assert.equal(env.calls[0].request.replace, true);
|
|
111
|
+
assert.deepEqual(env.remote.data, { keep: true, removed: true });
|
|
112
|
+
assert.equal(env.remote.files[0].filename, 'New_ File.txt');
|
|
113
|
+
assert.equal(fs.statSync(upload).mtimeMs / 1000, 130);
|
|
114
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 120);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('downsync replaces changed files and preserves the server timestamp', async t => {
|
|
118
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
119
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'remote' }, remoteDataTime: 150 });
|
|
120
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
121
|
+
fs.writeFileSync(localFile, 'old');
|
|
122
|
+
fs.utimesSync(localFile, 100, 100);
|
|
123
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
124
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
125
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
126
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
127
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 150);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('two-way sync chooses the newer data and file copies independently', async t => {
|
|
131
|
+
const old = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 140 };
|
|
132
|
+
const env = fixture(t, {
|
|
133
|
+
localDataTime: 160,
|
|
134
|
+
remoteDataTime: 150,
|
|
135
|
+
remoteFiles: [old],
|
|
136
|
+
remoteBytes: { [old.path]: 'remote'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const localFile = Path.join(env.paths.files, old.filename);
|
|
140
|
+
fs.writeFileSync(localFile, 'stale');
|
|
141
|
+
fs.utimesSync(localFile, 100, 100);
|
|
142
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
143
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData']);
|
|
144
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
145
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
146
|
+
assert.deepEqual(env.remote.data, env.app.readSyncBucketLocal(env.item).data);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('two-way sync reports equal-time content conflicts instead of guessing', async t => {
|
|
150
|
+
const env = fixture(t, { remoteDataTime: 100 });
|
|
151
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
152
|
+
assert.equal(env.app.errors.length, 1);
|
|
153
|
+
assert.match(env.app.errors[0], /equal timestamps/);
|
|
154
|
+
assert.deepEqual(env.calls, []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('invalid local data blocks every bucket mutation including file deletion', async t => {
|
|
158
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
159
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
160
|
+
fs.writeFileSync(env.paths.data, '{invalid');
|
|
161
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
162
|
+
assert.equal(env.app.errors.length, 1);
|
|
163
|
+
assert.deepEqual(env.calls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('bucket dry runs leave data, files, and server records unchanged', async t => {
|
|
167
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
168
|
+
const env = fixture(t, { remoteFiles: [removed], dry: true });
|
|
169
|
+
const added = Path.join(env.paths.files, 'new.txt');
|
|
170
|
+
fs.writeFileSync(added, 'new');
|
|
171
|
+
const originalTime = fs.statSync(env.paths.data).mtimeMs;
|
|
172
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
173
|
+
assert.deepEqual(env.calls, []);
|
|
174
|
+
assert.equal(env.remote.files.length, 1);
|
|
175
|
+
assert.deepEqual(env.remote.data, { keep: false, server: true });
|
|
176
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs, originalTime);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a failed download keeps the previous local file intact', async t => {
|
|
180
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
181
|
+
const env = fixture(t, { remoteFiles: [file] });
|
|
182
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
183
|
+
fs.writeFileSync(localFile, 'before');
|
|
184
|
+
fs.utimesSync(localFile, 100, 100);
|
|
185
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
186
|
+
assert.equal(env.app.errors.length, 1);
|
|
187
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'before');
|
|
188
|
+
});
|