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

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
 
@@ -162,6 +167,8 @@ export SERVICENOW_OAUTH_TOKEN="***"
162
167
  jsn incidents list
163
168
  ```
164
169
 
170
+ For the credential identity, auth-method, diagnostics, migration, and concurrency contract, see [`docs/AUTH_LIFECYCLE.md`](docs/AUTH_LIFECYCLE.md).
171
+
165
172
  ## Local data
166
173
 
167
174
  JSN stores durable local data under `~/.jsn/`. Documentation lives in
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.14",
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/auth.js CHANGED
@@ -41,30 +41,32 @@ function credKey(instance, username) {
41
41
 
42
42
  // ─── PKCE state persistence (shared with Go version) ───
43
43
 
44
- function pkceStatePath(instance) {
44
+ function pkceStatePath(instance, username) {
45
45
  const dir = path.join(globalConfigDir(), 'pkce');
46
46
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
- const filename = sanitizeKeyPart(instance) + '.json';
47
+ // A known profile identity gets its own pending-login slot. Keep the bare
48
+ // instance slot for legacy/no-profile flows, where no identity exists yet.
49
+ const filename = credKey(instance, username) + '.json';
48
50
  return path.join(dir, filename);
49
51
  }
50
52
 
51
- function savePKCEState(instance, pkce) {
52
- const filePath = pkceStatePath(instance);
53
+ function savePKCEState(instance, pkce, username) {
54
+ const filePath = pkceStatePath(instance, username);
53
55
  fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
54
56
  }
55
57
 
56
- function loadPKCEState(instance) {
58
+ function loadPKCEState(instance, username) {
57
59
  try {
58
- const data = fs.readFileSync(pkceStatePath(instance), 'utf-8');
60
+ const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
59
61
  return JSON.parse(data);
60
62
  } catch {
61
63
  return null;
62
64
  }
63
65
  }
64
66
 
65
- function removePKCEState(instance) {
67
+ function removePKCEState(instance, username) {
66
68
  try {
67
- fs.unlinkSync(pkceStatePath(instance));
69
+ fs.unlinkSync(pkceStatePath(instance, username));
68
70
  } catch {
69
71
  // ignore
70
72
  }
@@ -746,7 +748,7 @@ export class AuthManager {
746
748
  instanceURL = normalizeInstanceURL(instanceURL);
747
749
  const clientID = getOAuthClientID();
748
750
  const pkce = generatePKCE();
749
- savePKCEState(instanceURL, pkce);
751
+ savePKCEState(instanceURL, pkce, username);
750
752
  const url = buildAuthURL(instanceURL, clientID, pkce);
751
753
  if (waitFile) {
752
754
  console.log(url);
@@ -766,7 +768,7 @@ export class AuthManager {
766
768
  const code = fs.readFileSync(filePath, 'utf-8').trim();
767
769
  if (code) {
768
770
  console.log(`\nAuthorization code found in ${filePath}`);
769
- removePKCEState(instanceURL);
771
+ removePKCEState(instanceURL, username);
770
772
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
771
773
  this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
772
774
  console.log('Token exchange successful!\n');
@@ -838,7 +840,7 @@ export class AuthManager {
838
840
  async loginWithCode(instanceURL, code, username = this._activeUsername()) {
839
841
  instanceURL = normalizeInstanceURL(instanceURL);
840
842
  const clientID = getOAuthClientID();
841
- const pkce = loadPKCEState(instanceURL);
843
+ const pkce = loadPKCEState(instanceURL, username);
842
844
  if (!pkce) {
843
845
  throw errAuth(
844
846
  `No pending login session for ${instanceURL}.\n\n` +
@@ -848,7 +850,7 @@ export class AuthManager {
848
850
  ` jsn auth login ${instanceURL} --code CODE`
849
851
  );
850
852
  }
851
- removePKCEState(instanceURL);
853
+ removePKCEState(instanceURL, username);
852
854
 
853
855
  const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
854
856
  this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
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
+ }