@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.78.1",
3
+ "version": "4.3.13-dev.80.1",
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/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 { isMutationCommand } from './mutations.js';
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(argv = {}) {
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 || 'global';
124
+ const scope = application?.scope || 'unknown';
111
125
  const updateSet = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
112
- ? selectedUpdateSet.name : 'Default';
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
- // ⚠️ Warning if in the Default update set (mutation commands only)
143
- if (isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
144
- const isGlobal = scope === 'global';
145
- if (isGlobal) {
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) {
@@ -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
+ }
@@ -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 {
@@ -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 won't land here. Run: jsn scopes set ${name}`;
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.scope || 'global', appSysId: val };
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.scope || 'global', appSysId: val };
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.name || val, sys_id: val };
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() === 'json';
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++) {