@jacebenson/jsn 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/auth.js ADDED
@@ -0,0 +1,273 @@
1
+ // OAuth 2.0 with PKCE authentication
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import readline from 'node:readline';
7
+ import { globalConfigDir, normalizeInstanceURL } from './config.js';
8
+ import { errAuth } from './errors.js';
9
+
10
+ const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
11
+ const REDIRECT_URI = '/sdk-oauth.do';
12
+
13
+ function credentialsPath(instance) {
14
+ const dir = path.join(globalConfigDir(), 'credentials');
15
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
16
+ const filename = Buffer.from(instance).toString('base64url') + '.json';
17
+ return path.join(dir, filename);
18
+ }
19
+
20
+ function getOAuthClientID() {
21
+ return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
22
+ }
23
+
24
+ function generatePKCE() {
25
+ const verifier = crypto.randomBytes(32).toString('base64url');
26
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
27
+ const state = crypto.randomBytes(16).toString('base64url');
28
+ return { code_verifier: verifier, code_challenge: challenge, state };
29
+ }
30
+
31
+ function buildAuthURL(instanceURL, clientID, pkce) {
32
+ const u = new URL('/oauth_auth.do', instanceURL);
33
+ u.searchParams.set('response_type', 'code');
34
+ u.searchParams.set('client_id', clientID);
35
+ u.searchParams.set('redirect_uri', REDIRECT_URI);
36
+ u.searchParams.set('state', pkce.state);
37
+ u.searchParams.set('code_challenge', pkce.code_challenge);
38
+ u.searchParams.set('code_challenge_method', 'S256');
39
+ u.searchParams.set('scope', 'openid');
40
+ return u.toString();
41
+ }
42
+
43
+ function loadCredentials(instance) {
44
+ try {
45
+ const data = fs.readFileSync(credentialsPath(instance), 'utf-8');
46
+ return JSON.parse(data);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function saveCredentials(instance, creds) {
53
+ fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
54
+ }
55
+
56
+ function deleteCredentials(instance) {
57
+ try {
58
+ fs.unlinkSync(credentialsPath(instance));
59
+ } catch {
60
+ // ignore
61
+ }
62
+ }
63
+
64
+ function askHidden(promptText) {
65
+ return new Promise((resolve) => {
66
+ const rl = readline.createInterface({
67
+ input: process.stdin,
68
+ output: process.stdout,
69
+ });
70
+
71
+ const stdin = process.stdin;
72
+ const stdout = process.stdout;
73
+
74
+ if (!stdin.isTTY) {
75
+ rl.question(promptText, (answer) => {
76
+ rl.close();
77
+ resolve(answer.trim());
78
+ });
79
+ return;
80
+ }
81
+
82
+ stdout.write(promptText);
83
+
84
+ stdin.setRawMode(true);
85
+ stdin.resume();
86
+ stdin.setEncoding('utf-8');
87
+
88
+ let input = '';
89
+ const onData = (key) => {
90
+ if (key === '\r' || key === '\n') {
91
+ stdin.removeListener('data', onData);
92
+ stdin.setRawMode(false);
93
+ stdin.pause();
94
+ stdout.write('\n');
95
+ rl.close();
96
+ resolve(input);
97
+ } else if (key === '\u0003') {
98
+ process.exit();
99
+ } else if (key === '\u007f') {
100
+ if (input.length > 0) {
101
+ input = input.slice(0, -1);
102
+ stdout.write('\b \b');
103
+ }
104
+ } else {
105
+ input += key;
106
+ stdout.write('*');
107
+ }
108
+ };
109
+ stdin.on('data', onData);
110
+ });
111
+ }
112
+
113
+ export class AuthManager {
114
+ constructor(configProvider) {
115
+ this.configProvider = configProvider;
116
+ this.httpClient = { timeout: 30000 };
117
+ }
118
+
119
+ isAuthenticated() {
120
+ if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
121
+ const instance = this.configProvider.getEffectiveInstance();
122
+ if (!instance) return false;
123
+ try {
124
+ this.getCredentialsFor(instance);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ isAuthenticatedFor(instance) {
132
+ if (!instance) return false;
133
+ const creds = loadCredentials(instance);
134
+ if (!creds) return false;
135
+ if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
136
+ return !!creds.access_token;
137
+ }
138
+
139
+ async getCredentials() {
140
+ if (process.env.SERVICENOW_OAUTH_TOKEN) {
141
+ return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN };
142
+ }
143
+ const instance = this.configProvider.getEffectiveInstance();
144
+ if (!instance) {
145
+ throw errAuth('No instance configured');
146
+ }
147
+ return this.getCredentialsFor(instance);
148
+ }
149
+
150
+ getCredentialsFor(instance) {
151
+ const creds = loadCredentials(instance);
152
+ if (!creds) {
153
+ throw errAuth(`Not authenticated for ${instance}`);
154
+ }
155
+ // Check expiry — refresh if less than 5 minutes remaining
156
+ if (creds.expires_at && Date.now() >= (creds.expires_at - 300) * 1000) {
157
+ if (creds.refresh_token) {
158
+ return this.refreshToken(instance, creds);
159
+ }
160
+ throw errAuth('Token expired, please login again');
161
+ }
162
+ return creds;
163
+ }
164
+
165
+ async login(instanceURL) {
166
+ instanceURL = normalizeInstanceURL(instanceURL);
167
+ const clientID = getOAuthClientID();
168
+ const pkce = generatePKCE();
169
+ const authURL = buildAuthURL(instanceURL, clientID, pkce);
170
+
171
+ console.log();
172
+ console.log('Opening browser for OAuth authentication...');
173
+ console.log('If the browser does not open automatically, visit:');
174
+ console.log(authURL);
175
+ console.log();
176
+
177
+ // Try to open browser
178
+ const open = (await import('node:child_process')).spawn;
179
+ const platform = process.platform;
180
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
181
+ try {
182
+ open(cmd, [authURL], { detached: true, stdio: 'ignore' });
183
+ } catch {
184
+ // ignore
185
+ }
186
+
187
+ console.log('After authenticating in the browser, copy the authorization code shown on the page.');
188
+ console.log('(input is hidden for security — just paste and press Enter)');
189
+ console.log();
190
+
191
+ const authCode = await askHidden('Authorization code (hidden on paste for security): ');
192
+ const code = authCode.trim();
193
+ if (!code) {
194
+ throw errAuth('Authorization code is required');
195
+ }
196
+
197
+ console.log('\nExchanging authorization code for tokens...');
198
+ const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
199
+ saveCredentials(instanceURL, newCreds);
200
+ return newCreds;
201
+ }
202
+
203
+ async exchangeCode(instanceURL, clientID, code, pkce) {
204
+ const tokenURL = `${instanceURL.replace(/\/$/, '')}/oauth_token.do`;
205
+ const body = new URLSearchParams();
206
+ body.set('grant_type', 'authorization_code');
207
+ body.set('client_id', clientID);
208
+ body.set('code', code);
209
+ body.set('redirect_uri', REDIRECT_URI);
210
+ body.set('code_verifier', pkce.code_verifier);
211
+
212
+ const resp = await fetch(tokenURL, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
215
+ body: body.toString(),
216
+ });
217
+
218
+ const text = await resp.text();
219
+ if (!resp.ok) {
220
+ throw errAuth(`Token exchange failed (status ${resp.status}): ${text}`);
221
+ }
222
+
223
+ const tokenResp = JSON.parse(text);
224
+ const expiresAt = tokenResp.expires_in ? Math.floor(Date.now() / 1000) + tokenResp.expires_in : 0;
225
+ return {
226
+ auth_method: 'oauth',
227
+ access_token: tokenResp.access_token,
228
+ refresh_token: tokenResp.refresh_token,
229
+ expires_at: expiresAt,
230
+ created_at: Math.floor(Date.now() / 1000),
231
+ };
232
+ }
233
+
234
+ async refreshToken(instance, creds) {
235
+ const tokenURL = `${instance.replace(/\/$/, '')}/oauth_token.do`;
236
+ const clientID = getOAuthClientID();
237
+ const body = new URLSearchParams();
238
+ body.set('grant_type', 'refresh_token');
239
+ body.set('client_id', clientID);
240
+ body.set('refresh_token', creds.refresh_token);
241
+
242
+ const resp = await fetch(tokenURL, {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
245
+ body: body.toString(),
246
+ });
247
+
248
+ if (!resp.ok) {
249
+ const text = await resp.text();
250
+ throw errAuth(`Token refresh failed: ${text}`);
251
+ }
252
+
253
+ const tokenResp = await resp.json();
254
+ const newCreds = {
255
+ auth_method: 'oauth',
256
+ access_token: tokenResp.access_token,
257
+ refresh_token: tokenResp.refresh_token,
258
+ created_at: Math.floor(Date.now() / 1000),
259
+ };
260
+ if (tokenResp.expires_in) {
261
+ newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
262
+ }
263
+ saveCredentials(instance, newCreds);
264
+ return newCreds;
265
+ }
266
+
267
+ logout(instance) {
268
+ if (!instance) {
269
+ throw errAuth('No instance specified');
270
+ }
271
+ deleteCredentials(instance);
272
+ }
273
+ }
package/src/cli.js ADDED
@@ -0,0 +1,142 @@
1
+ // Root CLI using yargs
2
+
3
+ import yargs from 'yargs';
4
+ import { hideBin } from 'yargs/helpers';
5
+ import process from 'node:process';
6
+ import { loadConfig, getEffectiveInstance } from './config.js';
7
+ import { App } from './app.js';
8
+
9
+ // Command modules
10
+ import { setupCmd } from './commands/setup.js';
11
+ import { authCmd } from './commands/auth.js';
12
+ import { profilesCmd } from './commands/profiles.js';
13
+ import { recordsCmd } from './commands/records.js';
14
+ import { incidentsCmd } from './commands/incidents.js';
15
+ import { changesCmd } from './commands/changes.js';
16
+ import { requestsCmd } from './commands/requests.js';
17
+ import { tasksCmd } from './commands/tasks.js';
18
+ import { usersCmd } from './commands/users.js';
19
+ import { groupsCmd } from './commands/groups.js';
20
+ import { groupMembersCmd } from './commands/groupmembers.js';
21
+ import { groupRolesCmd } from './commands/grouproles.js';
22
+ import { ticketsCmd } from './commands/tickets.js';
23
+ import { versionCmd } from './commands/version.js';
24
+ import { devCmd } from './commands/dev.js';
25
+
26
+ function wrap(handler) {
27
+ return async (argv) => {
28
+ try {
29
+ const app = argv.app;
30
+ if (!app) {
31
+ process.stderr.write('Error: App context not initialized.\n');
32
+ process.exit(1);
33
+ }
34
+ await handler(argv, app);
35
+ } catch (err) {
36
+ const app = argv.app;
37
+ if (app) {
38
+ app.err(err);
39
+ } else {
40
+ process.stderr.write(`Error: ${err.message || err}\n`);
41
+ }
42
+ process.exit(1);
43
+ }
44
+ };
45
+ }
46
+
47
+ export const cli = yargs(hideBin(process.argv))
48
+ .scriptName('jsn')
49
+ .usage('Usage: $0 <command> [options]')
50
+ .option('instance', {
51
+ describe: 'ServiceNow instance URL (e.g., https://dev12345.service-now.com)',
52
+ type: 'string',
53
+ global: true,
54
+ })
55
+ .option('profile', {
56
+ alias: 'p',
57
+ describe: 'Configuration profile to use',
58
+ type: 'string',
59
+ global: true,
60
+ })
61
+ .option('format', {
62
+ describe: 'Output format: auto, json, markdown, styled, quiet',
63
+ type: 'string',
64
+ global: true,
65
+ })
66
+ .option('json', {
67
+ describe: 'Output in JSON format',
68
+ type: 'boolean',
69
+ global: true,
70
+ })
71
+ .option('quiet', {
72
+ alias: 'q',
73
+ describe: 'Output only data, no envelope',
74
+ type: 'boolean',
75
+ global: true,
76
+ })
77
+ .option('styled', {
78
+ describe: 'Force styled output',
79
+ type: 'boolean',
80
+ global: true,
81
+ })
82
+ .option('markdown', {
83
+ describe: 'Output in Markdown format',
84
+ type: 'boolean',
85
+ global: true,
86
+ })
87
+ .middleware((argv) => {
88
+ // Determine format from flags
89
+ let format = 'auto';
90
+ if (argv.json) format = 'json';
91
+ else if (argv.quiet) format = 'quiet';
92
+ else if (argv.styled) format = 'styled';
93
+ else if (argv.markdown) format = 'markdown';
94
+ else if (argv.format) format = argv.format;
95
+
96
+ const cfg = loadConfig({
97
+ instance: argv.instance,
98
+ profile: argv.profile,
99
+ format,
100
+ });
101
+
102
+ argv.app = new App(cfg);
103
+
104
+ // Check auth for non-auth commands
105
+ const cmd = argv._[0];
106
+ const skipAuth = ['help', 'version', 'setup', 'auth', undefined].includes(cmd);
107
+ if (!skipAuth) {
108
+ const instance = getEffectiveInstance(cfg);
109
+ if (!argv.app.auth.isAuthenticated() && instance) {
110
+ process.stderr.write(`\n⚠️ Not authenticated to ${instance}\n\n`);
111
+ process.stderr.write('To get started, run:\n');
112
+ process.stderr.write(' jsn setup # Interactive setup\n');
113
+ process.stderr.write(` jsn auth login ${instance} # Login to instance\n\n`);
114
+ }
115
+ }
116
+
117
+ // Print context header for interactive terminals
118
+ if (!['help', 'version', 'completion'].includes(cmd)) {
119
+ argv.app.printContextHeader();
120
+ }
121
+ })
122
+ .command(setupCmd(wrap))
123
+ .command(authCmd(wrap))
124
+ .command(profilesCmd(wrap))
125
+ .command(recordsCmd(wrap))
126
+ .command(incidentsCmd(wrap))
127
+ .command(changesCmd(wrap))
128
+ .command(requestsCmd(wrap))
129
+ .command(tasksCmd(wrap))
130
+ .command(usersCmd(wrap))
131
+ .command(groupsCmd(wrap))
132
+ .command(groupMembersCmd(wrap))
133
+ .command(groupRolesCmd(wrap))
134
+ .command(ticketsCmd(wrap))
135
+ .command(devCmd(wrap))
136
+ .command(versionCmd(wrap))
137
+ .demandCommand(1, 'You must specify a command')
138
+ .help('help', 'Show help')
139
+ .version(false)
140
+ .strictCommands()
141
+ .strictOptions(false)
142
+ .epilogue('LEARN MORE\n Use "jsn <command> --help" for more information about a command.');
@@ -0,0 +1,256 @@
1
+ // Generic command builder for CRUD operations on a ServiceNow table
2
+ // Used by incidents, changes, requests, tasks, and most dev subcommands
3
+
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix } from '../helpers.js';
5
+ import { select } from '@inquirer/prompts';
6
+ import { isTTY, FormatAuto } from '../output.js';
7
+
8
+ export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
9
+ return {
10
+ command: `${displayName} [subcommand]`,
11
+ aliases: [table, alias],
12
+ describe: `Manage ${displayName}`,
13
+ builder: (yargs) => {
14
+ return yargs
15
+ .command({
16
+ command: 'list',
17
+ aliases: ['ls'],
18
+ describe: `List ${table}`,
19
+ builder: (y) => y
20
+ .option('query', { type: 'string', describe: 'Encoded query string' })
21
+ .option('columns', { alias: 'c', type: 'string', describe: 'Comma-separated columns' })
22
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
23
+ .option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset for pagination' }),
24
+ handler: wrap(async (argv, app) => {
25
+ const query = argv.query || '';
26
+ const columns = argv.columns ? argv.columns.split(',') : defaultColumns;
27
+ const limit = argv.limit;
28
+ const offset = argv.offset;
29
+
30
+ // Interactive picker in TTY with auto format and no explicit query/offset
31
+ const isInteractive = isTTY(process.stdout) && isTTY(process.stdin);
32
+ if (isInteractive && app.output.getFormat() === FormatAuto && !query && offset === 0) {
33
+ const pickerColumns = ['sys_id', 'number', 'short_description', 'state', 'assigned_to'];
34
+ const pickerParams = new URLSearchParams();
35
+ pickerParams.set('sysparm_limit', String(limit));
36
+ pickerParams.set('sysparm_offset', '0');
37
+ pickerParams.set('sysparm_display_value', 'all');
38
+ pickerParams.set('sysparm_fields', pickerColumns.join(','));
39
+ pickerParams.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
40
+
41
+ const pickerRecords = await app.sdk.list(table, pickerParams);
42
+ if (pickerRecords.length === 0) {
43
+ app.ok({
44
+ table,
45
+ count: 0,
46
+ columns: pickerColumns,
47
+ records: [],
48
+ pagination: { limit, offset: 0 },
49
+ context: { instance_url: app.getEffectiveInstance() },
50
+ }, {
51
+ summary: `0 ${table}(s)`,
52
+ breadcrumbs: [
53
+ { action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
54
+ ],
55
+ });
56
+ return;
57
+ }
58
+
59
+ const choices = pickerRecords.map((record) => {
60
+ const number = getStringField(record, 'number');
61
+ const desc = getStringField(record, 'short_description');
62
+ const state = getStringField(record, 'state');
63
+ const assigned = getStringField(record, 'assigned_to');
64
+ let label = `${number} ${desc} | ${state}`;
65
+ if (assigned) {
66
+ label += ` → ${assigned}`;
67
+ }
68
+ return { name: label, value: number };
69
+ });
70
+
71
+ let selectedNumber;
72
+ try {
73
+ selectedNumber = await select({
74
+ message: `Select a ${displayName.slice(0, -1)}:`,
75
+ choices,
76
+ });
77
+ } catch (err) {
78
+ if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
79
+ return;
80
+ }
81
+ throw err;
82
+ }
83
+
84
+ if (selectedNumber) {
85
+ const showParams = new URLSearchParams();
86
+ showParams.set('sysparm_query', `number=${selectedNumber}`);
87
+ showParams.set('sysparm_display_value', 'all');
88
+ showParams.set('sysparm_limit', '1');
89
+ const showRecords = await app.sdk.list(table, showParams);
90
+ if (showRecords.length === 0) {
91
+ throw new Error(`${displayName} not found: ${selectedNumber}`);
92
+ }
93
+ const record = showRecords[0];
94
+ record._context = {
95
+ instance_url: app.getEffectiveInstance(),
96
+ table,
97
+ };
98
+ app.ok(record, {
99
+ summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${selectedNumber}`,
100
+ breadcrumbs: [
101
+ { action: 'update', cmd: `jsn ${alias} update ${selectedNumber} --data '{...}'`, description: `Update this ${displayName}` },
102
+ { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
103
+ ],
104
+ });
105
+ }
106
+ return;
107
+ }
108
+
109
+ const params = new URLSearchParams();
110
+ params.set('sysparm_limit', String(limit));
111
+ params.set('sysparm_offset', String(offset));
112
+ params.set('sysparm_display_value', 'all');
113
+ const fetchColumns = ['sys_id', ...columns];
114
+ params.set('sysparm_fields', fetchColumns.join(','));
115
+ const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
116
+ params.set('sysparm_query', q);
117
+
118
+ const records = await app.sdk.list(table, params);
119
+ const displayRecords = records.map(r => formatRecordForDisplay(r, columns));
120
+
121
+ const breadcrumbs = [
122
+ { action: 'create', cmd: `jsn ${alias} create --description "..."`, description: `Create a new ${displayName}` },
123
+ { action: 'filter', cmd: `jsn ${alias} list --query "priority=1"`, description: 'Filter: critical only' },
124
+ ];
125
+
126
+ if (records.length === limit) {
127
+ breadcrumbs.push({
128
+ action: 'next',
129
+ cmd: `jsn ${alias} list --limit ${limit} --offset ${offset + limit}${buildQuerySuffix(query)}`,
130
+ description: `Next page (offset ${offset + limit})`,
131
+ });
132
+ }
133
+ if (offset > 0) {
134
+ breadcrumbs.push({
135
+ action: 'prev',
136
+ cmd: `jsn ${alias} list --limit ${limit} --offset ${Math.max(0, offset - limit)}${buildQuerySuffix(query)}`,
137
+ description: 'Previous page',
138
+ });
139
+ }
140
+
141
+ app.ok({
142
+ table,
143
+ count: records.length,
144
+ columns,
145
+ records: displayRecords,
146
+ pagination: { limit, offset },
147
+ context: { instance_url: app.getEffectiveInstance() },
148
+ }, {
149
+ summary: `${records.length} ${table}(s)`,
150
+ breadcrumbs,
151
+ });
152
+ }),
153
+ })
154
+ .command({
155
+ command: 'show <number>',
156
+ aliases: ['get'],
157
+ describe: `Show a specific ${displayName}`,
158
+ handler: wrap(async (argv, app) => {
159
+ const number = argv.number;
160
+ const params = new URLSearchParams();
161
+ params.set('sysparm_query', `number=${number}`);
162
+ params.set('sysparm_display_value', 'all');
163
+ params.set('sysparm_limit', '1');
164
+ const records = await app.sdk.list(table, params);
165
+ if (records.length === 0) {
166
+ throw new Error(`${displayName} not found: ${number}`);
167
+ }
168
+ const record = records[0];
169
+ record._context = {
170
+ instance_url: app.getEffectiveInstance(),
171
+ table,
172
+ };
173
+ app.ok(record, {
174
+ summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
175
+ breadcrumbs: [
176
+ { action: 'update', cmd: `jsn ${alias} update ${number} --data '{...}'`, description: `Update this ${displayName}` },
177
+ { action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
178
+ ],
179
+ });
180
+ }),
181
+ })
182
+ .command({
183
+ command: 'create',
184
+ describe: `Create a new ${displayName}`,
185
+ builder: (y) => y
186
+ .option('description', { alias: 'd', type: 'string', describe: 'Short description' })
187
+ .option('priority', { type: 'string', describe: 'Priority (1-5)' })
188
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
189
+ handler: wrap(async (argv, app) => {
190
+ const recordData = {};
191
+ if (argv.data) {
192
+ Object.assign(recordData, JSON.parse(argv.data));
193
+ }
194
+ if (argv.description) recordData.short_description = argv.description;
195
+ if (argv.priority) recordData.priority = argv.priority;
196
+ if (!recordData.short_description) {
197
+ throw new Error('short_description is required (use --description or --data)');
198
+ }
199
+ const record = await app.sdk.create(table, recordData);
200
+ app.ok(record, {
201
+ summary: `Created ${displayName} ${getStringField(record, 'number')}`,
202
+ breadcrumbs: [
203
+ { action: 'view', cmd: `jsn ${alias} show ${getStringField(record, 'number')}`, description: `View the new ${displayName}` },
204
+ ],
205
+ });
206
+ }),
207
+ })
208
+ .command({
209
+ command: 'update <number>',
210
+ describe: `Update a ${displayName}`,
211
+ builder: (y) => y
212
+ .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
213
+ handler: wrap(async (argv, app) => {
214
+ const number = argv.number;
215
+ const recordData = JSON.parse(argv.data);
216
+ const findParams = new URLSearchParams();
217
+ findParams.set('sysparm_query', `number=${number}`);
218
+ findParams.set('sysparm_limit', '1');
219
+ const records = await app.sdk.list(table, findParams);
220
+ if (records.length === 0) {
221
+ throw new Error(`${displayName} not found: ${number}`);
222
+ }
223
+ const sysID = getStringField(records[0], 'sys_id');
224
+ const updated = await app.sdk.update(table, sysID, recordData);
225
+ app.ok(updated, {
226
+ summary: `Updated ${displayName} ${number}`,
227
+ breadcrumbs: [
228
+ { action: 'view', cmd: `jsn ${alias} show ${number}`, description: `View the updated ${displayName}` },
229
+ ],
230
+ });
231
+ }),
232
+ })
233
+ .command({
234
+ command: 'delete <number>',
235
+ describe: `Delete a ${displayName}`,
236
+ handler: wrap(async (argv, app) => {
237
+ const number = argv.number;
238
+ const findParams = new URLSearchParams();
239
+ findParams.set('sysparm_query', `number=${number}`);
240
+ findParams.set('sysparm_limit', '1');
241
+ const records = await app.sdk.list(table, findParams);
242
+ if (records.length === 0) {
243
+ throw new Error(`${displayName} not found: ${number}`);
244
+ }
245
+ const sysID = getStringField(records[0], 'sys_id');
246
+ await app.sdk.delete(table, sysID);
247
+ app.ok({ number, message: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} deleted` }, {
248
+ summary: `Deleted ${displayName} ${number}`,
249
+ });
250
+ }),
251
+ })
252
+
253
+ },
254
+ handler: () => {}, // Handled by subcommands
255
+ };
256
+ }