@jacebenson/jsn 4.3.13-dev.78.1 → 4.3.13-dev.80.1
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 +1 -1
- package/src/app.js +24 -39
- package/src/capabilities.js +4 -0
- package/src/cli.js +9 -0
- package/src/commands/history.js +26 -0
- package/src/commands/rest.js +1 -1
- package/src/commands/scopes.js +1 -1
- package/src/commands/updatesets.js +4 -3
- package/src/context.js +6 -6
- package/src/errors.js +1 -1
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/output.js +1 -0
- package/src/sdk.js +42 -1
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// setEffectiveInstance() are thin session-backed shims kept for the many
|
|
8
8
|
// command handlers that already call them.
|
|
9
9
|
|
|
10
|
+
import { appendMutation } from './mutation-history.js';
|
|
10
11
|
import { AuthManager } from './auth.js';
|
|
11
12
|
import { createAuthRecovery } from './auth-recovery.js';
|
|
12
13
|
import { SDKClient } from './sdk.js';
|
|
@@ -16,7 +17,7 @@ import { resolveSession, applySession, contextFromSession, requireSessionInstanc
|
|
|
16
17
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
17
18
|
import { errUsage, errAuth } from './errors.js';
|
|
18
19
|
import { canPrompt } from './helpers.js';
|
|
19
|
-
import {
|
|
20
|
+
import { guardMutationContext } from './mutation-context.js';
|
|
20
21
|
import process from 'node:process';
|
|
21
22
|
|
|
22
23
|
|
|
@@ -59,6 +60,7 @@ export class App {
|
|
|
59
60
|
}), {
|
|
60
61
|
domain: session?.domain || '',
|
|
61
62
|
recovery: createAuthRecovery({ app: this, session }),
|
|
63
|
+
onMutation: event => this.recordMutation(instance, session?.profileName, event),
|
|
62
64
|
});
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -67,9 +69,21 @@ export class App {
|
|
|
67
69
|
const profile = options.profile || {};
|
|
68
70
|
return new SDKClient(instance, this.auth.createProfileProvider(instance, options), {
|
|
69
71
|
domain: options.domain ?? profile.domain ?? '',
|
|
72
|
+
onMutation: event => this.recordMutation(instance, options.profileName || null, event),
|
|
70
73
|
});
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
recordMutation(instance, profile, event) {
|
|
77
|
+
if (!this._authArgv) return;
|
|
78
|
+
try { appendMutation({ ...event, instance, profile, reason: this._authArgv.reason }); }
|
|
79
|
+
catch {
|
|
80
|
+
if (!this._historyWarningShown) {
|
|
81
|
+
process.stderr.write('Warning: local mutation history could not be saved. The remote result is unchanged.\n');
|
|
82
|
+
this._historyWarningShown = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
loadContext() {
|
|
74
88
|
this.context = contextFromSession(this.session, this.config);
|
|
75
89
|
}
|
|
@@ -78,7 +92,7 @@ export class App {
|
|
|
78
92
|
return this.session?.instance || '';
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
async printContextHeader(
|
|
95
|
+
async printContextHeader(_argv = {}) {
|
|
82
96
|
if (!this.getEffectiveInstance() || !this.sdk) return;
|
|
83
97
|
if (process.env.JSN_NO_HEADER) return;
|
|
84
98
|
if (this.output.getFormat() === FormatJSON || this.output.getFormat() === FormatQuiet) return;
|
|
@@ -104,12 +118,12 @@ export class App {
|
|
|
104
118
|
|
|
105
119
|
// Both lookups depend on the user, but not on one another.
|
|
106
120
|
const [application, selectedUpdateSet] = userSysID ? await Promise.all([
|
|
107
|
-
getCurrentApplication(this.sdk, userSysID).catch(() => null),
|
|
121
|
+
getCurrentApplication(this.sdk, userSysID, { unknownWhenMissing: true }).catch(() => null),
|
|
108
122
|
getCurrentUpdateSet(this.sdk, userSysID).catch(() => null),
|
|
109
123
|
]) : [null, null];
|
|
110
|
-
const scope = application?.scope || '
|
|
124
|
+
const scope = application?.scope || 'unknown';
|
|
111
125
|
const updateSet = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
112
|
-
? selectedUpdateSet.name : '
|
|
126
|
+
? selectedUpdateSet.name : 'unknown';
|
|
113
127
|
const updateSetSysID = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
114
128
|
? selectedUpdateSet.sys_id : '';
|
|
115
129
|
this.context.scope = scope;
|
|
@@ -139,43 +153,14 @@ export class App {
|
|
|
139
153
|
|
|
140
154
|
process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
|
|
141
155
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// 🔴 HARD WARNING — Global + Default
|
|
147
|
-
process.stderr.write(
|
|
148
|
-
'\x1b[31m' + // red
|
|
149
|
-
'┏━ GLOBAL + DEFAULT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n' +
|
|
150
|
-
'┃ You are in Global scope with the Default update set. ┃\n' +
|
|
151
|
-
'┃ Changes ARE captured — in the Default update set. ┃\n' +
|
|
152
|
-
'┃ Moving them to a named update set later requires ┃\n' +
|
|
153
|
-
'┃ manual surgery, one change at a time. Avoid the mess. ┃\n' +
|
|
154
|
-
'┃ ┃\n' +
|
|
155
|
-
'┃ Create a named update set now: ┃\n' +
|
|
156
|
-
'┃ jsn updatesets create --name "My Feature" ┃\n' +
|
|
157
|
-
'┃ ┃\n' +
|
|
158
|
-
'┃ Or switch to a scoped scope first: ┃\n' +
|
|
159
|
-
'┃ jsn scopes list ┃\n' +
|
|
160
|
-
'┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m\n'
|
|
161
|
-
);
|
|
162
|
-
} else {
|
|
163
|
-
// 🟡 SOFT WARNING — Scope + Default
|
|
164
|
-
process.stderr.write(
|
|
165
|
-
'\x1b[33m' + // yellow
|
|
166
|
-
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
|
167
|
-
' ⚠ Default update set in scope [' + scope + ']\n' +
|
|
168
|
-
' Changes are contained to this scope, but a named\n' +
|
|
169
|
-
' update set is still recommended for tracking.\n' +
|
|
170
|
-
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
|
171
|
-
'\x1b[0m' // reset
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async guardMutationContext(argv) {
|
|
159
|
+
this.mutationContext = await guardMutationContext(this, argv);
|
|
175
160
|
}
|
|
176
161
|
|
|
177
162
|
ok(data, opts = {}) {
|
|
178
|
-
this.output.ok(data, opts);
|
|
163
|
+
this.output.ok(data, this.mutationContext ? { ...opts, context: { ...opts.context, mutation: this.mutationContext } } : opts);
|
|
179
164
|
}
|
|
180
165
|
|
|
181
166
|
err(error) {
|
package/src/capabilities.js
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// skipDailyChecks: true,
|
|
17
17
|
// // Suppress the current instance header for profile management commands.
|
|
18
18
|
// skipContextHeader: true,
|
|
19
|
+
// // Remediation commands bypass mutation capture-context approval only.
|
|
20
|
+
// mutationContextExemptSubcommands: ['create', 'set'],
|
|
21
|
+
// // REST methods that are reads (read-only profile policy is separate).
|
|
22
|
+
// mutationContextReadMethods: ['GET', 'HEAD', 'OPTIONS'],
|
|
19
23
|
// }
|
|
20
24
|
//
|
|
21
25
|
// cli.js derives every skip-list and the mutation guard's path list from
|
package/src/cli.js
CHANGED
|
@@ -71,6 +71,7 @@ import { scrapiCmd } from './commands/scrapi.js';
|
|
|
71
71
|
import { b4rulesCmd } from './commands/b4rules.js';
|
|
72
72
|
import { atfCmd } from './commands/atf.js';
|
|
73
73
|
import { approvalsCmd } from './commands/approvals.js';
|
|
74
|
+
import { historyCmd } from './commands/history.js';
|
|
74
75
|
import { perfCmd } from './commands/perf.js';
|
|
75
76
|
import { customizationsCmd } from './commands/customizations.js';
|
|
76
77
|
import { midserversCmd } from './commands/midservers.js';
|
|
@@ -117,6 +118,7 @@ function wrap(handler) {
|
|
|
117
118
|
process.stderr.write('Error: App context not initialized.\n');
|
|
118
119
|
process.exit(1);
|
|
119
120
|
}
|
|
121
|
+
await app.guardMutationContext(argv);
|
|
120
122
|
await handler(argv, app);
|
|
121
123
|
} catch (err) {
|
|
122
124
|
// All error rendering goes through the unified renderer in
|
|
@@ -145,6 +147,12 @@ export function buildCLI() {
|
|
|
145
147
|
},
|
|
146
148
|
])
|
|
147
149
|
.options({
|
|
150
|
+
reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
|
|
151
|
+
|
|
152
|
+
force: {
|
|
153
|
+
type: 'boolean',
|
|
154
|
+
description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
|
|
155
|
+
},
|
|
148
156
|
instance: {
|
|
149
157
|
type: 'string',
|
|
150
158
|
description: 'ServiceNow instance URL',
|
|
@@ -411,6 +419,7 @@ export function buildCLI() {
|
|
|
411
419
|
.command(approvalsCmd(wrap))
|
|
412
420
|
.command(skippedUpdatesCmd(wrap))
|
|
413
421
|
.command(perfCmd(wrap))
|
|
422
|
+
.command(historyCmd(wrap))
|
|
414
423
|
.command(customizationsCmd(wrap))
|
|
415
424
|
.command(midserversCmd(wrap))
|
|
416
425
|
.command(diffCmd(wrap))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { listHistory, pruneHistory } from '../mutation-history.js';
|
|
3
|
+
|
|
4
|
+
export function historyCmd(wrap) {
|
|
5
|
+
declareCapabilities('history', { noInstance: true, skipDailyChecks: true });
|
|
6
|
+
const list = wrap(async (argv, app) => {
|
|
7
|
+
const entries = listHistory({ ...argv, instance: argv['instance-url'], profile: argv['profile-name'] });
|
|
8
|
+
app.ok({ entries, retentionDays: 7, _formatted: entries.length
|
|
9
|
+
? entries.map(e => `${e.timestamp} ${e.operation} ${e.table}/${e.sys_id || '(unknown ID)'} ${e.outcome} ${e.instance}${e.reason ? ` — ${e.reason}` : ''}`).join('\n')
|
|
10
|
+
: 'No local mutations in the last seven days.' });
|
|
11
|
+
});
|
|
12
|
+
const filters = b => b
|
|
13
|
+
.option('since', { type: 'string', describe: 'Earliest ISO timestamp' })
|
|
14
|
+
.option('until', { type: 'string', describe: 'Latest ISO timestamp' })
|
|
15
|
+
.option('instance-url', { type: 'string', describe: 'Filter by instance origin' })
|
|
16
|
+
.option('profile-name', { type: 'string', describe: 'Filter by recorded profile name (including removed profiles)' })
|
|
17
|
+
.option('table', { type: 'string', describe: 'Filter by table' })
|
|
18
|
+
.option('operation', { choices: ['create', 'update', 'delete'], type: 'string' })
|
|
19
|
+
.option('record', { type: 'string', describe: 'Filter by full resolved sys_id' })
|
|
20
|
+
.option('limit', { type: 'number', default: 50, describe: 'Maximum entries (1–10000)' });
|
|
21
|
+
return { command: 'history [subcommand]', describe: 'Local seven-day mutation history (not a durable ServiceNow audit)',
|
|
22
|
+
builder: y => filters(y).command({ command: 'list', describe: 'List local mutations', builder: filters, handler: list })
|
|
23
|
+
.command({ command: 'prune', describe: 'Remove expired entries only', handler: wrap(async (_argv, app) => {
|
|
24
|
+
app.ok({ ...pruneHistory(), _formatted: 'Expired local history removed; seven days retained.' });
|
|
25
|
+
}) }), handler: list };
|
|
26
|
+
}
|
package/src/commands/rest.js
CHANGED
|
@@ -2,7 +2,7 @@ import { declareCapabilities } from '../capabilities.js';
|
|
|
2
2
|
|
|
3
3
|
// Raw REST passthrough — can hit any endpoint with any method, so the whole
|
|
4
4
|
// command is a mutation surface on read-only profiles.
|
|
5
|
-
declareCapabilities('rest', { mutationSubcommands: [''] });
|
|
5
|
+
declareCapabilities('rest', { mutationSubcommands: [''], mutationContextReadMethods: ['GET', 'HEAD', 'OPTIONS'] });
|
|
6
6
|
|
|
7
7
|
export function restCmd(wrap) {
|
|
8
8
|
return {
|
package/src/commands/scopes.js
CHANGED
|
@@ -3,7 +3,7 @@ import { requireCurrentUserSysId, setCurrentApplication } from '../context.js';
|
|
|
3
3
|
import { declareCapabilities } from '../capabilities.js';
|
|
4
4
|
import { unwrapSysId } from '../resolve-record.js';
|
|
5
5
|
|
|
6
|
-
declareCapabilities('scopes', { mutationSubcommands: ['create', 'set'] });
|
|
6
|
+
declareCapabilities('scopes', { mutationSubcommands: ['create', 'set'], mutationContextExemptSubcommands: ['create', 'set'] });
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Resolve a scope identifier to a single sys_scope record.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { editor, confirm } from '../prompts.js';
|
|
3
|
-
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack } from '../helpers.js';
|
|
3
|
+
import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch, canPrompt, buildRerunCommand, verifyWriteBack, quoteShellArg } from '../helpers.js';
|
|
4
4
|
import { getActiveProfile } from '../config.js';
|
|
5
5
|
import { FormatAuto } from '../output.js';
|
|
6
6
|
import { getCurrentApplication, getCurrentUpdateSet, requireCurrentUserSysId, setCurrentApplication, setCurrentUpdateSet } from '../context.js';
|
|
@@ -11,6 +11,7 @@ import { unwrapSysId } from '../resolve-record.js';
|
|
|
11
11
|
|
|
12
12
|
declareCapabilities('updatesets', {
|
|
13
13
|
mutationSubcommands: ['create', 'set', 'complete', 'ignore', 'parent'],
|
|
14
|
+
mutationContextExemptSubcommands: ['create', 'set'],
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -27,7 +28,7 @@ export function scopeMismatchWarning(updateSetApp, currentApp) {
|
|
|
27
28
|
const updateSetAppId = updateSetApp?.value || (typeof updateSetApp === 'string' ? updateSetApp : '');
|
|
28
29
|
if (!updateSetAppId || !currentApp.appSysId || updateSetAppId === currentApp.appSysId) return '';
|
|
29
30
|
const name = updateSetApp?.display_value || updateSetApp;
|
|
30
|
-
return ` ⚠️ update set is in app "${name}", but current app is "${currentApp.scope}" — changes
|
|
31
|
+
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
32
|
}
|
|
32
33
|
|
|
33
34
|
/** Format a picker label for an update set: "Name [State] (app/scope)". */
|
|
@@ -424,7 +425,7 @@ export function updateSetsCmd(wrap) {
|
|
|
424
425
|
return {
|
|
425
426
|
command: 'updatesets [subcommand]',
|
|
426
427
|
aliases: ['updateset', 'us'],
|
|
427
|
-
describe: 'Manage update sets',
|
|
428
|
+
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
429
|
builder: (yargs) => {
|
|
429
430
|
return yargs
|
|
430
431
|
.command({
|
package/src/context.js
CHANGED
|
@@ -18,26 +18,26 @@ export async function getCurrentUser(sdk) {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function getCurrentApplication(sdk, userSysID) {
|
|
21
|
+
export async function getCurrentApplication(sdk, userSysID, { unknownWhenMissing = false } = {}) {
|
|
22
22
|
const params = new URLSearchParams();
|
|
23
23
|
params.set('sysparm_query', `user=${userSysID}^name=apps.current_app`);
|
|
24
24
|
params.set('sysparm_limit', '1');
|
|
25
25
|
params.set('sysparm_fields', 'value');
|
|
26
26
|
const records = await sdk.list('sys_user_preference', params);
|
|
27
|
-
if (records.length === 0) return { scope: 'global', appSysId: '' };
|
|
27
|
+
if (records.length === 0) return { scope: unknownWhenMissing ? '' : 'global', appSysId: '' };
|
|
28
28
|
const val = records[0].value?.value || records[0].value;
|
|
29
|
-
if (!val) return { scope: 'global', appSysId: '' };
|
|
29
|
+
if (!val) return { scope: unknownWhenMissing ? '' : 'global', appSysId: '' };
|
|
30
30
|
|
|
31
31
|
// Try to resolve sys_id to scope name
|
|
32
32
|
try {
|
|
33
33
|
const app = await sdk.get('sys_scope', val);
|
|
34
|
-
if (app) return { scope: app
|
|
34
|
+
if (app) return { scope: getStringField(app, 'scope') || (unknownWhenMissing ? '' : 'global'), appSysId: val };
|
|
35
35
|
} catch {
|
|
36
36
|
// fallback
|
|
37
37
|
}
|
|
38
38
|
try {
|
|
39
39
|
const app2 = await sdk.get('sys_app', val);
|
|
40
|
-
if (app2) return { scope: app2
|
|
40
|
+
if (app2) return { scope: getStringField(app2, 'scope') || (unknownWhenMissing ? '' : 'global'), appSysId: val };
|
|
41
41
|
} catch {
|
|
42
42
|
// fallback
|
|
43
43
|
}
|
|
@@ -56,7 +56,7 @@ export async function getCurrentUpdateSet(sdk, userSysID) {
|
|
|
56
56
|
|
|
57
57
|
try {
|
|
58
58
|
const us = await sdk.get('sys_update_set', val);
|
|
59
|
-
if (us) return { name: us
|
|
59
|
+
if (us) return { name: getStringField(us, 'name') || val, sys_id: val, application: us.application, state: getStringField(us, 'state') };
|
|
60
60
|
} catch {
|
|
61
61
|
// fallback
|
|
62
62
|
}
|
package/src/errors.js
CHANGED
|
@@ -213,7 +213,7 @@ export function isErrorCode(err, code) {
|
|
|
213
213
|
* back to argv.format for callers without an App (unit tests).
|
|
214
214
|
*/
|
|
215
215
|
function resolveJsonMode(app, argv) {
|
|
216
|
-
if (app?.output) return app.output.effectiveFormat()
|
|
216
|
+
if (app?.output) return ['json', 'quiet'].includes(app.output.effectiveFormat());
|
|
217
217
|
return argv?.format === 'json';
|
|
218
218
|
}
|
|
219
219
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Capture-context approval is independent of delete confirmation preferences.
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { collectCapabilities } from './capabilities.js';
|
|
4
|
+
import { isMutationCommand } from './mutations.js';
|
|
5
|
+
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
6
|
+
import { canUsePicker, getStringField, quoteShellArg } from './helpers.js';
|
|
7
|
+
import { confirm } from './prompts.js';
|
|
8
|
+
import { AppError, CodeConfirmationRequired, errUsageHint } from './errors.js';
|
|
9
|
+
|
|
10
|
+
export function needsMutationContext(argv) {
|
|
11
|
+
if (!isMutationCommand(argv)) return false;
|
|
12
|
+
const caps = collectCapabilities().get(argv._?.[0]) || {};
|
|
13
|
+
if (caps.mutationContextReadMethods?.includes(String(argv.method || 'GET').toUpperCase())) return false;
|
|
14
|
+
return !caps.mutationContextExemptSubcommands?.some(sub =>
|
|
15
|
+
sub.split(' ').every((part, index) => argv._?.[index + 1] === part));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function diagnostic(code, message, command, severity = 'warning') {
|
|
19
|
+
return { code, severity, message, remediation: { command } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function guardMutationContext(app, argv, { ask = confirm, rawArgs = process.argv.slice(2) } = {}) {
|
|
23
|
+
if (!needsMutationContext(argv) || !app.sdk) return null;
|
|
24
|
+
let application, updateSet;
|
|
25
|
+
try {
|
|
26
|
+
const user = await getCurrentUser(app.sdk);
|
|
27
|
+
if (user?.sys_id) [application, updateSet] = await Promise.all([
|
|
28
|
+
getCurrentApplication(app.sdk, user.sys_id, { unknownWhenMissing: true }),
|
|
29
|
+
getCurrentUpdateSet(app.sdk, user.sys_id),
|
|
30
|
+
]);
|
|
31
|
+
} catch {
|
|
32
|
+
// Failed reads are unknown context, never evidence of Global + Default.
|
|
33
|
+
}
|
|
34
|
+
const context = { scope: application?.scope || null, application_id: application?.appSysId || null,
|
|
35
|
+
update_set: updateSet ? { name: updateSet.name, sys_id: updateSet.sys_id, state: updateSet.state || null } : null,
|
|
36
|
+
diagnostics: [], approval_required: false, approved: false };
|
|
37
|
+
if (!context.scope || !updateSet) context.diagnostics.push(diagnostic('capture_context_unknown',
|
|
38
|
+
'Capture context could not be determined. Identify the intended scope and update set before development changes.', 'jsn updatesets list'));
|
|
39
|
+
const appId = getStringField(updateSet || {}, 'application');
|
|
40
|
+
const rawAppId = updateSet?.application?.value || appId;
|
|
41
|
+
if (application?.appSysId && rawAppId && application.appSysId !== rawAppId) {
|
|
42
|
+
context.diagnostics.push(diagnostic('update_set_scope_mismatch',
|
|
43
|
+
`Selected scope ${context.scope} and update set ${updateSet.name} belong to different applications; changes may not be captured in this set.`,
|
|
44
|
+
`jsn scopes set ${quoteShellArg(rawAppId)}`));
|
|
45
|
+
}
|
|
46
|
+
if (updateSet?.state && !['in progress', 'in_progress'].includes(updateSet.state.toLowerCase())) {
|
|
47
|
+
context.diagnostics.push(diagnostic('update_set_not_in_progress', `Selected update set ${updateSet.name} is ${updateSet.state}; select an in-progress capture set.`, 'jsn updatesets set', 'error'));
|
|
48
|
+
const error = errUsageHint('Selected capture update set is not in progress.', 'Run: jsn updatesets set');
|
|
49
|
+
error.context = context; throw error;
|
|
50
|
+
}
|
|
51
|
+
const globalDefault = context.scope?.toLowerCase() === 'global' && updateSet?.name?.toLowerCase() === 'default';
|
|
52
|
+
if (globalDefault) {
|
|
53
|
+
context.approval_required = true;
|
|
54
|
+
context.diagnostics.push(diagnostic('global_default_update_set',
|
|
55
|
+
'Global scope + Default update set can capture customization in the wrong place or leave changes untracked. Select or create a dedicated update set; not all record changes are update-set tracked.',
|
|
56
|
+
'jsn updatesets create --name "My Feature"'));
|
|
57
|
+
if (argv.force) context.approved = true;
|
|
58
|
+
else if (canUsePicker(app, argv)) {
|
|
59
|
+
context.approved = await ask({ message: `Scope: Global; update set: Default. ${context.diagnostics.at(-1).message} Approve this exact mutation: ${['jsn', ...rawArgs].map(quoteShellArg).join(' ')}?`, default: false });
|
|
60
|
+
}
|
|
61
|
+
if (!context.approved) {
|
|
62
|
+
const target = app.session?.profileName ? ` --profile ${quoteShellArg(app.session.profileName)}` : '';
|
|
63
|
+
const rerun = ['jsn', ...rawArgs.filter(arg => arg !== '--force')].map(quoteShellArg).join(' ') + (rawArgs.includes('--profile') ? '' : target);
|
|
64
|
+
const error = new AppError(CodeConfirmationRequired, 'Mutation in Global scope + Default update set requires explicit approval.',
|
|
65
|
+
`Select a dedicated set: jsn updatesets set; or jsn updatesets create --name "My Feature". Ask the user to approve this exact operation, then rerun: ${rerun} --force`);
|
|
66
|
+
error.context = context; throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (canUsePicker(app, argv)) {
|
|
70
|
+
process.stderr.write(`Mutation context: scope ${context.scope || 'unknown'}; update set ${updateSet?.name || 'unknown'}${updateSet?.sys_id ? ` (${updateSet.sys_id})` : ''}.\n`);
|
|
71
|
+
for (const item of context.diagnostics) process.stderr.write(`Warning (${item.code}): ${item.message} Run: ${item.remediation.command}\n`);
|
|
72
|
+
}
|
|
73
|
+
return context;
|
|
74
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Local convenience history, independent of documentation indexes and sync.
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { errUsage } from './errors.js';
|
|
7
|
+
|
|
8
|
+
export const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
9
|
+
const operations = ['create', 'update', 'delete'];
|
|
10
|
+
const outcomes = ['succeeded', 'failed', 'unknown'];
|
|
11
|
+
export function historyPath() {
|
|
12
|
+
return path.join(process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn'), 'history.db');
|
|
13
|
+
}
|
|
14
|
+
function openHistory(filename, now) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
16
|
+
const db = new Database(filename);
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
|
|
19
|
+
db.pragma('busy_timeout = 3000');
|
|
20
|
+
db.exec(`CREATE TABLE IF NOT EXISTS mutation_history (
|
|
21
|
+
id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, instance TEXT NOT NULL,
|
|
22
|
+
profile TEXT, table_name TEXT NOT NULL, sys_id TEXT, operation TEXT NOT NULL,
|
|
23
|
+
outcome TEXT NOT NULL, reason TEXT, http_status INTEGER
|
|
24
|
+
); CREATE INDEX IF NOT EXISTS mutation_history_time ON mutation_history(timestamp)`);
|
|
25
|
+
db.prepare('DELETE FROM mutation_history WHERE timestamp < ?').run(now - RETENTION_MS);
|
|
26
|
+
return db;
|
|
27
|
+
} catch (error) { db.close(); throw error; }
|
|
28
|
+
}
|
|
29
|
+
export function sanitizeReason(value) {
|
|
30
|
+
if (!value) return null;
|
|
31
|
+
// eslint-disable-next-line no-control-regex -- strip controls from user-supplied prose
|
|
32
|
+
return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
33
|
+
.replace(/\b(?:Bearer|Basic)\s+\S+/gi, '[redacted]')
|
|
34
|
+
.replace(/\b(password|token|cookie|secret|authorization|access_token|sysparm_ck)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, '$1=[redacted]')
|
|
35
|
+
.slice(0, 1000);
|
|
36
|
+
}
|
|
37
|
+
export function appendMutation(event, { filename = historyPath(), now = Date.now() } = {}) {
|
|
38
|
+
if (!operations.includes(event.operation) || !outcomes.includes(event.outcome)) throw errUsage('Invalid history event');
|
|
39
|
+
const instance = new URL(event.instance).origin;
|
|
40
|
+
// Deliberate whitelist: never serialize SDK options, response bodies, or payloads.
|
|
41
|
+
const row = { timestamp: now, instance, profile: event.profile || null,
|
|
42
|
+
table_name: event.table, sys_id: event.sys_id || null, operation: event.operation,
|
|
43
|
+
outcome: event.outcome, reason: sanitizeReason(event.reason), http_status: event.http_status || null };
|
|
44
|
+
const db = openHistory(filename, now);
|
|
45
|
+
try {
|
|
46
|
+
db.prepare(`INSERT INTO mutation_history
|
|
47
|
+
(timestamp,instance,profile,table_name,sys_id,operation,outcome,reason,http_status)
|
|
48
|
+
VALUES (@timestamp,@instance,@profile,@table_name,@sys_id,@operation,@outcome,@reason,@http_status)`).run(row);
|
|
49
|
+
} finally { db.close(); }
|
|
50
|
+
}
|
|
51
|
+
export function pruneHistory({ filename = historyPath(), now = Date.now() } = {}) {
|
|
52
|
+
const db = openHistory(filename, now);
|
|
53
|
+
try { return { retentionDays: 7 }; } finally { db.close(); }
|
|
54
|
+
}
|
|
55
|
+
export function listHistory(filters = {}, { filename = historyPath(), now = Date.now() } = {}) {
|
|
56
|
+
const where = [], values = [];
|
|
57
|
+
for (const [key, column] of Object.entries({ instance: 'instance', profile: 'profile', table: 'table_name', operation: 'operation', record: 'sys_id' })) {
|
|
58
|
+
if (filters[key]) { where.push(`${column} = ?`); values.push(key === 'instance' ? new URL(filters[key]).origin : filters[key]); }
|
|
59
|
+
}
|
|
60
|
+
for (const [key, operator] of [['since', '>='], ['until', '<=']]) {
|
|
61
|
+
if (filters[key]) {
|
|
62
|
+
const time = Date.parse(filters[key]);
|
|
63
|
+
if (!Number.isFinite(time)) throw errUsage(`Invalid --${key} timestamp; use an ISO date`);
|
|
64
|
+
where.push(`timestamp ${operator} ?`); values.push(time);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const limit = filters.limit ?? 50;
|
|
68
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000) throw errUsage('--limit must be an integer from 1 to 10000');
|
|
69
|
+
const db = openHistory(filename, now);
|
|
70
|
+
try {
|
|
71
|
+
return db.prepare(`SELECT id,timestamp,instance,profile,table_name AS "table",sys_id,operation,outcome,reason,http_status
|
|
72
|
+
FROM mutation_history ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY timestamp DESC,id DESC LIMIT ?`)
|
|
73
|
+
.all(...values, limit).map(row => ({ ...row, timestamp: new Date(row.timestamp).toISOString() }));
|
|
74
|
+
} finally { db.close(); }
|
|
75
|
+
}
|
package/src/output.js
CHANGED
|
@@ -160,6 +160,7 @@ export class OutputWriter {
|
|
|
160
160
|
const format = this.effectiveFormat();
|
|
161
161
|
const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
|
|
162
162
|
const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
|
|
163
|
+
if (e.context) resp.context = e.context;
|
|
163
164
|
if (e.status === 401 || e.status === 403) resp.status = e.status;
|
|
164
165
|
if (e.recovery) {
|
|
165
166
|
resp.recovery = e.recovery;
|
package/src/sdk.js
CHANGED
|
@@ -48,6 +48,7 @@ export class SDKClient {
|
|
|
48
48
|
this.baseURL = baseURL.replace(/\/$/, '');
|
|
49
49
|
this.authProvider = authProvider;
|
|
50
50
|
this.recovery = opts.recovery;
|
|
51
|
+
this.onMutation = opts.onMutation;
|
|
51
52
|
this.timeout = opts.timeout || DEFAULT_TIMEOUT;
|
|
52
53
|
this.domain = opts.domain || '';
|
|
53
54
|
this.oauthCookies = null;
|
|
@@ -104,7 +105,47 @@ export class SDKClient {
|
|
|
104
105
|
|
|
105
106
|
// Keep the timeout alive through body consumption for text/JSON requests.
|
|
106
107
|
// Raw-response callers retain their existing timeout boundary at headers.
|
|
107
|
-
async _withAuthenticatedRequest(endpoint, opts, {
|
|
108
|
+
async _withAuthenticatedRequest(endpoint, opts, settings, consume = (response) => response) {
|
|
109
|
+
const url = new URL(endpoint);
|
|
110
|
+
const match = url.pathname.match(/^\/api\/now\/table\/([^/]+)(?:\/([^/]+))?\/?$/);
|
|
111
|
+
const operation = { POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete' }[(opts.method || 'GET').toUpperCase()];
|
|
112
|
+
const event = match && operation && this.onMutation ? {
|
|
113
|
+
table: decodeURIComponent(match[1]), sys_id: match[2] ? decodeURIComponent(match[2]) : null, operation,
|
|
114
|
+
} : null;
|
|
115
|
+
let result;
|
|
116
|
+
try {
|
|
117
|
+
result = await this._performAuthenticatedRequest(endpoint, opts, settings, async response => {
|
|
118
|
+
if (event) event.http_status = response.status;
|
|
119
|
+
const value = await consume(response);
|
|
120
|
+
if (event && value instanceof Response && response.ok && operation === 'create') {
|
|
121
|
+
try { event.sys_id = (await response.clone().json())?.result?.sys_id || null; } catch { /* ID remains unknown. */ }
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (event) await this._reportMutation({ ...event, outcome: error.status >= 400 && error.status < 500 && error.status !== 408
|
|
127
|
+
? 'failed' : 'unknown', http_status: error.status || null });
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
if (event) {
|
|
131
|
+
let body = result;
|
|
132
|
+
if (typeof body === 'string') { try { body = JSON.parse(body); } catch { body = null; } }
|
|
133
|
+
const id = body?.result?.sys_id;
|
|
134
|
+
if (typeof id === 'string') event.sys_id = id;
|
|
135
|
+
else if (typeof id?.value === 'string') event.sys_id = id.value;
|
|
136
|
+
await this._reportMutation({ ...event, outcome: event.http_status >= 400
|
|
137
|
+
? (event.http_status < 500 && event.http_status !== 408 ? 'failed' : 'unknown')
|
|
138
|
+
: (event.sys_id ? 'succeeded' : 'unknown') });
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async _reportMutation(event) {
|
|
144
|
+
// History failures must never change the outcome of a completed remote write.
|
|
145
|
+
try { await this.onMutation(event); } catch { /* App callback reports local storage failures. */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _performAuthenticatedRequest(endpoint, opts, {
|
|
108
149
|
timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
|
|
109
150
|
}, consume = (response) => response) {
|
|
110
151
|
for (let attempt = 0; attempt < 2; attempt++) {
|