@jacebenson/jsn 4.0.0 → 4.0.2
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 +2 -1
- package/skills/servicenow/SKILL.md +2 -2
- package/src/auth.js +30 -10
- package/src/cli.js +13 -19
- package/src/commands/_ticket.js +7 -1
- package/src/commands/auth.js +10 -0
- package/src/commands/catalog.js +3 -4
- package/src/commands/dev/_generic.js +7 -14
- package/src/commands/dev/flows.js +161 -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/docs/community-sources.json +18 -0
- package/src/commands/docs/community.js +343 -0
- package/src/commands/docs/db.js +36 -0
- 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 +1 -1
- 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/skill.js +1 -6
- package/src/commands/tickets.js +4 -1
- package/src/commands/users.js +4 -1
- package/src/errors.js +26 -0
- package/src/helpers.js +65 -8
- package/src/mutations.js +7 -1
- package/src/output.js +6 -3
- package/src/paginated-search.js +1 -1
- package/src/records/inspect.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
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": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"chalk": "^5.4.1",
|
|
33
33
|
"cli-table3": "^0.6.5",
|
|
34
34
|
"gray-matter": "^4.0.3",
|
|
35
|
+
"turndown": "^7.2.4",
|
|
35
36
|
"yargs": "^17.7.2"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
@@ -14,7 +14,7 @@ compatibility: |
|
|
|
14
14
|
Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
|
|
15
15
|
metadata:
|
|
16
16
|
author: jacebenson
|
|
17
|
-
version: "
|
|
17
|
+
version: "4.0.0"
|
|
18
18
|
repository: https://github.com/jacebenson/jsn
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -69,7 +69,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
69
69
|
| **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
|
|
70
70
|
| **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
|
|
71
71
|
| **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
|
|
72
|
-
| **Config** | `setup`, `auth`, `
|
|
72
|
+
| **Config** | `setup`, `auth`, `updatesets`, `scopes` |
|
|
73
73
|
| **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
|
|
74
74
|
| **Developer** | `eval`, `rest`, `skill`, `version` |
|
|
75
75
|
|
package/src/auth.js
CHANGED
|
@@ -7,19 +7,34 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
import {
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
11
|
import { password as passwordPrompt } from '@inquirer/prompts';
|
|
12
12
|
import { globalConfigDir, normalizeInstanceURL } from './config.js';
|
|
13
13
|
import { errAuth } from './errors.js';
|
|
14
14
|
|
|
15
15
|
// ─── Credential key helpers ───
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Sanitize an instance URL into a filesystem/arg-safe key part.
|
|
19
|
+
*
|
|
20
|
+
* Whitelist approach (issue #143 findings #2/#3): first map the `://`
|
|
21
|
+
* protocol separator to `_` exactly as the Go version's encoding does
|
|
22
|
+
* (so existing keyring keys like `https_dev437538.service-now.com` keep
|
|
23
|
+
* matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
|
|
24
|
+
* stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
|
|
25
|
+
* spaces, and backslashes — which broke out of double-quoted shell args
|
|
26
|
+
* in keyring calls and, on Windows, out of path.join() credential dirs.
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeKeyPart(instance) {
|
|
29
|
+
return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* Build a compound key for credential storage: <username>@<instance>
|
|
19
34
|
* When username is omitted (legacy), just the normalized instance URL is used.
|
|
20
35
|
*/
|
|
21
36
|
function credKey(instance, username) {
|
|
22
|
-
const normalized = instance
|
|
37
|
+
const normalized = sanitizeKeyPart(instance);
|
|
23
38
|
if (!username) return normalized;
|
|
24
39
|
return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
|
|
25
40
|
}
|
|
@@ -29,7 +44,7 @@ function credKey(instance, username) {
|
|
|
29
44
|
function pkceStatePath(instance) {
|
|
30
45
|
const dir = path.join(globalConfigDir(), 'pkce');
|
|
31
46
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
-
const filename = instance
|
|
47
|
+
const filename = sanitizeKeyPart(instance) + '.json';
|
|
33
48
|
return path.join(dir, filename);
|
|
34
49
|
}
|
|
35
50
|
|
|
@@ -133,8 +148,11 @@ function buildAuthURL(instanceURL, clientID, pkce) {
|
|
|
133
148
|
|
|
134
149
|
function keyringLookup(key) {
|
|
135
150
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
// execFileSync with an arg array — no shell, so `key` can never
|
|
152
|
+
// break out into command injection (issue #143 finding #2).
|
|
153
|
+
const result = execFileSync(
|
|
154
|
+
'secret-tool',
|
|
155
|
+
['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
138
156
|
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
139
157
|
);
|
|
140
158
|
const trimmed = result.trim();
|
|
@@ -157,9 +175,10 @@ function keyringLookup(key) {
|
|
|
157
175
|
|
|
158
176
|
function keyringStore(key, creds) {
|
|
159
177
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
178
|
+
execFileSync(
|
|
179
|
+
'secret-tool',
|
|
180
|
+
['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
|
|
181
|
+
KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
163
182
|
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
164
183
|
);
|
|
165
184
|
return true;
|
|
@@ -170,8 +189,9 @@ function keyringStore(key, creds) {
|
|
|
170
189
|
|
|
171
190
|
function keyringDelete(key) {
|
|
172
191
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
192
|
+
execFileSync(
|
|
193
|
+
'secret-tool',
|
|
194
|
+
['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
175
195
|
{ stdio: 'ignore' }
|
|
176
196
|
);
|
|
177
197
|
} catch {
|
package/src/cli.js
CHANGED
|
@@ -5,10 +5,11 @@ import { hideBin } from 'yargs/helpers';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import { loadConfig,
|
|
8
|
+
import { loadConfig, getActiveProfile, globalConfigDir } from './config.js';
|
|
9
9
|
import { App } from './app.js';
|
|
10
10
|
import { renderHelp } from './help.js';
|
|
11
11
|
import { isMutationCommand } from './mutations.js';
|
|
12
|
+
import { guardExit } from './errors.js';
|
|
12
13
|
|
|
13
14
|
// Command modules
|
|
14
15
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -94,14 +95,6 @@ export function buildCLI() {
|
|
|
94
95
|
const cfg = loadConfig();
|
|
95
96
|
const app = new App(cfg);
|
|
96
97
|
|
|
97
|
-
// Resolve instance once
|
|
98
|
-
let effectiveInstance;
|
|
99
|
-
try {
|
|
100
|
-
effectiveInstance = getEffectiveInstance(cfg);
|
|
101
|
-
} catch {
|
|
102
|
-
effectiveInstance = null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
98
|
return yargs(hideBin(process.argv))
|
|
106
99
|
.scriptName('jsn')
|
|
107
100
|
.usage('Usage: jsn <command> [options]')
|
|
@@ -161,8 +154,11 @@ export function buildCLI() {
|
|
|
161
154
|
if (argv.profile) {
|
|
162
155
|
const profile = (cfg.profiles || {})[argv.profile];
|
|
163
156
|
if (!profile) {
|
|
164
|
-
|
|
165
|
-
|
|
157
|
+
guardExit(argv, {
|
|
158
|
+
code: 'unknown_profile',
|
|
159
|
+
message: `Unknown profile "${argv.profile}"`,
|
|
160
|
+
hint: 'Run "jsn auth status" to list profiles.',
|
|
161
|
+
});
|
|
166
162
|
}
|
|
167
163
|
const url = profile.instance_url;
|
|
168
164
|
app._overrideInstance = url;
|
|
@@ -193,18 +189,16 @@ export function buildCLI() {
|
|
|
193
189
|
// requireInstance throws errUsage; format it cleanly (the yargs
|
|
194
190
|
// fail handler re-throws middleware errors, which would print a
|
|
195
191
|
// raw stack trace).
|
|
196
|
-
|
|
197
|
-
process.exit(1);
|
|
192
|
+
guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
|
|
198
193
|
}
|
|
199
194
|
const profile = getActiveProfile(cfg);
|
|
200
195
|
if (profile?.read_only === true) {
|
|
201
196
|
const name = cfg.activeProfile || cfg.defaultProfile;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
207
|
-
process.exit(1);
|
|
197
|
+
guardExit(argv, {
|
|
198
|
+
code: 'read_only',
|
|
199
|
+
message: `Profile "${name}" is read-only. Mutations are blocked.`,
|
|
200
|
+
hint: 'Switch to a write-enabled profile first:\n jsn auth switch <name>',
|
|
201
|
+
});
|
|
208
202
|
}
|
|
209
203
|
}
|
|
210
204
|
},
|
package/src/commands/_ticket.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generic command builder for CRUD operations on a ServiceNow table
|
|
2
2
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
3
|
|
|
4
|
-
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
|
|
4
|
+
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
|
|
5
5
|
import { search } from '@inquirer/prompts';
|
|
6
6
|
import { isTTY, FormatAuto } from '../output.js';
|
|
7
7
|
|
|
@@ -86,6 +86,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
if (selectedNumber) {
|
|
89
|
+
assertSafeExactMatch(selectedNumber);
|
|
89
90
|
const showParams = new URLSearchParams();
|
|
90
91
|
showParams.set('sysparm_query', `number=${selectedNumber}`);
|
|
91
92
|
showParams.set('sysparm_display_value', 'all');
|
|
@@ -161,6 +162,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
161
162
|
describe: `Show a specific ${displayName}`,
|
|
162
163
|
handler: wrap(async (argv, app) => {
|
|
163
164
|
const number = argv.number;
|
|
165
|
+
assertSafeExactMatch(number);
|
|
164
166
|
const params = new URLSearchParams();
|
|
165
167
|
params.set('sysparm_query', `number=${number}`);
|
|
166
168
|
params.set('sysparm_display_value', 'all');
|
|
@@ -216,6 +218,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
216
218
|
.option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
|
|
217
219
|
handler: wrap(async (argv, app) => {
|
|
218
220
|
const number = argv.number;
|
|
221
|
+
assertSafeExactMatch(number);
|
|
219
222
|
const recordData = JSON.parse(argv.data);
|
|
220
223
|
const findParams = new URLSearchParams();
|
|
221
224
|
findParams.set('sysparm_query', `number=${number}`);
|
|
@@ -237,8 +240,11 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
237
240
|
.command({
|
|
238
241
|
command: 'delete <number>',
|
|
239
242
|
describe: `Delete a ${displayName}`,
|
|
243
|
+
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
240
244
|
handler: wrap(async (argv, app) => {
|
|
241
245
|
const number = argv.number;
|
|
246
|
+
await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
|
|
247
|
+
assertSafeExactMatch(number);
|
|
242
248
|
const findParams = new URLSearchParams();
|
|
243
249
|
findParams.set('sysparm_query', `number=${number}`);
|
|
244
250
|
findParams.set('sysparm_limit', '1');
|
package/src/commands/auth.js
CHANGED
|
@@ -57,6 +57,9 @@ export async function loginWizard(app, argv = {}) {
|
|
|
57
57
|
if (argv['read-only']) {
|
|
58
58
|
profile.read_only = true;
|
|
59
59
|
}
|
|
60
|
+
if (argv['skip-confirmations']) {
|
|
61
|
+
profile.skip_confirmations = true;
|
|
62
|
+
}
|
|
60
63
|
|
|
61
64
|
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
62
65
|
const useBasic = authMethod.toLowerCase().startsWith('b');
|
|
@@ -192,6 +195,11 @@ export function authCmd(wrap) {
|
|
|
192
195
|
describe: 'Mark the created profile as read-only (blocks mutation commands)',
|
|
193
196
|
type: 'boolean',
|
|
194
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,
|
|
195
203
|
}),
|
|
196
204
|
handler: wrap(async (argv, app) => {
|
|
197
205
|
let instanceURL;
|
|
@@ -293,6 +301,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
293
301
|
auth_method: argv.password ? 'basic' : 'oauth',
|
|
294
302
|
username: username || undefined,
|
|
295
303
|
read_only: argv['read-only'] || undefined,
|
|
304
|
+
skip_confirmations: argv['skip-confirmations'] || undefined,
|
|
296
305
|
};
|
|
297
306
|
|
|
298
307
|
// Re-save OAuth credentials with username now that we have it,
|
|
@@ -405,6 +414,7 @@ Examples:
|
|
|
405
414
|
stale: daysSinceLastSeen > 7,
|
|
406
415
|
default: instance === defaultInstance,
|
|
407
416
|
read_only: profile.read_only || false,
|
|
417
|
+
skip_confirmations: profile.skip_confirmations || false,
|
|
408
418
|
});
|
|
409
419
|
}
|
|
410
420
|
|
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,16 @@ 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
|
+
}
|
|
310
|
+
const labelCache = buildLabelCache(inspection.payload);
|
|
311
|
+
if (labelCache.size > 0) {
|
|
312
|
+
if (!ctx.labelCache) ctx.labelCache = new Map();
|
|
313
|
+
for (const [k, v] of labelCache) ctx.labelCache.set(k, v);
|
|
314
|
+
}
|
|
305
315
|
const payload = inspection.payload;
|
|
306
316
|
if (Object.keys(payload).length > 0) {
|
|
307
317
|
return formatFlowStructureFromPayload(payload, ctx);
|
|
@@ -309,6 +319,146 @@ async function formatFlowStructure(inspection, ctx) {
|
|
|
309
319
|
return formatFlowStructureFallback(inspection);
|
|
310
320
|
}
|
|
311
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The ProcessFlow payload carries a label cache (also serialized as
|
|
324
|
+
* labelCacheAsJsonString) mapping data-pill names like "<step-uid>.<var>"
|
|
325
|
+
* to the human labels the Flow Designer UI shows, e.g.
|
|
326
|
+
* "1 - Get Catalog Variables➛session_title". Build a Map so guid-prefixed
|
|
327
|
+
* pills can be rendered readably.
|
|
328
|
+
*/
|
|
329
|
+
function buildLabelCache(payload) {
|
|
330
|
+
const cache = new Map();
|
|
331
|
+
if (!payload || typeof payload !== 'object') return cache;
|
|
332
|
+
let entries = payload.label_cache;
|
|
333
|
+
if (!Array.isArray(entries)) {
|
|
334
|
+
try {
|
|
335
|
+
entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
|
|
336
|
+
} catch {
|
|
337
|
+
entries = [];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const e of entries) {
|
|
341
|
+
if (!e || typeof e !== 'object') continue;
|
|
342
|
+
const name = getStringField(e, 'name');
|
|
343
|
+
const label = getStringField(e, 'label');
|
|
344
|
+
if (name && label) cache.set(name, label);
|
|
345
|
+
}
|
|
346
|
+
return cache;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Replace opaque guid-prefixed pills ({{<32-hex-guid>.<var>}}) with their
|
|
353
|
+
* human label from the payload's label cache. Readable step refs like
|
|
354
|
+
* {{Created_1.table_name}} are NOT matched and stay raw.
|
|
355
|
+
*/
|
|
356
|
+
function resolveGuidPills(value, labelCache) {
|
|
357
|
+
if (!labelCache || labelCache.size === 0 || !value) return value;
|
|
358
|
+
return String(value).replace(GUID_PILL_RE, (m, guid, name) => {
|
|
359
|
+
const label = labelCache.get(`${guid}.${name}`);
|
|
360
|
+
return label ? `{{${label}}}` : m;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* ServiceNow serializes catalog variable selections in action inputs as
|
|
366
|
+
* comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
|
|
367
|
+
* Catalog Task steps). The payload carries the source table on the parameter
|
|
368
|
+
* attributes, but the value itself also names it in each token. Resolve those
|
|
369
|
+
* sys_ids to readable labels (question_text, falling back to name) with one
|
|
370
|
+
* batched Table API query per distinct table.
|
|
371
|
+
*/
|
|
372
|
+
async function collectCatalogVarNames(inspection, ctx) {
|
|
373
|
+
const names = new Map();
|
|
374
|
+
if (!ctx?.sdk) return names;
|
|
375
|
+
|
|
376
|
+
const refs = new Map(); // table -> Set<sys_id>
|
|
377
|
+
const TOKEN_RE = /([0-9a-f]{32}):([a-z_]+)/g;
|
|
378
|
+
|
|
379
|
+
function addRefsFromValue(value) {
|
|
380
|
+
if (!value) return;
|
|
381
|
+
for (const m of String(value).matchAll(TOKEN_RE)) {
|
|
382
|
+
const [, id, table] = m;
|
|
383
|
+
if (!refs.has(table)) refs.set(table, new Set());
|
|
384
|
+
refs.get(table).add(id);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function walk(items) {
|
|
389
|
+
if (!Array.isArray(items)) return;
|
|
390
|
+
for (const item of items) {
|
|
391
|
+
if (!item || typeof item !== 'object') continue;
|
|
392
|
+
if (Array.isArray(item.inputs)) {
|
|
393
|
+
for (const input of item.inputs) {
|
|
394
|
+
if (!input || typeof input !== 'object') continue;
|
|
395
|
+
addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (Array.isArray(item.flowBlock)) walk(item.flowBlock);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const payload = inspection?.payload;
|
|
403
|
+
if (payload && typeof payload === 'object') {
|
|
404
|
+
walk(payload.actionInstances);
|
|
405
|
+
if (Array.isArray(payload.flowLogicInstances)) {
|
|
406
|
+
for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Fallback table sources may also carry inputs (e.g. decoded logic values)
|
|
410
|
+
walk(inspection?.actionInstances);
|
|
411
|
+
if (Array.isArray(inspection?.flowLogicInstances)) {
|
|
412
|
+
for (const logic of inspection.flowLogicInstances) {
|
|
413
|
+
if (logic?._decodedValues?.inputs) {
|
|
414
|
+
for (const input of logic._decodedValues.inputs) {
|
|
415
|
+
if (!input || typeof input !== 'object') continue;
|
|
416
|
+
addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (const [table, ids] of refs) {
|
|
423
|
+
const idList = [...ids];
|
|
424
|
+
for (let i = 0; i < idList.length; i += 100) {
|
|
425
|
+
const chunk = idList.slice(i, i + 100);
|
|
426
|
+
try {
|
|
427
|
+
const params = new URLSearchParams();
|
|
428
|
+
params.set('sysparm_query', `sys_idIN${chunk.join(',')}`);
|
|
429
|
+
params.set('sysparm_fields', 'sys_id,name,question_text');
|
|
430
|
+
params.set('sysparm_display_value', 'all');
|
|
431
|
+
params.set('sysparm_limit', String(chunk.length));
|
|
432
|
+
const records = await ctx.sdk.list(table, params);
|
|
433
|
+
for (const r of records) {
|
|
434
|
+
const id = getStringField(r, 'sys_id');
|
|
435
|
+
const label = firstNonEmpty(getStringField(r, 'question_text'), getStringField(r, 'name'));
|
|
436
|
+
if (id && label) names.set(id, label);
|
|
437
|
+
}
|
|
438
|
+
} catch {
|
|
439
|
+
// resolution failed (no access, deleted record) — leave raw sys_ids
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return names;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Replace catalog variable "sys_id:table" pairs with readable labels when the
|
|
449
|
+
* resolver map has them. Returns null when the value isn't a catalog-variable
|
|
450
|
+
* list (so callers keep rendering it raw).
|
|
451
|
+
*/
|
|
452
|
+
function resolveCatalogVarValue(value, catalogVarNames) {
|
|
453
|
+
if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
|
|
454
|
+
const trimmed = String(value).trim();
|
|
455
|
+
if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
|
|
456
|
+
return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
|
|
457
|
+
const label = catalogVarNames.get(id);
|
|
458
|
+
return label || m;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
312
462
|
async function formatFlowStructureFromPayload(payload, ctx) {
|
|
313
463
|
const childUIDs = new Set();
|
|
314
464
|
|
|
@@ -497,11 +647,11 @@ async function formatStepLine(stepNum, pad, step, ctx) {
|
|
|
497
647
|
case 'subflow':
|
|
498
648
|
return formatSubFlowStep(stepNum, pad, step.data, ctx);
|
|
499
649
|
default:
|
|
500
|
-
return formatActionStep(stepNum, pad, step.data);
|
|
650
|
+
return formatActionStep(stepNum, pad, step.data, ctx);
|
|
501
651
|
}
|
|
502
652
|
}
|
|
503
653
|
|
|
504
|
-
export function formatActionStep(stepNum, pad, action) {
|
|
654
|
+
export function formatActionStep(stepNum, pad, action, ctx) {
|
|
505
655
|
const lines = [];
|
|
506
656
|
let actionName = firstNonEmpty(
|
|
507
657
|
getNestedString(action, 'actionType', 'fName'),
|
|
@@ -549,6 +699,15 @@ export function formatActionStep(stepNum, pad, action) {
|
|
|
549
699
|
let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
|
|
550
700
|
if (!inputValue) continue;
|
|
551
701
|
|
|
702
|
+
// Catalog variable selections arrive as "sys_id:table" pairs; resolve
|
|
703
|
+
// them to readable names when we have a resolver map (built per flow).
|
|
704
|
+
const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
|
|
705
|
+
if (resolvedCatalog) inputValue = resolvedCatalog;
|
|
706
|
+
|
|
707
|
+
// Opaque guid-prefixed pills ({{<32-hex>.<var>}}) get their human
|
|
708
|
+
// label from the payload's label cache. Readable step refs stay raw.
|
|
709
|
+
inputValue = resolveGuidPills(inputValue, ctx?.labelCache);
|
|
710
|
+
|
|
552
711
|
let label = inputName;
|
|
553
712
|
if (raw.parameter && typeof raw.parameter === 'object') {
|
|
554
713
|
label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
|
|
@@ -22,15 +22,6 @@ function getIntValue(record, key) {
|
|
|
22
22
|
return 0;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function getBoolValue(record, key) {
|
|
26
|
-
if (!record || typeof record !== 'object') return false;
|
|
27
|
-
const val = record[key];
|
|
28
|
-
if (typeof val === 'boolean') return val;
|
|
29
|
-
if (typeof val === 'string') return val === 'true';
|
|
30
|
-
if (typeof val === 'object' && val.value != null) return String(val.value) === 'true';
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
25
|
async function showForm(app, table, viewName) {
|
|
35
26
|
// 1) Look up the view
|
|
36
27
|
const viewParams = new URLSearchParams();
|
|
@@ -88,8 +79,6 @@ async function showForm(app, table, viewName) {
|
|
|
88
79
|
|
|
89
80
|
sectionsOut.sort((a, b) => a.position - b.position);
|
|
90
81
|
|
|
91
|
-
const totalElements = sectionsOut.reduce((sum, s) => sum + s.elements.length, 0);
|
|
92
|
-
|
|
93
82
|
// Build formatted output
|
|
94
83
|
const lines = [];
|
|
95
84
|
lines.push(`Form: ${table} → ${viewName}`);
|
|
@@ -20,7 +20,7 @@ export const importCmd = (wrap) => buildDevCmd('import', 'sys_import_set', ['imp
|
|
|
20
20
|
const rows = await app.sdk.list('sys_import_set_row', rp);
|
|
21
21
|
console.log(` Import rows: ${rows.length}`);
|
|
22
22
|
console.log(` → jsn records list --table sys_import_set_row --query "sys_import_set=${sysID}" --limit 200`);
|
|
23
|
-
} catch
|
|
23
|
+
} catch {
|
|
24
24
|
console.log(` Import rows: (unavailable)`);
|
|
25
25
|
}
|
|
26
26
|
},
|
|
@@ -17,7 +17,7 @@ export function listsCmd(wrap) {
|
|
|
17
17
|
handler: wrap(async (argv, app) => {
|
|
18
18
|
app.requireInstance();
|
|
19
19
|
let table = argv.table;
|
|
20
|
-
let viewSysID
|
|
20
|
+
let viewSysID;
|
|
21
21
|
|
|
22
22
|
// If no table, pick one interactively
|
|
23
23
|
if (!table) {
|
|
@@ -76,7 +76,7 @@ export function listsCmd(wrap) {
|
|
|
76
76
|
const table = argv.table;
|
|
77
77
|
|
|
78
78
|
// Resolve view sys_id
|
|
79
|
-
let viewSysID
|
|
79
|
+
let viewSysID;
|
|
80
80
|
const vp = new URLSearchParams();
|
|
81
81
|
vp.set('sysparm_limit', '1'); vp.set('sysparm_fields', 'sys_id');
|
|
82
82
|
vp.set('sysparm_query', `name=${argv.view}^name=${table}`);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sources": [
|
|
3
|
+
{
|
|
4
|
+
"name": "maik-skoddow-knowledge-sources",
|
|
5
|
+
"author": "Maik Skoddow",
|
|
6
|
+
"author_url": "https://www.servicenow.com/community/user/viewprofilepage/user-id/116698",
|
|
7
|
+
"url": "https://www.servicenow.com/community/training-and-certifications/knowledge-sources-to-go/ta-p/2310130",
|
|
8
|
+
"expand": true
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "mark-roethof-experiences",
|
|
12
|
+
"author": "Mark Roethof",
|
|
13
|
+
"author_url": "https://www.servicenow.com/community/user/viewprofilepage/user-id/201738",
|
|
14
|
+
"url": "https://www.servicenow.com/community/developer-blog/456-articles-blogs-videos-podcasts-share-projects-experiences/ba-p/2292127",
|
|
15
|
+
"expand": true
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|