@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 +8 -1
- package/package.json +1 -1
- package/src/auth.js +14 -12
- package/src/cli.js +2 -0
- package/src/commands/diff.js +205 -0
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
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
|
-
|
|
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
|
+
}
|