@jacebenson/jsn 4.0.4 → 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/cli.js +14 -0
- package/src/commands/_ticket.js +3 -3
- package/src/commands/catalog.js +66 -4
- package/src/commands/dev/_generic.js +3 -3
- package/src/commands/dev/eval.js +27 -5
- package/src/commands/records.js +20 -3
- package/src/errors.js +15 -0
- package/src/helpers.js +110 -5
- package/src/output.js +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
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": {
|
|
@@ -29,8 +29,6 @@
|
|
|
29
29
|
"@inquirer/core": "^10.1.0",
|
|
30
30
|
"@inquirer/prompts": "^7.5.1",
|
|
31
31
|
"better-sqlite3": "^13.0.3",
|
|
32
|
-
"chalk": "^5.4.1",
|
|
33
|
-
"cli-table3": "^0.6.5",
|
|
34
32
|
"gray-matter": "^4.0.3",
|
|
35
33
|
"turndown": "^7.2.4",
|
|
36
34
|
"yargs": "^17.7.2"
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { App } from './app.js';
|
|
|
10
10
|
import { renderHelp } from './help.js';
|
|
11
11
|
import { isMutationCommand } from './mutations.js';
|
|
12
12
|
import { guardExit } from './errors.js';
|
|
13
|
+
import { FormatJSON } from './output.js';
|
|
13
14
|
|
|
14
15
|
// Command modules
|
|
15
16
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -86,6 +87,19 @@ function wrap(handler) {
|
|
|
86
87
|
process.stderr.write(`Error (system): ${err.message}\n`);
|
|
87
88
|
process.exit(3);
|
|
88
89
|
}
|
|
90
|
+
if (err.code === 'confirmation_required') {
|
|
91
|
+
// Structured error for AI agents / piped consumers: emit the
|
|
92
|
+
// JSON envelope on stdout (where tooling reads) so the agent can
|
|
93
|
+
// surface the decision to its user or re-run with --force.
|
|
94
|
+
const app = argv.app;
|
|
95
|
+
if (app && app.output.effectiveFormat() === FormatJSON) {
|
|
96
|
+
app.err(err);
|
|
97
|
+
} else {
|
|
98
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
99
|
+
if (err.hint) process.stderr.write(`\n${err.hint}\n`);
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
89
103
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
90
104
|
process.exit(1);
|
|
91
105
|
}
|
package/src/commands/_ticket.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Generic command builder for CRUD operations on a ServiceNow table
|
|
2
2
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
3
|
|
|
4
|
-
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
|
|
4
|
+
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch, canPrompt } from '../helpers.js';
|
|
5
5
|
import { search } from '@inquirer/prompts';
|
|
6
|
-
import {
|
|
6
|
+
import { FormatAuto } from '../output.js';
|
|
7
7
|
|
|
8
8
|
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
|
|
9
9
|
return {
|
|
@@ -28,7 +28,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
28
28
|
const offset = argv.offset;
|
|
29
29
|
|
|
30
30
|
// Interactive picker in TTY with auto format and no explicit query/offset
|
|
31
|
-
const isInteractive =
|
|
31
|
+
const isInteractive = canPrompt();
|
|
32
32
|
if (isInteractive && app.output.getFormat() === FormatAuto && !query && offset === 0) {
|
|
33
33
|
const pickerColumns = ['sys_id', 'number', 'short_description', 'state', 'assigned_to'];
|
|
34
34
|
const pickerParams = new URLSearchParams();
|
package/src/commands/catalog.js
CHANGED
|
@@ -175,7 +175,15 @@ export function catalogCmd(wrap) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
async function showItem(app, sysID) {
|
|
178
|
-
|
|
178
|
+
// Use list + display_value=all so reference fields (category, flow) resolve
|
|
179
|
+
// to readable display values instead of raw sys_ids.
|
|
180
|
+
const p = new URLSearchParams();
|
|
181
|
+
p.set('sysparm_query', `sys_id=${sysID}`);
|
|
182
|
+
p.set('sysparm_limit', '1');
|
|
183
|
+
p.set('sysparm_display_value', 'all');
|
|
184
|
+
const items = await app.sdk.list('sc_cat_item', p);
|
|
185
|
+
const item = items[0] || null;
|
|
186
|
+
if (!item) throw new Error(`Not found: ${sysID}`);
|
|
179
187
|
|
|
180
188
|
const vp = new URLSearchParams();
|
|
181
189
|
vp.set('sysparm_query', `cat_item=${sysID}^active=true`);
|
|
@@ -216,7 +224,8 @@ async function showItem(app, sysID) {
|
|
|
216
224
|
const wfName = getStringField(item, 'workflow') || '';
|
|
217
225
|
const wfID = item.workflow?.value || '';
|
|
218
226
|
const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
|
|
219
|
-
|
|
227
|
+
|
|
228
|
+
const data = {
|
|
220
229
|
name: getStringField(item, 'name'),
|
|
221
230
|
short_description: getStringField(item, 'short_description'),
|
|
222
231
|
category: getStringField(item, 'category'),
|
|
@@ -232,7 +241,60 @@ async function showItem(app, sysID) {
|
|
|
232
241
|
standalone: standalone.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })),
|
|
233
242
|
sets: Array.from(setVars.values()).map(s => ({ name: s.name, variables: s.variables.map(v => ({ label: v.question_text || v.name, type: v.type, mandatory: String(v.mandatory) === 'true' })) })),
|
|
234
243
|
} : undefined,
|
|
235
|
-
}
|
|
236
|
-
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Styled-mode rendering (non-enumerable so JSON output stays clean)
|
|
247
|
+
Object.defineProperty(data, '_formatted', {
|
|
248
|
+
value: buildCatalogFormatted(data, standalone, Array.from(setVars.values()), totalVars),
|
|
249
|
+
enumerable: false,
|
|
250
|
+
configurable: true,
|
|
237
251
|
});
|
|
252
|
+
|
|
253
|
+
app.ok(data, {
|
|
254
|
+
summary: `${data.name} — ${totalVars} variable(s)`,
|
|
255
|
+
breadcrumbs: [
|
|
256
|
+
{ action: 'list', cmd: 'jsn catalogitems list', description: 'Back to all catalog items' },
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildCatalogFormatted(data, standalone, setVars, totalVars) {
|
|
262
|
+
const lines = [];
|
|
263
|
+
lines.push(`${data.name} (sc_cat_item)`);
|
|
264
|
+
lines.push('');
|
|
265
|
+
lines.push('─ Details ─');
|
|
266
|
+
if (data.short_description) lines.push(` short_description: ${data.short_description}`);
|
|
267
|
+
if (data.category) lines.push(` category: ${data.category}`);
|
|
268
|
+
lines.push(` active: ${data.active}`);
|
|
269
|
+
if (data.url) lines.push(` url: ${data.url}`);
|
|
270
|
+
if (data.execution_plan) lines.push(` execution_plan: ${data.execution_plan}`);
|
|
271
|
+
lines.push('');
|
|
272
|
+
if (data.flow) {
|
|
273
|
+
lines.push('─ Flow ─');
|
|
274
|
+
lines.push(` ${data.flow}`);
|
|
275
|
+
if (data.flow_cmd) lines.push(` → ${data.flow_cmd}`);
|
|
276
|
+
lines.push('');
|
|
277
|
+
}
|
|
278
|
+
if (data.workflow) {
|
|
279
|
+
lines.push('─ Workflow ─');
|
|
280
|
+
lines.push(` ${data.workflow}`);
|
|
281
|
+
if (data.workflow_cmd) lines.push(` → ${data.workflow_cmd}`);
|
|
282
|
+
lines.push('');
|
|
283
|
+
}
|
|
284
|
+
if (totalVars > 0) {
|
|
285
|
+
lines.push('─ Catalog Variables ─');
|
|
286
|
+
for (const v of standalone) {
|
|
287
|
+
const label = v.question_text || v.name;
|
|
288
|
+
lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
|
|
289
|
+
}
|
|
290
|
+
for (const s of setVars) {
|
|
291
|
+
lines.push(` [${s.name}]`);
|
|
292
|
+
for (const v of s.variables) {
|
|
293
|
+
const label = v.question_text || v.name;
|
|
294
|
+
lines.push(` ${label}: ${v.type}${String(v.mandatory) === 'true' ? ' (mandatory)' : ''}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
lines.push('');
|
|
298
|
+
}
|
|
299
|
+
return lines.join('\n');
|
|
238
300
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Generic dev subcommand builder for table-based CRUD
|
|
2
2
|
|
|
3
|
-
import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch } from '../../helpers.js';
|
|
3
|
+
import { formatRecordForDisplay, getStringField, isHexString, parseDataArg, confirmDelete, assertSafeExactMatch, canPrompt } from '../../helpers.js';
|
|
4
4
|
import { getCurrentUser, getCurrentApplication } from '../../context.js';
|
|
5
5
|
import { paginatedSearch } from '../../paginated-search.js';
|
|
6
|
-
import {
|
|
6
|
+
import { FormatAuto } from '../../output.js';
|
|
7
7
|
|
|
8
8
|
function vowelArticle(word) {
|
|
9
9
|
const first = word.charAt(0).toLowerCase();
|
|
@@ -111,7 +111,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
111
111
|
const limit = argv.limit;
|
|
112
112
|
|
|
113
113
|
// Interactive picker with pagination
|
|
114
|
-
const interactive =
|
|
114
|
+
const interactive = canPrompt();
|
|
115
115
|
if (interactive && app.output.getFormat() === FormatAuto && !query) {
|
|
116
116
|
// Get total count
|
|
117
117
|
let totalCount = 0;
|
package/src/commands/dev/eval.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { getCurrentUser, getCurrentApplication } from '../../context.js';
|
|
2
3
|
|
|
3
4
|
export function evalCmd(wrap) {
|
|
4
5
|
return {
|
|
@@ -8,16 +9,34 @@ export function evalCmd(wrap) {
|
|
|
8
9
|
return yargs
|
|
9
10
|
.option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
|
|
10
11
|
.option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
|
|
11
|
-
.option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default:
|
|
12
|
+
.option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: active scope from banner)' });
|
|
12
13
|
},
|
|
13
14
|
handler: wrap(async (argv, app) => {
|
|
14
15
|
let script;
|
|
15
16
|
let scopeSysId = null;
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Resolve execution scope: explicit --scope wins; otherwise use the
|
|
19
|
+
// active scope from apps.current_app (same source as the banner);
|
|
20
|
+
// fall back to global only when no active scope exists.
|
|
21
|
+
let scope = argv.scope || '';
|
|
22
|
+
if (!scope) {
|
|
23
|
+
try {
|
|
24
|
+
const user = await getCurrentUser(app.sdk);
|
|
25
|
+
if (user && user.sys_id) {
|
|
26
|
+
const currentApp = await getCurrentApplication(app.sdk, user.sys_id);
|
|
27
|
+
if (currentApp && currentApp.scope && currentApp.scope !== 'global') {
|
|
28
|
+
scope = currentApp.scope;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore — fall back to global
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (scope) {
|
|
37
|
+
scopeSysId = await app.sdk.resolveScope(scope);
|
|
19
38
|
if (!scopeSysId) {
|
|
20
|
-
throw new Error(`Scope not found: ${
|
|
39
|
+
throw new Error(`Scope not found: ${scope}`);
|
|
21
40
|
}
|
|
22
41
|
}
|
|
23
42
|
|
|
@@ -36,10 +55,13 @@ export function evalCmd(wrap) {
|
|
|
36
55
|
const warning = scopeSysId
|
|
37
56
|
? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
|
|
38
57
|
: '';
|
|
39
|
-
|
|
58
|
+
// Pass the resolved sys_id (not the name) — sys.scripts.do only
|
|
59
|
+
// honors selectable scope values, which are sys_ids.
|
|
60
|
+
const output = await app.sdk.executeScript(script, scopeSysId || '');
|
|
40
61
|
app.ok({
|
|
41
62
|
script,
|
|
42
63
|
output,
|
|
64
|
+
scope: scope || 'global',
|
|
43
65
|
instance: app.getEffectiveInstance(),
|
|
44
66
|
}, {
|
|
45
67
|
summary: 'Script executed' + warning,
|
package/src/commands/records.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete, assertSafeExactMatch, verifyWriteBack } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
const tableDefaultColumns = {
|
|
4
4
|
incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
|
|
@@ -135,7 +135,8 @@ export function recordsCmd(wrap) {
|
|
|
135
135
|
.option('table', { type: 'string', demandOption: true, describe: 'Table name' })
|
|
136
136
|
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
137
137
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
138
|
-
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
138
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
139
|
+
.option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
|
|
139
140
|
handler: wrap(async (argv, app) => {
|
|
140
141
|
const recordData = parseDataArg(argv);
|
|
141
142
|
const warnings = checkDerivedFields(argv.table, recordData);
|
|
@@ -143,6 +144,14 @@ export function recordsCmd(wrap) {
|
|
|
143
144
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
144
145
|
}
|
|
145
146
|
const record = await app.sdk.create(argv.table, recordData);
|
|
147
|
+
const sysID = getStringField(record, 'sys_id');
|
|
148
|
+
const mismatches = await verifyWriteBack(app, argv.table, sysID, recordData);
|
|
149
|
+
for (const m of mismatches) {
|
|
150
|
+
process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
|
|
151
|
+
}
|
|
152
|
+
if (mismatches.length > 0 && argv.strict) {
|
|
153
|
+
throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after create`);
|
|
154
|
+
}
|
|
146
155
|
app.ok(record, { summary: `Created record in ${argv.table}` });
|
|
147
156
|
}),
|
|
148
157
|
})
|
|
@@ -154,7 +163,8 @@ export function recordsCmd(wrap) {
|
|
|
154
163
|
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
|
|
155
164
|
.option('data', { type: 'string', describe: 'JSON fields (e.g. \'{"state":"2"}\')' })
|
|
156
165
|
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
157
|
-
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
166
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin (pipe-friendly)' })
|
|
167
|
+
.option('strict', { type: 'boolean', default: false, describe: 'Exit non-zero if any supplied field was not persisted (write-back check)' }),
|
|
158
168
|
handler: wrap(async (argv, app) => {
|
|
159
169
|
const recordData = parseDataArg(argv);
|
|
160
170
|
const warnings = checkDerivedFields(argv.table, recordData);
|
|
@@ -162,6 +172,13 @@ export function recordsCmd(wrap) {
|
|
|
162
172
|
process.stderr.write(`⚠ ${w.hint}\n`);
|
|
163
173
|
}
|
|
164
174
|
const record = await app.sdk.update(argv.table, argv['sys-id'], recordData);
|
|
175
|
+
const mismatches = await verifyWriteBack(app, argv.table, argv['sys-id'], recordData);
|
|
176
|
+
for (const m of mismatches) {
|
|
177
|
+
process.stderr.write(`⚠ field "${m.field}" was not persisted (sent: "${m.sent}", got: "${m.got}")\n`);
|
|
178
|
+
}
|
|
179
|
+
if (mismatches.length > 0 && argv.strict) {
|
|
180
|
+
throw new Error(`Strict mode: ${mismatches.length} field(s) not persisted after update`);
|
|
181
|
+
}
|
|
165
182
|
app.ok(record, { summary: `Updated record in ${argv.table}` });
|
|
166
183
|
}),
|
|
167
184
|
})
|
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/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/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
|
}
|