@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.
@@ -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/nodejs/skills/servicenow/SKILL.md';
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
@@ -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');
@@ -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 vowelArticle(word) {
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 = 0;
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
  }
@@ -43,7 +43,7 @@ export const paginatedSearch = createPrompt((config, done) => {
43
43
  setActive(0);
44
44
  setStatus('done');
45
45
  }
46
- } catch (err) {
46
+ } catch {
47
47
  if (!controller.signal.aborted) {
48
48
  setStatus('done');
49
49
  setChoices([]);
@@ -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');