@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.
Files changed (80) hide show
  1. package/README.md +137 -0
  2. package/package.json +4 -3
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +12 -0
  9. package/src/cli.js +45 -9
  10. package/src/command-audience.js +38 -0
  11. package/src/commands/_generic.js +45 -28
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +24 -20
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/catalog-order.js +93 -0
  18. package/src/commands/catalog.js +8 -10
  19. package/src/commands/changes.js +2 -4
  20. package/src/commands/cmdb-search.js +38 -0
  21. package/src/commands/cmdb.js +2 -0
  22. package/src/commands/columns.js +2 -1
  23. package/src/commands/customizations.js +13 -7
  24. package/src/commands/discover.js +54 -0
  25. package/src/commands/docs/db.js +9 -1
  26. package/src/commands/docs/docs.js +25 -2
  27. package/src/commands/docs/ingest.js +20 -3
  28. package/src/commands/docs/install.js +86 -0
  29. package/src/commands/docs/locks.js +34 -0
  30. package/src/commands/docs/refresh.js +22 -2
  31. package/src/commands/docs/replace.js +30 -0
  32. package/src/commands/docs/snapshot-check.js +11 -0
  33. package/src/commands/docs/snapshot.js +130 -0
  34. package/src/commands/docs/sync.js +16 -6
  35. package/src/commands/forms.js +22 -97
  36. package/src/commands/history.js +57 -0
  37. package/src/commands/incidents.js +2 -4
  38. package/src/commands/knowledge.js +75 -0
  39. package/src/commands/midservers.js +117 -10
  40. package/src/commands/records.js +57 -104
  41. package/src/commands/requests.js +9 -15
  42. package/src/commands/rest.js +15 -2
  43. package/src/commands/scan.js +234 -0
  44. package/src/commands/scopes.js +1 -1
  45. package/src/commands/setup.js +46 -9
  46. package/src/commands/skill.js +73 -76
  47. package/src/commands/tasks.js +2 -4
  48. package/src/commands/tickets.js +20 -19
  49. package/src/commands/uipolicies.js +2 -27
  50. package/src/commands/updatesets.js +66 -13
  51. package/src/commands/upgrade.js +69 -0
  52. package/src/commands/version.js +14 -8
  53. package/src/constrained-query.js +20 -0
  54. package/src/context.js +6 -6
  55. package/src/customization-guidance.js +31 -0
  56. package/src/customizations-report.js +6 -0
  57. package/src/customizations.js +28 -13
  58. package/src/errors.js +23 -1
  59. package/src/essential-records.js +40 -0
  60. package/src/field-behavior.js +15 -0
  61. package/src/help.js +12 -2
  62. package/src/metadata/records.js +249 -0
  63. package/src/midserver-linux-remote.js +210 -0
  64. package/src/midserver-remote-recipe.js +49 -0
  65. package/src/midserver-remote.js +221 -0
  66. package/src/midserver-windows-remote.js +318 -0
  67. package/src/midserver-zip.js +2 -2
  68. package/src/mutation-context.js +74 -0
  69. package/src/mutation-history.js +75 -0
  70. package/src/mutations.js +3 -2
  71. package/src/output.js +34 -2
  72. package/src/record-command-profiles.js +70 -0
  73. package/src/record-profiles.js +93 -0
  74. package/src/records-engine.js +57 -0
  75. package/src/related-lists.js +153 -0
  76. package/src/resolve-record.js +50 -28
  77. package/src/scan.js +249 -0
  78. package/src/sdk-batch.js +130 -0
  79. package/src/sdk.js +157 -18
  80. package/src/update-set-xml.js +89 -0
@@ -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 recordSkillInstall(targetPath) {
123
- const bundled = readBundledSkill();
124
- const version = extractVersion(bundled) || 'unknown';
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: targetPath,
133
+ skillLocation: targetPaths[0],
134
+ skillLocations: targetPaths,
127
135
  skillVersion: version,
128
136
  skillLastChecked: new Date().toISOString(),
129
137
  });
130
138
  }
131
139
 
132
- // ── Skill version check (once per day, uses recorded location) ──
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 location = cfg.skillLocation;
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
- const bundledVersion = extractVersion(bundled);
155
-
156
- if (!installedVersion || !bundledVersion) {
157
- return { current: true, error: 'Could not determine skill version' };
158
- }
159
-
160
- const current = installedVersion === bundledVersion;
161
-
162
- if (!current) {
163
- process.stderr.write(
164
- `\n⚠ jsn: ServiceNow skill is outdated (installed v${installedVersion}, bundled v${bundledVersion}).\n` +
165
- ` Run "jsn skill install" to update.\n\n`
166
- );
167
- }
168
-
169
- // Update last-checked timestamp
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
- return { current, installed_version: installedVersion, bundled_version: bundledVersion };
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: 'Check if the installed skill matches the bundled version',
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 is current (v${result.installed_version} matches bundled v${result.bundled_version})`;
211
+ summary = `✓ Skill contents match the packaged copy (v${result.bundled_version})`;
219
212
  } else {
220
- summary = `⚠ Skill outdated: installed v${result.installed_version} vs bundled v${result.bundled_version} — run "jsn skill install" to update`;
213
+ summary = `⚠ Skill contents differ — run "jsn skill install" to replace installed copies`;
221
214
  }
222
215
 
223
- app.ok(result, { summary });
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: 'Download the latest skill file from GitHub to stdout',
222
+ describe: 'Print the packaged skill to stdout (offline)',
229
223
  handler: wrap(async (_argv, _app) => {
230
- const res = await fetch(SKILL_RAW_URL);
231
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
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: 'Download and save the latest skill file',
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 for interactive picker.',
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 res = await fetch(SKILL_RAW_URL);
275
- if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
276
- const content = await res.text();
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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
- if (fetchedVersion) {
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
- const existingVersion = extractVersion(existing);
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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
- fs.writeFileSync(targetPath, content, 'utf-8');
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[0].path);
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: 'Re-download and reinstall to default target' },
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: SKILL_RAW_URL }, okOpts);
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 Download the latest skill from GitHub');
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('');
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
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 buildTicketCommands('sc_task', 'tasks', 'task', taskDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('tasks', wrap);
7
5
  }
@@ -1,5 +1,7 @@
1
- import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, buildRecordPayload } from '../helpers.js';
2
- import { resolveRecord, resolveSysId } from '../resolve-record.js';
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.set('sysparm_query', q);
33
- const records = await app.sdk.list('task', params);
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 resolveRecord(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
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.create('task', recordData);
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.update('task', sysID, recordData);
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
- const sysID = await resolveSysId(app.sdk, { table: 'task', identifier: argv.number, matchField: 'number', resource: 'Ticket' });
105
- await app.sdk.delete('task', sysID);
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 { buildDevCmd } from './_generic.js';
2
- import { getStringField } from '../helpers.js';
1
+ import { buildProfileCommand } from '../record-profiles.js';
3
2
 
4
- export const uipoliciesCmd = (wrap) => buildDevCmd('uipolicies', 'sys_ui_policy', ['uipolicy', 'up'],
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 won't land here. Run: jsn scopes set ${name}`;
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 ${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.xml`,
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 an update set to XML (raw XML to stdout, or --out file)',
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
- if (argv.out) {
507
- await fs.promises.writeFile(argv.out, xml);
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: argv.out,
521
+ out,
512
522
  bytes,
513
- }, { summary: `Exported update set "${getStringField(record, 'name')}" (${bytes} bytes) to ${argv.out}` });
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)');