@jacebenson/jsn 4.3.7 → 4.3.8-dev.47

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 (41) hide show
  1. package/README.md +266 -1
  2. package/package.json +1 -1
  3. package/skills/servicenow/SKILL.md +16 -0
  4. package/src/app.js +8 -1
  5. package/src/auth.js +14 -12
  6. package/src/cli-adapter.js +162 -11
  7. package/src/cli.js +121 -13
  8. package/src/commands/_generic.js +3 -2
  9. package/src/commands/_simple.js +1 -1
  10. package/src/commands/actions.js +156 -0
  11. package/src/commands/auth.js +21 -11
  12. package/src/commands/cmdb.js +18 -0
  13. package/src/commands/diff.js +205 -0
  14. package/src/commands/docs/docs.js +18 -16
  15. package/src/commands/flows.js +149 -47
  16. package/src/commands/graphql.js +95 -0
  17. package/src/commands/groupmembers.js +19 -4
  18. package/src/commands/grouproles.js +15 -4
  19. package/src/commands/midservers.js +708 -0
  20. package/src/commands/perf.js +36 -9
  21. package/src/commands/platform.js +202 -0
  22. package/src/commands/records.js +19 -15
  23. package/src/commands/skill.js +5 -2
  24. package/src/commands/transactions.js +78 -0
  25. package/src/commands/updatesets.js +77 -11
  26. package/src/errors.js +71 -6
  27. package/src/flow-create.js +884 -0
  28. package/src/flow-designer-interactive.js +417 -0
  29. package/src/flow-inspection.js +15 -5
  30. package/src/flow-publish.js +32 -0
  31. package/src/flow-test.js +122 -0
  32. package/src/flow-triggers.js +448 -0
  33. package/src/flow-versioning.js +53 -0
  34. package/src/help.js +93 -28
  35. package/src/helpers.js +124 -9
  36. package/src/midserver-direct.js +312 -0
  37. package/src/midserver-install.js +170 -0
  38. package/src/midserver-zip.js +107 -0
  39. package/src/output.js +7 -0
  40. package/src/processflow-actions.js +366 -0
  41. package/src/sdk.js +8 -0
@@ -0,0 +1,708 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { input, password as passwordPrompt, select } from '@inquirer/prompts';
5
+ import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt, assertSafeExactMatch } from '../helpers.js';
6
+ import { FormatAuto } from '../output.js';
7
+ import { declareCapabilities } from '../capabilities.js';
8
+ import { resolveRecord, unwrapSysId } from '../resolve-record.js';
9
+ import { detectDocker, defaultRun, installDockerMidServer, midInstallDirectory, normalizeMidName, validateMidCredentials } from '../midserver-install.js';
10
+ import { detectDirectPlatform, followDirectLog, installDirectMidServer, readDirectInstallation, removeDirectInstallation, resolveDirectLogFile, runDirectInstallation } from '../midserver-direct.js';
11
+
12
+ const AGENT_TABLE = 'ecc_agent';
13
+ const QUEUE_TABLE = 'ecc_queue';
14
+ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'version', 'validated', 'last_heartbeat', 'sys_updated_on'];
15
+ const DEFAULT_TIMEOUT = 120000;
16
+ const DEFAULT_INTERVAL = 2000;
17
+
18
+ declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute', 'start', 'stop'] });
19
+
20
+ function agentName(record) {
21
+ const name = getStringField(record, 'name');
22
+ return `mid.server.${name}`;
23
+ }
24
+
25
+ function queueValue(value) {
26
+ return value && typeof value === 'object' ? (value.value || value.display_value || '') : String(value || '');
27
+ }
28
+
29
+ function escapeXml(value) {
30
+ return String(value)
31
+ .replaceAll('&', '&')
32
+ .replaceAll('"', '"')
33
+ .replaceAll('<', '&lt;')
34
+ .replaceAll('>', '&gt;')
35
+ .replaceAll("'", '&apos;');
36
+ }
37
+
38
+ export function buildCommandPayload(command) {
39
+ return `<parameters><parameter name="name" value="${escapeXml(command)}"/></parameters>`;
40
+ }
41
+
42
+ function decodeXml(value) {
43
+ return String(value || '')
44
+ .replaceAll('&lt;', '<')
45
+ .replaceAll('&gt;', '>')
46
+ .replaceAll('&quot;', '"')
47
+ .replaceAll('&apos;', "'")
48
+ .replaceAll('&amp;', '&');
49
+ }
50
+
51
+ export function parseCommandResult(payload) {
52
+ if (!payload) return { stdout: '', stderr: '' };
53
+ const result = payload.match(/<result\b[^>]*>([\s\S]*?)<\/result>/i)?.[1] || payload;
54
+ const stdout = result.match(/<stdout\b[^>]*>([\s\S]*?)<\/stdout>/i)?.[1] || '';
55
+ const stderr = result.match(/<stderr\b[^>]*>([\s\S]*?)<\/stderr>/i)?.[1] || '';
56
+ return { stdout: decodeXml(stdout), stderr: decodeXml(stderr) };
57
+ }
58
+
59
+ async function waitForCommandResult(sdk, outputSysId, timeoutMs, intervalMs) {
60
+ const started = Date.now();
61
+ while (Date.now() - started <= timeoutMs) {
62
+ const params = new URLSearchParams({
63
+ sysparm_query: `response_to=${outputSysId}^queue=input`,
64
+ sysparm_limit: '1',
65
+ sysparm_display_value: 'all',
66
+ sysparm_fields: 'sys_id,state,error,payload,name,topic,queue,agent,created_on,processed',
67
+ });
68
+ const records = await sdk.list(QUEUE_TABLE, params);
69
+ if (records.length > 0) return records[0];
70
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
71
+ }
72
+ const error = new Error(`Timed out waiting for MID Server response to ECC Queue record ${outputSysId}`);
73
+ error.code = 'midserver_timeout';
74
+ throw error;
75
+ }
76
+
77
+ async function resolveMidServer(sdk, identifier) {
78
+ try {
79
+ return await resolveRecord(sdk, {
80
+ table: AGENT_TABLE,
81
+ identifier,
82
+ matchField: 'name',
83
+ resource: 'MID Server',
84
+ });
85
+ } catch (error) {
86
+ if (error.code !== 'not_found' || identifier.startsWith('mid.server.')) throw error;
87
+ return resolveRecord(sdk, {
88
+ table: AGENT_TABLE,
89
+ identifier: `mid.server.${identifier}`,
90
+ matchField: 'name',
91
+ resource: 'MID Server',
92
+ });
93
+ }
94
+ }
95
+
96
+ async function showMidDiagnostics(app, argv, kind) {
97
+ app.requireInstance();
98
+ let mid;
99
+ if (argv.identifier) {
100
+ mid = await resolveMidServer(app.sdk, argv.identifier);
101
+ } else {
102
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) {
103
+ throw new Error('An identifier is required outside an interactive terminal');
104
+ }
105
+ mid = await interactiveList({
106
+ app, table: AGENT_TABLE, singular: 'MID Server', columns: LIST_FIELDS,
107
+ limit: 50, labelField: 'name', message: `Select a MID Server for ${kind}`,
108
+ formatLabel: record => `${getStringField(record, 'name')} [${getStringField(record, 'status')}]`,
109
+ });
110
+ if (!mid) return;
111
+ }
112
+ const name = getStringField(mid, 'name');
113
+ const sysId = unwrapSysId(mid);
114
+ assertSafeExactMatch(name);
115
+ assertSafeExactMatch(sysId);
116
+ const issues = kind === 'issues';
117
+ const table = issues ? 'ecc_agent_issue' : 'mid_server_event';
118
+ const columns = issues
119
+ ? ['sys_id', 'state', 'source', 'message', 'last_detected']
120
+ : ['sys_id', 'event', 'source', 'message', 'sys_created_on'];
121
+ const query = issues
122
+ ? `mid_server=${sysId}${argv.all ? '' : '^state!=resolved'}^ORDERBYDESClast_detected`
123
+ : `agent=${name}^ORDERBYDESCsys_created_on`;
124
+ const limit = argv.limit ?? 50;
125
+ const offset = argv.offset ?? 0;
126
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0) {
127
+ throw new Error('Use --limit between 1 and 1000 and a nonnegative --offset');
128
+ }
129
+ const records = await app.sdk.list(table, new URLSearchParams({
130
+ sysparm_query: query, sysparm_fields: columns.join(','), sysparm_display_value: 'all',
131
+ sysparm_limit: String(limit), sysparm_offset: String(offset),
132
+ }));
133
+ const instanceURL = app.getEffectiveInstance();
134
+ const url = new URL(`/${table}_list.do`, instanceURL);
135
+ url.searchParams.set('sysparm_query', query);
136
+ const rows = records.map(record => ({
137
+ ...record,
138
+ url: new URL(`/${table}.do?sys_id=${encodeURIComponent(unwrapSysId(record))}`, instanceURL).href,
139
+ }));
140
+ const label = issues ? (argv.all ? 'issues' : 'unresolved issues') : 'lifecycle events';
141
+ const summary = rows.length
142
+ ? `Showing ${rows.length} ${label} for MID Server '${name}' (offset ${offset})`
143
+ : `No ${label} returned for MID Server '${name}' (offset ${offset})`;
144
+ const lines = [summary, ''];
145
+ for (const record of rows) {
146
+ lines.push(`${getStringField(record, issues ? 'last_detected' : 'sys_created_on')} ${getStringField(record, issues ? 'state' : 'event')}`);
147
+ const source = getStringField(record, 'source');
148
+ const message = getStringField(record, 'message');
149
+ if (source) lines.push(` ${source}`);
150
+ if (message) lines.push(` ${message}`);
151
+ lines.push(` ${record.url}`, '');
152
+ }
153
+ lines.push(`View in ServiceNow: ${url.href}`);
154
+ app.ok({
155
+ mid_server: name, sys_id: sysId, table, count: rows.length, records: rows,
156
+ pagination: { limit, offset }, url: url.href, context: { instance_url: instanceURL },
157
+ _formatted: `${lines.join('\n')}\n`,
158
+ }, { summary });
159
+ }
160
+
161
+ function midDiagnosticsCommand(kind, wrap) {
162
+ return {
163
+ command: `${kind} [identifier]`,
164
+ describe: kind === 'issues' ? 'Show MID Server issues (unresolved by default)' : 'Show MID Server lifecycle history',
165
+ builder: b => {
166
+ b.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records (1-1000)' })
167
+ .option('offset', { type: 'number', default: 0, describe: 'Result offset' });
168
+ if (kind === 'issues') b.option('all', { type: 'boolean', default: false, describe: 'Include resolved issues' });
169
+ return b;
170
+ },
171
+ handler: wrap(async (argv, app) => showMidDiagnostics(app, argv, kind)),
172
+ };
173
+ }
174
+
175
+ // Progress for long, otherwise-silent direct-install phases. Human terminals
176
+ // only, and on stderr, so the stdout envelope and --json output stay clean.
177
+ export function directProgress(app) {
178
+ const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
179
+ if (!human) return () => {};
180
+ const mib = bytes => (Number(bytes || 0) / (1024 * 1024)).toFixed(0);
181
+ return event => {
182
+ if (event.phase === 'download-start') {
183
+ process.stderr.write(`Downloading the official MID ZIP from ServiceNow (about 532 MiB).\n ${event.archiveURL}\n`);
184
+ } else if (event.phase === 'download') {
185
+ const percent = event.total ? ` (${Math.floor((event.received / event.total) * 100)}%)` : '';
186
+ process.stderr.write(`\r ${mib(event.received)} MiB${percent} downloaded...`);
187
+ if (event.done) process.stderr.write('\n');
188
+ } else if (event.phase === 'extract-start') {
189
+ process.stderr.write(`Extracting ${event.entries} files into ${event.installDir}...\n`);
190
+ } else if (event.phase === 'extract-done') {
191
+ process.stderr.write('Extraction complete. Configuring the MID Server...\n');
192
+ }
193
+ };
194
+ }
195
+
196
+ // A registration wait is minutes of silence otherwise; report elapsed progress.
197
+ export function registrationProgress(app, timeoutMs) {
198
+ const human = app.output?.getFormat() === FormatAuto && (canPrompt() || process.stderr.isTTY === true);
199
+ if (!human) return undefined;
200
+ const seconds = Math.round(timeoutMs / 1000);
201
+ let lastReport = 0;
202
+ return elapsedMs => {
203
+ if (elapsedMs - lastReport < 15000) return;
204
+ lastReport = elapsedMs;
205
+ process.stderr.write(`Waiting for ServiceNow to register the MID Server... ${Math.round(elapsedMs / 1000)}s of ${seconds}s\n`);
206
+ };
207
+ }
208
+
209
+ async function listAgents(app, argv) {
210
+ const columns = argv.columns
211
+ ? [...new Set(argv.columns.split(',').map(column => column.trim()).filter(Boolean))]
212
+ : LIST_FIELDS;
213
+ const query = argv.query || '';
214
+
215
+ if (canPrompt() && app.output.getFormat() === FormatAuto && !query) {
216
+ const picked = await interactiveList({
217
+ app,
218
+ table: AGENT_TABLE,
219
+ singular: 'MID Server',
220
+ columns,
221
+ limit: argv.limit,
222
+ labelField: 'name',
223
+ message: 'Select a MID Server',
224
+ formatLabel: (record) => {
225
+ const name = getStringField(record, 'name') || getStringField(record, 'sys_id');
226
+ const status = getStringField(record, 'status');
227
+ return status ? `${name} [${status}]` : name;
228
+ },
229
+ });
230
+ if (picked === undefined || picked === null) return;
231
+ picked._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
232
+ app.ok(picked, {
233
+ summary: `MID Server: ${getStringField(picked, 'name') || getStringField(picked, 'sys_id')}`,
234
+ breadcrumbs: [{ action: 'list', cmd: 'jsn midservers list', description: 'Back to all MID Servers' }],
235
+ });
236
+ return;
237
+ }
238
+
239
+ const params = new URLSearchParams({
240
+ sysparm_limit: String(argv.limit),
241
+ sysparm_display_value: 'all',
242
+ sysparm_fields: ['sys_id', ...columns.filter(column => column !== 'sys_id')].join(','),
243
+ sysparm_query: query ? `${query}^ORDERBYDESCsys_updated_on` : 'ORDERBYname',
244
+ });
245
+ const records = await app.sdk.list(AGENT_TABLE, params);
246
+ app.ok({
247
+ table: AGENT_TABLE,
248
+ count: records.length,
249
+ columns,
250
+ records: records.map(record => formatRecordForDisplay(record, columns)),
251
+ context: { instance_url: app.getEffectiveInstance() },
252
+ }, { summary: `${records.length} MID Server(s)` });
253
+ }
254
+
255
+ export function assertMidServerUp(record) {
256
+ const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
257
+ const status = getStringField(record, 'status');
258
+ if (status.toLowerCase() !== 'up') {
259
+ const error = new Error(`MID Server '${name}' is not Up (status: ${status || 'unknown'}). Start or install it before executing a command.`);
260
+ error.code = 'midserver_not_up';
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ export function assertMidServerReady(record) {
266
+ assertMidServerUp(record);
267
+ const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
268
+ const validated = queueValue(record.validated).toLowerCase();
269
+ if (validated !== 'true') {
270
+ const error = new Error(`MID Server '${name}' is not validated yet (validation: ${validated || 'unknown'}). Use the manual Validate action in ServiceNow before executing a command.`);
271
+ error.code = 'midserver_not_validated';
272
+ throw error;
273
+ }
274
+ }
275
+
276
+ async function promptMidCredentials(app, argv) {
277
+ if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
278
+ let username = argv.username || '';
279
+ while (true) {
280
+ username = username || (await input({
281
+ message: 'MID Server username:',
282
+ validate: value => value.trim() ? true : 'Username is required',
283
+ })).trim();
284
+ const password = await passwordPrompt({
285
+ message: 'MID Server password:',
286
+ mask: '*',
287
+ validate: value => value ? true : 'Password is required',
288
+ });
289
+ try {
290
+ const validation = await validateMidCredentials(app.getEffectiveInstance(), username, password);
291
+ return { username, password, releaseName: validation.releaseName };
292
+ } catch (error) {
293
+ const answer = await input({ message: `${error.message}. Press Enter to retry or Q to quit:` });
294
+ if (answer.trim().toLowerCase() === 'q') {
295
+ const quit = new Error('MID Server installation cancelled');
296
+ quit.code = 'cancelled';
297
+ throw quit;
298
+ }
299
+ username = argv.username || '';
300
+ }
301
+ }
302
+ }
303
+
304
+ export async function waitForMidServerUp(app, name, timeoutMs, intervalMs, onWait) {
305
+ const started = Date.now();
306
+ const fullName = name;
307
+ while (Date.now() - started <= timeoutMs) {
308
+ onWait?.(Date.now() - started);
309
+ const params = new URLSearchParams({
310
+ sysparm_query: `name=${fullName}`,
311
+ sysparm_limit: '1',
312
+ sysparm_display_value: 'all',
313
+ sysparm_fields: 'sys_id,name,status,validated,host_name,version,last_heartbeat',
314
+ });
315
+ const records = await app.sdk.list(AGENT_TABLE, params);
316
+ if (records[0] && getStringField(records[0], 'status').toLowerCase() === 'up') return records[0];
317
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
318
+ }
319
+ throw new Error(`MID Server '${fullName}' did not report Up within ${timeoutMs}ms`);
320
+ }
321
+
322
+ function removeLocalMidServer(instanceURL, name, directory) {
323
+ const direct = readDirectInstallation(instanceURL, name, directory);
324
+ if (direct) { removeDirectInstallation(direct); return true; }
325
+ const installDir = directory || midInstallDirectory(instanceURL, name);
326
+ const composePath = path.join(installDir, 'docker-compose.yaml');
327
+ const envPath = path.join(installDir, '.env');
328
+ if (!fs.existsSync(composePath)) {
329
+ if (fs.existsSync(path.join(installDir, 'agent'))) throw new Error('Native MID directory exists without trustworthy ownership metadata; refusing removal');
330
+ return false;
331
+ }
332
+ const composeArgs = ['compose', ...(fs.existsSync(envPath) ? ['--env-file', envPath] : []), '-f', composePath, 'down'];
333
+ try {
334
+ defaultRun('docker', composeArgs, { cwd: installDir });
335
+ } catch {
336
+ defaultRun('sudo', ['systemctl', 'start', 'docker']);
337
+ defaultRun('sudo', ['docker', ...composeArgs], { cwd: installDir });
338
+ }
339
+ try {
340
+ fs.rmSync(installDir, { recursive: true, force: true });
341
+ } catch {
342
+ defaultRun('sudo', ['rm', '-rf', '--', installDir]);
343
+ }
344
+ return true;
345
+ }
346
+
347
+ async function showMidServerLogs(app, identifier, argv) {
348
+ const installDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), identifier);
349
+ const direct = readDirectInstallation(app.getEffectiveInstance(), identifier, argv.directory);
350
+ if (direct) {
351
+ if (!Number.isInteger(argv.tail) || argv.tail < 1 || argv.tail > 10000) throw new Error('Use --tail between 1 and 10000');
352
+ const logfile = resolveDirectLogFile(installDir);
353
+ if (!logfile) throw new Error('No MID agent log yet; the installation may only be prepared');
354
+ const stat = fs.lstatSync(logfile);
355
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Untrusted MID log path');
356
+ const fd = fs.openSync(logfile, 'r');
357
+ let text;
358
+ try {
359
+ const length = Math.min(stat.size, 1024 * 1024);
360
+ const buffer = Buffer.alloc(length);
361
+ fs.readSync(fd, buffer, 0, length, stat.size - length);
362
+ const all = buffer.toString('utf8').split('\n');
363
+ if (all.length && all[all.length - 1] === '') all.pop();
364
+ text = all.slice(-argv.tail).join('\n');
365
+ } finally { fs.closeSync(fd); }
366
+ if (argv.follow) {
367
+ // Following streams new lines directly; the JSON envelope would be unreachable.
368
+ process.stdout.write(`Tailing ${logfile} (Ctrl+C to stop)\n${text}\n`);
369
+ return followDirectLog(installDir);
370
+ }
371
+ app.ok({ installation: 'direct', logfile, text, _formatted: `${text}\n` }, { summary: 'Local MID agent log (last 1 MiB maximum)' });
372
+ return;
373
+ }
374
+ const composePath = path.join(installDir, 'docker-compose.yaml');
375
+ const envPath = path.join(installDir, '.env');
376
+ if (!fs.existsSync(composePath)) throw new Error(`No local Compose installation found for MID Server '${identifier}'`);
377
+ const args = ['compose', ...(fs.existsSync(envPath) ? ['--env-file', envPath] : []), '-f', composePath, 'logs', '--tail', String(argv.tail)];
378
+ if (argv.follow) args.push('--follow');
379
+ try {
380
+ defaultRun('docker', args, { cwd: installDir });
381
+ } catch {
382
+ defaultRun('sudo', ['systemctl', 'start', 'docker']);
383
+ defaultRun('sudo', ['docker', ...args], { cwd: installDir });
384
+ }
385
+ }
386
+
387
+ export function midserversCmd(wrap) {
388
+ return {
389
+ command: 'midservers [subcommand]',
390
+ aliases: ['midserver', 'mid'],
391
+ describe: 'Manage ServiceNow MID Servers and run commands through them',
392
+ builder: (y) => y
393
+ .command(midDiagnosticsCommand('issues', wrap))
394
+ .command(midDiagnosticsCommand('events', wrap))
395
+ .command({
396
+ command: 'list',
397
+ aliases: ['ls'],
398
+ describe: 'List MID Servers',
399
+ builder: (b) => b
400
+ .option('query', { type: 'string', describe: 'Encoded query against ecc_agent' })
401
+ .option('columns', { type: 'string', describe: 'Comma-separated fields' })
402
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
403
+ handler: wrap(async (argv, app) => {
404
+ await listAgents(app, argv);
405
+ }),
406
+ })
407
+ .command({
408
+ command: 'show <identifier>',
409
+ aliases: ['get'],
410
+ describe: 'Show a MID Server by name or sys_id',
411
+ handler: wrap(async (argv, app) => {
412
+ const record = await resolveMidServer(app.sdk, argv.identifier);
413
+ record._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
414
+ app.ok(record, { summary: `MID Server: ${getStringField(record, 'name') || argv.identifier}` });
415
+ }),
416
+ })
417
+ .command({
418
+ command: 'logs <identifier>',
419
+ describe: 'Show local native or Docker Compose MID logs',
420
+ builder: (b) => b
421
+ .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
422
+ .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
423
+ .option('directory', { type: 'string', describe: 'Local installation directory override' }),
424
+ handler: wrap(async (argv, app) => {
425
+ await showMidServerLogs(app, argv.identifier, argv);
426
+ }),
427
+ })
428
+ .command({
429
+ command: '<identifier> logs',
430
+ describe: 'Show local native or Docker Compose MID logs',
431
+ builder: (b) => b
432
+ .option('tail', { type: 'number', default: 100, describe: 'Number of log lines' })
433
+ .option('follow', { type: 'boolean', default: false, describe: 'Follow the logs' })
434
+ .option('directory', { type: 'string', describe: 'Local installation directory override' }),
435
+ handler: wrap(async (argv, app) => {
436
+ await showMidServerLogs(app, argv.identifier, argv);
437
+ }),
438
+ })
439
+ .command({
440
+ command: 'start <identifier>',
441
+ describe: 'Start an owned direct MID (Windows: foreground console, no service)',
442
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
443
+ handler: wrap(async (argv, app) => {
444
+ const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
445
+ if (!metadata) throw new Error('No owned direct MID installation found');
446
+ if (metadata.state !== 'prepared') throw new Error('MID is already started or has an uncertain runtime state; stop it first');
447
+ const result = runDirectInstallation(metadata, 'start');
448
+ app.ok(result, { summary: result.platform === 'windows' ? 'Windows MID console exited; no service was registered' : 'MID start command completed; registration and manual validation must be checked in ServiceNow' });
449
+ }),
450
+ })
451
+ .command({
452
+ command: 'stop <identifier>',
453
+ describe: 'Stop an owned Linux direct MID; Windows console uses Ctrl+C',
454
+ builder: b => b.option('directory', { type: 'string', describe: 'Installation directory override' }),
455
+ handler: wrap(async (argv, app) => {
456
+ const metadata = readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory);
457
+ if (!metadata) throw new Error('No owned direct MID installation found');
458
+ app.ok(runDirectInstallation(metadata, 'stop'), { summary: 'Owned native MID stop command completed' });
459
+ }),
460
+ })
461
+ .command({
462
+ command: 'add',
463
+ describe: 'Install and verify a MID Server',
464
+ builder: (b) => b
465
+ .option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
466
+ .option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
467
+ .option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
468
+ .option('directory', { type: 'string', describe: 'Installation directory (must not exist for direct ZIP)' })
469
+ .option('wait-timeout', { type: 'number', default: 300000, describe: 'How long to wait for the MID Server to report Up' })
470
+ .option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
471
+ handler: wrap(async (argv, app) => {
472
+ app.requireInstance();
473
+ const credentials = await promptMidCredentials(app, argv);
474
+ const name = argv.name || (await input({
475
+ message: 'MID Server name:',
476
+ validate: value => value.trim() ? true : 'MID Server name is required',
477
+ })).trim();
478
+ const dockerStatus = detectDocker();
479
+ const dockerAvailable = dockerStatus.available;
480
+ const installMethod = argv['install-method'] || await select({
481
+ message: dockerAvailable
482
+ ? 'Docker and Docker Compose are ready. How should the MID Server be installed?'
483
+ : dockerStatus.installed && dockerStatus.compose
484
+ ? 'Docker is installed, but this user cannot access the Docker daemon. How should the MID Server be installed?'
485
+ : 'Docker was not detected. How should the MID Server be installed?',
486
+ choices: dockerAvailable
487
+ ? [
488
+ { name: 'Docker (recommended)', value: 'docker' },
489
+ { name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
490
+ ]
491
+ : dockerStatus.installed && dockerStatus.compose
492
+ ? [
493
+ { name: 'Run with sudo', value: 'sudo' },
494
+ { name: 'Prepare files now, run Docker later', value: 'prepare' },
495
+ { name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
496
+ ]
497
+ : [
498
+ { name: 'Prepare files now, run Docker later', value: 'prepare' },
499
+ { name: 'Direct ZIP install (native user-owned runtime)', value: 'direct' },
500
+ ],
501
+ });
502
+ if (installMethod === 'direct') {
503
+ const native = detectDirectPlatform();
504
+ const decision = await select({
505
+ message: native === 'linux' ? 'No system service or elevation will be used. Choose runtime:' : 'Windows console runtime only; native service registration is not supported. Choose:',
506
+ choices: [
507
+ ...(native === 'linux' ? [{ name: 'Start user-owned background MID', value: 'start' }] : []),
508
+ { name: 'Prepare private files only (do not start)', value: 'prepare' },
509
+ { name: 'Cancel without downloading', value: 'cancel' },
510
+ ],
511
+ });
512
+ if (decision === 'cancel') return;
513
+ const shortName = normalizeMidName(name);
514
+ const existing = await app.sdk.list(AGENT_TABLE, new URLSearchParams({ sysparm_query: `name=${shortName}`, sysparm_fields: 'sys_id,name', sysparm_limit: '1' }));
515
+ if (existing.length) throw new Error(`MID '${shortName}' already exists in ServiceNow; choose a new name. Existing runtime and record were not changed.`);
516
+ const installation = await installDirectMidServer({
517
+ instanceURL: app.getEffectiveInstance(), ...credentials, name, directory: argv.directory,
518
+ runNow: decision === 'start', onProgress: directProgress(app),
519
+ });
520
+ if (installation.state === 'prepared') {
521
+ app.ok(installation, { summary: `Prepared native MID '${installation.name}' in ${installation.installDir}; not started or registered. Start with jsn midservers start ${installation.name}${argv.directory ? ' --directory ' + JSON.stringify(installation.installDir) : ''}. Windows start runs in the foreground console; no service is installed.` });
522
+ return;
523
+ }
524
+ let record;
525
+ const directWait = Math.max(1, argv['wait-timeout']);
526
+ const directInterval = Math.max(500, argv['wait-interval']);
527
+ const onWait = registrationProgress(app, directWait);
528
+ const started = Date.now();
529
+ try {
530
+ record = await waitForMidServerUp(app, installation.name, directWait, directInterval, onWait);
531
+ onWait?.(directWait);
532
+ }
533
+ catch {
534
+ onWait?.(Math.max(directWait, Date.now() - started));
535
+ app.ok({ ...installation, registration: 'pending', validated: false }, { summary: `Native MID start command completed; registration is pending. Runtime retained for diagnostics at ${installation.installDir}; no forced teardown.` });
536
+ return;
537
+ }
538
+ const validated = queueValue(record.validated).toLowerCase() === 'true';
539
+ const url = new URL(`/ecc_agent.do?sys_id=${encodeURIComponent(unwrapSysId(record))}`, app.getEffectiveInstance()).href;
540
+ app.ok({ ...installation, registration: 'up', validated, record, url }, { summary: validated ? `MID '${installation.name}' is Up and manually validated` : `MID '${installation.name}' is Up but pending manual validation. Use Validate in ServiceNow: ${url}` });
541
+ return;
542
+ }
543
+
544
+ const installation = await installDockerMidServer({
545
+ instanceURL: app.getEffectiveInstance(),
546
+ username: credentials.username,
547
+ password: credentials.password,
548
+ name,
549
+ releaseName: credentials.releaseName,
550
+ directory: argv.directory,
551
+ runNow: false,
552
+ });
553
+ if (installMethod === 'sudo') {
554
+ defaultRun('sudo', ['systemctl', 'start', 'docker']);
555
+ defaultRun('sudo', ['docker', 'compose', '--env-file', path.join(installation.installDir, '.env'), '-f', installation.composePath, 'up', '-d'], { cwd: installation.installDir });
556
+ installation.prepared = false;
557
+ }
558
+ if (installation.prepared) {
559
+ app.ok(installation, { summary: `Prepared MID Server files in ${installation.installDir}. Run: ${installation.runCommand}` });
560
+ return;
561
+ }
562
+ console.log('MID Server container started. ServiceNow may take a few minutes to register it and report Up.');
563
+ let record;
564
+ try {
565
+ record = await waitForMidServerUp(app, installation.name, Math.max(1, argv['wait-timeout']), Math.max(500, argv['wait-interval']));
566
+ } catch (error) {
567
+ try { installation.rollback(); } catch { /* preserve the verification error */ }
568
+ throw error;
569
+ }
570
+ app.ok({
571
+ installation: 'docker',
572
+ directory: installation.installDir,
573
+ release: installation.releaseName,
574
+ record,
575
+ }, { summary: `Installed MID Server '${installation.name}' and verified it is Up` });
576
+ }),
577
+ })
578
+ .command({
579
+ command: 'remove [identifier]',
580
+ aliases: ['delete'],
581
+ describe: 'Remove a MID Server record (interactive when no identifier is given)',
582
+ builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' })
583
+ .option('directory', { type: 'string', describe: 'Local installation directory override' }),
584
+ handler: wrap(async (argv, app) => {
585
+ let record;
586
+ if (argv.identifier) {
587
+ try {
588
+ record = await resolveMidServer(app.sdk, argv.identifier);
589
+ } catch (error) {
590
+ if (error.code !== 'not_found') throw error;
591
+ const localDir = argv.directory || midInstallDirectory(app.getEffectiveInstance(), argv.identifier);
592
+ if (!readDirectInstallation(app.getEffectiveInstance(), argv.identifier, argv.directory) && !fs.existsSync(path.join(localDir, 'docker-compose.yaml'))) throw error;
593
+ record = null;
594
+ }
595
+ } else {
596
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) {
597
+ throw new Error('An identifier is required outside an interactive terminal');
598
+ }
599
+ record = await interactiveList({
600
+ app,
601
+ table: AGENT_TABLE,
602
+ singular: 'MID Server',
603
+ columns: LIST_FIELDS,
604
+ limit: 50,
605
+ labelField: 'name',
606
+ message: 'Select a MID Server to remove',
607
+ formatLabel: (candidate) => {
608
+ const name = getStringField(candidate, 'name') || getStringField(candidate, 'sys_id');
609
+ const status = getStringField(candidate, 'status');
610
+ return status ? `${name} [${status}]` : name;
611
+ },
612
+ });
613
+ if (!record) return;
614
+ }
615
+ const sysId = record ? unwrapSysId(record) : null;
616
+ const name = getStringField(record, 'name') || argv.identifier || sysId;
617
+ await confirmDelete(app, argv, `Remove MID Server '${name}'`);
618
+ const direct = readDirectInstallation(app.getEffectiveInstance(), name, argv.directory);
619
+ const localRemoved = removeLocalMidServer(app.getEffectiveInstance(), name, argv.directory);
620
+ if (record) await app.sdk.delete(AGENT_TABLE, sysId);
621
+ app.ok({ name, sys_id: sysId, deleted: Boolean(record), local_compose_removed: localRemoved && !direct, local_installation_removed: localRemoved, local_installation_type: direct ? 'direct' : 'docker' }, { summary: `Removed MID Server '${name}'${localRemoved ? ' and its owned local runtime' : ''}` });
622
+ }),
623
+ })
624
+ .command({
625
+ command: 'execute [identifier]',
626
+ describe: 'Execute a shell command through a MID Server',
627
+ builder: (b) => b
628
+ .option('command', { alias: 'c', type: 'string', describe: 'Command line to execute on the MID Server host' })
629
+ .option('timeout', { type: 'number', default: DEFAULT_TIMEOUT, describe: 'Response timeout in milliseconds' })
630
+ .option('interval', { type: 'number', default: DEFAULT_INTERVAL, describe: 'Polling interval in milliseconds' }),
631
+ handler: wrap(async (argv, app) => {
632
+ let record;
633
+ if (argv.identifier) {
634
+ record = await resolveMidServer(app.sdk, argv.identifier);
635
+ } else {
636
+ if (!canPrompt() || app.output.getFormat() !== FormatAuto) {
637
+ throw new Error('An identifier and --command are required outside an interactive terminal');
638
+ }
639
+ record = await interactiveList({
640
+ app,
641
+ table: AGENT_TABLE,
642
+ singular: 'MID Server',
643
+ columns: LIST_FIELDS,
644
+ limit: 50,
645
+ labelField: 'name',
646
+ message: 'Select a MID Server to execute on',
647
+ formatLabel: (candidate) => {
648
+ const name = getStringField(candidate, 'name') || getStringField(candidate, 'sys_id');
649
+ const status = getStringField(candidate, 'status');
650
+ return status ? `${name} [${status}]` : name;
651
+ },
652
+ });
653
+ if (!record) return;
654
+ }
655
+ const command = argv.command || await input({
656
+ message: 'Command to execute:',
657
+ validate: value => value.trim() ? true : 'Command is required',
658
+ });
659
+ assertMidServerReady(record);
660
+ const agent = agentName(record);
661
+ const output = await app.sdk.create(QUEUE_TABLE, {
662
+ agent,
663
+ topic: 'Command',
664
+ queue: 'output',
665
+ name: command,
666
+ payload: buildCommandPayload(command),
667
+ });
668
+ const outputSysId = unwrapSysId(output);
669
+ if (!outputSysId) throw new Error('ServiceNow did not return the ECC Queue sys_id for the command');
670
+ const response = await waitForCommandResult(app.sdk, outputSysId, Math.max(1, argv.timeout), Math.max(250, argv.interval));
671
+ const responseError = getStringField(response, 'error');
672
+ if (responseError) throw new Error(`MID Server command failed: ${responseError}`);
673
+ const result = parseCommandResult(queueValue(response.payload));
674
+ const responseSysId = unwrapSysId(response);
675
+ const responseURL = responseSysId
676
+ ? new URL(`/ecc_queue.do?sys_id=${encodeURIComponent(responseSysId)}`, app.getEffectiveInstance()).href
677
+ : '';
678
+ const summary = `Executed command on MID Server '${getStringField(record, 'name')}'`;
679
+ const lines = [summary, '', result.stdout || '(no stdout)'];
680
+ if (result.stderr) lines.push('', 'stderr:', result.stderr);
681
+ if (responseURL) lines.push('', `ECC input: ${responseURL}`);
682
+ app.ok({
683
+ mid_server: getStringField(record, 'name'),
684
+ command,
685
+ output: { sys_id: outputSysId, ...output },
686
+ response: { ...response, ...result, url: responseURL },
687
+ _formatted: `${lines.join('\n')}\n`,
688
+ }, { summary });
689
+ }),
690
+ }),
691
+ handler: () => {
692
+ console.log('Manage ServiceNow MID Servers and execute commands through them.');
693
+ console.log('');
694
+ console.log('Available subcommands:');
695
+ console.log(' list List MID Servers');
696
+ console.log(' issues [name_or_sys_id] Show unresolved issues (--all includes resolved)');
697
+ console.log(' events [name_or_sys_id] Show lifecycle history');
698
+ console.log(' show <name_or_sys_id> Show a MID Server');
699
+ console.log(' add --name <name> Install a MID Server (Docker or direct ZIP)');
700
+ console.log(' remove <name_or_sys_id> Remove a MID Server record');
701
+ console.log(' execute <name_or_sys_id> Execute a shell command');
702
+ console.log('');
703
+ console.log('Run "jsn midservers <command> --help" for details.');
704
+ },
705
+ };
706
+ }
707
+
708
+ export { waitForCommandResult };