@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/system.js ADDED
@@ -0,0 +1,612 @@
1
+ // System Status and Administration Layer
2
+
3
+ const fs = require('fs');
4
+ const Path = require('path');
5
+ const crypto = require('crypto');
6
+ const cli = require('pixl-cli');
7
+ const Tools = cli.Tools;
8
+
9
+ const BROADCAST_TYPES = ['info', 'warning', 'error', 'critical'];
10
+
11
+ module.exports = {
12
+
13
+ async cmd_system() {
14
+ // Route all admin dashboard and maintenance operations through one compact
15
+ // command family. A bare `xy system` always opens the read-only dashboard.
16
+ var cmd = this.args.other.shift();
17
+ if (!cmd) return this.cmd_system_dashboard();
18
+
19
+ switch (cmd) {
20
+ case 'dashboard': await this.cmd_system_dashboard(); break;
21
+ case 'import': await this.cmd_system_import(); break;
22
+ case 'export': await this.cmd_system_export(); break;
23
+ case 'delete': await this.cmd_system_delete(); break;
24
+ case 'maintenance':
25
+ case 'maint': await this.cmd_system_maintenance(); break;
26
+ case 'optimize': await this.cmd_system_optimize(); break;
27
+ case 'reset': await this.cmd_system_reset(); break;
28
+ case 'restart': await this.cmd_system_conductor_command('restart'); break;
29
+ case 'shutdown': await this.cmd_system_conductor_command('shutdown'); break;
30
+ case 'upgrade': await this.cmd_system_upgrade(); break;
31
+ case 'email': await this.cmd_system_email(); break;
32
+ case 'diagnostic':
33
+ case 'diagnostics':
34
+ case 'diag': await this.cmd_system_diagnostic(); break;
35
+ case 'broadcast': await this.cmd_system_broadcast(); break;
36
+ default: this.dieUsage('system'); break;
37
+ }
38
+ },
39
+
40
+ async loadSystemStats() {
41
+ // getMultiple supplies live application state. Refresh server and conductor
42
+ // status separately because the bootstrap copy may have been cached locally.
43
+ await this.getMultiple();
44
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading system statistics...') });
45
+ var [stats_result, servers_result] = await Promise.all([
46
+ this.api.adminStats({}),
47
+ this.api.getServers({})
48
+ ]);
49
+ cli.progress.end();
50
+ if (stats_result.err) this.die(stats_result.err);
51
+ if (servers_result.err) this.die(servers_result.err);
52
+ this.masters = servers_result.data.masters || {};
53
+ this.servers = servers_result.data.servers || {};
54
+ return stats_result.data.stats || {};
55
+ },
56
+
57
+ async cmd_system_dashboard() {
58
+ if (this.args.other.length) return this.die("Unexpected System dashboard argument: " + this.args.other[0]);
59
+ delete this.args.other;
60
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System dashboard option: --" + Tools.firstKey(this.args));
61
+
62
+ var data = await this.loadSystemStats();
63
+ var sockets = (data.sockets || []).filter( socket => socket.type == 'user' );
64
+
65
+ if (this.format.match(/json/)) {
66
+ return this.jsonOutput({
67
+ stats: data,
68
+ masters: this.masters || {},
69
+ internalJobs: this.internalJobs || {},
70
+ users: sockets
71
+ });
72
+ }
73
+
74
+ var stats = this.stats || {};
75
+ var cache = data.cache;
76
+ var records = (data.db && data.db.records) || {};
77
+ var memory = stats.memoryUsage || {};
78
+
79
+ println("\n " + this.color('theme').bold("SYSTEM STATUS & MAINTENANCE"));
80
+ println("" + dashGrid([
81
+ ["Process CPU", Math.round(stats.cpu || 0) + '%'],
82
+ ["Process Memory", Tools.getTextFromBytes(stats.mem || 0, 1).replace(/bytes/, 'B')],
83
+ ["DB Memory", Tools.getTextFromBytes(memory.external || 0, 1).replace(/bytes/, 'B')],
84
+ ["Cache Memory", cache ? Tools.getTextFromBytes(cache.bytes || 0, 1).replace(/bytes/, 'B') : 'n/a'],
85
+ ["Cache Objects", cache ? Tools.commify(cache.count || 0) : 'n/a'],
86
+ ["Cache Utilization", cache ? cache.full : 'n/a'],
87
+ ["DB Disk Size", data.db && data.db.sqlite ? Tools.getTextFromBytes(data.db.sqlite, 1).replace(/bytes/, 'B') : 'n/a'],
88
+ ["Job DB Rows", Tools.commify(records.jobs || 0)],
89
+ ["Server DB Rows", Tools.commify(records.servers || 0)],
90
+ ["Snapshot DB Rows", Tools.commify(records.snapshots || 0)],
91
+ ["Alert DB Rows", Tools.commify(records.alerts || 0)],
92
+ ["Activity DB Rows", Tools.commify(records.activity || 0)]
93
+ ], {
94
+ minCols: 3,
95
+ maxCols: 6,
96
+ gap: 1,
97
+ indent: 1,
98
+ valueStyles: ['bold', 'green']
99
+ }));
100
+
101
+ this.printInternalJobs({ title: 'Internal System Jobs' });
102
+ this.printSystemConductors();
103
+ this.printSystemUsers(sockets);
104
+
105
+ this.printSuggestedCommands({
106
+ "Export system data": "xy system export xyops-export.json.gz",
107
+ "Run maintenance": "xy system maintenance",
108
+ "Generate diagnostics": "xy system diagnostic",
109
+ "Broadcast a message": 'xy system broadcast "System maintenance begins soon"',
110
+ "View System help": "xy help system"
111
+ });
112
+ },
113
+
114
+ printSystemConductors() {
115
+ // Keep the useful columns from the dedicated web UI page, omitting only its
116
+ // browser action buttons. Offline peers retain their identity and status.
117
+ var masters = Object.values(this.masters || {}).sort( function(a, b) {
118
+ return String(a.id).toLowerCase().localeCompare(String(b.id).toLowerCase());
119
+ });
120
+
121
+ this.printBoxTable({
122
+ title: 'All Conductors',
123
+ header: ['Host ID', 'Status', 'xyOps', 'Load Avg', 'Ping', 'Uptime'],
124
+ rows: masters.map( item => {
125
+ var stats = item.stats || {};
126
+ var status = item.online ? (item.master ? green('Primary') : this.color('blue')('Online')) : gray('Offline');
127
+ return [
128
+ this.color('theme').bold(item.id),
129
+ status,
130
+ item.online && item.version ? 'v' + String(item.version).replace(/^v/, '') : '-',
131
+ item.online ? this.formatSystemNumber(stats.load || 0) : '-',
132
+ item.online ? (Tools.commify(item.ping || 0) + ' ms') : '-',
133
+ item.online && item.date ? Tools.getTextFromSeconds(Math.max(0, this.epoch - item.date), true, true) : 'n/a'
134
+ ];
135
+ })
136
+ });
137
+ },
138
+
139
+ printSystemUsers(sockets) {
140
+ // adminStats includes every WebSocket type. This table intentionally shows
141
+ // only interactive user sessions, matching the System page in the web UI.
142
+ sockets.sort( function(a, b) { return (a.timeStart < b.timeStart) ? 1 : -1; });
143
+
144
+ this.printBoxTable({
145
+ title: 'All Connected Users',
146
+ header: ['Username', 'Socket ID', 'IP Address', 'Location', 'Duration', 'Ping'],
147
+ rows: sockets.map( socket => {
148
+ var loc = socket.loc;
149
+ if (loc && (typeof(loc) == 'object')) loc = loc.loc || loc.id || '';
150
+ return [
151
+ bold(socket.username || '(Unknown)'),
152
+ this.color('theme').bold(socket.id),
153
+ socket.ip || 'n/a',
154
+ loc ? ('#' + loc) : 'n/a',
155
+ Tools.getTextFromSeconds(Math.max(0, this.epoch - (socket.timeStart || this.epoch)), true, true),
156
+ Tools.commify(socket.ping || 0) + ' ms'
157
+ ];
158
+ })
159
+ });
160
+ },
161
+
162
+ formatSystemNumber(value) {
163
+ value = Number(value) || 0;
164
+ return value.toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
165
+ },
166
+
167
+ parseSystemCSV(value, label, opts) {
168
+ // CLI users can supply comma-separated text, repeat a JSON array through
169
+ // --json, or use the friendly `all` / `none` selectors where appropriate.
170
+ opts = opts || {};
171
+ var values = Array.isArray(value) ? value.slice(0) : ((typeof(value) == 'string') ? value.split(',') : []);
172
+ values = values.map( item => String(item).trim() ).filter( item => !!item );
173
+ values = Array.from(new Set(values));
174
+ if (!values.length && !opts.empty) this.die(label + " must contain at least one item.");
175
+ if (values.includes('all') && values.length > 1) this.die(label + " cannot combine 'all' with other items.");
176
+ if (values.includes('none') && values.length > 1) this.die(label + " cannot combine 'none' with other items.");
177
+ if (values[0] == 'none') return [];
178
+ return values;
179
+ },
180
+
181
+ validateSystemSelection(values, allowed, label) {
182
+ if (values.length == 1 && values[0] == 'all') return;
183
+ var invalid = values.find( item => !allowed.includes(item) );
184
+ if (invalid) {
185
+ var suggestion = this.findClosestString(invalid, allowed);
186
+ this.die("Unknown " + label + ': "' + invalid + '".' + (suggestion ? ' Did you mean "' + suggestion + '"?' : ''));
187
+ }
188
+ },
189
+
190
+ async cmd_system_export() {
191
+ var filename = this.args.other.shift();
192
+ if (!filename || this.args.other.length) return this.dieUsage('system export');
193
+ filename = this.requireSystemFilename(filename);
194
+
195
+ var list_defs = (this.config.ui && this.config.ui.list_list) || [];
196
+ var index_defs = (this.config.ui && this.config.ui.database_list) || [];
197
+ var extra_defs = (this.config.ui && this.config.ui.extra_list) || [];
198
+ var lists = this.parseSystemCSV(('lists' in this.args) ? this.args.lists : 'all', 'Export lists', { empty: true });
199
+ var indexes = this.parseSystemCSV(('indexes' in this.args) ? this.args.indexes : (this.args.tables || 'none'), 'Export indexes', { empty: true });
200
+ var extras = this.parseSystemCSV(('extras' in this.args) ? this.args.extras : 'none', 'Export extras', { empty: true });
201
+ var overwrite = this.args.overwrite === true;
202
+
203
+ this.validateSystemSelection(lists, list_defs.map( item => item.id ), 'export list');
204
+ this.validateSystemSelection(indexes, index_defs.map( item => item.id ), 'export index');
205
+ this.validateSystemSelection(extras, extra_defs.map( item => item.id ), 'export extra');
206
+ if (lists.length == 1 && lists[0] == 'all') lists = list_defs.map( item => item.id );
207
+ if (indexes.length == 1 && indexes[0] == 'all') indexes = index_defs.map( item => item.id );
208
+ if (extras.length == 1 && extras[0] == 'all') extras = extra_defs.map( item => item.id );
209
+ if (!lists.length && !indexes.length && !extras.length) this.die("Select at least one list, index, or extra to export.");
210
+ if (('overwrite' in this.args) && (typeof(this.args.overwrite) != 'boolean')) this.die("System export --overwrite must be true or false.");
211
+
212
+ delete this.args.other;
213
+ delete this.args.lists;
214
+ delete this.args.indexes;
215
+ delete this.args.tables;
216
+ delete this.args.extras;
217
+ delete this.args.overwrite;
218
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System export option: --" + Tools.firstKey(this.args));
219
+ if (fs.existsSync(filename) && !overwrite) return this.die("Export file already exists. Add --overwrite to replace it: " + filename);
220
+
221
+ var request = { lists: lists, indexes: indexes, extras: extras };
222
+ if (this.dry || this.verbose) {
223
+ println("\n " + this.color('theme').bold('SYSTEM DATA EXPORT:'));
224
+ this.jsonOutput({ request: request, output: filename, overwrite: overwrite });
225
+ }
226
+ if (this.dry) {
227
+ this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without downloading data.");
228
+ return;
229
+ }
230
+
231
+ var temp_file = Path.join(Path.dirname(filename), '.xycli-system-export-' + crypto.randomBytes(12).toString('hex'));
232
+
233
+ cli.progress.start({ amount: 1, pct: false, text: gray('→ Exporting system data...') });
234
+ try {
235
+ // Create the partial download privately before handing it to the SDK. The
236
+ // completed archive can contain API Keys, Secret metadata, and user data.
237
+ fs.writeFileSync(temp_file, '', { flag: 'wx', mode: 0o600 });
238
+ var { err } = await this.api.adminExportData(request, { download: temp_file });
239
+ if (err) throw new Error(err);
240
+ fs.chmodSync(temp_file, 0o600);
241
+ if (overwrite) fs.renameSync(temp_file, filename);
242
+ else {
243
+ fs.linkSync(temp_file, filename);
244
+ fs.unlinkSync(temp_file);
245
+ }
246
+ }
247
+ catch (err) {
248
+ cli.progress.end();
249
+ if (fs.existsSync(temp_file)) fs.unlinkSync(temp_file);
250
+ return this.die("Failed to export system data: " + err.message);
251
+ }
252
+ cli.progress.end();
253
+
254
+ var result = { code: 0, file: filename, size: fs.statSync(filename).size };
255
+ if (this.format.match(/json/)) return this.jsonOutput(result);
256
+ this.toast('✅', 'green', "System data exported to: " + filename);
257
+ },
258
+
259
+ requireSystemFilename(value) {
260
+ if ((typeof(value) != 'string') || !value.trim() || value.includes('\0')) this.die("Please specify a valid file path.");
261
+ return Path.resolve(value);
262
+ },
263
+
264
+ async cmd_system_import() {
265
+ var filename = this.args.other.shift();
266
+ if (!filename || this.args.other.length) return this.dieUsage('system import');
267
+ filename = this.requireSystemFilename(filename);
268
+ if (!fs.existsSync(filename) || !fs.statSync(filename).isFile()) this.die("Import file not found: " + filename);
269
+
270
+ var source = this.args.source || 'xyops';
271
+ if (!['xyops', 'cronicle'].includes(source)) this.die("System import --source must be 'xyops' or 'cronicle'.");
272
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) this.die("System import --confirm must be true or false.");
273
+
274
+ delete this.args.other;
275
+ delete this.args.source;
276
+ var confirmed = this.args.confirm === true;
277
+ delete this.args.confirm;
278
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System import option: --" + Tools.firstKey(this.args));
279
+
280
+ this.printMutationSummary({
281
+ title: 'Import System Data',
282
+ rows: [
283
+ ['File', filename],
284
+ ['Size', Tools.getTextFromBytes(fs.statSync(filename).size)],
285
+ ['Source Format', source == 'xyops' ? 'xyOps' : 'Cronicle']
286
+ ]
287
+ });
288
+ if (!confirmed) {
289
+ this.toast('⚠️', 'orange', "Bulk import can replace data, stop jobs, clear the queue, and pause the scheduler. Add '--confirm' to continue.");
290
+ return;
291
+ }
292
+
293
+ var data = await this.callStandardAPI('adminImportData', { format: source }, {
294
+ files: [filename],
295
+ text: 'Uploading system data...'
296
+ });
297
+ if (this.dry) return;
298
+ if (this.format.match(/json/)) return this.jsonOutput(data);
299
+ this.toast('✅', 'green', "The system import job has started" + (data.id ? ': #' + data.id : '.'));
300
+ },
301
+
302
+ buildSystemDeleteItems(selection) {
303
+ var list_defs = (this.config.ui && this.config.ui.list_list) || [];
304
+ var index_defs = (this.config.ui && this.config.ui.database_list) || [];
305
+ var list_ids = list_defs.map( item => item.id );
306
+ var index_ids = index_defs.map( item => item.id );
307
+ var lists = [];
308
+ var indexes = [];
309
+
310
+ if (selection.length == 1 && selection[0] == 'all') {
311
+ lists = list_ids.slice(0);
312
+ indexes = index_ids.slice(0);
313
+ }
314
+ else {
315
+ selection.forEach( name => {
316
+ if (name.match(/^list:(.+)$/)) {
317
+ var list = RegExp.$1;
318
+ if (!list_ids.includes(list) && list != 'stats') this.die('Unknown system data list: "' + list + '".');
319
+ lists.push(list);
320
+ }
321
+ else if (name.match(/^(db|index):(.+)$/)) {
322
+ var index = RegExp.$2;
323
+ if (!index_ids.includes(index)) this.die('Unknown system database index: "' + index + '".');
324
+ indexes.push(index);
325
+ }
326
+ else if (name == 'alerts') {
327
+ // Alert definitions and Alert history share the same friendly name.
328
+ lists.push('alerts');
329
+ indexes.push('alerts');
330
+ }
331
+ else if (list_ids.includes(name) || (name == 'stats')) lists.push(name);
332
+ else if (index_ids.includes(name)) indexes.push(name);
333
+ else {
334
+ var allowed = Array.from(new Set(list_ids.concat(index_ids, ['stats'])));
335
+ var suggestion = this.findClosestString(name, allowed);
336
+ this.die('Unknown system data selection: "' + name + '".' + (suggestion ? ' Did you mean "' + suggestion + '"?' : ''));
337
+ }
338
+ });
339
+ }
340
+
341
+ lists = Array.from(new Set(lists));
342
+ indexes = Array.from(new Set(indexes));
343
+ var items = [];
344
+
345
+ // Delete dependent binary/encrypted data before deleting its metadata list.
346
+ if (lists.includes('users')) items.push({ type: 'users' });
347
+ if (lists.includes('buckets')) items.push({ type: 'bucketData' }, { type: 'bucketFiles' });
348
+ if (lists.includes('secrets')) items.push({ type: 'secretData' });
349
+ lists.forEach( list => items.push({ type: 'list', key: 'global/' + list }));
350
+ indexes.forEach( index => items.push({ type: 'index', index: index }));
351
+ return { items: items, lists: lists, indexes: indexes };
352
+ },
353
+
354
+ async cmd_system_delete() {
355
+ var selection = this.parseSystemCSV(this.args.other.shift(), 'System delete selection');
356
+ if (this.args.other.length) this.die("Unexpected System delete argument: " + this.args.other[0]);
357
+ var plan = this.buildSystemDeleteItems(selection);
358
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) this.die("System delete --confirm must be true or false.");
359
+ var confirmed = this.args.confirm === true;
360
+
361
+ delete this.args.other;
362
+ delete this.args.confirm;
363
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System delete option: --" + Tools.firstKey(this.args));
364
+
365
+ this.printMutationSummary({
366
+ title: 'Delete System Data',
367
+ rows: [
368
+ ['Storage Lists', plan.lists.join(', ') || '(None)'],
369
+ ['Database Indexes', plan.indexes.join(', ') || '(None)'],
370
+ ['Operations', Tools.commify(plan.items.length)]
371
+ ]
372
+ });
373
+ if (!confirmed) {
374
+ var warning = selection.includes('all') ?
375
+ "This will permanently delete ALL xyOps system data. Add '--confirm' to continue." :
376
+ "This will permanently delete the selected xyOps system data. Add '--confirm' to continue.";
377
+ this.toast('⚠️', 'orange', warning);
378
+ return;
379
+ }
380
+
381
+ var data = await this.callStandardAPI('adminDeleteData', { items: plan.items }, { text: 'Starting bulk deletion...' });
382
+ if (this.dry) return;
383
+ if (this.format.match(/json/)) return this.jsonOutput(data);
384
+ this.toast('✅', 'green', "The system data deletion job has started" + (data.id ? ': #' + data.id : '.'));
385
+ },
386
+
387
+ async cmd_system_maintenance() {
388
+ this.requireEmptySystemArgs('maintenance');
389
+ var data = await this.callStandardAPI('adminRunMaintenance', {}, { text: 'Starting system maintenance...' });
390
+ if (this.dry) return;
391
+ if (this.format.match(/json/)) return this.jsonOutput(data);
392
+ this.toast('✅', 'green', "The maintenance job has started" + (data.id ? ': #' + data.id : '.'));
393
+ },
394
+
395
+ async cmd_system_optimize() {
396
+ this.requireEmptySystemArgs('optimize');
397
+ var data = await this.callStandardAPI('adminRunOptimization', {}, { text: 'Starting database optimization...' });
398
+ if (this.dry) return;
399
+ if (this.format.match(/json/)) return this.jsonOutput(data);
400
+ this.toast('✅', 'green', "The database optimization job has started" + (data.id ? ': #' + data.id : '.'));
401
+ },
402
+
403
+ requireEmptySystemArgs(command) {
404
+ if (this.args.other.length) this.die("Unexpected System " + command + " argument: " + this.args.other[0]);
405
+ delete this.args.other;
406
+ if (Tools.numKeys(this.args)) this.die("Unsupported System " + command + " option: --" + Tools.firstKey(this.args));
407
+ },
408
+
409
+ async cmd_system_reset() {
410
+ var target = this.args.other.shift();
411
+ if (!target || this.args.other.length) return this.dieUsage('system reset');
412
+ if (!['daily', 'rates', 'sync'].includes(target)) this.die("System reset target must be 'daily', 'rates', or 'sync'.");
413
+
414
+ var id = this.args.id;
415
+ delete this.args.other;
416
+ delete this.args.id;
417
+ if (id && target != 'rates') this.die("The --id option is only available for rate limit resets.");
418
+
419
+ // Sync governance covers every up-only managed definition, so resetting it
420
+ // requires a deliberate confirmation. Daily and rate resets retain their
421
+ // existing immediate behavior.
422
+ var confirmed = false;
423
+ if (target == 'sync') {
424
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) this.die("System reset sync --confirm must be true or false.");
425
+ confirmed = this.args.confirm === true;
426
+ delete this.args.confirm;
427
+ }
428
+
429
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System reset option: --" + Tools.firstKey(this.args));
430
+
431
+ if (target == 'sync') {
432
+ this.printMutationSummary({
433
+ title: 'Reset Sync State',
434
+ rows: [
435
+ ['Target', 'All remote-management flags'],
436
+ ['New State', 'Empty sync map']
437
+ ]
438
+ });
439
+ if (!confirmed) {
440
+ this.toast('⚠️', 'orange', "This will clear all sync state flags and remove their edit warnings. Add '--confirm' to continue.");
441
+ return;
442
+ }
443
+
444
+ var data = await this.callStandardAPI('update_global_state', { sync: {} }, { text: 'Resetting sync state flags...' });
445
+ if (this.dry) return;
446
+ if (this.format.match(/json/)) return this.jsonOutput(data);
447
+ this.toast('✅', 'green', "The sync state flags have been reset.");
448
+ return;
449
+ }
450
+
451
+ var method = target == 'daily' ? 'adminResetDailyStats' : 'adminResetJobRateLimits';
452
+ var request = id ? { id: id } : {};
453
+ var data = await this.callStandardAPI(method, request, { text: 'Resetting ' + (target == 'daily' ? 'daily statistics' : 'rate limit windows') + '...' });
454
+ if (this.dry) return;
455
+ if (this.format.match(/json/)) return this.jsonOutput(data);
456
+ this.toast('✅', 'green', target == 'daily' ? "The daily statistics have been reset." : "The job rate limit windows have been reset.");
457
+ },
458
+
459
+ async cmd_system_conductor_command(action) {
460
+ // Conductor shutdown uses the underlying control script's "stop" command,
461
+ // while the friendlier public CLI command remains "shutdown".
462
+ var kind = this.args.other.shift();
463
+ var host = this.args.other.shift();
464
+ if (!kind || !host || this.args.other.length) return this.dieUsage('system ' + action);
465
+ if (kind != 'conductor') this.die("System " + action + " target type must be 'conductor'.");
466
+ if ((typeof(host) != 'string') || !host.match(/^[\w\-.]+$/)) this.die("Conductor hostname is invalid.");
467
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) this.die("System " + action + " --confirm must be true or false.");
468
+ var confirmed = this.args.confirm === true;
469
+
470
+ delete this.args.other;
471
+ delete this.args.confirm;
472
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System " + action + " option: --" + Tools.firstKey(this.args));
473
+
474
+ // Resolve the exact hostname against the current conductor inventory before
475
+ // displaying a preview or allowing the destructive request to proceed.
476
+ await this.getMultiple();
477
+ var conductors = Object.values(this.masters || {});
478
+ var conductor = conductors.find( item => item.id == host );
479
+ if (!conductor) {
480
+ var suggestion = this.findClosestString(host, conductors.map( item => item.id ));
481
+ this.die("Conductor hostname not found: \"" + host + "\"." + (suggestion ? " Did you mean \"" + suggestion + "\"?" : ''));
482
+ }
483
+
484
+ this.printMutationSummary({
485
+ title: action + ' Conductor',
486
+ rows: [
487
+ ['Hostname', host],
488
+ ['Action', action]
489
+ ]
490
+ });
491
+ if (!confirmed) {
492
+ this.toast('⚠️', 'orange', "This will " + action + " the conductor and may interrupt service. Review the hostname above, then add '--confirm' to continue.");
493
+ return;
494
+ }
495
+
496
+ var command = action == 'shutdown' ? 'stop' : 'restart';
497
+ var data = await this.callStandardAPI('masterCommand', {
498
+ host: host,
499
+ commands: [command]
500
+ }, { text: 'Sending conductor ' + action + ' request...' });
501
+ if (this.dry) return;
502
+ if (this.format.match(/json/)) return this.jsonOutput(data);
503
+ this.toast('✅', 'green', "The " + action + " request was successfully sent to conductor '" + host + "'.");
504
+ },
505
+
506
+ async cmd_system_upgrade() {
507
+ var kind = this.args.other.shift();
508
+ var target_value = this.args.other.shift();
509
+ if (!kind || !target_value || this.args.other.length) return this.dieUsage('system upgrade');
510
+ if (kind == 'masters') kind = 'conductors';
511
+ if (kind == 'servers') kind = 'workers';
512
+ if (!['conductors', 'workers'].includes(kind)) this.die("System upgrade target type must be 'conductors' or 'workers'.");
513
+
514
+ var targets = this.parseSystemCSV(target_value, 'Upgrade targets');
515
+ var release = this.args.version || this.args.release || 'latest';
516
+ var stagger = ('stagger' in this.args) ? this.args.stagger : 60;
517
+ if ((typeof(release) != 'string') || !release.match(/^[\w\-.]+$/)) this.die("Upgrade version is invalid.");
518
+ if (!Number.isInteger(stagger) || stagger < 0) this.die("Upgrade --stagger must be a non-negative integer number of seconds.");
519
+ if (('confirm' in this.args) && (typeof(this.args.confirm) != 'boolean')) this.die("System upgrade --confirm must be true or false.");
520
+ var confirmed = this.args.confirm === true;
521
+
522
+ delete this.args.other;
523
+ delete this.args.version;
524
+ delete this.args.release;
525
+ delete this.args.stagger;
526
+ delete this.args.confirm;
527
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System upgrade option: --" + Tools.firstKey(this.args));
528
+
529
+ this.printMutationSummary({
530
+ title: 'Upgrade ' + kind,
531
+ rows: [
532
+ ['Targets', targets.join(', ')],
533
+ ['Version', release],
534
+ ['Stagger', Tools.getTextFromSeconds(stagger, false, true)]
535
+ ]
536
+ });
537
+ if (!confirmed) {
538
+ this.toast('⚠️', 'orange', "Server upgrades can interrupt service. Review the targets above, then add '--confirm' to continue.");
539
+ return;
540
+ }
541
+
542
+ var method = kind == 'conductors' ? 'adminUpgradeMasters' : 'adminUpgradeWorkers';
543
+ var data = await this.callStandardAPI(method, { targets: targets, release: release, stagger: stagger }, { text: 'Starting ' + kind + ' upgrade...' });
544
+ if (this.dry) return;
545
+ if (this.format.match(/json/)) return this.jsonOutput(data);
546
+ this.toast('✅', 'green', "The " + kind + " upgrade job has started.");
547
+ },
548
+
549
+ async cmd_system_email() {
550
+ var to = this.args.other.shift() || this.args.to;
551
+ if (!to || this.args.other.length) return this.dieUsage('system email');
552
+ delete this.args.other;
553
+ delete this.args.to;
554
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System email option: --" + Tools.firstKey(this.args));
555
+
556
+ var data = await this.callStandardAPI('adminSendTestEmail', { to: to }, { text: 'Sending test email...' });
557
+ if (this.dry) return;
558
+ if (this.format.match(/json/)) return this.jsonOutput(data);
559
+
560
+ println("\n " + this.color('theme').bold('TEST EMAIL RESULTS'));
561
+ println("\n" + this.markdown("**Result:** " + data.description + "\n\n" + (data.details || '')).trimEnd());
562
+ },
563
+
564
+ async cmd_system_diagnostic() {
565
+ this.requireEmptySystemArgs('diagnostic');
566
+ var data = await this.loadSystemStats();
567
+
568
+ // Diagnostics are deliberately structured data rather than a second copy of
569
+ // the browser's Markdown report generator. This also makes the result easy
570
+ // to save, filter, compare, and attach to a support request.
571
+ this.jsonOutput({
572
+ generated: this.epoch,
573
+ timezone: this.config.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || '',
574
+ system: data,
575
+ conductors: this.masters || {},
576
+ workers: this.servers || {},
577
+ internalJobs: this.internalJobs || {},
578
+ activeJobs: this.activeJobs || {},
579
+ activeAlerts: this.activeAlerts || {},
580
+ state: this.state || {},
581
+ stats: this.stats || {}
582
+ });
583
+ },
584
+
585
+ async cmd_system_broadcast() {
586
+ var positional = this.args.other.join(' ').trim();
587
+ var message = this.args.message || positional;
588
+ if (!message || ((typeof(message) == 'string') && !message.trim())) return this.dieUsage('system broadcast');
589
+ if (positional && this.args.message && positional != this.args.message) this.die("Conflicting broadcast messages.");
590
+ if (typeof(message) != 'string') this.die("Broadcast message must be plain text.");
591
+
592
+ var type = String(this.args.type || 'info').toLowerCase();
593
+ if (!BROADCAST_TYPES.includes(type)) this.die("Broadcast --type must be one of: " + BROADCAST_TYPES.join(', '));
594
+ var request = { type: type, message: message.trim() };
595
+ if (this.args.sound) {
596
+ if (typeof(this.args.sound) != 'string') this.die("Broadcast --sound must be a filename.");
597
+ request.sound = this.args.sound;
598
+ }
599
+
600
+ delete this.args.other;
601
+ delete this.args.message;
602
+ delete this.args.type;
603
+ delete this.args.sound;
604
+ if (Tools.numKeys(this.args)) return this.die("Unsupported System broadcast option: --" + Tools.firstKey(this.args));
605
+
606
+ var data = await this.callStandardAPI('adminBroadcastMessage', request, { text: 'Broadcasting message...' });
607
+ if (this.dry) return;
608
+ if (this.format.match(/json/)) return this.jsonOutput(data);
609
+ this.toast('✅', 'green', "Message broadcast to all connected users.");
610
+ }
611
+
612
+ };