@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/apikey.js ADDED
@@ -0,0 +1,425 @@
1
+ // API Key Layer
2
+
3
+ const cli = require('pixl-cli');
4
+ const Tools = cli.Tools;
5
+
6
+ module.exports = {
7
+
8
+ async cmd_keys() {
9
+ // Collection alias for listing and filtering API Keys.
10
+ await this.cmd_get_api_keys();
11
+ },
12
+
13
+ async cmd_key() {
14
+ // Singular router, e.g. `xy key create` or `xy key update KEY_ID`.
15
+ var cmd = this.args.other.shift();
16
+ if (!cmd) return this.dieUsage('key');
17
+ switch (cmd) {
18
+ case 'list': await this.cmd_get_api_keys(); break;
19
+ case 'get': await this.cmd_get_api_key(); break;
20
+ case 'create': await this.cmd_create_api_key(); break;
21
+ case 'update': await this.cmd_update_api_key(); break;
22
+ case 'delete': await this.cmd_delete_api_key(); break;
23
+
24
+ default:
25
+ // A bare ID or title opens key details, matching `xy event ID_OR_TITLE`.
26
+ this.args.other.unshift(cmd);
27
+ await this.cmd_get_api_key();
28
+ break;
29
+ }
30
+ },
31
+
32
+ async cmd_get_api_keys() {
33
+ // List API Keys with optional fuzzy filters. Secret hashes are stripped.
34
+ this.prepSearchArgs();
35
+ await this.getMultiple();
36
+
37
+ var api_keys = this.api_keys.map( this.sanitizeAPIKey.bind(this) );
38
+ var is_filtered = false;
39
+
40
+ if (this.args.other.length) {
41
+ // Search the safe, human-facing fields only.
42
+ var search = this.args.other.join(' ');
43
+ api_keys = this.findObjectsFuzzy(api_keys, {
44
+ id: search,
45
+ title: search,
46
+ description: search,
47
+ mask: search
48
+ }, 1);
49
+ is_filtered = true;
50
+ }
51
+ delete this.args.other;
52
+
53
+ if ('active' in this.args) {
54
+ var active = this.parseAPIKeyBoolean(this.args.active, 'active');
55
+ api_keys = api_keys.filter( api_key => !!api_key.active === active );
56
+ delete this.args.active;
57
+ is_filtered = true;
58
+ }
59
+
60
+ if ('expired' in this.args) {
61
+ var expired = this.parseAPIKeyBoolean(this.args.expired, 'expired');
62
+ api_keys = api_keys.filter( api_key => {
63
+ var is_expired = !!api_key.expires && (this.epoch >= api_key.expires);
64
+ return is_expired === expired;
65
+ });
66
+ delete this.args.expired;
67
+ is_filtered = true;
68
+ }
69
+
70
+ if ('privilege' in this.args) {
71
+ var privileges = this.parseAPIKeyList(this.args.privilege);
72
+ api_keys = api_keys.filter( api_key => {
73
+ return privileges.every( id => !!(api_key.privileges || {})[id] );
74
+ });
75
+ delete this.args.privilege;
76
+ is_filtered = true;
77
+ }
78
+
79
+ if ('role' in this.args) {
80
+ var roles = this.parseAPIKeyList(this.args.role);
81
+ api_keys = api_keys.filter( api_key => roles.every( id => (api_key.roles || []).includes(id) ) );
82
+ delete this.args.role;
83
+ is_filtered = true;
84
+ }
85
+
86
+ if (Tools.numKeys(this.args)) {
87
+ // Remaining options are ordinary fuzzy property filters.
88
+ api_keys = this.findObjectsFuzzy(api_keys, this.args);
89
+ is_filtered = true;
90
+ }
91
+
92
+ api_keys.sort( function(a, b) {
93
+ return String(a.title || '').toLowerCase().localeCompare(String(b.title || '').toLowerCase());
94
+ } );
95
+
96
+ if (this.format.match(/json/)) return this.jsonOutput(api_keys);
97
+
98
+ var total = api_keys.length;
99
+ var rows = api_keys.slice(this.offset, this.offset + this.limit);
100
+ this.printPaginatedBoxTable({
101
+ title: is_filtered ? 'Filtered API Keys' : 'All API Keys',
102
+ header: ['Key ID', 'App Title', 'Partial Key', 'Status', 'Last Used'],
103
+ rows: rows,
104
+ list: { length: total },
105
+ offset: this.offset,
106
+ limit: this.limit
107
+ }, api_key => {
108
+ var state = ((this.state.api_keys || {})[api_key.id]) || {};
109
+ return [
110
+ this.color('theme').bold(api_key.id),
111
+ bold(api_key.title),
112
+ api_key.mask || gray('(Unavailable)'),
113
+ this.getNiceAPIKeyStatus(api_key),
114
+ // this.getNicePrivileges(api_key.privileges),
115
+ // this.getNiceRoles(api_key.roles),
116
+ state.date ? this.getRelativeDateTime(state.date, true) : gray('Never')
117
+ ];
118
+ });
119
+
120
+ this.printSuggestedCommands({
121
+ "View key details": "xy key KEY_ID",
122
+ "Create API Key": "xy key create --title \"My App\" --privileges.admin",
123
+ "Update API Key": "xy key update KEY_ID [--KEY VALUE, ...]"
124
+ });
125
+ },
126
+
127
+ async cmd_get_api_key() {
128
+ // Show one API Key by exact ID or fuzzy title, without its stored hash.
129
+ await this.getMultiple();
130
+ if (!this.api_keys.length) return this.die("No API Keys found.");
131
+
132
+ var api_key = null;
133
+ if (this.args.other.length) {
134
+ var search = this.args.other.join(' ');
135
+ api_key = Tools.findObject(this.api_keys, { id: search }) ||
136
+ this.findObjectFuzzy(this.api_keys, { title: search });
137
+ }
138
+ else if (this.args.id) {
139
+ api_key = Tools.findObject(this.api_keys, { id: this.args.id });
140
+ }
141
+ else if (this.args.title) {
142
+ api_key = this.findObjectFuzzy(this.api_keys, { title: this.args.title });
143
+ }
144
+ else return this.dieUsage('key get');
145
+
146
+ if (!api_key) return this.die("Could not find API Key based on your criteria.");
147
+ api_key = this.sanitizeAPIKey(api_key);
148
+
149
+ if (this.format.match(/json/)) return this.jsonOutput(api_key);
150
+
151
+ var state = ((this.state.api_keys || {})[api_key.id]) || {};
152
+ this.printBoxList({
153
+ title: 'API Key Summary',
154
+ rows: [
155
+ [ 'Key ID', gray(api_key.id) ],
156
+ [ 'App Title', this.color('theme').bold(api_key.title) ],
157
+ [ 'Partial Key', api_key.mask || gray('(Unavailable)') ],
158
+ [ 'Status', this.getNiceAPIKeyStatus(api_key) ],
159
+ [ 'Privileges', this.getNicePrivileges(api_key.privileges) ],
160
+ [ 'Roles', this.getNiceRoles(api_key.roles) ],
161
+ [ 'Rate Limit', api_key.max_per_sec ? Tools.commify(api_key.max_per_sec) + '/sec' : 'Unlimited' ],
162
+ [ 'Expiration', api_key.expires ? this.getNiceDateTime(api_key.expires, true) : 'Never' ],
163
+ [ 'Last Used', state.date ? this.getNiceDateTime(state.date, true, true) : 'Never' ],
164
+ [ 'Author', api_key.username ],
165
+ [ 'Created', this.getNiceDateTime(api_key.created, true, true) ],
166
+ [ 'Modified', this.getNiceDateTime(api_key.modified, true, true) ],
167
+ [ 'Revision', api_key.revision ]
168
+ ]
169
+ });
170
+
171
+ // App descriptions may contain multiple lines, just like user notes.
172
+ if (api_key.description) {
173
+ this.printUserNotes('APP DESCRIPTION', api_key.description);
174
+ }
175
+
176
+ this.printSuggestedCommands({
177
+ "Update API Key": `xy key update ${api_key.id} [--KEY VALUE, ...]`,
178
+ "Export API Key": `xy key ${api_key.id} --export key.json`,
179
+ "Disable API Key": api_key.active ? `xy key update ${api_key.id} --active false` : '',
180
+ "Delete API Key": `xy key delete ${api_key.id}`,
181
+ "List all API Keys": "xy keys"
182
+ });
183
+ },
184
+
185
+ async cmd_create_api_key() {
186
+ // Create a key. xyOps generates the secret and returns it exactly once.
187
+ await this.getMultiple();
188
+ if ('delete' in this.args) return this.die("The '--delete' option is only available for update commands.");
189
+
190
+ var params = {
191
+ active: true,
192
+ description: '',
193
+ privileges: Tools.copyHash(this.config.default_user_privileges || {}, true),
194
+ roles: [],
195
+ max_per_sec: 0,
196
+ expires: 0
197
+ };
198
+ this.mergeDotArgs(params, this.args);
199
+ delete params.other;
200
+ this.processAPIKeyUpdates(params);
201
+ delete params.id;
202
+
203
+ if (!params.title || !String(params.title).trim()) return this.dieUsage('key create');
204
+ params.title = String(params.title).trim();
205
+
206
+ var data = await this.callStandardAPI('createApiKey', params, {
207
+ text: 'Creating API Key...',
208
+ filterResponse: data => {
209
+ return {
210
+ code: data.code,
211
+ api_key: this.sanitizeAPIKey(data.api_key),
212
+ plain_key: '[REDACTED: shown once below]'
213
+ };
214
+ }
215
+ });
216
+ if (this.dry) return;
217
+
218
+ // Current xyOps releases always activate a new key at insertion time. Honor
219
+ // an explicitly disabled create by immediately applying the requested state.
220
+ // If that follow-up fails, still reveal the one-time secret so it is not lost.
221
+ var state_error = null;
222
+ if (!params.active && data.api_key.active) {
223
+ var state_result = await this.api.updateApiKey({ id: data.api_key.id, active: false });
224
+ state_error = state_result.err;
225
+ if (!state_error) data.api_key.active = false;
226
+ }
227
+
228
+ var result = {
229
+ api_key: this.sanitizeAPIKey(data.api_key),
230
+ plain_key: data.plain_key
231
+ };
232
+ if (state_error) result.error = "The key was created, but could not be disabled: " + (state_error.message || state_error);
233
+ if (this.format.match(/json/)) {
234
+ this.jsonOutput(result);
235
+ if (state_error) process.exitCode = 1;
236
+ return;
237
+ }
238
+
239
+ if (state_error) this.toast('⚠️', 'yellow', "API Key created, but it could not be disabled: #" + data.api_key.id);
240
+ else this.toast('✅', 'green', "Successfully created API Key: #" + data.api_key.id);
241
+ println( "\n " + cyan.bold("API Key Secret: ") + bold(data.plain_key) );
242
+ println( " " + yellow.bold("Save this secret now. It will never be displayed again.") );
243
+ if (state_error) {
244
+ println( "\n " + red.bold("Disable Error: ") + (state_error.message || state_error) );
245
+ process.exitCode = 1;
246
+ return;
247
+ }
248
+
249
+ this.printSuggestedCommands({
250
+ "View key details": `xy key ${data.api_key.id}`,
251
+ "Update API Key": `xy key update ${data.api_key.id} [--KEY VALUE, ...]`,
252
+ "Disable API Key": `xy key update ${data.api_key.id} --active false`,
253
+ "List all API Keys": "xy keys"
254
+ });
255
+ },
256
+
257
+ async cmd_update_api_key() {
258
+ // Updates require the safe internal ID, avoiding ambiguous title mutations.
259
+ await this.getMultiple();
260
+ var id = this.getAPIKeyIDArgument();
261
+ var api_key = Tools.findObject(this.api_keys, { id: id });
262
+ if (!api_key) return this.die("Could not find API Key from ID: " + id);
263
+
264
+ delete this.args.id;
265
+ delete this.args.other;
266
+
267
+ this.printMutationSummary({
268
+ title: 'Update API Key',
269
+ rows: [
270
+ [ 'API Key ID', gray(api_key.id) ],
271
+ [ 'Title', this.color('theme').bold(api_key.title) ]
272
+ ]
273
+ });
274
+
275
+ if (!await this.confirmSyncUpdate('api_key', api_key.id, 'API key')) return;
276
+ if (!Tools.numKeys(this.args)) return this.die("No updates specified for API Key.");
277
+ if (('key' in this.args) || ('plain_key' in this.args)) {
278
+ return this.die("API Key secrets cannot be updated. Create a new key instead.");
279
+ }
280
+
281
+ this.printUpdateData(this.args);
282
+
283
+ // Seed editable nested fields from the current key so a dotted privilege
284
+ // update does not replace and erase all its sibling privileges.
285
+ var params = {
286
+ id: api_key.id,
287
+ active: api_key.active,
288
+ title: api_key.title,
289
+ description: api_key.description || '',
290
+ privileges: Tools.copyHash(api_key.privileges || {}, true),
291
+ roles: [].concat(api_key.roles || []),
292
+ max_per_sec: api_key.max_per_sec || 0,
293
+ expires: api_key.expires || 0
294
+ };
295
+ this.mergeDotArgs(params, this.args);
296
+ this.applyDeleteArgs(params, this.args);
297
+ this.processAPIKeyUpdates(params);
298
+
299
+ if (!params.title || !String(params.title).trim()) return this.die("API Key title cannot be empty.");
300
+ params.title = String(params.title).trim();
301
+
302
+ await this.callStandardAPI('updateApiKey', params, {
303
+ text: 'Updating API Key...'
304
+ });
305
+ if (this.dry) return;
306
+ this.toast('✅', 'green', "Successfully updated API Key: #" + api_key.id);
307
+ },
308
+
309
+ async cmd_delete_api_key() {
310
+ // Delete by exact internal ID only, and require an explicit confirmation.
311
+ await this.getMultiple();
312
+ var id = this.getAPIKeyIDArgument();
313
+ var api_key = Tools.findObject(this.api_keys, { id: id });
314
+ if (!api_key) return this.die("Could not find API Key from ID: " + id);
315
+ this.printMutationSummary({
316
+ title: 'Delete API Key',
317
+ rows: [
318
+ [ 'API Key ID', gray(api_key.id) ],
319
+ [ 'Title', this.color('theme').bold(api_key.title) ]
320
+ ]
321
+ });
322
+ if (this.args.confirm !== true) {
323
+ this.toast('⚠️', 'orange', "Please confirm the API Key delete by adding '--confirm'.");
324
+ return;
325
+ }
326
+
327
+ await this.callStandardAPI('deleteApiKey', { id: id }, {
328
+ text: 'Deleting API Key...'
329
+ });
330
+ if (this.dry) return;
331
+ this.toast('✅', 'green', "Successfully deleted API Key: #" + id);
332
+ },
333
+
334
+ getAPIKeyIDArgument() {
335
+ // Consume an ID from either the positional slot or --id.
336
+ var id = this.args.id;
337
+ if (this.args.other && this.args.other.length) id = this.args.other.shift();
338
+ if (!id) this.die("Missing required API Key ID argument.");
339
+ if (this.args.other && this.args.other.length) this.die("Unexpected argument after API Key ID: " + this.args.other[0]);
340
+ return String(id);
341
+ },
342
+
343
+ parseAPIKeyBoolean(value, name) {
344
+ // pixl-cli handles common scalar types, but also accept friendly spellings.
345
+ if ((value === true) || (value === 1) || (value === '1')) return true;
346
+ if ((value === false) || (value === 0) || (value === '0')) return false;
347
+ if (String(value).match(/^(yes|on)$/i)) return true;
348
+ if (String(value).match(/^(no|off)$/i)) return false;
349
+ this.die("Invalid boolean value for '" + name + "': " + value);
350
+ },
351
+
352
+ parseAPIKeyList(value) {
353
+ // Accept JSON arrays, repeated options, or a comma-separated list.
354
+ if ((value === undefined) || (value === null) || (value === '')) return [];
355
+ return Tools.alwaysArray(value).reduce( function(list, item) {
356
+ return list.concat(String(item).split(/\s*,\s*/));
357
+ }, [] ).filter( item => !!item );
358
+ },
359
+
360
+ processAPIKeyUpdates(api_key) {
361
+ // Normalize all convenient CLI spellings into the APIKey data model.
362
+ if ('active' in api_key) api_key.active = this.parseAPIKeyBoolean(api_key.active, 'active');
363
+
364
+ if (Array.isArray(api_key.privileges) || (typeof(api_key.privileges) == 'string')) {
365
+ var privileges = this.parseAPIKeyList(api_key.privileges);
366
+ api_key.privileges = {};
367
+ privileges.forEach( id => { api_key.privileges[id] = true; } );
368
+ }
369
+ if (!api_key.privileges || (typeof(api_key.privileges) != 'object')) api_key.privileges = {};
370
+
371
+ if ('privilege' in api_key) {
372
+ this.parseAPIKeyList(api_key.privilege).forEach( id => { api_key.privileges[id] = true; } );
373
+ delete api_key.privilege;
374
+ }
375
+
376
+ // Privilege hashes should only contain enabled keys. This makes dotted
377
+ // updates such as `--privileges.tag_jobs false` behave like a deletion.
378
+ Object.keys(api_key.privileges).forEach( id => {
379
+ if (this.parseAPIKeyBoolean(api_key.privileges[id], 'privileges.' + id)) {
380
+ api_key.privileges[id] = true;
381
+ }
382
+ else delete api_key.privileges[id];
383
+ });
384
+ if (api_key.privileges.admin) api_key.privileges = { admin: true };
385
+
386
+ api_key.roles = this.parseAPIKeyList(api_key.roles);
387
+ if ('role' in api_key) {
388
+ api_key.roles = api_key.roles.concat(this.parseAPIKeyList(api_key.role));
389
+ delete api_key.role;
390
+ }
391
+ api_key.roles = Array.from(new Set(api_key.roles));
392
+
393
+ // Keep the server's field name internal and expose a shorter CLI option.
394
+ if ('rate' in api_key) {
395
+ api_key.max_per_sec = api_key.rate;
396
+ delete api_key.rate;
397
+ }
398
+ if ('max_per_sec' in api_key) {
399
+ api_key.max_per_sec = Number(api_key.max_per_sec);
400
+ if (!Number.isInteger(api_key.max_per_sec) || (api_key.max_per_sec < 0)) {
401
+ this.die("Rate limit must be a non-negative integer.");
402
+ }
403
+ }
404
+
405
+ if (typeof(api_key.expires) == 'string') {
406
+ if (api_key.expires.match(/^(never|none|off)$/i)) api_key.expires = 0;
407
+ else if (api_key.expires.match(/^\d+$/)) api_key.expires = parseInt(api_key.expires);
408
+ else api_key.expires = Tools.parseDate(api_key.expires) || this.die("Could not parse API Key expiration: " + api_key.expires);
409
+ }
410
+ if ((api_key.expires === false) || (api_key.expires === null)) api_key.expires = 0;
411
+ if (!Number.isFinite(api_key.expires) || (api_key.expires < 0)) {
412
+ this.die("API Key expiration must be a date, Unix timestamp, or 'never'.");
413
+ }
414
+
415
+ // These values are always generated and controlled by xyOps itself.
416
+ delete api_key.key;
417
+ delete api_key.plain_key;
418
+ delete api_key.mask;
419
+ delete api_key.username;
420
+ delete api_key.created;
421
+ delete api_key.modified;
422
+ delete api_key.revision;
423
+ }
424
+
425
+ };