@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
@@ -0,0 +1,152 @@
1
+ import { formatRecordForDisplay, getStringField } from '../helpers.js';
2
+
3
+ export function groupRolesCmd(wrap) {
4
+ return {
5
+ command: 'grouproles [subcommand]',
6
+ aliases: ['grouprole', 'grole', 'gr'],
7
+ describe: 'Manage group roles',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .command({
11
+ command: 'list',
12
+ aliases: ['ls'],
13
+ describe: 'List group roles',
14
+ builder: (y) => y
15
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
16
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
17
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
+ handler: wrap(async (argv, app) => {
19
+ const columns = argv.columns ? argv.columns.split(',') : ['group.name', 'role.name'];
20
+ const params = new URLSearchParams();
21
+ params.set('sysparm_limit', String(argv.limit));
22
+ params.set('sysparm_display_value', 'all');
23
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
24
+ if (argv.query) params.set('sysparm_query', argv.query);
25
+ const records = await app.sdk.list('sys_group_has_role', params);
26
+ app.ok({
27
+ table: 'sys_group_has_role',
28
+ count: records.length,
29
+ columns,
30
+ records: records.map(r => formatRecordForDisplay(r, columns)),
31
+ context: { instance_url: app.getEffectiveInstance() },
32
+ }, { summary: `${records.length} group role(s)` });
33
+ }),
34
+ })
35
+ .command({
36
+ command: 'add',
37
+ describe: 'Add a role to a group',
38
+ builder: (y) => y
39
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
40
+ .option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' }),
41
+ handler: wrap(async (argv, app) => {
42
+ const groupName = argv.group;
43
+ const roleName = argv.role;
44
+
45
+ // Resolve group sys_id
46
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
47
+ const groupParams = new URLSearchParams();
48
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
49
+ groupParams.set('sysparm_limit', '1');
50
+ groupParams.set('sysparm_fields', 'sys_id');
51
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
52
+ if (groupRecords.length === 0) {
53
+ throw new Error(`Group not found: ${groupName}`);
54
+ }
55
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
56
+
57
+ // Resolve role sys_id
58
+ const isRoleSysID = roleName.length === 32 && /^[0-9a-fA-F]+$/.test(roleName);
59
+ const roleParams = new URLSearchParams();
60
+ roleParams.set('sysparm_query', isRoleSysID ? `sys_id=${roleName}` : `name=${roleName}`);
61
+ roleParams.set('sysparm_limit', '1');
62
+ roleParams.set('sysparm_fields', 'sys_id');
63
+ const roleRecords = await app.sdk.list('sys_user_role', roleParams);
64
+ if (roleRecords.length === 0) {
65
+ throw new Error(`Role not found: ${roleName}`);
66
+ }
67
+ const roleSysID = getStringField(roleRecords[0], 'sys_id');
68
+
69
+ // Check if role assignment already exists
70
+ const checkParams = new URLSearchParams();
71
+ checkParams.set('sysparm_query', `group=${groupSysID}^role=${roleSysID}`);
72
+ checkParams.set('sysparm_limit', '1');
73
+ checkParams.set('sysparm_fields', 'sys_id');
74
+ const existing = await app.sdk.list('sys_group_has_role', checkParams);
75
+ if (existing.length > 0) {
76
+ throw new Error(`Role ${roleName} is already assigned to group ${groupName}`);
77
+ }
78
+
79
+ // Create the role assignment
80
+ const record = await app.sdk.create('sys_group_has_role', {
81
+ group: groupSysID,
82
+ role: roleSysID,
83
+ });
84
+ app.ok(record, {
85
+ summary: `Added role ${roleName} to ${groupName}`,
86
+ breadcrumbs: [
87
+ { action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
88
+ { action: 'remove', cmd: `jsn grouproles remove --group "${groupName}" --role "${roleName}"`, description: 'Remove this role' },
89
+ ],
90
+ });
91
+ }),
92
+ })
93
+ .command({
94
+ command: 'remove',
95
+ describe: 'Remove a role from a group',
96
+ builder: (y) => y
97
+ .option('group', { alias: 'g', type: 'string', demandOption: true, describe: 'Group name or sys_id (required)' })
98
+ .option('role', { alias: 'r', type: 'string', demandOption: true, describe: 'Role name or sys_id (required)' }),
99
+ handler: wrap(async (argv, app) => {
100
+ const groupName = argv.group;
101
+ const roleName = argv.role;
102
+
103
+ // Resolve group sys_id
104
+ const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
105
+ const groupParams = new URLSearchParams();
106
+ groupParams.set('sysparm_query', isGroupSysID ? `sys_id=${groupName}` : `name=${groupName}`);
107
+ groupParams.set('sysparm_limit', '1');
108
+ groupParams.set('sysparm_fields', 'sys_id');
109
+ const groupRecords = await app.sdk.list('sys_user_group', groupParams);
110
+ if (groupRecords.length === 0) {
111
+ throw new Error(`Group not found: ${groupName}`);
112
+ }
113
+ const groupSysID = getStringField(groupRecords[0], 'sys_id');
114
+
115
+ // Resolve role sys_id
116
+ const isRoleSysID = roleName.length === 32 && /^[0-9a-fA-F]+$/.test(roleName);
117
+ const roleParams = new URLSearchParams();
118
+ roleParams.set('sysparm_query', isRoleSysID ? `sys_id=${roleName}` : `name=${roleName}`);
119
+ roleParams.set('sysparm_limit', '1');
120
+ roleParams.set('sysparm_fields', 'sys_id');
121
+ const roleRecords = await app.sdk.list('sys_user_role', roleParams);
122
+ if (roleRecords.length === 0) {
123
+ throw new Error(`Role not found: ${roleName}`);
124
+ }
125
+ const roleSysID = getStringField(roleRecords[0], 'sys_id');
126
+
127
+ // Find the role assignment record
128
+ const findParams = new URLSearchParams();
129
+ findParams.set('sysparm_query', `group=${groupSysID}^role=${roleSysID}`);
130
+ findParams.set('sysparm_limit', '1');
131
+ findParams.set('sysparm_fields', 'sys_id');
132
+ const assignmentRecords = await app.sdk.list('sys_group_has_role', findParams);
133
+ if (assignmentRecords.length === 0) {
134
+ throw new Error(`Role ${roleName} is not assigned to group ${groupName}`);
135
+ }
136
+ const assignmentSysID = getStringField(assignmentRecords[0], 'sys_id');
137
+
138
+ // Delete the role assignment
139
+ await app.sdk.delete('sys_group_has_role', assignmentSysID);
140
+ app.ok({ group: groupName, role: roleName }, {
141
+ summary: `Removed role ${roleName} from ${groupName}`,
142
+ breadcrumbs: [
143
+ { action: 'list', cmd: `jsn grouproles list --query "group.name=${groupName}"`, description: 'List group roles' },
144
+ { action: 'add', cmd: `jsn grouproles add --group "${groupName}" --role "${roleName}"`, description: 'Add role back to group' },
145
+ ],
146
+ });
147
+ }),
148
+ });
149
+ },
150
+ handler: () => {},
151
+ };
152
+ }
@@ -0,0 +1,141 @@
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
2
+
3
+ export function groupsCmd(wrap) {
4
+ return {
5
+ command: 'groups [subcommand]',
6
+ aliases: ['group'],
7
+ describe: 'Manage ServiceNow groups',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .command({
11
+ command: 'list',
12
+ aliases: ['ls'],
13
+ describe: 'List groups',
14
+ builder: (y) => y
15
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
16
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
17
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
+ handler: wrap(async (argv, app) => {
19
+ const columns = argv.columns ? argv.columns.split(',') : ['name', 'manager', 'email'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_user_group', singular: 'group', columns, limit: argv.limit, query, labelField: 'name',
24
+ formatLabel: r => `${getStringField(r, 'name')} ${getStringField(r, 'manager') ? '→ ' + getStringField(r, 'manager') : ''}`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user_group' };
28
+ return app.ok(picked, { summary: `Group: ${getStringField(picked, 'name')}` });
29
+ }
30
+
31
+ const params = new URLSearchParams();
32
+ params.set('sysparm_limit', String(argv.limit));
33
+ params.set('sysparm_display_value', 'all');
34
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
35
+ if (argv.query) params.set('sysparm_query', argv.query);
36
+ const records = await app.sdk.list('sys_user_group', params);
37
+ app.ok({
38
+ table: 'sys_user_group',
39
+ count: records.length,
40
+ columns,
41
+ records: records.map(r => formatRecordForDisplay(r, columns)),
42
+ context: { instance_url: app.getEffectiveInstance() },
43
+ }, { summary: `${records.length} group(s)` });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'show <name>',
48
+ aliases: ['get'],
49
+ describe: 'Show a group by name or sys_id',
50
+ handler: wrap(async (argv, app) => {
51
+ const id = argv.name;
52
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
53
+ const params = new URLSearchParams();
54
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
55
+ params.set('sysparm_limit', '1');
56
+ params.set('sysparm_display_value', 'all');
57
+ const records = await app.sdk.list('sys_user_group', params);
58
+ if (records.length === 0) {
59
+ throw new Error(`Group not found: ${id}`);
60
+ }
61
+ app.ok(records[0], { summary: `Group ${id}` });
62
+ }),
63
+ })
64
+ .command({
65
+ command: 'create',
66
+ describe: 'Create a new group',
67
+ builder: (y) => y
68
+ .option('name', { alias: 'n', type: 'string', describe: 'Group name' })
69
+ .option('manager', { type: 'string', describe: 'Manager name or sys_id' })
70
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
71
+ handler: wrap(async (argv, app) => {
72
+ const recordData = {};
73
+ if (argv.data) {
74
+ Object.assign(recordData, JSON.parse(argv.data));
75
+ }
76
+ if (argv.name) recordData.name = argv.name;
77
+ if (argv.manager) recordData.manager = argv.manager;
78
+ if (!recordData.name) {
79
+ throw new Error('name is required (use --name or --data)');
80
+ }
81
+ const record = await app.sdk.create('sys_user_group', recordData);
82
+ app.ok(record, {
83
+ summary: `Created group ${getStringField(record, 'name')}`,
84
+ breadcrumbs: [
85
+ { action: 'view', cmd: `jsn groups show ${getStringField(record, 'name')}`, description: `View the new group` },
86
+ ],
87
+ });
88
+ }),
89
+ })
90
+ .command({
91
+ command: 'update <name>',
92
+ describe: 'Update a group by name or sys_id',
93
+ builder: (y) => y
94
+ .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
95
+ handler: wrap(async (argv, app) => {
96
+ const id = argv.name;
97
+ const recordData = JSON.parse(argv.data);
98
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
99
+ const params = new URLSearchParams();
100
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
101
+ params.set('sysparm_limit', '1');
102
+ params.set('sysparm_fields', 'sys_id');
103
+ const records = await app.sdk.list('sys_user_group', params);
104
+ if (records.length === 0) {
105
+ throw new Error(`Group not found: ${id}`);
106
+ }
107
+ const sysID = getStringField(records[0], 'sys_id');
108
+ const updated = await app.sdk.update('sys_user_group', sysID, recordData);
109
+ app.ok(updated, {
110
+ summary: `Updated group ${id}`,
111
+ breadcrumbs: [
112
+ { action: 'view', cmd: `jsn groups show ${id}`, description: `View the updated group` },
113
+ ],
114
+ });
115
+ }),
116
+ })
117
+ .command({
118
+ command: 'delete <name>',
119
+ describe: 'Delete a group by name or sys_id',
120
+ handler: wrap(async (argv, app) => {
121
+ const id = argv.name;
122
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
123
+ const params = new URLSearchParams();
124
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
125
+ params.set('sysparm_limit', '1');
126
+ params.set('sysparm_fields', 'sys_id');
127
+ const records = await app.sdk.list('sys_user_group', params);
128
+ if (records.length === 0) {
129
+ throw new Error(`Group not found: ${id}`);
130
+ }
131
+ const sysID = getStringField(records[0], 'sys_id');
132
+ await app.sdk.delete('sys_user_group', sysID);
133
+ app.ok({ identifier: id, message: 'Group deleted' }, {
134
+ summary: `Deleted group ${id}`,
135
+ });
136
+ }),
137
+ });
138
+ },
139
+ handler: () => {},
140
+ };
141
+ }
@@ -0,0 +1,7 @@
1
+ import { buildTicketCommands } from './_ticket.js';
2
+
3
+ const incidentDefaultColumns = ['number', 'short_description', 'priority', 'state', 'assigned_to'];
4
+
5
+ export function incidentsCmd(wrap) {
6
+ return buildTicketCommands('incident', 'incidents', 'inc', incidentDefaultColumns, {}, null, wrap);
7
+ }
@@ -0,0 +1,91 @@
1
+ import { saveConfig } from '../config.js';
2
+
3
+ export function profilesCmd(wrap) {
4
+ return {
5
+ command: 'profiles [subcommand]',
6
+ aliases: ['profile'],
7
+ describe: 'Manage configuration profiles',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .command({
11
+ command: 'list',
12
+ describe: 'List all profiles',
13
+ handler: wrap(async (_argv, app) => {
14
+ const profiles = [];
15
+ for (const [name, profile] of Object.entries(app.config.profiles || {})) {
16
+ const instance = profile.instance_url || '';
17
+ const isAuth = app.auth.isAuthenticatedFor(instance);
18
+ const isDefault = name === app.config.defaultProfile;
19
+ profiles.push({
20
+ name,
21
+ instance,
22
+ username: profile.username || '',
23
+ authenticated: isAuth,
24
+ default: isDefault,
25
+ });
26
+ }
27
+ app.ok({ profiles }, { summary: `${profiles.length} profile(s)` });
28
+ }),
29
+ })
30
+ .command({
31
+ command: 'use <name>',
32
+ describe: 'Set active profile',
33
+ handler: wrap(async (argv, app) => {
34
+ const name = argv.name;
35
+ if (!app.config.profiles[name]) {
36
+ throw new Error(`Profile not found: ${name}`);
37
+ }
38
+ app.config.defaultProfile = name;
39
+ app.config.activeProfile = name;
40
+ saveConfig(app.config);
41
+ app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
42
+ }),
43
+ })
44
+ .command({
45
+ command: 'show [name]',
46
+ describe: 'Show profile details',
47
+ handler: wrap(async (argv, app) => {
48
+ const name = argv.name || app.config.defaultProfile || Object.keys(app.config.profiles || {})[0];
49
+ if (!name || !app.config.profiles[name]) {
50
+ throw new Error('No profiles configured');
51
+ }
52
+ const profile = app.config.profiles[name];
53
+ app.ok({ name, ...profile }, { summary: `Profile: ${name}` });
54
+ }),
55
+ })
56
+ .command({
57
+ command: 'remove <name>',
58
+ describe: 'Remove a profile',
59
+ handler: wrap(async (argv, app) => {
60
+ const name = argv.name;
61
+ if (!app.config.profiles[name]) {
62
+ throw new Error(`Profile not found: ${name}`);
63
+ }
64
+ delete app.config.profiles[name];
65
+ if (app.config.defaultProfile === name) {
66
+ app.config.defaultProfile = '';
67
+ }
68
+ if (app.config.activeProfile === name) {
69
+ app.config.activeProfile = '';
70
+ }
71
+ saveConfig(app.config);
72
+ app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
73
+ }),
74
+ })
75
+
76
+ },
77
+ handler: (argv) => {
78
+ // When no subcommand is given, show help
79
+ if (!argv._[1]) {
80
+ console.log('Manage your ServiceNow instance profiles.\n');
81
+ console.log('Usage: jsn profiles <list|show|use|remove> [options]\n');
82
+ console.log('Commands:');
83
+ console.log(' list List all profiles');
84
+ console.log(' show [name] Show profile details');
85
+ console.log(' use <name> Switch to a different profile');
86
+ console.log(' remove <name> Remove a profile');
87
+ console.log('\nRun "jsn profiles <command> --help" for more details.');
88
+ }
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,171 @@
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList } from '../helpers.js';
2
+
3
+ const tableDefaultColumns = {
4
+ incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
5
+ change_request: ['number', 'short_description', 'risk', 'state', 'assigned_to'],
6
+ change_task: ['number', 'short_description', 'state', 'assigned_to'],
7
+ problem: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
8
+ sc_request: ['number', 'short_description', 'request_state', 'requested_for'],
9
+ sc_req_item: ['number', 'short_description', 'stage', 'assigned_to'],
10
+ sc_task: ['number', 'short_description', 'state', 'assigned_to'],
11
+ sys_user: ['user_name', 'name', 'email', 'active'],
12
+ sys_user_group: ['name', 'manager', 'email'],
13
+ cmdb_ci: ['name', 'operational_status', 'ip_address'],
14
+ cmdb_ci_server: ['name', 'operational_status', 'ip_address'],
15
+ kb_knowledge: ['number', 'short_description', 'workflow_state', 'author'],
16
+ };
17
+
18
+ function getDefaultColumns(table) {
19
+ return tableDefaultColumns[table] || ['sys_id'];
20
+ }
21
+
22
+ export function recordsCmd(wrap) {
23
+ return {
24
+ command: 'records <subcommand>',
25
+ describe: 'Query and manage records in any table (e.g. "records list --table incident --query priority=1")',
26
+ builder: (yargs) => {
27
+ return yargs
28
+ .command({
29
+ command: 'list',
30
+ describe: 'List records from a table',
31
+ builder: (y) => y
32
+ .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
33
+ .option('sys-id', { type: 'string', describe: 'Record sys_id (filters to a single record)' })
34
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
35
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
36
+ .option('limit', { type: 'number', default: 20, describe: 'Max records' })
37
+ .option('offset', { type: 'number', default: 0, describe: 'Offset' }),
38
+ handler: wrap(async (argv, app) => {
39
+ const table = argv.table;
40
+ const columns = argv.columns ? argv.columns.split(',') : getDefaultColumns(table);
41
+ let query = argv.query || '';
42
+
43
+ // If --sys-id is provided, append it to the query
44
+ if (argv['sys-id']) {
45
+ if (query) query += '^';
46
+ query += `sys_id=${argv['sys-id']}`;
47
+ }
48
+
49
+ // Interactive picker (only when no --sys-id filter, otherwise go straight to results)
50
+ if (!argv['sys-id']) {
51
+ const picked = await interactiveList({
52
+ app, table, singular: 'record', columns, limit: argv.limit, query, labelField: 'sys_id',
53
+ formatLabel: r => {
54
+ const cols = getDefaultColumns(table);
55
+ return cols.map(c => `${c}: ${getStringField(r, c) || '-'}`).join(' | ');
56
+ },
57
+ });
58
+ if (picked) {
59
+ picked._context = { instance_url: app.getEffectiveInstance(), table };
60
+ return app.ok(picked, { summary: `Record from ${table}` });
61
+ }
62
+ }
63
+
64
+ // Text/table fallback (or sys-id direct lookup)
65
+ const params = new URLSearchParams();
66
+ params.set('sysparm_limit', argv['sys-id'] ? '1' : String(argv.limit));
67
+ params.set('sysparm_offset', String(argv.offset));
68
+ params.set('sysparm_display_value', 'all');
69
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
70
+ if (query) params.set('sysparm_query', query);
71
+ const records = await app.sdk.list(table, params);
72
+ const displayRecords = records.map(r => formatRecordForDisplay(r, columns));
73
+ const breadcrumbs = [
74
+ { action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
75
+ { action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
76
+ { action: 'columns', cmd: `jsn dev columns --table ${table}`, description: 'View available columns' },
77
+ ];
78
+ if (argv['sys-id']) {
79
+ breadcrumbs.unshift({
80
+ action: 'get',
81
+ cmd: `jsn records get --table ${table} --sys-id ${argv['sys-id']}`,
82
+ description: 'Get full record details',
83
+ });
84
+ }
85
+ if (records.length === argv.limit) {
86
+ breadcrumbs.push({
87
+ action: 'next',
88
+ cmd: `jsn records list --table ${table} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${buildQuerySuffix(argv.query)}`,
89
+ description: `Next page`,
90
+ });
91
+ }
92
+ if (argv.offset > 0) {
93
+ breadcrumbs.push({
94
+ action: 'prev',
95
+ cmd: `jsn records list --table ${table} --limit ${argv.limit} --offset ${Math.max(0, argv.offset - argv.limit)}${buildQuerySuffix(argv.query)}`,
96
+ description: 'Previous page',
97
+ });
98
+ }
99
+ app.ok({
100
+ table,
101
+ count: records.length,
102
+ columns,
103
+ records: displayRecords,
104
+ pagination: { limit: argv.limit, offset: argv.offset },
105
+ context: { instance_url: app.getEffectiveInstance() },
106
+ }, { summary: `${records.length} record(s) from ${table}`, breadcrumbs });
107
+ }),
108
+ })
109
+ .command({
110
+ command: 'get',
111
+ describe: 'Get a single record by sys_id',
112
+ builder: (y) => y
113
+ .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
114
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
115
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' }),
116
+ handler: wrap(async (argv, app) => {
117
+ const params = new URLSearchParams();
118
+ params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
119
+ params.set('sysparm_limit', '1');
120
+ params.set('sysparm_display_value', 'true');
121
+ if (argv.columns) params.set('sysparm_fields', argv.columns);
122
+ const records = await app.sdk.list(argv.table, params);
123
+ if (records.length === 0) {
124
+ throw new Error(`Record not found: ${argv['sys-id']}`);
125
+ }
126
+ app.ok(records[0], { summary: `Record from ${argv.table}` });
127
+ }),
128
+ })
129
+ .command({
130
+ command: 'create',
131
+ describe: 'Create a new record',
132
+ builder: (y) => y
133
+ .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
134
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
135
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
136
+ handler: wrap(async (argv, app) => {
137
+ const recordData = parseDataArg(argv);
138
+ const record = await app.sdk.create(argv.table, recordData);
139
+ app.ok(record, { summary: `Created record in ${argv.table}` });
140
+ }),
141
+ })
142
+ .command({
143
+ command: 'update',
144
+ describe: 'Update an existing record',
145
+ builder: (y) => y
146
+ .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
147
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
148
+ .option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
149
+ .option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
150
+ handler: wrap(async (argv, app) => {
151
+ const recordData = parseDataArg(argv);
152
+ const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
153
+ app.ok(record, { summary: `Updated record in ${argv.table}` });
154
+ }),
155
+ })
156
+ .command({
157
+ command: 'delete',
158
+ describe: 'Delete a record',
159
+ builder: (y) => y
160
+ .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
161
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' }),
162
+ handler: wrap(async (argv, app) => {
163
+ await app.sdk.delete(argv.table, argv['sys-id']);
164
+ app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
165
+ }),
166
+ })
167
+
168
+ },
169
+ handler: () => {},
170
+ };
171
+ }
@@ -0,0 +1,59 @@
1
+ import { buildTicketCommands } from './_ticket.js';
2
+ import { getStringField } from '../helpers.js';
3
+
4
+ const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
5
+
6
+ export function requestsCmd(wrap) {
7
+ // Get the base command definition from the shared ticket builder
8
+ const baseCmd = buildTicketCommands('sc_req_item', 'requests', 'req', requestDefaultColumns, {}, null, wrap);
9
+
10
+ // Override the builder to inject our enriched show handler.
11
+ // yargs allows re-registration — the last .command() call wins the slot.
12
+ const originalBuilder = baseCmd.builder;
13
+ baseCmd.builder = (yargs) => {
14
+ const y = originalBuilder(yargs);
15
+ return y.command({
16
+ command: 'show <number>',
17
+ aliases: ['get'],
18
+ describe: 'Show a specific request with attachments and catalog variables',
19
+ handler: wrap(enrichedShowHandler),
20
+ });
21
+ };
22
+
23
+ return baseCmd;
24
+ }
25
+
26
+ async function enrichedShowHandler(argv, app) {
27
+ const number = argv.number;
28
+ const params = new URLSearchParams();
29
+ params.set('sysparm_query', `number=${number}`);
30
+ params.set('sysparm_display_value', 'all');
31
+ params.set('sysparm_limit', '1');
32
+ const records = await app.sdk.list('sc_req_item', params);
33
+ if (records.length === 0) {
34
+ throw new Error(`Request not found: ${number}`);
35
+ }
36
+ const record = records[0];
37
+ const sysID = getStringField(record, 'sys_id');
38
+
39
+ // Fetch attachments and catalog variables in parallel
40
+ const [attachments, variables] = await Promise.all([
41
+ app.sdk.fetchAttachments('sc_req_item', sysID).catch(() => []),
42
+ app.sdk.fetchCatalogVariables(sysID).catch(() => []),
43
+ ]);
44
+
45
+ record._attachments = attachments;
46
+ record._variables = variables;
47
+ record._context = {
48
+ instance_url: app.getEffectiveInstance(),
49
+ table: 'sc_req_item',
50
+ };
51
+
52
+ app.ok(record, {
53
+ summary: `Request ${number}`,
54
+ breadcrumbs: [
55
+ { action: 'update', cmd: `jsn req update ${number} --data '{...}'`, description: 'Update this request' },
56
+ { action: 'list', cmd: `jsn req list`, description: 'Back to all requests' },
57
+ ],
58
+ });
59
+ }
@@ -0,0 +1,39 @@
1
+ import readline from 'node:readline';
2
+ import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
3
+
4
+ export function setupCmd(wrap) {
5
+ return {
6
+ command: 'setup',
7
+ describe: 'Interactive first-time setup',
8
+ handler: wrap(async (_argv, app) => {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
11
+
12
+ console.log('Welcome to JSN - ServiceNow CLI');
13
+ console.log();
14
+
15
+ let instance = getEffectiveInstance(app.config);
16
+ if (!instance) {
17
+ instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
18
+ instance = normalizeInstanceURL(instance);
19
+ }
20
+ console.log(`Instance: ${instance}`);
21
+
22
+ const profileName = await ask('Profile name (default): ') || 'default';
23
+ await setProfile(app.config, profileName, { instance_url: instance });
24
+ // Set as active profile so subsequent commands know which instance to use
25
+ app.config.activeProfile = profileName;
26
+ app.config.defaultProfile = profileName;
27
+ saveConfig(app.config);
28
+
29
+ const loginNow = await ask('Login now? [Y/n]: ');
30
+ if (!loginNow || loginNow.toLowerCase() !== 'n') {
31
+ await app.auth.login(instance);
32
+ console.log('Login successful!');
33
+ }
34
+
35
+ rl.close();
36
+ app.ok({ setup: true, instance, profile: profileName }, { summary: 'Setup complete' });
37
+ }),
38
+ };
39
+ }