@jacebenson/jsn 4.3.7 → 4.3.8-dev.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +266 -1
  2. package/package.json +1 -1
  3. package/skills/servicenow/SKILL.md +16 -0
  4. package/src/app.js +8 -1
  5. package/src/auth.js +14 -12
  6. package/src/cli-adapter.js +162 -11
  7. package/src/cli.js +121 -13
  8. package/src/commands/_generic.js +3 -2
  9. package/src/commands/_simple.js +1 -1
  10. package/src/commands/actions.js +156 -0
  11. package/src/commands/auth.js +21 -11
  12. package/src/commands/cmdb.js +18 -0
  13. package/src/commands/diff.js +205 -0
  14. package/src/commands/docs/docs.js +18 -16
  15. package/src/commands/flows.js +149 -47
  16. package/src/commands/graphql.js +95 -0
  17. package/src/commands/groupmembers.js +19 -4
  18. package/src/commands/grouproles.js +15 -4
  19. package/src/commands/midservers.js +708 -0
  20. package/src/commands/perf.js +36 -9
  21. package/src/commands/platform.js +202 -0
  22. package/src/commands/records.js +19 -15
  23. package/src/commands/skill.js +5 -2
  24. package/src/commands/transactions.js +78 -0
  25. package/src/commands/updatesets.js +77 -11
  26. package/src/errors.js +71 -6
  27. package/src/flow-create.js +884 -0
  28. package/src/flow-designer-interactive.js +417 -0
  29. package/src/flow-inspection.js +15 -5
  30. package/src/flow-publish.js +32 -0
  31. package/src/flow-test.js +122 -0
  32. package/src/flow-triggers.js +448 -0
  33. package/src/flow-versioning.js +53 -0
  34. package/src/help.js +93 -28
  35. package/src/helpers.js +124 -9
  36. package/src/midserver-direct.js +312 -0
  37. package/src/midserver-install.js +170 -0
  38. package/src/midserver-zip.js +107 -0
  39. package/src/output.js +7 -0
  40. package/src/processflow-actions.js +366 -0
  41. package/src/sdk.js +8 -0
package/src/help.js CHANGED
@@ -1,5 +1,14 @@
1
1
  // Custom grouped help renderer
2
2
  // Organized by ServiceNow domain model with subgroup dividers
3
+ //
4
+ // The command rows are generated from the LIVE command registry
5
+ // (`cli.commandRegistry()`): every registered root command appears, with the
6
+ // description its own registration declares. The tables below are placement
7
+ // hints only — group order, dividers, and a fallback description for a command
8
+ // that declares none. A command that is registered but not hinted lands in the
9
+ // trailing "MORE" group, so help can never silently drop a command again
10
+ // (`approvals`, `cmdb`, `docs`, `perf`, `platform`, … were missing from the
11
+ // hand-written list while being fully registered).
3
12
 
4
13
  function renderGroup(name, commands) {
5
14
  const lines = [`\n${name}`];
@@ -23,6 +32,8 @@ const CORE_COMMANDS = [
23
32
  { name: 'changes', alias: 'chg', desc: 'Manage change requests' },
24
33
  { name: 'requests', alias: 'req', desc: 'Manage service catalog requests' },
25
34
  { name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
35
+ { name: 'tickets', desc: 'Query generic tickets' },
36
+ { name: 'approvals', desc: 'Approval requests (list, approve, reject, submit, history)' },
26
37
  ];
27
38
 
28
39
  const AUTOMATION_COMMANDS = [
@@ -96,6 +107,7 @@ const DATA_COMMANDS = [
96
107
  { name: 'tables', alias: 't', desc: 'Table definitions' },
97
108
  { name: 'columns', alias: 'col', desc: 'Column definitions' },
98
109
  { name: 'relationships', alias: 'rel', desc: 'Table relationships' },
110
+ { name: 'cmdb', desc: 'CMDB: CI lookup and relationship traversal (read-only)' },
99
111
 
100
112
  { divider: '── Shared Code, Transforms, Logs ──' },
101
113
  { name: 'includes', alias: 'si', desc: 'Script includes' },
@@ -104,6 +116,8 @@ const DATA_COMMANDS = [
104
116
  { name: 'properties', alias: 'prop', desc: 'System properties' },
105
117
 
106
118
  { name: 'records', desc: 'Generic Table API (any table)' },
119
+ { name: 'transactions', desc: 'Client transaction timings' },
120
+ { name: 'snippets', desc: 'Saved query snippets' },
107
121
  ];
108
122
 
109
123
  const CONFIG_COMMANDS = [
@@ -112,12 +126,20 @@ const CONFIG_COMMANDS = [
112
126
  { name: 'updatesets', desc: 'Update sets (set, create, export)' },
113
127
  { name: 'scopes', desc: 'Application scopes' },
114
128
  { name: 'domains', desc: 'Domains (domain separation)' },
129
+ { name: 'midservers', desc: 'MID Servers and MID-run commands' },
130
+ { name: 'platform', desc: 'Platform health and node statistics (read-only)' },
131
+ { name: 'completion', desc: 'Shell completion script' },
115
132
  ];
116
133
 
117
134
  const DEVELOPER_COMMANDS = [
118
135
  { name: 'atf', desc: 'Automated Test Framework (list, suites, run, results)' },
119
136
  { name: 'eval', desc: 'Run server-side JavaScript' },
120
137
  { name: 'rest', desc: 'Raw REST API calls' },
138
+ { name: 'graphql', desc: 'GraphQL queries' },
139
+ { name: 'codesearch', desc: 'Search code across ServiceNow artifacts' },
140
+ { name: 'docs', desc: 'Local ServiceNow documentation search' },
141
+ { name: 'diff', desc: 'Compare records across configured profiles' },
142
+ { name: 'perf', desc: 'Read-only performance capture and comparison' },
121
143
  { name: 'skill', desc: 'AI agent skill file (show, fetch, install)' },
122
144
  { name: 'version', desc: 'Version info (--check for npm updates)' },
123
145
  ];
@@ -132,17 +154,50 @@ const COMMAND_GROUPS = {
132
154
  'DEVELOPER': DEVELOPER_COMMANDS,
133
155
  };
134
156
 
135
- function renderFlags() {
136
- return `
137
- FLAGS
138
- --instance ServiceNow instance URL (e.g., https://dev12345.service-now.com) [string]
139
- -p, --profile Configuration profile to use [string]
140
- --format Output format: auto, json, markdown, styled, quiet [string]
141
- --json Output in JSON format [boolean]
142
- -q, --quiet Output only data, no envelope [boolean]
143
- --styled Force styled output [boolean]
144
- --markdown Output in Markdown format [boolean]
145
- --help Show help [boolean]`;
157
+ /**
158
+ * Domain separation is an optional plugin — only advertise `domains` when the
159
+ * active profile's capability probe found it installed.
160
+ */
161
+ function isCapabilityHidden(name, config) {
162
+ if (name !== 'domains') return false;
163
+ const activeName = config?.activeProfile || config?.defaultProfile;
164
+ return !(activeName && config?.profiles?.[activeName]?.domain_separation);
165
+ }
166
+
167
+ /**
168
+ * Group rows for one domain: registry first (alias/description from the
169
+ * command's own registration), hint text only as a fallback description.
170
+ */
171
+ function rowsFor(hint, byName, used, config) {
172
+ const rows = [];
173
+ for (const entry of hint) {
174
+ if (entry.divider) { rows.push(entry); continue; }
175
+ const node = byName.get(entry.name);
176
+ if (!node || isCapabilityHidden(node.name, config)) continue;
177
+ used.add(node.name);
178
+ rows.push({
179
+ name: node.name,
180
+ alias: node.aliases[0] || entry.alias || '',
181
+ desc: node.description || entry.desc || '',
182
+ });
183
+ }
184
+ return rows;
185
+ }
186
+
187
+ function renderFlags(options) {
188
+ // Derived from the registered global options, so a new global flag cannot be
189
+ // missing from help (--get and --csv were, along with the csv format choice).
190
+ const lines = [];
191
+ for (const spec of options) {
192
+ if (spec.name === 'help') continue;
193
+ const alias = spec.alias.length ? `-${spec.alias.join(', -')}, ` : '';
194
+ const suffix = spec.name === 'format' && spec.choices.length
195
+ ? ` [string: ${spec.choices.join(', ')}]`
196
+ : ` [${spec.type}]`;
197
+ lines.push(` ${`${alias}--${spec.name}`.padEnd(24)}${spec.description}${suffix}`);
198
+ }
199
+ lines.push(` ${'--help'.padEnd(24)}Show help${' [boolean]'}`);
200
+ return `\nFLAGS\n${lines.join('\n')}`;
146
201
  }
147
202
 
148
203
  function renderTips() {
@@ -150,35 +205,45 @@ function renderTips() {
150
205
  TIPS
151
206
  --query is available on every list command (e.g. "incidents list --query priority=1")
152
207
  Use "jsn <command> --help" for details, or "jsn <command> list --help" for list options
208
+ "jsn --json" prints the registered command surface as JSON for scripts and agents
153
209
 
154
210
  LEARN MORE
155
211
  Use "jsn <command> --help" for more information about a command.`;
156
212
  }
157
213
 
158
- export function renderHelp(config) {
159
- // Domain separation is an optional plugin — only advertise `domains`
160
- // when the active profile's capability probe found it installed.
161
- const activeName = config?.activeProfile || config?.defaultProfile;
162
- const hasDS = !!(activeName && config?.profiles?.[activeName]?.domain_separation);
163
- const configCmds = hasDS
164
- ? CONFIG_COMMANDS
165
- : CONFIG_COMMANDS.filter((c) => !c.name || c.name !== 'domains');
166
-
167
- const COMMAND_GROUPS_LOCAL = {
168
- ...COMMAND_GROUPS,
169
- 'CONFIGURATION': configCmds,
170
- };
171
-
172
- const sections = Object.entries(COMMAND_GROUPS_LOCAL).map(([name, cmds]) => renderGroup(name, cmds));
214
+ /**
215
+ * Render the grouped root help.
216
+ *
217
+ * @param {object} config — profile config (capability-conditioned hiding)
218
+ * @param {{commands: Array, options: Array}} [registry] — from
219
+ * `cli.commandRegistry()`. Without it only the hint tables render, which is
220
+ * the fallback for callers that have no registry (unit tests).
221
+ */
222
+ export function renderHelp(config, registry = null) {
223
+ const commands = Array.isArray(registry?.commands) ? registry.commands : [];
224
+ const byName = new Map(commands.map((command) => [command.name, command]));
225
+ const used = new Set();
226
+
227
+ const sections = Object.entries(COMMAND_GROUPS)
228
+ .map(([name, hint]) => [name, rowsFor(hint, byName, used, config)])
229
+ .filter(([, rows]) => rows.some((row) => !row.divider))
230
+ .map(([name, rows]) => renderGroup(name, rows));
231
+
232
+ // Anything registered that the hint tables don't place. This is the safety
233
+ // net that keeps help complete as commands are added at the root.
234
+ const unplaced = commands
235
+ .filter((command) => !used.has(command.name) && !isCapabilityHidden(command.name, config))
236
+ .map((command) => ({ name: command.name, alias: command.aliases[0] || '', desc: command.description || '' }));
237
+ if (unplaced.length) sections.push(renderGroup('MORE', unplaced));
173
238
 
174
239
  return [
175
240
  'Usage: jsn <command> [options]',
176
241
  '',
177
- `Command-line interface for ServiceNow`,
242
+ 'Command-line interface for ServiceNow',
178
243
  '',
179
244
  ...sections,
180
245
  '',
181
- renderFlags(),
246
+ renderFlags(Array.isArray(registry?.options) ? registry.options : []),
182
247
  renderTips(),
183
248
  '',
184
249
  ].join('\n');
package/src/helpers.js CHANGED
@@ -32,6 +32,93 @@ export function canPrompt() {
32
32
  return isTTY(process.stdout) && isTTY(process.stdin);
33
33
  }
34
34
 
35
+ /**
36
+ * True when an interactive picker may be opened for THIS invocation.
37
+ *
38
+ * canPrompt() alone answers "is a human reachable" — it does not know that
39
+ * the caller asked for machine output (`--json`, `--quiet`, `--csv`, …) or
40
+ * a one-shot extraction (`--get`). Opening a picker in either case blocks a
41
+ * script and corrupts the requested output shape, so every picker gate
42
+ * routes through here.
43
+ *
44
+ * @param {object} app — app instance (uses app.output when present)
45
+ * @param {object} [argv] — parsed args (checks argv.get)
46
+ * @returns {boolean} true when a picker may be shown
47
+ */
48
+ export function canUsePicker(app, argv = {}) {
49
+ if (argv && argv.get) return false;
50
+ if (!canPrompt()) return false;
51
+ const writer = app?.output;
52
+ if (!writer) return true; // no output controller (unit-test app) — canPrompt decided
53
+ const format = typeof writer.getFormat === 'function' ? writer.getFormat() : undefined;
54
+ if (!format) return true;
55
+ return format === FormatAuto;
56
+ }
57
+
58
+ /**
59
+ * Render a parent command's subcommand list for its default handler.
60
+ *
61
+ * Returned through the `_formatted` side channel so a terminal prints the list
62
+ * while `--json`/`--quiet` keep the structured envelope. A parent command that
63
+ * only sets a summary leaves terminal users with no way to discover children.
64
+ */
65
+ export function formatCommandList({ title, commands, footer }) {
66
+ const width = Math.max(...commands.map(command => command.name.length));
67
+ const lines = [title, '', 'Available subcommands:'];
68
+ for (const command of commands) lines.push(` ${command.name.padEnd(width)} ${command.description}`);
69
+ if (footer) lines.push('', footer);
70
+ return `${lines.join('\n')}\n`;
71
+ }
72
+
73
+ /**
74
+ * Quote one argv token so the emitted confirmation hint is copy-pasteable
75
+ * in a POSIX shell. Boring tokens stay bare; anything containing spaces,
76
+ * quotes, `$`, `;`, `&`, `*` … gets single-quoted with embedded quotes
77
+ * escaped. `^ = < > ~ ! ,` are ServiceNow query characters — common in
78
+ * encoded queries — and are shell-safe bare, so they stay bare.
79
+ *
80
+ * @param {*} value — token
81
+ * @returns {string} shell-safe token
82
+ */
83
+ export function quoteShellArg(value) {
84
+ const s = value == null ? '' : String(value);
85
+ if (s === '') return "''";
86
+ if (/^[A-Za-z0-9_@%+=:,./^-]+$/.test(s)) return s;
87
+ return `'${s.split("'").join("'\\''")}'`;
88
+ }
89
+
90
+ /**
91
+ * Build the exact, safely-quoted rerun command for a confirmation hint.
92
+ *
93
+ * `argv._` holds command PATH NAMES only — the local adapter stores
94
+ * positionals and flags in named fields — so a hint built from `argv._`
95
+ * alone loses the target (`jsn incidents delete` with no number). Callers
96
+ * therefore pass the complete target tokens.
97
+ *
98
+ * @param {object} argv — parsed args (reads `_`, `profile`, `instance`)
99
+ * @param {object} [opts]
100
+ * @param {string|string[]} [opts.rerun] — complete rerun command: a token
101
+ * array (quoted here) or a full command string (used verbatim)
102
+ * @returns {string} command without `--force`, or '' when unknowable
103
+ */
104
+ export function buildRerunCommand(argv = {}, { rerun = null } = {}) {
105
+ let parts;
106
+ if (Array.isArray(rerun) && rerun.length > 0) {
107
+ parts = ['jsn', ...rerun].map(quoteShellArg);
108
+ } else if (typeof rerun === 'string' && rerun.trim()) {
109
+ parts = [rerun.trim()];
110
+ } else {
111
+ parts = ['jsn', ...(argv?._ || [])].map(quoteShellArg);
112
+ }
113
+ if (parts.length === 1) return ''; // no path known — nothing worth repeating
114
+ const joined = parts.join(' ');
115
+ // Re-pin the profile/instance the caller was operating on, so following
116
+ // the hint cannot silently retarget a different instance or profile.
117
+ if (argv?.profile && !joined.includes('--profile')) parts.push('--profile', quoteShellArg(argv.profile));
118
+ if (argv?.instance && !joined.includes('--instance')) parts.push('--instance', quoteShellArg(argv.instance));
119
+ return parts.join(' ');
120
+ }
121
+
35
122
  /**
36
123
  * Confirm a destructive action before executing.
37
124
  *
@@ -44,10 +131,12 @@ export function canPrompt() {
44
131
  * @param {object} app — app instance (reads active profile from app.config)
45
132
  * @param {object} argv — parsed args (checks argv.force)
46
133
  * @param {string} question — e.g. `Delete incident INC0010001?`
134
+ * @param {object} [options] — { rerun } complete rerun command or token
135
+ * array for the hint (see buildRerunCommand)
47
136
  * @returns {Promise<boolean>} true when the action may proceed
48
137
  * @throws when confirmation is required but denied/unavailable
49
138
  */
50
- export async function confirmDelete(app, argv, question) {
139
+ export async function confirmDelete(app, argv, question, options = {}) {
51
140
  const profile = getActiveProfile(app.config);
52
141
  if (profile?.skip_confirmations === true) return true;
53
142
  if (argv.force) return true;
@@ -65,8 +154,7 @@ export async function confirmDelete(app, argv, question) {
65
154
  }
66
155
  // No human available (pipes, scripts, AI agents). Fail fast with a
67
156
  // structured error an agent can act on — never hang waiting for stdin.
68
- const cmd = ['jsn', ...(argv._ || [])].join(' ');
69
- throw errConfirmationRequired(question, cmd);
157
+ throw errConfirmationRequired(question, buildRerunCommand(argv, options));
70
158
  }
71
159
 
72
160
 
@@ -283,14 +371,39 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
283
371
  const pickerFields = pickerColumns.join(',');
284
372
  const baseQuery = query ? query + '^' : '';
285
373
 
286
- // Get total count
287
- let totalCount;
374
+ // Get total count. A FAILED count is unknown — never zero. Mapping the
375
+ // failure to 0 used to make callers report an invented "empty table"
376
+ // success (or silently return) when the real cause was a 403/network
377
+ // error. Only a measured 0 means empty; otherwise confirm emptiness with
378
+ // one bounded list request before showing the picker.
379
+ let totalCount = null;
380
+ let countError = null;
288
381
  try {
289
382
  totalCount = await app.sdk.aggregateCount(table, query || '');
290
- } catch {
291
- totalCount = 0;
383
+ } catch (err) {
384
+ countError = err;
385
+ }
386
+ if (totalCount === 0) return null; // measured empty
387
+
388
+ if (countError) {
389
+ try {
390
+ const probeParams = new URLSearchParams();
391
+ probeParams.set('sysparm_limit', '1');
392
+ probeParams.set('sysparm_display_value', 'all');
393
+ probeParams.set('sysparm_fields', 'sys_id');
394
+ if (query) probeParams.set('sysparm_query', query);
395
+ const probe = await app.sdk.list(table, probeParams);
396
+ if (!Array.isArray(probe)) throw new Error('list returned a non-array result');
397
+ if (probe.length === 0) return null; // the list itself confirms empty
398
+ } catch (err) {
399
+ // Both the count and the list failed — surface the failure instead of
400
+ // reporting an empty result nobody measured.
401
+ throw new Error(
402
+ `Could not list ${table}: ${err.message || err} (aggregate count also failed: ${countError.message || countError})`,
403
+ { cause: err }
404
+ );
405
+ }
292
406
  }
293
- if (totalCount === 0) return null;
294
407
 
295
408
  // Paginated source adapter: (term, offset, { signal }) => choices[]
296
409
  async function serverSource(term, offset, { signal: _signal } = {}) {
@@ -403,7 +516,9 @@ export function parseDataArg(argv) {
403
516
  try {
404
517
  return JSON.parse(raw);
405
518
  } catch (e) {
406
- throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
519
+ // Never echo the raw payload: real --data bodies carry personal data and
520
+ // secrets, and the parse position from e.message is enough to debug.
521
+ throw new Error(`Invalid JSON: ${e.message}\n\nHint: Use --data-file for multiline payloads to avoid shell escaping issues.`, { cause: e });
407
522
  }
408
523
  }
409
524