@jacebenson/jsn 0.0.10 → 0.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.
@@ -2,6 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
+ const NPM_PKG = '@jacebenson/jsn';
6
+ const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
7
+
5
8
  function getVersion() {
6
9
  try {
7
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -13,13 +16,66 @@ function getVersion() {
13
16
  }
14
17
  }
15
18
 
19
+ async function checkLatest() {
20
+ try {
21
+ const res = await fetch(NPM_REGISTRY);
22
+ if (!res.ok) return null;
23
+ const data = await res.json();
24
+ return data.version;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
16
30
  export function versionCmd(wrap) {
17
31
  return {
18
32
  command: 'version',
19
33
  describe: 'Show version information',
20
- handler: wrap(async (_argv, app) => {
34
+ builder: (y) => y
35
+ .option('check', {
36
+ alias: 'c',
37
+ type: 'boolean',
38
+ describe: 'Check npm registry for newer version',
39
+ }),
40
+ handler: wrap(async (argv, app) => {
21
41
  const version = getVersion();
22
- app.ok({ version }, { summary: `jsn ${version}` });
42
+
43
+ if (argv.check) {
44
+ const latest = await checkLatest();
45
+ if (!latest) {
46
+ app.ok({ version }, {
47
+ summary: `jsn ${version} (could not reach npm registry to check for updates)`,
48
+ notice: `Run \`npm view ${NPM_PKG}\` directly to check manually`,
49
+ });
50
+ return;
51
+ }
52
+
53
+ const isUpToDate = version === latest;
54
+
55
+ if (isUpToDate) {
56
+ app.ok({
57
+ version,
58
+ latest,
59
+ upToDate: true,
60
+ }, {
61
+ summary: `jsn ${version} — up to date`,
62
+ });
63
+ } else {
64
+ app.ok({
65
+ version,
66
+ latest,
67
+ upToDate: false,
68
+ installCmd: `npm install -g ${NPM_PKG}`,
69
+ }, {
70
+ summary: `jsn ${version} — newer version ${latest} available`,
71
+ breadcrumbs: [
72
+ { action: 'update', cmd: `npm install -g ${NPM_PKG}`, description: `Update jsn from ${version} to ${latest}` },
73
+ ],
74
+ });
75
+ }
76
+ } else {
77
+ app.ok({ version }, { summary: `jsn ${version}` });
78
+ }
23
79
  }),
24
80
  };
25
81
  }
package/src/help.js ADDED
@@ -0,0 +1,85 @@
1
+ // Custom grouped help renderer
2
+ // Mirrors the Go version's custom Cobra help template
3
+
4
+ const COMMAND_GROUPS = {
5
+ 'CORE COMMANDS': [
6
+ { name: 'incidents', alias: 'inc', desc: 'Manage incidents' },
7
+ { name: 'changes', alias: 'chg', desc: 'Manage change requests' },
8
+ { name: 'requests', alias: 'req', desc: 'Manage service catalog requests' },
9
+ { name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
10
+ { name: 'tickets', alias: 'ticket', desc: 'Query generic tickets' },
11
+ ],
12
+ 'DATA & ADMIN': [
13
+ { name: 'records', desc: 'Generic Table API for any table' },
14
+ { name: 'users', alias: 'user', desc: 'Manage ServiceNow users' },
15
+ { name: 'groups', alias: 'group', desc: 'Manage groups' },
16
+ { name: 'groupmembers', alias: 'gm', desc: 'Manage group memberships' },
17
+ { 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' },
26
+ ],
27
+ };
28
+
29
+ const UTILITY_COMMANDS = [
30
+ { name: 'skill', desc: 'Manage the jsn AI agent skill file (show, fetch, install)' },
31
+ { name: 'version', desc: 'Show version information (--check for npm updates)' },
32
+ ];
33
+
34
+ function renderGroup(name, commands) {
35
+ const lines = [`\n${name}`];
36
+ lines.push('─'.repeat(50));
37
+ for (const cmd of commands) {
38
+ const aliasPart = cmd.alias ? ` (${cmd.alias})` : '';
39
+ const padded = ` jsn ${cmd.name}`.padEnd(22);
40
+ lines.push(`${padded}${cmd.desc}${aliasPart}`);
41
+ }
42
+ return lines.join('\n');
43
+ }
44
+
45
+ function renderFlags() {
46
+ return `
47
+ FLAGS
48
+ --instance ServiceNow instance URL (e.g., https://dev12345.service-now.com) [string]
49
+ -p, --profile Configuration profile to use [string]
50
+ --format Output format: auto, json, markdown, styled, quiet [string]
51
+ --json Output in JSON format [boolean]
52
+ -q, --quiet Output only data, no envelope [boolean]
53
+ --styled Force styled output [boolean]
54
+ --markdown Output in Markdown format [boolean]
55
+ --help Show help [boolean]`;
56
+ }
57
+
58
+ function renderTips() {
59
+ return `
60
+ TIPS
61
+ --query is available on every list command (e.g. "incidents list --query priority=1")
62
+ Use "jsn <command> --help" for details, or "jsn <command> list --help" for list options
63
+
64
+ LEARN MORE
65
+ Use "jsn <command> --help" for more information about a command.`;
66
+ }
67
+
68
+ export function renderHelp() {
69
+ const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
70
+ const utilitySection = renderGroup('UTILITY', UTILITY_COMMANDS);
71
+
72
+ return [
73
+ 'Usage: jsn <command> [options]',
74
+ '',
75
+ `Command-line interface for ServiceNow`,
76
+ '',
77
+ ...sections,
78
+ '',
79
+ utilitySection,
80
+ '',
81
+ renderFlags(),
82
+ renderTips(),
83
+ '',
84
+ ].join('\n');
85
+ }
package/src/helpers.js CHANGED
@@ -1,5 +1,9 @@
1
1
  // Shared helper utilities
2
2
 
3
+ import fs from 'node:fs';
4
+ import search from '@inquirer/search';
5
+ import { isTTY, FormatAuto } from './output.js';
6
+
3
7
  export function getStringField(record, field) {
4
8
  if (!record || typeof record !== 'object') return '';
5
9
  const val = record[field];
@@ -58,3 +62,83 @@ export function extractProfileName(instanceURL) {
58
62
  export function buildQuerySuffix(query) {
59
63
  return query ? ` --query "${query}"` : '';
60
64
  }
65
+
66
+ /**
67
+ * Shared interactive list helper with search-as-you-type.
68
+ * All list commands that want an interactive TTY picker should use this.
69
+ *
70
+ * @param {object} opts
71
+ * @param {App} opts.app
72
+ * @param {string} opts.table — ServiceNow table name
73
+ * @param {string} opts.singular — e.g. "script include", "log entry"
74
+ * @param {string[]} opts.columns — default display columns
75
+ * @param {number} opts.limit — max records (default 20)
76
+ * @param {string} opts.query — optional encoded query
77
+ * @param {Function} opts.formatLabel — (record) => string for the picker display
78
+ * @param {string} opts.labelField — field used to match selection (default: 'name')
79
+ * @returns {Promise<void>|null} null if no selection made or non-interactive
80
+ */
81
+ export async function interactiveList({ app, table, singular, columns, limit = 20, query = '', formatLabel, labelField = 'name' }) {
82
+ const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
83
+ if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin) || query) {
84
+ return null; // not interactive — caller should fall back to text/table
85
+ }
86
+
87
+ const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id')];
88
+ const params = new URLSearchParams();
89
+ params.set('sysparm_limit', String(limit));
90
+ params.set('sysparm_display_value', 'all');
91
+ params.set('sysparm_fields', pickerColumns.join(','));
92
+ params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
93
+
94
+ const records = await app.sdk.list(table, params);
95
+ if (records.length === 0) return null;
96
+
97
+ const choices = records.map(r => ({
98
+ name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
99
+ value: r,
100
+ }));
101
+
102
+ try {
103
+ const selected = await search({
104
+ message: `Select ${vowelArticle(singular)} ${singular}:`,
105
+ source: async (input) => {
106
+ if (!input) return choices;
107
+ const term = input.toLowerCase();
108
+ return choices.filter(c => c.name.toLowerCase().includes(term));
109
+ },
110
+ });
111
+ return selected; // the record object
112
+ } catch (err) {
113
+ if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
114
+ return null;
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ function vowelArticle(word) {
121
+ const first = word.charAt(0).toLowerCase();
122
+ return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
123
+ }
124
+
125
+ /**
126
+ * Parse --data or --data-file into a JSON object.
127
+ * If --data-file is given, reads the file. Otherwise parses --data directly.
128
+ * Throws if neither is provided or JSON is invalid.
129
+ */
130
+ export function parseDataArg(argv) {
131
+ let raw;
132
+ if (argv['data-file']) {
133
+ raw = fs.readFileSync(argv['data-file'], 'utf-8');
134
+ } else if (argv.data) {
135
+ raw = argv.data;
136
+ } else {
137
+ throw new Error('--data or --data-file is required');
138
+ }
139
+ try {
140
+ return JSON.parse(raw);
141
+ } catch (e) {
142
+ 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 });
143
+ }
144
+ }
package/src/output.js CHANGED
@@ -358,6 +358,36 @@ export class OutputWriter {
358
358
  this.writer.write('\n');
359
359
  }
360
360
 
361
+ // ── Attachments ──
362
+ if (data._attachments && Array.isArray(data._attachments) && data._attachments.length > 0) {
363
+ this.writer.write('─ Attachments ─\n');
364
+ for (const att of data._attachments) {
365
+ const fileName = getDisplayValue(att.file_name);
366
+ const createdBy = getDisplayValue(att.sys_created_by);
367
+ const createdOn = getDisplayValue(att.sys_created_on);
368
+ this.writer.write(` ${fileName} (by ${createdBy}, ${createdOn})\n`);
369
+ }
370
+ this.writer.write('\n');
371
+ }
372
+
373
+ // ── Catalog Variables ──
374
+ if (data._variables && Array.isArray(data._variables) && data._variables.length > 0) {
375
+ this.writer.write('─ Catalog Variables ─\n');
376
+ for (const v of data._variables) {
377
+ const q = v.question || '';
378
+ const val = v.value || '';
379
+ if (val.includes('\n')) {
380
+ this.writer.write(` ${q}:\n`);
381
+ for (const line of val.split('\n')) {
382
+ this.writer.write(` ${line}\n`);
383
+ }
384
+ } else {
385
+ this.writer.write(` ${q}: ${val}\n`);
386
+ }
387
+ }
388
+ this.writer.write('\n');
389
+ }
390
+
361
391
  if (instanceURL && table) {
362
392
  const sysID = getDisplayValue(data.sys_id);
363
393
  if (sysID) {
package/src/sdk.js CHANGED
@@ -79,6 +79,56 @@ export class SDKClient {
79
79
  }
80
80
  }
81
81
 
82
+ // _fetchWithAuth makes an authenticated fetch and returns the Response object.
83
+ async _fetchWithAuth(endpoint, opts = {}) {
84
+ const controller = new AbortController();
85
+ const timer = setTimeout(() => controller.abort(), this.timeout);
86
+ try {
87
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
88
+ await this._setAuth(req);
89
+ return await fetch(req);
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ }
94
+
95
+ // rawRequest is like request() but returns the response text as-is (no JSON parsing).
96
+ // Used for non-JSON endpoints like sys.scripts.do (returns HTML).
97
+ async rawRequest(endpoint, opts = {}) {
98
+ const controller = new AbortController();
99
+ const timer = setTimeout(() => controller.abort(), this.timeout);
100
+
101
+ try {
102
+ const req = new Request(endpoint, {
103
+ ...opts,
104
+ signal: controller.signal,
105
+ });
106
+ if (opts.body && typeof opts.body === 'string') {
107
+ req.headers.set('Content-Type', opts.headers?.['Content-Type'] || 'application/x-www-form-urlencoded');
108
+ }
109
+ await this._setAuth(req);
110
+
111
+ const resp = await fetch(req);
112
+ const body = await resp.text();
113
+
114
+ if (!resp.ok) {
115
+ throw errAPI(resp.status, body || resp.statusText);
116
+ }
117
+
118
+ return body;
119
+ } catch (err) {
120
+ if (err.name === 'AbortError') {
121
+ throw errNetwork(new Error('Request timed out'));
122
+ }
123
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
124
+ throw errNetwork(err);
125
+ }
126
+ throw err;
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
130
+ }
131
+
82
132
  async list(table, params = {}) {
83
133
  const query = new URLSearchParams(params).toString();
84
134
  const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
@@ -115,6 +165,22 @@ export class SDKClient {
115
165
  await this.request(endpoint, { method: 'DELETE' });
116
166
  }
117
167
 
168
+ async getCurrentUser() {
169
+ const params = new URLSearchParams();
170
+ params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
171
+ params.set('sysparm_limit', '1');
172
+ params.set('sysparm_display_value', 'all');
173
+ params.set('sysparm_fields', 'sys_id,user_name,name');
174
+ const records = await this.list('sys_user', params);
175
+ if (records.length === 0) return null;
176
+ const r = records[0];
177
+ return {
178
+ sys_id: r.sys_id?.value || r.sys_id,
179
+ user_name: r.user_name?.display_value || r.user_name,
180
+ name: r.name?.display_value || r.name,
181
+ };
182
+ }
183
+
118
184
  async inspectFlow(identifier) {
119
185
  const isSysID = identifier.length === 32 && /^[0-9a-fA-F]+$/.test(identifier);
120
186
 
@@ -286,6 +352,198 @@ export class SDKClient {
286
352
 
287
353
  return 0;
288
354
  }
355
+
356
+ // ─── Background Script Execution (3-step OAuth session flow) ───
357
+
358
+ /**
359
+ * Execute a background script on the ServiceNow instance via sys.scripts.do.
360
+ * Uses a 3-step session-establishment flow compatible with OAuth tokens:
361
+ * 1. Make a REST API call to get session cookies
362
+ * 2. GET /sys.scripts.do with cookies to extract the CSRF token (sysparm_ck)
363
+ * 3. POST /sys.scripts.do with the script, CSRF token, and cookies
364
+ *
365
+ * @param {string} script - JavaScript code to execute
366
+ * @returns {Promise<string>} The script's output text
367
+ */
368
+ async executeScript(script) {
369
+ // Step 1: Warm up the session by hitting any REST API — this makes
370
+ // ServiceNow issue session cookies for subsequent UI page requests.
371
+ // We capture cookies to forward them (Node.js fetch() has no built-in cookie jar).
372
+ const cookies = await this._warmSession();
373
+
374
+ // Step 2: GET /sys.scripts.do to extract the CSRF token from the HTML form.
375
+ const csrfToken = await this._getScriptsPageCSRF(cookies);
376
+
377
+ // Step 3: POST the script with form data including the CSRF token.
378
+ const endpoint = `${this.baseURL}/sys.scripts.do`;
379
+ const formBody = new URLSearchParams();
380
+ formBody.set('script', script);
381
+ formBody.set('sysparm_ck', csrfToken);
382
+ formBody.set('runscript', 'Run script');
383
+ formBody.set('sys_scope', 'global');
384
+ formBody.set('record_for_rollback', 'on');
385
+ formBody.set('quota_managed_transaction', 'on');
386
+
387
+ const html = await this.rawRequest(endpoint, {
388
+ method: 'POST',
389
+ headers: {
390
+ 'Content-Type': 'application/x-www-form-urlencoded',
391
+ ...(cookies ? { Cookie: cookies } : {}),
392
+ },
393
+ body: formBody.toString(),
394
+ });
395
+
396
+ return this._extractScriptOutput(html);
397
+ }
398
+
399
+ /**
400
+ * Fetch a record and related data from multiple tables.
401
+ * @param {string} table - Table to query
402
+ * @param {URLSearchParams} params - Query params for the main record
403
+ * @param {Array<{table: string, queryField: string, queryValue: string, fields: string[], displayAs: string}>} related - Related tables to fetch
404
+ * @returns {Promise<{_record: object, [key: string]: any}>}
405
+ */
406
+ async recordWithRelated(table, params, related) {
407
+ const records = await this.list(table, params);
408
+ if (records.length === 0) throw new Error('Record not found');
409
+
410
+ const result = { _record: records[0] };
411
+
412
+ for (const rel of related) {
413
+ try {
414
+ const relParams = new URLSearchParams();
415
+ relParams.set('sysparm_display_value', 'all');
416
+ relParams.set('sysparm_fields', (rel.fields || []).join(','));
417
+ relParams.set('sysparm_query', `${rel.queryField}=${rel.queryValue}`);
418
+ relParams.set('sysparm_limit', '100');
419
+ result[rel.displayAs] = await this.list(rel.table, relParams);
420
+ } catch {
421
+ result[rel.displayAs] = [];
422
+ }
423
+ }
424
+
425
+ return result;
426
+ }
427
+
428
+ /**
429
+ * Fetch attachments for a record.
430
+ * @param {string} tableName - Table name (e.g. sc_req_item)
431
+ * @param {string} tableSysID - Sys ID of the record
432
+ * @returns {Promise<Array>}
433
+ */
434
+ async fetchAttachments(tableName, tableSysID) {
435
+ const params = new URLSearchParams();
436
+ params.set('sysparm_display_value', 'all');
437
+ params.set('sysparm_fields', 'sys_id,file_name,sys_created_on,sys_created_by');
438
+ params.set('sysparm_query', `table_name=${tableName}^table_sys_id=${tableSysID}`);
439
+ return this.list('sys_attachment', params);
440
+ }
441
+
442
+ /**
443
+ * Fetch catalog variables for a request item (RITM).
444
+ * @param {string} ritmSysID - Sys ID of the request item
445
+ * @returns {Promise<Array<{question: string, value: string}>>}
446
+ */
447
+ async fetchCatalogVariables(ritmSysID) {
448
+ const params = new URLSearchParams();
449
+ params.set('sysparm_display_value', 'all');
450
+ params.set('sysparm_fields', 'item_option_new,value');
451
+ params.set('sysparm_query', `request_item=${ritmSysID}`);
452
+ params.set('sysparm_limit', '100');
453
+
454
+ const optRecords = await this.list('sc_item_option', params);
455
+ const variables = [];
456
+
457
+ for (const opt of optRecords) {
458
+ let question = '';
459
+ if (opt.item_option_new && typeof opt.item_option_new === 'object') {
460
+ question = opt.item_option_new.display_value || '';
461
+ }
462
+
463
+ let value = '';
464
+ if (opt.value && typeof opt.value === 'object') {
465
+ value = opt.value.display_value || opt.value.value || '';
466
+ } else if (typeof opt.value === 'string') {
467
+ value = opt.value;
468
+ }
469
+
470
+ if (question) {
471
+ variables.push({ question, value });
472
+ }
473
+ }
474
+
475
+ return variables;
476
+ }
477
+
478
+ async _warmSession() {
479
+ try {
480
+ const endpoint = `${this.baseURL}/api/now/table/sys_user?sysparm_limit=1`;
481
+ const resp = await this._fetchWithAuth(endpoint, { method: 'GET', headers: { Accept: 'application/json' } });
482
+ // Extract cookies for subsequent UI page requests
483
+ const setCookie = resp.headers.getSetCookie?.() || resp.headers.get('set-cookie');
484
+ if (setCookie) {
485
+ return Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;
486
+ }
487
+ return '';
488
+ } catch {
489
+ return '';
490
+ }
491
+ }
492
+
493
+ async _getScriptsPageCSRF(cookies) {
494
+ const endpoint = `${this.baseURL}/sys.scripts.do`;
495
+ const html = await this.rawRequest(endpoint, {
496
+ method: 'GET',
497
+ headers: cookies ? { Cookie: cookies } : {},
498
+ });
499
+
500
+ // Extract <input name="sysparm_ck" type="hidden" value="TOKEN"> from HTML
501
+ const marker = '<input name="sysparm_ck" type="hidden" value="';
502
+ const idx = html.indexOf(marker);
503
+ if (idx !== -1) {
504
+ const start = idx + marker.length;
505
+ const end = html.indexOf('"', start);
506
+ if (end !== -1) return html.substring(start, end);
507
+ const altEnd = html.indexOf('">', start);
508
+ if (altEnd !== -1) return html.substring(start, altEnd);
509
+ }
510
+
511
+ // Fallback: try without type attribute
512
+ const altMarker = 'name="sysparm_ck" value="';
513
+ const altIdx = html.indexOf(altMarker);
514
+ if (altIdx !== -1) {
515
+ const start = altIdx + altMarker.length;
516
+ const end = html.indexOf('"', start);
517
+ if (end !== -1) return html.substring(start, end);
518
+ }
519
+
520
+ // Not authorized or couldn't extract token
521
+ if (html.includes('not authorized') || html.includes('login.do')) {
522
+ throw new Error('Not authorized to access scripts page. Your OAuth token may not support UI sessions. Try the browser: ' + this.baseURL + '/sys.scripts.do');
523
+ }
524
+ throw new Error('Could not find CSRF token on scripts page (response: ' + html.substring(0, 200) + ')');
525
+ }
526
+
527
+ _extractScriptOutput(html) {
528
+ // Convert <BR> and <BR/> to newlines first
529
+ let out = html.replace(/<BR\s*\/?>/gi, '\n').replace(/<br\s*\/?>/gi, '\n');
530
+
531
+ // Find <PRE>...</PRE> content
532
+ const preMatch = out.match(/<PRE[^>]*>([\s\S]*?)<\/PRE>/i);
533
+ if (preMatch) {
534
+ out = preMatch[1];
535
+ }
536
+
537
+ // Strip remaining HTML tags
538
+ out = out.replace(/<[^>]+>/g, '');
539
+
540
+ // Clean up: decode HTML entities, trim lines
541
+ out = out.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"');
542
+
543
+ // Trim each line and remove empty lines
544
+ const lines = out.split('\n').map(l => l.trim()).filter(l => l.length > 0);
545
+ return lines.join('\n');
546
+ }
289
547
  }
290
548
 
291
549
  function getBoolField(record, field) {