@jacebenson/jsn 4.3.13-dev.79.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.79.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';
@@ -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
  }
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';
@@ -146,6 +147,8 @@ export function buildCLI() {
146
147
  },
147
148
  ])
148
149
  .options({
150
+ reason: { type: 'string', describe: 'Optional non-sensitive reason saved in local mutation history' },
151
+
149
152
  force: {
150
153
  type: 'boolean',
151
154
  description: 'Approve Global + Default mutation context only after user approval; delete commands also use this for their separate confirmation',
@@ -416,6 +419,7 @@ export function buildCLI() {
416
419
  .command(approvalsCmd(wrap))
417
420
  .command(skippedUpdatesCmd(wrap))
418
421
  .command(perfCmd(wrap))
422
+ .command(historyCmd(wrap))
419
423
  .command(customizationsCmd(wrap))
420
424
  .command(midserversCmd(wrap))
421
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
+ }
@@ -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/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++) {