@pixlcore/xycli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +543 -0
  2. package/docs/help.md +2587 -0
  3. package/docs/screenshots.md +219 -0
  4. package/docs/sync.md +920 -0
  5. package/docs/tutorial.md +204 -0
  6. package/index.js +444 -0
  7. package/lib/alerts.js +735 -0
  8. package/lib/apikey.js +425 -0
  9. package/lib/buckets.js +607 -0
  10. package/lib/categories.js +323 -0
  11. package/lib/channels.js +290 -0
  12. package/lib/config.js +86 -0
  13. package/lib/dashboard.js +237 -0
  14. package/lib/doc.js +131 -0
  15. package/lib/events.js +1314 -0
  16. package/lib/groups.js +1038 -0
  17. package/lib/jobs.js +1254 -0
  18. package/lib/log.js +125 -0
  19. package/lib/marketplace.js +386 -0
  20. package/lib/monitors.js +412 -0
  21. package/lib/plugins.js +491 -0
  22. package/lib/secrets.js +441 -0
  23. package/lib/servers.js +1502 -0
  24. package/lib/snapshots.js +498 -0
  25. package/lib/sync.js +963 -0
  26. package/lib/system.js +612 -0
  27. package/lib/tags.js +250 -0
  28. package/lib/tickets.js +681 -0
  29. package/lib/transfer.js +447 -0
  30. package/lib/utils.js +2745 -0
  31. package/lib/webhook.js +414 -0
  32. package/logo.webp +0 -0
  33. package/package.json +38 -0
  34. package/test/README.md +97 -0
  35. package/test/api-validation.test.js +170 -0
  36. package/test/categories.test.js +246 -0
  37. package/test/channels.test.js +209 -0
  38. package/test/config.test.js +96 -0
  39. package/test/doc.test.js +84 -0
  40. package/test/groups.test.js +211 -0
  41. package/test/help.test.js +26 -0
  42. package/test/helpers/common.js +127 -0
  43. package/test/helpers/pagination-preload.cjs +18 -0
  44. package/test/log.test.js +103 -0
  45. package/test/markdown.test.js +61 -0
  46. package/test/marketplace.test.js +229 -0
  47. package/test/monitors.test.js +227 -0
  48. package/test/pagination.test.js +138 -0
  49. package/test/plugins.test.js +351 -0
  50. package/test/secrets.test.js +271 -0
  51. package/test/servers.test.js +303 -0
  52. package/test/snapshots.test.js +167 -0
  53. package/test/sync-unit.test.js +446 -0
  54. package/test/sync.test.js +542 -0
  55. package/test/system.test.js +94 -0
  56. package/test/tags.test.js +230 -0
  57. package/test/tickets.test.js +261 -0
  58. package/test/toast.test.js +73 -0
  59. package/test/transfer-apikey.test.js +96 -0
  60. package/test/transfer-workflow.test.js +122 -0
  61. package/test/transfer.test.js +314 -0
  62. package/test/webhook.test.js +332 -0
  63. package/test.js +49 -0
package/lib/webhook.js ADDED
@@ -0,0 +1,414 @@
1
+ // Web Hook Management Layer
2
+
3
+ const cli = require('pixl-cli');
4
+ const Tools = cli.Tools;
5
+
6
+ const WEB_HOOK_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'];
7
+ const WEB_HOOK_FIELDS = [
8
+ 'title', 'enabled', 'icon', 'url', 'method', 'headers', 'header', 'body',
9
+ 'timeout', 'retries', 'follow', 'ssl_cert_bypass', 'max_per_day', 'notes'
10
+ ];
11
+
12
+ module.exports = {
13
+
14
+ async cmd_hooks() {
15
+ // Short user-friendly alias for the canonical webhooks command.
16
+ await this.cmd_webhooks();
17
+ },
18
+
19
+ async cmd_hook() {
20
+ // Short user-friendly alias for the canonical webhook command.
21
+ await this.cmd_webhook();
22
+ },
23
+
24
+ async cmd_webhooks() {
25
+ await this.cmd_get_webhooks();
26
+ },
27
+
28
+ async cmd_webhook() {
29
+ // Route Web Hook operations, or treat a bare ID or title as a detail lookup.
30
+ var cmd = this.args.other.shift();
31
+ if (!cmd) return this.dieUsage('webhook');
32
+
33
+ switch (cmd) {
34
+ case 'list': await this.cmd_get_webhooks(); break;
35
+ case 'get': await this.cmd_get_webhook(); break;
36
+ case 'create': await this.cmd_create_webhook(); break;
37
+ case 'update': await this.cmd_update_webhook(); break;
38
+ case 'test': await this.cmd_test_webhook(); break;
39
+ case 'delete': await this.cmd_delete_webhook(); break;
40
+
41
+ default:
42
+ this.args.other.unshift(cmd);
43
+ await this.cmd_get_webhook();
44
+ break;
45
+ }
46
+ },
47
+
48
+ async cmd_get_webhooks() {
49
+ // Web Hooks are included in getMultiple, so filtering and pagination can
50
+ // happen locally without extra API requests.
51
+ this.prepSearchArgs();
52
+ await this.getMultiple();
53
+
54
+ var hooks = this.web_hooks.slice(0);
55
+ var is_filtered = !!this.args.other.length;
56
+ if (is_filtered) {
57
+ var search = this.args.other.join(' ');
58
+ hooks = this.findObjectsFuzzy(hooks, {
59
+ id: search,
60
+ title: search,
61
+ url: search,
62
+ notes: search,
63
+ username: search
64
+ }, 1);
65
+ }
66
+ delete this.args.other;
67
+
68
+ if ('enabled' in this.args) {
69
+ var enabled = this.parseWebHookBoolean(this.args.enabled, 'enabled');
70
+ hooks = hooks.filter( hook => !!hook.enabled === enabled );
71
+ delete this.args.enabled;
72
+ is_filtered = true;
73
+ }
74
+ if ('method' in this.args) {
75
+ var method = String(this.args.method).toUpperCase();
76
+ hooks = hooks.filter( hook => String(hook.method).toUpperCase() === method );
77
+ delete this.args.method;
78
+ is_filtered = true;
79
+ }
80
+ if (Tools.numKeys(this.args)) {
81
+ hooks = this.findObjectsFuzzy(hooks, this.args);
82
+ is_filtered = true;
83
+ }
84
+
85
+ hooks.sort( (a, b) => String(a.title || '').toLowerCase().localeCompare(String(b.title || '').toLowerCase()) );
86
+ if (this.format.match(/json/)) return this.jsonOutput(hooks);
87
+
88
+ this.printPaginatedBoxTable({
89
+ title: is_filtered ? 'Filtered Web Hooks' : 'All Web Hooks',
90
+ header: ['Hook ID', 'Title', 'Method', 'URL', 'Status', 'Modified'],
91
+ rows: hooks.slice(this.offset, this.offset + this.limit),
92
+ list: { length: hooks.length },
93
+ offset: this.offset,
94
+ limit: this.limit
95
+ }, hook => [
96
+ this.color('theme').bold(hook.id),
97
+ bold(hook.title),
98
+ String(hook.method || '').toUpperCase(),
99
+ hook.url,
100
+ this.getNiceEnabled(hook.enabled),
101
+ this.getRelativeDateTime(hook.modified, true)
102
+ ]);
103
+
104
+ this.printSuggestedCommands({
105
+ "View Hook details": "xy hook HOOK_ID_OR_TITLE",
106
+ "Create a Hook": 'xy hook create --title "My Hook" --url https://example.com/hook',
107
+ "List disabled Hooks": "xy hooks --enabled false",
108
+ "Test a Hook": "xy hook test HOOK_ID"
109
+ });
110
+ },
111
+
112
+ async cmd_get_webhook() {
113
+ // Exact IDs take priority, while fuzzy titles are safe for read-only views.
114
+ await this.getMultiple();
115
+ var selector = this.args.other.join(' ') || this.args.id || this.args.title;
116
+ if (!selector) return this.dieUsage('webhook get');
117
+
118
+ delete this.args.other;
119
+ delete this.args.id;
120
+ delete this.args.title;
121
+ if (Tools.numKeys(this.args)) return this.die("Unsupported Web Hook get option: --" + Tools.firstKey(this.args));
122
+
123
+ var match = Tools.findObject(this.web_hooks, { id: selector }) || this.findObjectFuzzy(this.web_hooks, { title: selector });
124
+ if (!match) return this.die("Could not find Web Hook based on your criteria: " + selector);
125
+ var hook = await this.fetchWebHook(match.id);
126
+ if (this.format.match(/json/)) return this.jsonOutput(hook);
127
+
128
+ this.printBoxList({
129
+ title: 'Web Hook Summary',
130
+ rows: [
131
+ [ 'Hook ID', gray(hook.id) ],
132
+ [ 'Title', this.color('theme').bold(hook.title) ],
133
+ [ 'Status', this.getNiceEnabled(hook.enabled) ],
134
+ [ 'Icon', hook.icon || gray('(None)') ],
135
+ [ 'URL', hook.url ],
136
+ [ 'Method', String(hook.method || '').toUpperCase() ],
137
+ [ 'Timeout', hook.timeout ? Tools.getTextFromSeconds(hook.timeout, false, true) : 'Infinite' ],
138
+ [ 'Retries', Tools.commify(hook.retries || 0) ],
139
+ [ 'Follow Redirects', hook.follow ? green('Yes') : gray('No') ],
140
+ [ 'SSL Cert Bypass', hook.ssl_cert_bypass ? yellow('Yes') : gray('No') ],
141
+ [ 'Daily Cap', hook.max_per_day ? Tools.commify(hook.max_per_day) : 'Unlimited' ],
142
+ [ 'Author', hook.username || gray('(Unknown)') ],
143
+ [ 'Created', this.getNiceDateTime(hook.created, true, true) ],
144
+ [ 'Modified', this.getNiceDateTime(hook.modified, true, true) ],
145
+ [ 'Revision', hook.revision || 1 ]
146
+ ]
147
+ });
148
+
149
+ this.printBoxTable({
150
+ title: 'Web Hook Headers',
151
+ header: ['#', 'Name', 'Value'],
152
+ rows: (hook.headers || []).map( (header, idx) => [idx, header.name, header.value] )
153
+ });
154
+
155
+ // Bodies may contain JSON, form data, XML, or arbitrary multiline text.
156
+ this.printWebHookBody(hook.body);
157
+ if (hook.notes) this.printUserNotes('WEB HOOK NOTES', hook.notes);
158
+
159
+ this.printSuggestedCommands({
160
+ "Test Hook": `xy hook test ${hook.id}`,
161
+ "Export Hook": `xy hook ${hook.id} --export hook.json`,
162
+ "Update Hook": `xy hook update ${hook.id} --timeout 60`,
163
+ "Replace headers": `xy hook update ${hook.id} --headers @headers.json`,
164
+ "Delete Hook": `xy hook delete ${hook.id}`
165
+ });
166
+ },
167
+
168
+ async cmd_create_webhook() {
169
+ // Defaults mirror the web editor and provide a useful JSON request template.
170
+ if (this.args.other.length) return this.dieUsage('webhook create');
171
+ delete this.args.other;
172
+
173
+ var params = this.prepareWebHookParams({
174
+ enabled: true,
175
+ icon: '',
176
+ url: '',
177
+ method: 'POST',
178
+ headers: [
179
+ { name: 'Content-Type', value: 'application/json' },
180
+ { name: 'User-Agent', value: 'xyOps/WebHook' }
181
+ ],
182
+ body: JSON.stringify({ content: '[description]', text: '[description]' }, null, "\t"),
183
+ timeout: 30,
184
+ retries: 0,
185
+ follow: false,
186
+ ssl_cert_bypass: false,
187
+ max_per_day: 0,
188
+ notes: ''
189
+ }, this.args, true);
190
+ if (!params.title || !params.url) return this.dieUsage('webhook create');
191
+
192
+ var data = await this.callStandardAPI('createWebHook', params, { text: 'Creating Web Hook...' });
193
+ if (this.dry) return;
194
+ if (this.format.match(/json/)) return this.jsonOutput(data.web_hook);
195
+
196
+ this.toast('✅', 'green', "Successfully created Web Hook: #" + data.web_hook.id);
197
+ this.printSuggestedCommands({
198
+ "View Hook details": `xy hook ${data.web_hook.id}`,
199
+ "Test Hook": `xy hook test ${data.web_hook.id}`,
200
+ "Update Hook": `xy hook update ${data.web_hook.id} --timeout 60`,
201
+ "List all Hooks": "xy hooks"
202
+ });
203
+ },
204
+
205
+ async cmd_update_webhook() {
206
+ var id = this.consumeWebHookID();
207
+ var hook = await this.fetchWebHook(id);
208
+
209
+ this.printWebHookMutationSummary('Update Web Hook', hook);
210
+ if (!await this.confirmSyncUpdate('web_hook', hook.id, 'Web Hook')) return;
211
+ if (!Tools.numKeys(this.args)) return this.die("No updates specified for Web Hook.");
212
+ this.printUpdateData(this.args);
213
+
214
+ var params = this.prepareWebHookParams(hook, this.args, false);
215
+ params.id = id;
216
+ var data = await this.callStandardAPI('updateWebHook', params, { text: 'Updating Web Hook...' });
217
+ if (this.dry) return;
218
+ if (this.format.match(/json/)) return this.jsonOutput(data);
219
+ this.toast('✅', 'green', "Successfully updated Web Hook: #" + id);
220
+ },
221
+
222
+ async cmd_test_webhook() {
223
+ // Test starts with the saved definition, then applies temporary CLI changes.
224
+ // Nothing is persisted, but the resulting HTTP request is real unless --dry.
225
+ var id = this.consumeWebHookID();
226
+ var hook = await this.fetchWebHook(id);
227
+ this.printWebHookMutationSummary('Test Web Hook', hook);
228
+
229
+ var overrides = this.prepareWebHookParams(hook, this.args, false);
230
+ var params = {};
231
+ ['id'].concat(WEB_HOOK_FIELDS.filter( key => key != 'header' )).forEach( key => {
232
+ if (key in hook) params[key] = Tools.copyHash({ value: hook[key] }, true).value;
233
+ });
234
+ Tools.mergeHashInto(params, overrides);
235
+ params.id = id;
236
+
237
+ var data = await this.callStandardAPI('testWebHook', params, { text: 'Testing Web Hook...' });
238
+ if (this.dry) return;
239
+ if (this.format.match(/json/)) return this.jsonOutput(data.result);
240
+
241
+ var result = data.result || {};
242
+ var details = String(result.details || '').trim();
243
+ if (result.description) details = '**Result:** ' + result.description + (details ? "\n\n" + details : '');
244
+ if (!details) details = result.code ? 'The Web Hook test failed without additional details.' : 'The Web Hook test completed without additional details.';
245
+
246
+ var title = 'WEB HOOK TEST RESULTS';
247
+ println( "\n " + (result.code ? red.bold(title) : this.color('theme').bold(title)) );
248
+ println( "\n" + this.markdown(details).trimEnd() );
249
+ },
250
+
251
+ async cmd_delete_webhook() {
252
+ var id = this.consumeWebHookID();
253
+ var hook = await this.fetchWebHook(id);
254
+ this.printWebHookMutationSummary('Delete Web Hook', hook);
255
+
256
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) {
257
+ return this.die("Web Hook delete --confirm must be true or false.");
258
+ }
259
+ if (this.args.confirm !== true) {
260
+ this.toast('⚠️', 'orange', "Please confirm the Web Hook delete by adding '--confirm'.");
261
+ return;
262
+ }
263
+ delete this.args.confirm;
264
+ if (Tools.numKeys(this.args)) return this.die("Unsupported Web Hook delete option: --" + Tools.firstKey(this.args));
265
+
266
+ var data = await this.callStandardAPI('deleteWebHook', { id: id }, { text: 'Deleting Web Hook...' });
267
+ if (this.dry) return;
268
+ if (this.format.match(/json/)) return this.jsonOutput(data);
269
+ this.toast('✅', 'green', "Successfully deleted Web Hook: #" + id);
270
+ },
271
+
272
+ async fetchWebHook(id) {
273
+ // Use the single-resource endpoint for authoritative data and access checks.
274
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading Web Hook...') });
275
+ var { err, data } = await this.api.getWebHook({ id: id });
276
+ cli.progress.end();
277
+ if (err) this.die(err);
278
+ return data.web_hook;
279
+ },
280
+
281
+ consumeWebHookID() {
282
+ // Updates, tests, and deletes require one exact internal ID.
283
+ var id = this.args.other.shift() || this.args.id;
284
+ if (!id) this.die("Missing required Web Hook ID argument.");
285
+ if (this.args.other.length) this.die("Unexpected argument after Web Hook ID: " + this.args.other[0]);
286
+ if (this.args.id && (this.args.id !== id)) this.die("Conflicting Web Hook ID arguments.");
287
+ if ((typeof(id) != 'string') || !id.match(/^[a-z0-9_]+$/)) this.die("Invalid Web Hook ID: " + id);
288
+ delete this.args.id;
289
+ delete this.args.other;
290
+ return id;
291
+ },
292
+
293
+ prepareWebHookParams(hook, input, creating) {
294
+ // Whole header arrays replace the saved list. Dotted edits start from the
295
+ // current list, and singular --header options append after those edits.
296
+ var params = creating ? Tools.copyHash(hook, true) : {};
297
+ var allowed = WEB_HOOK_FIELDS.concat(creating ? ['id'] : []);
298
+ Object.keys(input).forEach( key => {
299
+ var root = key.split('.')[0];
300
+ if (!allowed.includes(root)) this.die("Unsupported Web Hook option: --" + key);
301
+ if (key.includes('.') && (root != 'headers')) this.die("Invalid argument path: " + key);
302
+ if (!creating && ['headers', 'header'].includes(root) && !('headers' in params)) {
303
+ params.headers = Tools.copyHash({ value: hook.headers || [] }, true).value;
304
+ }
305
+ });
306
+
307
+ // Apply complete values first, then dotted edits, regardless of CLI order.
308
+ Object.keys(input).filter( key => !key.includes('.') ).forEach( key => { params[key] = input[key]; } );
309
+ var dotted = {};
310
+ Object.keys(input).filter( key => key.includes('.') ).forEach( key => { dotted[key] = input[key]; } );
311
+ this.mergeDotArgs(params, dotted);
312
+
313
+ if ('header' in params) {
314
+ if (!('headers' in params)) params.headers = [];
315
+ if (!Array.isArray(params.headers)) this.die("Web Hook headers must be a JSON array.");
316
+ Tools.alwaysArray(params.header).forEach( header => {
317
+ if (typeof(header) == 'string') {
318
+ try { header = JSON.parse(header); }
319
+ catch (err) { this.die("Web Hook --header must contain a JSON object."); }
320
+ }
321
+ if (!Tools.isaHash(header)) this.die("Web Hook --header must contain a JSON object.");
322
+ params.headers.push(header);
323
+ });
324
+ delete params.header;
325
+ }
326
+ if ('headers' in params) params.headers = this.validateWebHookHeaders(params.headers);
327
+
328
+ if ('title' in params) {
329
+ if ((typeof(params.title) != 'string') || !params.title.trim()) this.die("Web Hook title cannot be empty and must be a string.");
330
+ params.title = params.title.trim();
331
+ }
332
+ if ('url' in params) {
333
+ if (typeof(params.url) != 'string') this.die("Web Hook url must be a string.");
334
+ params.url = params.url.trim();
335
+ if (!params.url.match(/^(?:https?:\/\/|\{\{)/i)) this.die("Web Hook url must begin with http://, https://, or {{.");
336
+ }
337
+ if ('method' in params) {
338
+ if (typeof(params.method) != 'string') this.die("Web Hook method must be a string.");
339
+ params.method = params.method.toUpperCase();
340
+ if (!WEB_HOOK_METHODS.includes(params.method)) this.die("Invalid Web Hook method: " + params.method);
341
+ }
342
+ ['enabled', 'follow', 'ssl_cert_bypass'].forEach( key => {
343
+ if (key in params) params[key] = this.parseWebHookBoolean(params[key], key);
344
+ });
345
+ ['timeout', 'retries', 'max_per_day'].forEach( key => {
346
+ if (!(key in params)) return;
347
+ params[key] = Number(params[key]);
348
+ if (!Number.isSafeInteger(params[key]) || (params[key] < 0)) {
349
+ this.die("Web Hook " + key + " must be a non-negative integer.");
350
+ }
351
+ });
352
+ if (('retries' in params) && (params.retries > 999)) this.die("Web Hook retries cannot exceed 999.");
353
+ if (('max_per_day' in params) && (params.max_per_day > 9999999)) this.die("Web Hook max_per_day cannot exceed 9999999.");
354
+ ['icon', 'body', 'notes'].forEach( key => {
355
+ if ((key in params) && (typeof(params[key]) != 'string')) this.die("Web Hook " + key + " must be a string.");
356
+ });
357
+ if ('icon' in params) params.icon = params.icon.replace(/^mdi\-/, '');
358
+ if (('id' in params) && ((typeof(params.id) != 'string') || !params.id.match(/^[a-z0-9_]+$/))) {
359
+ this.die("Invalid Web Hook ID: " + params.id);
360
+ }
361
+ return params;
362
+ },
363
+
364
+ validateWebHookHeaders(headers) {
365
+ if (!Array.isArray(headers)) this.die("Web Hook headers must be a JSON array.");
366
+ return headers.map( (header, idx) => {
367
+ if (!Tools.isaHash(header)) this.die("Web Hook headers." + idx + " must be a JSON object.");
368
+ var extra = Object.keys(header).find( key => !['name', 'value'].includes(key) );
369
+ if (extra) this.die("Unsupported property in Web Hook headers." + idx + ': "' + extra + '".');
370
+ if ((typeof(header.name) != 'string') || !header.name.match(/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/)) {
371
+ this.die("Invalid HTTP header name in Web Hook headers." + idx + ".");
372
+ }
373
+ if ((typeof(header.value) != 'string') || !header.value.match(/^[\t\x20-\x7E\x80-\xFF]*$/)) {
374
+ this.die("Invalid HTTP header value in Web Hook headers." + idx + ".");
375
+ }
376
+ return { name: header.name, value: header.value };
377
+ });
378
+ },
379
+
380
+ parseWebHookBoolean(value, name) {
381
+ if ([true, 1, '1', 'true', 'yes', 'on'].includes(value)) return true;
382
+ if ([false, 0, '0', 'false', 'no', 'off'].includes(value)) return false;
383
+ this.die("Invalid boolean value for '" + name + "': " + value);
384
+ },
385
+
386
+ printWebHookMutationSummary(title, hook) {
387
+ this.printMutationSummary({
388
+ title: title,
389
+ rows: [
390
+ [ 'Hook ID', gray(hook.id) ],
391
+ [ 'Title', this.color('theme').bold(hook.title) ]
392
+ ]
393
+ });
394
+ },
395
+
396
+ printWebHookBody(body) {
397
+ println( "\n " + this.color('theme').bold('WEB HOOK BODY') );
398
+ if (!body) {
399
+ println( " " + gray('(Empty)') );
400
+ return;
401
+ }
402
+
403
+ body = String(body).trim();
404
+ var language = '';
405
+ if (body.match(/^\{[\s\S]*\}$/) || body.match(/^\[[\s\S]*\]$/)) language = 'json';
406
+
407
+ var source_lines = body.split(/\r?\n/).map( line => ' ' + line ).join("\n");
408
+ var highlight_opts = { ignoreIllegals: true };
409
+ if (language) highlight_opts.language = language;
410
+
411
+ println( "\n" + this.highlight(source_lines, highlight_opts) );
412
+ }
413
+
414
+ }; // module.exports
package/logo.webp ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pixlcore/xycli",
3
+ "version": "1.0.0",
4
+ "description": "A complete command-line interface for the xyOps workflow automation system.",
5
+ "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
+ "homepage": "https://github.com/pixlcore/xycli",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "bin": {
10
+ "xy": "index.js"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/pixlcore/xycli"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/pixlcore/xycli/issues"
18
+ },
19
+ "keywords": [
20
+ "xyops", "cli"
21
+ ],
22
+ "scripts": {
23
+ "test": "node test.js"
24
+ },
25
+ "dependencies": {
26
+ "@pixlcore/xyops-sdk": "^1.0.7",
27
+ "cli-highlight": "2.1.11",
28
+ "diff": "9.0.0",
29
+ "pixl-cli": "^1.1.6",
30
+ "html-to-text": "10.0.1",
31
+ "marked": "15.0.12",
32
+ "marked-terminal": "7.3.0",
33
+ "uncatch": "^1.0.4"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/test/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # CLI Integration Tests
2
+
3
+ These tests exercise the CLI against a running xyOps development server. They use Node.js's built-in test runner and the project's existing dependencies.
4
+
5
+ ## Setup
6
+
7
+ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
8
+
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
+
11
+ Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server tests need a connected server, 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
+
13
+ ## Running Tests
14
+
15
+ Run every suite, one at a time:
16
+
17
+ ```sh
18
+ npm test
19
+ ```
20
+
21
+ The equivalent controller command is:
22
+
23
+ ```sh
24
+ node test.js
25
+ ```
26
+
27
+ Run selected suites, or list the available names:
28
+
29
+ ```sh
30
+ node test.js categories channels
31
+ npm test -- transfer-apikey
32
+ node test.js --list
33
+ ```
34
+
35
+ The controller discovers `test/*.test.js`, uses a separate process for each suite, and sets native test concurrency to one. Do not run multiple controllers against the same server at once. Some checks compare the definitions before and after a test, so avoid editing server objects while the suite runs.
36
+
37
+ You can also run one file directly and choose a native reporter:
38
+
39
+ ```sh
40
+ node --test --test-reporter=spec test/monitors.test.js
41
+ ```
42
+
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
+
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:
46
+
47
+ ```sh
48
+ node --test test/config.test.js test/sync-unit.test.js
49
+ ```
50
+
51
+ ## Coverage
52
+
53
+ | Suite | Coverage |
54
+ | --- | --- |
55
+ | `categories` | CRUD, actions and limits, JSON input, validation, pagination, help |
56
+ | `channels` | CRUD, recipients, indexed edits, filters, validation, help |
57
+ | `config` | Offline user-file updates, inherited-setting isolation, missing-file creation, dotted edits, permissions, and read-only display |
58
+ | `log` | Log searches, latest rows, matching, column selection, native output |
59
+ | `markdown` | Terminal rendering, inline formatting inside list items, typographic bullets, and output margins |
60
+ | `monitors` | CRUD, groups, evaluator requests, type conversions, validation, help |
61
+ | `marketplace` | Search, details, README rendering, install preview, confirmed install and upgrade, validation, help |
62
+ | `pagination` | Pagination arguments, search requests, command variants, resource limits |
63
+ | `plugins` | CRUD for all four Plugin types, parameters, groups, validation, help |
64
+ | `secrets` | Vault metadata, assignments, redacted previews, full field replacement, confirmed decryption, deletion, validation, help |
65
+ | `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
66
+ | `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
67
+ | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
68
+ | `sync-unit` | Offline PID locking and stale recovery, up-only deletion guards, 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 |
69
+ | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
70
+ | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
71
+ | `toast` | Warning-box word wrapping and terminal-width limits |
72
+ | `transfer` | All portable object types, preview and confirmation, dependencies, gzip, validation, partial failures |
73
+ | `transfer-apikey` | Export, deletion, import, and authentication with the original plaintext secret |
74
+ | `transfer-workflow` | Fresh workflow migration with shared dependencies |
75
+ | `webhook` | CRUD, aliases, headers, exact body input, live Markdown test reports, sparse updates, validation, help |
76
+
77
+ Pagination checks that require saved events, completed jobs, or alert history report a skip when those records are unavailable.
78
+
79
+ ## Fixtures and Cleanup
80
+
81
+ The mutating suites create uniquely named disposable objects and remove them in cleanup blocks, including after assertion failures. Test events have no active triggers, channels are never invoked, monitor sources use constants, and Plugin definitions are never executed or attached to other objects. Temporary files live in private operating-system temp directories and are removed by native test hooks.
82
+
83
+ The Marketplace suite installs and upgrades `pixlcore/xyplug-ai`, then deletes it without executing or attaching it to an Event. The suite requires this product to be uninstalled when the test begins and removes its own installation during cleanup. Ticket fixtures remain in `draft` for their entire lifecycle so assigned test users never receive email notifications.
84
+
85
+ API key tests keep plaintext secrets in memory. Export files contain the stored hash and mask, and are deleted after the test. Captured CLI output is omitted from process failure messages because it can contain credentials.
86
+
87
+ Secret Vault tests use uniquely generated disposable values. They verify sensitive values by digest wherever practical, ensure dry-run and verbose diagnostics are redacted, and delete the vault during cleanup. Confirmed decrypt checks intentionally exercise the audited API operation.
88
+
89
+ A forced process termination or an unavailable server can prevent server cleanup. If a run is interrupted, check for its uniquely named `cli_*` fixtures before rerunning. The tests never remove unrelated objects.
90
+
91
+ ## Adding a Suite
92
+
93
+ Add a CommonJS file named `test/NAME.test.js`. The controller will discover it automatically. Use `node:test` and `node:assert/strict`, and reuse `test/helpers/common.js` for configuration, CLI calls, temporary directories, and sequential lifecycle checks.
94
+
95
+ Call `loadTestConfig()` before any server request. Register temporary directory cleanup immediately, and put mutations inside a `try` block with fixture cleanup in `finally`. Await each lifecycle check so dependent operations run in order.
96
+
97
+ Keep helper modules under `test/helpers/`. The pagination preload records only method names and pagination fields at the SDK boundary; it does not record authentication or response data.