@jacebenson/jsn 4.3.8-dev.57 → 4.3.8-dev.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.8-dev.57",
3
+ "version": "4.3.8-dev.58",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,8 +21,8 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
21
21
  const picked = await paginatedSearch({ message, totalCount: runs.length, pageSize: 10, source: async (term = '') => runs.filter(run => `${run.run_id} ${run.label} ${run.instance}`.toLowerCase().includes(term.toLowerCase())).map(run => ({ name: `${run.run_id} ${run.label || '(unlabeled)'} ${run.instance}`, value: run })) });
22
22
  return picked?.value ? requireReport(picked.value.run_id) : null;
23
23
  }
24
- function show(app, report) {
25
- app.ok({ ...report, _formatted: formatReport(report) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
24
+ function show(app, report, options = {}) {
25
+ app.ok({ ...report, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
26
26
  }
27
27
  export function customizationsCmd(wrap) {
28
28
  return {
@@ -50,9 +50,11 @@ export function customizationsCmd(wrap) {
50
50
  app.ok({ runs, count: runs.length, _formatted: formatReportList(runs) }, { summary: `${runs.length} customization capture(s)`, breadcrumbs: [listHint, ...(runs[0] ? [showHint(runs[0].run_id)] : [])] });
51
51
  }),
52
52
  })
53
- .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', handler: wrap(async (argv, app) => {
53
+ .command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
54
+ .option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
55
+ .option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' }), handler: wrap(async (argv, app) => {
54
56
  const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
55
- if (report) show(app, report);
57
+ if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
56
58
  }) })
57
59
  .command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance', handler: wrap(async (argv, app) => {
58
60
  if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
@@ -14,7 +14,7 @@ export const SKIP_DISPOSITIONS = ['4', '104'];
14
14
  const SKIP_QUERY = `dispositionIN${SKIP_DISPOSITIONS.join(',')}`;
15
15
  const SOURCES = {
16
16
  sys_scope: { fields: 'sys_id,name,scope,sys_class_name', query: 'ORDERBYsys_id', countQuery: '', kind: 'inventory read' },
17
- sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
17
+ sys_metadata: { fields: 'sys_id,sys_name,sys_class_name,sys_scope,sys_update_name,sys_updated_on,sys_updated_by', query: 'ORDERBYsys_id', countQuery: '', kind: 'bounded sample' },
18
18
  sys_upgrade_history_log: { fields: 'sys_id,file_name,disposition,resolution_status,sys_updated_on', query: `${SKIP_QUERY}^ORDERBYsys_id`, countQuery: SKIP_QUERY, kind: 'inventory read' },
19
19
  };
20
20
  // The broad sys_metadata read is an arbitrary sys_id-ordered slice of a table
@@ -27,6 +27,19 @@ export const RESOLUTION_CHUNK_SIZE = 25;
27
27
  const RESOLUTION_MAX_PAGES = 3;
28
28
  const value = v => v && typeof v === 'object' ? v.value ?? '' : v ?? '';
29
29
  const text = v => String(value(v));
30
+ const humanize = value => String(value || '').replace(/^sys_/, '').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replaceAll('_', ' ').replace(/\bui\b/gi, 'UI').replace(/\b[a-z]/g, letter => letter.toUpperCase());
31
+ const friendlyTable = table => humanize(table) || 'Unknown type';
32
+ const friendlyArtifactName = (name, updateName, table, sysId) => {
33
+ if (name && name !== sysId && name !== updateName) return /[_]|[a-z][A-Z]/.test(name) ? humanize(name) : name;
34
+ if (updateName) {
35
+ const prefixes = [table, table.replace(/_[^_]+$/, ''), table.replace(/_[^_]+_[^_]+$/, '')].filter(Boolean);
36
+ const prefix = prefixes.find(candidate => updateName.startsWith(`${candidate}_`));
37
+ const fallback = prefix ? updateName.slice(prefix.length + 1) : updateName;
38
+ return humanize(fallback) || 'Name unavailable';
39
+ }
40
+ return sysId ? 'Name unavailable' : 'Unnamed artifact';
41
+ };
42
+ const displayArtifactName = identity => friendlyArtifactName(identity?.name || '', identity?.update_name || '', identity?.table || '', identity?.sys_id || '');
30
43
 
31
44
  // How many rows are available for the same query? The Stats API count is the
32
45
  // only evidence that lets a capture claim completeness; without it coverage
@@ -281,7 +294,7 @@ export function compareReports(baseline, newer) {
281
294
  unknowns };
282
295
  }
283
296
 
284
- export function formatReport(report) {
297
+ function formatReportDetailed(report) {
285
298
  const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, '', 'COVERAGE'];
286
299
  for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}${coverage.kind ? ` [${coverage.kind}]` : ''}: ${coverage.status}, ${coverage.fetched} fetched of ${coverage.available === null || coverage.available === undefined ? 'unknown' : coverage.available} available. ${coverage.reason}`);
287
300
  for (const limitation of report.coverage.limitations) lines.push(`Unknown: ${limitation}`);
@@ -295,7 +308,7 @@ export function formatReport(report) {
295
308
  for (const [category, items] of Object.entries(report.categories)) {
296
309
  lines.push('', `${category.toUpperCase()} (${items.length})`);
297
310
  for (const item of items) {
298
- lines.push(`${item.identity.table}:${item.identity.sys_id} ${item.identity.name}`, ` Scope: ${item.scope.scope || item.scope.sys_id || 'unknown'}`, ` Risk: ${item.risk.rating}. ${item.risk.rationale}`);
311
+ lines.push(`${friendlyTable(item.identity.table)}:${item.identity.sys_id} ${displayArtifactName(item.identity)}`, ` Scope: ${item.scope.scope || item.scope.sys_id || 'unknown'}`, ` Risk: ${item.risk.rating}. ${item.risk.rationale}`);
299
312
  for (const evidence of item.evidence) lines.push(` Evidence: ${evidence.source}:${evidence.sys_id}. ${evidence.basis}`);
300
313
  if (item.reversion) {
301
314
  for (const impact of item.reversion.known_impact) lines.push(` Known impact: ${impact}`);
@@ -308,6 +321,43 @@ export function formatReport(report) {
308
321
  }
309
322
  return lines.join('\n') + '\n';
310
323
  }
324
+ export const CUSTOMIZATION_GROUPS = ['type', 'scope', 'updated-by'];
325
+ function groupKey(item, groupBy) {
326
+ if (groupBy === 'scope') return item.scope?.scope || item.scope?.sys_id || '(unknown scope)';
327
+ if (groupBy === 'updated-by') return item.identity?.updated_by || '(unknown updater)';
328
+ return item.identity?.table || 'unknown';
329
+ }
330
+ export function formatReport(report, { groupBy = 'type', filter = '' } = {}) {
331
+ if (!CUSTOMIZATION_GROUPS.includes(groupBy)) throw new Error(`group-by must be one of: ${CUSTOMIZATION_GROUPS.join(', ')}`);
332
+ if (filter) {
333
+ const filtered = structuredClone(report);
334
+ for (const [category, items] of Object.entries(filtered.categories)) filtered.categories[category] = items.filter(item => {
335
+ const key = groupKey(item, groupBy);
336
+ return key.toLowerCase() === String(filter).toLowerCase() || (groupBy === 'type' && friendlyTable(key).toLowerCase() === String(filter).toLowerCase());
337
+ });
338
+ const text = formatReportDetailed(filtered);
339
+ return `${text}\nFiltered by ${groupBy}: ${filter}\n`;
340
+ }
341
+ const items = Object.values(report.categories).flat();
342
+ const groups = new Map();
343
+ for (const item of items) {
344
+ const key = groupKey(item, groupBy);
345
+ groups.set(key, (groups.get(key) || 0) + 1);
346
+ }
347
+ const label = { type: 'file type', scope: 'scope', 'updated-by': 'updated by' }[groupBy];
348
+ const lines = [`Customization capture ${report.run_id}${report.label ? ` (${report.label})` : ''}`, `Instance: ${report.instance}`, `Status: ${report.status}`, `Artifacts: ${items.length}`, '', `GROUPED BY ${label.toUpperCase()}`];
349
+ if (!groups.size) lines.push('(no artifacts)');
350
+ for (const [key, count] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
351
+ const byCategory = Object.entries(report.categories).map(([category, records]) => [category.replaceAll('_', ' '), records.filter(item => groupKey(item, groupBy) === key).length]).filter(([, total]) => total > 0).map(([category, total]) => `${category} ${total}`).join(', ');
352
+ lines.push(`${groupBy === 'type' ? friendlyTable(key) : key} (${count}) ${byCategory}`);
353
+ }
354
+ lines.push('', 'Expand a group with --filter <group-name>. File-type filters accept the friendly label or table name. Regroup with --group-by scope or --group-by updated-by.', 'Examples:', ` jsn customizations show ${report.run_id} --filter "Script Include"`, ` jsn customizations show ${report.run_id} --group-by scope`, ` jsn customizations show ${report.run_id} --group-by updated-by`);
355
+ lines.push('', 'Coverage and limitations are shared across these artifacts.');
356
+ for (const [source, coverage] of Object.entries(report.coverage.sources)) lines.push(`${source}: ${coverage.status}, ${coverage.fetched}/${coverage.available ?? 'unknown'} rows${coverage.kind ? ` (${coverage.kind})` : ''}`);
357
+ lines.push('Limits: metadata is a bounded sample; skipped-upgrade evidence is historical, not proof of a current OOTB difference. Scope and updater are clues, not proof of ownership.');
358
+ return lines.join('\n') + '\n';
359
+ }
360
+
311
361
  export function formatReportList(runs) {
312
362
  return ['RUN ID STATUS LABEL INSTANCE', ...runs.map(run => `${run.run_id} ${run.status} ${run.label || '(unlabeled)'} ${run.instance}`), ...(runs.length ? [] : ['(no customization captures)'])].join('\n') + '\n';
313
363
  }
@@ -408,7 +458,7 @@ export async function captureCustomizations({ sdk, customerScopes = [], pageSize
408
458
  const scopeId = text(row.sys_scope);
409
459
  const scope = scopes.get(scopeId);
410
460
  const scopeName = text(scope?.scope);
411
- const identity = { sys_id: text(row.sys_id), table: text(row.sys_class_name) || 'unknown', name: text(row.sys_name) || text(row.sys_update_name) || text(row.sys_id), update_name: text(row.sys_update_name) };
461
+ const identity = { sys_id: text(row.sys_id), table: text(row.sys_class_name) || 'unknown', name: text(row.sys_name) || text(row.sys_update_name) || text(row.sys_id), update_name: text(row.sys_update_name), updated_by: text(row.sys_updated_by) || null };
412
462
  const evidence = [{ source: 'sys_metadata', sys_id: identity.sys_id, basis: 'Visible application metadata record', updated_on: text(row.sys_updated_on) }];
413
463
  const item = {
414
464
  identity, scope: { sys_id: scopeId || null, name: text(scope?.name) || null, scope: scopeName || null }, evidence,