@jacebenson/jsn 4.3.13 → 4.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/package.json +4 -3
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/forms.js +22 -97
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- package/src/commands/rest.js +15 -2
- package/src/commands/scan.js +234 -0
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +12 -2
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
package/src/commands/skill.js
CHANGED
|
@@ -18,8 +18,6 @@ const SKILL_REPO_PATH = path.join(
|
|
|
18
18
|
'..', '..', 'skills', SKILL_NAME, 'SKILL.md'
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/main/skills/servicenow/SKILL.md';
|
|
22
|
-
|
|
23
21
|
/**
|
|
24
22
|
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
25
23
|
*/
|
|
@@ -119,62 +117,56 @@ function saveSkillConfig(updates) {
|
|
|
119
117
|
fs.writeFileSync(globalConfigPath(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
120
118
|
}
|
|
121
119
|
|
|
122
|
-
function
|
|
123
|
-
const
|
|
124
|
-
|
|
120
|
+
function replaceSkill(targetPath, content) {
|
|
121
|
+
const temporary = `${targetPath}.${process.pid}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(temporary, content, { encoding: 'utf-8', mode: 0o600 });
|
|
124
|
+
fs.renameSync(temporary, targetPath);
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(temporary, { force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function recordSkillInstall(targetPaths, content) {
|
|
131
|
+
const version = extractVersion(content) || 'unknown';
|
|
125
132
|
saveSkillConfig({
|
|
126
|
-
skillLocation:
|
|
133
|
+
skillLocation: targetPaths[0],
|
|
134
|
+
skillLocations: targetPaths,
|
|
127
135
|
skillVersion: version,
|
|
128
136
|
skillLastChecked: new Date().toISOString(),
|
|
129
137
|
});
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
export function checkSkill() {
|
|
140
|
+
// Compare bytes, including edits that retain the same version number.
|
|
141
|
+
export function checkSkill({ warn = true } = {}) {
|
|
135
142
|
const cfg = loadSkillConfig();
|
|
136
|
-
const
|
|
137
|
-
if (!location) return { current: true, note: 'Skill not installed — run "jsn skill install"' };
|
|
138
|
-
|
|
139
|
-
// Throttle: skip if checked within last 24 hours
|
|
140
|
-
if (cfg.skillLastChecked) {
|
|
141
|
-
const elapsed = Date.now() - new Date(cfg.skillLastChecked).getTime();
|
|
142
|
-
if (elapsed < 86400000) return { current: true, note: 'Checked within 24h — skipping' };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let installed;
|
|
146
|
-
try {
|
|
147
|
-
installed = fs.readFileSync(location, 'utf-8');
|
|
148
|
-
} catch {
|
|
149
|
-
return { current: true, note: `Skill file not found at ${location} — run "jsn skill install"` };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const installedVersion = extractVersion(installed);
|
|
143
|
+
const locations = cfg.skillLocations || (cfg.skillLocation ? [cfg.skillLocation] : []);
|
|
153
144
|
const bundled = readBundledSkill();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
145
|
+
if (bundled === null) return { current: false, error: 'Bundled skill missing; reinstall jsn.' };
|
|
146
|
+
if (!locations.length) return { current: false, note: 'Skill not installed — run "jsn skill install"' };
|
|
147
|
+
const copies = locations.map(location => {
|
|
148
|
+
let installed;
|
|
149
|
+
try { installed = fs.readFileSync(location, 'utf-8'); } catch {
|
|
150
|
+
return { location, current: false, status: 'missing' };
|
|
151
|
+
}
|
|
152
|
+
const current = installed === bundled;
|
|
153
|
+
const actual = installed.split('\n');
|
|
154
|
+
const expected = bundled.split('\n');
|
|
155
|
+
const line = current ? undefined : Array.from({ length: Math.max(actual.length, expected.length) }, (_, i) => i)
|
|
156
|
+
.find(i => actual[i] !== expected[i]) + 1;
|
|
157
|
+
return { location, current, status: current ? 'matching' : 'different',
|
|
158
|
+
installed_version: extractVersion(installed), first_different_line: line };
|
|
159
|
+
});
|
|
160
|
+
const current = copies.every(copy => copy.current);
|
|
161
|
+
if (!current && warn) process.stderr.write('ServiceNow skill differs from the packaged copy. Run "jsn skill check" for locations or "jsn skill install" to replace it.\n');
|
|
170
162
|
saveSkillConfig({ skillLastChecked: new Date().toISOString() });
|
|
171
|
-
|
|
172
|
-
|
|
163
|
+
return { current, installed_version: copies[0].installed_version,
|
|
164
|
+
bundled_version: extractVersion(bundled), copies };
|
|
173
165
|
}
|
|
174
166
|
|
|
175
167
|
function extractVersion(content) {
|
|
176
168
|
// Matches version in YAML frontmatter (top-level or nested under metadata:)
|
|
177
|
-
const m = content.match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
|
|
169
|
+
const m = (content || '').match(/^\s*version:\s*["']?(.+?)["']?\s*$/m);
|
|
178
170
|
return m ? m[1] : null;
|
|
179
171
|
}
|
|
180
172
|
|
|
@@ -197,6 +189,7 @@ export function skillCmd(wrap) {
|
|
|
197
189
|
}
|
|
198
190
|
app.ok({
|
|
199
191
|
content,
|
|
192
|
+
_formatted: content.endsWith('\n') ? content : `${content}\n`,
|
|
200
193
|
bundled: SKILL_REPO_PATH,
|
|
201
194
|
}, {
|
|
202
195
|
summary: 'jsn AI agent skill file (bundled)',
|
|
@@ -205,9 +198,9 @@ export function skillCmd(wrap) {
|
|
|
205
198
|
})
|
|
206
199
|
.command({
|
|
207
200
|
command: 'check',
|
|
208
|
-
describe: '
|
|
201
|
+
describe: 'Compare installed skill contents with the packaged skill',
|
|
209
202
|
handler: wrap(async (_argv, app) => {
|
|
210
|
-
const result = checkSkill();
|
|
203
|
+
const result = checkSkill({ warn: false });
|
|
211
204
|
|
|
212
205
|
let summary;
|
|
213
206
|
if (result.error) {
|
|
@@ -215,21 +208,21 @@ export function skillCmd(wrap) {
|
|
|
215
208
|
} else if (result.note) {
|
|
216
209
|
summary = result.note;
|
|
217
210
|
} else if (result.current) {
|
|
218
|
-
summary = `✓ Skill
|
|
211
|
+
summary = `✓ Skill contents match the packaged copy (v${result.bundled_version})`;
|
|
219
212
|
} else {
|
|
220
|
-
summary = `⚠ Skill
|
|
213
|
+
summary = `⚠ Skill contents differ — run "jsn skill install" to replace installed copies`;
|
|
221
214
|
}
|
|
222
215
|
|
|
223
|
-
|
|
216
|
+
const details = result.copies?.map(copy => `${copy.status}: ${copy.location}${copy.first_different_line ? ` (first difference: line ${copy.first_different_line})` : ''}`).join('\n');
|
|
217
|
+
app.ok({ ...result, _formatted: [summary, details].filter(Boolean).join('\n') }, { summary });
|
|
224
218
|
}),
|
|
225
219
|
})
|
|
226
220
|
.command({
|
|
227
221
|
command: 'fetch',
|
|
228
|
-
describe: '
|
|
222
|
+
describe: 'Print the packaged skill to stdout (offline)',
|
|
229
223
|
handler: wrap(async (_argv, _app) => {
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
const content = await res.text();
|
|
224
|
+
const content = readBundledSkill();
|
|
225
|
+
if (content === null) throw new Error('Bundled skill missing; reinstall jsn.');
|
|
233
226
|
process.stdout.write(content);
|
|
234
227
|
}),
|
|
235
228
|
})
|
|
@@ -246,7 +239,6 @@ export function skillCmd(wrap) {
|
|
|
246
239
|
app.ok({
|
|
247
240
|
bundled: SKILL_REPO_PATH,
|
|
248
241
|
targets: allPaths,
|
|
249
|
-
raw_url: SKILL_RAW_URL,
|
|
250
242
|
}, {
|
|
251
243
|
summary: 'Skill file locations and install targets',
|
|
252
244
|
breadcrumbs: [
|
|
@@ -260,7 +252,7 @@ export function skillCmd(wrap) {
|
|
|
260
252
|
})
|
|
261
253
|
.command({
|
|
262
254
|
command: 'install [dir]',
|
|
263
|
-
describe: '
|
|
255
|
+
describe: 'Install the bundled skill matched to this CLI version',
|
|
264
256
|
builder: (y) => y
|
|
265
257
|
.positional('dir', {
|
|
266
258
|
type: 'string',
|
|
@@ -268,12 +260,13 @@ export function skillCmd(wrap) {
|
|
|
268
260
|
})
|
|
269
261
|
.option('target', {
|
|
270
262
|
type: 'string',
|
|
271
|
-
describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit
|
|
263
|
+
describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit to replace recorded copies, or choose targets on first install.',
|
|
272
264
|
}),
|
|
273
265
|
handler: wrap(async (argv, app) => {
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
276
|
-
|
|
266
|
+
const content = readBundledSkill();
|
|
267
|
+
if (!content) {
|
|
268
|
+
throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md). Reinstall jsn to restore it.');
|
|
269
|
+
}
|
|
277
270
|
|
|
278
271
|
// Resolve target directories
|
|
279
272
|
let targets = [];
|
|
@@ -283,7 +276,7 @@ export function skillCmd(wrap) {
|
|
|
283
276
|
const p = path.resolve(argv.dir);
|
|
284
277
|
fs.mkdirSync(p, { recursive: true });
|
|
285
278
|
const targetPath = path.join(p, 'SKILL.md');
|
|
286
|
-
|
|
279
|
+
replaceSkill(targetPath, content);
|
|
287
280
|
targets.push({ name: path.basename(argv.dir), path: targetPath });
|
|
288
281
|
} else if (argv.target) {
|
|
289
282
|
// Explicit --target: install to specified agents
|
|
@@ -296,38 +289,41 @@ export function skillCmd(wrap) {
|
|
|
296
289
|
if (key === 'cursor') {
|
|
297
290
|
fs.mkdirSync(dir, { recursive: true });
|
|
298
291
|
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
299
|
-
|
|
292
|
+
replaceSkill(targetPath, content);
|
|
300
293
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
301
294
|
} else {
|
|
302
295
|
fs.mkdirSync(dir, { recursive: true });
|
|
303
296
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
304
|
-
|
|
297
|
+
replaceSkill(targetPath, content);
|
|
305
298
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
306
299
|
}
|
|
307
300
|
}
|
|
308
301
|
}
|
|
302
|
+
} else if (loadSkillConfig().skillLocation) {
|
|
303
|
+
const cfg = loadSkillConfig();
|
|
304
|
+
for (const targetPath of cfg.skillLocations || [cfg.skillLocation]) {
|
|
305
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
306
|
+
replaceSkill(targetPath, content);
|
|
307
|
+
targets.push({ name: targetPath, path: targetPath });
|
|
308
|
+
}
|
|
309
309
|
} else if (canUsePicker(app, argv)) {
|
|
310
310
|
// Interactive: show multi-select picker. canUsePicker() (not a
|
|
311
311
|
// bare stdin TTY check) keeps a PTY allocated for an agent,
|
|
312
312
|
// CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
|
|
313
|
-
const fetchedVersion = extractVersion(content);
|
|
314
313
|
// Check if any existing installed copies are outdated
|
|
315
314
|
const outdated = [];
|
|
316
|
-
|
|
317
|
-
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
315
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
318
316
|
const skillPath = key === 'cursor'
|
|
319
317
|
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
320
318
|
: path.join(dir, 'SKILL.md');
|
|
321
319
|
try {
|
|
322
320
|
const existing = fs.readFileSync(skillPath, 'utf-8');
|
|
323
|
-
|
|
324
|
-
if (existingVersion && existingVersion !== fetchedVersion) {
|
|
321
|
+
if (existing !== content) {
|
|
325
322
|
outdated.push(TARGET_NAMES[key]);
|
|
326
323
|
}
|
|
327
324
|
} catch {
|
|
328
325
|
// Not installed — skip
|
|
329
326
|
}
|
|
330
|
-
}
|
|
331
327
|
}
|
|
332
328
|
const promptMsg = outdated.length > 0
|
|
333
329
|
? `Where should the jsn skill be installed? (${outdated.length} outdated)`
|
|
@@ -366,12 +362,12 @@ export function skillCmd(wrap) {
|
|
|
366
362
|
if (key === 'cursor') {
|
|
367
363
|
fs.mkdirSync(dir, { recursive: true });
|
|
368
364
|
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
369
|
-
|
|
365
|
+
replaceSkill(targetPath, content);
|
|
370
366
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
371
367
|
} else {
|
|
372
368
|
fs.mkdirSync(dir, { recursive: true });
|
|
373
369
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
374
|
-
|
|
370
|
+
replaceSkill(targetPath, content);
|
|
375
371
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
376
372
|
}
|
|
377
373
|
}
|
|
@@ -380,7 +376,7 @@ export function skillCmd(wrap) {
|
|
|
380
376
|
const dir = AGENT_SKILL_DIRS.hermes;
|
|
381
377
|
fs.mkdirSync(dir, { recursive: true });
|
|
382
378
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
383
|
-
|
|
379
|
+
replaceSkill(targetPath, content);
|
|
384
380
|
targets.push({ name: TARGET_NAMES.hermes, path: targetPath });
|
|
385
381
|
}
|
|
386
382
|
|
|
@@ -389,7 +385,7 @@ export function skillCmd(wrap) {
|
|
|
389
385
|
}
|
|
390
386
|
|
|
391
387
|
// Record install location so checkSkill knows where to look
|
|
392
|
-
recordSkillInstall(targets
|
|
388
|
+
recordSkillInstall(targets.map(target => target.path), content);
|
|
393
389
|
|
|
394
390
|
const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
|
|
395
391
|
const summary = targets.length === 1
|
|
@@ -399,7 +395,7 @@ export function skillCmd(wrap) {
|
|
|
399
395
|
const okOpts = {
|
|
400
396
|
summary,
|
|
401
397
|
breadcrumbs: [
|
|
402
|
-
{ action: 'reinstall', cmd: 'jsn skill install', description: '
|
|
398
|
+
{ action: 'reinstall', cmd: 'jsn skill install', description: 'Replace recorded copies with the packaged skill' },
|
|
403
399
|
],
|
|
404
400
|
};
|
|
405
401
|
|
|
@@ -407,7 +403,7 @@ export function skillCmd(wrap) {
|
|
|
407
403
|
okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
|
|
408
404
|
}
|
|
409
405
|
|
|
410
|
-
app.ok({ installed, from:
|
|
406
|
+
app.ok({ installed, from: SKILL_REPO_PATH }, okOpts);
|
|
411
407
|
}),
|
|
412
408
|
});
|
|
413
409
|
},
|
|
@@ -416,7 +412,8 @@ export function skillCmd(wrap) {
|
|
|
416
412
|
console.log('');
|
|
417
413
|
console.log('Available subcommands:');
|
|
418
414
|
console.log(' show Show the bundled skill file');
|
|
419
|
-
console.log(' fetch
|
|
415
|
+
console.log(' fetch Print the packaged skill (offline)');
|
|
416
|
+
console.log(' check Show whether installed contents differ');
|
|
420
417
|
console.log(' path Show skill file locations');
|
|
421
418
|
console.log(' install Install skill file to agents directory');
|
|
422
419
|
console.log('');
|
package/src/commands/tasks.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
const taskDefaultColumns = ['number', 'short_description', 'state', 'assigned_to'];
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
4
2
|
|
|
5
3
|
export function tasksCmd(wrap) {
|
|
6
|
-
return
|
|
4
|
+
return buildProfileCommand('tasks', wrap);
|
|
7
5
|
}
|
package/src/commands/tickets.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { enrichRelatedLists, relatedListOptions } from '../related-lists.js';
|
|
2
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload, confirmDelete } from '../helpers.js';
|
|
3
|
+
import { getRecord, listRecords, recordListParams, createRecord, updateRecord, deleteRecord, recordOptions } from '../records-engine.js';
|
|
4
|
+
import { resolveSysId } from '../resolve-record.js';
|
|
3
5
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
6
|
|
|
5
7
|
declareCapabilities('tickets', { mutationSubcommands: ['create', 'update', 'delete'] });
|
|
@@ -15,23 +17,18 @@ export function ticketsCmd(wrap) {
|
|
|
15
17
|
command: 'list',
|
|
16
18
|
aliases: ['ls'],
|
|
17
19
|
describe: 'List tickets',
|
|
18
|
-
builder: (y) => y
|
|
20
|
+
builder: (y) => recordOptions(y)
|
|
19
21
|
.option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
|
|
20
22
|
.option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
|
|
21
23
|
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
|
|
22
24
|
.option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
|
|
23
25
|
handler: wrap(async (argv, app) => {
|
|
24
26
|
const columns = argv.columns ? argv.columns.split(',') : ['number', 'short_description', 'state', 'assigned_to'];
|
|
25
|
-
const params = new URLSearchParams();
|
|
26
|
-
params.set('sysparm_limit', String(argv.limit));
|
|
27
|
-
params.set('sysparm_offset', String(argv.offset));
|
|
28
|
-
params.set('sysparm_display_value', 'all');
|
|
29
27
|
const fields = resolveFieldsParam(columns);
|
|
30
|
-
if (fields) params.set('sysparm_fields', fields);
|
|
31
28
|
const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
|
|
32
|
-
params.
|
|
33
|
-
const records = await app.sdk
|
|
34
|
-
const displayRecords = fields ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
29
|
+
const params = recordListParams({ limit: argv.limit, offset: argv.offset, fields, query: q });
|
|
30
|
+
const records = await listRecords(app.sdk, { table: 'task', params, displayValues: argv.displayValues });
|
|
31
|
+
const displayRecords = fields && !argv.displayValues ? records.map(r => formatRecordForDisplay(r, columns)) : records;
|
|
35
32
|
const breadcrumbs = [
|
|
36
33
|
{ action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
|
|
37
34
|
];
|
|
@@ -63,46 +60,50 @@ export function ticketsCmd(wrap) {
|
|
|
63
60
|
command: 'show <number>',
|
|
64
61
|
aliases: ['get'],
|
|
65
62
|
describe: 'Show a ticket',
|
|
63
|
+
builder: y => relatedListOptions(recordOptions(y)),
|
|
66
64
|
handler: wrap(async (argv, app) => {
|
|
67
|
-
const record = await
|
|
65
|
+
const record = await getRecord(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
66
|
+
await enrichRelatedLists(record, { table: 'task', argv, app });
|
|
68
67
|
app.ok(record, { summary: `Ticket ${argv.number}` });
|
|
69
68
|
}),
|
|
70
69
|
})
|
|
71
70
|
.command({
|
|
72
71
|
command: 'create',
|
|
73
72
|
describe: 'Create a new ticket',
|
|
74
|
-
builder: (y) => y
|
|
73
|
+
builder: (y) => recordOptions(y, false)
|
|
75
74
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
76
75
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
77
76
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
78
77
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
79
78
|
handler: wrap(async (argv, app) => {
|
|
80
79
|
const recordData = buildRecordPayload(argv);
|
|
81
|
-
const record = await app.sdk
|
|
80
|
+
const record = await createRecord(app.sdk, { table: 'task', data: recordData });
|
|
82
81
|
app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
|
|
83
82
|
}),
|
|
84
83
|
})
|
|
85
84
|
.command({
|
|
86
85
|
command: 'update <number>',
|
|
87
86
|
describe: 'Update a ticket',
|
|
88
|
-
builder: (y) => y
|
|
87
|
+
builder: (y) => recordOptions(y, false)
|
|
89
88
|
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
90
89
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
91
90
|
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
92
91
|
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
93
92
|
handler: wrap(async (argv, app) => {
|
|
94
93
|
const recordData = buildRecordPayload(argv);
|
|
95
|
-
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
96
|
-
const updated = await app.sdk
|
|
94
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
95
|
+
const updated = await updateRecord(app.sdk, { table: 'task', record: { sys_id: sysID }, data: recordData });
|
|
97
96
|
app.ok(updated, { summary: `Updated ticket ${argv.number}` });
|
|
98
97
|
}),
|
|
99
98
|
})
|
|
100
99
|
.command({
|
|
101
100
|
command: 'delete <number>',
|
|
102
101
|
describe: 'Delete a ticket',
|
|
102
|
+
builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
|
|
103
103
|
handler: wrap(async (argv, app) => {
|
|
104
|
-
|
|
105
|
-
await app.sdk
|
|
104
|
+
await confirmDelete(app, argv, `Delete ticket ${argv.number}`);
|
|
105
|
+
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, identifierField: argv.identifierField, displayValues: argv.displayValues, matchField: 'number', resource: 'Ticket' });
|
|
106
|
+
await deleteRecord(app.sdk, { table: 'task', record: { sys_id: sysID } });
|
|
106
107
|
app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
|
|
107
108
|
}),
|
|
108
109
|
})
|
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { getStringField } from '../helpers.js';
|
|
1
|
+
import { buildProfileCommand } from '../record-profiles.js';
|
|
3
2
|
|
|
4
|
-
export const uipoliciesCmd =
|
|
5
|
-
['short_description', 'table', 'sys_class_name', 'active'], wrap, {
|
|
6
|
-
singular: 'UI policy',
|
|
7
|
-
scopeValidation: true,
|
|
8
|
-
onShow: async (record, app) => {
|
|
9
|
-
const sysID = getStringField(record, 'sys_id') || '';
|
|
10
|
-
if (!sysID) return;
|
|
11
|
-
const mp = new URLSearchParams();
|
|
12
|
-
mp.set('sysparm_query', `ui_policy=${sysID}^ORDERBYorder`);
|
|
13
|
-
mp.set('sysparm_limit', '100'); mp.set('sysparm_display_value', 'all');
|
|
14
|
-
mp.set('sysparm_fields', 'name,table,field_name,read_only,mandatory,visible,sys_id');
|
|
15
|
-
const actions = await app.sdk.list('sys_ui_policy_action', mp);
|
|
16
|
-
if (actions.length > 0) {
|
|
17
|
-
console.log(` Actions (${actions.length}):`);
|
|
18
|
-
for (const a of actions) {
|
|
19
|
-
const f = getStringField(a, 'field_name') || '(all fields)';
|
|
20
|
-
const flags = [];
|
|
21
|
-
if (getStringField(a, 'mandatory') === 'true') flags.push('mandatory');
|
|
22
|
-
if (getStringField(a, 'read_only') === 'true') flags.push('read-only');
|
|
23
|
-
if (getStringField(a, 'visible') === 'false') flags.push('hidden');
|
|
24
|
-
console.log(` ${f} ${flags.length ? `[${flags.join(', ')}]` : ''}`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
});
|
|
3
|
+
export const uipoliciesCmd = wrap => buildProfileCommand('uipolicies', wrap);
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inspectUpdateSetXML, updateSetFilename, MAX_UPDATE_SET_BYTES } from '../update-set-xml.js';
|
|
2
4
|
import { editor, confirm } from '../prompts.js';
|
|
3
|
-
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
|
|
5
|
+
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack, quoteShellArg } from '../helpers.js';
|
|
4
6
|
import { getActiveProfile } from '../config.js';
|
|
5
7
|
import { FormatAuto } from '../output.js';
|
|
6
8
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
7
|
-
import { errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
9
|
+
import { AppError, CodeConfirmationRequired, errAPI, errUsage, errConfirmationRequired } from '../errors.js';
|
|
8
10
|
import { declareCapabilities } from '../capabilities.js';
|
|
9
11
|
import { resolveScope } from './scopes.js';
|
|
10
12
|
import { unwrapSysId } from '../resolve-record.js';
|
|
11
13
|
|
|
12
14
|
declareCapabilities('updatesets', {
|
|
13
|
-
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent'],
|
|
15
|
+
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent', 'import', 'reopen'],
|
|
16
|
+
mutationContextExemptSubcommands: ['create', 'set', 'import', 'reopen'],
|
|
17
|
+
localPreviewSubcommands: ['import'],
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -27,7 +31,7 @@ export function scopeMismatchWarning(updateSetApp, currentApp) {
|
|
|
27
31
|
const updateSetAppId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
|
|
28
32
|
if (!updateSetAppId || !currentApp.appSysId || updateSetAppId === currentApp.appSysId) return '';
|
|
29
33
|
const name = updateSetApp?.display_value || updateSetApp;
|
|
30
|
-
return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes
|
|
34
|
+
return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes may not be captured in this set. Run: jsn scopes set ${quoteShellArg(updateSetAppId)}`;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/** Format a picker label for an update set: "Name [State] (app/scope)". */
|
|
@@ -361,6 +365,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
361
365
|
const stateRaw = String(full.state?.value ?? full.state ?? '').toLowerCase();
|
|
362
366
|
const isClosed = ['complete', 'committed'].includes(stateRaw);
|
|
363
367
|
const hints = [];
|
|
368
|
+
hints.push({ action: 'scan', cmd: `jsn scan run --update-set ${sysID}${app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : ''}`, description: 'Offer an explicit scan of current records before completing or promoting this set' });
|
|
364
369
|
if (isClosed) {
|
|
365
370
|
hints.push({
|
|
366
371
|
action: 'set',
|
|
@@ -381,7 +386,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
381
386
|
});
|
|
382
387
|
hints.push({
|
|
383
388
|
action: 'export',
|
|
384
|
-
cmd: `jsn updatesets export "${name}" --out ${
|
|
389
|
+
cmd: `jsn updatesets export "${name}" --out ${quoteShellArg(updateSetFilename(name))}`,
|
|
385
390
|
description: 'Export this update set to XML',
|
|
386
391
|
});
|
|
387
392
|
if (!isClosed) {
|
|
@@ -424,7 +429,7 @@ export function updateSetsCmd(wrap) {
|
|
|
424
429
|
return {
|
|
425
430
|
command: 'updatesets [subcommand]',
|
|
426
431
|
aliases: ['updateset', 'us'],
|
|
427
|
-
describe: 'Manage update sets',
|
|
432
|
+
describe: 'Manage update sets. Global + Default mutations require approval (--force only after user approval); create/set remain available to select a dedicated capture set. Scope mismatches warn; completed capture sets block writes.',
|
|
428
433
|
builder: (yargs) => {
|
|
429
434
|
return yargs
|
|
430
435
|
.command({
|
|
@@ -477,11 +482,14 @@ export function updateSetsCmd(wrap) {
|
|
|
477
482
|
})
|
|
478
483
|
.command({
|
|
479
484
|
command: 'export <name>',
|
|
480
|
-
describe: 'Export
|
|
485
|
+
describe: 'Export XML without changing state; auto-name a file in a terminal, preserve piped stdout',
|
|
481
486
|
builder: (y) => y
|
|
482
487
|
.positional('name', { describe: 'Update set name', type: 'string' })
|
|
483
|
-
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
488
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
489
|
+
.option('out-dir', { type: 'string', describe: 'Write an automatically named XML file in this directory' })
|
|
490
|
+
.option('stdout', { type: 'boolean', describe: 'Write raw XML to stdout, including in a terminal' }),
|
|
484
491
|
handler: wrap(async (argv, app) => {
|
|
492
|
+
if ([Boolean(argv.out), Boolean(argv.outDir), Boolean(argv.stdout)].filter(Boolean).length > 1) throw errUsage('Choose one of --out, --out-dir, or --stdout');
|
|
485
493
|
app.requireInstance();
|
|
486
494
|
const record = await resolveUpdateSetByName(app, argv.name);
|
|
487
495
|
const sysID = getStringField(record, 'sys_id');
|
|
@@ -503,14 +511,16 @@ export function updateSetsCmd(wrap) {
|
|
|
503
511
|
}
|
|
504
512
|
|
|
505
513
|
const bytes = Buffer.byteLength(xml, 'utf-8');
|
|
506
|
-
|
|
507
|
-
|
|
514
|
+
const out = argv.out || (argv.outDir || (process.stdout.isTTY && !argv.stdout && !argv.json && argv.format !== 'json')
|
|
515
|
+
? path.join(argv.outDir || '.', updateSetFilename(getStringField(record, 'name'))) : null);
|
|
516
|
+
if (out) {
|
|
517
|
+
await fs.promises.writeFile(out, xml, { flag: 'wx' });
|
|
508
518
|
app.ok({
|
|
509
519
|
update_set: getStringField(record, 'name'),
|
|
510
520
|
sys_id: sysID,
|
|
511
|
-
out
|
|
521
|
+
out,
|
|
512
522
|
bytes,
|
|
513
|
-
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${
|
|
523
|
+
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${out}` });
|
|
514
524
|
return;
|
|
515
525
|
}
|
|
516
526
|
|
|
@@ -518,7 +528,7 @@ export function updateSetsCmd(wrap) {
|
|
|
518
528
|
// (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
|
|
519
529
|
// envelope when the user explicitly asks for it — auto format
|
|
520
530
|
// detection must NOT hijack the payload for piped consumers.
|
|
521
|
-
if (argv.json || argv.format === 'json') {
|
|
531
|
+
if (!argv.stdout && (argv.json || argv.format === 'json')) {
|
|
522
532
|
app.ok({
|
|
523
533
|
update_set: getStringField(record, 'name'),
|
|
524
534
|
sys_id: sysID,
|
|
@@ -530,6 +540,47 @@ export function updateSetsCmd(wrap) {
|
|
|
530
540
|
process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
|
|
531
541
|
}),
|
|
532
542
|
})
|
|
543
|
+
.command({
|
|
544
|
+
command: 'import <file>',
|
|
545
|
+
describe: 'Load XML as a retrieved update set; never preview, commit, or select it automatically',
|
|
546
|
+
builder: y => y
|
|
547
|
+
.positional('file', { type: 'string', describe: 'Update set XML export' })
|
|
548
|
+
.option('dry-run', { type: 'boolean', describe: 'Inspect locally without any instance requests' })
|
|
549
|
+
.option('yes', { type: 'boolean', describe: 'Confirm loading the displayed XML into the selected target' }),
|
|
550
|
+
handler: wrap(async (argv, app) => {
|
|
551
|
+
const stat = await fs.promises.stat(argv.file);
|
|
552
|
+
if (!stat.isFile() || stat.size > MAX_UPDATE_SET_BYTES) throw errUsage('Expected an XML file no larger than 50 MiB');
|
|
553
|
+
const xml = await fs.promises.readFile(argv.file, 'utf8');
|
|
554
|
+
const inspection = inspectUpdateSetXML(xml);
|
|
555
|
+
const { members, ...summary } = inspection;
|
|
556
|
+
if (argv.dryRun) return app.ok({ ...summary, members, dry_run: true }, { summary: `XML: ${inspection.name}; ${inspection.count} updates (local inspection only)` });
|
|
557
|
+
app.requireInstance();
|
|
558
|
+
const question = `Load "${inspection.name}" (${inspection.count} updates) into ${app.getEffectiveInstance()} as a retrieved update set? This does not commit its changes.`;
|
|
559
|
+
if (!argv.yes) {
|
|
560
|
+
if (!canPrompt() || argv.json || argv.get || (argv.format && argv.format !== FormatAuto)) {
|
|
561
|
+
throw new AppError(CodeConfirmationRequired, `${question} — confirmation required. Pass --yes after approving this target.`,
|
|
562
|
+
`Rerun: ${buildRerunCommand(argv, { rerun: ['updatesets', 'import', argv.file, '--yes'] })}`);
|
|
563
|
+
}
|
|
564
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
565
|
+
}
|
|
566
|
+
const result = await app.sdk.importUpdateSet(xml, inspection, path.basename(argv.file));
|
|
567
|
+
app.ok({ ...summary, ...result, committed: false }, { summary: `Loaded retrieved update set "${inspection.name}" (${result.count} updates); not committed` });
|
|
568
|
+
}),
|
|
569
|
+
})
|
|
570
|
+
.command({
|
|
571
|
+
command: 'reopen <name>',
|
|
572
|
+
describe: 'Explicitly return a complete or ignored local update set to in progress; does not select it',
|
|
573
|
+
handler: wrap(async (argv, app) => {
|
|
574
|
+
const target = await resolveCompletionTarget(app, argv.name);
|
|
575
|
+
const record = await fetchUpdateSetById(app, target.sys_id);
|
|
576
|
+
const state = String(record?.state?.value ?? record?.state ?? '').toLowerCase();
|
|
577
|
+
if (!['complete', 'ignore'].includes(state)) throw errUsage(`Cannot reopen an update set in state "${state}"; expected complete or ignore`);
|
|
578
|
+
await app.sdk.update('sys_update_set', target.sys_id, { state: 'in progress' });
|
|
579
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', target.sys_id, { state: 'in progress' });
|
|
580
|
+
if (mismatches.length) throw errAPI(200, 'Reopen could not be verified; inspect the update set before retrying');
|
|
581
|
+
app.ok({ update_set: target.name, sys_id: target.sys_id, state: 'in progress' }, { summary: `Reopened update set: ${target.name}` });
|
|
582
|
+
}),
|
|
583
|
+
})
|
|
533
584
|
.command({
|
|
534
585
|
command: 'set [name]',
|
|
535
586
|
describe: 'Set the current update set (interactive picker with scope when run bare)',
|
|
@@ -737,6 +788,8 @@ export function updateSetsCmd(wrap) {
|
|
|
737
788
|
console.log(' list List update sets (shows scope)');
|
|
738
789
|
console.log(' show <name> Show an update set (type counts + risky items)');
|
|
739
790
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
791
|
+
console.log(' import <file> Load retrieved XML (--dry-run inspects locally; --yes confirms)');
|
|
792
|
+
console.log(' reopen <name> Explicitly return a closed set to in progress');
|
|
740
793
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
741
794
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
742
795
|
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|