@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/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(/^(buckets|secrets|users|roles)$/) );
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(/^(bucket|secret|user|role)$/)) {
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
- var xy_item = Tools.findObject(list, { id: item.data.id });
204
- if (!xy_item) {
205
- this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
206
- return;
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
- // render diff
317
- var diff = "```diff\n";
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 digest = Tools.digestHex( this.config.base_url, 'sha256', 16);
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(/^(buckets|secrets|users|roles)$/) );
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, ['buckets', 'secrets', 'users', 'roles'])) {
779
- this.die("Sorry, the sync engine cannot process buckets, secrets, users, or roles.");
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(/^(bucket|secret|user|role)$/)) {
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 => println(yellow(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 => println(yellow(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) println("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
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
- println( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
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
- println( "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] }) );
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.7",
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
+ });