@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
package/README.md CHANGED
@@ -64,17 +64,18 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
64
64
 
65
65
  Every command supports `--json`, `--query`, and `--help`.
66
66
 
67
- ## Profiles
67
+ ## Profiles (instances)
68
68
 
69
69
  Switch between instances without re-authenticating.
70
70
 
71
71
  ```bash
72
72
  jsn auth login https://dev12345.service-now.com
73
- jsn profiles list
74
- jsn profiles use dev12345
75
- jsn profiles show
73
+ jsn auth status
74
+ jsn auth switch dev12345
76
75
  ```
77
76
 
77
+ Add another instance with `jsn auth login <name-or-url>` — run bare for an interactive picker.
78
+
78
79
  ## Output Formats
79
80
 
80
81
  | Flag | When to use |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.4.3",
3
+ "version": "4.0.1",
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": {
@@ -28,9 +28,11 @@
28
28
  "dependencies": {
29
29
  "@inquirer/core": "^10.1.0",
30
30
  "@inquirer/prompts": "^7.5.1",
31
+ "better-sqlite3": "^13.0.3",
31
32
  "chalk": "^5.4.1",
32
33
  "cli-table3": "^0.6.5",
33
34
  "gray-matter": "^4.0.3",
35
+ "turndown": "^7.2.4",
34
36
  "yargs": "^17.7.2"
35
37
  },
36
38
  "devDependencies": {
@@ -14,7 +14,7 @@ compatibility: |
14
14
  Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
15
  metadata:
16
16
  author: jacebenson
17
- version: "3.1.1"
17
+ version: "4.0.0"
18
18
  repository: https://github.com/jacebenson/jsn
19
19
  ---
20
20
 
@@ -44,7 +44,7 @@ All `list` subcommands use a paginated interactive picker in TTY mode:
44
44
  2. **Use sys_id for updates** — All update/delete operations require sys_id
45
45
  3. **Check auth first** — Run `jsn auth status` before operations
46
46
  4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
47
- 5. **Use `--profile <name>`** to target a specific instance, or `jsn profiles use <name>` to change default
47
+ 5. **Use `--profile <name>`** to target a specific instance, or `jsn auth switch <name>` to change the default
48
48
  6. **Before using `eval` or `rest`** — Ask: *"Is there a more specific jsn command?"* Prefer specific over generic over escape hatch over eval.
49
49
  7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
50
50
 
@@ -69,7 +69,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
69
69
  | **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
70
70
  | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
71
71
  | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
72
- | **Config** | `setup`, `auth`, `profiles`, `updatesets`, `scopes` |
72
+ | **Config** | `setup`, `auth`, `updatesets`, `scopes` |
73
73
  | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
74
74
  | **Developer** | `eval`, `rest`, `skill`, `version` |
75
75
 
package/src/app.js CHANGED
@@ -120,10 +120,15 @@ export class App {
120
120
 
121
121
  const scopeFormatted = `[${scope}]`;
122
122
 
123
+ // Read-only profiles get a lock badge on every command run, so the
124
+ // armed safety is always visible (not just in `auth status`).
125
+ const activeProfile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
126
+ const roBadge = activeProfile?.read_only === true ? ' 🔒' : '';
127
+
123
128
  process.stderr.write('# Use `jsn scopes` to change scope, `jsn updatesets set` to change updateset\n');
124
129
  process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
125
130
 
126
- const profileStr = `]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}]8;;\x07`;
131
+ const profileStr = `\u001b]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}${roBadge}\u001b]8;;\x07`;
127
132
  const userStr = `]8;;${userLink}\x07${String(displayUserName).padEnd(9)}]8;;\x07`;
128
133
  const scopeStr = `]8;;${scopeLink}\x07${String(scopeFormatted).padEnd(17)}]8;;\x07`;
129
134
  const updateSetStr = `]8;;${updateSetLink}\x07${updateSet}]8;;\x07`;
@@ -131,9 +136,7 @@ export class App {
131
136
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
132
137
 
133
138
  // ⚠️ Warning if in the Default update set (mutation commands only)
134
- const profile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
135
- const isYolo = profile?.yolo === true;
136
- if (!isYolo && isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
139
+ if (isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
137
140
  const isGlobal = scope === 'global';
138
141
  if (isGlobal) {
139
142
  // 🔴 HARD WARNING — Global + Default
@@ -150,8 +153,6 @@ export class App {
150
153
  '┃ ┃\n' +
151
154
  '┃ Or switch to a scoped scope first: ┃\n' +
152
155
  '┃ jsn scopes list ┃\n' +
153
- '┃ ┃\n' +
154
- '┃ (Run jsn updatesets yolo to silence this check) ┃\n' +
155
156
  '┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m\n'
156
157
  );
157
158
  } else {
@@ -162,7 +163,6 @@ export class App {
162
163
  ' ⚠ Default update set in scope [' + scope + ']\n' +
163
164
  ' Changes are contained to this scope, but a named\n' +
164
165
  ' update set is still recommended for tracking.\n' +
165
- ' (Run \x1b[1mjsn updatesets yolo\x1b[22m to silence this warning)\n' +
166
166
  '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
167
167
  '\x1b[0m' // reset
168
168
  );
package/src/auth.js CHANGED
@@ -7,19 +7,34 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import crypto from 'node:crypto';
10
- import { execSync } from 'node:child_process';
10
+ import { execFileSync } from 'node:child_process';
11
11
  import { password as passwordPrompt } from '@inquirer/prompts';
12
12
  import { globalConfigDir, normalizeInstanceURL } from './config.js';
13
13
  import { errAuth } from './errors.js';
14
14
 
15
15
  // ─── Credential key helpers ───
16
16
 
17
+ /**
18
+ * Sanitize an instance URL into a filesystem/arg-safe key part.
19
+ *
20
+ * Whitelist approach (issue #143 findings #2/#3): first map the `://`
21
+ * protocol separator to `_` exactly as the Go version's encoding does
22
+ * (so existing keyring keys like `https_dev437538.service-now.com` keep
23
+ * matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
24
+ * stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
25
+ * spaces, and backslashes — which broke out of double-quoted shell args
26
+ * in keyring calls and, on Windows, out of path.join() credential dirs.
27
+ */
28
+ export function sanitizeKeyPart(instance) {
29
+ return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
30
+ }
31
+
17
32
  /**
18
33
  * Build a compound key for credential storage: <username>@<instance>
19
34
  * When username is omitted (legacy), just the normalized instance URL is used.
20
35
  */
21
36
  function credKey(instance, username) {
22
- const normalized = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_');
37
+ const normalized = sanitizeKeyPart(instance);
23
38
  if (!username) return normalized;
24
39
  return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
25
40
  }
@@ -29,7 +44,7 @@ function credKey(instance, username) {
29
44
  function pkceStatePath(instance) {
30
45
  const dir = path.join(globalConfigDir(), 'pkce');
31
46
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
- const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
47
+ const filename = sanitizeKeyPart(instance) + '.json';
33
48
  return path.join(dir, filename);
34
49
  }
35
50
 
@@ -58,24 +73,6 @@ function removePKCEState(instance) {
58
73
  const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
59
74
  const REDIRECT_URI = '/sdk-oauth.do';
60
75
 
61
- // ─── WSL detection ───
62
-
63
- /**
64
- * Detect if running under Windows Subsystem for Linux.
65
- * Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
66
- */
67
- let _cachedIsWSL = null;
68
- async function isWSLEnv() {
69
- if (_cachedIsWSL !== null) return _cachedIsWSL;
70
- try {
71
- const data = fs.readFileSync('/proc/version', 'utf-8');
72
- _cachedIsWSL = /microsoft|wsl/i.test(data);
73
- } catch {
74
- _cachedIsWSL = false;
75
- }
76
- return _cachedIsWSL;
77
- }
78
-
79
76
  // ─── Basic auth from environment variables ───
80
77
  // SN_USERNAME / SN_PASSWORD — global credentials
81
78
  // SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
@@ -151,8 +148,11 @@ function buildAuthURL(instanceURL, clientID, pkce) {
151
148
 
152
149
  function keyringLookup(key) {
153
150
  try {
154
- const result = execSync(
155
- `secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
151
+ // execFileSync with an arg array — no shell, so `key` can never
152
+ // break out into command injection (issue #143 finding #2).
153
+ const result = execFileSync(
154
+ 'secret-tool',
155
+ ['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
156
156
  { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
157
157
  );
158
158
  const trimmed = result.trim();
@@ -175,9 +175,10 @@ function keyringLookup(key) {
175
175
 
176
176
  function keyringStore(key, creds) {
177
177
  try {
178
- execSync(
179
- `secret-tool store --label="Password for '${key}' on '${KEYRING_SERVICE}'" ` +
180
- `${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
178
+ execFileSync(
179
+ 'secret-tool',
180
+ ['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
181
+ KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
181
182
  { stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
182
183
  );
183
184
  return true;
@@ -188,8 +189,9 @@ function keyringStore(key, creds) {
188
189
 
189
190
  function keyringDelete(key) {
190
191
  try {
191
- execSync(
192
- `secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
192
+ execFileSync(
193
+ 'secret-tool',
194
+ ['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
193
195
  { stdio: 'ignore' }
194
196
  );
195
197
  } catch {
@@ -390,43 +392,12 @@ export class AuthManager {
390
392
  const authURL = buildAuthURL(instanceURL, clientID, pkce);
391
393
 
392
394
  console.log();
393
- console.log('Opening browser for OAuth authentication...');
394
- console.log('If the browser does not open automatically, visit:');
395
+ console.log('Open this URL in your browser to authenticate:');
395
396
  console.log(authURL);
396
397
  console.log();
397
-
398
- // Try to open browser
399
- const open = (await import('node:child_process')).spawn;
400
- const platform = process.platform;
401
- let cmd, args;
402
- if (platform === 'darwin') {
403
- cmd = 'open';
404
- args = [authURL];
405
- } else if (platform === 'win32') {
406
- cmd = 'cmd';
407
- // Windows: cmd.exe interprets & as a command separator, which truncates
408
- // the URL after the first parameter. Passing an empty string as the first
409
- // arg to `start` serves as the window title, and spawn auto-quotes the
410
- // URL arg, protecting & and other special chars from cmd.exe's parser.
411
- args = ['/c', 'start', '', authURL];
412
- } else {
413
- // WSL detection: check if we're running under Windows Subsystem for Linux
414
- // On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
415
- // on &. We call cmd.exe directly with proper quoting instead.
416
- const isWSL = platform === 'linux' && (await isWSLEnv());
417
- if (isWSL) {
418
- cmd = 'cmd.exe';
419
- args = ['/c', 'start', '', authURL];
420
- } else {
421
- cmd = 'xdg-open';
422
- args = [authURL];
423
- }
424
- }
425
- const child = open(cmd, args, { detached: true, stdio: 'ignore' });
426
- child.on('error', () => {
427
- // Browser open command not available — user will open the URL manually
428
- });
429
- child.unref();
398
+ console.log('(The browser is not opened automatically — if a browser tab opens,');
399
+ console.log(' use the URL above if the opened page shows an error.)');
400
+ console.log();
430
401
 
431
402
  console.log('After authenticating in the browser, copy the authorization code shown on the page.');
432
403
  console.log('(input is hidden for security — just paste and press Enter)');
package/src/cli.js CHANGED
@@ -5,15 +5,15 @@ import { hideBin } from 'yargs/helpers';
5
5
  import process from 'node:process';
6
6
  import path from 'node:path';
7
7
  import fs from 'node:fs';
8
- import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
8
+ import { loadConfig, getActiveProfile, globalConfigDir } from './config.js';
9
9
  import { App } from './app.js';
10
10
  import { renderHelp } from './help.js';
11
11
  import { isMutationCommand } from './mutations.js';
12
+ import { guardExit } from './errors.js';
12
13
 
13
14
  // Command modules
14
15
  import { setupCmd } from './commands/setup.js';
15
16
  import { authCmd } from './commands/auth.js';
16
- import { profilesCmd } from './commands/profiles.js';
17
17
  import { recordsCmd } from './commands/records.js';
18
18
  import { catalogCmd } from './commands/catalog.js';
19
19
  import { incidentsCmd } from './commands/incidents.js';
@@ -95,14 +95,6 @@ export function buildCLI() {
95
95
  const cfg = loadConfig();
96
96
  const app = new App(cfg);
97
97
 
98
- // Resolve instance once
99
- let effectiveInstance;
100
- try {
101
- effectiveInstance = getEffectiveInstance(cfg);
102
- } catch {
103
- effectiveInstance = null;
104
- }
105
-
106
98
  return yargs(hideBin(process.argv))
107
99
  .scriptName('jsn')
108
100
  .usage('Usage: jsn <command> [options]')
@@ -162,8 +154,11 @@ export function buildCLI() {
162
154
  if (argv.profile) {
163
155
  const profile = (cfg.profiles || {})[argv.profile];
164
156
  if (!profile) {
165
- process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
166
- process.exit(1);
157
+ guardExit(argv, {
158
+ code: 'unknown_profile',
159
+ message: `Unknown profile "${argv.profile}"`,
160
+ hint: 'Run "jsn auth status" to list profiles.',
161
+ });
167
162
  }
168
163
  const url = profile.instance_url;
169
164
  app._overrideInstance = url;
@@ -176,10 +171,10 @@ export function buildCLI() {
176
171
  argv._overrideInstance = argv.instance;
177
172
  }
178
173
  },
179
- // Guard mutation commands when no instance configured
174
+ // Guard mutation commands: require an instance, block read-only profiles
180
175
  (argv) => {
181
176
  const cmd = (argv._[0] || '').toString();
182
- if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill', 'docs'].includes(cmd)) {
177
+ if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'skill', 'docs'].includes(cmd)) {
183
178
  return;
184
179
  }
185
180
  // Check for --instance override
@@ -187,11 +182,24 @@ export function buildCLI() {
187
182
  app._overrideInstance = argv.instance;
188
183
  argv._overrideInstance = argv.instance;
189
184
  }
190
- // Determine if this is a mutation
191
- const subcommand = (argv._[1] || '').toString();
192
- const isMutation = isMutationCommand(cmd, subcommand);
193
- if (isMutation) {
194
- app.requireInstance();
185
+ if (isMutationCommand(argv)) {
186
+ try {
187
+ app.requireInstance();
188
+ } catch (err) {
189
+ // requireInstance throws errUsage; format it cleanly (the yargs
190
+ // fail handler re-throws middleware errors, which would print a
191
+ // raw stack trace).
192
+ guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
193
+ }
194
+ const profile = getActiveProfile(cfg);
195
+ if (profile?.read_only === true) {
196
+ const name = cfg.activeProfile || cfg.defaultProfile;
197
+ guardExit(argv, {
198
+ code: 'read_only',
199
+ message: `Profile "${name}" is read-only. Mutations are blocked.`,
200
+ hint: 'Switch to a write-enabled profile first:\n jsn auth switch <name>',
201
+ });
202
+ }
195
203
  }
196
204
  },
197
205
  ])
@@ -277,7 +285,6 @@ export function buildCLI() {
277
285
  // CONFIGURATION
278
286
  .command(setupCmd(wrap))
279
287
  .command(authCmd(wrap))
280
- .command(profilesCmd(wrap))
281
288
  .command(updateSetsCmd(wrap))
282
289
  .command(scopesCmd(wrap))
283
290
  // CORE
@@ -1,7 +1,7 @@
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 } from '../helpers.js';
4
+ import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, assertSafeExactMatch } from '../helpers.js';
5
5
  import { search } from '@inquirer/prompts';
6
6
  import { isTTY, FormatAuto } from '../output.js';
7
7
 
@@ -86,6 +86,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
86
86
  }
87
87
 
88
88
  if (selectedNumber) {
89
+ assertSafeExactMatch(selectedNumber);
89
90
  const showParams = new URLSearchParams();
90
91
  showParams.set('sysparm_query', `number=${selectedNumber}`);
91
92
  showParams.set('sysparm_display_value', 'all');
@@ -161,6 +162,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
161
162
  describe: `Show a specific ${displayName}`,
162
163
  handler: wrap(async (argv, app) => {
163
164
  const number = argv.number;
165
+ assertSafeExactMatch(number);
164
166
  const params = new URLSearchParams();
165
167
  params.set('sysparm_query', `number=${number}`);
166
168
  params.set('sysparm_display_value', 'all');
@@ -216,6 +218,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
216
218
  .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
217
219
  handler: wrap(async (argv, app) => {
218
220
  const number = argv.number;
221
+ assertSafeExactMatch(number);
219
222
  const recordData = JSON.parse(argv.data);
220
223
  const findParams = new URLSearchParams();
221
224
  findParams.set('sysparm_query', `number=${number}`);
@@ -237,8 +240,11 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
237
240
  .command({
238
241
  command: 'delete <number>',
239
242
  describe: `Delete a ${displayName}`,
243
+ builder: (y) => y.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
240
244
  handler: wrap(async (argv, app) => {
241
245
  const number = argv.number;
246
+ await confirmDelete(app, argv, `Delete ${displayName} ${number}`);
247
+ assertSafeExactMatch(number);
242
248
  const findParams = new URLSearchParams();
243
249
  findParams.set('sysparm_query', `number=${number}`);
244
250
  findParams.set('sysparm_limit', '1');