@jacebenson/jsn 4.2.2 → 4.2.4
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 +20 -0
- package/package.json +1 -1
- package/src/auth.js +41 -7
- package/src/cli.js +6 -0
- package/src/commands/auth.js +102 -18
- package/src/commands/dev/flows.js +120 -2
- package/src/commands/dev/rest.js +8 -1
- package/src/commands/instance.js +362 -0
- package/src/commands/platform.js +127 -0
- package/src/commands/records.js +46 -1
- package/src/commands/transactions.js +100 -0
- package/src/flow-context.js +184 -0
- package/src/records/inspect.js +14 -13
- package/src/sdk.js +72 -6
package/README.md
CHANGED
|
@@ -53,6 +53,12 @@ jsn incidents create --description "Server down" --priority 1
|
|
|
53
53
|
# Admin tasks
|
|
54
54
|
jsn flows list # Interactive picker with pagination
|
|
55
55
|
jsn flows show "Assign Task" # Full flow detail
|
|
56
|
+
jsn flows executions # All states, newest first
|
|
57
|
+
jsn flows executions --active # Only waiting/running/queued
|
|
58
|
+
jsn flows executions --since "2026-08-25 00:00:00" --until "2026-08-26 00:00:00"
|
|
59
|
+
jsn flows executions --summary # Server totals + sampled duration metrics by flow
|
|
60
|
+
jsn flows executions --limit 100 # Inspect more rows from the matching population
|
|
61
|
+
jsn flows executions --record <sys_id> # Executions for one source record
|
|
56
62
|
jsn rules list --query "collection=incident"
|
|
57
63
|
jsn updatesets set "My Feature"
|
|
58
64
|
|
|
@@ -78,6 +84,20 @@ jsn snippets run open-inc
|
|
|
78
84
|
jsn logs follow --level error --tail 10
|
|
79
85
|
```
|
|
80
86
|
|
|
87
|
+
### Flow execution fields
|
|
88
|
+
|
|
89
|
+
`jsn flows executions` reads `sys_flow_context` and returns both the raw row and a normalized `execution` object. JSN discovers the runtime columns from `sys_dictionary` first, then uses these mappings:
|
|
90
|
+
|
|
91
|
+
- `started`: `started`, `start_time`, `started_at`, then `sys_created_on`
|
|
92
|
+
- `ended`: `ended`, `end_time`, `ended_at`, then `completed_on`
|
|
93
|
+
- `duration_seconds`: stored `duration`, `run_time`, or `execution_duration`, otherwise `ended - started`
|
|
94
|
+
- `waiting_age_seconds`: current time minus `started` while the status is waiting, queued, paused, or pending
|
|
95
|
+
- `execution_age_seconds`: current time minus `started` while the status is running, executing, or processing
|
|
96
|
+
- `status`: `state`, then `status`, then `execution_state`
|
|
97
|
+
- `error`: `error`, `error_message`, `exception`, then `message`
|
|
98
|
+
|
|
99
|
+
The timestamp fields are instance-dependent. `sys_created_on` is a fallback for when the context row was created, not a claim that it is the true runtime start time.
|
|
100
|
+
|
|
81
101
|
## Commands
|
|
82
102
|
|
|
83
103
|
Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain:
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -271,6 +271,25 @@ function askHidden(promptText) {
|
|
|
271
271
|
return passwordPrompt({ message: promptText });
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Extract the browser session token and cookies from pasted DevTools headers
|
|
276
|
+
* or a "Copy as cURL" command. Only these two values are retained.
|
|
277
|
+
*/
|
|
278
|
+
export function parseBrowserSessionInput(input) {
|
|
279
|
+
const text = String(input || '');
|
|
280
|
+
const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
|
|
281
|
+
const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
|
|
282
|
+
const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
|
|
283
|
+
const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
|
|
284
|
+
const token = tokenMatch?.[1]?.trim() || '';
|
|
285
|
+
const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
|
|
286
|
+
.trim()
|
|
287
|
+
.replace(/['"`]\s*(?:\\\s*)?$/, '');
|
|
288
|
+
if (!token) throw errAuth('No X-UserToken header found');
|
|
289
|
+
if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
|
|
290
|
+
return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
|
|
291
|
+
}
|
|
292
|
+
|
|
274
293
|
export class AuthManager {
|
|
275
294
|
/**
|
|
276
295
|
* @param {object} identity — the resolved-identity provider. Production
|
|
@@ -366,6 +385,12 @@ export class AuthManager {
|
|
|
366
385
|
if (getBasicAuthFromEnv(instance)) return true;
|
|
367
386
|
const creds = loadCredentials(instance, this._activeUsername());
|
|
368
387
|
if (!creds) return false;
|
|
388
|
+
if (creds.auth_method === 'basic') {
|
|
389
|
+
return Boolean(creds.username && creds.password);
|
|
390
|
+
}
|
|
391
|
+
if (creds.auth_method === 'gck') {
|
|
392
|
+
return Boolean(creds.access_token && creds.cookies);
|
|
393
|
+
}
|
|
369
394
|
if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
|
|
370
395
|
return !!creds.access_token;
|
|
371
396
|
}
|
|
@@ -384,11 +409,11 @@ export class AuthManager {
|
|
|
384
409
|
return this.getCredentialsFor(instance);
|
|
385
410
|
}
|
|
386
411
|
|
|
387
|
-
getCredentialsFor(instance) {
|
|
412
|
+
getCredentialsFor(instance, username = this._activeUsername()) {
|
|
388
413
|
// Check basic auth from env vars first
|
|
389
414
|
const basicCreds = getBasicAuthFromEnv(instance);
|
|
390
415
|
if (basicCreds) return basicCreds;
|
|
391
|
-
const creds = loadCredentials(instance,
|
|
416
|
+
const creds = loadCredentials(instance, username);
|
|
392
417
|
if (!creds) {
|
|
393
418
|
throw errAuth(`Not authenticated for ${instance}`);
|
|
394
419
|
}
|
|
@@ -483,17 +508,19 @@ export class AuthManager {
|
|
|
483
508
|
* Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
|
|
484
509
|
* Stores the basic auth credentials so they persist across sessions.
|
|
485
510
|
*/
|
|
486
|
-
async loginWithPassword(instanceURL) {
|
|
511
|
+
async loginWithPassword(instanceURL, username = this._activeUsername()) {
|
|
487
512
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
488
|
-
const
|
|
489
|
-
|
|
513
|
+
const envCreds = getBasicAuthFromEnv(instanceURL);
|
|
514
|
+
const storedCreds = loadCredentials(instanceURL, username);
|
|
515
|
+
const creds = envCreds || storedCreds;
|
|
516
|
+
if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
|
|
490
517
|
throw errAuth(
|
|
491
|
-
`No basic auth credentials found in environment.\n\n` +
|
|
518
|
+
`No basic auth credentials found in environment or stored profile.\n\n` +
|
|
492
519
|
`Set the following environment variables:\n` +
|
|
493
520
|
` SN_USERNAME=admin (or SN_${envVarName(instanceURL)}_USERNAME)\n` +
|
|
494
521
|
` SN_PASSWORD=<password> (or SN_${envVarName(instanceURL)}_PASSWORD)\n\n` +
|
|
495
522
|
`Then run:\n` +
|
|
496
|
-
` jsn auth login --
|
|
523
|
+
` jsn auth login --basic ${instanceURL}`
|
|
497
524
|
);
|
|
498
525
|
}
|
|
499
526
|
saveCredentials(instanceURL, creds, creds.username);
|
|
@@ -505,6 +532,13 @@ export class AuthManager {
|
|
|
505
532
|
* Complete login using an authorization code obtained from a prior buildAuthURL() call.
|
|
506
533
|
* The PKCE state must have been saved by an earlier buildAuthURL() call.
|
|
507
534
|
*/
|
|
535
|
+
async loginWithGck(instanceURL, input, username = this._activeUsername()) {
|
|
536
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
537
|
+
const creds = parseBrowserSessionInput(input);
|
|
538
|
+
saveCredentials(instanceURL, { ...creds, username: username || undefined }, username || undefined);
|
|
539
|
+
return { ...creds, username: username || undefined };
|
|
540
|
+
}
|
|
541
|
+
|
|
508
542
|
async loginWithCode(instanceURL, code) {
|
|
509
543
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
510
544
|
const clientID = getOAuthClientID();
|
package/src/cli.js
CHANGED
|
@@ -29,6 +29,9 @@ import { groupsCmd } from './commands/groups.js';
|
|
|
29
29
|
import { groupMembersCmd } from './commands/groupmembers.js';
|
|
30
30
|
import { groupRolesCmd } from './commands/grouproles.js';
|
|
31
31
|
import { ticketsCmd } from './commands/tickets.js';
|
|
32
|
+
import { transactionsCmd } from './commands/transactions.js';
|
|
33
|
+
import { instanceCmd } from './commands/instance.js';
|
|
34
|
+
import { platformCmd } from './commands/platform.js';
|
|
32
35
|
import { codesearchCmd } from './commands/codesearch.js';
|
|
33
36
|
import { versionCmd } from './commands/version.js';
|
|
34
37
|
import { devCmd } from './commands/dev.js';
|
|
@@ -354,6 +357,9 @@ export function buildCLI() {
|
|
|
354
357
|
// DATA
|
|
355
358
|
.command(cmdbCmd(wrap))
|
|
356
359
|
.command(recordsCmd(wrap))
|
|
360
|
+
.command(transactionsCmd(wrap))
|
|
361
|
+
.command(instanceCmd(wrap))
|
|
362
|
+
.command(platformCmd(wrap))
|
|
357
363
|
.command(snippetsCmd(wrap))
|
|
358
364
|
.command(ticketsCmd(wrap))
|
|
359
365
|
.command(codesearchCmd(wrap))
|
package/src/commands/auth.js
CHANGED
|
@@ -82,6 +82,25 @@ export function resolveInstanceArg(arg, cfg) {
|
|
|
82
82
|
return normalizeInstanceURL(`${trimmed}.service-now.com`);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Select the login method. Explicit Basic Auth wins; otherwise reuse the
|
|
87
|
+
* method recorded on an existing profile. New instances remain OAuth by
|
|
88
|
+
* default.
|
|
89
|
+
*/
|
|
90
|
+
export function shouldUseBasicAuth(argv = {}, cfg = {}, instanceURL = '') {
|
|
91
|
+
if (argv.basic || argv.password) return true;
|
|
92
|
+
return Object.values(cfg.profiles || {}).some(profile =>
|
|
93
|
+
profile.instance_url === instanceURL && profile.auth_method === 'basic'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function shouldUseGckAuth(argv = {}, cfg = {}, instanceURL = '') {
|
|
98
|
+
if (argv.gck || argv.headers) return true;
|
|
99
|
+
return Object.values(cfg.profiles || {}).some(profile =>
|
|
100
|
+
profile.instance_url === instanceURL && profile.auth_method === 'gck'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
/**
|
|
86
105
|
* Pick a profile by name — interactive search picker when ambiguous,
|
|
87
106
|
* auto-selects when there's exactly one, throws when none configured.
|
|
@@ -111,9 +130,50 @@ export async function pickProfile(app, message) {
|
|
|
111
130
|
* always asks otherwise.
|
|
112
131
|
*/
|
|
113
132
|
export function resolveWizardInstance(app, argv = {}) {
|
|
114
|
-
//
|
|
115
|
-
// the
|
|
116
|
-
|
|
133
|
+
// Only explicit inputs may pre-fill the wizard. The active/default profile
|
|
134
|
+
// belongs to the existing connection and must not silently become the new
|
|
135
|
+
// instance when setup is adding a profile.
|
|
136
|
+
return process.env.SERVICENOW_INSTANCE_URL || app._overrideInstance || argv.instance || '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function readBrowserSessionInput() {
|
|
140
|
+
const stdin = process.stdin;
|
|
141
|
+
console.log('Paste the complete cURL command, then press Ctrl+D:');
|
|
142
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
for await (const chunk of stdin) chunks.push(chunk);
|
|
145
|
+
return Buffer.concat(chunks).toString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
let pasted = '';
|
|
150
|
+
const finish = () => {
|
|
151
|
+
stdin.setRawMode(false);
|
|
152
|
+
stdin.pause();
|
|
153
|
+
stdin.removeListener('data', onData);
|
|
154
|
+
stdin.removeListener('error', onError);
|
|
155
|
+
process.stdout.write('\n');
|
|
156
|
+
};
|
|
157
|
+
const onError = (err) => { finish(); reject(err); };
|
|
158
|
+
const onData = (chunk) => {
|
|
159
|
+
const text = chunk.toString();
|
|
160
|
+
const eof = text.indexOf('\x04');
|
|
161
|
+
if (eof !== -1) {
|
|
162
|
+
pasted += text.slice(0, eof);
|
|
163
|
+
finish();
|
|
164
|
+
resolve(pasted);
|
|
165
|
+
} else if (text.includes('\x03')) {
|
|
166
|
+
finish();
|
|
167
|
+
reject(new Error('Browser session input cancelled'));
|
|
168
|
+
} else {
|
|
169
|
+
pasted += text;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
stdin.setRawMode(true);
|
|
173
|
+
stdin.resume();
|
|
174
|
+
stdin.on('data', onData);
|
|
175
|
+
stdin.on('error', onError);
|
|
176
|
+
});
|
|
117
177
|
}
|
|
118
178
|
|
|
119
179
|
/**
|
|
@@ -126,7 +186,7 @@ export function resolveWizardInstance(app, argv = {}) {
|
|
|
126
186
|
*/
|
|
127
187
|
export async function loginWizard(app, argv = {}) {
|
|
128
188
|
const readline = (await import('node:readline')).default;
|
|
129
|
-
|
|
189
|
+
let rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
130
190
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
131
191
|
|
|
132
192
|
console.log('Welcome to JSN - ServiceNow CLI');
|
|
@@ -161,8 +221,9 @@ export async function loginWizard(app, argv = {}) {
|
|
|
161
221
|
profile.skip_confirmations = true;
|
|
162
222
|
}
|
|
163
223
|
|
|
164
|
-
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
165
|
-
const
|
|
224
|
+
const authMethod = await ask('Authentication method (OAuth/Basic/Browser session) [OAuth]: ');
|
|
225
|
+
const useGck = authMethod.toLowerCase().startsWith('g') || authMethod.toLowerCase().startsWith('browser');
|
|
226
|
+
const useBasic = !useGck && authMethod.toLowerCase().startsWith('b');
|
|
166
227
|
|
|
167
228
|
let loggedIn = false;
|
|
168
229
|
if (useBasic) {
|
|
@@ -179,6 +240,12 @@ export async function loginWizard(app, argv = {}) {
|
|
|
179
240
|
}
|
|
180
241
|
profile.username = username;
|
|
181
242
|
profile.auth_method = 'basic';
|
|
243
|
+
} else if (useGck) {
|
|
244
|
+
rl.close();
|
|
245
|
+
const input = await readBrowserSessionInput();
|
|
246
|
+
await app.auth.loginWithGck(instance, input);
|
|
247
|
+
profile.auth_method = 'gck';
|
|
248
|
+
loggedIn = true;
|
|
182
249
|
} else {
|
|
183
250
|
profile.auth_method = 'oauth';
|
|
184
251
|
}
|
|
@@ -192,6 +259,11 @@ export async function loginWizard(app, argv = {}) {
|
|
|
192
259
|
// so a piped/scripted call never blocks on prompts.
|
|
193
260
|
const { canPrompt } = await import('../helpers.js');
|
|
194
261
|
if (canPrompt()) {
|
|
262
|
+
// @inquirer/prompts owns stdin while these confirmations run. Close the
|
|
263
|
+
// older readline interface before handing stdin to Inquirer. This matters
|
|
264
|
+
// on Windows Git Bash: leaving both readers attached can make the later
|
|
265
|
+
// OAuth question see EOF and let Node exit instead of waiting for input.
|
|
266
|
+
rl.close();
|
|
195
267
|
const { confirm } = await import('@inquirer/prompts');
|
|
196
268
|
profile.read_only = await confirm({ message: 'Read-only profile (blocks mutation commands)?', default: profile.read_only || false });
|
|
197
269
|
profile.skip_confirmations = await confirm({ message: 'Skip confirmations (deletes run without prompting)?', default: profile.skip_confirmations || false });
|
|
@@ -201,7 +273,10 @@ export async function loginWizard(app, argv = {}) {
|
|
|
201
273
|
|
|
202
274
|
await saveConfig(app.config);
|
|
203
275
|
|
|
204
|
-
if (!useBasic) {
|
|
276
|
+
if (!useBasic && !useGck) {
|
|
277
|
+
// Recreate readline after Inquirer has released stdin. Do not reuse the
|
|
278
|
+
// interface that was open before the confirmation prompts.
|
|
279
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
205
280
|
const loginNow = await ask('Login now? [Y/n]: ');
|
|
206
281
|
rl.close();
|
|
207
282
|
if (!loginNow || loginNow.toLowerCase() !== 'n') {
|
|
@@ -426,7 +501,7 @@ export async function removeProfile(app, name) {
|
|
|
426
501
|
export function authCmd(wrap) {
|
|
427
502
|
return {
|
|
428
503
|
command: 'auth [subcommand]',
|
|
429
|
-
describe: 'Manage authentication (OAuth or
|
|
504
|
+
describe: 'Manage authentication (OAuth or Basic Auth)',
|
|
430
505
|
builder: (yargs) => {
|
|
431
506
|
return yargs
|
|
432
507
|
.command({
|
|
@@ -437,8 +512,9 @@ export function authCmd(wrap) {
|
|
|
437
512
|
describe: 'Authorization code from browser (bypasses interactive prompt)',
|
|
438
513
|
type: 'string',
|
|
439
514
|
})
|
|
440
|
-
.option('
|
|
441
|
-
|
|
515
|
+
.option('basic', {
|
|
516
|
+
alias: 'password',
|
|
517
|
+
describe: 'Authenticate with Basic Auth via env vars (SN_USERNAME/SN_PASSWORD)',
|
|
442
518
|
type: 'boolean',
|
|
443
519
|
})
|
|
444
520
|
.option('print-url', {
|
|
@@ -497,15 +573,20 @@ export function authCmd(wrap) {
|
|
|
497
573
|
Examples:
|
|
498
574
|
jsn auth login https://dev12345.service-now.com
|
|
499
575
|
jsn auth login dev373698
|
|
500
|
-
jsn auth login --
|
|
576
|
+
jsn auth login --basic https://dev328604.service-now.com
|
|
501
577
|
|
|
502
578
|
Find your instance URL in your browser's address bar when logged into ServiceNow.`);
|
|
503
579
|
}
|
|
504
580
|
}
|
|
505
581
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
582
|
+
const targetProfile = Object.values(app.config.profiles || {})
|
|
583
|
+
.find(profile => profile.instance_url === instanceURL);
|
|
584
|
+
const targetUsername = targetProfile?.username || '';
|
|
585
|
+
const useBasic = shouldUseBasicAuth(argv, app.config, instanceURL);
|
|
586
|
+
// --basic (with --password as a compatibility alias): authenticate
|
|
587
|
+
// with basic auth from env vars and persist it for this profile.
|
|
588
|
+
if (useBasic) {
|
|
589
|
+
await app.auth.loginWithPassword(instanceURL, targetUsername || undefined);
|
|
509
590
|
}
|
|
510
591
|
// --print-url with --wait-file: print URL and wait for code file
|
|
511
592
|
else if (argv.printUrl && argv.waitFile) {
|
|
@@ -528,9 +609,12 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
528
609
|
// Verify auth by fetching current user
|
|
529
610
|
let username = '';
|
|
530
611
|
try {
|
|
531
|
-
if (!app.sdk) {
|
|
612
|
+
if (!app.sdk || targetUsername) {
|
|
532
613
|
const { SDKClient } = await import('../sdk.js');
|
|
533
|
-
|
|
614
|
+
const authProvider = targetUsername
|
|
615
|
+
? { getCredentials: () => app.auth.getCredentialsFor(instanceURL, targetUsername) }
|
|
616
|
+
: app.auth;
|
|
617
|
+
app.sdk = new SDKClient(instanceURL, authProvider);
|
|
534
618
|
}
|
|
535
619
|
const user = await app.sdk.getCurrentUser();
|
|
536
620
|
username = user?.user_name || user?.name || '';
|
|
@@ -560,7 +644,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
560
644
|
app.config.profiles[profileName] = {
|
|
561
645
|
...(app.config.profiles[profileName] || {}),
|
|
562
646
|
instance_url: instanceURL,
|
|
563
|
-
auth_method:
|
|
647
|
+
auth_method: useBasic ? 'basic' : 'oauth',
|
|
564
648
|
username: username || undefined,
|
|
565
649
|
read_only: argv['read-only'] || undefined,
|
|
566
650
|
skip_confirmations: argv['skip-confirmations'] || undefined,
|
|
@@ -571,7 +655,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
571
655
|
// so they're keyed by <user>@<instance> for per-user isolation.
|
|
572
656
|
// The legacy bare-instance key is cleaned up behind the
|
|
573
657
|
// AuthManager seam (credential-store internals don't leak here).
|
|
574
|
-
if (!
|
|
658
|
+
if (!useBasic && username) {
|
|
575
659
|
app.auth.migrateLegacyCredential(instanceURL, username);
|
|
576
660
|
}
|
|
577
661
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
|
|
2
|
+
import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../../flow-context.js';
|
|
2
3
|
import { declareCapabilities } from '../../capabilities.js';
|
|
3
4
|
|
|
4
5
|
declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete'], devAlias: true });
|
|
@@ -66,6 +67,62 @@ export function flowsCmd(wrap) {
|
|
|
66
67
|
}, { summary: `${records.length} flow(s)` });
|
|
67
68
|
}),
|
|
68
69
|
})
|
|
70
|
+
.command({
|
|
71
|
+
command: 'executions',
|
|
72
|
+
aliases: ['runs', 'contexts'],
|
|
73
|
+
describe: 'Show Flow Designer executions from sys_flow_context',
|
|
74
|
+
builder: (y) => y
|
|
75
|
+
.option('query', { type: 'string', describe: 'Encoded query (e.g. "state=WAITING" or "source_record=<sys_id>")' })
|
|
76
|
+
.option('record', { type: 'string', describe: 'Only executions for this source record sys_id' })
|
|
77
|
+
.option('since', { type: 'string', describe: 'Only contexts created on or after this timestamp' })
|
|
78
|
+
.option('until', { type: 'string', describe: 'Only contexts created on or before this timestamp' })
|
|
79
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max executions' })
|
|
80
|
+
.option('summary', { type: 'boolean', default: false, describe: 'Return local counts and duration metrics by flow' })
|
|
81
|
+
.option('active', { type: 'boolean', default: false, describe: 'Only waiting, running, and queued executions' })
|
|
82
|
+
.option('all', { type: 'boolean', default: false, describe: 'Compatibility flag; all states are now shown by default' }),
|
|
83
|
+
handler: wrap(async (argv, app) => {
|
|
84
|
+
app.requireInstance();
|
|
85
|
+
const fields = await discoverFlowContextFields(app.sdk);
|
|
86
|
+
const query = buildFlowContextQuery({
|
|
87
|
+
record: argv.record,
|
|
88
|
+
since: argv.since,
|
|
89
|
+
until: argv.until,
|
|
90
|
+
query: argv.query || (argv.active ? 'stateINWAITING,RUNNING,QUEUED' : ''),
|
|
91
|
+
});
|
|
92
|
+
const params = new URLSearchParams();
|
|
93
|
+
params.set('sysparm_query', query);
|
|
94
|
+
params.set('sysparm_limit', String(argv.limit));
|
|
95
|
+
params.set('sysparm_fields', [...fields].join(','));
|
|
96
|
+
params.set('sysparm_display_value', 'all');
|
|
97
|
+
const records = await app.sdk.list('sys_flow_context', params);
|
|
98
|
+
const executions = records.map(record => normalizeFlowContext(record));
|
|
99
|
+
const sampledSummary = summarizeFlowContexts(executions);
|
|
100
|
+
let executionSummary = sampledSummary;
|
|
101
|
+
if (argv.summary) {
|
|
102
|
+
const [totalCount, stats] = await Promise.all([
|
|
103
|
+
app.sdk.aggregateCount('sys_flow_context', query).catch(() => null),
|
|
104
|
+
app.sdk.aggregate('sys_flow_context', { query, groupBy: ['name', 'state'], count: true }).catch(() => null),
|
|
105
|
+
]);
|
|
106
|
+
executionSummary = mergeFlowContextStats(stats?.groups || stats?.stats || [], sampledSummary, totalCount);
|
|
107
|
+
}
|
|
108
|
+
const data = {
|
|
109
|
+
table: 'sys_flow_context',
|
|
110
|
+
count: argv.summary ? executionSummary.total_count ?? executions.length : executions.length,
|
|
111
|
+
sample_count: executions.length,
|
|
112
|
+
query,
|
|
113
|
+
fields: [...fields],
|
|
114
|
+
field_mapping: aggregateFlowContextMappings(executions),
|
|
115
|
+
missing_fields: [...new Set(executions.flatMap(execution => execution.missing_fields))],
|
|
116
|
+
summary: executionSummary,
|
|
117
|
+
executions: argv.summary ? undefined : executions,
|
|
118
|
+
_formatted: argv.summary ? formatFlowContextSummary(executionSummary) : undefined,
|
|
119
|
+
context: { instance_url: app.getEffectiveInstance(), timezone: 'UTC' },
|
|
120
|
+
};
|
|
121
|
+
app.ok(data, { summary: argv.summary
|
|
122
|
+
? `${executionSummary.total_count ?? executions.length} matching flow execution(s), ${executions.length} sampled`
|
|
123
|
+
: `${executions.length} flow execution(s)` });
|
|
124
|
+
}),
|
|
125
|
+
})
|
|
69
126
|
.command({
|
|
70
127
|
command: 'show <identifier>',
|
|
71
128
|
aliases: ['get'],
|
|
@@ -128,6 +185,7 @@ export function flowsCmd(wrap) {
|
|
|
128
185
|
console.log('');
|
|
129
186
|
console.log('Available subcommands:');
|
|
130
187
|
console.log(' list List flows');
|
|
188
|
+
console.log(' executions Show flow executions from sys_flow_context');
|
|
131
189
|
console.log(' show <identifier> Show flow details by name or sys_id');
|
|
132
190
|
console.log(' create Create a new flow (not yet implemented)');
|
|
133
191
|
console.log(' update <identifier> Update a flow (not yet implemented)');
|
|
@@ -650,10 +708,66 @@ async function formatStepLine(stepNum, pad, step, ctx) {
|
|
|
650
708
|
case 'subflow':
|
|
651
709
|
return formatSubFlowStep(stepNum, pad, step.data, ctx);
|
|
652
710
|
default:
|
|
653
|
-
return
|
|
711
|
+
return formatActionStepWithChildren(stepNum, pad, step.data, ctx);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function formatActionStepWithChildren(stepNum, pad, action, ctx) {
|
|
716
|
+
const lines = formatActionStep(stepNum, pad, action, ctx);
|
|
717
|
+
const depth = ctx?.depth ?? 1;
|
|
718
|
+
if (!ctx?.sdk?.inspectCustomAction || depth <= 1) return lines;
|
|
719
|
+
|
|
720
|
+
const actionName = getActionName(action);
|
|
721
|
+
if (!actionName) return lines;
|
|
722
|
+
|
|
723
|
+
if (!ctx.customActionCache) ctx.customActionCache = new Map();
|
|
724
|
+
if (!ctx.customActionCache.has(actionName)) {
|
|
725
|
+
ctx.customActionCache.set(actionName, ctx.sdk.inspectCustomAction(actionName).catch(() => null));
|
|
726
|
+
}
|
|
727
|
+
const details = await ctx.customActionCache.get(actionName);
|
|
728
|
+
const steps = details?.steps;
|
|
729
|
+
if (!Array.isArray(steps) || steps.length === 0 || !hasUsefulActionSteps(actionName, steps)) return lines;
|
|
730
|
+
|
|
731
|
+
lines.push(`${pad} ↳ Internal action steps`);
|
|
732
|
+
const ordered = [...steps].sort((a, b) => parseOrderField(a) - parseOrderField(b));
|
|
733
|
+
ordered.forEach((step, index) => {
|
|
734
|
+
const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'), 'Action step');
|
|
735
|
+
const type = getNestedString(step, 'step_type', 'display_value') || getStringField(step, 'step_type');
|
|
736
|
+
const suffix = type ? ` - ${type}` : '';
|
|
737
|
+
lines.push(`${pad} Step ${index + 1}: ${label}${suffix}`);
|
|
738
|
+
appendStepValues(lines, pad + ' ', step, ctx);
|
|
739
|
+
});
|
|
740
|
+
return lines;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function hasUsefulActionSteps(actionName, steps) {
|
|
744
|
+
if (steps.length !== 1) return true;
|
|
745
|
+
const step = steps[0];
|
|
746
|
+
const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'))
|
|
747
|
+
.replace(/\s+step$/i, '').trim().toLowerCase();
|
|
748
|
+
return label !== actionName.trim().toLowerCase();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function appendStepValues(lines, pad, step, ctx) {
|
|
752
|
+
for (const field of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) {
|
|
753
|
+
const value = getStringField(step, field);
|
|
754
|
+
if (!value) continue;
|
|
755
|
+
const resolved = resolveGuidPills(value, ctx?.labelCache);
|
|
756
|
+
for (const part of resolved.split('^')) {
|
|
757
|
+
if (part) lines.push(`${pad}${field}: ${part}`);
|
|
758
|
+
}
|
|
654
759
|
}
|
|
655
760
|
}
|
|
656
761
|
|
|
762
|
+
function getActionName(action) {
|
|
763
|
+
return firstNonEmpty(
|
|
764
|
+
getNestedString(action, 'actionType', 'fName'),
|
|
765
|
+
getStringField(action, 'actionName'),
|
|
766
|
+
getStringField(action, 'actionInternalName'),
|
|
767
|
+
getStringField(action, 'name'),
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
657
771
|
export function formatActionStep(stepNum, pad, action, ctx) {
|
|
658
772
|
const lines = [];
|
|
659
773
|
let actionName = firstNonEmpty(
|
|
@@ -700,7 +814,6 @@ export function formatActionStep(stepNum, pad, action, ctx) {
|
|
|
700
814
|
if (inputName === 'table_name' && showsTableSuffix) continue;
|
|
701
815
|
|
|
702
816
|
let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
|
|
703
|
-
if (!inputValue) continue;
|
|
704
817
|
|
|
705
818
|
// Catalog variable selections arrive as "sys_id:table" pairs; resolve
|
|
706
819
|
// them to readable names when we have a resolver map (built per flow).
|
|
@@ -716,6 +829,11 @@ export function formatActionStep(stepNum, pad, action, ctx) {
|
|
|
716
829
|
label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
|
|
717
830
|
}
|
|
718
831
|
|
|
832
|
+
if (!inputValue) {
|
|
833
|
+
lines.push(`${pad} ${label}: (not set)`);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
719
837
|
// ServiceNow writes multi-field payloads (encoded-query style) as
|
|
720
838
|
// "field=value^field2=value2". Split those onto one line per field;
|
|
721
839
|
// plain long strings stay on a single line, untruncated.
|
package/src/commands/dev/rest.js
CHANGED
|
@@ -26,6 +26,11 @@ export function restCmd(wrap) {
|
|
|
26
26
|
type: 'string',
|
|
27
27
|
describe: 'Table name shorthand for api/now/table/{table}',
|
|
28
28
|
})
|
|
29
|
+
.option('raw', {
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
default: false,
|
|
32
|
+
describe: 'Return non-JSON response bodies as text (for XML/HTML diagnostic endpoints)',
|
|
33
|
+
})
|
|
29
34
|
.option('query', {
|
|
30
35
|
type: 'string',
|
|
31
36
|
describe: 'Query parameters string',
|
|
@@ -63,7 +68,9 @@ export function restCmd(wrap) {
|
|
|
63
68
|
requestOptions.body = argv.data;
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
const result =
|
|
71
|
+
const result = argv.raw
|
|
72
|
+
? await app.sdk.rawRequest(url, requestOptions)
|
|
73
|
+
: await app.sdk.request(url, requestOptions);
|
|
67
74
|
app.ok(result, { summary: `${method} ${endpoint}` });
|
|
68
75
|
}),
|
|
69
76
|
};
|