@jacebenson/jsn 4.0.0 → 4.0.2
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 +2 -1
- package/skills/servicenow/SKILL.md +2 -2
- package/src/auth.js +30 -10
- package/src/cli.js +13 -19
- package/src/commands/_ticket.js +7 -1
- package/src/commands/auth.js +10 -0
- package/src/commands/catalog.js +3 -4
- package/src/commands/dev/_generic.js +7 -14
- package/src/commands/dev/flows.js +161 -2
- package/src/commands/dev/forms.js +0 -11
- package/src/commands/dev/import.js +1 -1
- package/src/commands/dev/lists.js +2 -2
- package/src/commands/docs/community-sources.json +18 -0
- package/src/commands/docs/community.js +343 -0
- package/src/commands/docs/db.js +36 -0
- package/src/commands/docs/docs.js +41 -0
- package/src/commands/docs/ingest.js +4 -17
- package/src/commands/docs/refresh.js +4 -17
- package/src/commands/docs/search.js +1 -1
- package/src/commands/grouproles.js +5 -1
- package/src/commands/groups.js +6 -3
- package/src/commands/records.js +5 -2
- package/src/commands/requests.js +2 -1
- package/src/commands/skill.js +1 -6
- package/src/commands/tickets.js +4 -1
- package/src/commands/users.js +4 -1
- package/src/errors.js +26 -0
- package/src/helpers.js +65 -8
- package/src/mutations.js +7 -1
- package/src/output.js +6 -3
- package/src/paginated-search.js +1 -1
- package/src/records/inspect.js +2 -1
package/src/commands/skill.js
CHANGED
|
@@ -14,7 +14,7 @@ const SKILL_REPO_PATH = path.join(
|
|
|
14
14
|
'..', '..', 'skills', SKILL_NAME, 'SKILL.md'
|
|
15
15
|
);
|
|
16
16
|
|
|
17
|
-
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/
|
|
17
|
+
const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/main/skills/servicenow/SKILL.md';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Resolve the user's real home directory, even when Hermes overrides $HOME.
|
|
@@ -62,11 +62,6 @@ function hermesSkillDir() {
|
|
|
62
62
|
return path.join(base, 'skills', SKILL_NAME);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** Resolve the installed SKILL.md path for Hermes. */
|
|
66
|
-
function hermesSkillPath() {
|
|
67
|
-
return path.join(hermesSkillDir(), 'SKILL.md');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
65
|
// Known agent skill directories (personal/user-level)
|
|
71
66
|
// Keyed by agent name for the --target flag
|
|
72
67
|
// Each value is the directory for the <name>/SKILL.md file
|
package/src/commands/tickets.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, buildQuerySuffix, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function ticketsCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -60,6 +60,7 @@ export function ticketsCmd(wrap) {
|
|
|
60
60
|
aliases: ['get'],
|
|
61
61
|
describe: 'Show a ticket',
|
|
62
62
|
handler: wrap(async (argv, app) => {
|
|
63
|
+
assertSafeExactMatch(argv.number);
|
|
63
64
|
const params = new URLSearchParams();
|
|
64
65
|
params.set('sysparm_query', `number=${argv.number}`);
|
|
65
66
|
params.set('sysparm_limit', '1');
|
|
@@ -87,6 +88,7 @@ export function ticketsCmd(wrap) {
|
|
|
87
88
|
builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
|
|
88
89
|
handler: wrap(async (argv, app) => {
|
|
89
90
|
const recordData = JSON.parse(argv.data);
|
|
91
|
+
assertSafeExactMatch(argv.number);
|
|
90
92
|
const findParams = new URLSearchParams();
|
|
91
93
|
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
92
94
|
findParams.set('sysparm_limit', '1');
|
|
@@ -103,6 +105,7 @@ export function ticketsCmd(wrap) {
|
|
|
103
105
|
command: 'delete <number>',
|
|
104
106
|
describe: 'Delete a ticket',
|
|
105
107
|
handler: wrap(async (argv, app) => {
|
|
108
|
+
assertSafeExactMatch(argv.number);
|
|
106
109
|
const findParams = new URLSearchParams();
|
|
107
110
|
findParams.set('sysparm_query', `number=${argv.number}`);
|
|
108
111
|
findParams.set('sysparm_limit', '1');
|
package/src/commands/users.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam } from '../helpers.js';
|
|
1
|
+
import { formatRecordForDisplay, getStringField, interactiveList, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export function usersCmd(wrap) {
|
|
4
4
|
return {
|
|
@@ -57,6 +57,7 @@ export function usersCmd(wrap) {
|
|
|
57
57
|
describe: 'Show a user by user_name or sys_id',
|
|
58
58
|
handler: wrap(async (argv, app) => {
|
|
59
59
|
const id = argv.identifier;
|
|
60
|
+
assertSafeExactMatch(id);
|
|
60
61
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
61
62
|
const params = new URLSearchParams();
|
|
62
63
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
|
@@ -104,6 +105,7 @@ export function usersCmd(wrap) {
|
|
|
104
105
|
.option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
|
|
105
106
|
handler: wrap(async (argv, app) => {
|
|
106
107
|
const id = argv.identifier;
|
|
108
|
+
assertSafeExactMatch(id);
|
|
107
109
|
const recordData = JSON.parse(argv.data);
|
|
108
110
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
109
111
|
const params = new URLSearchParams();
|
|
@@ -130,6 +132,7 @@ export function usersCmd(wrap) {
|
|
|
130
132
|
describe: 'Delete a user by user_name or sys_id',
|
|
131
133
|
handler: wrap(async (argv, app) => {
|
|
132
134
|
const id = argv.identifier;
|
|
135
|
+
assertSafeExactMatch(id);
|
|
133
136
|
const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
|
|
134
137
|
const params = new URLSearchParams();
|
|
135
138
|
params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
|
package/src/errors.js
CHANGED
|
@@ -126,3 +126,29 @@ export function asError(err) {
|
|
|
126
126
|
export function isErrorCode(err, code) {
|
|
127
127
|
return err instanceof AppError && err.code === code;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format a safety-guard failure (mutation guard middleware in cli.js).
|
|
132
|
+
* In JSON mode writes the documented error envelope to stdout so piped
|
|
133
|
+
* consumers (`jsn ... --json | jq`) get a structured error instead of a
|
|
134
|
+
* raw stderr message. Otherwise writes human text to stderr.
|
|
135
|
+
* Always exits non-zero via guardExit.
|
|
136
|
+
*/
|
|
137
|
+
export function guardError(argv, { code, message, hint = '' }) {
|
|
138
|
+
if (argv?.format === 'json') {
|
|
139
|
+
return {
|
|
140
|
+
stream: 'stdout',
|
|
141
|
+
text: JSON.stringify({ ok: false, error: message, code, hint }) + '\n',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
let text = `Error: ${message}\n`;
|
|
145
|
+
if (hint) text += `\n${hint}\n`;
|
|
146
|
+
return { stream: 'stderr', text };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function guardExit(argv, opts) {
|
|
150
|
+
const { stream, text } = guardError(argv, opts);
|
|
151
|
+
if (stream === 'stdout') process.stdout.write(text);
|
|
152
|
+
else process.stderr.write(text);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
package/src/helpers.js
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
1
|
// Shared helper utilities
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import readline from 'node:readline';
|
|
4
5
|
import { paginatedSearch } from './paginated-search.js';
|
|
5
6
|
import { isTTY, FormatAuto } from './output.js';
|
|
7
|
+
import { getActiveProfile } from './config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Confirm a destructive action before executing.
|
|
11
|
+
*
|
|
12
|
+
* Default posture is ASK: interactive terminals get a y/N prompt;
|
|
13
|
+
* non-interactive invocation (pipes, scripts, AI agents) is rejected
|
|
14
|
+
* unless --force is passed. A profile can opt out entirely with the
|
|
15
|
+
* per-instance `skip_confirmations` flag (set via `auth login
|
|
16
|
+
* --skip-confirmations`).
|
|
17
|
+
*
|
|
18
|
+
* @param {object} app — app instance (reads active profile from app.config)
|
|
19
|
+
* @param {object} argv — parsed args (checks argv.force)
|
|
20
|
+
* @param {string} question — e.g. `Delete incident INC0010001?`
|
|
21
|
+
* @returns {Promise<boolean>} true when the action may proceed
|
|
22
|
+
* @throws when confirmation is required but denied/unavailable
|
|
23
|
+
*/
|
|
24
|
+
export async function confirmDelete(app, argv, question) {
|
|
25
|
+
const profile = getActiveProfile(app.config);
|
|
26
|
+
if (profile?.skip_confirmations === true) return true;
|
|
27
|
+
if (argv.force) return true;
|
|
28
|
+
if (isTTY(process.stdout) && isTTY(process.stdin)) {
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const answer = await new Promise((resolve) => {
|
|
31
|
+
rl.question(`${question} (y/N): `, resolve);
|
|
32
|
+
});
|
|
33
|
+
rl.close();
|
|
34
|
+
const response = answer.trim().toLowerCase();
|
|
35
|
+
if (response !== 'y' && response !== 'yes') {
|
|
36
|
+
throw new Error('Deletion cancelled');
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${question} — confirmation required. Pass --force to skip, or set skip_confirmations on the profile to disable prompts.`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
6
45
|
|
|
7
46
|
export function getStringField(record, field) {
|
|
8
47
|
if (!record || typeof record !== 'object') return '';
|
|
@@ -74,6 +113,29 @@ export function resolveFieldsParam(columns) {
|
|
|
74
113
|
return ['sys_id', ...columns].join(',');
|
|
75
114
|
}
|
|
76
115
|
|
|
116
|
+
// ServiceNow encoded-query metacharacters. In sysparm_query, `^` is the
|
|
117
|
+
// AND-separator (^OR starts an OR clause), `= < > ~ !` are operators,
|
|
118
|
+
// and `,` is used by IN lists. An identifier containing any of these
|
|
119
|
+
// silently broadens an exact-match lookup — with sysparm_limit=1 the
|
|
120
|
+
// wrong record wins (issue #143 finding #5). Exact-match identifiers
|
|
121
|
+
// (numbers, sys_ids, names) never legitimately contain them.
|
|
122
|
+
const QUERY_METACHARS = /[\^=<>~!,]/;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate that an identifier is safe to use in an exact-match encoded
|
|
126
|
+
* query (field=value). Throws when the value contains characters that
|
|
127
|
+
* would change the query semantics.
|
|
128
|
+
* @param {string} value — identifier (number, sys_id, name)
|
|
129
|
+
*/
|
|
130
|
+
export function assertSafeExactMatch(value) {
|
|
131
|
+
if (value && QUERY_METACHARS.test(value)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Unsafe identifier for exact-match lookup: contains ServiceNow query characters (^ = < > ~ ! ,). ` +
|
|
134
|
+
`Refusing to build a query that could match more than the named record.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
77
139
|
/**
|
|
78
140
|
* Shared interactive list helper with search-as-you-type.
|
|
79
141
|
* All list commands that want an interactive TTY picker should use this.
|
|
@@ -89,12 +151,7 @@ export function resolveFieldsParam(columns) {
|
|
|
89
151
|
* @param {string} opts.labelField — field used to match selection (default: 'name')
|
|
90
152
|
* @returns {Promise<void>|null} null if no selection made or non-interactive
|
|
91
153
|
*/
|
|
92
|
-
function
|
|
93
|
-
const first = word.charAt(0).toLowerCase();
|
|
94
|
-
return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name' }) {
|
|
154
|
+
export async function interactiveList({ app, table, singular, columns, limit = 50, _query = '', formatLabel, labelField = 'name' }) {
|
|
98
155
|
app.requireInstance();
|
|
99
156
|
const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
|
|
100
157
|
if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin)) {
|
|
@@ -105,7 +162,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
105
162
|
const pickerFields = pickerColumns.join(',');
|
|
106
163
|
|
|
107
164
|
// Get total count
|
|
108
|
-
let totalCount
|
|
165
|
+
let totalCount;
|
|
109
166
|
try {
|
|
110
167
|
totalCount = await app.sdk.aggregateCount(table, '');
|
|
111
168
|
} catch {
|
|
@@ -114,7 +171,7 @@ export async function interactiveList({ app, table, singular, columns, limit = 5
|
|
|
114
171
|
if (totalCount === 0) return null;
|
|
115
172
|
|
|
116
173
|
// Paginated source adapter: (term, offset, { signal }) => choices[]
|
|
117
|
-
async function serverSource(term, offset, { signal } = {}) {
|
|
174
|
+
async function serverSource(term, offset, { signal: _signal } = {}) {
|
|
118
175
|
try {
|
|
119
176
|
const params = new URLSearchParams();
|
|
120
177
|
params.set('sysparm_limit', String(limit));
|
package/src/mutations.js
CHANGED
|
@@ -62,8 +62,14 @@ export const MUTATION_COMMANDS = [
|
|
|
62
62
|
['catalog', 'create-item'],
|
|
63
63
|
// Dev CRUD commands (root + `jsn dev` forms)
|
|
64
64
|
...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
|
|
65
|
-
// Dev eval (no sub-action)
|
|
65
|
+
// Dev eval (no sub-action) — both spellings must be gated:
|
|
66
|
+
// `jsn eval` (root) and legacy `jsn dev eval`
|
|
67
|
+
['eval'],
|
|
66
68
|
['dev', 'eval'],
|
|
69
|
+
// Raw REST passthrough — can hit any endpoint with any method,
|
|
70
|
+
// so the whole command is a mutation surface on read-only profiles
|
|
71
|
+
['rest'],
|
|
72
|
+
['dev', 'rest'],
|
|
67
73
|
// Scopes: create + set (root + dev forms)
|
|
68
74
|
['scopes', 'create'],
|
|
69
75
|
['scopes', 'set'],
|
package/src/output.js
CHANGED
|
@@ -200,12 +200,15 @@ export class OutputWriter {
|
|
|
200
200
|
// Show lock icon for read-only profiles
|
|
201
201
|
const lockIcon = p.read_only ? ' 🔒' : '';
|
|
202
202
|
|
|
203
|
+
// Show confirmations-off badge for skip_confirmations profiles
|
|
204
|
+
const confirmBadge = p.skip_confirmations ? ' ⚡' : '';
|
|
205
|
+
|
|
203
206
|
if (p.name) {
|
|
204
|
-
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
207
|
+
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
205
208
|
} else if (p.username) {
|
|
206
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
209
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
207
210
|
} else {
|
|
208
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
211
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${confirmBadge}${verifiedStr}${staleStr}\n`);
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
214
|
}
|
package/src/paginated-search.js
CHANGED
package/src/records/inspect.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isHexString, getStringField } from '../helpers.js';
|
|
1
|
+
import { isHexString, getStringField, assertSafeExactMatch } from '../helpers.js';
|
|
2
2
|
|
|
3
3
|
export async function resolveIdentifier(app, table, identifier) {
|
|
4
4
|
// If it looks like a sys_id (32 hex chars), use it directly
|
|
@@ -7,6 +7,7 @@ export async function resolveIdentifier(app, table, identifier) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// Otherwise, assume it's a record number — query the table
|
|
10
|
+
assertSafeExactMatch(identifier);
|
|
10
11
|
const params = new URLSearchParams();
|
|
11
12
|
params.set('sysparm_query', `number=${identifier}`);
|
|
12
13
|
params.set('sysparm_limit', '1');
|