@jacebenson/jsn 3.4.3 → 4.0.1
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 +5 -4
- package/package.json +3 -1
- package/skills/servicenow/SKILL.md +3 -3
- package/src/app.js +7 -7
- package/src/auth.js +34 -63
- package/src/cli.js +27 -20
- package/src/commands/_ticket.js +7 -1
- package/src/commands/auth.js +303 -40
- package/src/commands/catalog.js +3 -4
- package/src/commands/dev/_generic.js +7 -14
- package/src/commands/dev/flows.js +110 -2
- package/src/commands/dev/forms.js +0 -11
- package/src/commands/dev/import.js +1 -1
- package/src/commands/dev/lists.js +2 -2
- package/src/commands/dev/updatesets.js +0 -16
- package/src/commands/docs/community-sources.json +18 -0
- package/src/commands/docs/community.js +343 -0
- package/src/commands/docs/db.js +38 -2
- package/src/commands/docs/docs.js +41 -0
- package/src/commands/docs/ingest.js +4 -17
- package/src/commands/docs/refresh.js +4 -17
- package/src/commands/docs/search.js +3 -3
- package/src/commands/docs/serve.js +2 -2
- package/src/commands/grouproles.js +5 -1
- package/src/commands/groups.js +6 -3
- package/src/commands/records.js +5 -2
- package/src/commands/requests.js +2 -1
- package/src/commands/setup.js +10 -84
- package/src/commands/skill.js +5 -7
- package/src/commands/tickets.js +4 -1
- package/src/commands/users.js +4 -1
- package/src/config.js +9 -0
- package/src/errors.js +26 -0
- package/src/help.js +2 -4
- package/src/helpers.js +65 -8
- package/src/mutations.js +40 -9
- package/src/output.js +6 -3
- package/src/paginated-search.js +1 -1
- package/src/records/inspect.js +2 -1
- package/src/commands/profiles.js +0 -190
package/src/commands/auth.js
CHANGED
|
@@ -1,16 +1,179 @@
|
|
|
1
|
-
import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
|
|
1
|
+
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
|
|
2
2
|
import { errUsage } from '../errors.js';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a login/logout/refresh argument to a concrete instance URL.
|
|
7
|
+
*
|
|
8
|
+
* gh-style precision: a full URL is used as-is; a bare name is first checked
|
|
9
|
+
* against known profile names (gh: --hostname), then treated as a ServiceNow
|
|
10
|
+
* instance name with the canonical `.service-now.com` host appended. This kills
|
|
11
|
+
* the old trap where `jsn auth login dev99999` silently targeted
|
|
12
|
+
* `https://dev99999` (a dead host).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveInstanceArg(arg, cfg) {
|
|
15
|
+
if (!arg) return '';
|
|
16
|
+
const trimmed = arg.trim();
|
|
17
|
+
if (/^https?:\/\//i.test(trimmed)) return normalizeInstanceURL(trimmed);
|
|
18
|
+
const profile = cfg?.profiles?.[trimmed];
|
|
19
|
+
if (profile?.instance_url) return normalizeInstanceURL(profile.instance_url);
|
|
20
|
+
// Already looks like a host (has a dot) — add the scheme, nothing else
|
|
21
|
+
if (trimmed.includes('.')) return normalizeInstanceURL(trimmed);
|
|
22
|
+
// Bare ServiceNow instance name → canonical host
|
|
23
|
+
return normalizeInstanceURL(`${trimmed}.service-now.com`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Interactive first-run wizard (formerly `jsn setup`).
|
|
28
|
+
*
|
|
29
|
+
* Walks: instance URL → profile name → auth method → optional OAuth login.
|
|
30
|
+
* Creates and activates the profile, then returns the result so the caller
|
|
31
|
+
* decides how to report it. Basic auth saves credentials directly (the OAuth
|
|
32
|
+
* path has no interactive username/password).
|
|
33
|
+
*/
|
|
34
|
+
export async function loginWizard(app, argv = {}) {
|
|
35
|
+
const readline = (await import('node:readline')).default;
|
|
36
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
38
|
+
|
|
39
|
+
console.log('Welcome to JSN - ServiceNow CLI');
|
|
40
|
+
console.log();
|
|
41
|
+
|
|
42
|
+
let instance = getEffectiveInstance(app.config);
|
|
43
|
+
if (!instance) {
|
|
44
|
+
instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
|
|
45
|
+
instance = normalizeInstanceURL(instance);
|
|
46
|
+
}
|
|
47
|
+
console.log(`Instance: ${instance}`);
|
|
48
|
+
|
|
49
|
+
// Warn if another profile already uses this instance URL
|
|
50
|
+
const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
|
|
51
|
+
if (existing) {
|
|
52
|
+
console.log(`Note: instance is also used by profile "${existing[0]}".`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const profileName = (await ask('Profile name (default): ')) || 'default';
|
|
56
|
+
const profile = { instance_url: instance };
|
|
57
|
+
if (argv['read-only']) {
|
|
58
|
+
profile.read_only = true;
|
|
59
|
+
}
|
|
60
|
+
if (argv['skip-confirmations']) {
|
|
61
|
+
profile.skip_confirmations = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
65
|
+
const useBasic = authMethod.toLowerCase().startsWith('b');
|
|
66
|
+
|
|
67
|
+
let loggedIn = false;
|
|
68
|
+
if (useBasic) {
|
|
69
|
+
const username = await ask('Username: ');
|
|
70
|
+
rl.close(); // Close outer readline before askHidden creates its own
|
|
71
|
+
const { loadCredentials, askHidden, saveCredentials } = await import('../auth.js');
|
|
72
|
+
const existingCreds = loadCredentials(instance) || loadCredentials(instance, username);
|
|
73
|
+
if (existingCreds && existingCreds.auth_method === 'basic') {
|
|
74
|
+
console.log(`Using existing credentials for ${instance}`);
|
|
75
|
+
} else {
|
|
76
|
+
const password = await askHidden('Password: ');
|
|
77
|
+
saveCredentials(instance, { auth_method: 'basic', username, password }, username);
|
|
78
|
+
console.log('Basic auth credentials saved');
|
|
79
|
+
}
|
|
80
|
+
profile.username = username;
|
|
81
|
+
profile.auth_method = 'basic';
|
|
82
|
+
} else {
|
|
83
|
+
profile.auth_method = 'oauth';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await setProfile(app.config, profileName, profile);
|
|
87
|
+
// Set as active profile so subsequent commands know which instance to use
|
|
88
|
+
app.config.activeProfile = profileName;
|
|
89
|
+
app.config.defaultProfile = profileName;
|
|
90
|
+
await saveConfig(app.config);
|
|
91
|
+
|
|
92
|
+
if (!useBasic) {
|
|
93
|
+
const loginNow = await ask('Login now? [Y/n]: ');
|
|
94
|
+
rl.close();
|
|
95
|
+
if (!loginNow || loginNow.toLowerCase() !== 'n') {
|
|
96
|
+
await app.auth.login(instance);
|
|
97
|
+
// Re-save credentials under <user>@<instance> after verifying username
|
|
98
|
+
try {
|
|
99
|
+
const { SDKClient } = await import('../sdk.js');
|
|
100
|
+
const sdk = new SDKClient(instance, app.auth);
|
|
101
|
+
const user = await sdk.getCurrentUser();
|
|
102
|
+
if (user && user.user_name) {
|
|
103
|
+
const { loadCredentials, saveCredentials } = await import('../auth.js');
|
|
104
|
+
const stored = loadCredentials(instance);
|
|
105
|
+
if (stored && stored.access_token) {
|
|
106
|
+
stored.username = user.user_name;
|
|
107
|
+
saveCredentials(instance, stored, user.user_name);
|
|
108
|
+
}
|
|
109
|
+
profile.username = user.user_name;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Non-fatal — token saved, username will be set on next auth login
|
|
113
|
+
}
|
|
114
|
+
console.log('Login successful!');
|
|
115
|
+
loggedIn = true;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
rl.close();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { instanceURL: instance, profileName, loggedIn };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* gh-faithful login target picker: existing profiles plus an "add a new
|
|
126
|
+
* instance" row. Typing filters profiles; picking the + row uses what you
|
|
127
|
+
* typed (or prompts for it).
|
|
128
|
+
*
|
|
129
|
+
* Returns { addNew: true, instanceName } or { addNew: false, name }.
|
|
130
|
+
*/
|
|
131
|
+
async function pickLoginTarget(app) {
|
|
132
|
+
const { search, input } = await import('@inquirer/prompts');
|
|
133
|
+
const ADD_NEW = '__add_new__';
|
|
134
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
135
|
+
let lastInput = '';
|
|
136
|
+
const selected = await search({
|
|
137
|
+
message: 'Log in to which instance?',
|
|
138
|
+
source: (term) => {
|
|
139
|
+
const filter = (term || '').toLowerCase();
|
|
140
|
+
lastInput = term || '';
|
|
141
|
+
const profiles = profileNames
|
|
142
|
+
.filter((name) => {
|
|
143
|
+
const label = `${name} — ${app.config.profiles[name].instance_url}`.toLowerCase();
|
|
144
|
+
return !filter || label.includes(filter);
|
|
145
|
+
})
|
|
146
|
+
.map((name) => ({
|
|
147
|
+
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
148
|
+
value: name,
|
|
149
|
+
}));
|
|
150
|
+
const addLabel = filter
|
|
151
|
+
? `+ Add "${term.trim()}" as a new instance`
|
|
152
|
+
: '+ Add a new instance';
|
|
153
|
+
return [{ name: addLabel, value: ADD_NEW }, ...profiles];
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
if (selected === ADD_NEW) {
|
|
157
|
+
const typed = lastInput.trim();
|
|
158
|
+
if (typed) return { addNew: true, instanceName: typed };
|
|
159
|
+
const name = await input({
|
|
160
|
+
message: 'ServiceNow instance name or URL (e.g. dev12345):',
|
|
161
|
+
validate: (v) => (v.trim() ? true : 'Instance name or URL is required'),
|
|
162
|
+
});
|
|
163
|
+
return { addNew: true, instanceName: name.trim() };
|
|
164
|
+
}
|
|
165
|
+
return { addNew: false, name: selected };
|
|
166
|
+
}
|
|
167
|
+
|
|
5
168
|
export function authCmd(wrap) {
|
|
6
169
|
return {
|
|
7
|
-
command: 'auth
|
|
170
|
+
command: 'auth [subcommand]',
|
|
8
171
|
describe: 'Manage authentication (OAuth or basic auth via env vars)',
|
|
9
172
|
builder: (yargs) => {
|
|
10
173
|
return yargs
|
|
11
174
|
.command({
|
|
12
175
|
command: 'login [instance]',
|
|
13
|
-
describe: 'Authenticate with a ServiceNow instance
|
|
176
|
+
describe: 'Authenticate with a ServiceNow instance (interactive picker when run bare)',
|
|
14
177
|
builder: (sub) => sub
|
|
15
178
|
.option('code', {
|
|
16
179
|
describe: 'Authorization code from browser (bypasses interactive prompt)',
|
|
@@ -27,38 +190,47 @@ export function authCmd(wrap) {
|
|
|
27
190
|
.option('wait-file', {
|
|
28
191
|
describe: 'File path to watch for authorization code (used with --print-url)',
|
|
29
192
|
type: 'string',
|
|
193
|
+
})
|
|
194
|
+
.option('read-only', {
|
|
195
|
+
describe: 'Mark the created profile as read-only (blocks mutation commands)',
|
|
196
|
+
type: 'boolean',
|
|
197
|
+
default: false,
|
|
198
|
+
})
|
|
199
|
+
.option('skip-confirmations', {
|
|
200
|
+
describe: 'Skip delete confirmations on this profile (deletes run without prompting)',
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
default: false,
|
|
30
203
|
}),
|
|
31
204
|
handler: wrap(async (argv, app) => {
|
|
32
205
|
let instanceURL;
|
|
33
206
|
if (argv.instance) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
207
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
208
|
+
} else if (app.isInteractive()) {
|
|
209
|
+
// gh-faithful: interactive terminals always get a picker —
|
|
210
|
+
// existing profiles plus "+ Add a new instance". Zero profiles
|
|
211
|
+
// goes straight to the setup wizard.
|
|
212
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
213
|
+
if (profileNames.length === 0) {
|
|
214
|
+
const wizard = await loginWizard(app, argv);
|
|
215
|
+
if (wizard.loggedIn) {
|
|
216
|
+
app.ok({ authenticated: true, instance: wizard.instanceURL, profile: wizard.profileName },
|
|
217
|
+
{ summary: `✓ Authenticated to ${wizard.instanceURL} (profile: ${wizard.profileName})` });
|
|
218
|
+
} else {
|
|
219
|
+
app.ok({ setup: true, instance: wizard.instanceURL, profile: wizard.profileName },
|
|
220
|
+
{ summary: `Profile "${wizard.profileName}" created for ${wizard.instanceURL}. Run "jsn auth login" to authenticate.` });
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const target = await pickLoginTarget(app);
|
|
225
|
+
if (target.addNew) {
|
|
226
|
+
instanceURL = resolveInstanceArg(target.instanceName, app.config);
|
|
38
227
|
} else {
|
|
39
|
-
instanceURL = normalizeInstanceURL(
|
|
228
|
+
instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
|
|
40
229
|
}
|
|
41
230
|
} else {
|
|
42
231
|
instanceURL = getEffectiveInstance(app.config);
|
|
43
232
|
if (!instanceURL) {
|
|
44
|
-
|
|
45
|
-
const profileNames = Object.keys(app.config.profiles || {});
|
|
46
|
-
if (profileNames.length > 0 && app.isInteractive()) {
|
|
47
|
-
const { search } = await import('@inquirer/prompts');
|
|
48
|
-
const choices = profileNames.map(name => ({
|
|
49
|
-
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
50
|
-
value: name,
|
|
51
|
-
}));
|
|
52
|
-
const selected = await search({
|
|
53
|
-
message: 'Select a profile:',
|
|
54
|
-
source: (input) => {
|
|
55
|
-
const filter = (input || '').toLowerCase();
|
|
56
|
-
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
|
|
60
|
-
} else {
|
|
61
|
-
throw errUsage(`Instance URL required.
|
|
233
|
+
throw errUsage(`Instance URL required.
|
|
62
234
|
|
|
63
235
|
Examples:
|
|
64
236
|
jsn auth login https://dev12345.service-now.com
|
|
@@ -66,7 +238,6 @@ Examples:
|
|
|
66
238
|
jsn auth login --password https://dev328604.service-now.com
|
|
67
239
|
|
|
68
240
|
Find your instance URL in your browser's address bar when logged into ServiceNow.`);
|
|
69
|
-
}
|
|
70
241
|
}
|
|
71
242
|
}
|
|
72
243
|
|
|
@@ -129,6 +300,8 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
129
300
|
instance_url: instanceURL,
|
|
130
301
|
auth_method: argv.password ? 'basic' : 'oauth',
|
|
131
302
|
username: username || undefined,
|
|
303
|
+
read_only: argv['read-only'] || undefined,
|
|
304
|
+
skip_confirmations: argv['skip-confirmations'] || undefined,
|
|
132
305
|
};
|
|
133
306
|
|
|
134
307
|
// Re-save OAuth credentials with username now that we have it,
|
|
@@ -169,11 +342,11 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
169
342
|
})
|
|
170
343
|
.command({
|
|
171
344
|
command: 'logout [instance]',
|
|
172
|
-
describe: '
|
|
345
|
+
describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
|
|
173
346
|
handler: wrap(async (argv, app) => {
|
|
174
347
|
let instanceURL;
|
|
175
348
|
if (argv.instance) {
|
|
176
|
-
instanceURL =
|
|
349
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
177
350
|
} else {
|
|
178
351
|
instanceURL = getEffectiveInstance(app.config);
|
|
179
352
|
if (!instanceURL) {
|
|
@@ -240,6 +413,8 @@ Examples:
|
|
|
240
413
|
days_since_last_seen: daysSinceLastSeen,
|
|
241
414
|
stale: daysSinceLastSeen > 7,
|
|
242
415
|
default: instance === defaultInstance,
|
|
416
|
+
read_only: profile.read_only || false,
|
|
417
|
+
skip_confirmations: profile.skip_confirmations || false,
|
|
243
418
|
});
|
|
244
419
|
}
|
|
245
420
|
|
|
@@ -273,13 +448,7 @@ Examples:
|
|
|
273
448
|
handler: wrap(async (argv, app) => {
|
|
274
449
|
let instanceURL;
|
|
275
450
|
if (argv.instance) {
|
|
276
|
-
|
|
277
|
-
const profiles = app.config.profiles || {};
|
|
278
|
-
if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
|
|
279
|
-
instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
|
|
280
|
-
} else {
|
|
281
|
-
instanceURL = normalizeInstanceURL(argv.instance);
|
|
282
|
-
}
|
|
451
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
283
452
|
} else {
|
|
284
453
|
instanceURL = getEffectiveInstance(app.config);
|
|
285
454
|
if (!instanceURL) {
|
|
@@ -300,17 +469,111 @@ Examples:
|
|
|
300
469
|
expires_at: refreshed.expires_at,
|
|
301
470
|
}, { summary: `✓ Token refreshed for ${instanceURL}` });
|
|
302
471
|
}),
|
|
472
|
+
})
|
|
473
|
+
.command({
|
|
474
|
+
command: 'switch [name]',
|
|
475
|
+
describe: 'Switch active profile (gh: auth switch)',
|
|
476
|
+
builder: (sub) => sub
|
|
477
|
+
.positional('name', {
|
|
478
|
+
describe: 'Profile name to activate',
|
|
479
|
+
type: 'string',
|
|
480
|
+
}),
|
|
481
|
+
handler: wrap(async (argv, app) => {
|
|
482
|
+
let name = argv.name;
|
|
483
|
+
if (!name) {
|
|
484
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
485
|
+
if (profileNames.length === 0) {
|
|
486
|
+
throw errUsage('No profiles configured. Run "jsn auth login" first.');
|
|
487
|
+
}
|
|
488
|
+
if (profileNames.length > 1 && app.isInteractive()) {
|
|
489
|
+
const { search } = await import('@inquirer/prompts');
|
|
490
|
+
const choices = profileNames.map(profileName => ({
|
|
491
|
+
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
492
|
+
value: profileName,
|
|
493
|
+
}));
|
|
494
|
+
const selected = await search({
|
|
495
|
+
message: 'Select a profile:',
|
|
496
|
+
source: (input) => {
|
|
497
|
+
const filter = (input || '').toLowerCase();
|
|
498
|
+
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
name = selected;
|
|
502
|
+
} else {
|
|
503
|
+
name = profileNames[0];
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
await setActiveProfile(app.config, name);
|
|
507
|
+
app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
|
|
508
|
+
}),
|
|
509
|
+
})
|
|
510
|
+
.command({
|
|
511
|
+
command: 'remove [name]',
|
|
512
|
+
describe: 'Delete an instance entry and its stored credentials',
|
|
513
|
+
builder: (sub) => sub
|
|
514
|
+
.positional('name', {
|
|
515
|
+
describe: 'Profile name to delete',
|
|
516
|
+
type: 'string',
|
|
517
|
+
}),
|
|
518
|
+
handler: wrap(async (argv, app) => {
|
|
519
|
+
let name = argv.name;
|
|
520
|
+
if (!name) {
|
|
521
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
522
|
+
if (profileNames.length === 0) {
|
|
523
|
+
throw errUsage('No profiles configured. Nothing to remove.');
|
|
524
|
+
}
|
|
525
|
+
if (profileNames.length > 1 && app.isInteractive()) {
|
|
526
|
+
const { search } = await import('@inquirer/prompts');
|
|
527
|
+
const choices = profileNames.map(profileName => ({
|
|
528
|
+
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
529
|
+
value: profileName,
|
|
530
|
+
}));
|
|
531
|
+
const selected = await search({
|
|
532
|
+
message: 'Remove which profile?',
|
|
533
|
+
source: (input) => {
|
|
534
|
+
const filter = (input || '').toLowerCase();
|
|
535
|
+
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
name = selected;
|
|
539
|
+
} else {
|
|
540
|
+
name = profileNames[0];
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (!app.config.profiles[name]) {
|
|
544
|
+
throw new Error(`Profile not found: ${name}`);
|
|
545
|
+
}
|
|
546
|
+
const instance = app.config.profiles[name].instance_url;
|
|
547
|
+
delete app.config.profiles[name];
|
|
548
|
+
if (app.config.defaultProfile === name) app.config.defaultProfile = '';
|
|
549
|
+
if (app.config.activeProfile === name) app.config.activeProfile = '';
|
|
550
|
+
saveConfig(app.config);
|
|
551
|
+
|
|
552
|
+
// Only clear credentials if no other profile uses this instance URL
|
|
553
|
+
const stillInUse = instance && Object.values(app.config.profiles || {})
|
|
554
|
+
.some(p => p.instance_url === instance);
|
|
555
|
+
if (instance && !stillInUse) {
|
|
556
|
+
app.auth.logout(instance);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
|
|
560
|
+
}),
|
|
303
561
|
});
|
|
304
562
|
},
|
|
305
563
|
handler: () => {
|
|
306
|
-
console.log('Manage
|
|
564
|
+
console.log('Manage authentication for ServiceNow instances.');
|
|
307
565
|
console.log('');
|
|
308
566
|
console.log('Available subcommands:');
|
|
309
|
-
console.log(' login <url>
|
|
310
|
-
console.log(' status
|
|
311
|
-
console.log('
|
|
567
|
+
console.log(' login <url|name> Log in / add an instance (picker when run bare)');
|
|
568
|
+
console.log(' status Show auth status for all profiles');
|
|
569
|
+
console.log(' switch <name> Switch the active profile');
|
|
570
|
+
console.log(' refresh Refresh the access token');
|
|
571
|
+
console.log(' logout Clear credentials (keeps the instance entry)');
|
|
572
|
+
console.log(' remove <name> Delete an instance entry and its credentials');
|
|
312
573
|
console.log('');
|
|
313
574
|
console.log('Run "jsn auth <command> --help" for details.');
|
|
575
|
+
console.log('');
|
|
576
|
+
console.log('Tip: "jsn auth login" is the only command you need to add an instance.');
|
|
314
577
|
},
|
|
315
578
|
};
|
|
316
579
|
}
|
package/src/commands/catalog.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getStringField, interactiveList } from '../helpers.js';
|
|
1
|
+
import { getStringField, interactiveList, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function catalogCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -16,7 +16,7 @@ export function catalogCmd(wrap) {
|
|
|
16
16
|
.option('short-description', { alias: 'd', type: 'string' })
|
|
17
17
|
.option('category', { alias: 'c', type: 'string' })
|
|
18
18
|
.option('variable', { alias: 'v', type: 'array', describe: 'Variable: "name:type:label"' })
|
|
19
|
-
.option('variables', { type: 'string', describe: 'JSON for submit_produce: {
|
|
19
|
+
.option('variables', { type: 'string', describe: 'JSON for submit_produce: {"key":"value"}' }),
|
|
20
20
|
handler: wrap(async (argv, app) => {
|
|
21
21
|
app.requireInstance();
|
|
22
22
|
|
|
@@ -150,6 +150,7 @@ export function catalogCmd(wrap) {
|
|
|
150
150
|
app.requireInstance();
|
|
151
151
|
let id = argv.id;
|
|
152
152
|
if (!/^[a-f0-9]{32}$/i.test(id)) {
|
|
153
|
+
assertSafeExactMatch(id);
|
|
153
154
|
const p = new URLSearchParams();
|
|
154
155
|
p.set('sysparm_query', `name=${id}`);
|
|
155
156
|
p.set('sysparm_limit', '1');
|
|
@@ -215,8 +216,6 @@ async function showItem(app, sysID) {
|
|
|
215
216
|
const wfName = getStringField(item, 'workflow') || '';
|
|
216
217
|
const wfID = item.workflow?.value || '';
|
|
217
218
|
const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
|
|
218
|
-
const planID = item.delivery_plan?.value || item.execution_plan?.value || '';
|
|
219
|
-
|
|
220
219
|
app.ok({
|
|
221
220
|
name: getStringField(item, 'name'),
|
|
222
221
|
short_description: getStringField(item, 'short_description'),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generic dev subcommand builder for table-based CRUD
|
|
2
2
|
|
|
3
|
-
import { formatRecordForDisplay, getStringField, isHexString, parseDataArg } from '../../helpers.js';
|
|
3
|
+
import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
|
|
4
4
|
import { getCurrentUser, getCurrentApplication } from '../../context.js';
|
|
5
5
|
import { paginatedSearch } from '../../paginated-search.js';
|
|
6
6
|
import { isTTY, FormatAuto } from '../../output.js';
|
|
@@ -81,7 +81,6 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
81
81
|
const readOnly = opts.readOnly || false;
|
|
82
82
|
const scopeValidation = opts.scopeValidation || false;
|
|
83
83
|
const extraQuery = opts.extraQuery || '';
|
|
84
|
-
const formatLabel = opts.formatLabel || null;
|
|
85
84
|
const showSummary = opts.showSummary || ((record, id) => `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${getStringField(record, 'name') || id}`);
|
|
86
85
|
const showBreadcrumbs = opts.showBreadcrumbs || ((record, id) => {
|
|
87
86
|
const crumbs = [
|
|
@@ -120,7 +119,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
120
119
|
totalCount = await app.sdk.aggregateCount(table, extraQuery || '');
|
|
121
120
|
} catch { /* non-critical */ }
|
|
122
121
|
|
|
123
|
-
const source = async (term, offset, { signal } = {}) => {
|
|
122
|
+
const source = async (term, offset, { signal: _signal } = {}) => {
|
|
124
123
|
const params = new URLSearchParams();
|
|
125
124
|
params.set('sysparm_limit', String(limit));
|
|
126
125
|
params.set('sysparm_display_value', 'all');
|
|
@@ -150,6 +149,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
150
149
|
|
|
151
150
|
if (selectedName) {
|
|
152
151
|
const recordName = typeof selectedName === 'object' ? selectedName.value : selectedName;
|
|
152
|
+
assertSafeExactMatch(recordName);
|
|
153
153
|
const showParams = new URLSearchParams();
|
|
154
154
|
showParams.set('sysparm_query', `name=${recordName}`);
|
|
155
155
|
showParams.set('sysparm_display_value', 'all');
|
|
@@ -202,6 +202,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
202
202
|
handler: wrap(async (argv, app) => {
|
|
203
203
|
const id = argv.identifier;
|
|
204
204
|
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
205
|
+
assertSafeExactMatch(id);
|
|
205
206
|
const params = new URLSearchParams();
|
|
206
207
|
params.set('sysparm_query', `${queryField}=${id}`);
|
|
207
208
|
params.set('sysparm_limit', '1');
|
|
@@ -259,6 +260,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
259
260
|
handler: wrap(async (argv, app) => {
|
|
260
261
|
const id = argv.identifier;
|
|
261
262
|
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
263
|
+
assertSafeExactMatch(id);
|
|
262
264
|
const findParams = new URLSearchParams();
|
|
263
265
|
findParams.set('sysparm_query', `${queryField}=${id}`);
|
|
264
266
|
findParams.set('sysparm_limit', '1');
|
|
@@ -289,6 +291,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
289
291
|
handler: wrap(async (argv, app) => {
|
|
290
292
|
const id = argv.identifier;
|
|
291
293
|
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
294
|
+
assertSafeExactMatch(id);
|
|
292
295
|
const findParams = new URLSearchParams();
|
|
293
296
|
findParams.set('sysparm_query', `${queryField}=${id}`);
|
|
294
297
|
findParams.set('sysparm_limit', '1');
|
|
@@ -308,17 +311,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
308
311
|
}
|
|
309
312
|
}
|
|
310
313
|
|
|
311
|
-
|
|
312
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
313
|
-
const answer = await new Promise((resolve) => {
|
|
314
|
-
rl.question(`Delete ${singular} '${recordName}'? (y/N): `, resolve);
|
|
315
|
-
});
|
|
316
|
-
rl.close();
|
|
317
|
-
const response = answer.trim().toLowerCase();
|
|
318
|
-
if (response !== 'y' && response !== 'yes') {
|
|
319
|
-
throw new Error('Deletion cancelled');
|
|
320
|
-
}
|
|
321
|
-
}
|
|
314
|
+
await confirmDelete(app, argv, `Delete ${singular} '${recordName}'`);
|
|
322
315
|
|
|
323
316
|
await app.sdk.delete(table, sysID);
|
|
324
317
|
app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
|
|
@@ -302,6 +302,11 @@ function inferFlowVersion(inspection) {
|
|
|
302
302
|
}
|
|
303
303
|
|
|
304
304
|
async function formatFlowStructure(inspection, ctx) {
|
|
305
|
+
const names = await collectCatalogVarNames(inspection, ctx);
|
|
306
|
+
if (names.size > 0) {
|
|
307
|
+
if (!ctx.catalogVarNames) ctx.catalogVarNames = new Map();
|
|
308
|
+
for (const [k, v] of names) ctx.catalogVarNames.set(k, v);
|
|
309
|
+
}
|
|
305
310
|
const payload = inspection.payload;
|
|
306
311
|
if (Object.keys(payload).length > 0) {
|
|
307
312
|
return formatFlowStructureFromPayload(payload, ctx);
|
|
@@ -309,6 +314,104 @@ async function formatFlowStructure(inspection, ctx) {
|
|
|
309
314
|
return formatFlowStructureFallback(inspection);
|
|
310
315
|
}
|
|
311
316
|
|
|
317
|
+
/**
|
|
318
|
+
* ServiceNow serializes catalog variable selections in action inputs as
|
|
319
|
+
* comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
|
|
320
|
+
* Catalog Task steps). The payload carries the source table on the parameter
|
|
321
|
+
* attributes, but the value itself also names it in each token. Resolve those
|
|
322
|
+
* sys_ids to readable labels (question_text, falling back to name) with one
|
|
323
|
+
* batched Table API query per distinct table.
|
|
324
|
+
*/
|
|
325
|
+
async function collectCatalogVarNames(inspection, ctx) {
|
|
326
|
+
const names = new Map();
|
|
327
|
+
if (!ctx?.sdk) return names;
|
|
328
|
+
|
|
329
|
+
const refs = new Map(); // table -> Set<sys_id>
|
|
330
|
+
const TOKEN_RE = /([0-9a-f]{32}):([a-z_]+)/g;
|
|
331
|
+
|
|
332
|
+
function addRefsFromValue(value) {
|
|
333
|
+
if (!value) return;
|
|
334
|
+
for (const m of String(value).matchAll(TOKEN_RE)) {
|
|
335
|
+
const [, id, table] = m;
|
|
336
|
+
if (!refs.has(table)) refs.set(table, new Set());
|
|
337
|
+
refs.get(table).add(id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function walk(items) {
|
|
342
|
+
if (!Array.isArray(items)) return;
|
|
343
|
+
for (const item of items) {
|
|
344
|
+
if (!item || typeof item !== 'object') continue;
|
|
345
|
+
if (Array.isArray(item.inputs)) {
|
|
346
|
+
for (const input of item.inputs) {
|
|
347
|
+
if (!input || typeof input !== 'object') continue;
|
|
348
|
+
addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (Array.isArray(item.flowBlock)) walk(item.flowBlock);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const payload = inspection?.payload;
|
|
356
|
+
if (payload && typeof payload === 'object') {
|
|
357
|
+
walk(payload.actionInstances);
|
|
358
|
+
if (Array.isArray(payload.flowLogicInstances)) {
|
|
359
|
+
for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Fallback table sources may also carry inputs (e.g. decoded logic values)
|
|
363
|
+
walk(inspection?.actionInstances);
|
|
364
|
+
if (Array.isArray(inspection?.flowLogicInstances)) {
|
|
365
|
+
for (const logic of inspection.flowLogicInstances) {
|
|
366
|
+
if (logic?._decodedValues?.inputs) {
|
|
367
|
+
for (const input of logic._decodedValues.inputs) {
|
|
368
|
+
if (!input || typeof input !== 'object') continue;
|
|
369
|
+
addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
for (const [table, ids] of refs) {
|
|
376
|
+
const idList = [...ids];
|
|
377
|
+
for (let i = 0; i < idList.length; i += 100) {
|
|
378
|
+
const chunk = idList.slice(i, i + 100);
|
|
379
|
+
try {
|
|
380
|
+
const params = new URLSearchParams();
|
|
381
|
+
params.set('sysparm_query', `sys_idIN${chunk.join(',')}`);
|
|
382
|
+
params.set('sysparm_fields', 'sys_id,name,question_text');
|
|
383
|
+
params.set('sysparm_display_value', 'all');
|
|
384
|
+
params.set('sysparm_limit', String(chunk.length));
|
|
385
|
+
const records = await ctx.sdk.list(table, params);
|
|
386
|
+
for (const r of records) {
|
|
387
|
+
const id = getStringField(r, 'sys_id');
|
|
388
|
+
const label = firstNonEmpty(getStringField(r, 'question_text'), getStringField(r, 'name'));
|
|
389
|
+
if (id && label) names.set(id, label);
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
// resolution failed (no access, deleted record) — leave raw sys_ids
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return names;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Replace catalog variable "sys_id:table" pairs with readable labels when the
|
|
402
|
+
* resolver map has them. Returns null when the value isn't a catalog-variable
|
|
403
|
+
* list (so callers keep rendering it raw).
|
|
404
|
+
*/
|
|
405
|
+
function resolveCatalogVarValue(value, catalogVarNames) {
|
|
406
|
+
if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
|
|
407
|
+
const trimmed = String(value).trim();
|
|
408
|
+
if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
|
|
409
|
+
return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
|
|
410
|
+
const label = catalogVarNames.get(id);
|
|
411
|
+
return label || m;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
312
415
|
async function formatFlowStructureFromPayload(payload, ctx) {
|
|
313
416
|
const childUIDs = new Set();
|
|
314
417
|
|
|
@@ -497,11 +600,11 @@ async function formatStepLine(stepNum, pad, step, ctx) {
|
|
|
497
600
|
case 'subflow':
|
|
498
601
|
return formatSubFlowStep(stepNum, pad, step.data, ctx);
|
|
499
602
|
default:
|
|
500
|
-
return formatActionStep(stepNum, pad, step.data);
|
|
603
|
+
return formatActionStep(stepNum, pad, step.data, ctx);
|
|
501
604
|
}
|
|
502
605
|
}
|
|
503
606
|
|
|
504
|
-
export function formatActionStep(stepNum, pad, action) {
|
|
607
|
+
export function formatActionStep(stepNum, pad, action, ctx) {
|
|
505
608
|
const lines = [];
|
|
506
609
|
let actionName = firstNonEmpty(
|
|
507
610
|
getNestedString(action, 'actionType', 'fName'),
|
|
@@ -549,6 +652,11 @@ export function formatActionStep(stepNum, pad, action) {
|
|
|
549
652
|
let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
|
|
550
653
|
if (!inputValue) continue;
|
|
551
654
|
|
|
655
|
+
// Catalog variable selections arrive as "sys_id:table" pairs; resolve
|
|
656
|
+
// them to readable names when we have a resolver map (built per flow).
|
|
657
|
+
const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
|
|
658
|
+
if (resolvedCatalog) inputValue = resolvedCatalog;
|
|
659
|
+
|
|
552
660
|
let label = inputName;
|
|
553
661
|
if (raw.parameter && typeof raw.parameter === 'object') {
|
|
554
662
|
label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
|