@jacebenson/jsn 4.3.12 → 4.3.13-dev.100.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 +55 -0
- package/package.json +2 -1
- 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 +24 -4
- package/src/cli.js +39 -9
- package/src/command-audience.js +35 -0
- package/src/commands/_generic.js +58 -37
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +38 -31
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/changes.js +2 -4
- 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/groups.js +11 -8
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +64 -119
- package/src/commands/requests.js +10 -16
- package/src/commands/rest.js +15 -2
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +72 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +31 -22
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +65 -13
- package/src/commands/users.js +11 -8
- 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 +10 -2
- package/src/helpers.js +44 -0
- 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/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
|
|
|
@@ -205,9 +197,9 @@ export function skillCmd(wrap) {
|
|
|
205
197
|
})
|
|
206
198
|
.command({
|
|
207
199
|
command: 'check',
|
|
208
|
-
describe: '
|
|
200
|
+
describe: 'Compare installed skill contents with the packaged skill',
|
|
209
201
|
handler: wrap(async (_argv, app) => {
|
|
210
|
-
const result = checkSkill();
|
|
202
|
+
const result = checkSkill({ warn: false });
|
|
211
203
|
|
|
212
204
|
let summary;
|
|
213
205
|
if (result.error) {
|
|
@@ -215,21 +207,21 @@ export function skillCmd(wrap) {
|
|
|
215
207
|
} else if (result.note) {
|
|
216
208
|
summary = result.note;
|
|
217
209
|
} else if (result.current) {
|
|
218
|
-
summary = `✓ Skill
|
|
210
|
+
summary = `✓ Skill contents match the packaged copy (v${result.bundled_version})`;
|
|
219
211
|
} else {
|
|
220
|
-
summary = `⚠ Skill
|
|
212
|
+
summary = `⚠ Skill contents differ — run "jsn skill install" to replace installed copies`;
|
|
221
213
|
}
|
|
222
214
|
|
|
223
|
-
|
|
215
|
+
const details = result.copies?.map(copy => `${copy.status}: ${copy.location}${copy.first_different_line ? ` (first difference: line ${copy.first_different_line})` : ''}`).join('\n');
|
|
216
|
+
app.ok({ ...result, _formatted: [summary, details].filter(Boolean).join('\n') }, { summary });
|
|
224
217
|
}),
|
|
225
218
|
})
|
|
226
219
|
.command({
|
|
227
220
|
command: 'fetch',
|
|
228
|
-
describe: '
|
|
221
|
+
describe: 'Print the packaged skill to stdout (offline)',
|
|
229
222
|
handler: wrap(async (_argv, _app) => {
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
232
|
-
const content = await res.text();
|
|
223
|
+
const content = readBundledSkill();
|
|
224
|
+
if (content === null) throw new Error('Bundled skill missing; reinstall jsn.');
|
|
233
225
|
process.stdout.write(content);
|
|
234
226
|
}),
|
|
235
227
|
})
|
|
@@ -246,7 +238,6 @@ export function skillCmd(wrap) {
|
|
|
246
238
|
app.ok({
|
|
247
239
|
bundled: SKILL_REPO_PATH,
|
|
248
240
|
targets: allPaths,
|
|
249
|
-
raw_url: SKILL_RAW_URL,
|
|
250
241
|
}, {
|
|
251
242
|
summary: 'Skill file locations and install targets',
|
|
252
243
|
breadcrumbs: [
|
|
@@ -260,7 +251,7 @@ export function skillCmd(wrap) {
|
|
|
260
251
|
})
|
|
261
252
|
.command({
|
|
262
253
|
command: 'install [dir]',
|
|
263
|
-
describe: '
|
|
254
|
+
describe: 'Install the bundled skill matched to this CLI version',
|
|
264
255
|
builder: (y) => y
|
|
265
256
|
.positional('dir', {
|
|
266
257
|
type: 'string',
|
|
@@ -268,12 +259,13 @@ export function skillCmd(wrap) {
|
|
|
268
259
|
})
|
|
269
260
|
.option('target', {
|
|
270
261
|
type: 'string',
|
|
271
|
-
describe: 'Target agent(s): hermes, copilot, vscode, claude, cursor, agents, opencode, openclaw, codex, or "all" (comma-separated). Omit
|
|
262
|
+
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
263
|
}),
|
|
273
264
|
handler: wrap(async (argv, app) => {
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
276
|
-
|
|
265
|
+
const content = readBundledSkill();
|
|
266
|
+
if (!content) {
|
|
267
|
+
throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md). Reinstall jsn to restore it.');
|
|
268
|
+
}
|
|
277
269
|
|
|
278
270
|
// Resolve target directories
|
|
279
271
|
let targets = [];
|
|
@@ -283,7 +275,7 @@ export function skillCmd(wrap) {
|
|
|
283
275
|
const p = path.resolve(argv.dir);
|
|
284
276
|
fs.mkdirSync(p, { recursive: true });
|
|
285
277
|
const targetPath = path.join(p, 'SKILL.md');
|
|
286
|
-
|
|
278
|
+
replaceSkill(targetPath, content);
|
|
287
279
|
targets.push({ name: path.basename(argv.dir), path: targetPath });
|
|
288
280
|
} else if (argv.target) {
|
|
289
281
|
// Explicit --target: install to specified agents
|
|
@@ -296,38 +288,41 @@ export function skillCmd(wrap) {
|
|
|
296
288
|
if (key === 'cursor') {
|
|
297
289
|
fs.mkdirSync(dir, { recursive: true });
|
|
298
290
|
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
299
|
-
|
|
291
|
+
replaceSkill(targetPath, content);
|
|
300
292
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
301
293
|
} else {
|
|
302
294
|
fs.mkdirSync(dir, { recursive: true });
|
|
303
295
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
304
|
-
|
|
296
|
+
replaceSkill(targetPath, content);
|
|
305
297
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
306
298
|
}
|
|
307
299
|
}
|
|
308
300
|
}
|
|
301
|
+
} else if (loadSkillConfig().skillLocation) {
|
|
302
|
+
const cfg = loadSkillConfig();
|
|
303
|
+
for (const targetPath of cfg.skillLocations || [cfg.skillLocation]) {
|
|
304
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
305
|
+
replaceSkill(targetPath, content);
|
|
306
|
+
targets.push({ name: targetPath, path: targetPath });
|
|
307
|
+
}
|
|
309
308
|
} else if (canUsePicker(app, argv)) {
|
|
310
309
|
// Interactive: show multi-select picker. canUsePicker() (not a
|
|
311
310
|
// bare stdin TTY check) keeps a PTY allocated for an agent,
|
|
312
311
|
// CI, JSN_NO_PROMPTS, --json/--quiet or --get out of a prompt.
|
|
313
|
-
const fetchedVersion = extractVersion(content);
|
|
314
312
|
// Check if any existing installed copies are outdated
|
|
315
313
|
const outdated = [];
|
|
316
|
-
|
|
317
|
-
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
314
|
+
for (const [key, dir] of Object.entries(AGENT_SKILL_DIRS)) {
|
|
318
315
|
const skillPath = key === 'cursor'
|
|
319
316
|
? path.join(dir, `${SKILL_NAME}.mdc`)
|
|
320
317
|
: path.join(dir, 'SKILL.md');
|
|
321
318
|
try {
|
|
322
319
|
const existing = fs.readFileSync(skillPath, 'utf-8');
|
|
323
|
-
|
|
324
|
-
if (existingVersion && existingVersion !== fetchedVersion) {
|
|
320
|
+
if (existing !== content) {
|
|
325
321
|
outdated.push(TARGET_NAMES[key]);
|
|
326
322
|
}
|
|
327
323
|
} catch {
|
|
328
324
|
// Not installed — skip
|
|
329
325
|
}
|
|
330
|
-
}
|
|
331
326
|
}
|
|
332
327
|
const promptMsg = outdated.length > 0
|
|
333
328
|
? `Where should the jsn skill be installed? (${outdated.length} outdated)`
|
|
@@ -366,12 +361,12 @@ export function skillCmd(wrap) {
|
|
|
366
361
|
if (key === 'cursor') {
|
|
367
362
|
fs.mkdirSync(dir, { recursive: true });
|
|
368
363
|
const targetPath = path.join(dir, `${SKILL_NAME}.mdc`);
|
|
369
|
-
|
|
364
|
+
replaceSkill(targetPath, content);
|
|
370
365
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
371
366
|
} else {
|
|
372
367
|
fs.mkdirSync(dir, { recursive: true });
|
|
373
368
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
374
|
-
|
|
369
|
+
replaceSkill(targetPath, content);
|
|
375
370
|
targets.push({ name: TARGET_NAMES[key], path: targetPath });
|
|
376
371
|
}
|
|
377
372
|
}
|
|
@@ -380,7 +375,7 @@ export function skillCmd(wrap) {
|
|
|
380
375
|
const dir = AGENT_SKILL_DIRS.hermes;
|
|
381
376
|
fs.mkdirSync(dir, { recursive: true });
|
|
382
377
|
const targetPath = path.join(dir, 'SKILL.md');
|
|
383
|
-
|
|
378
|
+
replaceSkill(targetPath, content);
|
|
384
379
|
targets.push({ name: TARGET_NAMES.hermes, path: targetPath });
|
|
385
380
|
}
|
|
386
381
|
|
|
@@ -389,7 +384,7 @@ export function skillCmd(wrap) {
|
|
|
389
384
|
}
|
|
390
385
|
|
|
391
386
|
// Record install location so checkSkill knows where to look
|
|
392
|
-
recordSkillInstall(targets
|
|
387
|
+
recordSkillInstall(targets.map(target => target.path), content);
|
|
393
388
|
|
|
394
389
|
const installed = targets.reduce((acc, t) => { acc[t.name] = t.path; return acc; }, {});
|
|
395
390
|
const summary = targets.length === 1
|
|
@@ -399,7 +394,7 @@ export function skillCmd(wrap) {
|
|
|
399
394
|
const okOpts = {
|
|
400
395
|
summary,
|
|
401
396
|
breadcrumbs: [
|
|
402
|
-
{ action: 'reinstall', cmd: 'jsn skill install', description: '
|
|
397
|
+
{ action: 'reinstall', cmd: 'jsn skill install', description: 'Replace recorded copies with the packaged skill' },
|
|
403
398
|
],
|
|
404
399
|
};
|
|
405
400
|
|
|
@@ -407,7 +402,7 @@ export function skillCmd(wrap) {
|
|
|
407
402
|
okOpts.notice = 'Installed to multiple agents. Restart or reload your agent to pick up the skill.';
|
|
408
403
|
}
|
|
409
404
|
|
|
410
|
-
app.ok({ installed, from:
|
|
405
|
+
app.ok({ installed, from: SKILL_REPO_PATH }, okOpts);
|
|
411
406
|
}),
|
|
412
407
|
});
|
|
413
408
|
},
|
|
@@ -416,7 +411,8 @@ export function skillCmd(wrap) {
|
|
|
416
411
|
console.log('');
|
|
417
412
|
console.log('Available subcommands:');
|
|
418
413
|
console.log(' show Show the bundled skill file');
|
|
419
|
-
console.log(' fetch
|
|
414
|
+
console.log(' fetch Print the packaged skill (offline)');
|
|
415
|
+
console.log(' check Show whether installed contents differ');
|
|
420
416
|
console.log(' path Show skill file locations');
|
|
421
417
|
console.log(' install Install skill file to agents directory');
|
|
422
418
|
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,25 +17,20 @@ 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
|
-
{ action: 'create', cmd: 'jsn tickets create
|
|
33
|
+
{ action: 'create', cmd: 'jsn tickets create -f short_description=New ticket', description: 'Create a new ticket' },
|
|
37
34
|
];
|
|
38
35
|
if (records.length === argv.limit) {
|
|
39
36
|
breadcrumbs.push({
|
|
@@ -63,38 +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)
|
|
74
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
75
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
76
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
77
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
75
78
|
handler: wrap(async (argv, app) => {
|
|
76
|
-
const recordData =
|
|
77
|
-
const record = await app.sdk
|
|
79
|
+
const recordData = buildRecordPayload(argv);
|
|
80
|
+
const record = await createRecord(app.sdk, { table: 'task', data: recordData });
|
|
78
81
|
app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
|
|
79
82
|
}),
|
|
80
83
|
})
|
|
81
84
|
.command({
|
|
82
85
|
command: 'update <number>',
|
|
83
86
|
describe: 'Update a ticket',
|
|
84
|
-
builder: (y) => y
|
|
87
|
+
builder: (y) => recordOptions(y, false)
|
|
88
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
89
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
90
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
91
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
85
92
|
handler: wrap(async (argv, app) => {
|
|
86
|
-
const recordData =
|
|
87
|
-
const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
|
|
88
|
-
const updated = await app.sdk
|
|
93
|
+
const recordData = buildRecordPayload(argv);
|
|
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 });
|
|
89
96
|
app.ok(updated, { summary: `Updated ticket ${argv.number}` });
|
|
90
97
|
}),
|
|
91
98
|
})
|
|
92
99
|
.command({
|
|
93
100
|
command: 'delete <number>',
|
|
94
101
|
describe: 'Delete a ticket',
|
|
102
|
+
builder: y => recordOptions(y, false).option('force', { type: 'boolean', default: false, describe: 'Skip confirmation after approval' }),
|
|
95
103
|
handler: wrap(async (argv, app) => {
|
|
96
|
-
|
|
97
|
-
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 } });
|
|
98
107
|
app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
|
|
99
108
|
}),
|
|
100
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)". */
|
|
@@ -381,7 +385,7 @@ export async function formatUpdateSetDetail(app, record) {
|
|
|
381
385
|
});
|
|
382
386
|
hints.push({
|
|
383
387
|
action: 'export',
|
|
384
|
-
cmd: `jsn updatesets export "${name}" --out ${
|
|
388
|
+
cmd: `jsn updatesets export "${name}" --out ${quoteShellArg(updateSetFilename(name))}`,
|
|
385
389
|
description: 'Export this update set to XML',
|
|
386
390
|
});
|
|
387
391
|
if (!isClosed) {
|
|
@@ -424,7 +428,7 @@ export function updateSetsCmd(wrap) {
|
|
|
424
428
|
return {
|
|
425
429
|
command: 'updatesets [subcommand]',
|
|
426
430
|
aliases: ['updateset', 'us'],
|
|
427
|
-
describe: 'Manage update sets',
|
|
431
|
+
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
432
|
builder: (yargs) => {
|
|
429
433
|
return yargs
|
|
430
434
|
.command({
|
|
@@ -477,11 +481,14 @@ export function updateSetsCmd(wrap) {
|
|
|
477
481
|
})
|
|
478
482
|
.command({
|
|
479
483
|
command: 'export <name>',
|
|
480
|
-
describe: 'Export
|
|
484
|
+
describe: 'Export XML without changing state; auto-name a file in a terminal, preserve piped stdout',
|
|
481
485
|
builder: (y) => y
|
|
482
486
|
.positional('name', { describe: 'Update set name', type: 'string' })
|
|
483
|
-
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
487
|
+
.option('out', { alias: 'o', type: 'string', describe: 'Write XML to this file instead of stdout' })
|
|
488
|
+
.option('out-dir', { type: 'string', describe: 'Write an automatically named XML file in this directory' })
|
|
489
|
+
.option('stdout', { type: 'boolean', describe: 'Write raw XML to stdout, including in a terminal' }),
|
|
484
490
|
handler: wrap(async (argv, app) => {
|
|
491
|
+
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
492
|
app.requireInstance();
|
|
486
493
|
const record = await resolveUpdateSetByName(app, argv.name);
|
|
487
494
|
const sysID = getStringField(record, 'sys_id');
|
|
@@ -503,14 +510,16 @@ export function updateSetsCmd(wrap) {
|
|
|
503
510
|
}
|
|
504
511
|
|
|
505
512
|
const bytes = Buffer.byteLength(xml, 'utf-8');
|
|
506
|
-
|
|
507
|
-
|
|
513
|
+
const out = argv.out || (argv.outDir || (process.stdout.isTTY && !argv.stdout && !argv.json && argv.format !== 'json')
|
|
514
|
+
? path.join(argv.outDir || '.', updateSetFilename(getStringField(record, 'name'))) : null);
|
|
515
|
+
if (out) {
|
|
516
|
+
await fs.promises.writeFile(out, xml, { flag: 'wx' });
|
|
508
517
|
app.ok({
|
|
509
518
|
update_set: getStringField(record, 'name'),
|
|
510
519
|
sys_id: sysID,
|
|
511
|
-
out
|
|
520
|
+
out,
|
|
512
521
|
bytes,
|
|
513
|
-
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${
|
|
522
|
+
}, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${out}` });
|
|
514
523
|
return;
|
|
515
524
|
}
|
|
516
525
|
|
|
@@ -518,7 +527,7 @@ export function updateSetsCmd(wrap) {
|
|
|
518
527
|
// (`jsn updatesets export "X" > set.xml`). Only wrap in the JSON
|
|
519
528
|
// envelope when the user explicitly asks for it — auto format
|
|
520
529
|
// detection must NOT hijack the payload for piped consumers.
|
|
521
|
-
if (argv.json || argv.format === 'json') {
|
|
530
|
+
if (!argv.stdout && (argv.json || argv.format === 'json')) {
|
|
522
531
|
app.ok({
|
|
523
532
|
update_set: getStringField(record, 'name'),
|
|
524
533
|
sys_id: sysID,
|
|
@@ -530,6 +539,47 @@ export function updateSetsCmd(wrap) {
|
|
|
530
539
|
process.stdout.write(xml + (xml.endsWith('\n') ? '' : '\n'));
|
|
531
540
|
}),
|
|
532
541
|
})
|
|
542
|
+
.command({
|
|
543
|
+
command: 'import <file>',
|
|
544
|
+
describe: 'Load XML as a retrieved update set; never preview, commit, or select it automatically',
|
|
545
|
+
builder: y => y
|
|
546
|
+
.positional('file', { type: 'string', describe: 'Update set XML export' })
|
|
547
|
+
.option('dry-run', { type: 'boolean', describe: 'Inspect locally without any instance requests' })
|
|
548
|
+
.option('yes', { type: 'boolean', describe: 'Confirm loading the displayed XML into the selected target' }),
|
|
549
|
+
handler: wrap(async (argv, app) => {
|
|
550
|
+
const stat = await fs.promises.stat(argv.file);
|
|
551
|
+
if (!stat.isFile() || stat.size > MAX_UPDATE_SET_BYTES) throw errUsage('Expected an XML file no larger than 50 MiB');
|
|
552
|
+
const xml = await fs.promises.readFile(argv.file, 'utf8');
|
|
553
|
+
const inspection = inspectUpdateSetXML(xml);
|
|
554
|
+
const { members, ...summary } = inspection;
|
|
555
|
+
if (argv.dryRun) return app.ok({ ...summary, members, dry_run: true }, { summary: `XML: ${inspection.name}; ${inspection.count} updates (local inspection only)` });
|
|
556
|
+
app.requireInstance();
|
|
557
|
+
const question = `Load "${inspection.name}" (${inspection.count} updates) into ${app.getEffectiveInstance()} as a retrieved update set? This does not commit its changes.`;
|
|
558
|
+
if (!argv.yes) {
|
|
559
|
+
if (!canPrompt() || argv.json || argv.get || (argv.format && argv.format !== FormatAuto)) {
|
|
560
|
+
throw new AppError(CodeConfirmationRequired, `${question} — confirmation required. Pass --yes after approving this target.`,
|
|
561
|
+
`Rerun: ${buildRerunCommand(argv, { rerun: ['updatesets', 'import', argv.file, '--yes'] })}`);
|
|
562
|
+
}
|
|
563
|
+
if (!await confirm({ message: question, default: false })) return;
|
|
564
|
+
}
|
|
565
|
+
const result = await app.sdk.importUpdateSet(xml, inspection, path.basename(argv.file));
|
|
566
|
+
app.ok({ ...summary, ...result, committed: false }, { summary: `Loaded retrieved update set "${inspection.name}" (${result.count} updates); not committed` });
|
|
567
|
+
}),
|
|
568
|
+
})
|
|
569
|
+
.command({
|
|
570
|
+
command: 'reopen <name>',
|
|
571
|
+
describe: 'Explicitly return a complete or ignored local update set to in progress; does not select it',
|
|
572
|
+
handler: wrap(async (argv, app) => {
|
|
573
|
+
const target = await resolveCompletionTarget(app, argv.name);
|
|
574
|
+
const record = await fetchUpdateSetById(app, target.sys_id);
|
|
575
|
+
const state = String(record?.state?.value ?? record?.state ?? '').toLowerCase();
|
|
576
|
+
if (!['complete', 'ignore'].includes(state)) throw errUsage(`Cannot reopen an update set in state "${state}"; expected complete or ignore`);
|
|
577
|
+
await app.sdk.update('sys_update_set', target.sys_id, { state: 'in progress' });
|
|
578
|
+
const mismatches = await verifyWriteBack(app, 'sys_update_set', target.sys_id, { state: 'in progress' });
|
|
579
|
+
if (mismatches.length) throw errAPI(200, 'Reopen could not be verified; inspect the update set before retrying');
|
|
580
|
+
app.ok({ update_set: target.name, sys_id: target.sys_id, state: 'in progress' }, { summary: `Reopened update set: ${target.name}` });
|
|
581
|
+
}),
|
|
582
|
+
})
|
|
533
583
|
.command({
|
|
534
584
|
command: 'set [name]',
|
|
535
585
|
describe: 'Set the current update set (interactive picker with scope when run bare)',
|
|
@@ -737,6 +787,8 @@ export function updateSetsCmd(wrap) {
|
|
|
737
787
|
console.log(' list List update sets (shows scope)');
|
|
738
788
|
console.log(' show <name> Show an update set (type counts + risky items)');
|
|
739
789
|
console.log(' export <name> Export an update set to XML (stdout or --out)');
|
|
790
|
+
console.log(' import <file> Load retrieved XML (--dry-run inspects locally; --yes confirms)');
|
|
791
|
+
console.log(' reopen <name> Explicitly return a closed set to in progress');
|
|
740
792
|
console.log(' set [name] Set the current update set (picker when run bare)');
|
|
741
793
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
742
794
|
console.log(' complete [name] Complete a named or current set with --notes (editor when omitted)');
|