@jacebenson/jsn 3.0.5 → 3.1.0

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/src/errors.js CHANGED
@@ -79,7 +79,34 @@ export function errAPI(status, msg) {
79
79
  if (status >= 500) {
80
80
  hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
81
81
  }
82
- return new AppError(CodeAPI, `API error (status ${status}): ${msg}`, hint, status);
82
+
83
+ // Try to extract structured error detail from JSON API error bodies
84
+ let displayMsg = msg;
85
+ if (typeof msg === 'string' && (msg.startsWith('{') || msg.startsWith('['))) {
86
+ try {
87
+ const parsed = JSON.parse(msg);
88
+ const errObj = parsed.error || parsed;
89
+ const detail = errObj.detail || errObj.message || '';
90
+ const message = errObj.message || '';
91
+
92
+ if (detail && message) {
93
+ displayMsg = `${message}: ${detail}`;
94
+ } else if (detail) {
95
+ displayMsg = detail;
96
+ } else if (message) {
97
+ displayMsg = message;
98
+ }
99
+
100
+ // Detect business rule abortion and add a hint
101
+ if (detail && detail.includes('Business Rule')) {
102
+ hint = 'A Business Rule rejected this operation. Check for overlapping routes, validation rules, or ACLs on this table.';
103
+ }
104
+ } catch {
105
+ // Not valid JSON, use original message
106
+ }
107
+ }
108
+
109
+ return new AppError(CodeAPI, `API error (status ${status}): ${displayMsg}`, hint, status);
83
110
  }
84
111
 
85
112
  export function errAmbiguous(resource, matches) {
package/src/help.js CHANGED
@@ -9,23 +9,63 @@ const COMMAND_GROUPS = {
9
9
  { name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
10
10
  { name: 'tickets', alias: 'ticket', desc: 'Query generic tickets' },
11
11
  ],
12
- 'DATA & ADMIN': [
12
+ 'AUTOMATION': [
13
+ { name: 'flows', desc: 'Manage Flow Designer flows' },
14
+ { name: 'actions', alias: 'action', desc: 'Manage flow actions (sys_hub_action_type_definition)' },
15
+ { name: 'rules', alias: 'br', desc: 'Manage business rules' },
16
+ { name: 'scrapi', desc: 'Manage Scripted REST APIs' },
17
+ { name: 'updatesets', desc: 'Manage update sets (set, yolo, export, import)' },
18
+ { name: 'eval', desc: 'Run server-side JavaScript' },
19
+ { name: 'rest', desc: 'Make raw REST API calls' },
20
+ ],
21
+ 'ACCESS': [
22
+ { name: 'acls', alias: 'acl', desc: 'Manage access controls' },
23
+ { name: 'roles', alias: 'role', desc: 'Manage roles' },
24
+ { name: 'scopes', desc: 'Manage application scopes' },
25
+ { name: 'properties', alias: 'prop', desc: 'Manage system properties' },
26
+ { name: 'privileges', alias: 'priv', desc: 'Manage scope privileges' },
27
+ { name: 'securitytypes', alias: 'st', desc: 'Manage security types' },
28
+ { name: 'aliases', alias: 'als', desc: 'Manage table aliases' },
29
+ ],
30
+ 'USER EXPERIENCE': [
31
+ { name: 'forms', desc: 'Manage form layouts' },
32
+ { name: 'lists', desc: 'Manage list layouts' },
33
+ { name: 'clientscripts', alias: 'cs', desc: 'Manage client scripts' },
34
+ { name: 'uipolicies', alias: 'up', desc: 'Manage UI policies' },
35
+ { name: 'uiactions', alias: 'ua', desc: 'Manage UI actions' },
36
+ { name: 'sppages', desc: 'Manage Service Portal pages' },
37
+ { name: 'spwidgets', desc: 'Manage Service Portal widgets' },
38
+ { name: 'uipages', desc: 'Manage UI pages' },
39
+ { name: 'appmenu', desc: 'Manage application menus' },
40
+ { name: 'listcontrols', alias: 'lc', desc: 'Manage list controls' },
41
+ { name: 'views', alias: 'vw', desc: 'Manage views' },
42
+ { name: 'uxscripts', alias: 'ux', desc: 'Manage UX source scripts' },
43
+ { name: 'catalogscripts', desc: 'Manage catalog client scripts' },
44
+ { name: 'cataloguipolicies', alias: 'cup', desc: 'Manage catalog UI policies' },
45
+ ],
46
+ 'DATA': [
13
47
  { name: 'records', desc: 'Generic Table API for any table' },
48
+ { name: 'tables', alias: 't', desc: 'Manage table definitions (sys_db_object)' },
49
+ { name: 'columns', alias: 'col', desc: 'Manage column definitions (sys_dictionary)' },
50
+ { name: 'includes', alias: 'si', desc: 'Manage script includes' },
51
+ { name: 'import', desc: 'Manage import sets' },
52
+ { name: 'logs', desc: 'View system logs' },
14
53
  { name: 'users', alias: 'user', desc: 'Manage ServiceNow users' },
15
54
  { name: 'groups', alias: 'group', desc: 'Manage groups' },
16
55
  { name: 'groupmembers', alias: 'gm', desc: 'Manage group memberships' },
17
56
  { name: 'grouproles', alias: 'gr', desc: 'Manage group roles' },
18
- ],
19
- 'DEVELOPMENT': [
20
- { name: 'dev', desc: 'Manage development artifacts (flows, includes, rules, ACLs, etc.)' },
21
- ],
22
- 'CONFIGURATION': [
23
- { name: 'setup', desc: 'Interactive first-time setup' },
24
- { name: 'auth', desc: 'Manage OAuth authentication' },
25
- { name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
57
+ { name: 'relationships', alias: 'rel', desc: 'Manage table relationships' },
58
+ { name: 'appmodules', alias: 'am', desc: 'Manage application modules' },
26
59
  ],
27
60
  };
28
61
 
62
+ const CONFIGURATION_COMMANDS = [
63
+ { name: 'setup', desc: 'Interactive first-time setup' },
64
+ { name: 'auth', desc: 'Manage OAuth authentication' },
65
+ { name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
66
+ { name: 'dev', desc: '[DEPRECATED] All dev subcommands are now top-level — use jsn flows, jsn acls, etc.' },
67
+ ];
68
+
29
69
  const UTILITY_COMMANDS = [
30
70
  { name: 'skill', desc: 'Manage the jsn AI agent skill file (show, fetch, install)' },
31
71
  { name: 'version', desc: 'Show version information (--check for npm updates)' },
@@ -67,6 +107,7 @@ LEARN MORE
67
107
 
68
108
  export function renderHelp() {
69
109
  const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
110
+ const configSection = renderGroup('CONFIGURATION', CONFIGURATION_COMMANDS);
70
111
  const utilitySection = renderGroup('UTILITY', UTILITY_COMMANDS);
71
112
 
72
113
  return [
@@ -76,6 +117,8 @@ export function renderHelp() {
76
117
  '',
77
118
  ...sections,
78
119
  '',
120
+ configSection,
121
+ '',
79
122
  utilitySection,
80
123
  '',
81
124
  renderFlags(),
package/src/helpers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Shared helper utilities
2
2
 
3
3
  import fs from 'node:fs';
4
- import search from '@inquirer/search';
4
+ import { paginatedSearch } from './paginated-search.js';
5
5
  import { isTTY, FormatAuto } from './output.js';
6
6
 
7
7
  export function getStringField(record, field) {
@@ -89,71 +89,129 @@ export function resolveFieldsParam(columns) {
89
89
  * @param {string} opts.labelField — field used to match selection (default: 'name')
90
90
  * @returns {Promise<void>|null} null if no selection made or non-interactive
91
91
  */
92
- export async function interactiveList({ app, table, singular, columns, limit = 20, query = '', formatLabel, labelField = 'name' }) {
92
+ export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name' }) {
93
+ app.requireInstance();
93
94
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
94
95
  if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin) || query) {
95
96
  return null; // not interactive — caller should fall back to text/table
96
97
  }
97
98
 
98
99
  const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
99
- const params = new URLSearchParams();
100
- params.set('sysparm_limit', String(limit));
101
- params.set('sysparm_display_value', 'all');
102
100
  const pickerFields = pickerColumns.join(',');
103
- if (pickerFields) params.set('sysparm_fields', pickerFields);
104
- params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
105
-
106
- const records = await app.sdk.list(table, params);
107
- if (records.length === 0) return null;
108
-
109
- const choices = records.map(r => ({
110
- name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
111
- value: r,
112
- }));
113
101
 
102
+ // Get total count
103
+ let totalCount = 0;
114
104
  try {
115
- const selected = await search({
116
- message: `Select ${vowelArticle(singular)} ${singular}:`,
117
- source: async (input) => {
118
- if (!input) return choices;
119
- const term = input.toLowerCase();
120
- return choices.filter(c => c.name.toLowerCase().includes(term));
121
- },
122
- });
123
- return selected; // the record object
124
- } catch (err) {
125
- if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
126
- return null;
105
+ totalCount = await app.sdk.aggregateCount(table, '');
106
+ } catch {
107
+ totalCount = 0;
108
+ }
109
+ if (totalCount === 0) return null;
110
+
111
+ // Server source adapter for paginatedSearch: (term, offset) => choices[]
112
+ async function serverSource(term, offset) {
113
+ const params = new URLSearchParams();
114
+ params.set('sysparm_limit', String(limit));
115
+ params.set('sysparm_display_value', 'all');
116
+ if (pickerFields) params.set('sysparm_fields', pickerFields);
117
+ if (term) {
118
+ params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
119
+ } else {
120
+ params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
121
+ params.set('sysparm_offset', String(offset));
127
122
  }
128
- throw err;
123
+ const records = await app.sdk.list(table, params);
124
+ return records.map(r => ({
125
+ name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
126
+ value: r,
127
+ }));
129
128
  }
129
+
130
+ const selected = await paginatedSearch({
131
+ message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
132
+ pageSize: 10,
133
+ totalCount,
134
+ source: serverSource,
135
+ });
136
+ // null = cancelled → undefined so callers skip fallback table
137
+ if (selected === null) return undefined;
138
+ return selected?.value; // unwrap {name, value} → raw record
130
139
  }
131
140
 
132
- function vowelArticle(word) {
133
- const first = word.charAt(0).toLowerCase();
134
- return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
141
+ /**
142
+ * Maps table name → array of field names that are computed by the platform.
143
+ * When a create/update payload contains these, a warning is emitted.
144
+ */
145
+ export const DERIVED_FIELDS = {
146
+ sys_ws_operation: ['operation_uri'],
147
+ sys_ws_definition: ['base_uri'],
148
+ incident: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
149
+ change_request: ['sys_created_on', 'sys_updated_on', 'sys_created_by', 'sys_updated_by', 'sys_mod_count'],
150
+ // Generic — all sys_ fields are system-managed
151
+ };
152
+
153
+ /**
154
+ * Check a data payload for fields that appear to be derived/read-only.
155
+ * Returns an array of warning objects ({field, hint}) for any matches.
156
+ * @param {string} table - Table name (e.g. 'sys_ws_operation')
157
+ * @param {object} data - The JSON payload being sent
158
+ * @returns {Array<{field: string, hint: string}>}
159
+ */
160
+ export function checkDerivedFields(table, data) {
161
+ if (!data || typeof data !== 'object') return [];
162
+ const warnings = [];
163
+
164
+ // Check explicitly known derived fields for this table
165
+ const knownFields = DERIVED_FIELDS[table] || [];
166
+ for (const field of knownFields) {
167
+ if (field in data && data[field] != null) {
168
+ warnings.push({
169
+ field,
170
+ hint: `${field} is a derived/read-only field. Setting it directly will be ignored.`,
171
+ });
172
+ }
173
+ }
174
+
175
+ // Warn about any sys_* fields that look like system-managed metadata
176
+ // (but be careful — not all sys_ fields are read-only)
177
+ if (data.sys_created_on) {
178
+ warnings.push({
179
+ field: 'sys_created_on',
180
+ hint: 'sys_created_on is a system-managed field. Setting it directly will be ignored.',
181
+ });
182
+ }
183
+ if (data.sys_updated_on) {
184
+ warnings.push({
185
+ field: 'sys_updated_on',
186
+ hint: 'sys_updated_on is a system-managed field. Setting it directly will be ignored.',
187
+ });
188
+ }
189
+
190
+ return warnings;
135
191
  }
136
192
 
137
193
  /**
138
- * Parse --data or --data-file into a JSON object.
139
- * If --data-file is given, reads the file. Otherwise parses --data directly.
140
- * Throws if neither is provided or JSON is invalid.
194
+ * Parse --data, --data-file, or --data-stdin into a JSON object.
195
+ * Priority: --data-file > --data-stdin > --data
196
+ * Throws if none is provided or JSON is invalid.
141
197
  */
142
198
  export function parseDataArg(argv) {
143
199
  let raw;
144
200
  if (argv['data-file']) {
145
201
  raw = fs.readFileSync(argv['data-file'], 'utf-8');
146
- // Strip UTF-8 BOM (\ufeff) which some editors (Windows/PowerShell) add
202
+ // Strip UTF-8 BOM (\\ufeff) which some editors (Windows/PowerShell) add
147
203
  if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
204
+ } else if (argv['data-stdin']) {
205
+ raw = fs.readFileSync(process.stdin.fd, 'utf-8');
148
206
  } else if (argv.data) {
149
207
  raw = argv.data;
150
208
  } else {
151
- throw new Error('--data or --data-file is required');
209
+ throw new Error('--data, --data-file, or --data-stdin is required');
152
210
  }
153
211
  try {
154
212
  return JSON.parse(raw);
155
213
  } catch (e) {
156
- throw new Error(`Invalid JSON: ${e.message}\n\nHint: On Windows PowerShell, use --data-file instead of --data to avoid quote mangling.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
214
+ 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 });
157
215
  }
158
216
  }
159
217
 
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Paginated interactive search prompt with server-side pagination.
3
+ *
4
+ * Uses raw readline for terminal control — @inquirer/search doesn't support
5
+ * scroll-based pagination (source only fires on keystrokes, not on scroll).
6
+ *
7
+ * Returns the selected choice ({name, value}), or null on cancel.
8
+ */
9
+
10
+ import readline from 'node:readline';
11
+
12
+ export async function paginatedSearch({ message, pageSize, totalCount, pageSize: pgSize, source }) {
13
+ const PAGE = pageSize || pgSize || 10;
14
+ let choices = [];
15
+ let active = 0;
16
+ let loaded = 0;
17
+ let cancelled = false;
18
+ let term = '';
19
+ let outputLines = 0; // number of lines currently on screen
20
+
21
+ // Write + track: returns number of newlines written (cursor advance)
22
+ function writeLines(lines) {
23
+ const out = lines.join('\n');
24
+ process.stdout.write(out);
25
+ return lines.length - 1; // N elements = N-1 newlines = cursor advances this many
26
+ }
27
+
28
+ function render() {
29
+ if (outputLines > 0) {
30
+ process.stdout.moveCursor(0, -outputLines);
31
+ process.stdout.clearScreenDown();
32
+ } else {
33
+ // First render: ensure we start on a fresh line
34
+ process.stdout.cursorTo(0);
35
+ process.stdout.clearLine(0);
36
+ }
37
+
38
+ const buf = [];
39
+
40
+ // Header: show message + count
41
+ const countInfo = totalCount > 0 ? ` (${loaded} of ${totalCount} loaded)` : '';
42
+ buf.push(`${message}${countInfo}`);
43
+
44
+ // Visible window: center active item
45
+ const start = Math.max(0, active - Math.floor(PAGE / 2));
46
+ const end = Math.min(choices.length, start + PAGE);
47
+ const visible = choices.slice(start, end);
48
+
49
+ for (let i = 0; i < visible.length; i++) {
50
+ const choice = visible[i];
51
+ const isActive = (start + i) === active;
52
+ const prefix = isActive ? '\x1b[7m' : '';
53
+ const suffix = isActive ? '\x1b[0m' : '';
54
+ const display = choice.name.length > 70 ? choice.name.slice(0, 67) + '...' : choice.name;
55
+ buf.push(` ${prefix}${display}${suffix}`);
56
+ }
57
+
58
+ // Pad to constant height
59
+ while (buf.length < PAGE + 2) {
60
+ buf.push('');
61
+ }
62
+
63
+ // Footer
64
+ const more = loaded < totalCount;
65
+ const footer = more
66
+ ? `↑↓ navigate • ⏎ select • esc cancel • type to filter • ↓ at end loads more`
67
+ : `↑↓ navigate • ⏎ select • esc cancel • type to filter`;
68
+ buf.push('');
69
+ buf.push(footer);
70
+
71
+ // Search bar
72
+ buf.push(`> ${term}`);
73
+
74
+ outputLines = writeLines(buf);
75
+ }
76
+
77
+ function cleanup() {
78
+ if (outputLines > 0) {
79
+ process.stdout.moveCursor(0, -outputLines);
80
+ process.stdout.clearScreenDown();
81
+ outputLines = 0;
82
+ }
83
+ // Ensure raw mode is off
84
+ try { process.stdin.setRawMode(false); } catch { /* ok */ }
85
+ }
86
+
87
+ return new Promise((resolve) => {
88
+ const rl = readline.createInterface({
89
+ input: process.stdin,
90
+ output: process.stdout,
91
+ terminal: true,
92
+ });
93
+ try { process.stdin.setRawMode(true); } catch { /* non-TTY */ }
94
+
95
+ let inputBuf = '';
96
+
97
+ async function loadPage() {
98
+ if (loaded >= totalCount) return;
99
+ const newItems = await source('', loaded);
100
+ if (newItems.length > 0) {
101
+ choices = choices.concat(newItems);
102
+ loaded = Math.min(choices.length, totalCount);
103
+ }
104
+ }
105
+
106
+ // Initial load
107
+ (async () => {
108
+ const initial = await source('', 0);
109
+ if (cancelled) return;
110
+ choices = initial;
111
+ loaded = Math.min(choices.length, totalCount || Infinity);
112
+ active = 0;
113
+ render();
114
+ })();
115
+
116
+ rl.input.on('keypress', async (_str, key) => {
117
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
118
+ cancelled = true;
119
+ rl.close();
120
+ return;
121
+ }
122
+
123
+ if (key.name === 'return' || key.name === 'enter') {
124
+ if (choices.length > 0 && active >= 0 && active < choices.length) {
125
+ rl.close();
126
+ return;
127
+ }
128
+ }
129
+
130
+ if (key.name === 'up' && active > 0) {
131
+ active--;
132
+ render();
133
+ return;
134
+ }
135
+
136
+ if (key.name === 'down') {
137
+ if (active < choices.length - 1) {
138
+ active++;
139
+ render();
140
+ } else if (loaded < totalCount) {
141
+ await loadPage();
142
+ if (active < choices.length - 1) active++;
143
+ render();
144
+ }
145
+ return;
146
+ }
147
+
148
+ if (key.name === 'backspace') {
149
+ if (inputBuf.length > 0) inputBuf = inputBuf.slice(0, -1);
150
+ } else if (key.sequence && key.sequence.length === 1 && !key.meta && !key.ctrl) {
151
+ inputBuf += key.sequence;
152
+ } else {
153
+ return;
154
+ }
155
+
156
+ // Search
157
+ term = inputBuf;
158
+ if (term) {
159
+ const results = await source(term, 0);
160
+ choices = results;
161
+ loaded = results.length;
162
+ } else {
163
+ // Reset to browse mode — reload first page
164
+ const results = await source('', 0);
165
+ choices = results;
166
+ loaded = Math.min(results.length, totalCount || Infinity);
167
+ }
168
+ active = 0;
169
+ render();
170
+ });
171
+
172
+ rl.on('close', () => {
173
+ cleanup();
174
+ const choice = (!cancelled && active >= 0 && active < choices.length) ? choices[active] : null;
175
+ resolve(cancelled ? null : choice);
176
+ });
177
+ });
178
+ }