@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/package.json +1 -3
- package/src/app.js +25 -3
- package/src/cli.js +17 -1
- package/src/commands/_ticket.js +3 -3
- package/src/commands/auth.js +116 -7
- package/src/commands/catalog.js +66 -4
- package/src/commands/dev/_generic.js +3 -3
- package/src/commands/dev/domains.js +292 -0
- package/src/commands/dev/eval.js +27 -5
- package/src/commands/dev/scopes.js +88 -36
- package/src/commands/dev/updatesets.js +297 -106
- package/src/commands/dev.js +16 -1
- package/src/commands/records.js +23 -5
- package/src/context.js +52 -5
- package/src/errors.js +15 -0
- package/src/help.js +16 -2
- package/src/helpers.js +110 -5
- package/src/mutations.js +10 -1
- package/src/output.js +8 -3
- package/src/sdk.js +4 -0
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
|
-
|
|
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 (
|
|
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
|
-
|
|
41
|
-
|
|
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 || !
|
|
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
|
-
//
|
|
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(
|
|
86
|
-
if (e.hint) this.writer.write(
|
|
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);
|