@jacebenson/jsn 4.0.4 → 4.2.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.
Files changed (47) hide show
  1. package/README.md +18 -2
  2. package/package.json +4 -3
  3. package/src/app.js +48 -64
  4. package/src/auth.js +46 -11
  5. package/src/capabilities.js +128 -0
  6. package/src/cli.js +129 -65
  7. package/src/commands/_ticket.js +37 -93
  8. package/src/commands/approvals.js +350 -0
  9. package/src/commands/atf.js +635 -0
  10. package/src/commands/auth.js +93 -13
  11. package/src/commands/catalog/enrich.js +139 -0
  12. package/src/commands/catalog.js +25 -81
  13. package/src/commands/cmdb.js +613 -0
  14. package/src/commands/codesearch.js +103 -0
  15. package/src/commands/dev/_generic.js +63 -111
  16. package/src/commands/dev/_simple.js +16 -12
  17. package/src/commands/dev/b4rules.js +1 -1
  18. package/src/commands/dev/domains.js +4 -0
  19. package/src/commands/dev/eval.js +65 -7
  20. package/src/commands/dev/flows.js +3 -0
  21. package/src/commands/dev/logs.js +97 -0
  22. package/src/commands/dev/rest.js +6 -0
  23. package/src/commands/dev/restmessage.js +1 -0
  24. package/src/commands/dev/scopes.js +49 -25
  25. package/src/commands/dev/scrapi.js +5 -0
  26. package/src/commands/dev/soapmessages.js +1 -0
  27. package/src/commands/dev/updatesets.js +145 -15
  28. package/src/commands/dev.js +3 -2
  29. package/src/commands/docs/db.js +14 -0
  30. package/src/commands/docs/docs.js +9 -21
  31. package/src/commands/docs/search.js +33 -10
  32. package/src/commands/groups.js +3 -0
  33. package/src/commands/records.js +274 -12
  34. package/src/commands/setup.js +5 -0
  35. package/src/commands/skill.js +3 -0
  36. package/src/commands/snippets.js +155 -0
  37. package/src/commands/tickets.js +9 -29
  38. package/src/commands/users.js +9 -35
  39. package/src/commands/version.js +3 -0
  40. package/src/errors.js +107 -1
  41. package/src/help.js +1 -0
  42. package/src/helpers.js +131 -15
  43. package/src/mutations.js +36 -91
  44. package/src/output.js +195 -64
  45. package/src/resolve-record.js +71 -0
  46. package/src/sdk.js +108 -8
  47. package/src/session.js +214 -0
package/README.md CHANGED
@@ -10,7 +10,7 @@ Works standalone or with AI agents (Claude Code, OpenCode, Hermes, Cursor, Copil
10
10
  npm install -g @jacebenson/jsn
11
11
  ```
12
12
 
13
- Node.js 18+. macOS, Linux, Windows.
13
+ Node.js (see `engines` in package.json). macOS, Linux, Windows.
14
14
 
15
15
  The install also copies an AI agent skill file to `~/.agents/skills/servicenow/SKILL.md`.
16
16
 
@@ -59,9 +59,23 @@ jsn updatesets set "My Feature"
59
59
  # Generic table access
60
60
  jsn records list --table incident --limit 50 --json | jq '.[].number'
61
61
  jsn records create --table incident --data '{"short_description":"test"}'
62
+ jsn records list --table incident --limit 50 --get "data.records.0.number" # no jq needed
63
+ jsn records list --table incident --query "active=true" # totals included by default
64
+ jsn records list --table incident --limit 10 --no-count # opt out of the total query
65
+ jsn records bulk --table incident --query "priority=1" --set '{"state":"3"}' # dry-run by default
66
+ jsn records get --table incident --sys-id 8a1234abcd5678 --attachments # record + its files
67
+ jsn records list --table incident --limit 10 --csv # output CSV
62
68
 
63
69
  # Script execution
64
70
  jsn eval "gs.info('Hello World')"
71
+ cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
72
+
73
+ # Saved query snippets (stored locally, run against the active profile)
74
+ jsn snippets save open-inc --table incident --query "active=true"
75
+ jsn snippets run open-inc
76
+
77
+ # Live log tailing (Ctrl+C to stop)
78
+ jsn logs follow --level error --tail 10
65
79
  ```
66
80
 
67
81
  ## Commands
@@ -76,7 +90,7 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
76
90
 
77
91
  **User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
78
92
 
79
- **Data** — `records`, `tables`, `columns`, `includes`, `import`, `logs`, `users`, `groups`
93
+ **Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
80
94
 
81
95
  Every command supports `--json`, `--query`, and `--help`.
82
96
 
@@ -106,6 +120,8 @@ jsn auth modify dev12345 # Toggle read-only / skip conf
106
120
  | `--json` | Pipelines, scripts |
107
121
  | `--markdown` | Documentation |
108
122
  | `--quiet` / `-q` | Data only, no envelope |
123
+ | `--csv` | Spreadsheets (opens straight into Excel) |
124
+ | `--get <path>` | Pull one value out of the JSON envelope — no jq (e.g. `--get "data.records.0.number"`) |
109
125
 
110
126
  ## Authentication
111
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.0.4",
3
+ "version": "4.2.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": {
@@ -11,6 +11,8 @@
11
11
  "test": "node --experimental-test-module-mocks --test $(find test -name '*.test.js')",
12
12
  "lint": "npx eslint src/ bin/ test/",
13
13
  "start": "node bin/jsn.js",
14
+ "docs": "npm --prefix docs run build",
15
+ "docs:serve": "npm --prefix docs run serve",
14
16
  "tag-reminder": "node scripts/tag-reminder.js",
15
17
  "release": "node scripts/release.js"
16
18
  },
@@ -29,13 +31,12 @@
29
31
  "@inquirer/core": "^10.1.0",
30
32
  "@inquirer/prompts": "^7.5.1",
31
33
  "better-sqlite3": "^13.0.3",
32
- "chalk": "^5.4.1",
33
- "cli-table3": "^0.6.5",
34
34
  "gray-matter": "^4.0.3",
35
35
  "turndown": "^7.2.4",
36
36
  "yargs": "^17.7.2"
37
37
  },
38
38
  "devDependencies": {
39
+ "@11ty/eleventy": "^3.1.6",
39
40
  "@eslint/js": "^10.0.1",
40
41
  "@inquirer/search": "^4.2.1",
41
42
  "globals": "^17.6.0"
package/src/app.js CHANGED
@@ -1,10 +1,17 @@
1
1
  // App context: bundles config, auth, SDK, output, and runtime context
2
+ //
3
+ // Identity ("which instance, as whom, with what profile flags") is owned by
4
+ // the SESSION (src/session.js): the constructor resolves one from the
5
+ // config, the cli.js middleware re-applies it once --instance/--profile are
6
+ // known (applySession), and getEffectiveInstance()/requireInstance()/
7
+ // setEffectiveInstance() are thin session-backed shims kept for the many
8
+ // command handlers that already call them.
2
9
 
3
10
  import { AuthManager } from './auth.js';
4
11
  import { SDKClient } from './sdk.js';
5
12
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled } from './output.js';
6
- import { getEffectiveInstance, normalizeInstanceURL, saveConfig } from './config.js';
7
- import { extractProfileName } from './helpers.js';
13
+ import { saveConfig } from './config.js';
14
+ import { resolveSession, applySession, contextFromSession, requireSessionInstance } from './session.js';
8
15
  import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
9
16
  import { errUsage, errAuth } from './errors.js';
10
17
  import { isMutationCommand } from './mutations.js';
@@ -14,52 +21,47 @@ import process from 'node:process';
14
21
  export class App {
15
22
  constructor(cfg) {
16
23
  this.config = cfg;
17
- this.auth = new AuthManager(this);
18
24
  this.output = new OutputWriter({ format: resolveFormat(cfg.format) });
19
25
  this.sdk = null;
20
-
21
- const instance = getEffectiveInstance(cfg);
22
- if (instance) {
23
- this.sdk = new SDKClient(instance, this.auth, {
24
- domain: this._profileDomain(cfg),
25
- });
26
+ // Back-compat field: flag-provided instance override (--instance /
27
+ // --profile). Written by applySession only; mirrored on argv no longer.
28
+ this._overrideInstance = null;
29
+ // Pinned argv flags for session re-resolution after config edits
30
+ // (auth switch / login / remove change the config under a pinned
31
+ // --profile). Set by applySession; {} means "no flags seen".
32
+ this._sessionArgv = {};
33
+
34
+ // Resolve the initial session from config alone (no argv yet — the
35
+ // middleware re-applies with argv). AuthManager is TOLD the identity:
36
+ // it reads username/instance through this provider over the session
37
+ // instead of reaching into config.profiles itself.
38
+ this.session = resolveSession({}, cfg);
39
+ this.auth = new AuthManager({
40
+ getUsername: () => this.session?.username || null,
41
+ getEffectiveInstance: () => this.session?.instance || '',
42
+ });
43
+
44
+ if (this.session.instance) {
45
+ this._buildSdk(this.session.instance, this.session);
26
46
  }
27
47
 
28
- this.context = {
29
- profileName: '',
30
- username: '',
31
- scope: '',
32
- updateSet: '',
33
- };
34
-
48
+ this.context = { profileName: '', username: '', scope: '', updateSet: '' };
35
49
  this.loadContext();
36
50
  }
37
51
 
38
- loadContext() {
39
- const activeName = this.config.activeProfile || this.config.defaultProfile;
40
- if (activeName && this.config.profiles[activeName]) {
41
- this.context.profileName = activeName;
42
- this.context.username = this.config.profiles[activeName].username || '';
43
- return;
44
- }
52
+ /** Build (or rebuild) the SDK client for an instance + session. */
53
+ _buildSdk(instance, session = this.session) {
54
+ this.sdk = new SDKClient(instance, this.auth, {
55
+ domain: session?.domain || '',
56
+ });
57
+ }
45
58
 
46
- // Fallback: no active profile name — find by instance URL (legacy config)
47
- const instance = getEffectiveInstance(this.config);
48
- if (!instance) return;
49
- this.context.profileName = extractProfileName(instance);
50
- for (const [name, profile] of Object.entries(this.config.profiles || {})) {
51
- if (profile.instance_url === instance) {
52
- this.context.profileName = name;
53
- this.context.username = profile.username || '';
54
- break;
55
- }
56
- }
59
+ loadContext() {
60
+ this.context = contextFromSession(this.session, this.config);
57
61
  }
58
62
 
59
63
  getEffectiveInstance() {
60
- // Command-line override (--instance, --profile) takes precedence
61
- if (this._overrideInstance) return this._overrideInstance;
62
- return getEffectiveInstance(this.config);
64
+ return this.session?.instance || '';
63
65
  }
64
66
 
65
67
  async printContextHeader(argv = {}) {
@@ -124,16 +126,15 @@ export class App {
124
126
 
125
127
  // Read-only profiles get a lock badge on every command run, so the
126
128
  // armed safety is always visible (not just in `auth status`).
127
- const activeProfile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
128
- const roBadge = activeProfile?.read_only === true ? ' 🔒' : '';
129
+ const roBadge = this.session?.readOnly === true ? ' 🔒' : '';
129
130
 
130
131
  process.stderr.write('# Use `jsn scopes` to change scope, `jsn updatesets set` to change updateset\n');
131
132
  process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
132
133
 
133
- const profileStr = `\u001b]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}${roBadge}\u001b]8;;\x07`;
134
- const userStr = `]8;;${userLink}\x07${String(displayUserName).padEnd(9)}]8;;\x07`;
135
- const scopeStr = `]8;;${scopeLink}\x07${String(scopeFormatted).padEnd(17)}]8;;\x07`;
136
- const updateSetStr = `]8;;${updateSetLink}\x07${updateSet}]8;;\x07`;
134
+ const profileStr = `]8;;${instanceLink}${String(this.context.profileName).padEnd(9)}${roBadge}]8;;`;
135
+ const userStr = `]8;;${userLink}${String(displayUserName).padEnd(9)}]8;;`;
136
+ const scopeStr = `]8;;${scopeLink}${String(scopeFormatted).padEnd(17)}]8;;`;
137
+ const updateSetStr = `]8;;${updateSetLink}${updateSet}]8;;`;
137
138
 
138
139
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
139
140
 
@@ -185,9 +186,7 @@ export class App {
185
186
  }
186
187
 
187
188
  requireInstance() {
188
- if (!this.getEffectiveInstance()) {
189
- throw errUsage('Instance URL required. Set via --instance flag, SERVICENOW_INSTANCE_URL env, or config file.');
190
- }
189
+ requireSessionInstance(this.session);
191
190
  }
192
191
 
193
192
  requireAuth() {
@@ -197,26 +196,11 @@ export class App {
197
196
  }
198
197
 
199
198
  /**
200
- * Override the effective instance for this command run.
201
- * Rebuilds the SDK client for the new instance.
199
+ * Back-compat shim: override the effective instance for this command run.
200
+ * Routes through the session resolver (rebuilds the SDK client).
202
201
  */
203
202
  setEffectiveInstance(url) {
204
- const normalizedUrl = normalizeInstanceURL(url);
205
- if (normalizedUrl) {
206
- this._overrideInstance = normalizedUrl;
207
- this.sdk = new SDKClient(normalizedUrl, this.auth, {
208
- domain: this._profileDomain(this.config),
209
- });
210
- }
211
- }
212
-
213
- /** Read the configured domain for the active profile (empty = no scoping). */
214
- _profileDomain(cfg) {
215
- const name = cfg.activeProfile || cfg.defaultProfile;
216
- if (name && cfg.profiles[name]) {
217
- return cfg.profiles[name].domain || '';
218
- }
219
- return '';
203
+ applySession(this, { instance: url });
220
204
  }
221
205
 
222
206
  /** Set (or clear with '') the domain for the active profile. */
package/src/auth.js CHANGED
@@ -272,23 +272,40 @@ function askHidden(promptText) {
272
272
  }
273
273
 
274
274
  export class AuthManager {
275
- constructor(configProvider) {
276
- this.configProvider = configProvider;
275
+ /**
276
+ * @param {object} identity — the resolved-identity provider. Production
277
+ * wiring (src/app.js) hands an adapter over app.session:
278
+ * { getUsername(), getEffectiveInstance() }. AuthManager no longer
279
+ * reaches into config.profiles itself — the session owns "who am I".
280
+ *
281
+ * Transitional back-compat: a legacy configProvider ({ config, ...
282
+ * getEffectiveInstance() }) is adapted to the identity surface, so the
283
+ * existing test fixtures (which predate the session) keep working.
284
+ */
285
+ constructor(identity) {
286
+ if (identity && identity.config && typeof identity.getUsername !== 'function') {
287
+ const provider = identity;
288
+ this.identity = {
289
+ getUsername: () => {
290
+ const cfg = provider.config;
291
+ const name = cfg.activeProfile || cfg.defaultProfile;
292
+ return (name && cfg.profiles?.[name]?.username) || null;
293
+ },
294
+ getEffectiveInstance: () => provider.getEffectiveInstance(),
295
+ };
296
+ } else {
297
+ this.identity = identity;
298
+ }
277
299
  this.httpClient = { timeout: 30000 };
278
300
  }
279
301
 
280
302
  /**
281
- * Resolve the username for credential keying from the active profile.
303
+ * The username for credential keying, from the resolved session identity.
282
304
  * Returns null when no profile is configured — callers fall back to bare
283
305
  * instance key for backward compatibility with legacy / no-profile usage.
284
306
  */
285
307
  _activeUsername() {
286
- const cfg = this.configProvider.config;
287
- const name = cfg.activeProfile || cfg.defaultProfile;
288
- if (name && cfg.profiles[name] && cfg.profiles[name].username) {
289
- return cfg.profiles[name].username;
290
- }
291
- return null;
308
+ return this.identity.getUsername() || null;
292
309
  }
293
310
 
294
311
  getLastSeen(instance) {
@@ -334,7 +351,7 @@ export class AuthManager {
334
351
  isAuthenticated() {
335
352
  if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
336
353
  if (getBasicAuthFromEnv()) return true;
337
- const instance = this.configProvider.getEffectiveInstance();
354
+ const instance = this.identity.getEffectiveInstance();
338
355
  if (!instance) return false;
339
356
  try {
340
357
  this.getCredentialsFor(instance);
@@ -357,7 +374,7 @@ export class AuthManager {
357
374
  if (process.env.SERVICENOW_OAUTH_TOKEN) {
358
375
  return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
359
376
  }
360
- const instance = this.configProvider.getEffectiveInstance();
377
+ const instance = this.identity.getEffectiveInstance();
361
378
  if (!instance) {
362
379
  throw errAuth('No instance configured');
363
380
  }
@@ -581,6 +598,24 @@ export class AuthManager {
581
598
  }
582
599
  deleteCredentials(instance, this._activeUsername());
583
600
  }
601
+
602
+ /**
603
+ * Migrate legacy bare-instance-keyed credentials to <user>@<instance>
604
+ * keying after an OAuth login verifies the username. Re-saves under the
605
+ * compound key and removes the bare key. Returns true when a migration
606
+ * happened. Credential-store internals (key shapes, legacy bare keys)
607
+ * live behind this seam — command handlers must not import
608
+ * loadCredentials/saveCredentials/deleteCredentials to do this themselves.
609
+ */
610
+ migrateLegacyCredential(instance, username) {
611
+ if (!instance || !username) return false;
612
+ const stored = loadCredentials(instance);
613
+ if (!stored || !stored.access_token) return false;
614
+ stored.username = username;
615
+ saveCredentials(instance, stored, username);
616
+ deleteCredentials(instance); // remove bare-instance legacy key
617
+ return true;
618
+ }
584
619
  }
585
620
 
586
621
  export { saveCredentials, loadCredentials, deleteCredentials, askHidden };
@@ -0,0 +1,128 @@
1
+ // Command capability registry — the single source of truth for command
2
+ // metadata that middleware cares about.
3
+ //
4
+ // Each command module declares its capabilities ONCE at the definition site
5
+ // via declareCapabilities(name, caps):
6
+ //
7
+ // {
8
+ // // Subcommand verbs that mutate instance state. The mutation guard
9
+ // // (src/mutations.js) derives its command-path list from these.
10
+ // mutationSubcommands: ['create', 'update', 'delete'],
11
+ // // Also registered under `jsn dev <name>` — derive both spellings.
12
+ // devAlias: true,
13
+ // // Command runs without a configured instance (joins the middleware
14
+ // // skip-list for the instance guard).
15
+ // noInstance: true,
16
+ // // Skip the once-per-day npm-version / skill freshness checks and the
17
+ // // interactive context header.
18
+ // skipDailyChecks: true,
19
+ // }
20
+ //
21
+ // cli.js derives every skip-list and the mutation guard's path list from
22
+ // this registry. Hand-maintained string lists of command names are a bug
23
+ // farm — see the catalogitems create read-only bypass that shipped because
24
+ // mutations.js listed `catalog create-item` while the real command is
25
+ // `catalogitems create`.
26
+ //
27
+ // Note: yargs strips custom properties off command modules when it
28
+ // registers them (only original/description/handler/builder survive in
29
+ // getCommandHandlers()), so declarations live in this module-level map,
30
+ // keyed by root command name. Declaration happens when the command module
31
+ // factory runs — i.e. during cli.js's registration chain — so the registry
32
+ // is complete once buildCLI() returns.
33
+
34
+ const REGISTRY = new Map();
35
+
36
+ /**
37
+ * Declare a command's capabilities. Called by command factories and
38
+ * hand-written command modules at definition time.
39
+ *
40
+ * @param {string} name — root command name (as typed: `jsn <name>`)
41
+ * @param {object} caps — capability declaration (see module header)
42
+ * @returns {object} the caps object (for convenient inline use)
43
+ */
44
+ export function declareCapabilities(name, caps) {
45
+ REGISTRY.set(name, { devAlias: false, ...caps });
46
+ return caps;
47
+ }
48
+
49
+ /**
50
+ * The collected capability registry: root command name → capabilities.
51
+ * Includes the implicit declarations for yargs built-ins ('help') and the
52
+ * shell plumbing ('completion') which have no factory to declare from.
53
+ *
54
+ * @returns {Map<string, object>}
55
+ */
56
+ export function collectCapabilities() {
57
+ const caps = new Map(REGISTRY);
58
+ if (!caps.has('help')) caps.set('help', { noInstance: true, skipDailyChecks: true });
59
+ if (!caps.has('version')) caps.set('version', { noInstance: true, skipDailyChecks: true });
60
+ if (!caps.has('completion')) caps.set('completion', { noInstance: true, skipDailyChecks: true });
61
+ return caps;
62
+ }
63
+
64
+ /**
65
+ * Derive the set of root command names that run without an instance.
66
+ * Preserves the legacy skip-list semantics exactly: help, version,
67
+ * completion, setup, auth, skill, docs.
68
+ *
69
+ * @param {Map<string, object>} [caps] — from collectCapabilities()
70
+ * @returns {Set<string>}
71
+ */
72
+ export function noInstanceCommands(caps = collectCapabilities()) {
73
+ const out = new Set();
74
+ for (const [name, c] of caps) {
75
+ if (c.noInstance) out.add(name);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * Derive the set of root command names that skip the daily npm-version /
82
+ * skill checks and the interactive context header. Legacy semantics:
83
+ * help, version, completion, skill, docs (a subset of noInstanceCommands).
84
+ *
85
+ * @param {Map<string, object>} [caps] — from collectCapabilities()
86
+ * @returns {Set<string>}
87
+ */
88
+ export function dailyCheckSkipCommands(caps = collectCapabilities()) {
89
+ const out = new Set();
90
+ for (const [name, c] of caps) {
91
+ if (c.skipDailyChecks) out.add(name);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Derive the mutation command-path list (argv._ token arrays) from the
98
+ * capability registry. Each entry is a prefix pattern: an argv._ path that
99
+ * begins with the pattern IS that mutation (trailing positionals like a
100
+ * sys_id don't change intent).
101
+ *
102
+ * Commands with `devAlias: true` are also registered under `jsn dev <name>`,
103
+ * so both spellings are emitted.
104
+ *
105
+ * A whole-command mutation surface (e.g. `jsn eval`, `jsn rest` — the
106
+ * command itself is the mutation, there is no mutating sub-verb) declares
107
+ * `mutationSubcommands: ['']`.
108
+ *
109
+ * @param {Map<string, object>} [caps] — from collectCapabilities()
110
+ * @returns {Array<string[]>}
111
+ */
112
+ export function mutationPaths(caps = collectCapabilities()) {
113
+ const paths = [];
114
+ for (const [name, c] of caps) {
115
+ if (!c.mutationSubcommands) continue;
116
+ for (const sub of c.mutationSubcommands) {
117
+ const parts = sub === '' ? [] : sub.split(' ');
118
+ paths.push([name, ...parts]);
119
+ if (c.devAlias) paths.push(['dev', name, ...parts]);
120
+ }
121
+ }
122
+ return paths;
123
+ }
124
+
125
+ /** Test hook: clear the registry between isolated buildCLI() runs. */
126
+ export function _resetCapabilities() {
127
+ REGISTRY.clear();
128
+ }