@jacebenson/jsn 4.3.7-dev.12 → 4.3.7-dev.13

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 CHANGED
@@ -72,6 +72,11 @@ jsn records bulk --table incident --query "priority=1" --set '{"state":"3"}' #
72
72
  jsn records get --table incident --sys-id 8a1234abcd5678 --attachments # record + its files
73
73
  jsn records list --table incident --limit 10 --csv # output CSV
74
74
 
75
+ # Read-only cross-profile record diff
76
+ jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod
77
+ jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --ignore state
78
+ jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --no-ignore
79
+
75
80
  # Script execution
76
81
  jsn eval "gs.info('Hello World')"
77
82
  cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
@@ -110,7 +115,7 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
110
115
 
111
116
  **User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
112
117
 
113
- **Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
118
+ **Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `diff record` (cross-profile read-only comparison), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
114
119
 
115
120
  Every command supports `--json`, `--query`, and `--help`.
116
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.7-dev.12",
3
+ "version": "4.3.7-dev.13",
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": {
package/src/cli.js CHANGED
@@ -69,6 +69,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
69
69
  import { atfCmd } from './commands/atf.js';
70
70
  import { approvalsCmd } from './commands/approvals.js';
71
71
  import { perfCmd } from './commands/perf.js';
72
+ import { diffCmd } from './commands/diff.js';
72
73
 
73
74
  function wrap(handler) {
74
75
  return async (argv) => {
@@ -371,6 +372,7 @@ export function buildCLI() {
371
372
  .command(atfCmd(wrap))
372
373
  .command(approvalsCmd(wrap))
373
374
  .command(perfCmd(wrap))
375
+ .command(diffCmd(wrap))
374
376
  .command(evalCmd(wrap))
375
377
  .command(restCmd(wrap))
376
378
  .command(skillCmd(wrap))
@@ -0,0 +1,205 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { errUsage, errUsageHint } from '../errors.js';
3
+ import { resolveSession } from '../session.js';
4
+ import { resolveRecord } from '../resolve-record.js';
5
+
6
+ declareCapabilities('diff', {});
7
+
8
+ const DEFAULT_IGNORED_FIELDS = [
9
+ 'sys_created_by',
10
+ 'sys_created_on',
11
+ 'sys_mod_count',
12
+ 'sys_updated_by',
13
+ 'sys_updated_on',
14
+ ];
15
+
16
+ function profileTarget(app, name, label) {
17
+ const session = resolveSession({ profile: name }, app.config);
18
+ if (session.unknownProfile || !session.profileName) {
19
+ throw errUsageHint(`Unknown ${label} profile "${name}"`, 'Run "jsn auth status" to list configured profiles.');
20
+ }
21
+ if (!session.instance || !session.profile) {
22
+ throw errUsage(`Profile "${name}" has no configured instance URL`);
23
+ }
24
+ return {
25
+ name: session.profileName,
26
+ instance: session.instance,
27
+ profile: session.profile,
28
+ username: session.username || '',
29
+ domain: session.domain || '',
30
+ };
31
+ }
32
+
33
+ function sdkForProfile(app, target) {
34
+ return app.getSDKForProfile(target.instance, {
35
+ profile: target.profile,
36
+ profileName: target.name,
37
+ username: target.username,
38
+ authMethod: target.profile.auth_method,
39
+ domain: target.domain,
40
+ });
41
+ }
42
+
43
+ async function fetchRecord(app, target, table, identifier) {
44
+ try {
45
+ const record = await resolveRecord(sdkForProfile(app, target), {
46
+ table,
47
+ identifier,
48
+ matchField: 'sys_id',
49
+ resource: 'Record',
50
+ });
51
+ return { status: 'found', record };
52
+ } catch (error) {
53
+ if (error?.code === 'not_found') return { status: 'missing', record: null };
54
+ if (error?.code === 'forbidden') return { status: 'forbidden', record: null, error: error.code };
55
+ if (error?.code === 'auth_error') return { status: 'auth_error', record: null, error: error.code };
56
+ return { status: 'error', record: null, error: error?.code || 'error' };
57
+ }
58
+ }
59
+
60
+ function stableValue(value) {
61
+ if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return value.value;
62
+ if (Array.isArray(value)) return value.map(stableValue);
63
+ if (value && typeof value === 'object') {
64
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function equalValue(a, b) {
70
+ return JSON.stringify(stableValue(a)) === JSON.stringify(stableValue(b));
71
+ }
72
+
73
+ function displayValue(value) {
74
+ if (value && typeof value === 'object' && value.display_value != null) return String(value.display_value);
75
+ if (value == null) return 'null';
76
+ if (typeof value === 'object') return JSON.stringify(value);
77
+ return String(value);
78
+ }
79
+
80
+ function fieldDifference(field, status, a, b) {
81
+ return {
82
+ field,
83
+ status,
84
+ ...(a !== undefined ? { a } : {}),
85
+ ...(b !== undefined ? { b } : {}),
86
+ };
87
+ }
88
+
89
+ export function compareRecords(recordA, recordB, ignoredFields = DEFAULT_IGNORED_FIELDS) {
90
+ const ignored = new Set(ignoredFields);
91
+ const fields = [...new Set([
92
+ ...Object.keys(recordA || {}),
93
+ ...Object.keys(recordB || {}),
94
+ ])].filter(field => !ignored.has(field)).sort();
95
+ const differences = [];
96
+ for (const field of fields) {
97
+ const hasA = Object.hasOwn(recordA || {}, field);
98
+ const hasB = Object.hasOwn(recordB || {}, field);
99
+ if (!hasA && hasB) differences.push(fieldDifference(field, 'added', undefined, recordB[field]));
100
+ else if (hasA && !hasB) differences.push(fieldDifference(field, 'missing', recordA[field], undefined));
101
+ else if (!equalValue(recordA[field], recordB[field])) differences.push(fieldDifference(field, 'changed', recordA[field], recordB[field]));
102
+ }
103
+ return differences;
104
+ }
105
+
106
+ export function formatRecordDiff(data) {
107
+ const lines = [
108
+ `Record diff: ${data.table} ${data.identifiers.a}`,
109
+ `A: ${data.profiles.a.name} (${data.existence.a})`,
110
+ `B: ${data.profiles.b.name} (${data.existence.b})`,
111
+ `Status: ${data.status}`,
112
+ ];
113
+ if (data.status === 'error' || data.status === 'forbidden' || data.status === 'auth_error' || data.status === 'missing' || data.status === 'both_missing') return `${lines.join('\n')}\n`;
114
+ lines.push(`Ignored: ${data.ignored_fields.join(', ') || '(none)'}`);
115
+ if (data.differences.length === 0) lines.push('No field differences');
116
+ for (const difference of data.differences) {
117
+ const left = difference.a === undefined ? '(missing)' : displayValue(difference.a);
118
+ const right = difference.b === undefined ? '(missing)' : displayValue(difference.b);
119
+ lines.push(`${difference.status.toUpperCase()} ${difference.field}: ${left} -> ${right}`);
120
+ }
121
+ return `${lines.join('\n')}\n`;
122
+ }
123
+
124
+ function ignoredFields(argv) {
125
+ if (argv['no-ignore']) return [];
126
+ return [...new Set([...DEFAULT_IGNORED_FIELDS, ...(argv.ignore || [])])].sort();
127
+ }
128
+
129
+ export function diffCmd(wrap) {
130
+ return {
131
+ command: 'diff [subcommand]',
132
+ describe: 'Compare records across configured profiles (read-only)',
133
+ builder: (y) => y.command({
134
+ command: 'record',
135
+ describe: 'Compare one record across two profiles',
136
+ builder: (b) => b
137
+ .option('table', { type: 'string', demandOption: true, describe: 'ServiceNow table' })
138
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id or exact identifier' })
139
+ .option('profile-a', { type: 'string', demandOption: true, describe: 'Profile for side A' })
140
+ .option('profile-b', { type: 'string', demandOption: true, describe: 'Profile for side B' })
141
+ .option('ignore', { type: 'string', array: true, default: [], describe: 'Additional field to ignore (repeatable)' })
142
+ .option('no-ignore', { type: 'boolean', default: false, describe: 'Do not ignore system noise fields' }),
143
+ handler: wrap(async (argv, app) => {
144
+ const table = String(argv.table || '').trim();
145
+ const identifier = String(argv['sys-id'] || '').trim();
146
+ if (!table || !identifier) throw errUsage('diff record requires --table and --sys-id');
147
+ if (argv['profile-a'] === argv['profile-b']) throw errUsage('Profile A and profile B must be different');
148
+ const targetA = profileTarget(app, argv['profile-a'], 'profile-a');
149
+ const targetB = profileTarget(app, argv['profile-b'], 'profile-b');
150
+ const [sideA, sideB] = await Promise.all([
151
+ fetchRecord(app, targetA, table, identifier),
152
+ fetchRecord(app, targetB, table, identifier),
153
+ ]);
154
+ const ignored = ignoredFields(argv);
155
+ let status = 'error';
156
+ let differences = [];
157
+ if (sideA.status === 'found' && sideB.status === 'found') {
158
+ differences = compareRecords(sideA.record, sideB.record, ignored);
159
+ status = differences.length ? 'different' : 'equal';
160
+ } else if (sideA.status === 'missing' && sideB.status === 'missing') {
161
+ status = 'both_missing';
162
+ } else if (sideA.status === 'missing' || sideB.status === 'missing') {
163
+ status = 'missing';
164
+ } else if (sideA.status === 'forbidden' || sideB.status === 'forbidden') {
165
+ status = 'forbidden';
166
+ } else if (sideA.status === 'auth_error' || sideB.status === 'auth_error') {
167
+ status = 'auth_error';
168
+ }
169
+ const data = {
170
+ table,
171
+ identifiers: { a: identifier, b: identifier },
172
+ profiles: {
173
+ a: { name: targetA.name, instance: targetA.instance },
174
+ b: { name: targetB.name, instance: targetB.instance },
175
+ },
176
+ existence: { a: sideA.status, b: sideB.status },
177
+ errors: { a: sideA.error || null, b: sideB.error || null },
178
+ ignored_fields: ignored,
179
+ status,
180
+ differences,
181
+ _formatted: formatRecordDiff({
182
+ table,
183
+ identifiers: { a: identifier },
184
+ profiles: dataProfiles(targetA, targetB),
185
+ existence: { a: sideA.status, b: sideB.status },
186
+ ignored_fields: ignored,
187
+ status,
188
+ differences,
189
+ }),
190
+ };
191
+ app.ok(data, { summary: `Record diff ${status}: ${table} ${identifier}` });
192
+ }),
193
+ }),
194
+ handler: wrap(async (_argv, app) => {
195
+ app.ok({ command: 'diff', subcommands: ['record'] }, { summary: 'Use `jsn diff record --help` for details.' });
196
+ }),
197
+ };
198
+ }
199
+
200
+ function dataProfiles(targetA, targetB) {
201
+ return {
202
+ a: { name: targetA.name, instance: targetA.instance },
203
+ b: { name: targetB.name, instance: targetB.instance },
204
+ };
205
+ }