@jacebenson/jsn 3.4.3 → 4.0.1

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.
Files changed (40) hide show
  1. package/README.md +5 -4
  2. package/package.json +3 -1
  3. package/skills/servicenow/SKILL.md +3 -3
  4. package/src/app.js +7 -7
  5. package/src/auth.js +34 -63
  6. package/src/cli.js +27 -20
  7. package/src/commands/_ticket.js +7 -1
  8. package/src/commands/auth.js +303 -40
  9. package/src/commands/catalog.js +3 -4
  10. package/src/commands/dev/_generic.js +7 -14
  11. package/src/commands/dev/flows.js +110 -2
  12. package/src/commands/dev/forms.js +0 -11
  13. package/src/commands/dev/import.js +1 -1
  14. package/src/commands/dev/lists.js +2 -2
  15. package/src/commands/dev/updatesets.js +0 -16
  16. package/src/commands/docs/community-sources.json +18 -0
  17. package/src/commands/docs/community.js +343 -0
  18. package/src/commands/docs/db.js +38 -2
  19. package/src/commands/docs/docs.js +41 -0
  20. package/src/commands/docs/ingest.js +4 -17
  21. package/src/commands/docs/refresh.js +4 -17
  22. package/src/commands/docs/search.js +3 -3
  23. package/src/commands/docs/serve.js +2 -2
  24. package/src/commands/grouproles.js +5 -1
  25. package/src/commands/groups.js +6 -3
  26. package/src/commands/records.js +5 -2
  27. package/src/commands/requests.js +2 -1
  28. package/src/commands/setup.js +10 -84
  29. package/src/commands/skill.js +5 -7
  30. package/src/commands/tickets.js +4 -1
  31. package/src/commands/users.js +4 -1
  32. package/src/config.js +9 -0
  33. package/src/errors.js +26 -0
  34. package/src/help.js +2 -4
  35. package/src/helpers.js +65 -8
  36. package/src/mutations.js +40 -9
  37. package/src/output.js +6 -3
  38. package/src/paginated-search.js +1 -1
  39. package/src/records/inspect.js +2 -1
  40. package/src/commands/profiles.js +0 -190
@@ -1,7 +1,7 @@
1
1
  // CLI search against the docs SQLite database.
2
2
 
3
3
  import fs from 'node:fs';
4
- import { DatabaseSync } from 'node:sqlite';
4
+ import Database from 'better-sqlite3';
5
5
  import { getDocsDbPath, hasEmbeddings } from './db.js';
6
6
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
7
7
 
@@ -24,7 +24,7 @@ export function searchDocs(opts = {}) {
24
24
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
25
25
  }
26
26
 
27
- const db = new DatabaseSync(dbPath, { readOnly: true });
27
+ const db = new Database(dbPath, { readonly: true });
28
28
  const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
29
29
  const offset = parseInt(opts.offset, 10) || 0;
30
30
  const bundle = opts.bundle ? String(opts.bundle) : null;
@@ -46,7 +46,7 @@ export function searchDocs(opts = {}) {
46
46
 
47
47
  // Total matching docs (for pagination)
48
48
  const total = db
49
- .prepare(`SELECT COUNT(*) AS n FROM docs_fts WHERE docs_fts MATCH @q${where}`)
49
+ .prepare(`SELECT COUNT(*) AS n FROM docs_fts JOIN docs d ON d.id = docs_fts.rowid WHERE docs_fts MATCH @q${where}`)
50
50
  .get({ q, ...(bundle && { bundle }), ...(docType && { docType }) }).n;
51
51
 
52
52
  let rows;
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import http from 'node:http';
6
6
  import { URL } from 'node:url';
7
- import { DatabaseSync } from 'node:sqlite';
7
+ import Database from 'better-sqlite3';
8
8
  import { getDocsDbPath, hasEmbeddings } from './db.js';
9
9
  import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
10
10
 
@@ -72,7 +72,7 @@ export function serveDocs(opts = {}) {
72
72
  throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
73
73
  }
74
74
 
75
- const db = new DatabaseSync(dbPath, { readOnly: true });
75
+ const db = new Database(dbPath, { readonly: true });
76
76
  const hasEmb = hasEmbeddings(db);
77
77
  const hrrDim = (() => {
78
78
  try {
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, getStringField, resolveFieldsParam } from '../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, resolveFieldsParam, assertSafeExactMatch } from '../helpers.js';
2
2
 
3
3
  export function groupRolesCmd(wrap) {
4
4
  return {
@@ -42,6 +42,8 @@ export function groupRolesCmd(wrap) {
42
42
  handler: wrap(async (argv, app) => {
43
43
  const groupName = argv.group;
44
44
  const roleName = argv.role;
45
+ assertSafeExactMatch(groupName);
46
+ assertSafeExactMatch(roleName);
45
47
 
46
48
  // Resolve group sys_id
47
49
  const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
@@ -100,6 +102,8 @@ export function groupRolesCmd(wrap) {
100
102
  handler: wrap(async (argv, app) => {
101
103
  const groupName = argv.group;
102
104
  const roleName = argv.role;
105
+ assertSafeExactMatch(groupName);
106
+ assertSafeExactMatch(roleName);
103
107
 
104
108
  // Resolve group sys_id
105
109
  const isGroupSysID = groupName.length === 32 && /^[0-9a-fA-F]+$/.test(groupName);
@@ -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 groupsCmd(wrap) {
4
4
  return {
@@ -32,13 +32,13 @@ export function groupsCmd(wrap) {
32
32
  cp.set('sysparm_query', `group=${sysID}`); cp.set('sysparm_limit', '0'); cp.set('sysparm_fields', 'sys_id');
33
33
  const members = await app.sdk.list('sys_user_grmember', cp);
34
34
  memberCount = members.length;
35
- } catch {}
35
+ } catch { /* non-critical */ }
36
36
  try {
37
37
  const rp = new URLSearchParams();
38
38
  rp.set('sysparm_query', `group=${sysID}`); rp.set('sysparm_limit', '0'); rp.set('sysparm_fields', 'sys_id');
39
39
  const roles = await app.sdk.list('sys_group_has_role', rp);
40
40
  roleCount = roles.length;
41
- } catch {}
41
+ } catch { /* non-critical */ }
42
42
 
43
43
  picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user_group' };
44
44
  return app.ok(picked, {
@@ -72,6 +72,7 @@ export function groupsCmd(wrap) {
72
72
  describe: 'Show a group by name or sys_id',
73
73
  handler: wrap(async (argv, app) => {
74
74
  const id = argv.name;
75
+ assertSafeExactMatch(id);
75
76
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
76
77
  const params = new URLSearchParams();
77
78
  params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
@@ -117,6 +118,7 @@ export function groupsCmd(wrap) {
117
118
  .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
118
119
  handler: wrap(async (argv, app) => {
119
120
  const id = argv.name;
121
+ assertSafeExactMatch(id);
120
122
  const recordData = JSON.parse(argv.data);
121
123
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
122
124
  const params = new URLSearchParams();
@@ -142,6 +144,7 @@ export function groupsCmd(wrap) {
142
144
  describe: 'Delete a group by name or sys_id',
143
145
  handler: wrap(async (argv, app) => {
144
146
  const id = argv.name;
147
+ assertSafeExactMatch(id);
145
148
  const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
146
149
  const params = new URLSearchParams();
147
150
  params.set('sysparm_query', isSysID ? `sys_id=${id}` : `name=${id}`);
@@ -1,4 +1,4 @@
1
- import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields } from '../helpers.js';
1
+ import { formatRecordForDisplay, buildQuerySuffix, parseDataArg, getStringField, interactiveList, resolveFieldsParam, checkDerivedFields, confirmDelete } from '../helpers.js';
2
2
 
3
3
  const tableDefaultColumns = {
4
4
  incident: ['number', 'short_description', 'priority', 'state', 'assigned_to'],
@@ -115,6 +115,7 @@ export function recordsCmd(wrap) {
115
115
  .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
116
116
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' }),
117
117
  handler: wrap(async (argv, app) => {
118
+ assertSafeExactMatch(argv['sys-id']);
118
119
  const params = new URLSearchParams();
119
120
  params.set('sysparm_query', `sys_id=${argv['sys-id']}`);
120
121
  params.set('sysparm_limit', '1');
@@ -169,8 +170,10 @@ export function recordsCmd(wrap) {
169
170
  describe: 'Delete a record',
170
171
  builder: (y) => y
171
172
  .option('table', { type: 'string', demandOption: true, describe: 'Table name' })
172
- .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' }),
173
+ .option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id' })
174
+ .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
173
175
  handler: wrap(async (argv, app) => {
176
+ await confirmDelete(app, argv, `Delete record from ${argv.table} (${argv['sys-id']})`);
174
177
  await app.sdk.delete(argv.table, argv['sys-id']);
175
178
  app.ok({ message: 'Record deleted', table: argv.table, sys_id: argv['sys-id'] }, { summary: `Deleted record from ${argv.table}` });
176
179
  }),
@@ -1,5 +1,5 @@
1
1
  import { buildTicketCommands } from './_ticket.js';
2
- import { getStringField } from '../helpers.js';
2
+ import { getStringField, assertSafeExactMatch } from '../helpers.js';
3
3
 
4
4
  const requestDefaultColumns = ['number', 'short_description', 'stage', 'assigned_to'];
5
5
 
@@ -25,6 +25,7 @@ export function requestsCmd(wrap) {
25
25
 
26
26
  async function enrichedShowHandler(argv, app) {
27
27
  const number = argv.number;
28
+ assertSafeExactMatch(number);
28
29
  const params = new URLSearchParams();
29
30
  params.set('sysparm_query', `number=${number}`);
30
31
  params.set('sysparm_display_value', 'all');
@@ -1,10 +1,13 @@
1
- import readline from 'node:readline';
2
- import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
1
+ import { loginWizard } from './auth.js';
3
2
 
3
+ // `jsn setup` is kept as a hidden compatibility alias for `jsn auth login`.
4
+ // The wizard lives in auth.js (loginWizard) so there is exactly ONE code path
5
+ // for first-run onboarding — this command can never drift out of sync.
4
6
  export function setupCmd(wrap) {
5
7
  return {
6
8
  command: 'setup',
7
- describe: 'Interactive first-time setup',
9
+ describe: 'Interactive first-time setup (use "jsn auth login")',
10
+ hidden: true, // legacy alias for `jsn auth login` — works but hidden from help
8
11
  builder: (yargs) => {
9
12
  return yargs
10
13
  .option('read-only', {
@@ -13,87 +16,10 @@ export function setupCmd(wrap) {
13
16
  default: false,
14
17
  });
15
18
  },
16
- handler: wrap(async (_argv, app) => {
17
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
18
- const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
19
-
20
- console.log('Welcome to JSN - ServiceNow CLI');
21
- console.log();
22
-
23
- let instance = getEffectiveInstance(app.config);
24
- if (!instance) {
25
- instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
26
- instance = normalizeInstanceURL(instance);
27
- }
28
- console.log(`Instance: ${instance}`);
29
-
30
- // Warn if another profile already uses this instance URL
31
- const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
32
- if (existing) {
33
- console.log(`Note: instance is also used by profile "${existing[0]}".`);
34
- }
35
-
36
- const profileName = (await ask('Profile name (default): ')) || 'default';
37
- const profile = { instance_url: instance };
38
- if (_argv['read-only']) {
39
- profile.read_only = true;
40
- }
41
-
42
- const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
43
- const useBasic = authMethod.toLowerCase().startsWith('b');
44
-
45
- if (useBasic) {
46
- const username = await ask('Username: ');
47
- rl.close(); // Close outer readline before askHidden creates its own
48
- const { loadCredentials, askHidden } = await import('../auth.js');
49
- const existing = loadCredentials(instance) || loadCredentials(instance, username);
50
- if (existing && existing.auth_method === 'basic') {
51
- console.log(`Using existing credentials for ${instance}`);
52
- } else {
53
- const password = await askHidden('Password: ');
54
- const { saveCredentials } = await import('../auth.js');
55
- saveCredentials(instance, { auth_method: 'basic', username, password }, username);
56
- profile.username = username;
57
- console.log('Basic auth credentials saved');
58
- }
59
- profile.auth_method = 'basic';
60
- } else {
61
- profile.auth_method = 'oauth';
62
- }
63
-
64
- await setProfile(app.config, profileName, profile);
65
- // Set as active profile so subsequent commands know which instance to use
66
- app.config.activeProfile = profileName;
67
- app.config.defaultProfile = profileName;
68
- saveConfig(app.config);
69
-
70
- if (!useBasic) {
71
- const loginNow = await ask('Login now? [Y/n]: ');
72
- if (!loginNow || loginNow.toLowerCase() !== 'n') {
73
- await app.auth.login(instance);
74
- // Re-save credentials under <user>@<instance> after verifying username
75
- try {
76
- const { SDKClient } = await import('./sdk.js');
77
- const sdk = new SDKClient(instance, app.auth);
78
- const user = await sdk.getCurrentUser();
79
- if (user && user.user_name) {
80
- const { loadCredentials, saveCredentials } = await import('../auth.js');
81
- const stored = loadCredentials(instance);
82
- if (stored && stored.access_token) {
83
- stored.username = user.user_name;
84
- saveCredentials(instance, stored, user.user_name);
85
- }
86
- profile.username = user.user_name;
87
- }
88
- } catch {
89
- // Non-fatal — token saved, username will be set on next auth login
90
- }
91
- console.log('Login successful!');
92
- }
93
- }
94
-
95
- rl.close();
96
- app.ok({ setup: true, instance, profile: profileName }, { summary: 'Setup complete' });
19
+ handler: wrap(async (argv, app) => {
20
+ console.log('Note: "jsn setup" is now "jsn auth login". Running the same wizard...\n');
21
+ const result = await loginWizard(app, argv);
22
+ app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
97
23
  }),
98
24
  };
99
25
  }
@@ -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.
@@ -39,7 +39,10 @@ function realHomeDir() {
39
39
  * profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
40
40
  */
41
41
  function hermesSkillDir() {
42
- const base = path.join(realHomeDir(), '.hermes');
42
+ // JSN_HERMES_BASE_DIR lets tests point at a temp .hermes tree.
43
+ // realHomeDir() deliberately bypasses $HOME (Hermes overrides it to a
44
+ // sandbox dir), so tests can't control the base via HOME.
45
+ const base = process.env.JSN_HERMES_BASE_DIR || path.join(realHomeDir(), '.hermes');
43
46
  const profileEnv = process.env.HERMES_PROFILE;
44
47
  let profileName = profileEnv;
45
48
 
@@ -59,11 +62,6 @@ function hermesSkillDir() {
59
62
  return path.join(base, 'skills', SKILL_NAME);
60
63
  }
61
64
 
62
- /** Resolve the installed SKILL.md path for Hermes. */
63
- function hermesSkillPath() {
64
- return path.join(hermesSkillDir(), 'SKILL.md');
65
- }
66
-
67
65
  // Known agent skill directories (personal/user-level)
68
66
  // Keyed by agent name for the --target flag
69
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/config.js CHANGED
@@ -168,3 +168,12 @@ export function setProfile(cfg, name, profile) {
168
168
  cfg.profiles[name] = profile;
169
169
  return saveConfig(cfg);
170
170
  }
171
+
172
+ export function setActiveProfile(cfg, name) {
173
+ if (!cfg.profiles || !cfg.profiles[name]) {
174
+ throw new Error(`Profile not found: ${name}`);
175
+ }
176
+ cfg.defaultProfile = name;
177
+ cfg.activeProfile = name;
178
+ return saveConfig(cfg);
179
+ }
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/help.js CHANGED
@@ -107,10 +107,8 @@ const DATA_COMMANDS = [
107
107
  ];
108
108
 
109
109
  const CONFIG_COMMANDS = [
110
- { name: 'setup', desc: 'Interactive first-time setup' },
111
- { name: 'auth', desc: 'OAuth authentication' },
112
- { name: 'profiles', alias: 'profile', desc: 'Instance profiles' },
113
- { name: 'updatesets', desc: 'Update sets (set, yolo, export, import)' },
110
+ { name: 'auth', desc: 'Authentication (login, status, switch, refresh, logout, remove)' },
111
+ { name: 'updatesets', desc: 'Update sets (set, create, export)' },
114
112
  { name: 'scopes', desc: 'Application scopes' },
115
113
  ];
116
114
 
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
@@ -2,6 +2,29 @@
2
2
  // Used by the read-only profile middleware to block mutation commands.
3
3
  // Each entry is a command path array matching argv._ tokens.
4
4
 
5
+ // Dev commands that expose create/update/delete via buildDevCmd.
6
+ // Each generates the root form (`jsn <name> create`) and the legacy dev form
7
+ // (`jsn dev <name> create`), so read-only profiles block both spellings.
8
+ const DEV_CRUD_COMMANDS = [
9
+ 'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
10
+ 'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
11
+ 'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
12
+ 'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
13
+ 'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
14
+ 'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
15
+ ];
16
+
17
+ function devCrudPaths(name) {
18
+ return [
19
+ [name, 'create'],
20
+ [name, 'update'],
21
+ [name, 'delete'],
22
+ ['dev', name, 'create'],
23
+ ['dev', name, 'update'],
24
+ ['dev', name, 'delete'],
25
+ ];
26
+ }
27
+
5
28
  export const MUTATION_COMMANDS = [
6
29
  // Incidents
7
30
  ['incidents', 'create'],
@@ -37,18 +60,26 @@ export const MUTATION_COMMANDS = [
37
60
  ['groups', 'delete'],
38
61
  // Catalog
39
62
  ['catalog', 'create-item'],
40
- // Dev flows
41
- ['dev', 'flows', 'create'],
42
- ['dev', 'flows', 'update'],
43
- ['dev', 'flows', 'delete'],
44
- // Dev scopes
63
+ // Dev CRUD commands (root + `jsn dev` forms)
64
+ ...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
65
+ // Dev eval (no sub-action) — both spellings must be gated:
66
+ // `jsn eval` (root) and legacy `jsn dev eval`
67
+ ['eval'],
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'],
73
+ // Scopes: create + set (root + dev forms)
74
+ ['scopes', 'create'],
75
+ ['scopes', 'set'],
45
76
  ['dev', 'scopes', 'create'],
46
77
  ['dev', 'scopes', 'set'],
47
- // Dev eval
48
- ['dev', 'eval'],
49
- // Dev updatesets
50
- ['dev', 'updatesets', 'set'],
78
+ // Update sets: create + set (root + dev forms)
79
+ ['updatesets', 'create'],
80
+ ['updatesets', 'set'],
51
81
  ['dev', 'updatesets', 'create'],
82
+ ['dev', 'updatesets', 'set'],
52
83
  ];
53
84
 
54
85
  /**
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');