@jacebenson/jsn 1.0.7 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +196 -243
  2. package/bin/jsn.js +2 -57
  3. package/package.json +35 -32
  4. package/skills/servicenow/SKILL.md +137 -0
  5. package/src/app.js +194 -0
  6. package/src/auth.js +517 -0
  7. package/src/cli.js +174 -0
  8. package/src/commands/_ticket.js +260 -0
  9. package/src/commands/auth.js +227 -0
  10. package/src/commands/catalog.js +325 -0
  11. package/src/commands/changes.js +7 -0
  12. package/src/commands/dev/_generic.js +342 -0
  13. package/src/commands/dev/_simple.js +89 -0
  14. package/src/commands/dev/eval.js +38 -0
  15. package/src/commands/dev/flows.js +573 -0
  16. package/src/commands/dev/forms.js +313 -0
  17. package/src/commands/dev/lists.js +233 -0
  18. package/src/commands/dev/logs.js +64 -0
  19. package/src/commands/dev/rest.js +64 -0
  20. package/src/commands/dev/scopes.js +156 -0
  21. package/src/commands/dev/updatesets.js +184 -0
  22. package/src/commands/dev.js +53 -0
  23. package/src/commands/groupmembers.js +152 -0
  24. package/src/commands/grouproles.js +152 -0
  25. package/src/commands/groups.js +141 -0
  26. package/src/commands/incidents.js +7 -0
  27. package/src/commands/profiles.js +91 -0
  28. package/src/commands/records.js +171 -0
  29. package/src/commands/requests.js +59 -0
  30. package/src/commands/setup.js +39 -0
  31. package/src/commands/skill.js +144 -0
  32. package/src/commands/tasks.js +7 -0
  33. package/src/commands/tickets.js +121 -0
  34. package/src/commands/users.js +144 -0
  35. package/src/commands/version.js +81 -0
  36. package/src/config.js +154 -0
  37. package/src/context.js +62 -0
  38. package/src/errors.js +101 -0
  39. package/src/help.js +85 -0
  40. package/src/helpers.js +182 -0
  41. package/src/output.js +440 -0
  42. package/src/sdk.js +615 -0
  43. package/scripts/install.sh +0 -227
  44. package/scripts/npm-install.js +0 -235
  45. package/scripts/pre-commit-check.sh +0 -61
package/src/context.js ADDED
@@ -0,0 +1,62 @@
1
+ // ServiceNow runtime context: current user, scope, update set
2
+
3
+ export async function getCurrentUser(sdk) {
4
+ const params = new URLSearchParams();
5
+ params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
6
+ params.set('sysparm_limit', '1');
7
+ params.set('sysparm_display_value', 'all');
8
+ params.set('sysparm_fields', 'sys_id,user_name,name');
9
+ const records = await sdk.list('sys_user', params);
10
+ if (records.length === 0) return null;
11
+ const r = records[0];
12
+ return {
13
+ sys_id: r.sys_id?.value || r.sys_id,
14
+ user_name: r.user_name?.display_value || r.user_name,
15
+ name: r.name?.display_value || r.name,
16
+ };
17
+ }
18
+
19
+ export async function getCurrentApplication(sdk, userSysID) {
20
+ const params = new URLSearchParams();
21
+ params.set('sysparm_query', `user=${userSysID}^name=apps.current_app`);
22
+ params.set('sysparm_limit', '1');
23
+ params.set('sysparm_fields', 'value');
24
+ const records = await sdk.list('sys_user_preference', params);
25
+ if (records.length === 0) return { scope: 'global' };
26
+ const val = records[0].value?.value || records[0].value;
27
+ if (!val) return { scope: 'global' };
28
+
29
+ // Try to resolve sys_id to scope name
30
+ try {
31
+ const app = await sdk.get('sys_scope', val);
32
+ if (app) return { scope: app.scope || 'global' };
33
+ } catch {
34
+ // fallback
35
+ }
36
+ try {
37
+ const app2 = await sdk.get('sys_app', val);
38
+ if (app2) return { scope: app2.scope || 'global' };
39
+ } catch {
40
+ // fallback
41
+ }
42
+ return { scope: val };
43
+ }
44
+
45
+ export async function getCurrentUpdateSet(sdk, userSysID) {
46
+ const params = new URLSearchParams();
47
+ params.set('sysparm_query', `user=${userSysID}^name=sys_update_set`);
48
+ params.set('sysparm_limit', '1');
49
+ params.set('sysparm_fields', 'value');
50
+ const records = await sdk.list('sys_user_preference', params);
51
+ if (records.length === 0) return null;
52
+ const val = records[0].value?.value || records[0].value;
53
+ if (!val || val === '-') return { name: 'Default', sys_id: '' };
54
+
55
+ try {
56
+ const us = await sdk.get('sys_update_set', val);
57
+ if (us) return { name: us.name || val, sys_id: val };
58
+ } catch {
59
+ // fallback
60
+ }
61
+ return { name: val, sys_id: val };
62
+ }
package/src/errors.js ADDED
@@ -0,0 +1,101 @@
1
+ // Structured error types with code, message, and optional hint.
2
+
3
+ export const CodeUsage = 'usage_error';
4
+ export const CodeNotFound = 'not_found';
5
+ export const CodeAuth = 'auth_error';
6
+ export const CodeForbidden = 'forbidden';
7
+ export const CodeRateLimit = 'rate_limited';
8
+ export const CodeNetwork = 'network_error';
9
+ export const CodeAPI = 'api_error';
10
+ export const CodeAmbiguous = 'ambiguous';
11
+ export const CodeEmptyResult = 'empty_result';
12
+
13
+ export class AppError extends Error {
14
+ constructor(code, message, hint = '', status = 0, cause = null) {
15
+ super(message);
16
+ this.name = 'AppError';
17
+ this.code = code;
18
+ this.hint = hint;
19
+ this.status = status;
20
+ this.cause = cause;
21
+ }
22
+
23
+ toString() {
24
+ if (this.hint) {
25
+ return `${this.code}: ${this.message}\nHint: ${this.hint}`;
26
+ }
27
+ return `${this.code}: ${this.message}`;
28
+ }
29
+ }
30
+
31
+ export function errUsage(msg) {
32
+ return new AppError(CodeUsage, msg);
33
+ }
34
+
35
+ export function errUsageHint(msg, hint) {
36
+ return new AppError(CodeUsage, msg, hint);
37
+ }
38
+
39
+ export function errNotFound(resource, identifier) {
40
+ return new AppError(
41
+ CodeNotFound,
42
+ `${resource} not found: ${identifier}`,
43
+ `Check the ${resource} exists and you have access to it.`
44
+ );
45
+ }
46
+
47
+ export function errNotFoundHint(resource, identifier, hint) {
48
+ return new AppError(CodeNotFound, `${resource} not found: ${identifier}`, hint);
49
+ }
50
+
51
+ export function errAuth(msg) {
52
+ return new AppError(CodeAuth, msg, 'Run: jsn auth login');
53
+ }
54
+
55
+ export function errForbidden(msg) {
56
+ return new AppError(CodeForbidden, msg, '', 403);
57
+ }
58
+
59
+ export function errRateLimit(retryAfter) {
60
+ return new AppError(
61
+ CodeRateLimit,
62
+ `Rate limited. Retry after ${retryAfter} seconds.`,
63
+ 'Wait before retrying, or use pagination for large queries.'
64
+ );
65
+ }
66
+
67
+ export function errNetwork(cause) {
68
+ return new AppError(
69
+ CodeNetwork,
70
+ `Network error: ${cause.message || cause}`,
71
+ 'Check your internet connection and instance URL.',
72
+ 0,
73
+ cause
74
+ );
75
+ }
76
+
77
+ export function errAPI(status, msg) {
78
+ let hint = 'Check the API documentation for this endpoint.';
79
+ if (status >= 500) {
80
+ hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
81
+ }
82
+ return new AppError(CodeAPI, `API error (status ${status}): ${msg}`, hint, status);
83
+ }
84
+
85
+ export function errAmbiguous(resource, matches) {
86
+ return new AppError(
87
+ CodeAmbiguous,
88
+ `Multiple ${resource} found matching your query`,
89
+ `Did you mean one of: ${matches.join(', ')}?`
90
+ );
91
+ }
92
+
93
+ export function asError(err) {
94
+ if (!err) return null;
95
+ if (err instanceof AppError) return err;
96
+ return new AppError('unknown', err.message || String(err));
97
+ }
98
+
99
+ export function isErrorCode(err, code) {
100
+ return err instanceof AppError && err.code === code;
101
+ }
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 ADDED
@@ -0,0 +1,182 @@
1
+ // Shared helper utilities
2
+
3
+ import fs from 'node:fs';
4
+ import search from '@inquirer/search';
5
+ import { isTTY, FormatAuto } from './output.js';
6
+
7
+ export function getStringField(record, field) {
8
+ if (!record || typeof record !== 'object') return '';
9
+ const val = record[field];
10
+ if (val == null) return '';
11
+ if (typeof val === 'string') return val;
12
+ if (typeof val === 'object') {
13
+ if (val.display_value != null) return String(val.display_value);
14
+ if (val.value != null) return String(val.value);
15
+ }
16
+ return String(val);
17
+ }
18
+
19
+ export function formatRecordForDisplay(record, columns) {
20
+ const result = {};
21
+
22
+ function extractValue(val) {
23
+ if (val == null) return '';
24
+ if (typeof val === 'string') return val;
25
+ if (typeof val === 'object') {
26
+ if (val.display_value != null && val.display_value !== '') return String(val.display_value);
27
+ if (val.value != null) return String(val.value);
28
+ }
29
+ return String(val);
30
+ }
31
+
32
+ if (record.sys_id != null) {
33
+ result.sys_id = extractValue(record.sys_id);
34
+ }
35
+
36
+ for (const col of columns) {
37
+ if (record[col] != null) {
38
+ result[col] = extractValue(record[col]);
39
+ } else {
40
+ result[col] = '';
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
46
+ export function truncateString(str, maxLen) {
47
+ if (!str || str.length <= maxLen) return str;
48
+ return str.slice(0, maxLen - 3) + '...';
49
+ }
50
+
51
+ export function isHexString(str) {
52
+ return /^[0-9a-fA-F]+$/.test(str);
53
+ }
54
+
55
+ export function extractProfileName(instanceURL) {
56
+ let name = instanceURL.replace(/^https?:\/\//, '');
57
+ name = name.replace(/\.service-now\.com$/, '');
58
+ name = name.replace(/\.servicenowservices\.com$/, '');
59
+ return name;
60
+ }
61
+
62
+ export function buildQuerySuffix(query) {
63
+ return query ? ` --query "${query}"` : '';
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
+ }
145
+
146
+ /**
147
+ * Translate human-readable type names to ServiceNow item_option_new type IDs.
148
+ * Maps common names like "date", "select", "multilinetext" to their integer IDs.
149
+ * Passes through numeric values unchanged.
150
+ */
151
+ const ITEM_OPTION_TYPE_NAMES = {
152
+ '1': 1, 'yesno': 1, 'yes/no': 1, 'boolean': 1,
153
+ '2': 2, 'multilinetext': 2, 'textarea': 2, 'multiline': 2,
154
+ '3': 3, 'multiplechoice': 3,
155
+ '4': 4, 'numericscale': 4, 'rating': 4,
156
+ '5': 5, 'select': 5, 'dropdown': 5, 'choice': 5, 'selectbox': 5,
157
+ '6': 6, 'string': 6, 'text': 6, 'singlelinetext': 6,
158
+ '7': 7, 'checkbox': 7, 'check': 7,
159
+ '8': 8, 'reference': 8, 'lookup': 8,
160
+ '9': 9, 'date': 9,
161
+ '10': 10, 'datetime': 10, 'date/time': 10,
162
+ '11': 11, 'label': 11,
163
+ '14': 14, 'custom': 14,
164
+ '18': 18, 'lookupselect': 18, 'lookupselectbox': 18,
165
+ '20': 20, 'containerstart': 20,
166
+ '21': 21, 'listcollector': 21,
167
+ '23': 23, 'html': 23,
168
+ '26': 26, 'email': 26,
169
+ '29': 29, 'duration': 29,
170
+ '31': 31, 'requestedfor': 31,
171
+ '32': 32, 'richtextlabel': 32, 'richtext': 32,
172
+ };
173
+
174
+ export function resolveItemOptionType(type) {
175
+ if (type == null) return 6; // default: Single Line Text
176
+ if (typeof type === 'number') return type;
177
+ const lower = String(type).toLowerCase().replace(/[\s_-]/g, '');
178
+ if (ITEM_OPTION_TYPE_NAMES[lower] != null) return ITEM_OPTION_TYPE_NAMES[lower];
179
+ const asNum = parseInt(String(type), 10);
180
+ if (!isNaN(asNum) && asNum > 0 && asNum < 100) return asNum;
181
+ return 6; // fallback to Single Line Text
182
+ }