@jacebenson/jsn 1.0.7 → 2.0.1
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/README.md +196 -243
- package/bin/jsn.js +2 -57
- package/package.json +35 -32
- package/skills/servicenow/SKILL.md +137 -0
- package/src/app.js +172 -0
- package/src/auth.js +517 -0
- package/src/cli.js +174 -0
- package/src/commands/_ticket.js +260 -0
- package/src/commands/auth.js +227 -0
- package/src/commands/catalog.js +325 -0
- package/src/commands/changes.js +7 -0
- package/src/commands/dev/_generic.js +342 -0
- package/src/commands/dev/_simple.js +89 -0
- package/src/commands/dev/eval.js +38 -0
- package/src/commands/dev/flows.js +573 -0
- package/src/commands/dev/forms.js +313 -0
- package/src/commands/dev/lists.js +233 -0
- package/src/commands/dev/logs.js +64 -0
- package/src/commands/dev/rest.js +64 -0
- package/src/commands/dev/scopes.js +156 -0
- package/src/commands/dev/updatesets.js +184 -0
- package/src/commands/dev.js +53 -0
- package/src/commands/groupmembers.js +152 -0
- package/src/commands/grouproles.js +152 -0
- package/src/commands/groups.js +141 -0
- package/src/commands/incidents.js +7 -0
- package/src/commands/profiles.js +91 -0
- package/src/commands/records.js +171 -0
- package/src/commands/requests.js +59 -0
- package/src/commands/setup.js +39 -0
- package/src/commands/skill.js +144 -0
- package/src/commands/tasks.js +7 -0
- package/src/commands/tickets.js +121 -0
- package/src/commands/users.js +144 -0
- package/src/commands/version.js +81 -0
- package/src/config.js +154 -0
- package/src/context.js +62 -0
- package/src/errors.js +101 -0
- package/src/help.js +85 -0
- package/src/helpers.js +182 -0
- package/src/output.js +440 -0
- package/src/sdk.js +615 -0
- package/scripts/install.sh +0 -227
- package/scripts/npm-install.js +0 -235
- package/scripts/pre-commit-check.sh +0 -61
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// Catalog item management commands
|
|
2
|
+
// NOTE: This is an AI-friendly high-level command that wraps sc_cat_item + item_option_new
|
|
3
|
+
|
|
4
|
+
import { resolveItemOptionType } from '../helpers.js';
|
|
5
|
+
|
|
6
|
+
export function catalogCmd(wrap) {
|
|
7
|
+
return {
|
|
8
|
+
command: 'catalog <subcommand>',
|
|
9
|
+
aliases: ['cat'],
|
|
10
|
+
describe: 'Manage Service Catalog items and variables',
|
|
11
|
+
builder: (yargs) => {
|
|
12
|
+
return yargs
|
|
13
|
+
// ── create-item: Create a new catalog item definition ──
|
|
14
|
+
.command({
|
|
15
|
+
command: 'create-item',
|
|
16
|
+
describe: 'Create a catalog item (sc_cat_item) with variables',
|
|
17
|
+
builder: (y) => y
|
|
18
|
+
.option('name', { alias: 'n', type: 'string', demandOption: true, describe: 'Catalog item name' })
|
|
19
|
+
.option('short-description', { alias: 'd', type: 'string', describe: 'Short description' })
|
|
20
|
+
.option('description', { type: 'string', describe: 'Full description' })
|
|
21
|
+
.option('category', { alias: 'c', type: 'string', describe: 'Category name (created if missing)' })
|
|
22
|
+
.option('variable', { alias: 'v', type: 'array', describe: 'Variable definition in format "name:type:label" or "name:type" (e.g. "reason:multilinetext:Reason" or "start_date:date")' })
|
|
23
|
+
.option('variables', { type: 'string', describe: 'JSON object of variable values for submit_produce API (e.g. \'{"quantity":"100","reason":"Need more"}\')' })
|
|
24
|
+
.option('update-set', { type: 'string', describe: 'Update set sys_id or name to capture changes' }),
|
|
25
|
+
handler: wrap(async (argv, app) => {
|
|
26
|
+
// If --variables is provided, use submit_produce API (request against existing item)
|
|
27
|
+
if (argv.variables) {
|
|
28
|
+
let varsObj;
|
|
29
|
+
try {
|
|
30
|
+
varsObj = JSON.parse(argv.variables);
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error('--variables must be a valid JSON object, e.g. {"reason":"Need this"}');
|
|
33
|
+
}
|
|
34
|
+
// Resolve the catalog item sys_id from name if needed
|
|
35
|
+
let itemSysID = argv.name;
|
|
36
|
+
if (!itemSysID.match(/^[a-f0-9]{32}$/i)) {
|
|
37
|
+
const params = new URLSearchParams();
|
|
38
|
+
params.set('sysparm_query', `name=${itemSysID}`);
|
|
39
|
+
params.set('sysparm_limit', '1');
|
|
40
|
+
params.set('sysparm_fields', 'sys_id');
|
|
41
|
+
const items = await app.sdk.list('sc_cat_item', params);
|
|
42
|
+
if (items.length === 0) throw new Error(`Catalog item not found: ${itemSysID}`);
|
|
43
|
+
const item = items[0];
|
|
44
|
+
itemSysID = item.sys_id?.value || item.sys_id;
|
|
45
|
+
}
|
|
46
|
+
// Submit via submit_produce API
|
|
47
|
+
const endpoint = `${app.sdk.baseURL}/api/sn_sc/servicecatalog/items/${itemSysID}/submit_produce`;
|
|
48
|
+
const body = { variables: varsObj };
|
|
49
|
+
const result = await app.sdk.request(endpoint, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
const reqID = result?.result?.sys_id || result?.result?.number || '';
|
|
54
|
+
app.ok({
|
|
55
|
+
requested_item: reqID,
|
|
56
|
+
item_id: itemSysID,
|
|
57
|
+
variables: varsObj,
|
|
58
|
+
}, {
|
|
59
|
+
summary: `Request created: ${result?.result?.number || reqID}`,
|
|
60
|
+
breadcrumbs: [
|
|
61
|
+
{ action: 'view', cmd: `jsn requests show ${reqID}`, description: 'View the request' },
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Original create-item flow: create a new catalog item definition
|
|
68
|
+
const name = argv.name;
|
|
69
|
+
const shortDesc = argv['short-description'] || '';
|
|
70
|
+
const description = argv.description || shortDesc;
|
|
71
|
+
let categoryID = '';
|
|
72
|
+
|
|
73
|
+
// Resolve or create category
|
|
74
|
+
if (argv.category) {
|
|
75
|
+
const catParams = new URLSearchParams();
|
|
76
|
+
catParams.set('sysparm_query', `title=${argv.category}`);
|
|
77
|
+
catParams.set('sysparm_limit', '1');
|
|
78
|
+
catParams.set('sysparm_fields', 'sys_id,title');
|
|
79
|
+
const cats = await app.sdk.list('sc_category', catParams);
|
|
80
|
+
if (cats.length > 0) {
|
|
81
|
+
categoryID = cats[0].sys_id?.value || cats[0].sys_id;
|
|
82
|
+
} else {
|
|
83
|
+
const newCat = await app.sdk.create('sc_category', {
|
|
84
|
+
title: argv.category,
|
|
85
|
+
description: `Category for ${argv.category}`,
|
|
86
|
+
});
|
|
87
|
+
categoryID = newCat.sys_id?.value || newCat.sys_id;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create the catalog item
|
|
92
|
+
const item = await app.sdk.create('sc_cat_item', {
|
|
93
|
+
name,
|
|
94
|
+
short_description: shortDesc,
|
|
95
|
+
description,
|
|
96
|
+
category: categoryID || undefined,
|
|
97
|
+
active: true,
|
|
98
|
+
type: 'item',
|
|
99
|
+
stage: 'requested',
|
|
100
|
+
});
|
|
101
|
+
const itemID = item.sys_id?.value || item.sys_id;
|
|
102
|
+
|
|
103
|
+
// Parse and create variables
|
|
104
|
+
const varDefs = [];
|
|
105
|
+
if (argv.variable) {
|
|
106
|
+
for (const v of argv.variable) {
|
|
107
|
+
const parts = String(v).split(':');
|
|
108
|
+
if (parts.length >= 2) {
|
|
109
|
+
const typeName = parts.length >= 2 ? parts[parts.length - 2] : '';
|
|
110
|
+
const label = parts.length >= 3 ? parts[parts.length - 1] : parts[0];
|
|
111
|
+
const typeID = resolveItemOptionType(typeName);
|
|
112
|
+
const sysName = parts[0].replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
|
|
113
|
+
varDefs.push({
|
|
114
|
+
name: sysName,
|
|
115
|
+
question_text: label,
|
|
116
|
+
type: typeID,
|
|
117
|
+
order: varDefs.length * 100 + 100,
|
|
118
|
+
cat_item: itemID,
|
|
119
|
+
mandatory: true,
|
|
120
|
+
active: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const vd of varDefs) {
|
|
126
|
+
try {
|
|
127
|
+
await app.sdk.create('item_option_new', vd);
|
|
128
|
+
} catch {
|
|
129
|
+
// variable creation failure is non-fatal
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
app.ok({
|
|
135
|
+
sys_id: itemID,
|
|
136
|
+
name,
|
|
137
|
+
category: argv.category || null,
|
|
138
|
+
variables_created: varDefs.length,
|
|
139
|
+
update_set: argv['update-set'] || null,
|
|
140
|
+
instance_url: app.getEffectiveInstance(),
|
|
141
|
+
}, {
|
|
142
|
+
summary: `Created catalog item: ${name} (${varDefs.length} variable(s))`,
|
|
143
|
+
breadcrumbs: [
|
|
144
|
+
{ action: 'view', cmd: `jsn records get --table sc_cat_item --sys-id ${itemID}`, description: 'View the catalog item' },
|
|
145
|
+
{ action: 'edit', cmd: `jsn catalog update-item ${itemID} --name "${name}"`, description: 'Edit the catalog item' },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
// ── items: List and show catalog items ──
|
|
151
|
+
.command({
|
|
152
|
+
command: 'items <subcommand>',
|
|
153
|
+
aliases: ['item'],
|
|
154
|
+
describe: 'Browse catalog items and inspect variables',
|
|
155
|
+
builder: (y) => y
|
|
156
|
+
.command({
|
|
157
|
+
command: 'list',
|
|
158
|
+
aliases: ['ls'],
|
|
159
|
+
describe: 'List catalog items',
|
|
160
|
+
builder: (y) => y
|
|
161
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
162
|
+
.option('category', { type: 'string', describe: 'Filter by category' }),
|
|
163
|
+
handler: wrap(async (argv, app) => {
|
|
164
|
+
let query = 'ORDERBYname';
|
|
165
|
+
if (argv.category) query = `category.titleLIKE${argv.category}^${query}`;
|
|
166
|
+
const params = new URLSearchParams();
|
|
167
|
+
params.set('sysparm_query', query);
|
|
168
|
+
params.set('sysparm_limit', String(argv.limit));
|
|
169
|
+
params.set('sysparm_display_value', 'all');
|
|
170
|
+
params.set('sysparm_fields', 'sys_id,name,short_description,category,active');
|
|
171
|
+
const items = await app.sdk.list('sc_cat_item', params);
|
|
172
|
+
app.ok({
|
|
173
|
+
table: 'sc_cat_item',
|
|
174
|
+
count: items.length,
|
|
175
|
+
columns: ['name', 'short_description', 'category', 'active'],
|
|
176
|
+
records: items.map(r => ({
|
|
177
|
+
sys_id: r.sys_id?.value || r.sys_id,
|
|
178
|
+
name: r.name?.display_value || r.name,
|
|
179
|
+
short_description: r.short_description?.display_value || r.short_description || '',
|
|
180
|
+
category: r.category?.display_value || '',
|
|
181
|
+
active: r.active?.display_value || r.active,
|
|
182
|
+
})),
|
|
183
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
184
|
+
}, { summary: `${items.length} catalog item(s)` });
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
187
|
+
.command({
|
|
188
|
+
command: 'show <id>',
|
|
189
|
+
describe: 'Show a catalog item with variables and variable sets',
|
|
190
|
+
handler: wrap(async (argv, app) => {
|
|
191
|
+
let itemID = argv.id;
|
|
192
|
+
// Resolve name to sys_id
|
|
193
|
+
if (!itemID.match(/^[a-f0-9]{32}$/i)) {
|
|
194
|
+
const params = new URLSearchParams();
|
|
195
|
+
params.set('sysparm_query', `name=${itemID}`);
|
|
196
|
+
params.set('sysparm_limit', '1');
|
|
197
|
+
params.set('sysparm_fields', 'sys_id,name');
|
|
198
|
+
const items = await app.sdk.list('sc_cat_item', params);
|
|
199
|
+
if (items.length === 0) throw new Error(`Catalog item not found: ${itemID}`);
|
|
200
|
+
itemID = items[0].sys_id?.value || items[0].sys_id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Get the catalog item
|
|
204
|
+
const item = await app.sdk.get('sc_cat_item', itemID);
|
|
205
|
+
|
|
206
|
+
// Get variables (item_option_new) for this catalog item
|
|
207
|
+
const varParams = new URLSearchParams();
|
|
208
|
+
varParams.set('sysparm_query', `cat_item=${itemID}^active=true`);
|
|
209
|
+
varParams.set('sysparm_limit', '100');
|
|
210
|
+
varParams.set('sysparm_display_value', 'all');
|
|
211
|
+
varParams.set('sysparm_fields', 'sys_id,name,question_text,type,order,mandatory,variable_set');
|
|
212
|
+
const variables = await app.sdk.list('item_option_new', varParams);
|
|
213
|
+
|
|
214
|
+
// Separate standalone vs variable-set variables
|
|
215
|
+
const standaloneVars = [];
|
|
216
|
+
const setVars = new Map(); // set_sys_id -> { name, label, variables: [] }
|
|
217
|
+
for (const v of variables) {
|
|
218
|
+
const vs = v.variable_set?.value || v.variable_set;
|
|
219
|
+
const entry = {
|
|
220
|
+
sys_id: v.sys_id?.value || v.sys_id,
|
|
221
|
+
name: v.name?.value || v.name || '',
|
|
222
|
+
question_text: v.question_text?.display_value || v.question_text || '',
|
|
223
|
+
type: v.type?.display_value || v.type,
|
|
224
|
+
order: v.order?.display_value || v.order,
|
|
225
|
+
mandatory: v.mandatory?.display_value || v.mandatory,
|
|
226
|
+
};
|
|
227
|
+
if (vs) {
|
|
228
|
+
if (!setVars.has(vs)) {
|
|
229
|
+
setVars.set(vs, { name: vs, label: vs, variables: [] });
|
|
230
|
+
}
|
|
231
|
+
setVars.get(vs).variables.push(entry);
|
|
232
|
+
} else {
|
|
233
|
+
standaloneVars.push(entry);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Try to resolve variable set names
|
|
238
|
+
for (const [sysID, setData] of setVars) {
|
|
239
|
+
try {
|
|
240
|
+
const vsRecord = await app.sdk.get('sc_cat_item_option_set', sysID);
|
|
241
|
+
setData.name = vsRecord.name?.value || vsRecord.name || sysID;
|
|
242
|
+
setData.label = vsRecord.label?.value || vsRecord.label || setData.name;
|
|
243
|
+
setData.order = vsRecord.order?.display_value || vsRecord.order;
|
|
244
|
+
} catch {
|
|
245
|
+
// Can't resolve, keep sys_id as name
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
app.ok({
|
|
250
|
+
sys_id: itemID,
|
|
251
|
+
name: item.name?.display_value || item.name,
|
|
252
|
+
short_description: item.short_description?.display_value || item.short_description || '',
|
|
253
|
+
description: item.description?.display_value || item.description || '',
|
|
254
|
+
category: item.category?.display_value || '',
|
|
255
|
+
active: item.active?.display_value || item.active,
|
|
256
|
+
variables: {
|
|
257
|
+
standalone: standaloneVars,
|
|
258
|
+
variable_sets: Array.from(setVars.values()),
|
|
259
|
+
},
|
|
260
|
+
instance_url: app.getEffectiveInstance(),
|
|
261
|
+
}, {
|
|
262
|
+
summary: `${item.name?.display_value || item.name} — ${standaloneVars.length + Array.from(setVars.values()).reduce((a, s) => a + s.variables.length, 0)} variable(s)`,
|
|
263
|
+
});
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
.demandCommand(1, 'Specify a subcommand: list or show'),
|
|
267
|
+
handler: () => {},
|
|
268
|
+
})
|
|
269
|
+
// ── list-items: Legacy alias ──
|
|
270
|
+
.command({
|
|
271
|
+
command: 'list-items',
|
|
272
|
+
aliases: ['ls'],
|
|
273
|
+
describe: 'List catalog items (use "catalog items list" instead)',
|
|
274
|
+
builder: (y) => y
|
|
275
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
276
|
+
.option('category', { type: 'string', describe: 'Filter by category' }),
|
|
277
|
+
handler: wrap(async (argv, app) => {
|
|
278
|
+
let query = 'ORDERBYname';
|
|
279
|
+
if (argv.category) query = `category.titleLIKE${argv.category}^${query}`;
|
|
280
|
+
const params = new URLSearchParams();
|
|
281
|
+
params.set('sysparm_query', query);
|
|
282
|
+
params.set('sysparm_limit', String(argv.limit));
|
|
283
|
+
params.set('sysparm_display_value', 'all');
|
|
284
|
+
params.set('sysparm_fields', 'sys_id,name,short_description,category,active');
|
|
285
|
+
const items = await app.sdk.list('sc_cat_item', params);
|
|
286
|
+
app.ok({
|
|
287
|
+
table: 'sc_cat_item',
|
|
288
|
+
count: items.length,
|
|
289
|
+
columns: ['name', 'short_description', 'category', 'active'],
|
|
290
|
+
records: items.map(r => ({
|
|
291
|
+
sys_id: r.sys_id?.value || r.sys_id,
|
|
292
|
+
name: r.name?.display_value || r.name,
|
|
293
|
+
short_description: r.short_description?.display_value || r.short_description || '',
|
|
294
|
+
category: r.category?.display_value || '',
|
|
295
|
+
active: r.active?.display_value || r.active,
|
|
296
|
+
})),
|
|
297
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
298
|
+
}, { summary: `${items.length} catalog item(s)` });
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
handler: (argv) => {
|
|
303
|
+
if (!argv._[1]) {
|
|
304
|
+
console.log('Manage Service Catalog items.\n');
|
|
305
|
+
console.log('Commands:');
|
|
306
|
+
console.log(' create-item Create a catalog item, or submit a request via submit_produce');
|
|
307
|
+
console.log(' items list List catalog items');
|
|
308
|
+
console.log(' items show Show catalog item details with variables');
|
|
309
|
+
console.log('\nExamples:');
|
|
310
|
+
console.log(' jsn catalog create-item "Request PTO" \\');
|
|
311
|
+
console.log(' --category "Time Off" \\');
|
|
312
|
+
console.log(' --short-description "Submit a PTO request" \\');
|
|
313
|
+
console.log(' --variable "start_date:date:Start Date" \\');
|
|
314
|
+
console.log(' --variable "type:select:Type" \\');
|
|
315
|
+
console.log(' --variable "reason:multilinetext:Reason/Notes"');
|
|
316
|
+
console.log('');
|
|
317
|
+
console.log(' # Submit a request via submit_produce:');
|
|
318
|
+
console.log(' jsn catalog create-item "Minecraft Coins" \\');
|
|
319
|
+
console.log(' --variables \'{"quantity":"100","reason":"Server upgrade"}\'');
|
|
320
|
+
console.log('\nVariable types: string, multilinetext, select, date, datetime, reference, checkbox, email');
|
|
321
|
+
console.log('\nRun "jsn catalog <command> --help" for details.');
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { buildTicketCommands } from './_ticket.js';
|
|
2
|
+
|
|
3
|
+
const changeDefaultColumns = ['number', 'short_description', 'risk', 'state', 'assigned_to'];
|
|
4
|
+
|
|
5
|
+
export function changesCmd(wrap) {
|
|
6
|
+
return buildTicketCommands('change_request', 'changes', 'chg', changeDefaultColumns, {}, null, wrap);
|
|
7
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Generic dev subcommand builder for table-based CRUD
|
|
2
|
+
|
|
3
|
+
import { formatRecordForDisplay, getStringField, isHexString, parseDataArg } from '../../helpers.js';
|
|
4
|
+
import { getCurrentUser, getCurrentApplication } from '../../context.js';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import search from '@inquirer/search';
|
|
7
|
+
import { isTTY, FormatAuto } from '../../output.js';
|
|
8
|
+
|
|
9
|
+
function vowelArticle(word) {
|
|
10
|
+
const first = word.charAt(0).toLowerCase();
|
|
11
|
+
return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildHints(name, singular, readOnly) {
|
|
15
|
+
const crumbs = [];
|
|
16
|
+
if (!readOnly) {
|
|
17
|
+
crumbs.push({
|
|
18
|
+
action: 'create',
|
|
19
|
+
cmd: `jsn dev ${name} create --name ... --label "..."`,
|
|
20
|
+
description: `Create ${vowelArticle(singular)} ${singular}`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
crumbs.push({
|
|
24
|
+
action: 'show',
|
|
25
|
+
cmd: `jsn dev ${name} show <name_or_sys_id>`,
|
|
26
|
+
description: `Show ${singular} details`,
|
|
27
|
+
});
|
|
28
|
+
return crumbs;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toSingular(name, explicitSingular) {
|
|
32
|
+
if (explicitSingular) return explicitSingular;
|
|
33
|
+
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
|
34
|
+
if (name.endsWith('es') && !name.endsWith('ses')) return name.slice(0, -2);
|
|
35
|
+
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getCurrentScope(sdk) {
|
|
40
|
+
try {
|
|
41
|
+
const user = await getCurrentUser(sdk);
|
|
42
|
+
if (!user) return 'global';
|
|
43
|
+
const app = await getCurrentApplication(sdk, user.sys_id);
|
|
44
|
+
return app?.scope || 'global';
|
|
45
|
+
} catch {
|
|
46
|
+
return 'global';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function checkScope(sdk, recordScope) {
|
|
51
|
+
const currentScope = await getCurrentScope(sdk);
|
|
52
|
+
if (currentScope === 'global') return null;
|
|
53
|
+
if (currentScope === recordScope) return null;
|
|
54
|
+
return { currentScope, recordScope };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {}) {
|
|
58
|
+
const showFields = opts.showFields !== undefined ? opts.showFields : null;
|
|
59
|
+
const singular = toSingular(name, opts.singular);
|
|
60
|
+
const readOnly = opts.readOnly || false;
|
|
61
|
+
const scopeValidation = opts.scopeValidation || false;
|
|
62
|
+
const showSummary = opts.showSummary || ((record, id) => `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${getStringField(record, 'name') || id}`);
|
|
63
|
+
const showBreadcrumbs = opts.showBreadcrumbs || ((record, id) => {
|
|
64
|
+
const crumbs = [
|
|
65
|
+
{ action: 'list', cmd: `jsn dev ${name} list`, description: `Back to all ${name}` },
|
|
66
|
+
];
|
|
67
|
+
if (!readOnly) {
|
|
68
|
+
crumbs.unshift(
|
|
69
|
+
{ action: 'delete', cmd: `jsn dev ${name} delete ${id}`, description: `Delete this ${singular}` },
|
|
70
|
+
{ action: 'update', cmd: `jsn dev ${name} update ${id} --data '{...}'`, description: `Update this ${singular}` }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return crumbs;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const builder = (yargs) => {
|
|
77
|
+
let y = yargs
|
|
78
|
+
.command({
|
|
79
|
+
command: 'list',
|
|
80
|
+
aliases: ['ls'],
|
|
81
|
+
describe: `List ${name}`,
|
|
82
|
+
builder: (y) => y
|
|
83
|
+
.option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true^priority=1")' })
|
|
84
|
+
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "name,label,super_class")' })
|
|
85
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
|
|
86
|
+
handler: wrap(async (argv, app) => {
|
|
87
|
+
const query = argv.query || '';
|
|
88
|
+
const columns = argv.columns ? argv.columns.split(',') : defaultColumns;
|
|
89
|
+
const limit = argv.limit;
|
|
90
|
+
|
|
91
|
+
// Interactive picker in TTY with auto format and no explicit query
|
|
92
|
+
const interactive = isTTY(process.stdout) && isTTY(process.stdin);
|
|
93
|
+
if (interactive && app.output.getFormat() === FormatAuto && !query) {
|
|
94
|
+
const pickerColumns = ['sys_id', 'name', ...columns.filter(c => c !== 'name' && c !== 'sys_id')];
|
|
95
|
+
const pickerParams = new URLSearchParams();
|
|
96
|
+
pickerParams.set('sysparm_limit', String(limit));
|
|
97
|
+
pickerParams.set('sysparm_display_value', 'all');
|
|
98
|
+
pickerParams.set('sysparm_fields', pickerColumns.join(','));
|
|
99
|
+
pickerParams.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
|
|
100
|
+
|
|
101
|
+
const pickerRecords = await app.sdk.list(table, pickerParams);
|
|
102
|
+
if (pickerRecords.length === 0) {
|
|
103
|
+
app.ok({
|
|
104
|
+
table,
|
|
105
|
+
count: 0,
|
|
106
|
+
columns: pickerColumns,
|
|
107
|
+
records: [],
|
|
108
|
+
pagination: { limit, offset: 0 },
|
|
109
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
110
|
+
}, {
|
|
111
|
+
summary: `0 ${name}(s)`,
|
|
112
|
+
breadcrumbs: buildHints(name, singular, readOnly),
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const choices = pickerRecords.map(r => {
|
|
118
|
+
const recordName = getStringField(r, 'name') || getStringField(r, 'sys_id');
|
|
119
|
+
const scope = getStringField(r, 'sys_scope') || '';
|
|
120
|
+
let label = recordName;
|
|
121
|
+
if (scope && scope !== 'global') label += ` [${scope}]`;
|
|
122
|
+
return { name: label, value: recordName };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let selectedName;
|
|
126
|
+
try {
|
|
127
|
+
selectedName = await search({
|
|
128
|
+
message: `Select ${vowelArticle(singular)} ${singular}:`,
|
|
129
|
+
source: async (input) => {
|
|
130
|
+
if (!input) return choices;
|
|
131
|
+
const term = input.toLowerCase();
|
|
132
|
+
return choices.filter(c => c.name.toLowerCase().includes(term));
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (selectedName) {
|
|
143
|
+
const showParams = new URLSearchParams();
|
|
144
|
+
showParams.set('sysparm_query', `name=${selectedName}`);
|
|
145
|
+
showParams.set('sysparm_display_value', 'all');
|
|
146
|
+
showParams.set('sysparm_limit', '1');
|
|
147
|
+
const showRecords = await app.sdk.list(table, showParams);
|
|
148
|
+
if (showRecords.length === 0) {
|
|
149
|
+
throw new Error(`${singular} not found: ${selectedName}`);
|
|
150
|
+
}
|
|
151
|
+
const record = showRecords[0];
|
|
152
|
+
record._context = { instance_url: app.getEffectiveInstance(), table };
|
|
153
|
+
if (opts.onShow) {
|
|
154
|
+
await opts.onShow(record, app);
|
|
155
|
+
}
|
|
156
|
+
app.ok(record, {
|
|
157
|
+
summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${selectedName}`,
|
|
158
|
+
breadcrumbs: [
|
|
159
|
+
...(readOnly ? [] : [{ action: 'update', cmd: `jsn dev ${name} update ${selectedName} --data '{...}'`, description: `Update this ${singular}` }]),
|
|
160
|
+
{ action: 'list', cmd: `jsn dev ${name} list`, description: `Back to all ${name}` },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Non-interactive: text/table output
|
|
168
|
+
const params = new URLSearchParams();
|
|
169
|
+
params.set('sysparm_limit', String(limit));
|
|
170
|
+
params.set('sysparm_display_value', 'all');
|
|
171
|
+
params.set('sysparm_fields', ['sys_id', ...columns].join(','));
|
|
172
|
+
const q = query ? query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
173
|
+
params.set('sysparm_query', q);
|
|
174
|
+
const records = await app.sdk.list(table, params);
|
|
175
|
+
app.ok({
|
|
176
|
+
table,
|
|
177
|
+
count: records.length,
|
|
178
|
+
columns,
|
|
179
|
+
records: records.map(r => formatRecordForDisplay(r, columns)),
|
|
180
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
181
|
+
}, {
|
|
182
|
+
summary: `${records.length} ${name}(s)`,
|
|
183
|
+
breadcrumbs: buildHints(name, singular, readOnly),
|
|
184
|
+
});
|
|
185
|
+
}),
|
|
186
|
+
})
|
|
187
|
+
.command({
|
|
188
|
+
command: 'show <identifier>',
|
|
189
|
+
aliases: ['get'],
|
|
190
|
+
describe: `Show ${vowelArticle(singular)} ${singular} by name or sys_id`,
|
|
191
|
+
handler: wrap(async (argv, app) => {
|
|
192
|
+
const id = argv.identifier;
|
|
193
|
+
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
194
|
+
const params = new URLSearchParams();
|
|
195
|
+
params.set('sysparm_query', `${queryField}=${id}`);
|
|
196
|
+
params.set('sysparm_limit', '1');
|
|
197
|
+
params.set('sysparm_display_value', 'all');
|
|
198
|
+
// Only restrict sysparm_fields if showFields is explicitly set.
|
|
199
|
+
// Go version fetches all fields for show unless explicitly restricted.
|
|
200
|
+
if (showFields && showFields.length > 0) {
|
|
201
|
+
params.set('sysparm_fields', [...new Set(['sys_id', ...showFields])].join(','));
|
|
202
|
+
}
|
|
203
|
+
const records = await app.sdk.list(table, params);
|
|
204
|
+
if (records.length === 0) {
|
|
205
|
+
throw new Error(`${singular} not found: ${id}`);
|
|
206
|
+
}
|
|
207
|
+
records[0]._context = {
|
|
208
|
+
instance_url: app.getEffectiveInstance(),
|
|
209
|
+
table,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (opts.onShow) {
|
|
213
|
+
await opts.onShow(records[0], app);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const summary = typeof showSummary === 'function' ? showSummary(records[0], id) : showSummary;
|
|
217
|
+
const breadcrumbs = typeof showBreadcrumbs === 'function' ? showBreadcrumbs(records[0], id) : showBreadcrumbs;
|
|
218
|
+
|
|
219
|
+
app.ok(records[0], { summary, breadcrumbs });
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!readOnly) {
|
|
224
|
+
y = y
|
|
225
|
+
.command({
|
|
226
|
+
command: 'create',
|
|
227
|
+
describe: `Create a new ${singular}`,
|
|
228
|
+
builder: (y) => y
|
|
229
|
+
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
230
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
|
|
231
|
+
handler: wrap(async (argv, app) => {
|
|
232
|
+
const recordData = parseDataArg(argv);
|
|
233
|
+
const record = await app.sdk.create(table, recordData);
|
|
234
|
+
app.ok(record, {
|
|
235
|
+
summary: `Created ${singular}`,
|
|
236
|
+
breadcrumbs: [
|
|
237
|
+
{ action: 'show', cmd: `jsn dev ${name} show ${getStringField(record, 'name') || getStringField(record, 'sys_id')}`, description: `View the new ${singular}` },
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
}),
|
|
241
|
+
})
|
|
242
|
+
.command({
|
|
243
|
+
command: 'update <identifier>',
|
|
244
|
+
describe: `Update ${vowelArticle(singular)} ${singular}`,
|
|
245
|
+
builder: (y) => y
|
|
246
|
+
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
247
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' }),
|
|
248
|
+
handler: wrap(async (argv, app) => {
|
|
249
|
+
const id = argv.identifier;
|
|
250
|
+
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
251
|
+
const findParams = new URLSearchParams();
|
|
252
|
+
findParams.set('sysparm_query', `${queryField}=${id}`);
|
|
253
|
+
findParams.set('sysparm_limit', '1');
|
|
254
|
+
findParams.set('sysparm_display_value', 'all');
|
|
255
|
+
const records = await app.sdk.list(table, findParams);
|
|
256
|
+
if (records.length === 0) {
|
|
257
|
+
throw new Error(`${singular} not found: ${id}`);
|
|
258
|
+
}
|
|
259
|
+
const sysID = getStringField(records[0], 'sys_id');
|
|
260
|
+
const recordScope = getStringField(records[0], 'sys_scope');
|
|
261
|
+
|
|
262
|
+
if (scopeValidation) {
|
|
263
|
+
const scopeErr = await checkScope(app.sdk, recordScope);
|
|
264
|
+
if (scopeErr) {
|
|
265
|
+
throw new Error(`record is in scope '${scopeErr.recordScope}', but your current scope is '${scopeErr.currentScope}'. Switch scope first: jsn dev scopes set ${scopeErr.recordScope}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const recordData = parseDataArg(argv);
|
|
270
|
+
const updated = await app.sdk.update(table, sysID, recordData);
|
|
271
|
+
app.ok(updated, { summary: `Updated ${singular} ${id}` });
|
|
272
|
+
}),
|
|
273
|
+
})
|
|
274
|
+
.command({
|
|
275
|
+
command: 'delete <identifier>',
|
|
276
|
+
describe: `Delete ${vowelArticle(singular)} ${singular}`,
|
|
277
|
+
builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
278
|
+
handler: wrap(async (argv, app) => {
|
|
279
|
+
const id = argv.identifier;
|
|
280
|
+
const queryField = isHexString(id) && id.length === 32 ? 'sys_id' : 'name';
|
|
281
|
+
const findParams = new URLSearchParams();
|
|
282
|
+
findParams.set('sysparm_query', `${queryField}=${id}`);
|
|
283
|
+
findParams.set('sysparm_limit', '1');
|
|
284
|
+
findParams.set('sysparm_display_value', 'all');
|
|
285
|
+
const records = await app.sdk.list(table, findParams);
|
|
286
|
+
if (records.length === 0) {
|
|
287
|
+
throw new Error(`${singular} not found: ${id}`);
|
|
288
|
+
}
|
|
289
|
+
const sysID = getStringField(records[0], 'sys_id');
|
|
290
|
+
const recordName = getStringField(records[0], 'name') || id;
|
|
291
|
+
const recordScope = getStringField(records[0], 'sys_scope');
|
|
292
|
+
|
|
293
|
+
if (scopeValidation) {
|
|
294
|
+
const scopeErr = await checkScope(app.sdk, recordScope);
|
|
295
|
+
if (scopeErr) {
|
|
296
|
+
throw new Error(`record is in scope '${scopeErr.recordScope}', but your current scope is '${scopeErr.currentScope}'. Switch scope first: jsn dev scopes set ${scopeErr.recordScope}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!argv.force && process.stdout.isTTY && process.stdin.isTTY) {
|
|
301
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
302
|
+
const answer = await new Promise((resolve) => {
|
|
303
|
+
rl.question(`Delete ${singular} '${recordName}'? (y/N): `, resolve);
|
|
304
|
+
});
|
|
305
|
+
rl.close();
|
|
306
|
+
const response = answer.trim().toLowerCase();
|
|
307
|
+
if (response !== 'y' && response !== 'yes') {
|
|
308
|
+
throw new Error('Deletion cancelled');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await app.sdk.delete(table, sysID);
|
|
313
|
+
app.ok({ name: recordName, sys_id: sysID, deleted: true }, { summary: `Deleted ${singular} '${recordName}'` });
|
|
314
|
+
}),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return y;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
command: `${name} [subcommand]`,
|
|
323
|
+
aliases: aliases || [],
|
|
324
|
+
describe: `Manage ${name} (e.g. "${name} list --query nameLIKEincident")`,
|
|
325
|
+
builder,
|
|
326
|
+
handler: (_argv) => {
|
|
327
|
+
// This handler only runs when no subcommand is matched (the default case).
|
|
328
|
+
// Show help for the subcommands available.
|
|
329
|
+
console.log(`Manage ${name} from the ${table} table.`);
|
|
330
|
+
console.log('');
|
|
331
|
+
console.log('Available subcommands:');
|
|
332
|
+
console.log(' list List all records');
|
|
333
|
+
console.log(' show Show record details');
|
|
334
|
+
if (!readOnly) {
|
|
335
|
+
console.log(' create Create a new record');
|
|
336
|
+
console.log(' update Update a record');
|
|
337
|
+
console.log(' delete Delete a record');
|
|
338
|
+
}
|
|
339
|
+
console.log(`\nRun "jsn dev ${name} <command> --help" for details.`);
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|