@jacebenson/jsn 3.0.2 → 3.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.0.2",
3
+ "version": "3.0.5",
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": {
@@ -43,4 +43,4 @@
43
43
  "README.md",
44
44
  "LICENSE"
45
45
  ]
46
- }
46
+ }
@@ -10,12 +10,12 @@ description: |
10
10
  license: MIT
11
11
  compatibility: |
12
12
  Requires jsn CLI (github.com/jacebenson/jsn). Install via:
13
- curl -fsSL https://jsn.jace.pro/install | bash
13
+ npm install -g @jacebenson/jsn
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
 
16
- Windows users: Download from GitHub releases. If jsn is not in your PATH, the setup
17
- command will automatically detect its location and show the correct command path
18
- (e.g., `.\jsn.exe tables list` instead of `jsn tables list`).
16
+ Windows users: Install Node.js first, then the npm command above. If jsn is not
17
+ in your PATH, the setup command will automatically detect its location and show
18
+ the correct command path.
19
19
  metadata:
20
20
  author: jacebenson
21
21
  version: "1.0.0"
@@ -54,6 +54,28 @@ async function checkScope(sdk, recordScope) {
54
54
  return { currentScope, recordScope };
55
55
  }
56
56
 
57
+ /**
58
+ * Resolve the API scope name from a record's sys_scope reference field.
59
+ * With sysparm_display_value=all, sys_scope is { display_value: "Name", value: "<sys_id>" }.
60
+ * getStringField returns the display_value (human-readable), but we need the API name
61
+ * (e.g. "x_example_app") to compare against getCurrentScope().
62
+ */
63
+ async function resolveRecordScope(sdk, record) {
64
+ const sysScope = record['sys_scope'];
65
+ let scopeId;
66
+ if (sysScope && typeof sysScope === 'object' && sysScope.value) {
67
+ scopeId = sysScope.value;
68
+ } else {
69
+ return getStringField(record, 'sys_scope');
70
+ }
71
+ try {
72
+ const scopeRecord = await sdk.get('sys_scope', scopeId);
73
+ return scopeRecord?.scope || 'global';
74
+ } catch {
75
+ return getStringField(record, 'sys_scope');
76
+ }
77
+ }
78
+
57
79
  export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {}) {
58
80
  const showFields = opts.showFields !== undefined ? opts.showFields : null;
59
81
  const singular = toSingular(name, opts.singular);
@@ -257,7 +279,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
257
279
  throw new Error(`${singular} not found: ${id}`);
258
280
  }
259
281
  const sysID = getStringField(records[0], 'sys_id');
260
- const recordScope = getStringField(records[0], 'sys_scope');
282
+ const recordScope = await resolveRecordScope(app.sdk, records[0]);
261
283
 
262
284
  if (scopeValidation) {
263
285
  const scopeErr = await checkScope(app.sdk, recordScope);
@@ -288,7 +310,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
288
310
  }
289
311
  const sysID = getStringField(records[0], 'sys_id');
290
312
  const recordName = getStringField(records[0], 'name') || id;
291
- const recordScope = getStringField(records[0], 'sys_scope');
313
+ const recordScope = await resolveRecordScope(app.sdk, records[0]);
292
314
 
293
315
  if (scopeValidation) {
294
316
  const scopeErr = await checkScope(app.sdk, recordScope);
@@ -79,11 +79,23 @@ export const columnsCmd = (wrap) => buildDevCmd('columns', 'sys_dictionary', ['c
79
79
  export const importCmd = (wrap) => buildDevCmd('import', 'sys_import_set', ['imports', 'imp'], ['sys_import_set', 'sys_import_row', 'sys_target_table', 'sys_target_sys_id'], wrap, { singular: 'import set', readOnly: true });
80
80
  export const spPagesCmd = (wrap) => buildDevCmd('sppages', 'sp_page', ['sp-pages', 'pages'], ['id', 'title', 'sys_scope'], wrap, { singular: 'Service Portal page', readOnly: true });
81
81
  export const spWidgetsCmd = (wrap) => buildDevCmd('spwidgets', 'sp_widget', ['sp-widget', 'widgets'], ['id', 'name', 'sys_scope'], wrap, { singular: 'Service Portal widget', readOnly: true });
82
- export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', readOnly: true });
83
- export const appMenuCmd = (wrap) => buildDevCmd('appmenu', 'sys_app_application', ['app-menu', 'menu'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application menu', readOnly: true });
82
+ export const uiPagesCmd = (wrap) => buildDevCmd('uipages', 'sys_ui_page', ['ui-page', 'pages'], ['name', 'sys_scope'], wrap, { singular: 'UI page', scopeValidation: true });
83
+ export const appMenuCmd = (wrap) => buildDevCmd('appmenu', 'sys_app_application', ['app-menu', 'menu'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application menu', scopeValidation: true });
84
84
  export const scRAPICmd = (wrap) => buildDevCmd('scrapi', 'sys_ws_operation', ['scripted-rest', 'rest-api'], ['name', 'sys_ws_definition', 'sys_scope'], wrap, { singular: 'scripted REST API', readOnly: true });
85
85
 
86
86
  // Commands with full CRUD
87
- export const aclsCmd = (wrap) => buildDevCmd('acls', 'sys_security_acl', ['acl'], ['name', 'operation', 'type', 'active', 'sys_scope'], wrap, { singular: 'ACL', readOnly: true });
87
+ export const aclsCmd = (wrap) => buildDevCmd('acls', 'sys_security_acl', ['acl'], ['name', 'operation', 'type', 'active', 'sys_scope'], wrap, { singular: 'ACL', scopeValidation: true });
88
88
  export const rolesCmd = (wrap) => buildDevCmd('roles', 'sys_user_role', ['role', 'r'], ['name', 'description', 'elevated_privilege', 'sys_scope'], wrap, { singular: 'role', scopeValidation: true });
89
- export const propertiesCmd = (wrap) => buildDevCmd('properties', 'sys_properties', ['property', 'prop'], ['name', 'value', 'description', 'sys_scope'], wrap, { singular: 'property', readOnly: true });
89
+ export const propertiesCmd = (wrap) => buildDevCmd('properties', 'sys_properties', ['property', 'prop'], ['name', 'value', 'description', 'sys_scope'], wrap, { singular: 'property', scopeValidation: true });
90
+
91
+ // New commands for scoped-app tables
92
+ export const relationshipsCmd = (wrap) => buildDevCmd('relationships', 'sys_relationship', ['relationship', 'rel'], ['name', 'sys_scope'], wrap, { singular: 'relationship', scopeValidation: true });
93
+ export const appmodulesCmd = (wrap) => buildDevCmd('appmodules', 'sys_app_module', ['appmodule', 'am'], ['name', 'active', 'sys_scope'], wrap, { singular: 'application module', scopeValidation: true });
94
+ export const listcontrolsCmd = (wrap) => buildDevCmd('listcontrols', 'sys_ui_list_control', ['listcontrol', 'lc'], ['name', 'active', 'sys_scope'], wrap, { singular: 'list control', scopeValidation: true });
95
+ export const viewsCmd = (wrap) => buildDevCmd('views', 'sys_ui_view', ['view', 'vw'], ['name', 'title', 'sys_scope'], wrap, { singular: 'view', scopeValidation: true });
96
+ export const privilegesCmd = (wrap) => buildDevCmd('privileges', 'sys_scope_privilege', ['privilege', 'priv'], ['name', 'status', 'sys_scope'], wrap, { singular: 'privilege', scopeValidation: true });
97
+ export const securitytypesCmd = (wrap) => buildDevCmd('securitytypes', 'sys_security_type', ['securitytype', 'st'], ['name', 'active', 'sys_scope'], wrap, { singular: 'security type', scopeValidation: true });
98
+ export const uxscriptsCmd = (wrap) => buildDevCmd('uxscripts', 'sys_ux_lib_source_script', ['uxscript', 'ux'], ['name', 'active', 'sys_scope'], wrap, { singular: 'UX script', scopeValidation: true });
99
+ export const aliasesCmd = (wrap) => buildDevCmd('aliases', 'sys_alias', ['alias', 'als'], ['name', 'table', 'sys_scope'], wrap, { singular: 'alias', scopeValidation: true });
100
+ export const catalogscriptsCmd = (wrap) => buildDevCmd('catalogscripts', 'catalog_script_client', ['catalogscript', 'cs'], ['name', 'table', 'active', 'sys_scope'], wrap, { singular: 'catalog script', scopeValidation: true });
101
+ export const cataloguipoliciesCmd = (wrap) => buildDevCmd('cataloguipolicies', 'catalog_ui_policy', ['cataloguipolicy', 'cup'], ['short_description', 'table', 'active', 'sys_scope'], wrap, { singular: 'catalog UI policy', scopeValidation: true });
@@ -7,10 +7,19 @@ export function evalCmd(wrap) {
7
7
  builder: (yargs) => {
8
8
  return yargs
9
9
  .option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
10
- .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' });
10
+ .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
11
+ .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: global)' });
11
12
  },
12
13
  handler: wrap(async (argv, app) => {
13
14
  let script;
15
+ let scopeSysId = null;
16
+
17
+ if (argv.scope) {
18
+ scopeSysId = await app.sdk.resolveScope(argv.scope);
19
+ if (!scopeSysId) {
20
+ throw new Error(`Scope not found: ${argv.scope}`);
21
+ }
22
+ }
14
23
 
15
24
  if (argv.file) {
16
25
  script = fs.readFileSync(argv.file, 'utf-8');
@@ -20,13 +29,20 @@ export function evalCmd(wrap) {
20
29
  throw new Error('--script or --file is required');
21
30
  }
22
31
 
23
- const output = await app.sdk.executeScript(script);
32
+ if (scopeSysId) {
33
+ script = `var $scopeSysId = '${scopeSysId}';\n${script}`;
34
+ }
35
+
36
+ const warning = scopeSysId
37
+ ? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
38
+ : '';
39
+ const output = await app.sdk.executeScript(script, argv.scope);
24
40
  app.ok({
25
41
  script,
26
42
  output,
27
43
  instance: app.getEffectiveInstance(),
28
44
  }, {
29
- summary: 'Script executed',
45
+ summary: 'Script executed' + warning,
30
46
  breadcrumbs: [{
31
47
  action: 'eval',
32
48
  cmd: 'jsn dev eval --script \'...\'',
@@ -25,7 +25,7 @@ export function flowsCmd(wrap) {
25
25
  formatLabel: r => `${getStringField(r, 'name')} ${getStringField(r, 'active') === 'true' ? '' : '[inactive]'}`,
26
26
  });
27
27
  if (picked) {
28
- const inspection = await app.sdk.inspectFlow(picked.sys_id);
28
+ const inspection = await app.sdk.inspectFlow(getStringField(picked, 'sys_id'));
29
29
  const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
30
30
  return app.ok({ ...inspection, _formatted: formatted }, {
31
31
  summary: `Flow: ${inspection.flow.name}`,
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
3
 
3
4
  export function updateSetsCmd(wrap) {
@@ -177,6 +178,40 @@ export function updateSetsCmd(wrap) {
177
178
  app.ok({ profile: name, yolo: true }, { summary: 'Default update set warning silenced for this profile' });
178
179
  }),
179
180
  })
181
+ .command({
182
+ command: 'export <name>',
183
+ describe: 'Export an update set to XML',
184
+ builder: (y) => y
185
+ .positional('name', {
186
+ describe: 'Update set name',
187
+ type: 'string',
188
+ })
189
+ .option('output', {
190
+ alias: 'o',
191
+ type: 'string',
192
+ describe: 'Output file path (default: stdout)',
193
+ }),
194
+ handler: wrap(async (argv, app) => {
195
+ const params = new URLSearchParams();
196
+ params.set('sysparm_query', `name=${argv.name}`);
197
+ params.set('sysparm_limit', '1');
198
+ params.set('sysparm_fields', 'sys_id,name');
199
+ const records = await app.sdk.list('sys_update_set', params);
200
+ if (records.length === 0) {
201
+ throw new Error(`Update set not found: ${argv.name}`);
202
+ }
203
+ const sysID = getStringField(records[0], 'sys_id');
204
+ const instance = app.getEffectiveInstance();
205
+ const url = `${instance}/sys_update_set.do?XML&sysparm_sys_id=${sysID}`;
206
+ const xml = await app.sdk.rawRequest(url, { method: 'GET' });
207
+ if (argv.output) {
208
+ fs.writeFileSync(argv.output, xml, 'utf-8');
209
+ app.ok({ name: argv.name, sys_id: sysID, output: argv.output }, { summary: `Exported update set to ${argv.output}` });
210
+ } else {
211
+ process.stdout.write(xml + '\n');
212
+ }
213
+ }),
214
+ })
180
215
 
181
216
  },
182
217
  handler: (argv) => {
@@ -187,6 +222,7 @@ export function updateSetsCmd(wrap) {
187
222
  console.log(' show <name> Show an update set');
188
223
  console.log(' set <name> Set the current update set');
189
224
  console.log(' create Create a new update set (auto-sets as current)');
225
+ console.log(' export <name> Export an update set to XML');
190
226
  console.log(' complete <name> Mark an update set as complete (coming soon)');
191
227
  console.log(' yolo Silence the "Default update set" warning');
192
228
  console.log('\nRun "jsn dev updatesets <command> --help" for details.');
@@ -6,6 +6,9 @@ import {
6
6
  tablesCmd, columnsCmd, importCmd,
7
7
  spPagesCmd, spWidgetsCmd, uiPagesCmd, appMenuCmd, scRAPICmd,
8
8
  aclsCmd, rolesCmd, propertiesCmd,
9
+ relationshipsCmd, appmodulesCmd, listcontrolsCmd, viewsCmd,
10
+ privilegesCmd, securitytypesCmd, uxscriptsCmd, aliasesCmd,
11
+ catalogscriptsCmd, cataloguipoliciesCmd,
9
12
  } from './dev/_simple.js';
10
13
  import { flowsCmd } from './dev/flows.js';
11
14
  import { formsCmd } from './dev/forms.js';
@@ -44,6 +47,16 @@ export function devCmd(wrap) {
44
47
  .command(updateSetsCmd(wrap))
45
48
  .command(scopesCmd(wrap))
46
49
  .command(propertiesCmd(wrap))
50
+ .command(relationshipsCmd(wrap))
51
+ .command(appmodulesCmd(wrap))
52
+ .command(listcontrolsCmd(wrap))
53
+ .command(viewsCmd(wrap))
54
+ .command(privilegesCmd(wrap))
55
+ .command(securitytypesCmd(wrap))
56
+ .command(uxscriptsCmd(wrap))
57
+ .command(aliasesCmd(wrap))
58
+ .command(catalogscriptsCmd(wrap))
59
+ .command(cataloguipoliciesCmd(wrap))
47
60
  .command(logsCmd(wrap))
48
61
  .command(evalCmd(wrap))
49
62
  .command(restCmd(wrap));
@@ -165,6 +165,34 @@ export function recordsCmd(wrap) {
165
165
  app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
166
166
  }),
167
167
  })
168
+ .command({
169
+ command: 'inspect <table> <identifier>',
170
+ aliases: ['debug', 'diag'],
171
+ describe: 'Inspect a record: show audit history, business rules, and running flows',
172
+ builder: (y) => y
173
+ .positional('table', { describe: 'Table name (e.g. incident)', type: 'string' })
174
+ .positional('identifier', { describe: 'Record sys_id or number', type: 'string' }),
175
+ handler: wrap(async (argv, app) => {
176
+ const { inspectRecord, formatInspectOutput } = await import('../records/inspect.js');
177
+ const result = await inspectRecord(app, argv.table, argv.identifier);
178
+ const breadcrumbs = [
179
+ ...result.flows.map(f => ({
180
+ action: 'show',
181
+ cmd: `jsn dev flows show "${f.flow}"`,
182
+ description: `View flow details for ${f.flow}`,
183
+ })),
184
+ ...result.businessRules.slice(0, 5).map(br => ({
185
+ action: 'show',
186
+ cmd: `jsn dev rules show "${br.name}"`,
187
+ description: `View business rule: ${br.name}`,
188
+ })),
189
+ ];
190
+ app.ok({ ...result, _formatted: formatInspectOutput(result) }, {
191
+ summary: `Inspected ${argv.table} ${argv.identifier}`,
192
+ breadcrumbs,
193
+ });
194
+ }),
195
+ })
168
196
 
169
197
  },
170
198
  handler: () => {},
@@ -8,6 +8,8 @@ const SKILL_REPO_PATH = path.join(
8
8
  '..', '..', 'skills', 'servicenow', 'SKILL.md'
9
9
  );
10
10
 
11
+ const HERMES_SKILL_PATH = path.join(os.homedir(), '.hermes', 'skills', 'servicenow', 'SKILL.md');
12
+
11
13
  const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
12
14
 
13
15
  function readBundledSkill() {
@@ -18,9 +20,23 @@ function readBundledSkill() {
18
20
  }
19
21
  }
20
22
 
23
+ function readInstalledSkill() {
24
+ try {
25
+ return fs.readFileSync(HERMES_SKILL_PATH, 'utf-8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
21
31
  export async function checkSkill() {
22
- const bundled = readBundledSkill();
23
- if (!bundled) return { current: false, error: 'Skill file not found in package' };
32
+ // Compare the Hermes-installed copy's version against GitHub (not the npm-bundled copy).
33
+ // Uses the YAML frontmatter "version" field so publishing jsn doesn't flag it.
34
+ const installed = readInstalledSkill() || readBundledSkill();
35
+ if (!installed) return { current: false, error: 'Skill file not found' };
36
+
37
+ const installedVersion = extractVersion(installed);
38
+ if (!installedVersion) return { current: false, error: 'No version field in installed skill' };
39
+
24
40
  try {
25
41
  const controller = new AbortController();
26
42
  const timer = setTimeout(() => controller.abort(), 10000);
@@ -28,18 +44,27 @@ export async function checkSkill() {
28
44
  clearTimeout(timer);
29
45
  if (!res.ok) return { current: false, error: `GitHub returned ${res.status}` };
30
46
  const upstream = await res.text();
31
- const current = bundled === upstream;
47
+ const upstreamVersion = extractVersion(upstream);
48
+ if (!upstreamVersion) return { current: false, error: 'No version field in upstream skill' };
49
+
50
+ const current = installedVersion === upstreamVersion;
32
51
  return {
33
52
  current,
34
- bundled_lines: bundled.split('\n').length,
35
- upstream_lines: upstream.split('\n').length,
36
- error: current ? null : 'Bundled skill is outdated — run "jsn skill install" to update',
53
+ installed_version: installedVersion,
54
+ upstream_version: upstreamVersion,
55
+ error: current ? null : `Skill version ${installedVersion} vs GitHub ${upstreamVersion} — run "jsn skill install" to update`,
37
56
  };
38
57
  } catch {
39
58
  return { current: false, error: 'Could not check — GitHub unreachable' };
40
59
  }
41
60
  }
42
61
 
62
+ function extractVersion(content) {
63
+ // Matches version in YAML frontmatter (top-level or nested under metadata:)
64
+ const m = content.match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
65
+ return m ? m[1] : null;
66
+ }
67
+
43
68
  export function skillCmd(wrap) {
44
69
  return {
45
70
  command: 'skill',
@@ -70,9 +95,9 @@ export function skillCmd(wrap) {
70
95
  if (result.error && !result.current) {
71
96
  app.ok(result, { summary: result.error });
72
97
  } else if (result.current) {
73
- app.ok(result, { summary: `✓ Skill is current (${result.bundled_lines} lines)` });
98
+ app.ok(result, { summary: `✓ Skill is current (v${result.installed_version})` });
74
99
  } else {
75
- app.ok(result, { summary: `⚠ Skill is outdated (bundled ${result.bundled_lines} lines vs upstream ${result.upstream_lines} lines) — run "jsn skill install" to update` });
100
+ app.ok(result, { summary: `⚠ Skill outdated: installed v${result.installed_version} vs GitHub v${result.upstream_version} — run "jsn skill install" to update` });
76
101
  }
77
102
  }),
78
103
  })
package/src/helpers.js CHANGED
@@ -143,6 +143,8 @@ export function parseDataArg(argv) {
143
143
  let raw;
144
144
  if (argv['data-file']) {
145
145
  raw = fs.readFileSync(argv['data-file'], 'utf-8');
146
+ // Strip UTF-8 BOM (\ufeff) which some editors (Windows/PowerShell) add
147
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
146
148
  } else if (argv.data) {
147
149
  raw = argv.data;
148
150
  } else {
@@ -0,0 +1,266 @@
1
+ import { isHexString, getStringField } from '../helpers.js';
2
+
3
+ export async function resolveIdentifier(app, table, identifier) {
4
+ // If it looks like a sys_id (32 hex chars), use it directly
5
+ if (isHexString(identifier) && identifier.length === 32) {
6
+ return identifier;
7
+ }
8
+
9
+ // Otherwise, assume it's a record number — query the table
10
+ const params = new URLSearchParams();
11
+ params.set('sysparm_query', `number=${identifier}`);
12
+ params.set('sysparm_limit', '1');
13
+ params.set('sysparm_fields', 'sys_id');
14
+ const records = await app.sdk.list(table, params);
15
+ if (records.length === 0) {
16
+ throw new Error(`Record not found in ${table}: ${identifier}`);
17
+ }
18
+ return getStringField(records[0], 'sys_id');
19
+ }
20
+
21
+ async function fetchHistory(app, table, sysId, limit = 20) {
22
+ const params = new URLSearchParams();
23
+ params.set('sysparm_query', `documentkey=${sysId}^tablename=${table}`);
24
+ params.set('sysparm_limit', String(limit));
25
+ params.set('sysparm_fields', 'fieldname,newvalue,oldvalue,sys_created_on,sys_created_by');
26
+ params.set('sysparm_display_value', 'all');
27
+ params.set('sysparm_order_by', 'sys_created_on');
28
+ const records = await app.sdk.list('sys_audit', params);
29
+ return records.map(r => ({
30
+ field: r.fieldname?.display_value || r.fieldname,
31
+ oldValue: (r.oldvalue?.display_value ?? r.oldvalue?.value ?? '') || '',
32
+ newValue: (r.newvalue?.display_value ?? r.newvalue?.value ?? '') || '',
33
+ _oldRaw: r.oldvalue?.value ?? r.oldvalue ?? '',
34
+ _newRaw: r.newvalue?.value ?? r.newvalue ?? '',
35
+ changedOn: r.sys_created_on?.display_value || r.sys_created_on,
36
+ changedBy: r.sys_created_by?.display_value || r.sys_created_by,
37
+ }));
38
+ }
39
+
40
+ // Known choice fields and their display labels from sys_choice
41
+ const CHOICE_FIELDS = new Set(['state', 'incident_state', 'priority', 'impact', 'urgency', 'active', 'category', 'subcategory']);
42
+
43
+ // Known sys_user reference fields
44
+ const USER_REF_FIELDS = new Set(['closed_by', 'assigned_to', 'opened_by', 'resolved_by', 'called_by', 'caller_id']);
45
+
46
+ // Boolean fields stored as 0/1 in sys_audit
47
+ const BOOL_FIELDS = new Set(['active']);
48
+
49
+ async function resolveHistoryDisplayValues(app, table, history) {
50
+ // Collect unique sys_ids for user reference resolution
51
+ const userSysIds = new Set();
52
+ for (const h of history) {
53
+ if (USER_REF_FIELDS.has(h.field)) {
54
+ if (isHexString(h.oldValue) && h.oldValue.length === 32) userSysIds.add(h.oldValue);
55
+ if (isHexString(h.newValue) && h.newValue.length === 32) userSysIds.add(h.newValue);
56
+ }
57
+ }
58
+
59
+ // Batch lookup user names
60
+ const userNameMap = new Map();
61
+ if (userSysIds.size > 0) {
62
+ const idList = [...userSysIds].join(',');
63
+ const params = new URLSearchParams();
64
+ params.set('sysparm_query', `sys_idIN${idList}`);
65
+ params.set('sysparm_limit', '100');
66
+ params.set('sysparm_fields', 'sys_id,name');
67
+ params.set('sysparm_display_value', 'all');
68
+ try {
69
+ const users = await app.sdk.list('sys_user', params);
70
+ for (const u of users) {
71
+ const uid = getStringField(u, 'sys_id');
72
+ const name = u.name?.display_value || u.name;
73
+ if (uid && name) userNameMap.set(uid, name);
74
+ }
75
+ } catch { /* non-fatal */ }
76
+ }
77
+
78
+ // Collect unique (field, value) pairs for choice resolution
79
+ const choiceLookups = new Set();
80
+ for (const h of history) {
81
+ if (CHOICE_FIELDS.has(h.field)) {
82
+ if (h.oldValue && h.oldValue !== '_raw_old_') choiceLookups.add(`${h.field}|${h.oldValue}`);
83
+ if (h.newValue && h.newValue !== '_raw_new_') choiceLookups.add(`${h.field}|${h.newValue}`);
84
+ }
85
+ }
86
+
87
+ // Batch lookup choice labels
88
+ const choiceLabelMap = new Map();
89
+ if (choiceLookups.size > 0) {
90
+ for (const key of choiceLookups) {
91
+ const [field, val] = key.split('|');
92
+ const params = new URLSearchParams();
93
+ params.set('sysparm_query', `name=${table}^element=${field}^value=${val}^language=en`);
94
+ params.set('sysparm_limit', '1');
95
+ params.set('sysparm_fields', 'value,label');
96
+ params.set('sysparm_display_value', 'all');
97
+ try {
98
+ const choices = await app.sdk.list('sys_choice', params);
99
+ if (choices.length > 0) {
100
+ const label = choices[0].label?.display_value || choices[0].label;
101
+ if (label) choiceLabelMap.set(key, label);
102
+ }
103
+ } catch { /* non-fatal */ }
104
+ }
105
+ }
106
+
107
+ // Apply resolutions to history entries
108
+ return history.map(h => {
109
+ let oldDisplay = h.oldValue;
110
+ let newDisplay = h.newValue;
111
+
112
+ // Resolve user references
113
+ if (USER_REF_FIELDS.has(h.field)) {
114
+ if (isHexString(oldDisplay) && oldDisplay.length === 32) {
115
+ oldDisplay = userNameMap.get(oldDisplay) || oldDisplay || '(empty)';
116
+ }
117
+ if (isHexString(newDisplay) && newDisplay.length === 32) {
118
+ newDisplay = userNameMap.get(newDisplay) || newDisplay || '(empty)';
119
+ }
120
+ }
121
+
122
+ // Resolve choice labels
123
+ if (CHOICE_FIELDS.has(h.field)) {
124
+ const oldLabel = choiceLabelMap.get(`${h.field}|${h.oldValue}`);
125
+ const newLabel = choiceLabelMap.get(`${h.field}|${h.newValue}`);
126
+ if (oldLabel) oldDisplay = oldLabel;
127
+ if (newLabel) newDisplay = newLabel;
128
+ }
129
+
130
+ // Resolve boolean fields
131
+ if (BOOL_FIELDS.has(h.field)) {
132
+ if (oldDisplay === '1') oldDisplay = 'Yes';
133
+ else if (oldDisplay === '0') oldDisplay = 'No';
134
+ if (newDisplay === '1') newDisplay = 'Yes';
135
+ else if (newDisplay === '0') newDisplay = 'No';
136
+ }
137
+
138
+ return { ...h, oldValue: oldDisplay || '(empty)', newValue: newDisplay || '(empty)' };
139
+ });
140
+ }
141
+
142
+ async function fetchBusinessRules(app, table) {
143
+ const params = new URLSearchParams();
144
+ params.set('sysparm_query', `collection=${table}^active=true`);
145
+ params.set('sysparm_limit', '50');
146
+ params.set('sysparm_fields', 'name,collection,order,active,sys_scope,when,filter_condition');
147
+ params.set('sysparm_display_value', 'all');
148
+ params.set('sysparm_order_by', 'order');
149
+ const records = await app.sdk.list('sys_script', params);
150
+ return records
151
+ .map(r => ({
152
+ name: r.name?.display_value || r.name,
153
+ order: r.order?.display_value || r.order,
154
+ scope: r.sys_scope?.display_value || r.sys_scope || 'global',
155
+ when: r.when?.display_value || r.when || '',
156
+ condition: r.filter_condition?.display_value || r.filter_condition?.value || '',
157
+ }))
158
+ .sort((a, b) => {
159
+ const aOrder = parseInt(String(a.order).replace(/,/g, ''), 10) || 0;
160
+ const bOrder = parseInt(String(b.order).replace(/,/g, ''), 10) || 0;
161
+ return aOrder - bOrder;
162
+ });
163
+ }
164
+
165
+ async function fetchFlows(app, sysId) {
166
+ const params = new URLSearchParams();
167
+ params.set('sysparm_query', `source_record=${sysId}`);
168
+ params.set('sysparm_limit', '20');
169
+ params.set('sysparm_fields', 'flow_catalog_model,name,execution_id,state,engine_major_version,sys_created_on,origins,calling_source');
170
+ params.set('sysparm_display_value', 'all');
171
+ try {
172
+ const records = await app.sdk.list('sys_flow_context', params);
173
+ return records.map(r => ({
174
+ flow: r.name?.display_value || r.name || r.flow_catalog_model?.display_value || r.flow_catalog_model?.value || '(deleted flow)',
175
+ executionId: r.execution_id?.display_value || r.execution_id,
176
+ state: r.state?.display_value || r.state,
177
+ version: r.engine_major_version?.display_value || r.engine_major_version,
178
+ started: r.sys_created_on?.display_value || r.sys_created_on,
179
+ }));
180
+ } catch {
181
+ return []; // Flow Designer might not be installed
182
+ }
183
+ }
184
+
185
+ export function formatInspectOutput(data) {
186
+ const lines = [];
187
+ lines.push('');
188
+ lines.push(`\u{1F4CB} ${data.table} ${data.sys_id}`);
189
+ lines.push('');
190
+
191
+ // History section
192
+ lines.push('\u25B6 HISTORY');
193
+ lines.push('\u2500'.repeat(50));
194
+ if (data.history.length === 0) {
195
+ lines.push(' (no audit history found)');
196
+ } else {
197
+ for (const h of data.history.slice(0, 10)) {
198
+ lines.push(` ${h.changedOn} ${h.changedBy} ${h.field}: ${h.oldValue || '(empty)'} \u2192 ${h.newValue}`);
199
+ }
200
+ if (data.history.length > 10) {
201
+ lines.push(` ... and ${data.history.length - 10} more`);
202
+ }
203
+ }
204
+ lines.push('');
205
+
206
+ // Business rules section
207
+ lines.push('\u25B6 BUSINESS RULES');
208
+ lines.push('\u2500'.repeat(50));
209
+ if (data.businessRules.length === 0) {
210
+ lines.push(' (no active business rules on this table)');
211
+ } else {
212
+ // Group by execution phase: before → display → after → async
213
+ const phases = ['before', 'display', 'after', 'async'];
214
+ const grouped = {};
215
+ for (const br of data.businessRules) {
216
+ const phase = br.when?.toLowerCase() || '';
217
+ if (!grouped[phase]) grouped[phase] = [];
218
+ grouped[phase].push(br);
219
+ }
220
+ for (const phase of phases) {
221
+ const rules = grouped[phase]?.sort((a, b) => {
222
+ const aOrder = parseInt(String(a.order).replace(/,/g, ''), 10) || 0;
223
+ const bOrder = parseInt(String(b.order).replace(/,/g, ''), 10) || 0;
224
+ return aOrder - bOrder;
225
+ });
226
+ if (!rules || rules.length === 0) continue;
227
+ lines.push(` ${phase.charAt(0).toUpperCase() + phase.slice(1)}:`);
228
+ for (const br of rules) {
229
+ lines.push(` [${br.order}] ${br.name}${br.scope !== 'global' ? ` (${br.scope})` : ''}`);
230
+ if (br.condition) {
231
+ const cond = br.condition.length > 80 ? br.condition.substring(0, 77) + '...' : br.condition;
232
+ lines.push(` Condition: ${cond}`);
233
+ }
234
+ }
235
+ }
236
+ }
237
+ lines.push('');
238
+
239
+ // Flows section
240
+ lines.push('\u25B6 RUNNING FLOWS');
241
+ lines.push('\u2500'.repeat(50));
242
+ if (data.flows.length === 0) {
243
+ lines.push(' (no running flows for this record)');
244
+ } else {
245
+ for (const f of data.flows) {
246
+ lines.push(` Flow: ${f.flow}`);
247
+ lines.push(` Status: ${f.state} | Version: ${f.version}`);
248
+ if (f.started) lines.push(` Started: ${f.started}`);
249
+ lines.push('');
250
+ }
251
+ }
252
+ lines.push('');
253
+
254
+ return lines.join('\n');
255
+ }
256
+
257
+ export async function inspectRecord(app, table, identifier) {
258
+ const sysId = await resolveIdentifier(app, table, identifier);
259
+ const [rawHistory, businessRules, flows] = await Promise.all([
260
+ fetchHistory(app, table, sysId),
261
+ fetchBusinessRules(app, table),
262
+ fetchFlows(app, sysId),
263
+ ]);
264
+ const history = await resolveHistoryDisplayValues(app, table, rawHistory);
265
+ return { table, sys_id: sysId, history, businessRules, flows };
266
+ }
package/src/sdk.js CHANGED
@@ -188,6 +188,25 @@ export class SDKClient {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * Resolve a scope name or sys_id to a sys_id.
193
+ * @param {string} scope - Scope name (e.g. "sn_notif_dashboard") or sys_id
194
+ * @returns {Promise<string|null>} The scope sys_id, or null if not found
195
+ */
196
+ async resolveScope(scope) {
197
+ if (!scope) return null;
198
+ // If it looks like a sys_id (32 hex chars), try it directly
199
+ if (/^[0-9a-fA-F]{32}$/.test(scope)) return scope;
200
+
201
+ const params = new URLSearchParams();
202
+ params.set('sysparm_query', `scope=${scope}`);
203
+ params.set('sysparm_limit', '1');
204
+ params.set('sysparm_fields', 'sys_id,name');
205
+ const records = await this.list('sys_scope', params);
206
+ if (records.length === 0) return null;
207
+ return records[0].sys_id?.value || records[0].sys_id;
208
+ }
209
+
191
210
  async inspectFlow(identifier) {
192
211
  const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
193
212
 
@@ -372,7 +391,7 @@ export class SDKClient {
372
391
  * @param {string} script - JavaScript code to execute
373
392
  * @returns {Promise<string>} The script's output text
374
393
  */
375
- async executeScript(script) {
394
+ async executeScript(script, scope) {
376
395
  // Step 1: Warm up the session by hitting any REST API — this makes
377
396
  // ServiceNow issue session cookies for subsequent UI page requests.
378
397
  // We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
@@ -387,7 +406,7 @@ export class SDKClient {
387
406
  formBody.set('script', script);
388
407
  formBody.set('sysparm_ck', csrfToken);
389
408
  formBody.set('runscript', 'Run script');
390
- formBody.set('sys_scope', 'global');
409
+ formBody.set('sys_scope', scope || 'global');
391
410
  formBody.set('record_for_rollback', 'on');
392
411
  formBody.set('quota_managed_transaction', 'on');
393
412