@jacebenson/jsn 0.0.9 → 0.1.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.
- package/README.md +195 -280
- package/package.json +3 -1
- package/skills/servicenow/SKILL.md +137 -0
- package/src/app.js +1 -1
- package/src/auth.js +139 -2
- package/src/cli.js +18 -6
- package/src/commands/_ticket.js +8 -4
- package/src/commands/auth.js +182 -32
- package/src/commands/dev/_generic.js +129 -12
- package/src/commands/dev/eval.js +27 -6
- package/src/commands/dev/flows.js +46 -1
- package/src/commands/dev/logs.js +15 -2
- package/src/commands/dev/scopes.js +62 -2
- package/src/commands/dev/updatesets.js +45 -2
- package/src/commands/groupmembers.js +114 -1
- package/src/commands/grouproles.js +114 -1
- package/src/commands/groups.js +87 -3
- package/src/commands/profiles.js +14 -2
- package/src/commands/records.js +24 -6
- package/src/commands/requests.js +53 -1
- package/src/commands/skill.js +109 -0
- package/src/commands/tickets.js +1 -1
- package/src/commands/users.js +90 -3
- package/src/commands/version.js +58 -2
- package/src/help.js +85 -0
- package/src/helpers.js +84 -0
- package/src/output.js +30 -0
- package/src/sdk.js +258 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
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.
|
|
10
|
+
license: MIT
|
|
11
|
+
compatibility: |
|
|
12
|
+
Requires jsn CLI (github.com/jacebenson/jsn). Install via:
|
|
13
|
+
curl -fsSL https://jsn.jace.pro/install | bash
|
|
14
|
+
Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
|
|
15
|
+
|
|
16
|
+
Windows users: Download from GitHub releases. If jsn is not in your PATH, the setup
|
|
17
|
+
command will automatically detect its location and show the correct command path
|
|
18
|
+
(e.g., `.\jsn.exe tables list` instead of `jsn tables list`).
|
|
19
|
+
metadata:
|
|
20
|
+
author: jacebenson
|
|
21
|
+
version: "1.0.0"
|
|
22
|
+
repository: https://github.com/jacebenson/jsn
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Jace's ServiceNow CLI
|
|
26
|
+
|
|
27
|
+
Explore and manage ServiceNow instances. Works standalone or with AI agents.
|
|
28
|
+
|
|
29
|
+
## Discovery
|
|
30
|
+
|
|
31
|
+
Commands are self-documenting. Use these to learn what's available:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
jsn --help # Root help with all top-level commands
|
|
35
|
+
jsn <command> --help # Detailed usage, flags, and examples for any command
|
|
36
|
+
jsn dev # Shows categorized dev subcommands
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Agent Rules
|
|
40
|
+
|
|
41
|
+
1. **Output modes** — `--json` when parsing data; `--md` when presenting to humans; `--agent` for automation
|
|
42
|
+
2. **Use sys_id for updates** — All update/delete operations require sys_id
|
|
43
|
+
3. **Check auth first** — Run `jsn auth status` before operations
|
|
44
|
+
4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
|
|
45
|
+
5. **Use `--profile <name>`** to target a specific instance, or `jsn profiles use <name>` to change default
|
|
46
|
+
6. **Before using `eval` or `rest`** — Ask yourself: *"Have I checked if there's a more specific jsn command?"* Verify `jsn records --table <name> create` won't work first. Prefer specific over generic over escape hatch over eval.
|
|
47
|
+
7. **CONFIRM before destructive operations** — Always show the user exactly what will be created, updated, or deleted and ask for explicit confirmation before executing. Never run `create`, `update`, `delete`, `set`, or `remove` commands without user approval.
|
|
48
|
+
|
|
49
|
+
## ⚠️ Destructive Operations Require Confirmation
|
|
50
|
+
|
|
51
|
+
**jsn does not have a `--confirm` or `--dry-run` flag.** It assumes you mean what you type. This means **YOU** (the agent) are the safety layer.
|
|
52
|
+
|
|
53
|
+
**Before any write operation, you MUST:**
|
|
54
|
+
|
|
55
|
+
1. **Show the user what you found** — Run a `list` or `show` command first
|
|
56
|
+
2. **Present the exact command you plan to run** — Include the sys_id, table name, and data
|
|
57
|
+
3. **Wait for explicit approval** — "Proceed? [y/N]" or similar
|
|
58
|
+
|
|
59
|
+
**Examples of destructive commands that require confirmation:**
|
|
60
|
+
- `jsn records --table <name> create`
|
|
61
|
+
- `jsn records --table <name> update <sys_id>`
|
|
62
|
+
- `jsn records --table <name> delete <sys_id>`
|
|
63
|
+
- `jsn dev updatesets set <name>`
|
|
64
|
+
- `jsn profiles remove <name>`
|
|
65
|
+
- Any `delete`, `update`, `create`, `set`, or `remove` subcommand
|
|
66
|
+
|
|
67
|
+
**Good pattern:**
|
|
68
|
+
```
|
|
69
|
+
User: "Delete that incident"
|
|
70
|
+
Agent: "Found: INC0010001 'Server down' (sys_id: abc123...)
|
|
71
|
+
Command: jsn records --table incident delete abc123...
|
|
72
|
+
Proceed? [y/N]"
|
|
73
|
+
User: "y"
|
|
74
|
+
Agent: [runs command]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Bad pattern:**
|
|
78
|
+
```
|
|
79
|
+
User: "Delete that incident"
|
|
80
|
+
Agent: [immediately runs jsn records --table incident delete ...]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## ⚠️ AVOID `jsn eval` for Record Creation
|
|
84
|
+
|
|
85
|
+
**Why:** `jsn eval` runs server-side JavaScript in the global scope. It returns HTTP 200 even when:
|
|
86
|
+
- The insert fails due to ACL violations
|
|
87
|
+
- Mandatory fields are missing
|
|
88
|
+
- The record lands in the wrong scope
|
|
89
|
+
- Logic errors prevent the insert entirely
|
|
90
|
+
|
|
91
|
+
**The AI trap:** Agents see "success" and assume the record was created. It wasn't.
|
|
92
|
+
|
|
93
|
+
**Always use instead:** `jsn records --table <name> create` — it returns the created record with sys_id or an explicit validation error.
|
|
94
|
+
|
|
95
|
+
**Only use `eval` when:** No specific command exists, `records` lacks required fields, and `rest` doesn't work. This is rare.
|
|
96
|
+
|
|
97
|
+
## Command Hierarchy
|
|
98
|
+
|
|
99
|
+
Pick the most specific tool for the job. **Never default to eval** — it's the last resort:
|
|
100
|
+
|
|
101
|
+
1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn jobs`, etc. — curated views with domain-aware formatting and validation
|
|
102
|
+
2. **`jsn records --table <name>`** — generic CRUD on any table (the workhorse, preferred for record creation)
|
|
103
|
+
3. **`jsn rest`** — raw Table API escape hatch when records command lacks required fields
|
|
104
|
+
4. **`jsn eval`** — ⚠️ **LAST RESORT ONLY** — server-side script execution when no other option exists
|
|
105
|
+
5. **Ask the human** — if none of the above work. Never generate standalone GlideRecord scripts as a fallback.
|
|
106
|
+
|
|
107
|
+
## JSON Envelope
|
|
108
|
+
|
|
109
|
+
All commands support `--json`. The envelope structure:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"ok": true,
|
|
114
|
+
"data": [ ... ],
|
|
115
|
+
"summary": "5 records",
|
|
116
|
+
"breadcrumbs": [
|
|
117
|
+
{"action": "show", "cmd": "jsn records --table incident <sys_id>", "description": "View details"}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Breadcrumbs suggest next commands — follow them for navigation.
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
~/.config/servicenow/
|
|
128
|
+
├── config.json # Profiles and settings
|
|
129
|
+
└── credentials.json # Auth tokens (fallback)
|
|
130
|
+
|
|
131
|
+
.servicenow/ # Per-repo config (optional)
|
|
132
|
+
└── config.json # Project-specific settings
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Environment variables:**
|
|
136
|
+
- `SERVICENOW_TOKEN` — Override stored token
|
|
137
|
+
- `SERVICENOW_INSTANCE` — Override instance URL
|
package/src/app.js
CHANGED
|
@@ -109,7 +109,7 @@ export class App {
|
|
|
109
109
|
|
|
110
110
|
const scopeFormatted = `[${scope}]`;
|
|
111
111
|
|
|
112
|
-
process.stderr.write('# Use `jsn
|
|
112
|
+
process.stderr.write('# Use `jsn dev scopes` to change scope, `jsn dev updatesets set` to change updateset\n');
|
|
113
113
|
process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
|
|
114
114
|
|
|
115
115
|
const profileStr = `]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}]8;;\x07`;
|
package/src/auth.js
CHANGED
|
@@ -1,19 +1,59 @@
|
|
|
1
1
|
// OAuth 2.0 with PKCE authentication
|
|
2
|
+
// Credentials are stored in the OS keyring (shared with Go version via libsecret/secret-tool)
|
|
3
|
+
// Falls back to file-based storage when keyring is unavailable.
|
|
2
4
|
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import crypto from 'node:crypto';
|
|
6
8
|
import readline from 'node:readline';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
7
10
|
import { globalConfigDir, normalizeInstanceURL } from './config.js';
|
|
8
11
|
import { errAuth } from './errors.js';
|
|
9
12
|
|
|
13
|
+
// ─── PKCE state persistence (shared with Go version) ───
|
|
14
|
+
|
|
15
|
+
function pkceStatePath(instance) {
|
|
16
|
+
const dir = path.join(globalConfigDir(), 'pkce');
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
|
|
19
|
+
return path.join(dir, filename);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function savePKCEState(instance, pkce) {
|
|
23
|
+
const filePath = pkceStatePath(instance);
|
|
24
|
+
fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadPKCEState(instance) {
|
|
28
|
+
try {
|
|
29
|
+
const data = fs.readFileSync(pkceStatePath(instance), 'utf-8');
|
|
30
|
+
return JSON.parse(data);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removePKCEState(instance) {
|
|
37
|
+
try {
|
|
38
|
+
fs.unlinkSync(pkceStatePath(instance));
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
10
44
|
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
11
45
|
const REDIRECT_URI = '/sdk-oauth.do';
|
|
12
46
|
|
|
47
|
+
// Keychain constants — same as Go version (internal/auth/store.go)
|
|
48
|
+
const KEYRING_SERVICE = 'servicenow-cli';
|
|
49
|
+
const KEYRING_ATTR_SERVICE = 'service';
|
|
50
|
+
const KEYRING_ATTR_USERNAME = 'username';
|
|
51
|
+
|
|
13
52
|
function credentialsPath(instance) {
|
|
14
53
|
const dir = path.join(globalConfigDir(), 'credentials');
|
|
15
54
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
|
-
|
|
55
|
+
// Match Go's filename encoding: replace :// and / and : with _
|
|
56
|
+
const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
|
|
17
57
|
return path.join(dir, filename);
|
|
18
58
|
}
|
|
19
59
|
|
|
@@ -37,10 +77,66 @@ function buildAuthURL(instanceURL, clientID, pkce) {
|
|
|
37
77
|
u.searchParams.set('code_challenge', pkce.code_challenge);
|
|
38
78
|
u.searchParams.set('code_challenge_method', 'S256');
|
|
39
79
|
u.searchParams.set('scope', 'openid');
|
|
80
|
+
u.searchParams.set('approval_prompt', 'force');
|
|
40
81
|
return u.toString();
|
|
41
82
|
}
|
|
42
83
|
|
|
84
|
+
// ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
|
|
85
|
+
|
|
86
|
+
function keyringLookup(instance) {
|
|
87
|
+
try {
|
|
88
|
+
const result = execSync(
|
|
89
|
+
`secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
90
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
91
|
+
);
|
|
92
|
+
const trimmed = result.trim();
|
|
93
|
+
if (!trimmed) return null;
|
|
94
|
+
const parsed = JSON.parse(trimmed);
|
|
95
|
+
// Normalize field names from Go's format to Node.js format
|
|
96
|
+
return {
|
|
97
|
+
auth_method: parsed.auth_method || 'oauth',
|
|
98
|
+
access_token: parsed.access_token || parsed.AccessToken || '',
|
|
99
|
+
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
100
|
+
expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
|
|
101
|
+
created_at: parsed.created_at || parsed.CreatedAt || 0,
|
|
102
|
+
};
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function keyringStore(instance, creds) {
|
|
109
|
+
try {
|
|
110
|
+
execSync(
|
|
111
|
+
`secret-tool store --label="Password for '${instance}' on '${KEYRING_SERVICE}'" ` +
|
|
112
|
+
`${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
113
|
+
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
114
|
+
);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function keyringDelete(instance) {
|
|
122
|
+
try {
|
|
123
|
+
execSync(
|
|
124
|
+
`secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
125
|
+
{ stdio: 'ignore' }
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── File-based storage (fallback) ───
|
|
133
|
+
|
|
43
134
|
function loadCredentials(instance) {
|
|
135
|
+
// Try keyring first (shared with Go version)
|
|
136
|
+
const keyringCreds = keyringLookup(instance);
|
|
137
|
+
if (keyringCreds) return keyringCreds;
|
|
138
|
+
|
|
139
|
+
// Fall back to file-based storage
|
|
44
140
|
try {
|
|
45
141
|
const data = fs.readFileSync(credentialsPath(instance), 'utf-8');
|
|
46
142
|
return JSON.parse(data);
|
|
@@ -50,10 +146,14 @@ function loadCredentials(instance) {
|
|
|
50
146
|
}
|
|
51
147
|
|
|
52
148
|
function saveCredentials(instance, creds) {
|
|
53
|
-
|
|
149
|
+
// Try keyring first, fall back to file
|
|
150
|
+
if (!keyringStore(instance, creds)) {
|
|
151
|
+
fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
152
|
+
}
|
|
54
153
|
}
|
|
55
154
|
|
|
56
155
|
function deleteCredentials(instance) {
|
|
156
|
+
keyringDelete(instance);
|
|
57
157
|
try {
|
|
58
158
|
fs.unlinkSync(credentialsPath(instance));
|
|
59
159
|
} catch {
|
|
@@ -210,6 +310,43 @@ export class AuthManager {
|
|
|
210
310
|
return newCreds;
|
|
211
311
|
}
|
|
212
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Build an OAuth authorization URL and persist PKCE state for later use.
|
|
315
|
+
* After calling this, the user can visit the URL in a browser and then
|
|
316
|
+
* call loginWithCode() with the resulting authorization code.
|
|
317
|
+
*/
|
|
318
|
+
buildAuthURL(instanceURL) {
|
|
319
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
320
|
+
const clientID = getOAuthClientID();
|
|
321
|
+
const pkce = generatePKCE();
|
|
322
|
+
savePKCEState(instanceURL, pkce);
|
|
323
|
+
return buildAuthURL(instanceURL, clientID, pkce);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Complete login using an authorization code obtained from a prior buildAuthURL() call.
|
|
328
|
+
* The PKCE state must have been saved by an earlier buildAuthURL() call.
|
|
329
|
+
*/
|
|
330
|
+
async loginWithCode(instanceURL, code) {
|
|
331
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
332
|
+
const clientID = getOAuthClientID();
|
|
333
|
+
const pkce = loadPKCEState(instanceURL);
|
|
334
|
+
if (!pkce) {
|
|
335
|
+
throw errAuth(
|
|
336
|
+
`No pending login session for ${instanceURL}.\n\n` +
|
|
337
|
+
'Run without --code first to generate one:\n' +
|
|
338
|
+
` jsn auth login ${instanceURL} --print-url\n\n` +
|
|
339
|
+
'This generates the PKCE challenge and saves it. Then call:\n' +
|
|
340
|
+
` jsn auth login ${instanceURL} --code CODE`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
removePKCEState(instanceURL);
|
|
344
|
+
|
|
345
|
+
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
346
|
+
saveCredentials(instanceURL, newCreds);
|
|
347
|
+
return newCreds;
|
|
348
|
+
}
|
|
349
|
+
|
|
213
350
|
async exchangeCode(instanceURL, clientID, code, pkce) {
|
|
214
351
|
const tokenURL = `${instanceURL.replace(/\/$/, '')}/oauth_token.do`;
|
|
215
352
|
const body = new URLSearchParams();
|
package/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import { loadConfig, getEffectiveInstance } from './config.js';
|
|
7
7
|
import { App } from './app.js';
|
|
8
|
+
import { renderHelp } from './help.js';
|
|
8
9
|
|
|
9
10
|
// Command modules
|
|
10
11
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -22,6 +23,7 @@ import { groupRolesCmd } from './commands/grouproles.js';
|
|
|
22
23
|
import { ticketsCmd } from './commands/tickets.js';
|
|
23
24
|
import { versionCmd } from './commands/version.js';
|
|
24
25
|
import { devCmd } from './commands/dev.js';
|
|
26
|
+
import { skillCmd } from './commands/skill.js';
|
|
25
27
|
|
|
26
28
|
function wrap(handler) {
|
|
27
29
|
return async (argv) => {
|
|
@@ -84,7 +86,7 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
84
86
|
type: 'boolean',
|
|
85
87
|
global: true,
|
|
86
88
|
})
|
|
87
|
-
.middleware((argv) => {
|
|
89
|
+
.middleware(async (argv) => {
|
|
88
90
|
// Determine format from flags
|
|
89
91
|
let format = 'auto';
|
|
90
92
|
if (argv.json) format = 'json';
|
|
@@ -103,7 +105,7 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
103
105
|
|
|
104
106
|
// Check auth for non-auth commands
|
|
105
107
|
const cmd = argv._[0];
|
|
106
|
-
const skipAuth = ['help', 'version', 'setup', 'auth', undefined].includes(cmd);
|
|
108
|
+
const skipAuth = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
|
|
107
109
|
if (!skipAuth) {
|
|
108
110
|
const instance = getEffectiveInstance(cfg);
|
|
109
111
|
if (!argv.app.auth.isAuthenticated() && instance) {
|
|
@@ -114,9 +116,9 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
// Print context header for interactive terminals
|
|
118
|
-
if (!['help', 'version', 'completion'].includes(cmd)) {
|
|
119
|
-
argv.app.printContextHeader();
|
|
119
|
+
// Print context header for interactive terminals (at the TOP, before command output)
|
|
120
|
+
if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
|
|
121
|
+
await argv.app.printContextHeader();
|
|
120
122
|
}
|
|
121
123
|
})
|
|
122
124
|
.command(setupCmd(wrap))
|
|
@@ -133,10 +135,20 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
133
135
|
.command(groupRolesCmd(wrap))
|
|
134
136
|
.command(ticketsCmd(wrap))
|
|
135
137
|
.command(devCmd(wrap))
|
|
138
|
+
.command(skillCmd(wrap))
|
|
136
139
|
.command(versionCmd(wrap))
|
|
137
140
|
.demandCommand(1, 'You must specify a command')
|
|
138
141
|
.help('help', 'Show help')
|
|
139
142
|
.version(false)
|
|
140
143
|
.strictCommands()
|
|
141
144
|
.strictOptions(false)
|
|
142
|
-
.
|
|
145
|
+
.fail((msg, err) => {
|
|
146
|
+
if (err) throw err;
|
|
147
|
+
// No command given → show custom grouped help instead of yargs error
|
|
148
|
+
if (msg === 'You must specify a command') {
|
|
149
|
+
process.stdout.write(renderHelp());
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
|
152
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
});
|
package/src/commands/_ticket.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
3
|
|
|
4
4
|
import { getStringField, formatRecordForDisplay, buildQuerySuffix } from '../helpers.js';
|
|
5
|
-
import
|
|
5
|
+
import search from '@inquirer/search';
|
|
6
6
|
import { isTTY, FormatAuto } from '../output.js';
|
|
7
7
|
|
|
8
8
|
export function buildTicketCommands(table, displayName, alias, defaultColumns, stateMap, iconFn, wrap) {
|
|
9
9
|
return {
|
|
10
10
|
command: `${displayName} [subcommand]`,
|
|
11
11
|
aliases: [table, alias],
|
|
12
|
-
describe: `Manage ${displayName}`,
|
|
12
|
+
describe: `Manage ${displayName} (e.g. "${displayName} list --query priority=1")`,
|
|
13
13
|
builder: (yargs) => {
|
|
14
14
|
return yargs
|
|
15
15
|
.command({
|
|
@@ -70,9 +70,13 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
70
70
|
|
|
71
71
|
let selectedNumber;
|
|
72
72
|
try {
|
|
73
|
-
selectedNumber = await
|
|
73
|
+
selectedNumber = await search({
|
|
74
74
|
message: `Select a ${displayName.slice(0, -1)}:`,
|
|
75
|
-
|
|
75
|
+
source: async (input) => {
|
|
76
|
+
if (!input) return choices;
|
|
77
|
+
const term = input.toLowerCase();
|
|
78
|
+
return choices.filter(c => c.name.toLowerCase().includes(term));
|
|
79
|
+
},
|
|
76
80
|
});
|
|
77
81
|
} catch (err) {
|
|
78
82
|
if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
|
package/src/commands/auth.js
CHANGED
|
@@ -1,61 +1,211 @@
|
|
|
1
1
|
import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
|
|
2
|
+
import { errUsage } from '../errors.js';
|
|
3
|
+
import process from 'node:process';
|
|
2
4
|
|
|
3
5
|
export function authCmd(wrap) {
|
|
4
6
|
return {
|
|
5
7
|
command: 'auth <subcommand>',
|
|
6
|
-
describe: 'Manage authentication',
|
|
8
|
+
describe: 'Manage OAuth authentication',
|
|
7
9
|
builder: (yargs) => {
|
|
8
10
|
return yargs
|
|
9
11
|
.command({
|
|
10
12
|
command: 'login [instance]',
|
|
11
|
-
describe: 'Authenticate with a ServiceNow instance',
|
|
13
|
+
describe: 'Authenticate with a ServiceNow instance via OAuth',
|
|
14
|
+
builder: (sub) => sub
|
|
15
|
+
.option('code', {
|
|
16
|
+
describe: 'Authorization code from browser (bypasses interactive prompt)',
|
|
17
|
+
type: 'string',
|
|
18
|
+
})
|
|
19
|
+
.option('print-url', {
|
|
20
|
+
describe: 'Print the OAuth URL and exit (saves PKCE state for --code)',
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
}),
|
|
12
23
|
handler: wrap(async (argv, app) => {
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
24
|
+
let instanceURL;
|
|
25
|
+
if (argv.instance) {
|
|
26
|
+
// Check if it's a profile name first
|
|
27
|
+
const profiles = app.config.profiles || {};
|
|
28
|
+
if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
|
|
29
|
+
instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
|
|
30
|
+
} else {
|
|
31
|
+
instanceURL = normalizeInstanceURL(argv.instance);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
instanceURL = getEffectiveInstance(app.config);
|
|
35
|
+
if (!instanceURL) {
|
|
36
|
+
// Try interactive profile picker
|
|
37
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
38
|
+
if (profileNames.length > 0 && app.isInteractive()) {
|
|
39
|
+
const { search } = await import('@inquirer/prompts');
|
|
40
|
+
const choices = profileNames.map(name => ({
|
|
41
|
+
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
42
|
+
value: name,
|
|
43
|
+
}));
|
|
44
|
+
const selected = await search({
|
|
45
|
+
message: 'Select a profile:',
|
|
46
|
+
source: (input) => {
|
|
47
|
+
const filter = (input || '').toLowerCase();
|
|
48
|
+
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
|
|
52
|
+
} else {
|
|
53
|
+
throw errUsage(`Instance URL required.
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
jsn auth login https://dev12345.service-now.com
|
|
57
|
+
jsn auth login dev373698
|
|
58
|
+
jsn auth login https://acme.service-now.com
|
|
59
|
+
|
|
60
|
+
Find your instance URL in your browser's address bar when logged into ServiceNow.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --print-url: just print the URL and exit
|
|
66
|
+
if (argv.printUrl) {
|
|
67
|
+
const authURL = app.auth.buildAuthURL(instanceURL);
|
|
68
|
+
console.log(authURL);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --code: exchange the provided authorization code directly
|
|
73
|
+
if (argv.code) {
|
|
74
|
+
await app.auth.loginWithCode(instanceURL, argv.code);
|
|
75
|
+
} else {
|
|
76
|
+
// Interactive: full OAuth flow with browser + prompt
|
|
77
|
+
await app.auth.login(instanceURL);
|
|
16
78
|
}
|
|
17
|
-
|
|
18
|
-
|
|
79
|
+
|
|
80
|
+
// Verify auth by fetching current user
|
|
81
|
+
let username = '';
|
|
82
|
+
try {
|
|
83
|
+
if (!app.sdk) {
|
|
84
|
+
const { SDKClient } = await import('../sdk.js');
|
|
85
|
+
app.sdk = new SDKClient(instanceURL, app.auth);
|
|
86
|
+
}
|
|
87
|
+
const user = await app.sdk.getCurrentUser();
|
|
88
|
+
username = user?.user_name || user?.name || '';
|
|
89
|
+
} catch {
|
|
90
|
+
// Non-fatal — token is saved, just couldn't verify
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Save to profiles
|
|
94
|
+
const profileName = instanceURL.replace(/https?:\/\//, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
95
|
+
if (!app.config.profiles) {
|
|
96
|
+
app.config.profiles = {};
|
|
97
|
+
}
|
|
98
|
+
app.config.profiles[profileName] = {
|
|
99
|
+
instance_url: instanceURL,
|
|
100
|
+
auth_method: 'oauth',
|
|
101
|
+
username: username || undefined,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Set as default if this is the first one
|
|
105
|
+
const setDefault = !app.config.instanceURL && !app.config.defaultProfile;
|
|
106
|
+
if (setDefault) {
|
|
107
|
+
app.config.instanceURL = instanceURL;
|
|
108
|
+
app.config.defaultProfile = profileName;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { saveConfig } = await import('../config.js');
|
|
112
|
+
saveConfig(app.config);
|
|
113
|
+
|
|
114
|
+
const result = {
|
|
115
|
+
authenticated: true,
|
|
116
|
+
instance: instanceURL,
|
|
117
|
+
username: username || undefined,
|
|
118
|
+
default: setDefault || undefined,
|
|
119
|
+
};
|
|
120
|
+
const summary = username
|
|
121
|
+
? `✓ Authenticated to ${instanceURL} as ${username}`
|
|
122
|
+
: `✓ Authenticated to ${instanceURL}`;
|
|
123
|
+
app.ok(result, { summary });
|
|
19
124
|
}),
|
|
20
125
|
})
|
|
21
126
|
.command({
|
|
22
|
-
command: 'logout',
|
|
23
|
-
describe: 'Remove stored credentials',
|
|
24
|
-
handler: wrap(async (
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
127
|
+
command: 'logout [instance]',
|
|
128
|
+
describe: 'Remove stored OAuth credentials',
|
|
129
|
+
handler: wrap(async (argv, app) => {
|
|
130
|
+
let instanceURL;
|
|
131
|
+
if (argv.instance) {
|
|
132
|
+
instanceURL = normalizeInstanceURL(argv.instance);
|
|
133
|
+
} else {
|
|
134
|
+
instanceURL = getEffectiveInstance(app.config);
|
|
135
|
+
if (!instanceURL) {
|
|
136
|
+
throw errUsage(`No instance specified.
|
|
137
|
+
|
|
138
|
+
Examples:
|
|
139
|
+
jsn auth logout
|
|
140
|
+
jsn auth logout https://dev12345.service-now.com`);
|
|
141
|
+
}
|
|
28
142
|
}
|
|
29
|
-
app.auth.logout(
|
|
30
|
-
app.ok({ logged_out: true, instance }, { summary:
|
|
143
|
+
app.auth.logout(instanceURL);
|
|
144
|
+
app.ok({ logged_out: true, instance: instanceURL }, { summary: `✓ Logged out from ${instanceURL}` });
|
|
31
145
|
}),
|
|
32
146
|
})
|
|
33
147
|
.command({
|
|
34
148
|
command: 'status',
|
|
35
|
-
describe: 'Show authentication status',
|
|
149
|
+
describe: 'Show detailed authentication status',
|
|
36
150
|
handler: wrap(async (_argv, app) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
151
|
+
const defaultInstance = getEffectiveInstance(app.config);
|
|
152
|
+
|
|
153
|
+
// Check environment auth
|
|
154
|
+
const envToken = process.env.SERVICENOW_OAUTH_TOKEN || '';
|
|
155
|
+
|
|
156
|
+
const profiles = [];
|
|
157
|
+
for (const [name, profile] of Object.entries(app.config.profiles || {})) {
|
|
158
|
+
const isAuth = app.auth.isAuthenticatedFor(profile.instance_url);
|
|
159
|
+
profiles.push({
|
|
160
|
+
name,
|
|
161
|
+
instance: profile.instance_url,
|
|
162
|
+
authenticated: isAuth,
|
|
163
|
+
default: profile.instance_url === defaultInstance,
|
|
164
|
+
});
|
|
41
165
|
}
|
|
42
|
-
|
|
43
|
-
app.ok({
|
|
166
|
+
|
|
167
|
+
app.ok({
|
|
168
|
+
default_instance: defaultInstance,
|
|
169
|
+
authenticated: app.auth.isAuthenticated(),
|
|
170
|
+
environment_auth: envToken ? true : undefined,
|
|
171
|
+
profiles,
|
|
172
|
+
}, { summary: `${profiles.length} profile(s)` });
|
|
44
173
|
}),
|
|
45
174
|
})
|
|
46
175
|
.command({
|
|
47
|
-
command: 'refresh',
|
|
48
|
-
describe: 'Refresh OAuth token',
|
|
49
|
-
handler: wrap(async (
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
176
|
+
command: 'refresh [instance]',
|
|
177
|
+
describe: 'Refresh OAuth token for an instance',
|
|
178
|
+
handler: wrap(async (argv, app) => {
|
|
179
|
+
let instanceURL;
|
|
180
|
+
if (argv.instance) {
|
|
181
|
+
// Check if it's a profile name or URL
|
|
182
|
+
const profiles = app.config.profiles || {};
|
|
183
|
+
if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
|
|
184
|
+
instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
|
|
185
|
+
} else {
|
|
186
|
+
instanceURL = normalizeInstanceURL(argv.instance);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
instanceURL = getEffectiveInstance(app.config);
|
|
190
|
+
if (!instanceURL) {
|
|
191
|
+
throw errUsage(`No instance specified and no default configured.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
jsn auth refresh
|
|
195
|
+
jsn auth refresh https://dev12345.service-now.com
|
|
196
|
+
jsn auth refresh dev12345`);
|
|
197
|
+
}
|
|
53
198
|
}
|
|
54
|
-
await app.auth.refreshToken(instance, await app.auth.getCredentialsFor(instance));
|
|
55
|
-
app.ok({ refreshed: true, instance }, { summary: `Token refreshed for ${instance}` });
|
|
56
|
-
}),
|
|
57
|
-
})
|
|
58
199
|
|
|
200
|
+
const creds = await app.auth.getCredentialsFor(instanceURL);
|
|
201
|
+
const refreshed = await app.auth.refreshToken(instanceURL, creds);
|
|
202
|
+
app.ok({
|
|
203
|
+
refreshed: true,
|
|
204
|
+
instance: instanceURL,
|
|
205
|
+
expires_at: refreshed.expires_at,
|
|
206
|
+
}, { summary: `✓ Token refreshed for ${instanceURL}` });
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
59
209
|
},
|
|
60
210
|
handler: () => {},
|
|
61
211
|
};
|