@pixlcore/xycli 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ // Local XYPDF deletion for xyOps System Hooks.
2
+ // This command never updates definitions or queries the remote server.
3
+
4
+ const fs = require('fs');
5
+ const Path = require('path');
6
+ const cli = require('pixl-cli');
7
+ const Tools = cli.Tools;
8
+
9
+ const MAX_HOOK_BYTES = 64 * 1024 * 1024;
10
+
11
+ // Map each supported xyOps deletion activity to both its STDIN payload key
12
+ // and its XYPDF item type. API Keys use "apikey" in the activity name but
13
+ // "api_key" in the payload; workflows are stored as Event definitions.
14
+ // Buckets are excluded because their data and uploaded files need separate
15
+ // deletion handling beyond the single XYPDF source and its property files.
16
+ const HOOK_TYPES = {
17
+ alert_delete: 'alert',
18
+ apikey_delete: 'api_key',
19
+ category_delete: 'category',
20
+ channel_delete: 'channel',
21
+ event_delete: 'event',
22
+ group_delete: 'group',
23
+ monitor_delete: 'monitor',
24
+ plugin_delete: 'plugin',
25
+ tag_delete: 'tag',
26
+ web_hook_delete: 'web_hook'
27
+ };
28
+
29
+ async function readHookPayload() {
30
+ // System Hooks send one JSON activity object followed by a newline. Read
31
+ // until EOF so a partial pipe write cannot be mistaken for a valid event.
32
+ if (process.stdin.isTTY) throw new Error("System Hook sync requires an activity payload on STDIN.");
33
+
34
+ var chunks = [];
35
+ var size = 0;
36
+ for await (const chunk of process.stdin) {
37
+ var buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ size += buffer.length;
39
+ if (size > MAX_HOOK_BYTES) throw new Error("System Hook activity payload is too large.");
40
+ chunks.push(buffer);
41
+ }
42
+
43
+ if (!size) throw new Error("System Hook activity payload is empty.");
44
+
45
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
46
+ catch (err) { throw new Error("Invalid System Hook activity JSON: " + err.message); }
47
+ }
48
+
49
+ function listLocalFiles(dir) {
50
+ // Match ordinary sync's visible-file scan, but never follow symbolic links.
51
+ // A local delete must not cross into an unrelated tree through a link.
52
+ var files = [];
53
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
54
+ if (entry.name.startsWith('.')) continue;
55
+ var file = Path.join(dir, entry.name);
56
+ if (entry.isDirectory()) files.push(...listLocalFiles(file));
57
+ else if (entry.isFile()) files.push(file);
58
+ }
59
+ return files;
60
+ }
61
+
62
+ function isInsideDir(file, dir) {
63
+ var relative = Path.relative(dir, file);
64
+ return !!relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
65
+ }
66
+
67
+ function scanSources(files) {
68
+ // Index every valid XYPDF stem, not only the target item. A source named
69
+ // "Foo" must not claim property files belonging to "Foo-V2".
70
+ var sources = [];
71
+ var bucket_dirs = [];
72
+ var parse_errors = [];
73
+ for (const file of files.filter( file => file.match(/\.json$/i) )) {
74
+ var raw = null;
75
+ try { raw = fs.readFileSync(file, 'utf8'); }
76
+ catch (err) { throw new Error("Failed to read JSON file: " + file + ": " + err.message); }
77
+
78
+ var xypdf = null;
79
+ try { xypdf = JSON.parse(raw); }
80
+ catch (err) {
81
+ parse_errors.push({ file, err });
82
+ continue;
83
+ }
84
+ if (!xypdf || (xypdf.type !== 'xypdf')) continue;
85
+
86
+ var stem = Path.basename(file).replace(/\.json$/i, '');
87
+ var item = Array.isArray(xypdf.items) && (xypdf.items.length === 1) ? xypdf.items[0] : null;
88
+ if (item && (item.type === 'bucket')) bucket_dirs.push(file.replace(/\.json$/i, ''));
89
+ sources.push({ file, stem, xypdf, item });
90
+ }
91
+
92
+ // Bucket uploaded files are opaque content even when named *.json. They
93
+ // must not participate in this definition source inventory.
94
+ for (const entry of parse_errors) {
95
+ if (bucket_dirs.some( dir => isInsideDir(entry.file, dir) )) continue;
96
+ throw new Error("Failed to parse JSON file: " + entry.file + ": " + entry.err.message);
97
+ }
98
+
99
+ return sources.filter( source => !bucket_dirs.some( dir => isInsideDir(source.file, dir) ) );
100
+ }
101
+
102
+ function findOwnedNeighbors(app, files, sources, source) {
103
+ var dir = Path.dirname(source.file);
104
+ var stem = source.stem;
105
+ var source_stems = sources.filter( item => Path.dirname(item.file) === dir )
106
+ .map( item => item.stem ).sort( (a, b) => b.length - a.length );
107
+ var neighbors = [];
108
+
109
+ for (const file of files) {
110
+ if (Path.dirname(file) !== dir || file === source.file) continue;
111
+ var name = Path.basename(file);
112
+ var name_stem = name.replace(/\.\w+$/, '');
113
+ if (!name_stem.startsWith(stem + '-')) continue;
114
+
115
+ // The longest XYPDF prefix owns a neighboring property file. Another
116
+ // XYPDF is always its own source, even if its name looks like a neighbor.
117
+ var owner = source_stems.find( other => name_stem.startsWith(other + '-') );
118
+ if (owner && (owner !== stem)) continue;
119
+ if (sources.some( other => other.file === file )) continue;
120
+
121
+ var prop_path = name_stem.substring(stem.length + 1);
122
+ if (!prop_path.match(/^[\w\.\-]+$/) ||
123
+ (typeof(app.getSyncPropByPath(source.item.data, prop_path)) !== 'string')) {
124
+ throw new Error("Neighbor file does not match a string property: " + file);
125
+ }
126
+ neighbors.push(file);
127
+ }
128
+ return neighbors;
129
+ }
130
+
131
+ module.exports = {
132
+
133
+ async runSyncSyshook() {
134
+ // The argument is an exact activity action, not a general sync direction.
135
+ // Ignore saved up/down/delete settings by never entering ordinary sync.
136
+ var action = this.args.syshook;
137
+ var type = HOOK_TYPES[action];
138
+ if (!type) this.die("Invalid --syshook action (use one of: " + Object.keys(HOOK_TYPES).join(', ') + ").");
139
+ if (['up', 'down', 'delete', 'setup', 'new'].some( key => key in this.args )) {
140
+ this.die("--syshook cannot be combined with ordinary sync modes.");
141
+ }
142
+ if (!this.args.other || (this.args.other.length !== 1)) {
143
+ this.die("--syshook requires exactly one sync directory.");
144
+ }
145
+ if (this.args.down_cmd && (typeof(this.args.down_cmd) !== 'string')) {
146
+ this.die("--down_cmd must be a shell command.");
147
+ }
148
+
149
+ var dir = Path.resolve(this.args.other[0]);
150
+ var stat = null;
151
+ try { stat = fs.statSync(dir); }
152
+ catch (err) { this.die("Sync directory is not accessible: " + dir + ": " + err.message); }
153
+ if (!stat.isDirectory()) this.die("Sync path is not a directory: " + dir);
154
+ this.args.other = [dir]; // completion commands run from this directory
155
+ this.sconfig = Tools.mergeHashes(this.config.sync || {}, this.args);
156
+ this.warnings = [];
157
+ this.errors = [];
158
+
159
+ var payload = null;
160
+ try { payload = await readHookPayload(); }
161
+ catch (err) { this.die(err.message); }
162
+ if (!payload || (typeof(payload) !== 'object') || Array.isArray(payload) ||
163
+ (payload.xy !== 1) || (payload.action !== action)) {
164
+ this.die("System Hook activity does not match --syshook " + action + ".");
165
+ }
166
+ var object = payload[type];
167
+ if (!object || (typeof(object) !== 'object') || Array.isArray(object) ||
168
+ (typeof(object.id) !== 'string') || !object.id.match(/^[a-z0-9_]+$/)) {
169
+ this.die("System Hook activity is missing a valid " + type + " ID.");
170
+ }
171
+
172
+ // Identify the single local source by its stored type and ID, never by
173
+ // title or filename. Read the entire local inventory before changing it.
174
+ var files = [];
175
+ var sources = [];
176
+ try {
177
+ files = listLocalFiles(dir);
178
+ sources = scanSources(files);
179
+ }
180
+ catch (err) { this.die("Failed to scan sync directory: " + err.message); }
181
+ var matches = sources.filter( source => source.item && (source.item.type === type) &&
182
+ source.item.data && (source.item.data.id === object.id) );
183
+ if (matches.length > 1) this.die("Duplicate XYPDF sources for " + type + " ID " + object.id + ".");
184
+ if (!matches.length) {
185
+ println("No local XYPDF source found for " + type + " ID " + object.id + ".");
186
+ return;
187
+ }
188
+ var source = matches[0];
189
+ if (source.xypdf.version !== '1.0') this.die("Cannot delete incompatible XYPDF source: " + source.file);
190
+
191
+ var neighbors = [];
192
+ try { neighbors = findOwnedNeighbors(this, files, sources, source); }
193
+ catch (err) { this.die(err.message); }
194
+ var targets = [...neighbors, source.file]; // keep JSON last for safe retries
195
+
196
+ for (const file of targets) {
197
+ println((this.dry ? "Would delete: " : "Deleting: ") + file);
198
+ if (this.dry) continue;
199
+ try { fs.unlinkSync(file); }
200
+ catch (err) { this.die("Failed to delete local file: " + file + ": " + err.message); }
201
+ }
202
+
203
+ // A dry run and a missing local source never trigger Git commands. Run
204
+ // only the explicitly supplied down_cmd after a real local deletion.
205
+ if (!this.dry && this.args.down_cmd) this.syncFinishCmd(this.args.down_cmd);
206
+ }
207
+ };
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
@@ -48,6 +52,7 @@ module.exports = {
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
  }
@@ -71,10 +76,10 @@ module.exports = {
71
76
  if (sconfig.delete && !Tools.includesAll(supported_lists, sconfig.delete)) this.die("Invalid sync.delete list: " + sconfig.delete.join(', '));
72
77
  if (sconfig.delete && !sconfig.delete.length) sconfig.delete = false;
73
78
 
74
- // Deletion requires up-only sync, including directions inherited from config.
75
- // Validate before scanning or applying any changes, even for a dry run.
76
- if (sconfig.delete && (!sconfig.up || sconfig.down)) {
77
- this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
79
+ // Deletion follows the sole enabled direction. Validate settings inherited
80
+ // from config as well as command-line args before scanning or writing.
81
+ if (sconfig.delete && (!!sconfig.up === !!sconfig.down)) {
82
+ this.die("Delete mode requires exactly one sync direction: --up or --down.");
78
83
  }
79
84
 
80
85
  // Bucket content needs the replacement API and data timestamps introduced
@@ -137,6 +142,8 @@ module.exports = {
137
142
 
138
143
  // load all xypdf files
139
144
  var items = [];
145
+ var local_delete_items = [];
146
+ var seen_sources = new Map();
140
147
 
141
148
  files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
142
149
  // file: { path, size, mtime }
@@ -211,16 +218,14 @@ module.exports = {
211
218
  return;
212
219
  }
213
220
 
214
- var xy_item = Tools.findObject(list, { id: item.data.id });
215
- if (!xy_item) {
216
- this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
217
- return;
218
- }
219
-
220
- if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
221
+ // Check duplicate identities even when their remote definition is
222
+ // missing, so --ignore_missing only relaxes the missing-ID check.
223
+ var source_key = item.type + '\0' + item.data.id;
224
+ if (seen_sources.has(source_key)) {
221
225
  this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
222
226
  return;
223
227
  }
228
+ seen_sources.set(source_key, file.path);
224
229
 
225
230
  // locate any external neighbor prop files
226
231
  var neighbor_error = false;
@@ -282,19 +287,38 @@ module.exports = {
282
287
  // include neighbor metadata for downsync
283
288
  if (!item.neighbors) item.neighbors = {};
284
289
  item.neighbors[ prop_path ] = neighbor.path;
290
+ if (!item.delete_neighbors) item.delete_neighbors = [];
291
+ item.delete_neighbors.push(neighbor.path);
285
292
  } ); // foreach neighbor
286
293
 
287
294
  // do not include item if neighbor loading or validation failed
288
295
  if (neighbor_error) return;
289
296
 
290
- // include effective mtime in item for two-way sync
291
- item.mtime = file.mtime;
292
-
293
- // include list name for convenience below
294
297
  item.list = type_def.list;
295
-
296
- // include file for downsync
297
298
  item.file = file.path;
299
+ var xy_item = Tools.findObject(list, { id: item.data.id });
300
+ if (!xy_item) {
301
+ // In a down-only delete run, xyOps owns this definition type.
302
+ // Queue its local files for the final destructive pass, after every
303
+ // source and neighbor has passed the normal scan validation.
304
+ if (sconfig.delete && sconfig.down && sconfig.delete.includes(item.list)) {
305
+ local_delete_items.push(item);
306
+ return;
307
+ }
308
+
309
+ // An opted-in Git sync can briefly retain a source after its xyOps
310
+ // definition is deleted. Skip only that source and keep scanning.
311
+ if (ignore_missing) {
312
+ println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
313
+ return;
314
+ }
315
+
316
+ this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
317
+ return;
318
+ }
319
+
320
+ // include effective mtime in item for two-way sync
321
+ item.mtime = file.mtime;
298
322
 
299
323
  // add to sync list
300
324
  items.push(item);
@@ -396,7 +420,12 @@ module.exports = {
396
420
  // now handle deletes
397
421
  if (!sconfig.delete) return await this.completeSync();
398
422
 
399
- // (deletes always happen upward, i.e. in xyops)
423
+ if (sconfig.down) {
424
+ this.deleteSyncLocalItems(local_delete_items);
425
+ return await this.completeSync();
426
+ }
427
+
428
+ // Up-only deletion removes remote definitions absent from the local tree.
400
429
  var list_data_types = [];
401
430
  for (var id in this.config.ui.data_types) {
402
431
  var type_def = this.config.ui.data_types[id];
@@ -433,13 +462,105 @@ module.exports = {
433
462
  await this.completeSync();
434
463
  },
435
464
 
465
+ deleteSyncLocalItems(items) {
466
+ // Validate every selected local deletion before removing any files. The
467
+ // normal source scan has already checked XYPDF identity and neighboring
468
+ // property paths; recheck the source and file types in case a checkout or
469
+ // another process changed the tree while this sync was running.
470
+ var plans = [];
471
+ for (const item of items) {
472
+ // Match the protection used by upward deletion for bundled or
473
+ // Marketplace definitions. A local source may retain these markers
474
+ // even after its remote record has disappeared.
475
+ if (Object.hasOwn(item.data, 'stock') || Object.hasOwn(item.data, 'marketplace')) continue;
476
+
477
+ var bucket_plan = null;
478
+ try {
479
+ if (item.type === 'bucket') bucket_plan = this.getSyncBucketLocalDeletePlan(item);
480
+ }
481
+ catch (err) {
482
+ this.logSyncError("Local delete preflight failed: " + err.message);
483
+ return;
484
+ }
485
+ var targets = [...new Set(item.delete_neighbors || []), ...(bucket_plan ? bucket_plan.files : []), item.file];
486
+ var dirs = bucket_plan ? bucket_plan.dirs : [];
487
+ try {
488
+ for (const file of targets) {
489
+ var stat = fs.lstatSync(file);
490
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Local deletion target is not a regular file: " + file);
491
+ }
492
+ for (const dir of dirs) {
493
+ var stat = fs.lstatSync(dir);
494
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Local deletion target is not a regular directory: " + dir);
495
+ }
496
+ var source = Tools.parseJSON(fs.readFileSync(item.file, 'utf8'));
497
+ var source_item = source && Array.isArray(source.items) && (source.items.length === 1) ? source.items[0] : null;
498
+ if (!source || (source.type !== 'xypdf') || (source.version !== '1.0') || !source_item ||
499
+ (source_item.type !== item.type) || !source_item.data || (source_item.data.id !== item.data.id)) {
500
+ throw new Error("Local XYPDF source changed during sync: " + item.file);
501
+ }
502
+ }
503
+ catch (err) {
504
+ this.logSyncError("Local delete preflight failed: " + err.message);
505
+ return;
506
+ }
507
+ plans.push({ item, targets, dirs });
508
+ }
509
+
510
+ for (const plan of plans) {
511
+ println( "\n " + cli.emoji('🗑️') + ' ' + red.bold(this.dry ? "Would delete local " : "Deleting local ") +
512
+ red.bold(plan.item.type) + ": " + red.bold(plan.item.data.title || plan.item.data.id) +
513
+ gray(" (" + plan.item.data.id + ")") );
514
+ // Keep the XYPDF JSON source last so a partial filesystem failure
515
+ // leaves the identity available for a later retry.
516
+ for (const file of plan.targets.slice(0, -1)) {
517
+ println(" " + file);
518
+ if (this.dry) continue;
519
+ try {
520
+ fs.unlinkSync(file);
521
+ this.downSynced = true;
522
+ }
523
+ catch (err) {
524
+ this.logSyncError("Failed to delete local file: " + file + ": " + err.message);
525
+ return;
526
+ }
527
+ }
528
+ for (const dir of plan.dirs) {
529
+ println(" " + dir + Path.sep);
530
+ if (this.dry) continue;
531
+ try {
532
+ fs.rmdirSync(dir);
533
+ this.downSynced = true;
534
+ }
535
+ catch (err) {
536
+ this.logSyncError("Failed to delete local directory: " + dir + ": " + err.message);
537
+ return;
538
+ }
539
+ }
540
+ var source_file = plan.targets[plan.targets.length - 1];
541
+ println(" " + source_file);
542
+ if (this.dry) continue;
543
+ try {
544
+ fs.unlinkSync(source_file);
545
+ this.downSynced = true;
546
+ }
547
+ catch (err) {
548
+ this.logSyncError("Failed to delete local file: " + source_file + ": " + err.message);
549
+ return;
550
+ }
551
+ }
552
+ },
553
+
436
554
  getSyncLockFile() {
437
555
  // Allow a fixed path for service installations. Otherwise, serialize all
438
- // local syncs targeting the exact same configured xyOps base URL.
556
+ // local syncs targeting the exact same configured xyOps base URL. Local
557
+ // System Hook deletions need no server config, so share a fixed lock key
558
+ // when no base URL is available.
439
559
  var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
440
560
  if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
441
561
 
442
- var digest = Tools.digestHex( this.config.base_url, 'sha256', 16);
562
+ var base_url = this.config.base_url || (('syshook' in this.args) ? 'syshook-local' : '');
563
+ var digest = Tools.digestHex( base_url, 'sha256', 16);
443
564
  return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
444
565
  },
445
566
 
@@ -824,8 +945,9 @@ module.exports = {
824
945
  }
825
946
  if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
826
947
 
948
+ var ignore_missing = !!this.args.ignore_missing;
827
949
  var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
828
- var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
950
+ var existing_keys = new_only ? this.scanSyncSourceIndex(['.'], { ignore_missing }) : {};
829
951
 
830
952
  this.printBoxList({
831
953
  title: 'Sync Setup',
@@ -889,7 +1011,7 @@ module.exports = {
889
1011
  return ext;
890
1012
  },
891
1013
 
892
- scanSyncSourceIndex(base_dirs) {
1014
+ scanSyncSourceIndex(base_dirs, opts = {}) {
893
1015
  // Build an identity index from an existing sync tree. Filenames and folders
894
1016
  // are deliberately ignored, because sync sources may be freely reorganized.
895
1017
  var files = [];
@@ -900,6 +1022,7 @@ module.exports = {
900
1022
  });
901
1023
 
902
1024
  var index = {};
1025
+ var seen_sources = new Map();
903
1026
  var bucket_extra_dirs = [];
904
1027
  files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
905
1028
  try {
@@ -941,14 +1064,26 @@ module.exports = {
941
1064
  if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
942
1065
  var list = this[type_def.list];
943
1066
  if (!list) this.die("Cannot locate internal list: " + type_def.list);
1067
+
1068
+ // Keep duplicate detection independent of remote existence, including
1069
+ // for sources intentionally skipped with --ignore_missing.
1070
+ var key = item.type + '-' + item.data.id;
1071
+ if (seen_sources.has(key)) {
1072
+ this.die("Duplicate source item found: " + file.path + " (already found in " + seen_sources.get(key) + ")");
1073
+ }
1074
+ seen_sources.set(key, file.path);
1075
+
944
1076
  if (!Tools.findObject(list, { id: item.data.id })) {
1077
+ // New-only setup needs the same grace period as ordinary sync.
1078
+ // A missing local source does not represent a new remote item.
1079
+ if (opts.ignore_missing) {
1080
+ println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
1081
+ return;
1082
+ }
1083
+
945
1084
  this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
946
1085
  }
947
1086
 
948
- var key = item.type + '-' + item.data.id;
949
- if (index[key]) {
950
- this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
951
- }
952
1087
  index[key] = { file: file.path, item: item };
953
1088
  });
954
1089
 
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
 
@@ -1265,7 +1265,11 @@ module.exports = {
1265
1265
  // cli.box performs word wrapping at this content width and never ellipsizes.
1266
1266
  var available_width = Math.max( 1, (cli.width() || 80) - 7 );
1267
1267
  var max_width = Math.min( available_width, cli.stringWidth(final_text) );
1268
- 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);
1269
1273
  },
1270
1274
 
1271
1275
  printMutationSummary(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
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-buckets-unit.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
@@ -66,10 +66,10 @@ node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.te
66
66
  | `secrets` | Vault metadata, assignments, redacted previews, full field replacement, confirmed decryption, deletion, validation, help |
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
- | `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 |
69
+ | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, up-only deletion, and down-only Event and Plugin file deletion |
70
+ | `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download, timestamp alignment, and down-only local file and whole-Bucket deletion on xyOps 1.1.2 or later |
71
71
  | `sync-buckets-unit` | Offline Bucket version gating, content setup, upload and download, timestamp alignment, and pre-delete safety |
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 |
72
+ | `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, one-way 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 |
73
73
  | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
74
74
  | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
75
75
  | `toast` | Warning-box word wrapping and terminal-width limits |
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
+ });