@jacebenson/jsn 4.3.7-dev.20 → 4.3.7-dev.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.20",
3
+ "version": "4.3.7-dev.21",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -69,6 +69,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
69
69
  import { atfCmd } from './commands/atf.js';
70
70
  import { approvalsCmd } from './commands/approvals.js';
71
71
  import { perfCmd } from './commands/perf.js';
72
+ import { midserversCmd } from './commands/midservers.js';
72
73
  import { diffCmd } from './commands/diff.js';
73
74
  import { graphqlCmd } from './commands/graphql.js';
74
75
 
@@ -373,6 +374,7 @@ export function buildCLI() {
373
374
  .command(atfCmd(wrap))
374
375
  .command(approvalsCmd(wrap))
375
376
  .command(perfCmd(wrap))
377
+ .command(midserversCmd(wrap))
376
378
  .command(diffCmd(wrap))
377
379
  .command(graphqlCmd(wrap))
378
380
  .command(evalCmd(wrap))
@@ -0,0 +1,213 @@
1
+ import { confirmDelete, getStringField, parseDataArg } from '../helpers.js';
2
+ import { declareCapabilities } from '../capabilities.js';
3
+ import { resolveRecord, unwrapSysId } from '../resolve-record.js';
4
+
5
+ const AGENT_TABLE = 'ecc_agent';
6
+ const QUEUE_TABLE = 'ecc_queue';
7
+ const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'version', 'validated', 'last_heartbeat', 'sys_updated_on'];
8
+ const DEFAULT_TIMEOUT = 120000;
9
+ const DEFAULT_INTERVAL = 2000;
10
+
11
+ declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute'] });
12
+
13
+ function agentName(record) {
14
+ const name = getStringField(record, 'name');
15
+ return name.startsWith('mid.server.') ? name : `mid.server.${name}`;
16
+ }
17
+
18
+ function queueValue(value) {
19
+ return value && typeof value === 'object' ? (value.value || value.display_value || '') : String(value || '');
20
+ }
21
+
22
+ function escapeXml(value) {
23
+ return String(value)
24
+ .replaceAll('&', '&')
25
+ .replaceAll('"', '"')
26
+ .replaceAll('<', '&lt;')
27
+ .replaceAll('>', '&gt;')
28
+ .replaceAll("'", '&apos;');
29
+ }
30
+
31
+ export function buildCommandPayload(command) {
32
+ return `<parameters><parameter name="name" value="${escapeXml(command)}"/></parameters>`;
33
+ }
34
+
35
+ function decodeXml(value) {
36
+ return String(value || '')
37
+ .replaceAll('&lt;', '<')
38
+ .replaceAll('&gt;', '>')
39
+ .replaceAll('&quot;', '"')
40
+ .replaceAll('&apos;', "'")
41
+ .replaceAll('&amp;', '&');
42
+ }
43
+
44
+ export function parseCommandResult(payload) {
45
+ if (!payload) return { stdout: '', stderr: '' };
46
+ const result = payload.match(/<result\b[^>]*>([\s\S]*?)<\/result>/i)?.[1] || payload;
47
+ const stdout = result.match(/<stdout\b[^>]*>([\s\S]*?)<\/stdout>/i)?.[1] || '';
48
+ const stderr = result.match(/<stderr\b[^>]*>([\s\S]*?)<\/stderr>/i)?.[1] || '';
49
+ return { stdout: decodeXml(stdout), stderr: decodeXml(stderr) };
50
+ }
51
+
52
+ async function waitForCommandResult(sdk, outputSysId, timeoutMs, intervalMs) {
53
+ const started = Date.now();
54
+ while (Date.now() - started <= timeoutMs) {
55
+ const params = new URLSearchParams({
56
+ sysparm_query: `response_to=${outputSysId}^queue=input`,
57
+ sysparm_limit: '1',
58
+ sysparm_display_value: 'all',
59
+ sysparm_fields: 'sys_id,state,error,payload,name,topic,queue,agent,created_on,processed',
60
+ });
61
+ const records = await sdk.list(QUEUE_TABLE, params);
62
+ if (records.length > 0) return records[0];
63
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
64
+ }
65
+ const error = new Error(`Timed out waiting for MID Server response to ECC Queue record ${outputSysId}`);
66
+ error.code = 'midserver_timeout';
67
+ throw error;
68
+ }
69
+
70
+ async function resolveMidServer(sdk, identifier) {
71
+ try {
72
+ return await resolveRecord(sdk, {
73
+ table: AGENT_TABLE,
74
+ identifier,
75
+ matchField: 'name',
76
+ resource: 'MID Server',
77
+ });
78
+ } catch (error) {
79
+ if (error.code !== 'not_found' || identifier.startsWith('mid.server.')) throw error;
80
+ return resolveRecord(sdk, {
81
+ table: AGENT_TABLE,
82
+ identifier: `mid.server.${identifier}`,
83
+ matchField: 'name',
84
+ resource: 'MID Server',
85
+ });
86
+ }
87
+ }
88
+
89
+ async function listAgents(app, argv) {
90
+ const params = new URLSearchParams({
91
+ sysparm_limit: String(argv.limit),
92
+ sysparm_display_value: 'all',
93
+ sysparm_fields: (argv.columns ? [...new Set(['sys_id', ...argv.columns.split(',')])] : LIST_FIELDS).join(','),
94
+ sysparm_query: argv.query || 'ORDERBYname',
95
+ });
96
+ const records = await app.sdk.list(AGENT_TABLE, params);
97
+ app.ok({
98
+ table: AGENT_TABLE,
99
+ count: records.length,
100
+ records,
101
+ context: { instance_url: app.getEffectiveInstance() },
102
+ }, { summary: `${records.length} MID Server(s)` });
103
+ }
104
+
105
+ export function midserversCmd(wrap) {
106
+ return {
107
+ command: 'midservers [subcommand]',
108
+ aliases: ['midserver', 'mid'],
109
+ describe: 'Manage ServiceNow MID Servers and run commands through them',
110
+ builder: (y) => y
111
+ .command({
112
+ command: 'list',
113
+ aliases: ['ls'],
114
+ describe: 'List MID Servers',
115
+ builder: (b) => b
116
+ .option('query', { type: 'string', describe: 'Encoded query against ecc_agent' })
117
+ .option('columns', { type: 'string', describe: 'Comma-separated fields' })
118
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
119
+ handler: wrap(async (argv, app) => {
120
+ await listAgents(app, argv);
121
+ }),
122
+ })
123
+ .command({
124
+ command: 'show <identifier>',
125
+ aliases: ['get'],
126
+ describe: 'Show a MID Server by name or sys_id',
127
+ handler: wrap(async (argv, app) => {
128
+ const record = await resolveMidServer(app.sdk, argv.identifier);
129
+ record._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
130
+ app.ok(record, { summary: `MID Server: ${getStringField(record, 'name') || argv.identifier}` });
131
+ }),
132
+ })
133
+ .command({
134
+ command: 'add',
135
+ describe: 'Create a MID Server record',
136
+ builder: (b) => b
137
+ .option('name', { type: 'string', describe: 'MID Server name (without the mid.server. prefix)' })
138
+ .option('host-name', { type: 'string', describe: 'MID Server host name' })
139
+ .option('ip-address', { type: 'string', describe: 'MID Server IP address' })
140
+ .option('data', { type: 'string', describe: 'JSON fields for the ecc_agent record' })
141
+ .option('data-file', { type: 'string', describe: 'Read JSON fields from a file' }),
142
+ handler: wrap(async (argv, app) => {
143
+ let data = argv.data || argv['data-file'] ? parseDataArg(argv) : {};
144
+ if (argv.name) data.name = argv.name;
145
+ if (!data.name) throw new Error('--name or --data with a name is required');
146
+ data.name = String(data.name).startsWith('mid.server.') ? data.name : `mid.server.${data.name}`;
147
+ if (argv['host-name']) data.host_name = argv['host-name'];
148
+ if (argv['ip-address']) data.ip_address = argv['ip-address'];
149
+ const record = await app.sdk.create(AGENT_TABLE, data);
150
+ app.ok(record, { summary: `Created MID Server '${getStringField(record, 'name') || data.name}'` });
151
+ }),
152
+ })
153
+ .command({
154
+ command: 'remove <identifier>',
155
+ aliases: ['delete'],
156
+ describe: 'Remove a MID Server record',
157
+ builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
158
+ handler: wrap(async (argv, app) => {
159
+ const record = await resolveMidServer(app.sdk, argv.identifier);
160
+ const sysId = unwrapSysId(record);
161
+ const name = getStringField(record, 'name') || argv.identifier;
162
+ await confirmDelete(app, argv, `Remove MID Server '${name}'`);
163
+ await app.sdk.delete(AGENT_TABLE, sysId);
164
+ app.ok({ name, sys_id: sysId, deleted: true }, { summary: `Removed MID Server '${name}'` });
165
+ }),
166
+ })
167
+ .command({
168
+ command: 'execute <identifier>',
169
+ describe: 'Execute a shell command through a MID Server',
170
+ builder: (b) => b
171
+ .option('command', { alias: 'c', type: 'string', demandOption: true, describe: 'Command line to execute on the MID Server host' })
172
+ .option('timeout', { type: 'number', default: DEFAULT_TIMEOUT, describe: 'Response timeout in milliseconds' })
173
+ .option('interval', { type: 'number', default: DEFAULT_INTERVAL, describe: 'Polling interval in milliseconds' }),
174
+ handler: wrap(async (argv, app) => {
175
+ const record = await resolveMidServer(app.sdk, argv.identifier);
176
+ const agent = agentName(record);
177
+ const output = await app.sdk.create(QUEUE_TABLE, {
178
+ agent,
179
+ topic: 'Command',
180
+ queue: 'output',
181
+ name: argv.command,
182
+ payload: buildCommandPayload(argv.command),
183
+ });
184
+ const outputSysId = unwrapSysId(output);
185
+ if (!outputSysId) throw new Error('ServiceNow did not return the ECC Queue sys_id for the command');
186
+ const response = await waitForCommandResult(app.sdk, outputSysId, Math.max(1, argv.timeout), Math.max(250, argv.interval));
187
+ const responseError = getStringField(response, 'error');
188
+ if (responseError) throw new Error(`MID Server command failed: ${responseError}`);
189
+ const result = parseCommandResult(queueValue(response.payload));
190
+ app.ok({
191
+ mid_server: getStringField(record, 'name'),
192
+ command: argv.command,
193
+ output: { sys_id: outputSysId, ...output },
194
+ response: { ...response, ...result },
195
+ }, { summary: `Executed command on MID Server '${getStringField(record, 'name')}'` });
196
+ }),
197
+ }),
198
+ handler: () => {
199
+ console.log('Manage ServiceNow MID Servers and execute commands through them.');
200
+ console.log('');
201
+ console.log('Available subcommands:');
202
+ console.log(' list List MID Servers');
203
+ console.log(' show <name_or_sys_id> Show a MID Server');
204
+ console.log(' add --name <name> Create a MID Server record');
205
+ console.log(' remove <name_or_sys_id> Remove a MID Server record');
206
+ console.log(' execute <name_or_sys_id> Execute a shell command');
207
+ console.log('');
208
+ console.log('Run "jsn midservers <command> --help" for details.');
209
+ },
210
+ };
211
+ }
212
+
213
+ export { waitForCommandResult };