@jacebenson/jsn 4.3.13-dev.77.1 → 4.3.13-dev.79.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 +10 -39
- package/src/capabilities.js +4 -0
- package/src/cli.js +5 -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/output.js +1 -0
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -16,7 +16,7 @@ import { resolveSession, applySession, contextFromSession, requireSessionInstanc
|
|
|
16
16
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
17
17
|
import { errUsage, errAuth } from './errors.js';
|
|
18
18
|
import { canPrompt } from './helpers.js';
|
|
19
|
-
import {
|
|
19
|
+
import { guardMutationContext } from './mutation-context.js';
|
|
20
20
|
import process from 'node:process';
|
|
21
21
|
|
|
22
22
|
|
|
@@ -78,7 +78,7 @@ export class App {
|
|
|
78
78
|
return this.session?.instance || '';
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
async printContextHeader(
|
|
81
|
+
async printContextHeader(_argv = {}) {
|
|
82
82
|
if (!this.getEffectiveInstance() || !this.sdk) return;
|
|
83
83
|
if (process.env.JSN_NO_HEADER) return;
|
|
84
84
|
if (this.output.getFormat() === FormatJSON || this.output.getFormat() === FormatQuiet) return;
|
|
@@ -104,12 +104,12 @@ export class App {
|
|
|
104
104
|
|
|
105
105
|
// Both lookups depend on the user, but not on one another.
|
|
106
106
|
const [application, selectedUpdateSet] = userSysID ? await Promise.all([
|
|
107
|
-
getCurrentApplication(this.sdk, userSysID).catch(() => null),
|
|
107
|
+
getCurrentApplication(this.sdk, userSysID, { unknownWhenMissing: true }).catch(() => null),
|
|
108
108
|
getCurrentUpdateSet(this.sdk, userSysID).catch(() => null),
|
|
109
109
|
]) : [null, null];
|
|
110
|
-
const scope = application?.scope || '
|
|
110
|
+
const scope = application?.scope || 'unknown';
|
|
111
111
|
const updateSet = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
112
|
-
? selectedUpdateSet.name : '
|
|
112
|
+
? selectedUpdateSet.name : 'unknown';
|
|
113
113
|
const updateSetSysID = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
114
114
|
? selectedUpdateSet.sys_id : '';
|
|
115
115
|
this.context.scope = scope;
|
|
@@ -139,43 +139,14 @@ export class App {
|
|
|
139
139
|
|
|
140
140
|
process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
|
|
141
141
|
|
|
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
|
-
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async guardMutationContext(argv) {
|
|
145
|
+
this.mutationContext = await guardMutationContext(this, argv);
|
|
175
146
|
}
|
|
176
147
|
|
|
177
148
|
ok(data, opts = {}) {
|
|
178
|
-
this.output.ok(data, opts);
|
|
149
|
+
this.output.ok(data, this.mutationContext ? { ...opts, context: { ...opts.context, mutation: this.mutationContext } } : opts);
|
|
179
150
|
}
|
|
180
151
|
|
|
181
152
|
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
|
@@ -117,6 +117,7 @@ function wrap(handler) {
|
|
|
117
117
|
process.stderr.write('Error: App context not initialized.\n');
|
|
118
118
|
process.exit(1);
|
|
119
119
|
}
|
|
120
|
+
await app.guardMutationContext(argv);
|
|
120
121
|
await handler(argv, app);
|
|
121
122
|
} catch (err) {
|
|
122
123
|
// All error rendering goes through the unified renderer in
|
|
@@ -145,6 +146,10 @@ export function buildCLI() {
|
|
|
145
146
|
},
|
|
146
147
|
])
|
|
147
148
|
.options({
|
|
149
|
+
force: {
|
|
150
|
+
type: 'boolean',
|
|
151
|
+
description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
|
|
152
|
+
},
|
|
148
153
|
instance: {
|
|
149
154
|
type: 'string',
|
|
150
155
|
description: 'ServiceNow instance URL',
|
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
|
+
}
|
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;
|