@jacebenson/jsn 4.3.12 → 4.3.13-dev.100.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 (75) hide show
  1. package/README.md +55 -0
  2. package/package.json +2 -1
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +24 -4
  9. package/src/cli.js +39 -9
  10. package/src/command-audience.js +35 -0
  11. package/src/commands/_generic.js +58 -37
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +38 -31
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/changes.js +2 -4
  18. package/src/commands/columns.js +2 -1
  19. package/src/commands/customizations.js +13 -7
  20. package/src/commands/discover.js +54 -0
  21. package/src/commands/docs/db.js +9 -1
  22. package/src/commands/docs/docs.js +25 -2
  23. package/src/commands/docs/ingest.js +20 -3
  24. package/src/commands/docs/install.js +86 -0
  25. package/src/commands/docs/locks.js +34 -0
  26. package/src/commands/docs/refresh.js +22 -2
  27. package/src/commands/docs/replace.js +30 -0
  28. package/src/commands/docs/snapshot-check.js +11 -0
  29. package/src/commands/docs/snapshot.js +130 -0
  30. package/src/commands/docs/sync.js +16 -6
  31. package/src/commands/forms.js +22 -97
  32. package/src/commands/groups.js +11 -8
  33. package/src/commands/history.js +57 -0
  34. package/src/commands/incidents.js +2 -4
  35. package/src/commands/midservers.js +117 -10
  36. package/src/commands/records.js +64 -119
  37. package/src/commands/requests.js +10 -16
  38. package/src/commands/rest.js +15 -2
  39. package/src/commands/scopes.js +1 -1
  40. package/src/commands/setup.js +46 -9
  41. package/src/commands/skill.js +72 -76
  42. package/src/commands/tasks.js +2 -4
  43. package/src/commands/tickets.js +31 -22
  44. package/src/commands/uipolicies.js +2 -27
  45. package/src/commands/updatesets.js +65 -13
  46. package/src/commands/users.js +11 -8
  47. package/src/commands/version.js +14 -8
  48. package/src/constrained-query.js +20 -0
  49. package/src/context.js +6 -6
  50. package/src/customization-guidance.js +31 -0
  51. package/src/customizations-report.js +6 -0
  52. package/src/customizations.js +28 -13
  53. package/src/errors.js +23 -1
  54. package/src/essential-records.js +40 -0
  55. package/src/field-behavior.js +15 -0
  56. package/src/help.js +10 -2
  57. package/src/helpers.js +44 -0
  58. package/src/metadata/records.js +249 -0
  59. package/src/midserver-linux-remote.js +210 -0
  60. package/src/midserver-remote-recipe.js +49 -0
  61. package/src/midserver-remote.js +221 -0
  62. package/src/midserver-windows-remote.js +318 -0
  63. package/src/midserver-zip.js +2 -2
  64. package/src/mutation-context.js +74 -0
  65. package/src/mutation-history.js +75 -0
  66. package/src/mutations.js +3 -2
  67. package/src/output.js +34 -2
  68. package/src/record-command-profiles.js +70 -0
  69. package/src/record-profiles.js +93 -0
  70. package/src/records-engine.js +57 -0
  71. package/src/related-lists.js +153 -0
  72. package/src/resolve-record.js +50 -28
  73. package/src/sdk-batch.js +130 -0
  74. package/src/sdk.js +157 -18
  75. package/src/update-set-xml.js +89 -0
package/README.md CHANGED
@@ -13,6 +13,47 @@ jsn setup
13
13
 
14
14
  See the [setup guide](docs/docs-setup.md) to connect an instance safely.
15
15
 
16
+ ### Dev channel
17
+
18
+ For the newest changes from `main`, including changes that may be unstable:
19
+
20
+ ```bash
21
+ npm install -g @jacebenson/jsn@dev
22
+ ```
23
+
24
+ Dev builds check the dev channel for updates during normal use, at most once
25
+ per day. Run `jsn version --check` to check immediately. To install the newest
26
+ dev build, run `npm install -g @jacebenson/jsn@dev` again. Updates are manual.
27
+ Return to stable with `npm install -g @jacebenson/jsn@latest`.
28
+
29
+ Pushes to `main` promote the commit to `dev` and publish a tested prerelease
30
+ under npm's `dev` tag. Versions include the workflow run number and attempt
31
+ (for example, `4.3.9-dev.123.1`); they do not move npm's stable `latest` tag.
32
+ The npm trusted publisher for `publish-dev.yml` must authorize the repository's
33
+ `dev` GitHub environment, alongside the production publishing configuration.
34
+
35
+ ### Supported versions
36
+
37
+ Only the current release and the two preceding releases in each channel are
38
+ supported (three stable releases and three dev builds). For example, with
39
+ stable `4.3.11`, versions `4.3.11`, `4.3.10`, and `4.3.9` are supported.
40
+ Update to the newest version in your channel before reporting an issue.
41
+
42
+ After each successful npm publish, deployment deprecates older versions in that
43
+ channel. Deprecated versions remain installable and appear in history, but npm
44
+ prints an update warning on installation. Existing deprecation messages are
45
+ preserved. No versions are unpublished.
46
+
47
+ The deprecation step uses the repository's `NPM_TOKEN` secret with permission to
48
+ modify `@jacebenson/jsn` and noninteractive authentication (including bypass 2FA
49
+ where required). Publishing continues to use OIDC. A deprecation failure fails
50
+ the workflow after publishing; it does not roll back the published package.
51
+ To retry only deprecation after fixing authentication, use the matching release
52
+ checkout and run `node scripts/deprecate-old-versions.mjs latest --apply` (or
53
+ `dev --apply`) with npm authentication configured and `NODE_AUTH_TOKEN` set.
54
+ Omit `--apply` to preview the affected versions without changing npm. The script
55
+ requires the checkout's package version to match the channel's current npm tag.
56
+
16
57
  ## Try it
17
58
 
18
59
  ```bash
@@ -37,3 +78,17 @@ npm test
37
78
  ## License
38
79
 
39
80
  MIT
81
+
82
+
83
+ ### Commands for the connected account
84
+
85
+ Login and OAuth refresh check for the current account's `admin` role. Profiles
86
+ without that role show a compact command list focused on tickets, generic
87
+ records, people/groups, documentation, and connection management. If the role
88
+ check is unavailable, the profile also uses compact help; this does not assert
89
+ that the account lacks admin. Existing profiles keep full help until renewed.
90
+
91
+ This applies to root help, root JSON, `discover`, and root shell completion.
92
+ Use `jsn --all-commands --help` or `jsn discover --all-commands --json` to inspect
93
+ every registered command. Explicit command paths remain available and ServiceNow
94
+ ACLs decide access. Reauthenticate (or refresh OAuth) after roles change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.12",
3
+ "version": "4.3.13-dev.100.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": {
@@ -32,6 +32,7 @@
32
32
  "@inquirer/prompts": "^7.5.1",
33
33
  "better-sqlite3": "^13.0.3",
34
34
  "js-yaml": "^5.5.0",
35
+ "saxes": "^6.0.0",
35
36
  "turndown": "^7.2.4"
36
37
  },
37
38
  "devDependencies": {
@@ -1,185 +1,33 @@
1
1
  ---
2
2
  name: servicenow
3
- description: |
4
- Interact with ServiceNow instances via the jsn CLI. Use when working with ServiceNow
5
- development, administration, or data exploration. Handles tables, records, business rules,
6
- flows, script includes, ACLs, update sets, and more. Triggered by ServiceNow URLs
7
- (service-now.com, servicenow.com) or when the user mentions ServiceNow, jsn, servicenow,
8
- or related terms like tables, records, business rules, flows, script includes, ACLs,
9
- update sets, or encoded queries.
3
+ description: Use the jsn CLI for ServiceNow development, administration, and data exploration.
10
4
  license: MIT
11
- compatibility: |
12
- Requires jsn CLI (github.com/jacebenson/jsn). Install via:
13
- npm install -g @jacebenson/jsn
14
- Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
15
5
  metadata:
16
6
  author: jacebenson
17
- version: "4.3.6"
7
+ version: "4.3.13"
18
8
  repository: https://github.com/jacebenson/jsn
19
9
  ---
20
10
 
21
- # Jace's ServiceNow CLI (jsn)
11
+ # ServiceNow with JSN
22
12
 
23
- Explore and manage ServiceNow instances. Interactive paginated pickers with server-side search on all list commands.
13
+ Discover commands with `jsn --help`, `jsn discover "search words"`, and
14
+ `jsn <command> --help`.
24
15
 
25
- ## Discovery
16
+ - Verify the intended instance and select it explicitly with `--profile <name>`.
17
+ - Prefer purpose-built or records commands over `eval`. Explain the need and get
18
+ approval before using eval; avoid bypassing ServiceNow protections.
19
+ - Honor delete confirmations. Get approval for destructive work and Global +
20
+ Default mutations; use `--force` only for the approved operation.
26
21
 
27
- ```bash
28
- jsn --help # All commands grouped by domain
29
- jsn <command> --help # Flags, subcommands, and examples
30
- ```
22
+ For development:
31
23
 
32
- ## Interactive Picker
24
+ 1. Choose or create the application scope and update set.
25
+ 2. Do the work, keeping related changes together.
26
+ 3. Verify the results on the instance and report where changes were captured.
27
+ 4. Offer to create ATFs and instance scans to verify the outcomes.
28
+ 5. Ask whether anything else belongs in the update set. If the user confirms
29
+ it is finished, complete it with notes.
33
30
 
34
- All `list` subcommands use a paginated interactive picker in TTY mode:
35
- - Arrow through items (10 per page)
36
- - Type to search (server-side LIKE query)
37
- - Scroll past last loaded item to load next page
38
- - Enter to select, Esc to cancel
39
- - Falls back to table output with `--json`
40
-
41
- ## Agent Rules
42
-
43
- 1. **Output modes** — `--json` when parsing data; text when presenting to humans
44
- 2. **Use sys_id for updates** — All update/delete operations require sys_id
45
- 3. **Check auth first** — Run `jsn auth status` before operations
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 auth switch <name>` to change the default
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
- 7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
50
-
51
- ## Command Hierarchy
52
-
53
- Pick the most specific tool for the job. **Never default to eval** — it's the last resort:
54
-
55
- 1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn catalogitems`, etc.
56
- 2. **`jsn records --table <name>`** — generic CRUD on any table
57
- 3. **`jsn docs search <query>`** — offline ServiceNow documentation search (no instance needed)
58
- 4. **`jsn rest`** — raw Table API escape hatch
59
- 5. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
60
- 6. **Ask the human** — if none of the above work
61
-
62
- ## Key Commands
63
-
64
- | Domain | Commands |
65
- |--------|---------|
66
- | **Core** | `incidents`, `changes`, `requests`, `tasks` |
67
- | **Catalog** | `catalogitems list/show/create` — items with variables and flow info |
68
- | **Automation** | `flows`, `actions`, `rules`, `triggers`, `scheduledjobs` |
69
- | **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
70
- | **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
71
- | **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
72
- | **Config** | `setup`, `auth`, `updatesets`, `scopes` |
73
- | **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
74
- | **Developer** | `eval`, `rest`, `skill`, `version` |
75
-
76
- ### Customization evidence
77
-
78
- ```sh
79
- jsn customizations capture --label review --customer-scope x_acme_app --json
80
- jsn customizations list --json
81
- jsn customizations show <run_id> --get data.categories
82
- jsn customizations show <run_id> --get data.triage
83
- jsn customizations html <run_id> [--out review.html]
84
- jsn customizations compare <baseline_id> <new_id> --json
85
- ```
86
-
87
- Capture performs bounded GET-only table reads and saves local reports. It never
88
- evals or creates, updates or deletes instance records. Standard authentication
89
- may refresh OAuth tokens, store refreshed credentials locally and bootstrap a
90
- session/GCK using authentication POSTs. List/show/compare are offline.
91
- Declare customer ownership with repeatable `--customer-scope` values or sys_ids;
92
- never infer customer ownership from a non-global scope. The declaration is
93
- caller evidence, not independent publisher verification. OOTB findings reflect
94
- historical upgrade skips, not a current verified baseline deviation. The upgrade
95
- history is read server-side for the skip dispositions and every observed skipped
96
- `file_name` is resolved against `sys_metadata` by chunked
97
- `sys_update_nameIN<chunk>` lookups, so the OOTB inventory covers the observed
98
- skipped artifacts; the broad `sys_metadata` read is an arbitrary `sys_id`-ordered
99
- slice and is reported as a bounded sample, not an inventory, describing only the
100
- unclassified and declared-customer categories. Unresolved skipped names and
101
- failed lookup chunks stay explicit rather than becoming absent artifacts, and a
102
- resolved artifact can lack a scope name when its scope record was outside the
103
- bounded `sys_scope` read. Missing
104
- ACL access, pagination limits and unmatched evidence remain explicit. All
105
- reports are incomplete; no findings is not proof of no customizations.
106
- Comparisons cover captured metadata/evidence, not source payloads. Removals mean
107
- no longer observed, not proven deletion. Reversion impact and dependencies
108
- remain unknown until separately investigated. Never recommend automatic reversion.
109
-
110
- Upgrade history joins `file_name` to metadata `sys_update_name`. Raw disposition
111
- `4` or `104` is skipped-customization evidence; resolution `reviewed_reverted`
112
- excludes that history row. Use raw values, not display labels or other skip reasons.
113
-
114
- ### Complete an update set
115
-
116
- ```bash
117
- jsn updatesets complete "My Feature" --notes "Fixed validation" --force --json
118
- ```
119
-
120
- `complete [name]` defaults to the current set when the name is omitted. Explicit
121
- names must be unique. Supply nonempty `--notes` in automation; real newlines
122
- separate bullets. A human terminal opens a notes editor when `--notes` is absent.
123
- Existing Description text is preserved and appended after confirmation. Use
124
- `--force` only after approval, or the profile's `skip_confirmations` policy.
125
- Notes apply only to the selected set. ServiceNow's normal batch rules can complete
126
- child sets when the parent completes; their descriptions are not replaced.
127
- Both selected-set Description and state are read back before success. On
128
- verification failure, inspect the set before retrying.
129
-
130
- ### Table and raw REST examples
131
-
132
- ```bash
133
- # Query a table with the normal records command
134
- jsn records list --table incident --query "active=true" --limit 5 --json
135
-
136
- # Get a single record
137
- jsn records get --table incident --sys-id abc123... --json
138
-
139
- # Raw endpoint access when no specific command fits
140
- jsn rest "/api/now/table/incident?sysparm_limit=3" --json
141
- ```
142
-
143
- ### `jsn docs` — offline documentation search
144
-
145
- No ServiceNow instance required. Searches the full ServiceNow docs locally with FTS5 + semantic hybrid search.
146
-
147
- ```bash
148
- # First run: download docs and build the index (~45k files, ~3-5 min)
149
- jsn docs sync
150
-
151
- # Check state
152
- jsn docs status
153
-
154
- # Search (keyword + semantic hybrid)
155
- jsn docs search "GlideRecord query" --limit 5 --json
156
-
157
- # Filter by bundle
158
- jsn docs search "REST API" --bundle it-service-management --json
159
-
160
- # Start the web UI
161
- jsn docs serve
162
-
163
- ```
164
-
165
- **Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
166
-
167
- ## Troubleshooting Field Behavior
168
-
169
- Check order (priority):
170
-
171
- 1. **Before-query business rules** — `sys_script` with `when=before`, `action=query`
172
- ```bash
173
- jsn b4rules list --query "collection=sys_user"
174
- ```
175
- 2. **ACLs** — `jsn acls list --query "name=incident"`
176
- 3. **Dictionary overrides** — `jsn records list --table sys_dictionary --query "name=incident^element=caller_id"`
177
- 4. **Client scripts** / **UI policies** — form-level field behavior
178
-
179
- ## Configuration
180
-
181
- ```
182
- ~/.config/servicenow/
183
- ├── config.json # Profiles and settings
184
- └── credentials/ # Auth tokens (fallback, one file per key)
185
- ```
31
+ For incoming update sets: import the XML, preview the set, and resolve preview
32
+ errors with the human where needed. Once the preview is clear, commit the set
33
+ and verify the results.
package/src/app.js CHANGED
@@ -7,7 +7,9 @@
7
7
  // setEffectiveInstance() are thin session-backed shims kept for the many
8
8
  // command handlers that already call them.
9
9
 
10
+ import { appendMutation } from './mutation-history.js';
10
11
  import { AuthManager } from './auth.js';
12
+ import { createAuthRecovery } from './auth-recovery.js';
11
13
  import { SDKClient } from './sdk.js';
12
14
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled, hyperlink } from './output.js';
13
15
  import { saveConfig } from './config.js';
@@ -15,7 +17,7 @@ import { resolveSession, applySession, contextFromSession, requireSessionInstanc
15
17
  import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
16
18
  import { errUsage, errAuth } from './errors.js';
17
19
  import { canPrompt } from './helpers.js';
18
- import { isMutationCommand } from './mutations.js';
20
+ import { guardMutationContext } from './mutation-context.js';
19
21
  import process from 'node:process';
20
22
 
21
23
 
@@ -53,8 +55,12 @@ export class App {
53
55
 
54
56
  /** Build (or rebuild) the SDK client for an instance + session. */
55
57
  _buildSdk(instance, session = this.session) {
56
- this.sdk = new SDKClient(instance, this.auth, {
58
+ this.sdk = new SDKClient(instance, this.auth.createProfileProvider(instance, {
59
+ username: session?.username, authMethod: session?.profile?.auth_method,
60
+ }), {
57
61
  domain: session?.domain || '',
62
+ recovery: createAuthRecovery({ app: this, session }),
63
+ onMutation: event => this.recordMutation(instance, session?.profileName, event),
58
64
  });
59
65
  }
60
66
 
@@ -63,9 +69,21 @@ export class App {
63
69
  const profile = options.profile || {};
64
70
  return new SDKClient(instance, this.auth.createProfileProvider(instance, options), {
65
71
  domain: options.domain ?? profile.domain ?? '',
72
+ onMutation: event => this.recordMutation(instance, options.profileName || null, event),
66
73
  });
67
74
  }
68
75
 
76
+ recordMutation(instance, profile, event) {
77
+ if (!this._authArgv) return;
78
+ try { appendMutation({ ...event, instance, profile, reason: this._authArgv.reason }); }
79
+ catch {
80
+ if (!this._historyWarningShown) {
81
+ process.stderr.write('Warning: local mutation history could not be saved. The remote result is unchanged.\n');
82
+ this._historyWarningShown = true;
83
+ }
84
+ }
85
+ }
86
+
69
87
  loadContext() {
70
88
  this.context = contextFromSession(this.session, this.config);
71
89
  }
@@ -74,7 +92,7 @@ export class App {
74
92
  return this.session?.instance || '';
75
93
  }
76
94
 
77
- async printContextHeader(argv = {}) {
95
+ async printContextHeader(_argv = {}) {
78
96
  if (!this.getEffectiveInstance() || !this.sdk) return;
79
97
  if (process.env.JSN_NO_HEADER) return;
80
98
  if (this.output.getFormat() === FormatJSON || this.output.getFormat() === FormatQuiet) return;
@@ -100,12 +118,12 @@ export class App {
100
118
 
101
119
  // Both lookups depend on the user, but not on one another.
102
120
  const [application, selectedUpdateSet] = userSysID ? await Promise.all([
103
- getCurrentApplication(this.sdk, userSysID).catch(() => null),
121
+ getCurrentApplication(this.sdk, userSysID, { unknownWhenMissing: true }).catch(() => null),
104
122
  getCurrentUpdateSet(this.sdk, userSysID).catch(() => null),
105
123
  ]) : [null, null];
106
- const scope = application?.scope || 'global';
124
+ const scope = application?.scope || 'unknown';
107
125
  const updateSet = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
108
- ? selectedUpdateSet.name : 'Default';
126
+ ? selectedUpdateSet.name : 'unknown';
109
127
  const updateSetSysID = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
110
128
  ? selectedUpdateSet.sys_id : '';
111
129
  this.context.scope = scope;
@@ -135,43 +153,14 @@ export class App {
135
153
 
136
154
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
137
155
 
138
- // ⚠️ Warning if in the Default update set (mutation commands only)
139
- if (isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
140
- const isGlobal = scope === 'global';
141
- if (isGlobal) {
142
- // 🔴 HARD WARNING — Global + Default
143
- process.stderr.write(
144
- '\x1b[31m' + // red
145
- '┏━ GLOBAL + DEFAULT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓\n' +
146
- '┃ You are in Global scope with the Default update set. ┃\n' +
147
- '┃ Changes ARE captured — in the Default update set. ┃\n' +
148
- '┃ Moving them to a named update set later requires ┃\n' +
149
- '┃ manual surgery, one change at a time. Avoid the mess. ┃\n' +
150
- '┃ ┃\n' +
151
- '┃ Create a named update set now: ┃\n' +
152
- '┃ jsn updatesets create --name "My Feature" ┃\n' +
153
- '┃ ┃\n' +
154
- '┃ Or switch to a scoped scope first: ┃\n' +
155
- '┃ jsn scopes list ┃\n' +
156
- '┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m\n'
157
- );
158
- } else {
159
- // 🟡 SOFT WARNING — Scope + Default
160
- process.stderr.write(
161
- '\x1b[33m' + // yellow
162
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
163
- ' ⚠ Default update set in scope [' + scope + ']\n' +
164
- ' Changes are contained to this scope, but a named\n' +
165
- ' update set is still recommended for tracking.\n' +
166
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
167
- '\x1b[0m' // reset
168
- );
169
- }
170
- }
156
+ }
157
+
158
+ async guardMutationContext(argv) {
159
+ this.mutationContext = await guardMutationContext(this, argv);
171
160
  }
172
161
 
173
162
  ok(data, opts = {}) {
174
- this.output.ok(data, opts);
163
+ this.output.ok(data, this.mutationContext ? { ...opts, context: { ...opts.context, mutation: this.mutationContext } } : opts);
175
164
  }
176
165
 
177
166
  err(error) {
@@ -0,0 +1,83 @@
1
+ import { errAPI, errAuth } from './errors.js';
2
+ import { canUsePicker, quoteShellArg } from './helpers.js';
3
+ import { confirm } from './prompts.js';
4
+
5
+ // Recovery is scoped to the SDK's resolved identity, never the mutable default.
6
+ // error supplies the same safe guidance if the single retry is also rejected.
7
+ export function createAuthRecovery({ app, session, argv = () => app._authArgv || {},
8
+ canInteract = canUsePicker, prompt = confirm,
9
+ renewBrowser = async (request, targetApp) => {
10
+ const { refreshProfile } = await import('./commands/auth.js');
11
+ return refreshProfile(request, targetApp);
12
+ },
13
+ }) {
14
+ const { instance, profileName, username, profile } = session;
15
+ const target = profileName ? `--profile ${quoteShellArg(profileName)}` : quoteShellArg(instance);
16
+ const label = profileName ? `profile ${quoteShellArg(profileName)}` : 'the active profile';
17
+ function failure(method = profile?.auth_method) {
18
+ const error = errAPI(401, '');
19
+ const login = `jsn auth login ${target}`;
20
+ error.message = `Authentication rejected for ${label} (HTTP 401).`;
21
+ error.profile = profileName || null;
22
+ error.recovery = method === 'basic'
23
+ ? `jsn auth login --basic ${quoteShellArg(instance)}${profileName ? ` --profile ${quoteShellArg(profileName)}` : ''}`
24
+ : method === 'gck' ? `jsn auth refresh ${target} --browser`
25
+ : method === 'oauth' ? `jsn auth refresh ${target}` : login;
26
+ error.hint = method === 'basic'
27
+ ? `Set SN_USERNAME/SN_PASSWORD to the correct credentials, then run: ${error.recovery}`
28
+ : method === 'oauth'
29
+ ? `Run interactively: ${error.recovery}. If refresh fails, run: ${login}`
30
+ : `Run: ${error.recovery}`;
31
+ return error;
32
+ }
33
+ // Keep the settled promise too: this provider gets only one recovery attempt.
34
+ let recoveryAttempt;
35
+ const provider = {
36
+ error: failure(),
37
+ credentialError(cause) {
38
+ // Local validation can fail before any HTTP response. Keep its precise
39
+ // reason, but target recovery to this invocation rather than the default.
40
+ const error = errAuth(`${cause.message} (${label}).`);
41
+ error.profile = profileName || null;
42
+ error.recovery = `jsn auth login ${target}`;
43
+ error.hint = `Run: ${error.recovery}`;
44
+ return error;
45
+ },
46
+ async recover({ status }) {
47
+ if (status !== 401) return false;
48
+ return recoveryAttempt ??= recoverOnce();
49
+ },
50
+ };
51
+ async function recoverOnce() {
52
+ let creds;
53
+ try {
54
+ creds = await app.auth.getCredentialsFor(instance, username, {
55
+ username, authMethod: profile?.auth_method, refresh: false,
56
+ });
57
+ } catch { throw provider.error; }
58
+ const method = profile?.auth_method || creds.auth_method;
59
+ provider.error = failure(method);
60
+ if (method === 'oauth' && creds.refresh_token) {
61
+ try {
62
+ await app.auth.refreshToken(instance, creds, username ?? null);
63
+ return true;
64
+ } catch { throw provider.error; }
65
+ }
66
+ if (method === 'gck' && profileName && ['auto', 'confirm'].includes(profile?.reauth ?? 'confirm')
67
+ && canInteract(app, argv())) {
68
+ try {
69
+ if ((profile?.reauth ?? 'confirm') === 'confirm'
70
+ && !await prompt({ message: `Sign in again in a browser for ${label}?`, default: false })) throw provider.error;
71
+ // Reuse the canonical GCK refresh/login operation without emitting a
72
+ // second success envelope or replacing the in-flight command's SDK.
73
+ const targetApp = Object.create(app);
74
+ targetApp.ok = () => {};
75
+ targetApp.sdk = app.getSDKForProfile(instance, { profile, authMethod: 'gck', username });
76
+ await renewBrowser({ profile: profileName, browser: true }, targetApp);
77
+ return true;
78
+ } catch { throw provider.error; }
79
+ }
80
+ throw provider.error;
81
+ }
82
+ return provider;
83
+ }
package/src/auth.js CHANGED
@@ -612,7 +612,9 @@ export class AuthManager {
612
612
  if (!instance) {
613
613
  throw errAuth('No instance specified');
614
614
  }
615
+ const existed = !!this.credentialStore.load(instance, username);
615
616
  this.credentialStore.delete(instance, username);
617
+ return existed && !this.credentialStore.load(instance, username);
616
618
  }
617
619
 
618
620
  /**
@@ -16,6 +16,10 @@
16
16
  // skipDailyChecks: true,
17
17
  // // Suppress the current instance header for profile management commands.
18
18
  // skipContextHeader: true,
19
+ // // Remediation commands bypass mutation capture-context approval only.
20
+ // mutationContextExemptSubcommands: ['create', 'set'],
21
+ // // REST methods that are reads (read-only profile policy is separate).
22
+ // mutationContextReadMethods: ['GET', 'HEAD', 'OPTIONS'],
19
23
  // }
20
24
  //
21
25
  // cli.js derives every skip-list and the mutation guard's path list from
@@ -130,3 +134,8 @@ export function mutationPaths(caps = collectCapabilities()) {
130
134
  export function _resetCapabilities() {
131
135
  REGISTRY.clear();
132
136
  }
137
+
138
+ /** Declared local-only previews bypass instance access, including context headers. */
139
+ export function isLocalPreview(argv, caps = collectCapabilities()) {
140
+ return argv.dryRun === true && caps.get(argv._?.[0])?.localPreviewSubcommands?.includes(argv._?.[1]) === true;
141
+ }
@@ -3,6 +3,14 @@ import process from 'node:process';
3
3
 
4
4
  const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
5
5
  const names = (x) => typeof x === 'string' ? [x] : (x || []);
6
+
7
+ /**
8
+ * A repeatable option may be declared either way: `type: 'array'` (this
9
+ * adapter's historic spelling) or `array: true` (the yargs spelling most
10
+ * contributors reach for). Both must accumulate; honoring only the first
11
+ * silently kept the LAST value of `-f a=1 -f b=2` and dropped the rest.
12
+ */
13
+ const isArrayOption = (spec) => spec?.type === 'array' || spec?.array === true;
6
14
  const commandName = (command) => command.trim().split(/\s+/)[0];
7
15
  const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
8
16
 
@@ -108,6 +116,7 @@ export class LocalCLI {
108
116
 
109
117
  scriptName(value) { this.script = value; return this; }
110
118
  usage(value) { this.root.usageText = value; return this; }
119
+ epilogue(value) { this.root.epilogueText = value; return this; }
111
120
  middleware(fn) { this.middlewares.push(...(Array.isArray(fn) ? fn : [fn])); return this; }
112
121
  option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
113
122
  options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
@@ -189,7 +198,7 @@ export class LocalCLI {
189
198
  _assign(argv, key, value, spec, opts = {}) {
190
199
  const validate = opts.validate !== false;
191
200
  const actual = spec?.name || key;
192
- const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
201
+ const values = isArrayOption(spec) ? (Array.isArray(value) ? value : [value]) : [value];
193
202
  if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
194
203
  this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
195
204
  return false;
@@ -201,7 +210,7 @@ export class LocalCLI {
201
210
  const converted = spec?.type === 'boolean'
202
211
  ? (value === 'true' ? true : value === 'false' ? false : value)
203
212
  : spec?.type === 'number' ? Number(value) : value;
204
- if (spec?.type === 'array') argv[actual] = [...(argv[actual] || []), ...values];
213
+ if (isArrayOption(spec)) argv[actual] = [...(argv[actual] || []), ...values];
205
214
  else argv[actual] = converted;
206
215
  argv[camel(actual)] = argv[actual];
207
216
  for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
@@ -210,8 +219,8 @@ export class LocalCLI {
210
219
  const options = this._optionConfig(path);
211
220
  const defs = {};
212
221
  for (const [key, spec] of options) defs[key] = {
213
- type: spec.type === 'number' || spec.type === 'array' ? 'string' : (spec.type || 'boolean'),
214
- multiple: spec.type === 'array',
222
+ type: spec.type === 'number' || isArrayOption(spec) ? 'string' : (spec.type || 'boolean'),
223
+ multiple: isArrayOption(spec),
215
224
  };
216
225
  args = args.map((arg) => {
217
226
  if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
@@ -286,6 +295,13 @@ export class LocalCLI {
286
295
  aliases: [...node.aliases],
287
296
  description: node.definition.describe || '',
288
297
  subcommands: node.children.filter(visible).map((child) => child.name),
298
+ discovery: node.definition.discovery || null,
299
+ help: `jsn ${node.name} --help`,
300
+ actions: node.children.filter(visible).map((child) => ({
301
+ name: child.name,
302
+ description: child.definition.describe || '',
303
+ help: `jsn ${node.name} ${child.name} --help`,
304
+ })),
289
305
  };
290
306
  };
291
307
  return {
@@ -316,6 +332,10 @@ export class LocalCLI {
316
332
  }
317
333
 
318
334
  _help(node, path = []) {
335
+ if (!path.length && this.rootHelpRenderer) {
336
+ process.stdout.write(this.rootHelpRenderer() + "\n");
337
+ return;
338
+ }
319
339
  const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
320
340
  if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
321
341
  if (node.children.length) lines.push('\nCommands:\n' + node.children.filter((n) => n.name !== this.completionCommand).map((n) => ` ${n.definition.command} ${n.definition.describe || ''}`).join('\n'));