@jacebenson/jsn 4.0.3 → 4.1.0

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/src/context.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // ServiceNow runtime context: current user, scope, update set
2
2
 
3
+ import { getStringField } from './helpers.js';
4
+
3
5
  export async function getCurrentUser(sdk) {
4
6
  const params = new URLSearchParams();
5
7
  params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
@@ -22,24 +24,24 @@ export async function getCurrentApplication(sdk, userSysID) {
22
24
  params.set('sysparm_limit', '1');
23
25
  params.set('sysparm_fields', 'value');
24
26
  const records = await sdk.list('sys_user_preference', params);
25
- if (records.length === 0) return { scope: 'global' };
27
+ if (records.length === 0) return { scope: 'global', appSysId: '' };
26
28
  const val = records[0].value?.value || records[0].value;
27
- if (!val) return { scope: 'global' };
29
+ if (!val) return { scope: 'global', appSysId: '' };
28
30
 
29
31
  // Try to resolve sys_id to scope name
30
32
  try {
31
33
  const app = await sdk.get('sys_scope', val);
32
- if (app) return { scope: app.scope || 'global' };
34
+ if (app) return { scope: app.scope || 'global', appSysId: val };
33
35
  } catch {
34
36
  // fallback
35
37
  }
36
38
  try {
37
39
  const app2 = await sdk.get('sys_app', val);
38
- if (app2) return { scope: app2.scope || 'global' };
40
+ if (app2) return { scope: app2.scope || 'global', appSysId: val };
39
41
  } catch {
40
42
  // fallback
41
43
  }
42
- return { scope: val };
44
+ return { scope: val, appSysId: val };
43
45
  }
44
46
 
45
47
  export async function getCurrentUpdateSet(sdk, userSysID) {
@@ -60,3 +62,48 @@ export async function getCurrentUpdateSet(sdk, userSysID) {
60
62
  }
61
63
  return { name: val, sys_id: val };
62
64
  }
65
+
66
+ /**
67
+ * Find-or-create a sys_user_preference and set its value. Shared by the
68
+ * scope/update-set "set" commands and create auto-set (was copy-pasted 3x).
69
+ */
70
+ async function setUserPreference(sdk, userSysID, name, value) {
71
+ const params = new URLSearchParams();
72
+ params.set('sysparm_query', `user=${userSysID}^name=${name}`);
73
+ params.set('sysparm_limit', '1');
74
+ params.set('sysparm_fields', 'sys_id');
75
+ const prefs = await sdk.list('sys_user_preference', params);
76
+ if (prefs.length > 0) {
77
+ await sdk.update('sys_user_preference', getStringField(prefs[0], 'sys_id'), { value });
78
+ } else {
79
+ await sdk.create('sys_user_preference', {
80
+ user: userSysID,
81
+ name,
82
+ value,
83
+ type: 'string',
84
+ });
85
+ }
86
+ }
87
+
88
+ /** Resolve the current user's sys_id (throws if it can't be determined). */
89
+ export async function requireCurrentUserSysId(sdk) {
90
+ const user = await sdk.list('sys_user', new URLSearchParams({
91
+ sysparm_query: 'user_name=javascript:gs.getUserName()',
92
+ sysparm_limit: '1',
93
+ sysparm_fields: 'sys_id',
94
+ }));
95
+ if (user.length === 0) {
96
+ throw new Error('Could not determine current user');
97
+ }
98
+ return getStringField(user[0], 'sys_id');
99
+ }
100
+
101
+ /** Set the current application scope via the apps.current_app preference. */
102
+ export async function setCurrentApplication(sdk, userSysID, scopeSysID) {
103
+ await setUserPreference(sdk, userSysID, 'apps.current_app', scopeSysID);
104
+ }
105
+
106
+ /** Set the current update set via the sys_update_set preference. */
107
+ export async function setCurrentUpdateSet(sdk, userSysID, updateSetSysID) {
108
+ await setUserPreference(sdk, userSysID, 'sys_update_set', updateSetSysID);
109
+ }
package/src/errors.js CHANGED
@@ -9,6 +9,7 @@ export const CodeNetwork = 'network_error';
9
9
  export const CodeAPI = 'api_error';
10
10
  export const CodeAmbiguous = 'ambiguous';
11
11
  export const CodeEmptyResult = 'empty_result';
12
+ export const CodeConfirmationRequired = 'confirmation_required';
12
13
 
13
14
  export class AppError extends Error {
14
15
  constructor(code, message, hint = '', status = 0, cause = null) {
@@ -117,6 +118,20 @@ export function errAmbiguous(resource, matches) {
117
118
  );
118
119
  }
119
120
 
121
+ /**
122
+ * Structured error for a destructive action that requires confirmation
123
+ * but no human is available to answer (non-TTY, pipes, or AI agents).
124
+ * The hint is written to be machine-parseable: an agent can read the
125
+ * re-run command and either present it to its user or execute it.
126
+ */
127
+ export function errConfirmationRequired(question, rerunCmd) {
128
+ return new AppError(
129
+ CodeConfirmationRequired,
130
+ `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`,
131
+ `This is a destructive action. Ask your user to confirm, then re-run:\n ${rerunCmd} --force\nOr set skip_confirmations on the profile to disable prompts:\n jsn auth login <instance> --skip-confirmations`
132
+ );
133
+ }
134
+
120
135
  export function asError(err) {
121
136
  if (!err) return null;
122
137
  if (err instanceof AppError) return err;
package/src/help.js CHANGED
@@ -111,6 +111,7 @@ const CONFIG_COMMANDS = [
111
111
  { name: 'auth', desc: 'Authentication (login, refresh, status)' },
112
112
  { name: 'updatesets', desc: 'Update sets (set, create, export)' },
113
113
  { name: 'scopes', desc: 'Application scopes' },
114
+ { name: 'domains', desc: 'Domains (domain separation)' },
114
115
  ];
115
116
 
116
117
  const DEVELOPER_COMMANDS = [
@@ -153,8 +154,21 @@ LEARN MORE
153
154
  Use "jsn <command> --help" for more information about a command.`;
154
155
  }
155
156
 
156
- export function renderHelp() {
157
- const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
157
+ export function renderHelp(config) {
158
+ // Domain separation is an optional plugin — only advertise `domains`
159
+ // when the active profile's capability probe found it installed.
160
+ const activeName = config?.activeProfile || config?.defaultProfile;
161
+ const hasDS = !!(activeName && config?.profiles?.[activeName]?.domain_separation);
162
+ const configCmds = hasDS
163
+ ? CONFIG_COMMANDS
164
+ : CONFIG_COMMANDS.filter((c) => !c.name || c.name !== 'domains');
165
+
166
+ const COMMAND_GROUPS_LOCAL = {
167
+ ...COMMAND_GROUPS,
168
+ 'CONFIGURATION': configCmds,
169
+ };
170
+
171
+ const sections = Object.entries(COMMAND_GROUPS_LOCAL).map(([name, cmds]) => renderGroup(name, cmds));
158
172
 
159
173
  return [
160
174
  'Usage: jsn <command> [options]',
package/src/helpers.js CHANGED
@@ -5,6 +5,32 @@ import readline from 'node:readline';
5
5
  import { paginatedSearch } from './paginated-search.js';
6
6
  import { isTTY, FormatAuto } from './output.js';
7
7
  import { getActiveProfile } from './config.js';
8
+ import { errConfirmationRequired } from './errors.js';
9
+
10
+ /**
11
+ * Detect whether a human is actually available to answer interactive
12
+ * prompts. Agent tooling (VSCode Copilot, Claude Code, Cursor, etc.)
13
+ * allocates a PTY for subprocesses, so isTTY() returns true even though
14
+ * no one will ever type a response. Those environments set signals that
15
+ * let us fall back to non-interactive behavior instead of hanging:
16
+ *
17
+ * - JSN_NO_PROMPTS=1 (explicit opt-out, documented for agents)
18
+ * - CI / NONINTERACTIVE / GITHUB_ACTIONS
19
+ * - CLAUDE_CODE / COPILOT_* / CURSOR_* agent markers
20
+ * - TERM=dumb
21
+ *
22
+ * @returns {boolean} true when a human can answer a prompt
23
+ */
24
+ export function canPrompt() {
25
+ if (process.env.JSN_NO_PROMPTS === '1') return false;
26
+ if (process.env.CI === 'true' || process.env.CI === '1') return false;
27
+ if (process.env.NONINTERACTIVE === 'true' || process.env.NONINTERACTIVE === '1') return false;
28
+ if (process.env.GITHUB_ACTIONS === 'true') return false;
29
+ if (process.env.TERM === 'dumb') return false;
30
+ const agentMarkers = ['CLAUDE_CODE', 'COPILOT_AGENT', 'VSCODE_AGENT', 'CURSOR_AGENT', 'CODEX_AGENT', 'GITHUB_COPILOT_AGENT'];
31
+ if (agentMarkers.some((m) => process.env[m] === 'true' || process.env[m] === '1')) return false;
32
+ return isTTY(process.stdout) && isTTY(process.stdin);
33
+ }
8
34
 
9
35
  /**
10
36
  * Confirm a destructive action before executing.
@@ -25,7 +51,7 @@ export async function confirmDelete(app, argv, question) {
25
51
  const profile = getActiveProfile(app.config);
26
52
  if (profile?.skip_confirmations === true) return true;
27
53
  if (argv.force) return true;
28
- if (isTTY(process.stdout) && isTTY(process.stdin)) {
54
+ if (canPrompt()) {
29
55
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
30
56
  const answer = await new Promise((resolve) => {
31
57
  rl.question(`${question} (y/N): `, resolve);
@@ -37,9 +63,10 @@ export async function confirmDelete(app, argv, question) {
37
63
  }
38
64
  return true;
39
65
  }
40
- throw new Error(
41
- `${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`
42
- );
66
+ // No human available (pipes, scripts, AI agents). Fail fast with a
67
+ // structured error an agent can act on — never hang waiting for stdin.
68
+ const cmd = ['jsn', ...(argv._ || [])].join(' ');
69
+ throw errConfirmationRequired(question, cmd);
43
70
  }
44
71
 
45
72
 
@@ -55,6 +82,84 @@ export function getStringField(record, field) {
55
82
  return String(val);
56
83
  }
57
84
 
85
+ /**
86
+ * Extract the raw value from a field that may be a string or a
87
+ * {display_value, value} object returned by the Table API.
88
+ * Prefers .value (the raw stored value) over .display_value.
89
+ */
90
+ function getRawValue(val) {
91
+ if (val == null) return '';
92
+ if (typeof val === 'object') {
93
+ if (val.value != null) return String(val.value);
94
+ if (val.display_value != null) return String(val.display_value);
95
+ return '';
96
+ }
97
+ return String(val);
98
+ }
99
+
100
+ /**
101
+ * Normalize a value the user supplied in --data for comparison against
102
+ * what the API persisted. Objects like {"value": "<sys_id>"} are unwrapped.
103
+ */
104
+ function normalizeSupplied(val) {
105
+ if (val == null) return '';
106
+ if (typeof val === 'object') {
107
+ if (val.value != null) return String(val.value);
108
+ return '';
109
+ }
110
+ return String(val);
111
+ }
112
+
113
+ /**
114
+ * Write-back verification for records create/update.
115
+ *
116
+ * After a mutation, re-GET the record and compare each field supplied in
117
+ * --data against what actually persisted. Surfaces silent server-side
118
+ * drops (inherited references, ACL-restricted fields, IO: prefixed strings,
119
+ * platform quirks) that ServiceNow ignores without erroring.
120
+ *
121
+ * Skips sys_* fields (system-managed, already warned by checkDerivedFields).
122
+ *
123
+ * @returns {Promise<Array<{field: string, sent: string, got: string}>>}
124
+ */
125
+ export async function verifyWriteBack(app, table, sysID, sentData) {
126
+ const mismatches = [];
127
+ if (!sysID || !sentData || typeof sentData !== 'object') return mismatches;
128
+
129
+ const fields = Object.keys(sentData).filter(f => f !== 'sys_id' && !f.startsWith('sys_'));
130
+ if (fields.length === 0) return mismatches;
131
+
132
+ let persisted;
133
+ try {
134
+ const params = new URLSearchParams();
135
+ params.set('sysparm_query', `sys_id=${sysID}`);
136
+ params.set('sysparm_limit', '1');
137
+ params.set('sysparm_display_value', 'all');
138
+ params.set('sysparm_fields', ['sys_id', ...fields].join(','));
139
+ const records = await app.sdk.list(table, params);
140
+ persisted = records[0] || null;
141
+ } catch {
142
+ // Verification GET failed — don't fail the mutation, just report it.
143
+ mismatches.push({ field: '(verify)', sent: '', got: 'write-back check failed (GET error)' });
144
+ return mismatches;
145
+ }
146
+
147
+ if (!persisted) {
148
+ mismatches.push({ field: '(verify)', sent: '', got: 'record not found after write' });
149
+ return mismatches;
150
+ }
151
+
152
+ for (const field of fields) {
153
+ const sent = normalizeSupplied(sentData[field]);
154
+ const got = getRawValue(persisted[field]);
155
+ if (sent !== got) {
156
+ mismatches.push({ field, sent, got });
157
+ }
158
+ }
159
+
160
+ return mismatches;
161
+ }
162
+
58
163
  export function formatRecordForDisplay(record, columns) {
59
164
  const result = {};
60
165
 
@@ -154,7 +259,7 @@ export function assertSafeExactMatch(value) {
154
259
  export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
155
260
  app.requireInstance();
156
261
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
157
- if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin)) {
262
+ if (effectiveFormat !== FormatAuto || !canPrompt()) {
158
263
  return null; // not interactive — caller should fall back to text/table
159
264
  }
160
265
 
package/src/mutations.js CHANGED
@@ -75,11 +75,20 @@ export const MUTATION_COMMANDS = [
75
75
  ['scopes', 'set'],
76
76
  ['dev', 'scopes', 'create'],
77
77
  ['dev', 'scopes', 'set'],
78
- // Update sets: create + set (root + dev forms)
78
+ // Domains: set writes the profile config (root + dev forms)
79
+ ['domains', 'set'],
80
+ ['dev', 'domains', 'set'],
81
+ // Update sets: create, set, complete, ignore, parent (root + dev forms)
79
82
  ['updatesets', 'create'],
80
83
  ['updatesets', 'set'],
84
+ ['updatesets', 'complete'],
85
+ ['updatesets', 'ignore'],
86
+ ['updatesets', 'parent'],
81
87
  ['dev', 'updatesets', 'create'],
82
88
  ['dev', 'updatesets', 'set'],
89
+ ['dev', 'updatesets', 'complete'],
90
+ ['dev', 'updatesets', 'ignore'],
91
+ ['dev', 'updatesets', 'parent'],
83
92
  ];
84
93
 
85
94
  /**
package/src/output.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Output formatting: JSON, Markdown, Styled, Quiet
2
2
 
3
3
  import process from 'node:process';
4
- import chalk from 'chalk';
5
4
 
6
5
  export const FormatAuto = 'auto';
7
6
  export const FormatJSON = 'json';
@@ -28,6 +27,12 @@ export function isTTY(writer) {
28
27
  return writer.isTTY === true;
29
28
  }
30
29
 
30
+ // Minimal ANSI colors — replaces chalk (only used for error/hint styling,
31
+ // which is already TTY-gated by callers).
32
+ const ansi = (code) => (text) => `\x1b[${code}m${text}\x1b[0m`;
33
+ const colorRed = ansi('31');
34
+ const colorYellow = ansi('33');
35
+
31
36
  export class OutputWriter {
32
37
  constructor(opts = {}) {
33
38
  this.format = opts.format || FormatAuto;
@@ -82,8 +87,8 @@ export class OutputWriter {
82
87
  break;
83
88
  default:
84
89
  if (isTTY(this.writer)) {
85
- this.writer.write(chalk.red(`Error (${e.code}): ${e.message}\n`));
86
- if (e.hint) this.writer.write(chalk.yellow(`Hint: ${e.hint}\n`));
90
+ this.writer.write(colorRed(`Error (${e.code}): ${e.message}\n`));
91
+ if (e.hint) this.writer.write(colorYellow(`Hint: ${e.hint}\n`));
87
92
  } else {
88
93
  this.writer.write(JSON.stringify(resp, null, 2) + '\n');
89
94
  }
package/src/sdk.js CHANGED
@@ -11,6 +11,7 @@ export class SDKClient {
11
11
  this.baseURL = baseURL.replace(/\/$/, '');
12
12
  this.authProvider = authProvider;
13
13
  this.timeout = opts.timeout || DEFAULT_TIMEOUT;
14
+ this.domain = opts.domain || '';
14
15
  }
15
16
 
16
17
  async _setAuth(req) {
@@ -53,6 +54,9 @@ export class SDKClient {
53
54
  if (opts.body && typeof opts.body === 'string') {
54
55
  req.headers.set('Content-Type', 'application/json');
55
56
  }
57
+ if (this.domain) {
58
+ req.headers.set('X-Now-Domain', this.domain);
59
+ }
56
60
  await this._setAuth(req);
57
61
 
58
62
  const resp = await fetch(req);