@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.
- package/README.md +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Event Categories Layer
|
|
2
|
+
|
|
3
|
+
const cli = require('pixl-cli');
|
|
4
|
+
const Tools = cli.Tools;
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
|
|
8
|
+
async cmd_categories() {
|
|
9
|
+
// The plural command always lists categories, with optional filters.
|
|
10
|
+
await this.cmd_get_categories();
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
async cmd_category() {
|
|
14
|
+
// Route category operations, or treat a bare ID or title as a detail lookup.
|
|
15
|
+
var cmd = this.args.other.shift();
|
|
16
|
+
if (!cmd) return this.dieUsage('category');
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case 'list': await this.cmd_get_categories(); break;
|
|
19
|
+
case 'get': await this.cmd_get_category(); break;
|
|
20
|
+
case 'create': await this.cmd_create_category(); break;
|
|
21
|
+
case 'update': await this.cmd_update_category(); break;
|
|
22
|
+
case 'delete': await this.cmd_delete_category(); break;
|
|
23
|
+
default:
|
|
24
|
+
this.args.other.unshift(cmd);
|
|
25
|
+
await this.cmd_get_category();
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async cmd_get_categories() {
|
|
31
|
+
// Load shared state for event counts and the established display helpers.
|
|
32
|
+
this.prepSearchArgs();
|
|
33
|
+
await this.getMultiple();
|
|
34
|
+
var categories = this.categories.slice(0);
|
|
35
|
+
var is_filtered = !!this.args.other.length;
|
|
36
|
+
if (is_filtered) {
|
|
37
|
+
var search = this.args.other.join(' ');
|
|
38
|
+
categories = this.findObjectsFuzzy(categories, { id: search, title: search, notes: search }, 1);
|
|
39
|
+
}
|
|
40
|
+
delete this.args.other;
|
|
41
|
+
|
|
42
|
+
if ('enabled' in this.args) {
|
|
43
|
+
var enabled = this.parseCategoryBoolean(this.args.enabled, 'enabled');
|
|
44
|
+
categories = categories.filter( category => !!category.enabled === enabled );
|
|
45
|
+
delete this.args.enabled;
|
|
46
|
+
is_filtered = true;
|
|
47
|
+
}
|
|
48
|
+
if (Tools.numKeys(this.args)) {
|
|
49
|
+
categories = this.findObjectsFuzzy(categories, this.args);
|
|
50
|
+
is_filtered = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Preserve the same category ordering used by the web UI and event lists.
|
|
54
|
+
categories.sort( function(a, b) {
|
|
55
|
+
return ((a.sort_order || 0) - (b.sort_order || 0)) || String(a.title).localeCompare(String(b.title));
|
|
56
|
+
});
|
|
57
|
+
if (this.format.match(/json/)) return this.jsonOutput(categories);
|
|
58
|
+
|
|
59
|
+
var counts = {};
|
|
60
|
+
this.events.forEach( event => { counts[event.category] = (counts[event.category] || 0) + 1; } );
|
|
61
|
+
this.printPaginatedBoxTable({
|
|
62
|
+
title: is_filtered ? 'Filtered Categories' : 'All Categories',
|
|
63
|
+
header: ['Category ID', 'Title', 'Status', 'Events', 'Author', 'Modified'],
|
|
64
|
+
rows: categories.slice(this.offset, this.offset + this.limit),
|
|
65
|
+
list: { length: categories.length },
|
|
66
|
+
offset: this.offset,
|
|
67
|
+
limit: this.limit
|
|
68
|
+
}, category => [
|
|
69
|
+
this.color('theme').bold(category.id),
|
|
70
|
+
this.getNiceCategory(category),
|
|
71
|
+
this.getNiceEnabled(category.enabled),
|
|
72
|
+
Tools.commify(counts[category.id] || 0),
|
|
73
|
+
category.username || gray('(Unknown)'),
|
|
74
|
+
this.getRelativeDateTime(category.modified, true)
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
this.printSuggestedCommands({
|
|
78
|
+
"View category details": "xy category CAT_ID_OR_TITLE",
|
|
79
|
+
"Create a category": 'xy category create --title "My Category" --notes "Hello"',
|
|
80
|
+
"List disabled categories": "xy categories --enabled false",
|
|
81
|
+
"List category events": "xy events --category CAT_ID"
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async cmd_get_category() {
|
|
86
|
+
// Read-only selection accepts an exact ID first, then a fuzzy title.
|
|
87
|
+
await this.getMultiple();
|
|
88
|
+
var selector = this.args.other.join(' ') || this.args.id || this.args.title;
|
|
89
|
+
if (!selector) return this.dieUsage('category get');
|
|
90
|
+
var match = Tools.findObject(this.categories, { id: selector }) ||
|
|
91
|
+
this.findObjectFuzzy(this.categories, { title: selector });
|
|
92
|
+
if (!match) return this.die("Could not find category based on your criteria: " + selector);
|
|
93
|
+
var category = await this.fetchCategory(match.id);
|
|
94
|
+
if (this.format.match(/json/)) return this.jsonOutput(category);
|
|
95
|
+
|
|
96
|
+
this.printBoxList({
|
|
97
|
+
title: 'Category Summary',
|
|
98
|
+
rows: [
|
|
99
|
+
[ 'Category ID', gray(category.id) ],
|
|
100
|
+
[ 'Title', this.getNiceCategory(category) ],
|
|
101
|
+
[ 'Status', this.getNiceEnabled(category.enabled) ],
|
|
102
|
+
[ 'Color', category.color || 'plain' ],
|
|
103
|
+
[ 'Icon', category.icon || gray('(None)') ],
|
|
104
|
+
[ 'Events', Tools.commify(Tools.findObjects(this.events, { category: category.id }).length) ],
|
|
105
|
+
[ 'Sort Order', category.sort_order || 0 ],
|
|
106
|
+
[ 'Author', category.username || gray('(Unknown)') ],
|
|
107
|
+
[ 'Created', this.getNiceDateTime(category.created, true, true) ],
|
|
108
|
+
[ 'Modified', this.getNiceDateTime(category.modified, true, true) ],
|
|
109
|
+
[ 'Revision', category.revision || 1 ]
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// User notes may contain multiple lines, so keep them out of the summary box.
|
|
114
|
+
if (category.notes) {
|
|
115
|
+
this.printUserNotes('CATEGORY NOTES', category.notes);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Display only the category's own arrays so indexes map to update paths.
|
|
119
|
+
// Descriptions and disabled styling mirror the event detail tables.
|
|
120
|
+
this.printBoxTable({
|
|
121
|
+
title: 'Category Actions',
|
|
122
|
+
header: ['#', 'Status', 'Condition', 'Type', 'Description'],
|
|
123
|
+
rows: (category.actions || []).map( (item, idx) => {
|
|
124
|
+
var disp = this.getJobActionDisplayArgs(item);
|
|
125
|
+
return [
|
|
126
|
+
idx,
|
|
127
|
+
this.getNiceEnabled(item.enabled),
|
|
128
|
+
item.enabled ? this.color(disp.condition.color).bold(disp.condition.title) : gray(disp.condition.title),
|
|
129
|
+
item.enabled ? disp.type : gray(disp.type),
|
|
130
|
+
item.enabled ? disp.desc : gray(disp.desc)
|
|
131
|
+
];
|
|
132
|
+
})
|
|
133
|
+
});
|
|
134
|
+
this.printBoxTable({
|
|
135
|
+
title: 'Category Limits',
|
|
136
|
+
header: ['#', 'Status', 'Limit', 'Description'],
|
|
137
|
+
rows: (category.limits || []).map( (item, idx) => {
|
|
138
|
+
var disp = this.getResLimitDisplayArgs(item);
|
|
139
|
+
return [
|
|
140
|
+
idx,
|
|
141
|
+
this.getNiceEnabled(item.enabled),
|
|
142
|
+
item.enabled ? bold(disp.nice_title) : gray(disp.nice_title),
|
|
143
|
+
item.enabled ? disp.nice_desc : gray(disp.nice_desc)
|
|
144
|
+
];
|
|
145
|
+
})
|
|
146
|
+
});
|
|
147
|
+
this.printSuggestedCommands({
|
|
148
|
+
"List category events": `xy events --category ${category.id}`,
|
|
149
|
+
"Export category": `xy category ${category.id} --export category.json`,
|
|
150
|
+
"Update category": `xy category update ${category.id} --notes "Updated notes"`,
|
|
151
|
+
"Append an action": `xy category update ${category.id} --action @action.json`,
|
|
152
|
+
"Append a limit": `xy category update ${category.id} --limit @limit.json`,
|
|
153
|
+
"Delete category": `xy category delete ${category.id}`
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async cmd_create_category() {
|
|
158
|
+
// Defaults match the web editor. The server assigns ID and audit fields.
|
|
159
|
+
if (this.args.other.length) return this.dieUsage('category create');
|
|
160
|
+
delete this.args.other;
|
|
161
|
+
var params = this.prepareCategoryParams({
|
|
162
|
+
enabled: true,
|
|
163
|
+
color: 'plain',
|
|
164
|
+
icon: '',
|
|
165
|
+
notes: '',
|
|
166
|
+
actions: [],
|
|
167
|
+
limits: []
|
|
168
|
+
}, this.args, true);
|
|
169
|
+
if (!params.title) return this.dieUsage('category create');
|
|
170
|
+
|
|
171
|
+
var data = await this.callStandardAPI('createCategory', params, { text: 'Creating category...' });
|
|
172
|
+
if (this.dry) return;
|
|
173
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.category);
|
|
174
|
+
this.toast('✅', 'green', "Successfully created category: #" + data.category.id);
|
|
175
|
+
this.printSuggestedCommands({
|
|
176
|
+
"View category details": `xy category ${data.category.id}`,
|
|
177
|
+
"Update category": `xy category update ${data.category.id} --enabled false`,
|
|
178
|
+
"Create an event": `xy event create --title "My Event" --category ${data.category.id}`,
|
|
179
|
+
"List all categories": "xy categories"
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async cmd_update_category() {
|
|
184
|
+
// Fetch by exact ID so nested changes preserve untouched array entries.
|
|
185
|
+
var id = this.consumeCategoryID();
|
|
186
|
+
var category = await this.fetchCategory(id);
|
|
187
|
+
|
|
188
|
+
this.printMutationSummary({
|
|
189
|
+
title: 'Update Category',
|
|
190
|
+
rows: [
|
|
191
|
+
[ 'Category ID', gray(category.id) ],
|
|
192
|
+
[ 'Title', this.color('theme').bold(category.title) ]
|
|
193
|
+
]
|
|
194
|
+
});
|
|
195
|
+
if (!await this.confirmSyncUpdate('category', category.id, 'category')) return;
|
|
196
|
+
if (!Tools.numKeys(this.args)) return this.die("No updates specified for category.");
|
|
197
|
+
this.printUpdateData(this.args);
|
|
198
|
+
|
|
199
|
+
var params = this.prepareCategoryParams(category, this.args, false);
|
|
200
|
+
params.id = id;
|
|
201
|
+
var data = await this.callStandardAPI('updateCategory', params, { text: 'Updating category...' });
|
|
202
|
+
if (this.dry) return;
|
|
203
|
+
if (this.format.match(/json/)) return this.jsonOutput(data);
|
|
204
|
+
this.toast('✅', 'green', "Successfully updated category: #" + id);
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async cmd_delete_category() {
|
|
208
|
+
// The server also refuses deletion while any event uses this category.
|
|
209
|
+
var id = this.consumeCategoryID();
|
|
210
|
+
var category = await this.fetchCategory(id);
|
|
211
|
+
|
|
212
|
+
this.printMutationSummary({
|
|
213
|
+
title: 'Delete Category',
|
|
214
|
+
rows: [
|
|
215
|
+
[ 'Category ID', gray(category.id) ],
|
|
216
|
+
[ 'Title', this.color('theme').bold(category.title) ]
|
|
217
|
+
]
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (this.args.confirm !== true) {
|
|
221
|
+
this.toast('⚠️', 'orange', "Please confirm the category delete by adding '--confirm'.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
delete this.args.confirm;
|
|
226
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported category delete option: --" + Tools.firstKey(this.args));
|
|
227
|
+
var data = await this.callStandardAPI('deleteCategory', { id: id }, { text: 'Deleting category...' });
|
|
228
|
+
if (this.dry) return;
|
|
229
|
+
if (this.format.match(/json/)) return this.jsonOutput(data);
|
|
230
|
+
this.toast('✅', 'green', "Successfully deleted category: #" + id);
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async fetchCategory(id) {
|
|
234
|
+
// Use the single-resource API for authoritative metadata and access checks.
|
|
235
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading category...') });
|
|
236
|
+
var { err, data } = await this.api.getCategory({ id: id });
|
|
237
|
+
cli.progress.end();
|
|
238
|
+
if (err) this.die(err);
|
|
239
|
+
return data.category;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
consumeCategoryID() {
|
|
243
|
+
// Updates and deletes never select by title, and cannot change the ID.
|
|
244
|
+
var id = this.args.other.shift() || this.args.id;
|
|
245
|
+
if (!id) this.die("Missing required Category ID argument.");
|
|
246
|
+
if (this.args.other.length) this.die("Unexpected argument after Category ID: " + this.args.other[0]);
|
|
247
|
+
if (this.args.id && (this.args.id !== id)) this.die("Conflicting Category ID arguments.");
|
|
248
|
+
if ((typeof(id) != 'string') || !id.match(/^[a-z0-9_]+$/)) this.die("Invalid Category ID: " + id);
|
|
249
|
+
delete this.args.other;
|
|
250
|
+
delete this.args.id;
|
|
251
|
+
return id;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
prepareCategoryParams(category, input, creating) {
|
|
255
|
+
// Only send edited top-level fields. In particular, a notes-only update
|
|
256
|
+
// should not rewrite actions, limits, sort order, or server audit metadata.
|
|
257
|
+
var params = creating ? Tools.copyHash(category, true) : {};
|
|
258
|
+
var fields = ['title', 'enabled', 'color', 'icon', 'notes', 'actions', 'limits', 'action', 'limit'];
|
|
259
|
+
fields.push(creating ? 'id' : 'sort_order');
|
|
260
|
+
Object.keys(input).forEach( key => {
|
|
261
|
+
var root = key.split('.')[0];
|
|
262
|
+
if (!fields.includes(root)) this.die("Unsupported category option: --" + key);
|
|
263
|
+
if (key.includes('.') && !['actions', 'limits', 'action', 'limit'].includes(root)) {
|
|
264
|
+
this.die("Invalid argument path: " + key);
|
|
265
|
+
}
|
|
266
|
+
var field = (root == 'action') ? 'actions' : ((root == 'limit') ? 'limits' : root);
|
|
267
|
+
if (!creating && (field in category)) params[field] = Tools.copyHash({ value: category[field] }, true).value;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Apply whole values before dotted edits, regardless of CLI option order.
|
|
271
|
+
Object.keys(input).filter( key => !key.includes('.') ).forEach( key => { params[key] = input[key]; } );
|
|
272
|
+
var dotted = {};
|
|
273
|
+
Object.keys(input).filter( key => key.includes('.') ).forEach( key => { dotted[key] = input[key]; } );
|
|
274
|
+
this.mergeDotArgs(params, dotted);
|
|
275
|
+
|
|
276
|
+
// Match event convenience syntax: plural arrays replace, singular objects
|
|
277
|
+
// append. Repeated singular flags are also accepted for batch additions.
|
|
278
|
+
['action', 'limit'].forEach( singular => {
|
|
279
|
+
var plural = singular + 's';
|
|
280
|
+
if (singular in params) {
|
|
281
|
+
if (!(plural in params)) params[plural] = [];
|
|
282
|
+
if (!Array.isArray(params[plural])) this.die("Category " + plural + " must be a JSON array.");
|
|
283
|
+
Tools.alwaysArray(params[singular]).forEach( item => {
|
|
284
|
+
// Repeated options arrive as an array of unparsed JSON strings.
|
|
285
|
+
if (typeof(item) == 'string') {
|
|
286
|
+
try { item = JSON.parse(item); }
|
|
287
|
+
catch (err) { this.die("Category --" + singular + " must contain a JSON object."); }
|
|
288
|
+
}
|
|
289
|
+
if (!Tools.isaHash(item)) this.die("Category --" + singular + " must contain a JSON object.");
|
|
290
|
+
params[plural].push(item);
|
|
291
|
+
});
|
|
292
|
+
delete params[singular];
|
|
293
|
+
}
|
|
294
|
+
if (plural in params) {
|
|
295
|
+
if (!Array.isArray(params[plural])) this.die("Category " + plural + " must be a JSON array.");
|
|
296
|
+
params[plural].forEach( (item, idx) => {
|
|
297
|
+
if (!Tools.isaHash(item)) this.die("Category " + plural + "." + idx + " must be a JSON object.");
|
|
298
|
+
if ('enabled' in item) item.enabled = this.parseCategoryBoolean(item.enabled, plural + '.' + idx + '.enabled');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if ('title' in params) {
|
|
304
|
+
if ((typeof(params.title) != 'string') || !params.title.trim()) this.die("Category title cannot be empty and must be a string.");
|
|
305
|
+
params.title = params.title.trim();
|
|
306
|
+
}
|
|
307
|
+
if ('enabled' in params) params.enabled = this.parseCategoryBoolean(params.enabled, 'enabled');
|
|
308
|
+
['color', 'icon', 'notes'].forEach( key => {
|
|
309
|
+
if ((key in params) && (typeof(params[key]) != 'string')) this.die("Category " + key + " must be a string.");
|
|
310
|
+
});
|
|
311
|
+
if (('id' in params) && ((typeof(params.id) != 'string') || !params.id.match(/^[a-z0-9_]+$/))) this.die("Invalid Category ID: " + params.id);
|
|
312
|
+
if (('sort_order' in params) && (!Number.isInteger(params.sort_order) || (params.sort_order < 0))) this.die("Category sort_order must be a non-negative integer.");
|
|
313
|
+
return params;
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
parseCategoryBoolean(value, name) {
|
|
317
|
+
// Include numeric and friendly boolean spellings used by other resources.
|
|
318
|
+
if ([true, 1, '1', 'true', 'yes', 'on'].includes(value)) return true;
|
|
319
|
+
if ([false, 0, '0', 'false', 'no', 'off'].includes(value)) return false;
|
|
320
|
+
this.die("Invalid boolean value for '" + name + "': " + value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
}; // module.exports
|
package/lib/channels.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Notification Channels Layer
|
|
2
|
+
|
|
3
|
+
const cli = require('pixl-cli');
|
|
4
|
+
const Tools = cli.Tools;
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
|
|
8
|
+
async cmd_channels() {
|
|
9
|
+
// The plural command lists channels, with optional search and filters.
|
|
10
|
+
await this.cmd_get_channels();
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
async cmd_channel() {
|
|
14
|
+
// Use the same operation names as categories, buckets, and API Keys.
|
|
15
|
+
var cmd = this.args.other.shift();
|
|
16
|
+
if (!cmd) return this.dieUsage('channel');
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case 'list': await this.cmd_get_channels(); break;
|
|
19
|
+
case 'get': await this.cmd_get_channel(); break;
|
|
20
|
+
case 'create': await this.cmd_create_channel(); break;
|
|
21
|
+
case 'update': await this.cmd_update_channel(); break;
|
|
22
|
+
case 'delete': await this.cmd_delete_channel(); break;
|
|
23
|
+
default:
|
|
24
|
+
// A bare ID or title opens channel details.
|
|
25
|
+
this.args.other.unshift(cmd);
|
|
26
|
+
await this.cmd_get_channel();
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async cmd_get_channels() {
|
|
32
|
+
// Consume pagination before turning the remaining options into filters.
|
|
33
|
+
this.prepSearchArgs();
|
|
34
|
+
await this.getMultiple();
|
|
35
|
+
var channels = this.channels.slice(0);
|
|
36
|
+
var is_filtered = !!this.args.other.length;
|
|
37
|
+
if (is_filtered) {
|
|
38
|
+
var search = this.args.other.join(' ');
|
|
39
|
+
channels = this.findObjectsFuzzy(channels, { id: search, title: search, notes: search, email: search }, 1);
|
|
40
|
+
}
|
|
41
|
+
delete this.args.other;
|
|
42
|
+
|
|
43
|
+
if ('enabled' in this.args) {
|
|
44
|
+
var enabled = this.parseChannelBoolean(this.args.enabled);
|
|
45
|
+
channels = channels.filter( channel => !!channel.enabled === enabled );
|
|
46
|
+
delete this.args.enabled;
|
|
47
|
+
is_filtered = true;
|
|
48
|
+
}
|
|
49
|
+
if ('user' in this.args) {
|
|
50
|
+
// User filters match whole usernames, not substrings of other users.
|
|
51
|
+
var users = this.parseChannelUsers(this.args.user);
|
|
52
|
+
channels = channels.filter( channel => users.every( user => (channel.users || []).includes(user) ) );
|
|
53
|
+
delete this.args.user;
|
|
54
|
+
is_filtered = true;
|
|
55
|
+
}
|
|
56
|
+
if (Tools.numKeys(this.args)) {
|
|
57
|
+
channels = this.findObjectsFuzzy(channels, this.args);
|
|
58
|
+
is_filtered = true;
|
|
59
|
+
}
|
|
60
|
+
channels.sort( (a, b) => String(a.title).toLowerCase().localeCompare(String(b.title).toLowerCase()) );
|
|
61
|
+
if (this.format.match(/json/)) return this.jsonOutput(channels);
|
|
62
|
+
|
|
63
|
+
this.printPaginatedBoxTable({
|
|
64
|
+
title: is_filtered ? 'Filtered Notification Channels' : 'All Notification Channels',
|
|
65
|
+
header: ['Channel ID', 'Title', 'Status', 'Users', 'Daily Cap', 'Modified'],
|
|
66
|
+
rows: channels.slice(this.offset, this.offset + this.limit),
|
|
67
|
+
list: { length: channels.length },
|
|
68
|
+
offset: this.offset,
|
|
69
|
+
limit: this.limit
|
|
70
|
+
}, channel => [
|
|
71
|
+
this.color('theme').bold(channel.id),
|
|
72
|
+
bold(this.getNiceChannel(channel)),
|
|
73
|
+
this.getNiceEnabled(channel.enabled),
|
|
74
|
+
Tools.commify((channel.users || []).length),
|
|
75
|
+
channel.max_per_day ? Tools.commify(channel.max_per_day) : gray('Unlimited'),
|
|
76
|
+
this.getRelativeDateTime(channel.modified, true)
|
|
77
|
+
]);
|
|
78
|
+
this.printSuggestedCommands({
|
|
79
|
+
"View channel details": "xy channel CHANNEL_ID_OR_TITLE",
|
|
80
|
+
"Create a channel": 'xy channel create --title "My Channel" --users admin',
|
|
81
|
+
"List disabled channels": "xy channels --enabled false",
|
|
82
|
+
"Find channels for a user": "xy channels --user admin"
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async cmd_get_channel() {
|
|
87
|
+
// Prefer an exact ID before falling back to a case-insensitive title match.
|
|
88
|
+
await this.getMultiple();
|
|
89
|
+
var selector = this.args.other.join(' ') || this.args.id || this.args.title;
|
|
90
|
+
if (!selector) return this.dieUsage('channel get');
|
|
91
|
+
var match = Tools.findObject(this.channels, { id: selector }) || this.findObjectFuzzy(this.channels, { title: selector });
|
|
92
|
+
if (!match) return this.die("Could not find channel based on your criteria: " + selector);
|
|
93
|
+
var channel = await this.fetchChannel(match.id);
|
|
94
|
+
if (this.format.match(/json/)) return this.jsonOutput(channel);
|
|
95
|
+
|
|
96
|
+
this.printBoxList({
|
|
97
|
+
title: 'Channel Summary',
|
|
98
|
+
rows: [
|
|
99
|
+
[ 'Channel ID', gray(channel.id) ],
|
|
100
|
+
[ 'Title', this.color('theme').bold(channel.title) ],
|
|
101
|
+
[ 'Status', this.getNiceEnabled(channel.enabled) ],
|
|
102
|
+
[ 'Icon', channel.icon || gray('(None)') ],
|
|
103
|
+
[ 'Users', (channel.users || []).join(', ') || gray('(None)') ],
|
|
104
|
+
[ 'Email', channel.email || gray('(None)') ],
|
|
105
|
+
[ 'Web Hook', channel.web_hook ? this.getNiceWebHook(channel.web_hook) + gray(' (' + channel.web_hook + ')') : gray('(None)') ],
|
|
106
|
+
[ 'Run Event', channel.run_event ? this.getNiceEvent(channel.run_event) + gray(' (' + channel.run_event + ')') : gray('(None)') ],
|
|
107
|
+
[ 'Sound', channel.sound || gray('(None)') ],
|
|
108
|
+
[ 'Daily Cap', channel.max_per_day ? Tools.commify(channel.max_per_day) : 'Unlimited' ],
|
|
109
|
+
[ 'Author', channel.username || gray('(Unknown)') ],
|
|
110
|
+
[ 'Created', this.getNiceDateTime(channel.created, true, true) ],
|
|
111
|
+
[ 'Modified', this.getNiceDateTime(channel.modified, true, true) ],
|
|
112
|
+
[ 'Revision', channel.revision || 1 ]
|
|
113
|
+
]
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// User notes may contain multiple lines, so keep them out of the summary box.
|
|
117
|
+
if (channel.notes) {
|
|
118
|
+
this.printUserNotes('CHANNEL NOTES', channel.notes);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.printSuggestedCommands({
|
|
122
|
+
"Update channel": `xy channel update ${channel.id} --max_per_day 100`,
|
|
123
|
+
"Export channel": `xy channel ${channel.id} --export channel.json`,
|
|
124
|
+
"Append a user": `xy channel update ${channel.id} --user USERNAME`,
|
|
125
|
+
"Disable channel": `xy channel update ${channel.id} --enabled false`,
|
|
126
|
+
"Delete channel": `xy channel delete ${channel.id}`
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async cmd_create_channel() {
|
|
131
|
+
// Match the web editor defaults, including unlimited daily notifications.
|
|
132
|
+
if (this.args.other.length) return this.dieUsage('channel create');
|
|
133
|
+
delete this.args.other;
|
|
134
|
+
|
|
135
|
+
var params = this.prepareChannelParams({
|
|
136
|
+
enabled: true,
|
|
137
|
+
icon: '',
|
|
138
|
+
users: [],
|
|
139
|
+
email: '',
|
|
140
|
+
web_hook: '',
|
|
141
|
+
run_event: '',
|
|
142
|
+
sound: '',
|
|
143
|
+
max_per_day: 0,
|
|
144
|
+
notes: ''
|
|
145
|
+
}, this.args, true);
|
|
146
|
+
|
|
147
|
+
if (!params.title) return this.dieUsage('channel create');
|
|
148
|
+
var data = await this.callStandardAPI('createChannel', params, { text: 'Creating notification channel...' });
|
|
149
|
+
if (this.dry) return;
|
|
150
|
+
if (this.format.match(/json/)) return this.jsonOutput(data.channel);
|
|
151
|
+
|
|
152
|
+
this.toast('✅', 'green', "Successfully created notification channel: #" + data.channel.id);
|
|
153
|
+
|
|
154
|
+
this.printSuggestedCommands({
|
|
155
|
+
"View channel details": `xy channel ${data.channel.id}`,
|
|
156
|
+
"Update channel": `xy channel update ${data.channel.id} --max_per_day 100`,
|
|
157
|
+
"Use in an event": `xy event update EVENT_ID --action '{ "type":"channel", "enabled":true, "condition":"error", "channel_id":"${data.channel.id}" }'`,
|
|
158
|
+
"List all channels": "xy channels"
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async cmd_update_channel() {
|
|
163
|
+
// Load the current channel for indexed edits and appending subscribed users.
|
|
164
|
+
var id = this.consumeChannelID();
|
|
165
|
+
var channel = await this.fetchChannel(id);
|
|
166
|
+
|
|
167
|
+
this.printMutationSummary({
|
|
168
|
+
title: 'Update Channel',
|
|
169
|
+
rows: [
|
|
170
|
+
[ 'Channel ID', gray(channel.id) ],
|
|
171
|
+
[ 'Title', this.color('theme').bold(channel.title) ]
|
|
172
|
+
]
|
|
173
|
+
});
|
|
174
|
+
if (!await this.confirmSyncUpdate('channel', channel.id, 'notification channel')) return;
|
|
175
|
+
if (!Tools.numKeys(this.args)) return this.die("No updates specified for notification channel.");
|
|
176
|
+
this.printUpdateData(this.args);
|
|
177
|
+
|
|
178
|
+
var params = this.prepareChannelParams(channel, this.args, false);
|
|
179
|
+
params.id = id;
|
|
180
|
+
var data = await this.callStandardAPI('updateChannel', params, { text: 'Updating notification channel...' });
|
|
181
|
+
if (this.dry) return;
|
|
182
|
+
if (this.format.match(/json/)) return this.jsonOutput(data);
|
|
183
|
+
this.toast('✅', 'green', "Successfully updated notification channel: #" + id);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async cmd_delete_channel() {
|
|
187
|
+
// Deleting the definition does not remove references from existing actions.
|
|
188
|
+
var id = this.consumeChannelID();
|
|
189
|
+
var channel = await this.fetchChannel(id);
|
|
190
|
+
|
|
191
|
+
this.printMutationSummary({
|
|
192
|
+
title: 'Delete Channel',
|
|
193
|
+
rows: [
|
|
194
|
+
[ 'Channel ID', gray(channel.id) ],
|
|
195
|
+
[ 'Title', this.color('theme').bold(channel.title) ]
|
|
196
|
+
]
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (this.args.confirm !== true) {
|
|
200
|
+
this.toast('⚠️', 'orange', "Please confirm the channel delete by adding '--confirm'.");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
delete this.args.confirm;
|
|
204
|
+
if (Tools.numKeys(this.args)) return this.die("Unsupported channel delete option: --" + Tools.firstKey(this.args));
|
|
205
|
+
var data = await this.callStandardAPI('deleteChannel', { id: id }, { text: 'Deleting channel...' });
|
|
206
|
+
if (this.dry) return;
|
|
207
|
+
if (this.format.match(/json/)) return this.jsonOutput(data);
|
|
208
|
+
this.toast('✅', 'green', "Successfully deleted channel: #" + id);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async fetchChannel(id) {
|
|
212
|
+
// Fetch authoritative metadata through the single-channel endpoint.
|
|
213
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading channel...') });
|
|
214
|
+
var { err, data } = await this.api.getChannel({ id: id });
|
|
215
|
+
cli.progress.end();
|
|
216
|
+
if (err) this.die(err);
|
|
217
|
+
return data.channel;
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
consumeChannelID() {
|
|
221
|
+
// Mutation selectors are exact IDs and cannot silently replace each other.
|
|
222
|
+
var id = this.args.other.shift() || this.args.id;
|
|
223
|
+
if (!id) this.die("Missing required Channel ID argument.");
|
|
224
|
+
if (this.args.other.length) this.die("Unexpected argument after Channel ID: " + this.args.other[0]);
|
|
225
|
+
if (this.args.id && (this.args.id !== id)) this.die("Conflicting Channel ID arguments.");
|
|
226
|
+
if ((typeof(id) != 'string') || !id.match(/^[a-z0-9_]+$/)) this.die("Invalid Channel ID: " + id);
|
|
227
|
+
delete this.args.id;
|
|
228
|
+
delete this.args.other;
|
|
229
|
+
return id;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
prepareChannelParams(channel, input, creating) {
|
|
233
|
+
// Send only the fields being edited, leaving audit fields and unrelated
|
|
234
|
+
// settings untouched. Only the users array supports indexed updates.
|
|
235
|
+
var params = creating ? Tools.copyHash(channel, true) : {};
|
|
236
|
+
var fields = ['title', 'enabled', 'icon', 'users', 'user', 'email', 'web_hook', 'run_event', 'sound', 'max_per_day', 'notes'];
|
|
237
|
+
if (creating) fields.push('id');
|
|
238
|
+
var dotted = {};
|
|
239
|
+
Object.keys(input).forEach( key => {
|
|
240
|
+
var root = key.split('.')[0];
|
|
241
|
+
if (!fields.includes(root)) this.die("Unsupported channel option: --" + key);
|
|
242
|
+
if (key.includes('.')) {
|
|
243
|
+
if (root != 'users') this.die("Invalid argument path: " + key);
|
|
244
|
+
dotted[key] = input[key];
|
|
245
|
+
}
|
|
246
|
+
else params[key] = input[key];
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Whole-list replacement precedes indexed edits, then --user appends.
|
|
250
|
+
// Clone the saved array so preparing a dry run cannot change shared state.
|
|
251
|
+
if (('users' in input) || ('user' in input) || Tools.numKeys(dotted)) {
|
|
252
|
+
params.users = this.parseChannelUsers(('users' in input) ? input.users : (channel.users || []));
|
|
253
|
+
this.mergeDotArgs(params, dotted);
|
|
254
|
+
if ('user' in input) params.users = params.users.concat(this.parseChannelUsers(input.user));
|
|
255
|
+
params.users = Array.from(new Set(this.parseChannelUsers(params.users)));
|
|
256
|
+
}
|
|
257
|
+
delete params.user;
|
|
258
|
+
|
|
259
|
+
if ('title' in params) {
|
|
260
|
+
if ((typeof(params.title) != 'string') || !params.title.trim()) this.die("Channel title cannot be empty and must be a string.");
|
|
261
|
+
params.title = params.title.trim();
|
|
262
|
+
}
|
|
263
|
+
if ('enabled' in params) params.enabled = this.parseChannelBoolean(params.enabled);
|
|
264
|
+
['icon', 'email', 'web_hook', 'run_event', 'sound', 'notes'].forEach( key => {
|
|
265
|
+
if ((key in params) && (typeof(params[key]) != 'string')) this.die("Channel " + key + " must be a string.");
|
|
266
|
+
});
|
|
267
|
+
if (('id' in params) && ((typeof(params.id) != 'string') || !params.id.match(/^[a-z0-9_]+$/))) this.die("Invalid Channel ID: " + params.id);
|
|
268
|
+
if (('max_per_day' in params) && (!Number.isSafeInteger(params.max_per_day) || (params.max_per_day < 0))) this.die("Channel max_per_day must be a non-negative integer.");
|
|
269
|
+
if (params.sound && !params.sound.match(/^[\w.-]+\.mp3$/i)) this.die("Channel sound must be an .mp3 filename, or an empty string.");
|
|
270
|
+
return params;
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
parseChannelUsers(value) {
|
|
274
|
+
// Support a JSON array, comma-separated usernames, and repeated --user.
|
|
275
|
+
var users = Array.isArray(value) ? value.slice(0) : ((typeof(value) == 'string') ? value.split(',') : null);
|
|
276
|
+
if (!users) this.die("Channel users must be a JSON array or comma-separated usernames.");
|
|
277
|
+
return users.map( user => {
|
|
278
|
+
if ((typeof(user) != 'string') || !user.trim() || /\s/.test(user.trim())) this.die("Channel usernames must be non-empty strings without whitespace.");
|
|
279
|
+
return user.trim();
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
parseChannelBoolean(value) {
|
|
284
|
+
// Accept the usual boolean and numeric spellings without truthy strings.
|
|
285
|
+
if ([true, 1, '1', 'true', 'yes', 'on'].includes(value)) return true;
|
|
286
|
+
if ([false, 0, '0', 'false', 'no', 'off'].includes(value)) return false;
|
|
287
|
+
this.die("Invalid boolean value for 'enabled': " + value);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
}; // module.exports
|