@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
@@ -0,0 +1,447 @@
1
+ // xyOps Portable Data Format (XYPDF) Import / Export
2
+ // Keep the wire format and dependency selection compatible with the web UI.
3
+
4
+ const fs = require('fs');
5
+ const Path = require('path');
6
+ const zlib = require('zlib');
7
+ const crypto = require('crypto');
8
+ const cli = require('pixl-cli');
9
+ const Tools = cli.Tools;
10
+
11
+ // One registry covers all portable types, including resources whose full CLI
12
+ // routers have not been implemented yet. API names are never taken from files.
13
+ const TYPES = {
14
+ alert: { command: 'alert', list: 'alerts', api: 'Alert' },
15
+ api_key: { command: 'key', list: 'api_keys', api: 'ApiKey' },
16
+ bucket: { command: 'bucket', list: 'buckets', api: 'Bucket' },
17
+ category: { command: 'category', list: 'categories', api: 'Category' },
18
+ channel: { command: 'channel', list: 'channels', api: 'Channel' },
19
+ event: { command: 'event', list: 'events', api: 'Event' },
20
+ group: { command: 'group', list: 'groups', api: 'Group' },
21
+ monitor: { command: 'monitor', list: 'monitors', api: 'Monitor' },
22
+ plugin: { command: 'plugin', list: 'plugins', api: 'Plugin' },
23
+ role: { command: 'role', list: 'roles', api: 'Role' },
24
+ tag: { command: 'tag', list: 'tags', api: 'Tag' },
25
+ web_hook: { command: 'webhook', aliases: ['hook'], list: 'web_hooks', api: 'WebHook' }
26
+ };
27
+ const DEPENDENCIES = ['buckets', 'categories', 'events', 'groups', 'plugins', 'tags', 'web_hooks'];
28
+ const AUDIT_FIELDS = ['created', 'modified', 'revision', 'sort_order', 'username'];
29
+
30
+ module.exports = {
31
+
32
+ async cmd_export_object(command) {
33
+ // Export is a read-only variant of a singular resource command. Handle it
34
+ // before normal routing so it cannot become a filter or a mutation field.
35
+ var type = Object.keys(TYPES).find( type => (TYPES[type].command == command) || (TYPES[type].aliases || []).includes(command) );
36
+ if (!type) return this.die("Export requires a singular object command. See 'xy help export'.");
37
+
38
+ var args = this.args;
39
+ var filename = this.requireTransferFilename(args.export);
40
+ var positionals = args.other.slice(0);
41
+
42
+ if (positionals[0] == 'get') positionals.shift();
43
+ else if (['list', 'create', 'update', 'delete', 'run', 'test', 'write', 'upload', 'download', 'empty', 'file', 'search'].includes(positionals[0])) {
44
+ return this.die("Use --export with object details, e.g. xy " + command + " OBJECT_ID --export FILE");
45
+ }
46
+
47
+ Object.keys(args).forEach( key => {
48
+ if (!['other', 'export', 'id', 'title', 'deps', 'overwrite'].includes(key)) this.die("Unsupported export option: --" + key);
49
+ });
50
+ if (('overwrite' in args) && (typeof(args.overwrite) != 'boolean')) return this.die("Export --overwrite must be true or false.");
51
+
52
+ var selectors = [positionals.join(' '), args.id, args.title].filter( value => value !== undefined && value !== '' );
53
+ if (selectors.length != 1 || typeof(selectors[0]) != 'string') return this.die("Specify one object ID or title to export.");
54
+
55
+ if (('deps' in args) && (type != 'event')) return this.die("The --deps option is only available for event and workflow exports.");
56
+ var deps = this.parseExportDependencies(args.deps);
57
+
58
+ await this.getMultiple();
59
+
60
+ var objects = this[TYPES[type].list];
61
+ var obj = Tools.findObject(objects, { id: selectors[0] }) || this.findObjectFuzzy(objects, { title: selectors[0] });
62
+ if (!obj) return this.die("Could not find " + type + " based on your criteria: " + selectors[0]);
63
+
64
+ // The in-memory lists contain the complete definitions. In particular,
65
+ // global/buckets contains metadata only, without fetching data or files.
66
+ var warnings = [];
67
+ var items = this.collectExportItems(type, obj, deps, warnings);
68
+
69
+ var payload = {
70
+ type: 'xypdf',
71
+ description: 'xyOps Portable Data Object',
72
+ version: '1.0',
73
+ xyops: this.xyopsVersion,
74
+ items: items
75
+ };
76
+
77
+ if (this.dry) {
78
+ this.jsonOutput({ file: filename, dry_run: true, warnings: warnings, payload: payload });
79
+ return;
80
+ }
81
+
82
+ var bytes = Buffer.from(JSON.stringify(payload, null, '\t') + '\n');
83
+ if (/\.gz$/i.test(filename)) bytes = zlib.gzipSync(bytes);
84
+ this.writeTransferFile(filename, bytes, args.overwrite === true);
85
+
86
+ if (this.format.match(/json/)) return this.jsonOutput({ code: 0, file: filename, count: items.length, warnings: warnings });
87
+
88
+ this.toast('✅', 'green', "Exported " + items.length + " item(s) to: " + filename);
89
+ warnings.forEach( warning => println(yellow(warning)) );
90
+ },
91
+
92
+ parseExportDependencies(value) {
93
+ // Match the UI's optional dependency groups. No dependencies by default.
94
+ if (value === undefined) return new Set();
95
+
96
+ var values = Array.isArray(value) ? value : [value];
97
+ var deps = new Set();
98
+
99
+ values.forEach( entry => {
100
+ if (typeof(entry) != 'string') this.die("Use --deps all or a comma-separated list of dependency types.");
101
+
102
+ entry.split(',').forEach( name => {
103
+ name = name.trim();
104
+
105
+ if (name == 'all') DEPENDENCIES.forEach( dep => deps.add(dep) );
106
+ else if (DEPENDENCIES.includes(name)) deps.add(name);
107
+ else this.die("Unknown export dependency: " + name + ". Choose: " + DEPENDENCIES.join(', ') + ", all");
108
+ });
109
+ });
110
+
111
+ return deps;
112
+ },
113
+
114
+ collectExportItems(type, obj, deps, warnings) {
115
+ // Mark each item before descending, so shared dependencies and workflow
116
+ // cycles cannot duplicate items or recurse indefinitely. Root stays first.
117
+ var items = [];
118
+ var visited = new Set();
119
+ var missing = new Set();
120
+
121
+ var include = (type, id, skip_stock) => {
122
+ if (!id) return;
123
+ if ((type == 'category' && id == 'general') || (type == 'web_hook' && id == 'example_hook')) return;
124
+
125
+ var data = Tools.findObject(this[TYPES[type].list], { id: id });
126
+ if (!data) {
127
+ var key = type + ':' + id;
128
+ if (!missing.has(key)) warnings.push("Dependency not found, omitted: " + key);
129
+ missing.add(key);
130
+ return;
131
+ }
132
+
133
+ if (skip_stock && data.stock) return;
134
+ visit(type, data);
135
+ };
136
+
137
+ var action = data => {
138
+ if (!data) return;
139
+
140
+ if (data.type == 'plugin' && deps.has('plugins')) include('plugin', data.plugin_id);
141
+ if (data.type == 'web_hook' && deps.has('web_hooks')) include('web_hook', data.web_hook);
142
+ if (['store', 'fetch'].includes(data.type) && deps.has('buckets')) include('bucket', data.bucket_id);
143
+ if (data.type == 'tag' && deps.has('tags')) include('tag', data.tag_id);
144
+ };
145
+
146
+ var visit = (type, data) => {
147
+ var key = type + ':' + data.id;
148
+ if (visited.has(key)) return;
149
+
150
+ visited.add(key);
151
+ items.push({ type: type, data: this.cleanTransferData(data) });
152
+
153
+ if (type != 'event') return;
154
+
155
+ if (data.workflow) {
156
+ (data.workflow.nodes || []).forEach( node => {
157
+ var value = node.data || {};
158
+
159
+ if (node.type == 'event' && deps.has('events')) include('event', value.event);
160
+ if (node.type == 'job' && deps.has('plugins')) include('plugin', value.plugin, true);
161
+ if (node.type == 'action') action(value);
162
+ });
163
+ }
164
+ else if (deps.has('plugins')) include('plugin', data.plugin, true);
165
+
166
+ if (deps.has('categories')) include('category', data.category);
167
+
168
+ if (deps.has('groups')) (data.targets || []).forEach( id => {
169
+ // A target may instead be a server ID, which is not portable.
170
+ if (Tools.findObject(this.groups, { id: id })) include('group', id);
171
+ });
172
+
173
+ if (deps.has('tags')) (data.tags || []).forEach( id => include('tag', id) );
174
+
175
+ if (deps.has('plugins')) (data.triggers || []).forEach( trigger => {
176
+ if (trigger.type == 'plugin') include('plugin', trigger.plugin_id);
177
+ });
178
+
179
+ (data.actions || []).forEach(action);
180
+ };
181
+
182
+ visit(type, obj);
183
+ return items;
184
+ },
185
+
186
+ cleanTransferData(data) {
187
+ // Strip server-managed metadata without mutating cached or parsed objects.
188
+ var clean = JSON.parse(JSON.stringify(data));
189
+ AUDIT_FIELDS.forEach( key => delete clean[key] );
190
+ if (clean.id === '') delete clean.id;
191
+
192
+ return clean;
193
+ },
194
+
195
+ requireTransferFilename(value) {
196
+ if (typeof(value) != 'string' || !value.trim() || value.includes('\0')) this.die("Please specify a file path.");
197
+
198
+ return Path.resolve(value);
199
+ },
200
+
201
+ writeTransferFile(filename, bytes, overwrite) {
202
+ // Write privately in the destination directory, then install the complete
203
+ // file atomically. A hard link gives no-clobber behavior without a race.
204
+ var temp = Path.join(Path.dirname(filename), '.xycli-export-' + crypto.randomBytes(12).toString('hex'));
205
+
206
+ try {
207
+ fs.writeFileSync(temp, bytes, { flag: 'wx', mode: 0o600 });
208
+
209
+ if (overwrite) fs.renameSync(temp, filename);
210
+ else fs.linkSync(temp, filename);
211
+ }
212
+ catch (err) {
213
+ // Throw so the cleanup runs before the CLI's fatal error handler exits.
214
+ if (err.code == 'EEXIST') throw new Error("Export file already exists. Add --overwrite to replace it: " + filename);
215
+ throw new Error("Failed to export file: " + err.message);
216
+ }
217
+ finally {
218
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
219
+ }
220
+ },
221
+
222
+ async cmd_import() {
223
+ // Parse and validate the whole file before planning any API calls.
224
+ if (this.args.other.length != 1) return this.dieUsage('import');
225
+ var filename = this.requireTransferFilename(this.args.other[0]);
226
+ Object.keys(this.args).forEach( key => {
227
+ if (!['other', 'confirm'].includes(key)) this.die("Unsupported import option: --" + key);
228
+ });
229
+ if (('confirm' in this.args) && typeof(this.args.confirm) != 'boolean') return this.die("Import --confirm must be true or false.");
230
+
231
+ var payload;
232
+ try {
233
+ var bytes = fs.readFileSync(filename);
234
+
235
+ // Accept gzip by signature as well as extension, including UI .json.gz.
236
+ if (/\.gz$/i.test(filename) || (bytes[0] == 0x1f && bytes[1] == 0x8b)) bytes = zlib.gunzipSync(bytes);
237
+
238
+ payload = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, ''));
239
+ }
240
+ catch (err) { return this.die("Failed to read XYPDF file: " + err.message); }
241
+
242
+ this.validateTransferPayload(payload);
243
+ await this.getMultiple();
244
+
245
+ var plan = payload.items.map( item => {
246
+ var data = this.cleanTransferData(item.data);
247
+ var existing = data.id && Tools.findObject(this[TYPES[item.type].list], { id: data.id });
248
+ var operation = existing ? 'update' : 'create';
249
+
250
+ return {
251
+ type: item.type,
252
+ operation: operation,
253
+ method: operation + TYPES[item.type].api,
254
+ data: data
255
+ };
256
+ }).reverse(); // The UI imports dependencies from the end of the file first.
257
+ plan = this.orderTransferPlan(plan);
258
+
259
+ var warnings = [];
260
+ if (plan.some( item => item.operation == 'update' )) warnings.push('Existing IDs will be updated with the imported fields.');
261
+
262
+ if (plan.some( item => item.type == 'event' && (item.data.triggers || []).some( trigger => trigger.enabled && /^(schedule|interval|single|startup|keyboard)$/.test(trigger.type) ) )) {
263
+ warnings.push('Imported events contain active triggers and may run automatically.');
264
+ }
265
+
266
+ if (this.dry || this.args.confirm !== true) {
267
+ this.jsonOutput({ file: filename, preview: true, warnings: warnings, items: plan });
268
+
269
+ if (!this.format.match(/json/)) {
270
+ this.toast('⚠️', 'orange', "Preview only. Review the data above, then use --confirm to apply the import.");
271
+ this.printSuggestedCommands({ "Review this import": "xy import " + this.quoteTransferFilename(filename) });
272
+ }
273
+
274
+ return;
275
+ }
276
+
277
+ // APIs are not transactional. Retain every completed result even if a
278
+ // later item fails, and stop at the first error.
279
+ var results = plan.map( item => ({
280
+ type: item.type,
281
+ id: item.data.id || '',
282
+ title: item.data.title,
283
+ operation: item.operation,
284
+ status: 'pending'
285
+ }) );
286
+ var error = null;
287
+
288
+ for (var idx = 0; idx < plan.length; idx++) {
289
+ var item = plan[idx];
290
+ var result = results[idx];
291
+
292
+ try {
293
+ if (this.verbose) this.jsonOutput({ method: item.method, data: item.data });
294
+
295
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Importing ' + item.type + ': ' + item.data.title) });
296
+ var response = await this.api[item.method](item.data);
297
+ if (response.err) throw response.err;
298
+
299
+ var data = response.data;
300
+ result.id = (data[item.type] && data[item.type].id) || data.id || item.data.id;
301
+ result.status = item.operation == 'create' ? 'created' : 'updated';
302
+ }
303
+ catch (err) {
304
+ error = result.error = err.message || String(err);
305
+ if (result.status == 'pending') result.status = 'failed';
306
+ break;
307
+ }
308
+ finally { cli.progress.end(); }
309
+ }
310
+
311
+ var report = { code: error ? 1 : 0, file: filename, warnings: warnings, items: results };
312
+ if (error) {
313
+ report.error = error;
314
+ process.exitCode = 1;
315
+ }
316
+
317
+ if (this.format.match(/json/)) return this.jsonOutput(report);
318
+
319
+ warnings.forEach( warning => println(yellow(warning)) );
320
+
321
+ this.printBoxTable({
322
+ title: 'Import Results',
323
+ header: ['Type', 'ID', 'Title', 'Result'],
324
+ rows: results.map( item => [item.type, item.id || '(New)', item.title, item.status] )
325
+ });
326
+
327
+ if (error) println("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
328
+ else this.printSuggestedCommands({
329
+ "List events": "xy events",
330
+ "List categories": "xy categories",
331
+ "List monitors": "xy monitors"
332
+ });
333
+ },
334
+
335
+ orderTransferPlan(plan) {
336
+ // Reversing the UI's root-first file usually puts dependencies first, but
337
+ // shared nested workflows can break that order. Visit new prerequisites
338
+ // before their consumers, regardless of where they appear in the file.
339
+ var byID = new Map(plan.filter( item => item.data.id ).map( item => [item.type + ':' + item.data.id, item] ));
340
+ var visited = new Set(), visiting = new Set(), ordered = [];
341
+
342
+ var visit = item => {
343
+ if (visited.has(item)) return;
344
+ if (visiting.has(item)) this.die("Circular dependency among new import objects: " + item.type + ':' + item.data.id);
345
+
346
+ visiting.add(item);
347
+
348
+ var need = (type, id) => {
349
+ var dependency = id && byID.get(type + ':' + id);
350
+
351
+ // Existing definitions already satisfy API reference checks.
352
+ if (dependency && dependency.operation == 'create') visit(dependency);
353
+ };
354
+
355
+ var each = (list, callback) => {
356
+ if (Array.isArray(list)) list.forEach(callback);
357
+ };
358
+
359
+ var action = action => {
360
+ if (!action) return;
361
+
362
+ if (action.type == 'plugin') need('plugin', action.plugin_id);
363
+ if (action.type == 'web_hook') need('web_hook', action.web_hook);
364
+ if (action.type == 'channel') need('channel', action.channel_id);
365
+ if (action.type == 'run_event') need('event', action.event_id);
366
+ if (action.type == 'tag') need('tag', action.tag_id);
367
+ if (['store', 'fetch'].includes(action.type)) need('bucket', action.bucket_id);
368
+ };
369
+
370
+ var data = item.data;
371
+ each(data.groups, id => need('group', id));
372
+ each(data.roles, id => need('role', id));
373
+ each(data.categories, id => need('category', id));
374
+ each(data.actions, action);
375
+ each(data.alert_actions, action);
376
+
377
+ if (item.type == 'event') {
378
+ need('category', data.category);
379
+ need('plugin', data.plugin);
380
+ each(data.targets, id => need('group', id));
381
+ each(data.tags, id => need('tag', id));
382
+
383
+ each(data.triggers, trigger => {
384
+ if (trigger && trigger.type == 'plugin') need('plugin', trigger.plugin_id);
385
+ });
386
+
387
+ if (data.workflow) each(data.workflow.nodes, node => {
388
+ var value = node.data || {};
389
+
390
+ if (node.type == 'event') need('event', value.event);
391
+ if (node.type == 'job') need('plugin', value.plugin);
392
+ if (node.type == 'action') action(value);
393
+ });
394
+ }
395
+
396
+ visiting.delete(item);
397
+ visited.add(item);
398
+ ordered.push(item);
399
+ };
400
+
401
+ plan.forEach(visit);
402
+ return ordered;
403
+ },
404
+
405
+ validateTransferPayload(payload) {
406
+ // XYPDF v1.1 added xyops, but the wire version is still "1.0", as in the UI.
407
+ if (!payload || payload.type !== 'xypdf' || payload.version !== '1.0' || !Array.isArray(payload.items) || !payload.items.length) {
408
+ this.die("Unknown format: Expected an XYPDF version 1.0 file with a non-empty items array.");
409
+ }
410
+
411
+ if (payload.xyops !== undefined && this.compareVersions(payload.xyops, this.xyopsVersion) > 0) this.die("This import requires xyOps v" + payload.xyops + " or higher.");
412
+
413
+ var seen = new Set();
414
+
415
+ var checkKeys = obj => {
416
+ if (!obj || typeof(obj) != 'object') return;
417
+
418
+ Object.keys(obj).forEach( key => {
419
+ if (['__proto__', 'prototype', 'constructor'].includes(key)) this.die("Unsafe property in import data: " + key);
420
+ checkKeys(obj[key]);
421
+ });
422
+ };
423
+
424
+ payload.items.forEach( (item, idx) => {
425
+ if (!item || !Object.hasOwn(TYPES, item.type)) this.die("Unknown XYPDF object type at item " + (idx + 1));
426
+
427
+ var data = item.data;
428
+ if (!data || typeof(data) != 'object' || Array.isArray(data)) this.die("Invalid object data at item " + (idx + 1));
429
+ if (typeof(data.title) != 'string' || !data.title.trim()) this.die("Missing title at item " + (idx + 1));
430
+ if (data.id !== undefined && data.id !== '' && (typeof(data.id) != 'string' || !/^[a-z0-9_]+$/.test(data.id))) this.die("Invalid object ID at item " + (idx + 1));
431
+
432
+ if (data.id) {
433
+ var key = item.type + ':' + data.id;
434
+ if (seen.has(key)) this.die("Duplicate object in import file: " + key);
435
+ seen.add(key);
436
+ }
437
+
438
+ checkKeys(data);
439
+ });
440
+ },
441
+
442
+ quoteTransferFilename(filename) {
443
+ // Single-quote suggested shell arguments, including embedded apostrophes.
444
+ return "'" + filename.replace(/'/g, "'\\''") + "'";
445
+ }
446
+
447
+ };