@jacebenson/jsn 4.3.7 → 4.3.8
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.
- package/README.md +246 -1
- package/package.json +1 -1
- package/src/app.js +8 -1
- package/src/auth.js +14 -12
- package/src/cli-adapter.js +162 -11
- package/src/cli.js +121 -13
- package/src/commands/_generic.js +3 -2
- package/src/commands/_simple.js +1 -1
- package/src/commands/actions.js +156 -0
- package/src/commands/auth.js +21 -11
- package/src/commands/cmdb.js +18 -0
- package/src/commands/diff.js +205 -0
- package/src/commands/docs/docs.js +18 -16
- package/src/commands/flows.js +149 -47
- package/src/commands/graphql.js +95 -0
- package/src/commands/groupmembers.js +19 -4
- package/src/commands/grouproles.js +15 -4
- package/src/commands/midservers.js +708 -0
- package/src/commands/perf.js +36 -9
- package/src/commands/platform.js +202 -0
- package/src/commands/records.js +19 -15
- package/src/commands/skill.js +5 -2
- package/src/commands/transactions.js +78 -0
- package/src/errors.js +71 -6
- package/src/flow-create.js +884 -0
- package/src/flow-designer-interactive.js +417 -0
- package/src/flow-inspection.js +15 -5
- package/src/flow-publish.js +32 -0
- package/src/flow-test.js +122 -0
- package/src/flow-triggers.js +448 -0
- package/src/flow-versioning.js +53 -0
- package/src/help.js +93 -28
- package/src/helpers.js +124 -9
- package/src/midserver-direct.js +312 -0
- package/src/midserver-install.js +170 -0
- package/src/midserver-zip.js +107 -0
- package/src/output.js +7 -0
- package/src/processflow-actions.js +366 -0
- package/src/sdk.js +8 -0
package/README.md
CHANGED
|
@@ -31,6 +31,19 @@ npm install -g @jacebenson/jsn
|
|
|
31
31
|
|
|
32
32
|
**Note:** git installs build `better-sqlite3` (docs search) from source, so the machine needs build tools (Python, make, a C++ compiler).
|
|
33
33
|
|
|
34
|
+
## Direct MID installation
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
jsn midservers add --install-method direct --name worker1
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Official x86-64 ZIPs for Linux and Windows, selected from the instance's
|
|
41
|
+
`mid.version`. Credentials are prompted and validated before installation.
|
|
42
|
+
Linux supports a user-owned background runtime. Windows currently prepares
|
|
43
|
+
private files and offers a foreground console, not native service registration.
|
|
44
|
+
See [direct MID installation](docs/mid-direct-install.md) for security boundaries,
|
|
45
|
+
lifecycle commands and the untested Windows host phases. Docker remains available.
|
|
46
|
+
|
|
34
47
|
## Quick Start
|
|
35
48
|
|
|
36
49
|
```bash
|
|
@@ -59,6 +72,7 @@ jsn flows executions --since "2026-08-25 00:00:00" --until "2026-08-26 00:00:00"
|
|
|
59
72
|
jsn flows executions --summary # Server totals + sampled duration metrics by flow
|
|
60
73
|
jsn flows executions --limit 100 # Inspect more rows from the matching population
|
|
61
74
|
jsn flows executions --record <sys_id> # Executions for one source record
|
|
75
|
+
jsn flows create --data-file flow.manifest.json # Create a draft record-triggered flow
|
|
62
76
|
jsn rules list --query "collection=incident"
|
|
63
77
|
jsn updatesets set "My Feature"
|
|
64
78
|
|
|
@@ -72,10 +86,19 @@ jsn records bulk --table incident --query "priority=1" --set '{"state":"3"}' #
|
|
|
72
86
|
jsn records get --table incident --sys-id 8a1234abcd5678 --attachments # record + its files
|
|
73
87
|
jsn records list --table incident --limit 10 --csv # output CSV
|
|
74
88
|
|
|
89
|
+
# Read-only cross-profile record diff
|
|
90
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod
|
|
91
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --ignore state
|
|
92
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --no-ignore
|
|
93
|
+
|
|
75
94
|
# Script execution
|
|
76
95
|
jsn eval "gs.info('Hello World')"
|
|
77
96
|
cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
|
|
78
97
|
|
|
98
|
+
# Now GraphQL (documents may contain mutations; read-only profiles are blocked)
|
|
99
|
+
jsn graphql --query '{ GlideRecord_Query { incident(pagination: {limit: 2}) { _results { number { value } } } } }'
|
|
100
|
+
cat incident.graphql | jsn graphql --stdin --variables '{"limit":2}'
|
|
101
|
+
|
|
79
102
|
# Saved query snippets (stored locally, run against the active profile)
|
|
80
103
|
jsn snippets save open-inc --table incident --query "active=true"
|
|
81
104
|
jsn snippets run open-inc
|
|
@@ -84,8 +107,203 @@ jsn snippets run open-inc
|
|
|
84
107
|
jsn logs follow --level error --tail 10
|
|
85
108
|
```
|
|
86
109
|
|
|
110
|
+
### Flow Designer actions
|
|
111
|
+
|
|
112
|
+
`actions` and `action` are the same command. `list`, `show`, and explicit
|
|
113
|
+
`delete` remain available. In a human terminal, omit the target to open a
|
|
114
|
+
searchable picker. Create and edit guide you through provider schemas and save
|
|
115
|
+
full Process Flow definitions, not ordinary table-field updates.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
jsn actions create # Guided scope, name, schemas, inputs and script
|
|
119
|
+
jsn actions edit # Pick an action, edit its saved definition
|
|
120
|
+
jsn actions definition # Pick and inspect a complete definition
|
|
121
|
+
jsn actions test --wait # Pick an action, supply saved-schema inputs, confirm
|
|
122
|
+
jsn actions step-types # Pick the exact transaction scope
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Script inputs offer a file path or your `$VISUAL`/`$EDITOR`. New script steps can
|
|
126
|
+
declare outputs and map them to action outputs. Variable declarations reuse
|
|
127
|
+
provider schema templates and receive fresh UI identities. Unknown nested
|
|
128
|
+
schema fields are retained. Unsupported input types stop with instructions to
|
|
129
|
+
use `--data-file`; JSN does not silently omit them or invent schema defaults.
|
|
130
|
+
Creation waits for an explicit final confirmation before allocating a parent.
|
|
131
|
+
It saves a draft only and never publishes or runs it automatically.
|
|
132
|
+
|
|
133
|
+
For automation, keep using explicit identifiers and full JSON:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
jsn actions step-types --scope <scope_sys_id> --get data > step-types.json
|
|
137
|
+
jsn actions create --scope <scope_sys_id> --data-file new-action.json --json
|
|
138
|
+
jsn actions definition <action_sys_id> --get data > action.json
|
|
139
|
+
# Edit action.json, retaining its ID, scope, lifecycle fields and all arrays.
|
|
140
|
+
jsn actions update <action_sys_id> --data-file action.json --json
|
|
141
|
+
jsn action edit <action_sys_id> --data-file action.json --json # same operation
|
|
142
|
+
jsn actions test <action_sys_id> --force --output-map '{"response":"hello"}' --wait --timeout 60 --json
|
|
143
|
+
jsn actions test <action_sys_id> --force --data-file action.json --output-map '{"response":"hello"}' --wait --get data.outputs.response.value
|
|
144
|
+
jsn actions delete <action_sys_id> --force # explicit cleanup, never automatic
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Existing targets infer their exact transaction scope from the action record.
|
|
148
|
+
An explicit `--scope` must match that record byte-for-byte and exist in
|
|
149
|
+
`sys_scope`. For Global, use the literal sys_id `global`.
|
|
150
|
+
|
|
151
|
+
This **illustrative create document** shows an input/script/output mapping.
|
|
152
|
+
Replace `<SCRIPT_STEP_TYPE_SYS_ID>` with the target's `step-types` result.
|
|
153
|
+
Start from that step type's full input/output schemas, preserving runtime
|
|
154
|
+
settings, status outputs and any other required fields. The shortened example
|
|
155
|
+
is not a captured or instance-verified template. New steps/variables need their
|
|
156
|
+
own identifiers; do not clone another action's record IDs. Leave new steps'
|
|
157
|
+
`step_id` and `action` blank or omitted. Create binds their `action` to the newly
|
|
158
|
+
allocated parent; persisted step IDs or parent bindings are rejected before creation.
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"name": "Echo response",
|
|
163
|
+
"description": "Return the supplied response",
|
|
164
|
+
"internal_name": "echo_response",
|
|
165
|
+
"inputs": [{"name": "response", "label": "Response", "type": "string", "mandatory": true}],
|
|
166
|
+
"outputs": [{"name": "response", "label": "Response", "type": "string", "value": "{{step[11111111-2222-4333-8444-555555555555].response}}"}],
|
|
167
|
+
"steps": [{
|
|
168
|
+
"cid": "11111111-2222-4333-8444-555555555555",
|
|
169
|
+
"step_type": "SCRIPT",
|
|
170
|
+
"step_type_id": "<SCRIPT_STEP_TYPE_SYS_ID>",
|
|
171
|
+
"label": "Echo script",
|
|
172
|
+
"order": 1,
|
|
173
|
+
"inputs": [
|
|
174
|
+
{"name": "required_run_time", "type": "choice", "value": "instance"},
|
|
175
|
+
{"name": "script", "type": "script", "value": "(function execute(inputs, outputs) { outputs.response = inputs.response; })(inputs, outputs);"}
|
|
176
|
+
],
|
|
177
|
+
"outputs": [],
|
|
178
|
+
"extended_inputs": [{"name": "response", "type": "string", "value": "{{inputs.response}}", "extended": true}],
|
|
179
|
+
"extended_outputs": [{"name": "response", "type": "string", "extended": true}]
|
|
180
|
+
}]
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Create first inserts the parent in `sys_hub_action_type_definition`, reads that
|
|
185
|
+
parent's own Process Flow defaults, then saves the inputs, outputs and nonempty
|
|
186
|
+
steps with the internal `/api/now/processflow/action/action_types/{id}` PUT.
|
|
187
|
+
It retains fresh lifecycle/status metadata instead of copying source snapshot
|
|
188
|
+
or status IDs. A subsequent definition GET plus `/step_instances` GET verifies
|
|
189
|
+
scripts, mappings and schemas. Update requires a complete document from
|
|
190
|
+
`definition`, including target-owned lifecycle fields. Omitted fields are
|
|
191
|
+
rejected before mutation. A PUT timeout triggers readback, not another PUT;
|
|
192
|
+
`persisted_after_timeout` means persistence was verified, not that HTTP success
|
|
193
|
+
was received. Failed creation reports the parent ID and leaves cleanup to you.
|
|
194
|
+
|
|
195
|
+
Test sends `action`, `outputMap`, `runOnThread` and `tracingEnabled` to `/test`.
|
|
196
|
+
Despite its wire name, `--output-map` supplies **action inputs**. Without
|
|
197
|
+
`--wait`, success means dispatch only, with `status: dispatched` and no outputs.
|
|
198
|
+
With `--wait`, JSN requires a `COMPLETE` context, blank `error_message`, and each
|
|
199
|
+
declared user output's actual value from `sys_flow_runtime_value`. Output entries
|
|
200
|
+
have `value`, `displayValue` and `hasValue`; false, zero and empty strings count.
|
|
201
|
+
Failures, denied/missing outputs and timeouts exit nonzero. The wait budget
|
|
202
|
+
starts after dispatch, defaults to 60 seconds, and is capped at 3600 seconds.
|
|
203
|
+
Timeout does not cancel the server execution. Draft actions can be tested without
|
|
204
|
+
publication. Testing runs their scripts and can have side effects. It requires
|
|
205
|
+
interactive confirmation or `--force`, unless the profile explicitly skips
|
|
206
|
+
confirmations. Read-only profiles block create/update/delete/test. No publish
|
|
207
|
+
or snapshot command is added.
|
|
208
|
+
These internal endpoints and runtime-table permissions can vary by instance.
|
|
209
|
+
|
|
87
210
|
### Flow execution fields
|
|
88
211
|
|
|
212
|
+
### Flow Designer terminal workflows
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
jsn flows create # Guided record trigger and published action inputs
|
|
216
|
+
jsn flows edit # Pick an inactive Draft, edit condition or existing inputs
|
|
217
|
+
jsn flows show # Pick a flow to inspect
|
|
218
|
+
jsn flows status # Pick a flow to diagnose
|
|
219
|
+
jsn flows test # Pick flow and a record from its actual trigger table, confirm
|
|
220
|
+
jsn flows publish # Pick a flow, explicitly confirm activation
|
|
221
|
+
# Automation must explicitly confirm publication too:
|
|
222
|
+
jsn flows publish <flow_sys_id> --force --json
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Wizards and omitted-target pickers require both a prompt-capable terminal and
|
|
226
|
+
`auto` output. `--json`, explicit formats, `--get`, `--data`, `--data-file`,
|
|
227
|
+
`--output-map`, pipes, `CI`, `NONINTERACTIVE` and `JSN_NO_PROMPTS=1` never open a
|
|
228
|
+
wizard. In those modes provide identifiers and required scope/record/data
|
|
229
|
+
explicitly. Tests, publish and deletion require `--force` or the profile's
|
|
230
|
+
existing explicit skip-confirmations setting when prompting is unavailable.
|
|
231
|
+
Read-only profiles still block mutations before authentication or prompting.
|
|
232
|
+
Press Escape in record pickers or Ctrl+C in any wizard to cancel. Refusing a
|
|
233
|
+
final confirmation leaves the instance untouched.
|
|
234
|
+
|
|
235
|
+
Existing action/flow scopes come from saved raw identifiers. New scopes offer
|
|
236
|
+
the active application preference if available, then a searchable scope picker.
|
|
237
|
+
No scope name is guessed. Input pills are passed through unchanged.
|
|
238
|
+
|
|
239
|
+
Flow edits are limited to the existing manifest contract. They do not rename
|
|
240
|
+
flows, add/remove steps, implement Flow Logic, subflows, variables or error
|
|
241
|
+
handling. Flow deletion remains unsupported. Action deletion retains its
|
|
242
|
+
existing explicit parent-record cleanup and absence readback, not a new native
|
|
243
|
+
builder deletion lifecycle. See [command coverage and local proof](docs/flow-designer-interactive.md).
|
|
244
|
+
|
|
245
|
+
### Flow creation manifest
|
|
246
|
+
|
|
247
|
+
`jsn flows create` accepts `--data-file` or inline `--data`, or guides human
|
|
248
|
+
terminal users without those options. The supported slice
|
|
249
|
+
supports one `record_create_or_update` trigger and published Action types whose
|
|
250
|
+
input schemas are available from the instance. JSN resolves action metadata,
|
|
251
|
+
defaults, and typed inputs from ServiceNow; callers do not provide GraphQL or
|
|
252
|
+
editor-generated identifiers.
|
|
253
|
+
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"name": "JSN-STUDY-ticket-log",
|
|
257
|
+
"description": "Log JSN study tickets",
|
|
258
|
+
"scope": "<SCOPE_SYS_ID>",
|
|
259
|
+
"trigger": {
|
|
260
|
+
"type": "record_create_or_update",
|
|
261
|
+
"table": "ticket",
|
|
262
|
+
"condition": "short_descriptionSTARTSWITHJSN-STUDY-"
|
|
263
|
+
},
|
|
264
|
+
"actions": [
|
|
265
|
+
{ "type": "Log", "inputs": { "log_level": "info", "log_message": "Ticket received" } }
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`scope` is required; JSN never defaults it to a tenant-specific or captured
|
|
271
|
+
scope. The record-trigger condition currently accepts one simple encoded-query
|
|
272
|
+
predicate (`fieldSTARTSWITHvalue`, `field=value`, `fieldLIKEvalue`, and the
|
|
273
|
+
equivalent single-predicate operators). Compound `^`/`^OR` expressions are
|
|
274
|
+
rejected until their predicate AST mapping is proven against the instance.
|
|
275
|
+
|
|
276
|
+
Creation leaves the flow in Draft and inactive, then creates and reads back an
|
|
277
|
+
Autosave version. `flows publish` remains a separate lifecycle operation: it
|
|
278
|
+
reads the saved ProcessFlow definition, POSTs `/snapshot`, creates an
|
|
279
|
+
`Activate/Publish` version, reads version history, and verifies `active=true`.
|
|
280
|
+
Do not use it in a dry-run: publishing activates the flow.
|
|
281
|
+
|
|
282
|
+
`jsn flows test <identifier> --record <sys_id>` is also a mutating operation.
|
|
283
|
+
It requires confirmation (or `--force`), reads the saved definition, verifies
|
|
284
|
+
that the supplied record exists in the record-trigger table, and POSTs the full
|
|
285
|
+
definition to the ProcessFlow test endpoint. A response with only a context ID
|
|
286
|
+
is reported as accepted, not completed. Test runs execute actions and can have
|
|
287
|
+
side effects; this slice does not execute test flows during development.
|
|
288
|
+
|
|
289
|
+
Flow Logic, subflows, flow-variable lifecycle, and error handling are deferred
|
|
290
|
+
and rejected by this slice rather than silently ignored.
|
|
291
|
+
|
|
292
|
+
`jsn flows update <identifier> --data-file update.json` updates supported fields
|
|
293
|
+
of existing inactive Draft flows. The manifest may contain only `trigger` (with
|
|
294
|
+
`table` and/or `condition`) and `actions`. Each action selects one saved instance
|
|
295
|
+
by its one-based `order` and reusable action `type` (name or sys_id). Its `inputs`
|
|
296
|
+
object may change only inputs already present in that saved Action. The CLI reads
|
|
297
|
+
the saved component identities itself; manifests never carry UI UUIDs. Omitted
|
|
298
|
+
trigger fields, Action inputs, metadata, and other flow components are preserved.
|
|
299
|
+
Names must resolve to exactly one flow, and the protected `jace-test-flow` is
|
|
300
|
+
rejected. Update sends the captured GraphQL `triggerInstances.update` and/or
|
|
301
|
+
`actions.update` shapes, reads the full ProcessFlow definition back, creates an
|
|
302
|
+
Autosave version, and reports update-set captures. It never publishes or
|
|
303
|
+
activates the flow. Unsupported Flow Logic, subflows, variables, error handling,
|
|
304
|
+
new Action inputs, ambiguous selectors, active flows, and non-Draft flows are
|
|
305
|
+
rejected before mutation.
|
|
306
|
+
|
|
89
307
|
`jsn flows executions` reads `sys_flow_context` and returns both the raw row and a normalized `execution` object. JSN discovers the runtime columns from `sys_dictionary` first, then uses these mappings:
|
|
90
308
|
|
|
91
309
|
- `started`: `started`, `start_time`, `started_at`, then `sys_created_on`
|
|
@@ -110,10 +328,31 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
|
|
|
110
328
|
|
|
111
329
|
**User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
|
|
112
330
|
|
|
113
|
-
**Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
|
|
331
|
+
**Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `diff record` (cross-profile read-only comparison), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
|
|
332
|
+
|
|
333
|
+
**Platform** — `platform health` (cluster records) and `platform stats` (bounded, read-only `/xmlstats.do` summary; allowlisted metrics only)
|
|
114
334
|
|
|
115
335
|
Every command supports `--json`, `--query`, and `--help`.
|
|
116
336
|
|
|
337
|
+
For multiline script fields, `--data-file` parses the file as UTF-8 JSON and
|
|
338
|
+
passes the resulting values through unchanged. On Windows PowerShell, generate
|
|
339
|
+
the payload as JSON text and inspect it before invoking JSN, for example:
|
|
340
|
+
|
|
341
|
+
```powershell
|
|
342
|
+
$script = [string](Get-Content -Path .\Example.script.js -Raw)
|
|
343
|
+
$payload = @{ script = $script; description = 'Example' } |
|
|
344
|
+
ConvertTo-Json -Depth 5 -Compress
|
|
345
|
+
Set-Content -Path .\Example.update.json -Value $payload -Encoding UTF8
|
|
346
|
+
Get-Content .\Example.update.json -Raw | ConvertFrom-Json | Select-Object -ExpandProperty script
|
|
347
|
+
jsn includes update Example --data-file .\Example.update.json --json
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`JSON.parse` preserves a JSON string as a JavaScript string, and the SDK
|
|
351
|
+
serializes update payloads with `JSON.stringify`. If the file already contains
|
|
352
|
+
a `{value=...}` or FileSystem-provider representation, that text is external
|
|
353
|
+
to JSN and must be corrected before the update. Use a read-back command or
|
|
354
|
+
`records update --strict` where applicable to verify persisted fields.
|
|
355
|
+
|
|
117
356
|
## Instances
|
|
118
357
|
|
|
119
358
|
Switch between instances without re-authenticating.
|
|
@@ -162,6 +401,12 @@ export SERVICENOW_OAUTH_TOKEN="***"
|
|
|
162
401
|
jsn incidents list
|
|
163
402
|
```
|
|
164
403
|
|
|
404
|
+
For the credential identity, auth-method, diagnostics, migration, and concurrency contract, see [`docs/AUTH_LIFECYCLE.md`](docs/AUTH_LIFECYCLE.md).
|
|
405
|
+
|
|
406
|
+
GraphQL uses ServiceNow's `/api/now/graphql` endpoint and sends `{query, variables}` through the authenticated SDK transport. The command preserves GraphQL `data` and `errors`; introspection and available namespaces depend on the target instance.
|
|
407
|
+
|
|
408
|
+
`jsn platform stats` uses a fixed `GET /xmlstats.do` request. It never accepts a processor path, query parameters, or request body, and it does not emit raw XML, cookies, credentials, or full node identifiers. Oversized, HTML/login, permission, unsupported, timeout, and malformed responses are reported with bounded collector statuses; missing metrics remain unavailable rather than being reported as zero.
|
|
409
|
+
|
|
165
410
|
## Local data
|
|
166
411
|
|
|
167
412
|
JSN stores durable local data under `~/.jsn/`. Documentation lives in
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { saveConfig } from './config.js';
|
|
|
14
14
|
import { resolveSession, applySession, contextFromSession, requireSessionInstance } from './session.js';
|
|
15
15
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
16
16
|
import { errUsage, errAuth } from './errors.js';
|
|
17
|
+
import { canPrompt } from './helpers.js';
|
|
17
18
|
import { isMutationCommand } from './mutations.js';
|
|
18
19
|
import process from 'node:process';
|
|
19
20
|
|
|
@@ -190,8 +191,14 @@ export class App {
|
|
|
190
191
|
this.output.err(error);
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* True only when a human can actually answer a prompt. Delegates to
|
|
196
|
+
* canPrompt() so the check is "both stdio descriptors are TTYs AND no
|
|
197
|
+
* agent/CI/opt-out marker is set" — a PTY allocated for an agent
|
|
198
|
+
* (stdout.isTTY true, JSN_NO_PROMPTS/CI set) is NOT interactive.
|
|
199
|
+
*/
|
|
193
200
|
isInteractive() {
|
|
194
|
-
return
|
|
201
|
+
return canPrompt();
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
requireInstance() {
|
package/src/auth.js
CHANGED
|
@@ -41,30 +41,32 @@ function credKey(instance, username) {
|
|
|
41
41
|
|
|
42
42
|
// ─── PKCE state persistence (shared with Go version) ───
|
|
43
43
|
|
|
44
|
-
function pkceStatePath(instance) {
|
|
44
|
+
function pkceStatePath(instance, username) {
|
|
45
45
|
const dir = path.join(globalConfigDir(), 'pkce');
|
|
46
46
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
-
|
|
47
|
+
// A known profile identity gets its own pending-login slot. Keep the bare
|
|
48
|
+
// instance slot for legacy/no-profile flows, where no identity exists yet.
|
|
49
|
+
const filename = credKey(instance, username) + '.json';
|
|
48
50
|
return path.join(dir, filename);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function savePKCEState(instance, pkce) {
|
|
52
|
-
const filePath = pkceStatePath(instance);
|
|
53
|
+
function savePKCEState(instance, pkce, username) {
|
|
54
|
+
const filePath = pkceStatePath(instance, username);
|
|
53
55
|
fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
function loadPKCEState(instance) {
|
|
58
|
+
function loadPKCEState(instance, username) {
|
|
57
59
|
try {
|
|
58
|
-
const data = fs.readFileSync(pkceStatePath(instance), 'utf-8');
|
|
60
|
+
const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
|
|
59
61
|
return JSON.parse(data);
|
|
60
62
|
} catch {
|
|
61
63
|
return null;
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function removePKCEState(instance) {
|
|
67
|
+
function removePKCEState(instance, username) {
|
|
66
68
|
try {
|
|
67
|
-
fs.unlinkSync(pkceStatePath(instance));
|
|
69
|
+
fs.unlinkSync(pkceStatePath(instance, username));
|
|
68
70
|
} catch {
|
|
69
71
|
// ignore
|
|
70
72
|
}
|
|
@@ -746,7 +748,7 @@ export class AuthManager {
|
|
|
746
748
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
747
749
|
const clientID = getOAuthClientID();
|
|
748
750
|
const pkce = generatePKCE();
|
|
749
|
-
savePKCEState(instanceURL, pkce);
|
|
751
|
+
savePKCEState(instanceURL, pkce, username);
|
|
750
752
|
const url = buildAuthURL(instanceURL, clientID, pkce);
|
|
751
753
|
if (waitFile) {
|
|
752
754
|
console.log(url);
|
|
@@ -766,7 +768,7 @@ export class AuthManager {
|
|
|
766
768
|
const code = fs.readFileSync(filePath, 'utf-8').trim();
|
|
767
769
|
if (code) {
|
|
768
770
|
console.log(`\nAuthorization code found in ${filePath}`);
|
|
769
|
-
removePKCEState(instanceURL);
|
|
771
|
+
removePKCEState(instanceURL, username);
|
|
770
772
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
771
773
|
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
772
774
|
console.log('Token exchange successful!\n');
|
|
@@ -838,7 +840,7 @@ export class AuthManager {
|
|
|
838
840
|
async loginWithCode(instanceURL, code, username = this._activeUsername()) {
|
|
839
841
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
840
842
|
const clientID = getOAuthClientID();
|
|
841
|
-
const pkce = loadPKCEState(instanceURL);
|
|
843
|
+
const pkce = loadPKCEState(instanceURL, username);
|
|
842
844
|
if (!pkce) {
|
|
843
845
|
throw errAuth(
|
|
844
846
|
`No pending login session for ${instanceURL}.\n\n` +
|
|
@@ -848,7 +850,7 @@ export class AuthManager {
|
|
|
848
850
|
` jsn auth login ${instanceURL} --code CODE`
|
|
849
851
|
);
|
|
850
852
|
}
|
|
851
|
-
removePKCEState(instanceURL);
|
|
853
|
+
removePKCEState(instanceURL, username);
|
|
852
854
|
|
|
853
855
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
854
856
|
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
package/src/cli-adapter.js
CHANGED
|
@@ -10,7 +10,11 @@ class CommandNode {
|
|
|
10
10
|
constructor(definition) {
|
|
11
11
|
this.definition = typeof definition === 'string' ? { command: definition } : definition;
|
|
12
12
|
this.name = commandName(this.definition.command || '');
|
|
13
|
-
|
|
13
|
+
// A command that lists its canonical name as its own alias publishes one
|
|
14
|
+
// token twice: the registry then has two entries for it and dispatch can
|
|
15
|
+
// resolve it through the alias table instead of the command table. An alias
|
|
16
|
+
// is an ALTERNATE name, so a self-alias is dropped at the source.
|
|
17
|
+
this.aliases = names(this.definition.aliases || this.definition.alias).filter((alias) => alias !== this.name);
|
|
14
18
|
this.children = [];
|
|
15
19
|
this.options = new Map();
|
|
16
20
|
this.positionals = new Map();
|
|
@@ -24,11 +28,56 @@ class CommandNode {
|
|
|
24
28
|
if (typeof this.definition.builder === 'function') this.definition.builder(this.api());
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Register a child, replacing any existing registration that claims the same
|
|
33
|
+
* canonical name (or that the new child claims as an alias).
|
|
34
|
+
*
|
|
35
|
+
* yargs semantics are last-registration-wins: a command module re-registers a
|
|
36
|
+
* subcommand to layer a specialized handler over the base one (see
|
|
37
|
+
* `requests show`). Appending instead of replacing left the base registration
|
|
38
|
+
* first in the list and reachable by dispatch/help while the duplicate was
|
|
39
|
+
* invisible to the registry (child counts, inventory, completion).
|
|
40
|
+
*
|
|
41
|
+
* Registration order is preserved (the slot is replaced in place) and the
|
|
42
|
+
* replaced node's aliases are carried over so layered commands keep resolving
|
|
43
|
+
* through the alias that the base registration published.
|
|
44
|
+
*
|
|
45
|
+
* A token must resolve to exactly ONE command. Two registrations can also
|
|
46
|
+
* collide without sharing a canonical name: an alias that another command
|
|
47
|
+
* already published (or already claims as its alias) is the same kind of
|
|
48
|
+
* claim, and registration order decides it — the later registration owns the
|
|
49
|
+
* token and the earlier one stops advertising it. The earlier command keeps
|
|
50
|
+
* its canonical name, so nothing becomes unreachable.
|
|
51
|
+
*/
|
|
52
|
+
register(child) {
|
|
53
|
+
const index = this.children.findIndex((existing) => existing.name === child.name
|
|
54
|
+
|| existing.aliases.includes(child.name)
|
|
55
|
+
|| child.aliases.includes(existing.name));
|
|
56
|
+
if (index === -1) {
|
|
57
|
+
for (const sibling of this.children) {
|
|
58
|
+
sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
|
|
59
|
+
}
|
|
60
|
+
this.children.push(child);
|
|
61
|
+
return child;
|
|
62
|
+
}
|
|
63
|
+
// Carry the replaced node's aliases over — minus any alias that is now the
|
|
64
|
+
// replacement's own canonical name, which would advertise the command as
|
|
65
|
+
// its own alias.
|
|
66
|
+
child.aliases = [...new Set([...child.aliases, ...this.children[index].aliases])]
|
|
67
|
+
.filter((alias) => alias !== child.name);
|
|
68
|
+
for (const sibling of this.children) {
|
|
69
|
+
if (sibling === this.children[index]) continue;
|
|
70
|
+
sibling.aliases = sibling.aliases.filter((alias) => !child.aliases.includes(alias));
|
|
71
|
+
}
|
|
72
|
+
this.children[index] = child;
|
|
73
|
+
return child;
|
|
74
|
+
}
|
|
75
|
+
|
|
27
76
|
api() {
|
|
28
77
|
const node = this;
|
|
29
78
|
return {
|
|
30
79
|
command(definition, builder, handler) {
|
|
31
|
-
node.
|
|
80
|
+
node.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
|
|
32
81
|
return this;
|
|
33
82
|
},
|
|
34
83
|
option(name, config = {}) { node.options.set(name, { ...config, name }); return this; },
|
|
@@ -63,10 +112,16 @@ export class LocalCLI {
|
|
|
63
112
|
option(name, config) { this.root.options.set(name, { ...config, name }); return this; }
|
|
64
113
|
options(config) { for (const [name, value] of Object.entries(config)) this.option(name, value); return this; }
|
|
65
114
|
command(definition, builder, handler) {
|
|
66
|
-
|
|
67
|
-
this.root.children.push(node);
|
|
115
|
+
this.root.register(new CommandNode(typeof definition === 'string' ? { command: definition, builder, handler } : definition));
|
|
68
116
|
return this;
|
|
69
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Register the fallback used when a parent command (a node with children) is
|
|
120
|
+
* invoked with no subcommand and declares no handler of its own. Without it
|
|
121
|
+
* those parents exited 0 having printed nothing, which reads as success to a
|
|
122
|
+
* script and as a hang to a human.
|
|
123
|
+
*/
|
|
124
|
+
onParentDiscovery(fn) { this.parentDiscovery = fn; return this; }
|
|
70
125
|
demandCommand(count, message) { this.root.demand = { count, message }; return this; }
|
|
71
126
|
completion(name, _description, handler) { this.completionCommand = name; this.completionHandler = handler; return this; }
|
|
72
127
|
help() { this.helpEnabled = true; return this; }
|
|
@@ -124,14 +179,22 @@ export class LocalCLI {
|
|
|
124
179
|
return options.get(key) || [...options.values()].find((spec) => spec.name === key || names(spec.alias).includes(key))
|
|
125
180
|
|| { name: key, type: typeof value === 'boolean' ? 'boolean' : 'string' };
|
|
126
181
|
}
|
|
127
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Assign one parsed value to argv.
|
|
184
|
+
*
|
|
185
|
+
* `validate: false` (tolerant mode, used by parseGlobalOptions) skips the
|
|
186
|
+
* choice/number checks so a value that will be thrown away anyway cannot
|
|
187
|
+
* abort a parse whose only purpose is reading the global output flags.
|
|
188
|
+
*/
|
|
189
|
+
_assign(argv, key, value, spec, opts = {}) {
|
|
190
|
+
const validate = opts.validate !== false;
|
|
128
191
|
const actual = spec?.name || key;
|
|
129
192
|
const values = spec?.type === 'array' ? (Array.isArray(value) ? value : [value]) : [value];
|
|
130
|
-
if (spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
193
|
+
if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
131
194
|
this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
|
|
132
195
|
return false;
|
|
133
196
|
}
|
|
134
|
-
if (spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
|
|
197
|
+
if (validate && spec?.type === 'number' && values.some((item) => item === '' || !Number.isFinite(Number(item)))) {
|
|
135
198
|
this._fail(`Invalid number for --${actual}: ${value}`);
|
|
136
199
|
return false;
|
|
137
200
|
}
|
|
@@ -143,7 +206,7 @@ export class LocalCLI {
|
|
|
143
206
|
argv[camel(actual)] = argv[actual];
|
|
144
207
|
for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
|
|
145
208
|
}
|
|
146
|
-
_parseOptions(args, path) {
|
|
209
|
+
_parseOptions(args, path, opts = {}) {
|
|
147
210
|
const options = this._optionConfig(path);
|
|
148
211
|
const defs = {};
|
|
149
212
|
for (const [key, spec] of options) defs[key] = {
|
|
@@ -166,15 +229,92 @@ export class LocalCLI {
|
|
|
166
229
|
const argv = { _: path.map((n) => n.name) };
|
|
167
230
|
for (const [key, value] of Object.entries(parsed.values)) {
|
|
168
231
|
const spec = this._specFor(options, key, value);
|
|
169
|
-
if (this._assign(argv, key, value, spec) === false) return null;
|
|
232
|
+
if (this._assign(argv, key, value, spec, opts) === false) return null;
|
|
170
233
|
}
|
|
171
|
-
for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec);
|
|
234
|
+
for (const [key, spec] of options) if (argv[spec.name] === undefined && spec.default !== undefined) this._assign(argv, key, spec.default, spec, opts);
|
|
172
235
|
return { values: argv, positionals: parsed.positionals };
|
|
173
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve the global options from raw tokens WITHOUT dispatching a command and
|
|
240
|
+
* without failing.
|
|
241
|
+
*
|
|
242
|
+
* The format middleware never runs when parsing fails, so the parse-time
|
|
243
|
+
* failure renderer has to know the format the caller asked for. Rather than
|
|
244
|
+
* re-scanning argv with a second, approximate tokenizer (any `--json`
|
|
245
|
+
* anywhere, first `--format` wins, no option termination, no value
|
|
246
|
+
* consumption, no `--no-` negation), it asks the SAME parser the successful
|
|
247
|
+
* path uses. Tolerant mode keeps a value error (`--format xml`, the very
|
|
248
|
+
* failure being rendered) from aborting the resolution.
|
|
249
|
+
*
|
|
250
|
+
* Returns null when the tokens cannot be parsed at all — the caller then
|
|
251
|
+
* falls back to the pipe/TTY default, exactly as it did before.
|
|
252
|
+
*/
|
|
253
|
+
parseGlobalOptions(args) {
|
|
254
|
+
const saved = this.failHandler;
|
|
255
|
+
this.failHandler = null; // errors here are not the failure being reported
|
|
256
|
+
try {
|
|
257
|
+
const { path, commandArgs } = this._findPath(args);
|
|
258
|
+
const parsed = this._parseOptions(commandArgs, path, { validate: false });
|
|
259
|
+
return parsed ? parsed.values : null;
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
} finally {
|
|
263
|
+
this.failHandler = saved;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
174
266
|
_fail(message, error) {
|
|
175
267
|
if (this.failHandler) return this.failHandler(message, error);
|
|
176
268
|
throw error || new Error(message);
|
|
177
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Describe the registered command surface: root commands (canonical name,
|
|
272
|
+
* aliases, describe, child names) and the global option declarations.
|
|
273
|
+
*
|
|
274
|
+
* This is the single source both the grouped root help and the machine
|
|
275
|
+
* discovery output are generated from, so neither can drift away from the
|
|
276
|
+
* commands that actually exist. Builders run here (idempotently) because a
|
|
277
|
+
* parent's children are only registered when its builder is invoked.
|
|
278
|
+
*/
|
|
279
|
+
commandRegistry() {
|
|
280
|
+
const visible = (node) => node.name !== this.completionCommand;
|
|
281
|
+
const serialize = (node) => {
|
|
282
|
+
node.build();
|
|
283
|
+
return {
|
|
284
|
+
name: node.name,
|
|
285
|
+
command: node.definition.command || node.name,
|
|
286
|
+
aliases: [...node.aliases],
|
|
287
|
+
description: node.definition.describe || '',
|
|
288
|
+
subcommands: node.children.filter(visible).map((child) => child.name),
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
return {
|
|
292
|
+
commands: this.root.children.filter(visible).map(serialize),
|
|
293
|
+
options: [...this.root.options.values()].map((spec) => ({
|
|
294
|
+
name: spec.name,
|
|
295
|
+
alias: names(spec.alias),
|
|
296
|
+
description: spec.description || spec.describe || '',
|
|
297
|
+
choices: spec.choices || [],
|
|
298
|
+
type: spec.type || 'boolean',
|
|
299
|
+
})),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Structured child signatures for a parent node, built on demand. */
|
|
304
|
+
subcommandsOf(node) {
|
|
305
|
+
return node.children
|
|
306
|
+
.filter((child) => child.name !== this.completionCommand)
|
|
307
|
+
.map((child) => {
|
|
308
|
+
child.build();
|
|
309
|
+
return {
|
|
310
|
+
name: child.name,
|
|
311
|
+
command: child.definition.command || child.name,
|
|
312
|
+
aliases: [...child.aliases],
|
|
313
|
+
description: child.definition.describe || '',
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
178
318
|
_help(node, path = []) {
|
|
179
319
|
const lines = [node.usageText || `Usage: ${this.script} ${path.join(' ') || '<command>'}`];
|
|
180
320
|
if (node.definition.describe) lines.push(`\n${node.definition.describe}`);
|
|
@@ -232,6 +372,11 @@ export class LocalCLI {
|
|
|
232
372
|
const parsed = this._parseOptions(commandArgs, path);
|
|
233
373
|
if (!parsed) return;
|
|
234
374
|
const argv = parsed.values;
|
|
375
|
+
// A parent command's own children are registry-derived, so a default
|
|
376
|
+
// handler can list exactly what is registered instead of repeating a
|
|
377
|
+
// hand-written list that drifts (see the records parent, which advertised
|
|
378
|
+
// an `inspect` description the registration disagreed with).
|
|
379
|
+
if (node.children.length) argv.subcommands = this.subcommandsOf(node);
|
|
235
380
|
const specs = [...this._optionConfig(path).values()];
|
|
236
381
|
const positions = positionSpec(node.definition.command || '').map((position) => ({
|
|
237
382
|
...position, ...node.positionals.get(position.name), name: position.name,
|
|
@@ -242,7 +387,13 @@ export class LocalCLI {
|
|
|
242
387
|
for (const middleware of this.middlewares) await middleware(argv);
|
|
243
388
|
for (const commandNode of path) for (const middleware of commandNode.middlewares) await middleware(argv);
|
|
244
389
|
if (node.definition.handler) await node.definition.handler(argv);
|
|
245
|
-
|
|
390
|
+
// A parent with no handler of its own used to fall through and exit 0
|
|
391
|
+
// with no output — indistinguishable from a command that ran and found
|
|
392
|
+
// nothing. The registered discovery hook (cli.js) prints the child
|
|
393
|
+
// surface instead: a JSON envelope in machine mode, a list for humans.
|
|
394
|
+
else if (path.length && argv.subcommands?.length && this.parentDiscovery) {
|
|
395
|
+
await this.parentDiscovery(argv, { path: path.map((n) => n.name), subcommands: argv.subcommands });
|
|
396
|
+
} else if (node.demand?.count && !path.length) return this._fail(node.demand.message);
|
|
246
397
|
}
|
|
247
398
|
}
|
|
248
399
|
|