@jacebenson/jsn 3.4.3 → 4.0.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 +5 -4
- package/package.json +2 -1
- package/skills/servicenow/SKILL.md +1 -1
- package/src/app.js +7 -7
- package/src/auth.js +4 -53
- package/src/cli.js +23 -10
- package/src/commands/auth.js +293 -40
- package/src/commands/dev/updatesets.js +0 -16
- package/src/commands/docs/db.js +2 -2
- package/src/commands/docs/search.js +2 -2
- package/src/commands/docs/serve.js +2 -2
- package/src/commands/setup.js +10 -84
- package/src/commands/skill.js +4 -1
- package/src/config.js +9 -0
- package/src/help.js +2 -4
- package/src/mutations.js +34 -9
- package/src/commands/profiles.js +0 -190
package/README.md
CHANGED
|
@@ -64,17 +64,18 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
|
|
|
64
64
|
|
|
65
65
|
Every command supports `--json`, `--query`, and `--help`.
|
|
66
66
|
|
|
67
|
-
## Profiles
|
|
67
|
+
## Profiles (instances)
|
|
68
68
|
|
|
69
69
|
Switch between instances without re-authenticating.
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
jsn auth login https://dev12345.service-now.com
|
|
73
|
-
jsn
|
|
74
|
-
jsn
|
|
75
|
-
jsn profiles show
|
|
73
|
+
jsn auth status
|
|
74
|
+
jsn auth switch dev12345
|
|
76
75
|
```
|
|
77
76
|
|
|
77
|
+
Add another instance with `jsn auth login <name-or-url>` — run bare for an interactive picker.
|
|
78
|
+
|
|
78
79
|
## Output Formats
|
|
79
80
|
|
|
80
81
|
| Flag | When to use |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@inquirer/core": "^10.1.0",
|
|
30
30
|
"@inquirer/prompts": "^7.5.1",
|
|
31
|
+
"better-sqlite3": "^13.0.3",
|
|
31
32
|
"chalk": "^5.4.1",
|
|
32
33
|
"cli-table3": "^0.6.5",
|
|
33
34
|
"gray-matter": "^4.0.3",
|
|
@@ -44,7 +44,7 @@ All `list` subcommands use a paginated interactive picker in TTY mode:
|
|
|
44
44
|
2. **Use sys_id for updates** — All update/delete operations require sys_id
|
|
45
45
|
3. **Check auth first** — Run `jsn auth status` before operations
|
|
46
46
|
4. **NEVER logout** — Only run `jsn auth logout` if the user explicitly asks
|
|
47
|
-
5. **Use `--profile <name>`** to target a specific instance, or `jsn
|
|
47
|
+
5. **Use `--profile <name>`** to target a specific instance, or `jsn auth switch <name>` to change the default
|
|
48
48
|
6. **Before using `eval` or `rest`** — Ask: *"Is there a more specific jsn command?"* Prefer specific over generic over escape hatch over eval.
|
|
49
49
|
7. **CONFIRM before destructive operations** — Show what will be created/updated/deleted and ask for approval.
|
|
50
50
|
|
package/src/app.js
CHANGED
|
@@ -120,10 +120,15 @@ export class App {
|
|
|
120
120
|
|
|
121
121
|
const scopeFormatted = `[${scope}]`;
|
|
122
122
|
|
|
123
|
+
// Read-only profiles get a lock badge on every command run, so the
|
|
124
|
+
// armed safety is always visible (not just in `auth status`).
|
|
125
|
+
const activeProfile = this.context.profileName ? this.config.profiles[this.context.profileName] : null;
|
|
126
|
+
const roBadge = activeProfile?.read_only === true ? ' 🔒' : '';
|
|
127
|
+
|
|
123
128
|
process.stderr.write('# Use `jsn scopes` to change scope, `jsn updatesets set` to change updateset\n');
|
|
124
129
|
process.stderr.write('PROFILE USER [SCOPE] UPDATE SET\n');
|
|
125
130
|
|
|
126
|
-
const profileStr =
|
|
131
|
+
const profileStr = `\u001b]8;;${instanceLink}\x07${String(this.context.profileName).padEnd(9)}${roBadge}\u001b]8;;\x07`;
|
|
127
132
|
const userStr = `]8;;${userLink}\x07${String(displayUserName).padEnd(9)}]8;;\x07`;
|
|
128
133
|
const scopeStr = `]8;;${scopeLink}\x07${String(scopeFormatted).padEnd(17)}]8;;\x07`;
|
|
129
134
|
const updateSetStr = `]8;;${updateSetLink}\x07${updateSet}]8;;\x07`;
|
|
@@ -131,9 +136,7 @@ export class App {
|
|
|
131
136
|
process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
|
|
132
137
|
|
|
133
138
|
// ⚠️ Warning if in the Default update set (mutation commands only)
|
|
134
|
-
|
|
135
|
-
const isYolo = profile?.yolo === true;
|
|
136
|
-
if (!isYolo && isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
|
|
139
|
+
if (isMutationCommand(argv) && updateSet && updateSet.toLowerCase().includes('default')) {
|
|
137
140
|
const isGlobal = scope === 'global';
|
|
138
141
|
if (isGlobal) {
|
|
139
142
|
// 🔴 HARD WARNING — Global + Default
|
|
@@ -150,8 +153,6 @@ export class App {
|
|
|
150
153
|
'┃ ┃\n' +
|
|
151
154
|
'┃ Or switch to a scoped scope first: ┃\n' +
|
|
152
155
|
'┃ jsn scopes list ┃\n' +
|
|
153
|
-
'┃ ┃\n' +
|
|
154
|
-
'┃ (Run jsn updatesets yolo to silence this check) ┃\n' +
|
|
155
156
|
'┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\x1b[0m\n'
|
|
156
157
|
);
|
|
157
158
|
} else {
|
|
@@ -162,7 +163,6 @@ export class App {
|
|
|
162
163
|
' ⚠ Default update set in scope [' + scope + ']\n' +
|
|
163
164
|
' Changes are contained to this scope, but a named\n' +
|
|
164
165
|
' update set is still recommended for tracking.\n' +
|
|
165
|
-
' (Run \x1b[1mjsn updatesets yolo\x1b[22m to silence this warning)\n' +
|
|
166
166
|
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
|
|
167
167
|
'\x1b[0m' // reset
|
|
168
168
|
);
|
package/src/auth.js
CHANGED
|
@@ -58,24 +58,6 @@ function removePKCEState(instance) {
|
|
|
58
58
|
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
59
59
|
const REDIRECT_URI = '/sdk-oauth.do';
|
|
60
60
|
|
|
61
|
-
// ─── WSL detection ───
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Detect if running under Windows Subsystem for Linux.
|
|
65
|
-
* Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
|
|
66
|
-
*/
|
|
67
|
-
let _cachedIsWSL = null;
|
|
68
|
-
async function isWSLEnv() {
|
|
69
|
-
if (_cachedIsWSL !== null) return _cachedIsWSL;
|
|
70
|
-
try {
|
|
71
|
-
const data = fs.readFileSync('/proc/version', 'utf-8');
|
|
72
|
-
_cachedIsWSL = /microsoft|wsl/i.test(data);
|
|
73
|
-
} catch {
|
|
74
|
-
_cachedIsWSL = false;
|
|
75
|
-
}
|
|
76
|
-
return _cachedIsWSL;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
61
|
// ─── Basic auth from environment variables ───
|
|
80
62
|
// SN_USERNAME / SN_PASSWORD — global credentials
|
|
81
63
|
// SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
|
|
@@ -390,43 +372,12 @@ export class AuthManager {
|
|
|
390
372
|
const authURL = buildAuthURL(instanceURL, clientID, pkce);
|
|
391
373
|
|
|
392
374
|
console.log();
|
|
393
|
-
console.log('
|
|
394
|
-
console.log('If the browser does not open automatically, visit:');
|
|
375
|
+
console.log('Open this URL in your browser to authenticate:');
|
|
395
376
|
console.log(authURL);
|
|
396
377
|
console.log();
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const platform = process.platform;
|
|
401
|
-
let cmd, args;
|
|
402
|
-
if (platform === 'darwin') {
|
|
403
|
-
cmd = 'open';
|
|
404
|
-
args = [authURL];
|
|
405
|
-
} else if (platform === 'win32') {
|
|
406
|
-
cmd = 'cmd';
|
|
407
|
-
// Windows: cmd.exe interprets & as a command separator, which truncates
|
|
408
|
-
// the URL after the first parameter. Passing an empty string as the first
|
|
409
|
-
// arg to `start` serves as the window title, and spawn auto-quotes the
|
|
410
|
-
// URL arg, protecting & and other special chars from cmd.exe's parser.
|
|
411
|
-
args = ['/c', 'start', '', authURL];
|
|
412
|
-
} else {
|
|
413
|
-
// WSL detection: check if we're running under Windows Subsystem for Linux
|
|
414
|
-
// On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
|
|
415
|
-
// on &. We call cmd.exe directly with proper quoting instead.
|
|
416
|
-
const isWSL = platform === 'linux' && (await isWSLEnv());
|
|
417
|
-
if (isWSL) {
|
|
418
|
-
cmd = 'cmd.exe';
|
|
419
|
-
args = ['/c', 'start', '', authURL];
|
|
420
|
-
} else {
|
|
421
|
-
cmd = 'xdg-open';
|
|
422
|
-
args = [authURL];
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
const child = open(cmd, args, { detached: true, stdio: 'ignore' });
|
|
426
|
-
child.on('error', () => {
|
|
427
|
-
// Browser open command not available — user will open the URL manually
|
|
428
|
-
});
|
|
429
|
-
child.unref();
|
|
378
|
+
console.log('(The browser is not opened automatically — if a browser tab opens,');
|
|
379
|
+
console.log(' use the URL above if the opened page shows an error.)');
|
|
380
|
+
console.log();
|
|
430
381
|
|
|
431
382
|
console.log('After authenticating in the browser, copy the authorization code shown on the page.');
|
|
432
383
|
console.log('(input is hidden for security — just paste and press Enter)');
|
package/src/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { hideBin } from 'yargs/helpers';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
|
|
8
|
+
import { loadConfig, getEffectiveInstance, getActiveProfile, globalConfigDir } from './config.js';
|
|
9
9
|
import { App } from './app.js';
|
|
10
10
|
import { renderHelp } from './help.js';
|
|
11
11
|
import { isMutationCommand } from './mutations.js';
|
|
@@ -13,7 +13,6 @@ import { isMutationCommand } from './mutations.js';
|
|
|
13
13
|
// Command modules
|
|
14
14
|
import { setupCmd } from './commands/setup.js';
|
|
15
15
|
import { authCmd } from './commands/auth.js';
|
|
16
|
-
import { profilesCmd } from './commands/profiles.js';
|
|
17
16
|
import { recordsCmd } from './commands/records.js';
|
|
18
17
|
import { catalogCmd } from './commands/catalog.js';
|
|
19
18
|
import { incidentsCmd } from './commands/incidents.js';
|
|
@@ -176,10 +175,10 @@ export function buildCLI() {
|
|
|
176
175
|
argv._overrideInstance = argv.instance;
|
|
177
176
|
}
|
|
178
177
|
},
|
|
179
|
-
// Guard mutation commands
|
|
178
|
+
// Guard mutation commands: require an instance, block read-only profiles
|
|
180
179
|
(argv) => {
|
|
181
180
|
const cmd = (argv._[0] || '').toString();
|
|
182
|
-
if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', '
|
|
181
|
+
if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'skill', 'docs'].includes(cmd)) {
|
|
183
182
|
return;
|
|
184
183
|
}
|
|
185
184
|
// Check for --instance override
|
|
@@ -187,11 +186,26 @@ export function buildCLI() {
|
|
|
187
186
|
app._overrideInstance = argv.instance;
|
|
188
187
|
argv._overrideInstance = argv.instance;
|
|
189
188
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
if (isMutationCommand(argv)) {
|
|
190
|
+
try {
|
|
191
|
+
app.requireInstance();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// requireInstance throws errUsage; format it cleanly (the yargs
|
|
194
|
+
// fail handler re-throws middleware errors, which would print a
|
|
195
|
+
// raw stack trace).
|
|
196
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const profile = getActiveProfile(cfg);
|
|
200
|
+
if (profile?.read_only === true) {
|
|
201
|
+
const name = cfg.activeProfile || cfg.defaultProfile;
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`Error: Profile "${name}" is read-only. Mutations are blocked.\n\n` +
|
|
204
|
+
'Switch to a write-enabled profile first:\n' +
|
|
205
|
+
` jsn auth switch <name>\n`
|
|
206
|
+
);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
195
209
|
}
|
|
196
210
|
},
|
|
197
211
|
])
|
|
@@ -277,7 +291,6 @@ export function buildCLI() {
|
|
|
277
291
|
// CONFIGURATION
|
|
278
292
|
.command(setupCmd(wrap))
|
|
279
293
|
.command(authCmd(wrap))
|
|
280
|
-
.command(profilesCmd(wrap))
|
|
281
294
|
.command(updateSetsCmd(wrap))
|
|
282
295
|
.command(scopesCmd(wrap))
|
|
283
296
|
// CORE
|
package/src/commands/auth.js
CHANGED
|
@@ -1,16 +1,176 @@
|
|
|
1
|
-
import { getEffectiveInstance, normalizeInstanceURL } from '../config.js';
|
|
1
|
+
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setActiveProfile, setProfile } from '../config.js';
|
|
2
2
|
import { errUsage } from '../errors.js';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a login/logout/refresh argument to a concrete instance URL.
|
|
7
|
+
*
|
|
8
|
+
* gh-style precision: a full URL is used as-is; a bare name is first checked
|
|
9
|
+
* against known profile names (gh: --hostname), then treated as a ServiceNow
|
|
10
|
+
* instance name with the canonical `.service-now.com` host appended. This kills
|
|
11
|
+
* the old trap where `jsn auth login dev99999` silently targeted
|
|
12
|
+
* `https://dev99999` (a dead host).
|
|
13
|
+
*/
|
|
14
|
+
export function resolveInstanceArg(arg, cfg) {
|
|
15
|
+
if (!arg) return '';
|
|
16
|
+
const trimmed = arg.trim();
|
|
17
|
+
if (/^https?:\/\//i.test(trimmed)) return normalizeInstanceURL(trimmed);
|
|
18
|
+
const profile = cfg?.profiles?.[trimmed];
|
|
19
|
+
if (profile?.instance_url) return normalizeInstanceURL(profile.instance_url);
|
|
20
|
+
// Already looks like a host (has a dot) — add the scheme, nothing else
|
|
21
|
+
if (trimmed.includes('.')) return normalizeInstanceURL(trimmed);
|
|
22
|
+
// Bare ServiceNow instance name → canonical host
|
|
23
|
+
return normalizeInstanceURL(`${trimmed}.service-now.com`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Interactive first-run wizard (formerly `jsn setup`).
|
|
28
|
+
*
|
|
29
|
+
* Walks: instance URL → profile name → auth method → optional OAuth login.
|
|
30
|
+
* Creates and activates the profile, then returns the result so the caller
|
|
31
|
+
* decides how to report it. Basic auth saves credentials directly (the OAuth
|
|
32
|
+
* path has no interactive username/password).
|
|
33
|
+
*/
|
|
34
|
+
export async function loginWizard(app, argv = {}) {
|
|
35
|
+
const readline = (await import('node:readline')).default;
|
|
36
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
37
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
38
|
+
|
|
39
|
+
console.log('Welcome to JSN - ServiceNow CLI');
|
|
40
|
+
console.log();
|
|
41
|
+
|
|
42
|
+
let instance = getEffectiveInstance(app.config);
|
|
43
|
+
if (!instance) {
|
|
44
|
+
instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
|
|
45
|
+
instance = normalizeInstanceURL(instance);
|
|
46
|
+
}
|
|
47
|
+
console.log(`Instance: ${instance}`);
|
|
48
|
+
|
|
49
|
+
// Warn if another profile already uses this instance URL
|
|
50
|
+
const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
|
|
51
|
+
if (existing) {
|
|
52
|
+
console.log(`Note: instance is also used by profile "${existing[0]}".`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const profileName = (await ask('Profile name (default): ')) || 'default';
|
|
56
|
+
const profile = { instance_url: instance };
|
|
57
|
+
if (argv['read-only']) {
|
|
58
|
+
profile.read_only = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
62
|
+
const useBasic = authMethod.toLowerCase().startsWith('b');
|
|
63
|
+
|
|
64
|
+
let loggedIn = false;
|
|
65
|
+
if (useBasic) {
|
|
66
|
+
const username = await ask('Username: ');
|
|
67
|
+
rl.close(); // Close outer readline before askHidden creates its own
|
|
68
|
+
const { loadCredentials, askHidden, saveCredentials } = await import('../auth.js');
|
|
69
|
+
const existingCreds = loadCredentials(instance) || loadCredentials(instance, username);
|
|
70
|
+
if (existingCreds && existingCreds.auth_method === 'basic') {
|
|
71
|
+
console.log(`Using existing credentials for ${instance}`);
|
|
72
|
+
} else {
|
|
73
|
+
const password = await askHidden('Password: ');
|
|
74
|
+
saveCredentials(instance, { auth_method: 'basic', username, password }, username);
|
|
75
|
+
console.log('Basic auth credentials saved');
|
|
76
|
+
}
|
|
77
|
+
profile.username = username;
|
|
78
|
+
profile.auth_method = 'basic';
|
|
79
|
+
} else {
|
|
80
|
+
profile.auth_method = 'oauth';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await setProfile(app.config, profileName, profile);
|
|
84
|
+
// Set as active profile so subsequent commands know which instance to use
|
|
85
|
+
app.config.activeProfile = profileName;
|
|
86
|
+
app.config.defaultProfile = profileName;
|
|
87
|
+
await saveConfig(app.config);
|
|
88
|
+
|
|
89
|
+
if (!useBasic) {
|
|
90
|
+
const loginNow = await ask('Login now? [Y/n]: ');
|
|
91
|
+
rl.close();
|
|
92
|
+
if (!loginNow || loginNow.toLowerCase() !== 'n') {
|
|
93
|
+
await app.auth.login(instance);
|
|
94
|
+
// Re-save credentials under <user>@<instance> after verifying username
|
|
95
|
+
try {
|
|
96
|
+
const { SDKClient } = await import('../sdk.js');
|
|
97
|
+
const sdk = new SDKClient(instance, app.auth);
|
|
98
|
+
const user = await sdk.getCurrentUser();
|
|
99
|
+
if (user && user.user_name) {
|
|
100
|
+
const { loadCredentials, saveCredentials } = await import('../auth.js');
|
|
101
|
+
const stored = loadCredentials(instance);
|
|
102
|
+
if (stored && stored.access_token) {
|
|
103
|
+
stored.username = user.user_name;
|
|
104
|
+
saveCredentials(instance, stored, user.user_name);
|
|
105
|
+
}
|
|
106
|
+
profile.username = user.user_name;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Non-fatal — token saved, username will be set on next auth login
|
|
110
|
+
}
|
|
111
|
+
console.log('Login successful!');
|
|
112
|
+
loggedIn = true;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
rl.close();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { instanceURL: instance, profileName, loggedIn };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* gh-faithful login target picker: existing profiles plus an "add a new
|
|
123
|
+
* instance" row. Typing filters profiles; picking the + row uses what you
|
|
124
|
+
* typed (or prompts for it).
|
|
125
|
+
*
|
|
126
|
+
* Returns { addNew: true, instanceName } or { addNew: false, name }.
|
|
127
|
+
*/
|
|
128
|
+
async function pickLoginTarget(app) {
|
|
129
|
+
const { search, input } = await import('@inquirer/prompts');
|
|
130
|
+
const ADD_NEW = '__add_new__';
|
|
131
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
132
|
+
let lastInput = '';
|
|
133
|
+
const selected = await search({
|
|
134
|
+
message: 'Log in to which instance?',
|
|
135
|
+
source: (term) => {
|
|
136
|
+
const filter = (term || '').toLowerCase();
|
|
137
|
+
lastInput = term || '';
|
|
138
|
+
const profiles = profileNames
|
|
139
|
+
.filter((name) => {
|
|
140
|
+
const label = `${name} — ${app.config.profiles[name].instance_url}`.toLowerCase();
|
|
141
|
+
return !filter || label.includes(filter);
|
|
142
|
+
})
|
|
143
|
+
.map((name) => ({
|
|
144
|
+
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
145
|
+
value: name,
|
|
146
|
+
}));
|
|
147
|
+
const addLabel = filter
|
|
148
|
+
? `+ Add "${term.trim()}" as a new instance`
|
|
149
|
+
: '+ Add a new instance';
|
|
150
|
+
return [{ name: addLabel, value: ADD_NEW }, ...profiles];
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
if (selected === ADD_NEW) {
|
|
154
|
+
const typed = lastInput.trim();
|
|
155
|
+
if (typed) return { addNew: true, instanceName: typed };
|
|
156
|
+
const name = await input({
|
|
157
|
+
message: 'ServiceNow instance name or URL (e.g. dev12345):',
|
|
158
|
+
validate: (v) => (v.trim() ? true : 'Instance name or URL is required'),
|
|
159
|
+
});
|
|
160
|
+
return { addNew: true, instanceName: name.trim() };
|
|
161
|
+
}
|
|
162
|
+
return { addNew: false, name: selected };
|
|
163
|
+
}
|
|
164
|
+
|
|
5
165
|
export function authCmd(wrap) {
|
|
6
166
|
return {
|
|
7
|
-
command: 'auth
|
|
167
|
+
command: 'auth [subcommand]',
|
|
8
168
|
describe: 'Manage authentication (OAuth or basic auth via env vars)',
|
|
9
169
|
builder: (yargs) => {
|
|
10
170
|
return yargs
|
|
11
171
|
.command({
|
|
12
172
|
command: 'login [instance]',
|
|
13
|
-
describe: 'Authenticate with a ServiceNow instance
|
|
173
|
+
describe: 'Authenticate with a ServiceNow instance (interactive picker when run bare)',
|
|
14
174
|
builder: (sub) => sub
|
|
15
175
|
.option('code', {
|
|
16
176
|
describe: 'Authorization code from browser (bypasses interactive prompt)',
|
|
@@ -27,38 +187,42 @@ export function authCmd(wrap) {
|
|
|
27
187
|
.option('wait-file', {
|
|
28
188
|
describe: 'File path to watch for authorization code (used with --print-url)',
|
|
29
189
|
type: 'string',
|
|
190
|
+
})
|
|
191
|
+
.option('read-only', {
|
|
192
|
+
describe: 'Mark the created profile as read-only (blocks mutation commands)',
|
|
193
|
+
type: 'boolean',
|
|
194
|
+
default: false,
|
|
30
195
|
}),
|
|
31
196
|
handler: wrap(async (argv, app) => {
|
|
32
197
|
let instanceURL;
|
|
33
198
|
if (argv.instance) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
199
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
200
|
+
} else if (app.isInteractive()) {
|
|
201
|
+
// gh-faithful: interactive terminals always get a picker —
|
|
202
|
+
// existing profiles plus "+ Add a new instance". Zero profiles
|
|
203
|
+
// goes straight to the setup wizard.
|
|
204
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
205
|
+
if (profileNames.length === 0) {
|
|
206
|
+
const wizard = await loginWizard(app, argv);
|
|
207
|
+
if (wizard.loggedIn) {
|
|
208
|
+
app.ok({ authenticated: true, instance: wizard.instanceURL, profile: wizard.profileName },
|
|
209
|
+
{ summary: `✓ Authenticated to ${wizard.instanceURL} (profile: ${wizard.profileName})` });
|
|
210
|
+
} else {
|
|
211
|
+
app.ok({ setup: true, instance: wizard.instanceURL, profile: wizard.profileName },
|
|
212
|
+
{ summary: `Profile "${wizard.profileName}" created for ${wizard.instanceURL}. Run "jsn auth login" to authenticate.` });
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const target = await pickLoginTarget(app);
|
|
217
|
+
if (target.addNew) {
|
|
218
|
+
instanceURL = resolveInstanceArg(target.instanceName, app.config);
|
|
38
219
|
} else {
|
|
39
|
-
instanceURL = normalizeInstanceURL(
|
|
220
|
+
instanceURL = normalizeInstanceURL(app.config.profiles[target.name].instance_url);
|
|
40
221
|
}
|
|
41
222
|
} else {
|
|
42
223
|
instanceURL = getEffectiveInstance(app.config);
|
|
43
224
|
if (!instanceURL) {
|
|
44
|
-
|
|
45
|
-
const profileNames = Object.keys(app.config.profiles || {});
|
|
46
|
-
if (profileNames.length > 0 && app.isInteractive()) {
|
|
47
|
-
const { search } = await import('@inquirer/prompts');
|
|
48
|
-
const choices = profileNames.map(name => ({
|
|
49
|
-
name: `${name} — ${app.config.profiles[name].instance_url}`,
|
|
50
|
-
value: name,
|
|
51
|
-
}));
|
|
52
|
-
const selected = await search({
|
|
53
|
-
message: 'Select a profile:',
|
|
54
|
-
source: (input) => {
|
|
55
|
-
const filter = (input || '').toLowerCase();
|
|
56
|
-
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
instanceURL = normalizeInstanceURL(app.config.profiles[selected].instance_url);
|
|
60
|
-
} else {
|
|
61
|
-
throw errUsage(`Instance URL required.
|
|
225
|
+
throw errUsage(`Instance URL required.
|
|
62
226
|
|
|
63
227
|
Examples:
|
|
64
228
|
jsn auth login https://dev12345.service-now.com
|
|
@@ -66,7 +230,6 @@ Examples:
|
|
|
66
230
|
jsn auth login --password https://dev328604.service-now.com
|
|
67
231
|
|
|
68
232
|
Find your instance URL in your browser's address bar when logged into ServiceNow.`);
|
|
69
|
-
}
|
|
70
233
|
}
|
|
71
234
|
}
|
|
72
235
|
|
|
@@ -129,6 +292,7 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
129
292
|
instance_url: instanceURL,
|
|
130
293
|
auth_method: argv.password ? 'basic' : 'oauth',
|
|
131
294
|
username: username || undefined,
|
|
295
|
+
read_only: argv['read-only'] || undefined,
|
|
132
296
|
};
|
|
133
297
|
|
|
134
298
|
// Re-save OAuth credentials with username now that we have it,
|
|
@@ -169,11 +333,11 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
169
333
|
})
|
|
170
334
|
.command({
|
|
171
335
|
command: 'logout [instance]',
|
|
172
|
-
describe: '
|
|
336
|
+
describe: 'Clear stored credentials (keeps the instance entry — use "auth remove" to delete it)',
|
|
173
337
|
handler: wrap(async (argv, app) => {
|
|
174
338
|
let instanceURL;
|
|
175
339
|
if (argv.instance) {
|
|
176
|
-
instanceURL =
|
|
340
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
177
341
|
} else {
|
|
178
342
|
instanceURL = getEffectiveInstance(app.config);
|
|
179
343
|
if (!instanceURL) {
|
|
@@ -240,6 +404,7 @@ Examples:
|
|
|
240
404
|
days_since_last_seen: daysSinceLastSeen,
|
|
241
405
|
stale: daysSinceLastSeen > 7,
|
|
242
406
|
default: instance === defaultInstance,
|
|
407
|
+
read_only: profile.read_only || false,
|
|
243
408
|
});
|
|
244
409
|
}
|
|
245
410
|
|
|
@@ -273,13 +438,7 @@ Examples:
|
|
|
273
438
|
handler: wrap(async (argv, app) => {
|
|
274
439
|
let instanceURL;
|
|
275
440
|
if (argv.instance) {
|
|
276
|
-
|
|
277
|
-
const profiles = app.config.profiles || {};
|
|
278
|
-
if (profiles[argv.instance] && profiles[argv.instance].instance_url) {
|
|
279
|
-
instanceURL = normalizeInstanceURL(profiles[argv.instance].instance_url);
|
|
280
|
-
} else {
|
|
281
|
-
instanceURL = normalizeInstanceURL(argv.instance);
|
|
282
|
-
}
|
|
441
|
+
instanceURL = resolveInstanceArg(argv.instance, app.config);
|
|
283
442
|
} else {
|
|
284
443
|
instanceURL = getEffectiveInstance(app.config);
|
|
285
444
|
if (!instanceURL) {
|
|
@@ -300,17 +459,111 @@ Examples:
|
|
|
300
459
|
expires_at: refreshed.expires_at,
|
|
301
460
|
}, { summary: `✓ Token refreshed for ${instanceURL}` });
|
|
302
461
|
}),
|
|
462
|
+
})
|
|
463
|
+
.command({
|
|
464
|
+
command: 'switch [name]',
|
|
465
|
+
describe: 'Switch active profile (gh: auth switch)',
|
|
466
|
+
builder: (sub) => sub
|
|
467
|
+
.positional('name', {
|
|
468
|
+
describe: 'Profile name to activate',
|
|
469
|
+
type: 'string',
|
|
470
|
+
}),
|
|
471
|
+
handler: wrap(async (argv, app) => {
|
|
472
|
+
let name = argv.name;
|
|
473
|
+
if (!name) {
|
|
474
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
475
|
+
if (profileNames.length === 0) {
|
|
476
|
+
throw errUsage('No profiles configured. Run "jsn auth login" first.');
|
|
477
|
+
}
|
|
478
|
+
if (profileNames.length > 1 && app.isInteractive()) {
|
|
479
|
+
const { search } = await import('@inquirer/prompts');
|
|
480
|
+
const choices = profileNames.map(profileName => ({
|
|
481
|
+
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
482
|
+
value: profileName,
|
|
483
|
+
}));
|
|
484
|
+
const selected = await search({
|
|
485
|
+
message: 'Select a profile:',
|
|
486
|
+
source: (input) => {
|
|
487
|
+
const filter = (input || '').toLowerCase();
|
|
488
|
+
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
name = selected;
|
|
492
|
+
} else {
|
|
493
|
+
name = profileNames[0];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
await setActiveProfile(app.config, name);
|
|
497
|
+
app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
|
|
498
|
+
}),
|
|
499
|
+
})
|
|
500
|
+
.command({
|
|
501
|
+
command: 'remove [name]',
|
|
502
|
+
describe: 'Delete an instance entry and its stored credentials',
|
|
503
|
+
builder: (sub) => sub
|
|
504
|
+
.positional('name', {
|
|
505
|
+
describe: 'Profile name to delete',
|
|
506
|
+
type: 'string',
|
|
507
|
+
}),
|
|
508
|
+
handler: wrap(async (argv, app) => {
|
|
509
|
+
let name = argv.name;
|
|
510
|
+
if (!name) {
|
|
511
|
+
const profileNames = Object.keys(app.config.profiles || {});
|
|
512
|
+
if (profileNames.length === 0) {
|
|
513
|
+
throw errUsage('No profiles configured. Nothing to remove.');
|
|
514
|
+
}
|
|
515
|
+
if (profileNames.length > 1 && app.isInteractive()) {
|
|
516
|
+
const { search } = await import('@inquirer/prompts');
|
|
517
|
+
const choices = profileNames.map(profileName => ({
|
|
518
|
+
name: `${profileName} — ${app.config.profiles[profileName].instance_url}`,
|
|
519
|
+
value: profileName,
|
|
520
|
+
}));
|
|
521
|
+
const selected = await search({
|
|
522
|
+
message: 'Remove which profile?',
|
|
523
|
+
source: (input) => {
|
|
524
|
+
const filter = (input || '').toLowerCase();
|
|
525
|
+
return choices.filter(c => c.name.toLowerCase().includes(filter));
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
name = selected;
|
|
529
|
+
} else {
|
|
530
|
+
name = profileNames[0];
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!app.config.profiles[name]) {
|
|
534
|
+
throw new Error(`Profile not found: ${name}`);
|
|
535
|
+
}
|
|
536
|
+
const instance = app.config.profiles[name].instance_url;
|
|
537
|
+
delete app.config.profiles[name];
|
|
538
|
+
if (app.config.defaultProfile === name) app.config.defaultProfile = '';
|
|
539
|
+
if (app.config.activeProfile === name) app.config.activeProfile = '';
|
|
540
|
+
saveConfig(app.config);
|
|
541
|
+
|
|
542
|
+
// Only clear credentials if no other profile uses this instance URL
|
|
543
|
+
const stillInUse = instance && Object.values(app.config.profiles || {})
|
|
544
|
+
.some(p => p.instance_url === instance);
|
|
545
|
+
if (instance && !stillInUse) {
|
|
546
|
+
app.auth.logout(instance);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
|
|
550
|
+
}),
|
|
303
551
|
});
|
|
304
552
|
},
|
|
305
553
|
handler: () => {
|
|
306
|
-
console.log('Manage
|
|
554
|
+
console.log('Manage authentication for ServiceNow instances.');
|
|
307
555
|
console.log('');
|
|
308
556
|
console.log('Available subcommands:');
|
|
309
|
-
console.log(' login <url>
|
|
310
|
-
console.log(' status
|
|
311
|
-
console.log('
|
|
557
|
+
console.log(' login <url|name> Log in / add an instance (picker when run bare)');
|
|
558
|
+
console.log(' status Show auth status for all profiles');
|
|
559
|
+
console.log(' switch <name> Switch the active profile');
|
|
560
|
+
console.log(' refresh Refresh the access token');
|
|
561
|
+
console.log(' logout Clear credentials (keeps the instance entry)');
|
|
562
|
+
console.log(' remove <name> Delete an instance entry and its credentials');
|
|
312
563
|
console.log('');
|
|
313
564
|
console.log('Run "jsn auth <command> --help" for details.');
|
|
565
|
+
console.log('');
|
|
566
|
+
console.log('Tip: "jsn auth login" is the only command you need to add an instance.');
|
|
314
567
|
},
|
|
315
568
|
};
|
|
316
569
|
}
|
|
@@ -164,21 +164,6 @@ export function updateSetsCmd(wrap) {
|
|
|
164
164
|
});
|
|
165
165
|
}),
|
|
166
166
|
})
|
|
167
|
-
.command({
|
|
168
|
-
command: 'yolo',
|
|
169
|
-
aliases: ['silence'],
|
|
170
|
-
describe: 'Silence the "Default update set" warning for this profile',
|
|
171
|
-
handler: wrap(async (argv, app) => {
|
|
172
|
-
const name = app.context.profileName;
|
|
173
|
-
if (!name) {
|
|
174
|
-
throw new Error('No active profile to mark as yolo. Run jsn setup first.');
|
|
175
|
-
}
|
|
176
|
-
app.config.profiles[name].yolo = true;
|
|
177
|
-
const { saveConfig } = await import('../../config.js');
|
|
178
|
-
saveConfig(app.config);
|
|
179
|
-
app.ok({ profile: name, yolo: true }, { summary: 'Default update set warning silenced for this profile' });
|
|
180
|
-
}),
|
|
181
|
-
})
|
|
182
167
|
.command({
|
|
183
168
|
command: 'export <name>',
|
|
184
169
|
describe: 'Export an update set to XML',
|
|
@@ -225,7 +210,6 @@ export function updateSetsCmd(wrap) {
|
|
|
225
210
|
console.log(' create Create a new update set (auto-sets as current)');
|
|
226
211
|
console.log(' export <name> Export an update set to XML');
|
|
227
212
|
console.log(' complete <name> Mark an update set as complete (coming soon)');
|
|
228
|
-
console.log(' yolo Silence the "Default update set" warning');
|
|
229
213
|
console.log('\nRun "jsn updatesets <command> --help" for details.');
|
|
230
214
|
console.log('\nTip: Create an update set first:');
|
|
231
215
|
console.log(' jsn updatesets create --name "My Feature"');
|
package/src/commands/docs/db.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
|
-
import
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
7
|
|
|
8
8
|
const DOCS_DIR_NAME = 'servicenow-cli/docs';
|
|
9
9
|
|
|
@@ -37,7 +37,7 @@ export function ensureDocsCacheDir() {
|
|
|
37
37
|
export function openDocsDb(opts = {}) {
|
|
38
38
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
39
39
|
ensureDocsCacheDir();
|
|
40
|
-
const db = new
|
|
40
|
+
const db = new Database(dbPath);
|
|
41
41
|
db.exec('PRAGMA journal_mode = WAL');
|
|
42
42
|
return db;
|
|
43
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// CLI search against the docs SQLite database.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
5
|
import { getDocsDbPath, hasEmbeddings } from './db.js';
|
|
6
6
|
import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ export function searchDocs(opts = {}) {
|
|
|
24
24
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const db = new
|
|
27
|
+
const db = new Database(dbPath, { readonly: true });
|
|
28
28
|
const limit = Math.min(parseInt(opts.limit, 10) || 20, 1000);
|
|
29
29
|
const offset = parseInt(opts.offset, 10) || 0;
|
|
30
30
|
const bundle = opts.bundle ? String(opts.bundle) : null;
|
|
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import http from 'node:http';
|
|
6
6
|
import { URL } from 'node:url';
|
|
7
|
-
import
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
8
|
import { getDocsDbPath, hasEmbeddings } from './db.js';
|
|
9
9
|
import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
|
|
10
10
|
|
|
@@ -72,7 +72,7 @@ export function serveDocs(opts = {}) {
|
|
|
72
72
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const db = new
|
|
75
|
+
const db = new Database(dbPath, { readonly: true });
|
|
76
76
|
const hasEmb = hasEmbeddings(db);
|
|
77
77
|
const hrrDim = (() => {
|
|
78
78
|
try {
|
package/src/commands/setup.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { getEffectiveInstance, normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
|
|
1
|
+
import { loginWizard } from './auth.js';
|
|
3
2
|
|
|
3
|
+
// `jsn setup` is kept as a hidden compatibility alias for `jsn auth login`.
|
|
4
|
+
// The wizard lives in auth.js (loginWizard) so there is exactly ONE code path
|
|
5
|
+
// for first-run onboarding — this command can never drift out of sync.
|
|
4
6
|
export function setupCmd(wrap) {
|
|
5
7
|
return {
|
|
6
8
|
command: 'setup',
|
|
7
|
-
describe: 'Interactive first-time setup',
|
|
9
|
+
describe: 'Interactive first-time setup (use "jsn auth login")',
|
|
10
|
+
hidden: true, // legacy alias for `jsn auth login` — works but hidden from help
|
|
8
11
|
builder: (yargs) => {
|
|
9
12
|
return yargs
|
|
10
13
|
.option('read-only', {
|
|
@@ -13,87 +16,10 @@ export function setupCmd(wrap) {
|
|
|
13
16
|
default: false,
|
|
14
17
|
});
|
|
15
18
|
},
|
|
16
|
-
handler: wrap(async (
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
console.log('Welcome to JSN - ServiceNow CLI');
|
|
21
|
-
console.log();
|
|
22
|
-
|
|
23
|
-
let instance = getEffectiveInstance(app.config);
|
|
24
|
-
if (!instance) {
|
|
25
|
-
instance = await ask('ServiceNow instance URL (e.g., dev12345.service-now.com): ');
|
|
26
|
-
instance = normalizeInstanceURL(instance);
|
|
27
|
-
}
|
|
28
|
-
console.log(`Instance: ${instance}`);
|
|
29
|
-
|
|
30
|
-
// Warn if another profile already uses this instance URL
|
|
31
|
-
const existing = Object.entries(app.config.profiles || {}).find(([, p]) => p.instance_url === instance);
|
|
32
|
-
if (existing) {
|
|
33
|
-
console.log(`Note: instance is also used by profile "${existing[0]}".`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const profileName = (await ask('Profile name (default): ')) || 'default';
|
|
37
|
-
const profile = { instance_url: instance };
|
|
38
|
-
if (_argv['read-only']) {
|
|
39
|
-
profile.read_only = true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const authMethod = await ask('Authentication method (OAuth/Basic) [OAuth]: ');
|
|
43
|
-
const useBasic = authMethod.toLowerCase().startsWith('b');
|
|
44
|
-
|
|
45
|
-
if (useBasic) {
|
|
46
|
-
const username = await ask('Username: ');
|
|
47
|
-
rl.close(); // Close outer readline before askHidden creates its own
|
|
48
|
-
const { loadCredentials, askHidden } = await import('../auth.js');
|
|
49
|
-
const existing = loadCredentials(instance) || loadCredentials(instance, username);
|
|
50
|
-
if (existing && existing.auth_method === 'basic') {
|
|
51
|
-
console.log(`Using existing credentials for ${instance}`);
|
|
52
|
-
} else {
|
|
53
|
-
const password = await askHidden('Password: ');
|
|
54
|
-
const { saveCredentials } = await import('../auth.js');
|
|
55
|
-
saveCredentials(instance, { auth_method: 'basic', username, password }, username);
|
|
56
|
-
profile.username = username;
|
|
57
|
-
console.log('Basic auth credentials saved');
|
|
58
|
-
}
|
|
59
|
-
profile.auth_method = 'basic';
|
|
60
|
-
} else {
|
|
61
|
-
profile.auth_method = 'oauth';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
await setProfile(app.config, profileName, profile);
|
|
65
|
-
// Set as active profile so subsequent commands know which instance to use
|
|
66
|
-
app.config.activeProfile = profileName;
|
|
67
|
-
app.config.defaultProfile = profileName;
|
|
68
|
-
saveConfig(app.config);
|
|
69
|
-
|
|
70
|
-
if (!useBasic) {
|
|
71
|
-
const loginNow = await ask('Login now? [Y/n]: ');
|
|
72
|
-
if (!loginNow || loginNow.toLowerCase() !== 'n') {
|
|
73
|
-
await app.auth.login(instance);
|
|
74
|
-
// Re-save credentials under <user>@<instance> after verifying username
|
|
75
|
-
try {
|
|
76
|
-
const { SDKClient } = await import('./sdk.js');
|
|
77
|
-
const sdk = new SDKClient(instance, app.auth);
|
|
78
|
-
const user = await sdk.getCurrentUser();
|
|
79
|
-
if (user && user.user_name) {
|
|
80
|
-
const { loadCredentials, saveCredentials } = await import('../auth.js');
|
|
81
|
-
const stored = loadCredentials(instance);
|
|
82
|
-
if (stored && stored.access_token) {
|
|
83
|
-
stored.username = user.user_name;
|
|
84
|
-
saveCredentials(instance, stored, user.user_name);
|
|
85
|
-
}
|
|
86
|
-
profile.username = user.user_name;
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// Non-fatal — token saved, username will be set on next auth login
|
|
90
|
-
}
|
|
91
|
-
console.log('Login successful!');
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
rl.close();
|
|
96
|
-
app.ok({ setup: true, instance, profile: profileName }, { summary: 'Setup complete' });
|
|
19
|
+
handler: wrap(async (argv, app) => {
|
|
20
|
+
console.log('Note: "jsn setup" is now "jsn auth login". Running the same wizard...\n');
|
|
21
|
+
const result = await loginWizard(app, argv);
|
|
22
|
+
app.ok({ setup: true, ...result }, { summary: 'Setup complete' });
|
|
97
23
|
}),
|
|
98
24
|
};
|
|
99
25
|
}
|
package/src/commands/skill.js
CHANGED
|
@@ -39,7 +39,10 @@ function realHomeDir() {
|
|
|
39
39
|
* profile directory, falling back to ~/.hermes/skills/ (legacy symlink path).
|
|
40
40
|
*/
|
|
41
41
|
function hermesSkillDir() {
|
|
42
|
-
|
|
42
|
+
// JSN_HERMES_BASE_DIR lets tests point at a temp .hermes tree.
|
|
43
|
+
// realHomeDir() deliberately bypasses $HOME (Hermes overrides it to a
|
|
44
|
+
// sandbox dir), so tests can't control the base via HOME.
|
|
45
|
+
const base = process.env.JSN_HERMES_BASE_DIR || path.join(realHomeDir(), '.hermes');
|
|
43
46
|
const profileEnv = process.env.HERMES_PROFILE;
|
|
44
47
|
let profileName = profileEnv;
|
|
45
48
|
|
package/src/config.js
CHANGED
|
@@ -168,3 +168,12 @@ export function setProfile(cfg, name, profile) {
|
|
|
168
168
|
cfg.profiles[name] = profile;
|
|
169
169
|
return saveConfig(cfg);
|
|
170
170
|
}
|
|
171
|
+
|
|
172
|
+
export function setActiveProfile(cfg, name) {
|
|
173
|
+
if (!cfg.profiles || !cfg.profiles[name]) {
|
|
174
|
+
throw new Error(`Profile not found: ${name}`);
|
|
175
|
+
}
|
|
176
|
+
cfg.defaultProfile = name;
|
|
177
|
+
cfg.activeProfile = name;
|
|
178
|
+
return saveConfig(cfg);
|
|
179
|
+
}
|
package/src/help.js
CHANGED
|
@@ -107,10 +107,8 @@ const DATA_COMMANDS = [
|
|
|
107
107
|
];
|
|
108
108
|
|
|
109
109
|
const CONFIG_COMMANDS = [
|
|
110
|
-
{ name: '
|
|
111
|
-
{ name: '
|
|
112
|
-
{ name: 'profiles', alias: 'profile', desc: 'Instance profiles' },
|
|
113
|
-
{ name: 'updatesets', desc: 'Update sets (set, yolo, export, import)' },
|
|
110
|
+
{ name: 'auth', desc: 'Authentication (login, status, switch, refresh, logout, remove)' },
|
|
111
|
+
{ name: 'updatesets', desc: 'Update sets (set, create, export)' },
|
|
114
112
|
{ name: 'scopes', desc: 'Application scopes' },
|
|
115
113
|
];
|
|
116
114
|
|
package/src/mutations.js
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
// Used by the read-only profile middleware to block mutation commands.
|
|
3
3
|
// Each entry is a command path array matching argv._ tokens.
|
|
4
4
|
|
|
5
|
+
// Dev commands that expose create/update/delete via buildDevCmd.
|
|
6
|
+
// Each generates the root form (`jsn <name> create`) and the legacy dev form
|
|
7
|
+
// (`jsn dev <name> create`), so read-only profiles block both spellings.
|
|
8
|
+
const DEV_CRUD_COMMANDS = [
|
|
9
|
+
'actions', 'includes', 'rules', 'clientscripts', 'uiactions', 'uipolicies',
|
|
10
|
+
'tables', 'uipages', 'appmenu', 'acls', 'roles', 'properties', 'relationships',
|
|
11
|
+
'appmodules', 'listcontrols', 'views', 'privileges', 'uxscripts',
|
|
12
|
+
'catalogscripts', 'scriptactions', 'scheduledjobs', 'asyncrules', 'triggers',
|
|
13
|
+
'email', 'restmethods', 'restmessage', 'soapmessages', 'uimacros',
|
|
14
|
+
'cataloguipolicies', 'aliases', 'columns', 'flows', 'scrapi',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function devCrudPaths(name) {
|
|
18
|
+
return [
|
|
19
|
+
[name, 'create'],
|
|
20
|
+
[name, 'update'],
|
|
21
|
+
[name, 'delete'],
|
|
22
|
+
['dev', name, 'create'],
|
|
23
|
+
['dev', name, 'update'],
|
|
24
|
+
['dev', name, 'delete'],
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export const MUTATION_COMMANDS = [
|
|
6
29
|
// Incidents
|
|
7
30
|
['incidents', 'create'],
|
|
@@ -37,18 +60,20 @@ export const MUTATION_COMMANDS = [
|
|
|
37
60
|
['groups', 'delete'],
|
|
38
61
|
// Catalog
|
|
39
62
|
['catalog', 'create-item'],
|
|
40
|
-
// Dev
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
['dev', '
|
|
44
|
-
//
|
|
63
|
+
// Dev CRUD commands (root + `jsn dev` forms)
|
|
64
|
+
...DEV_CRUD_COMMANDS.flatMap(devCrudPaths),
|
|
65
|
+
// Dev eval (no sub-action)
|
|
66
|
+
['dev', 'eval'],
|
|
67
|
+
// Scopes: create + set (root + dev forms)
|
|
68
|
+
['scopes', 'create'],
|
|
69
|
+
['scopes', 'set'],
|
|
45
70
|
['dev', 'scopes', 'create'],
|
|
46
71
|
['dev', 'scopes', 'set'],
|
|
47
|
-
//
|
|
48
|
-
['
|
|
49
|
-
|
|
50
|
-
['dev', 'updatesets', 'set'],
|
|
72
|
+
// Update sets: create + set (root + dev forms)
|
|
73
|
+
['updatesets', 'create'],
|
|
74
|
+
['updatesets', 'set'],
|
|
51
75
|
['dev', 'updatesets', 'create'],
|
|
76
|
+
['dev', 'updatesets', 'set'],
|
|
52
77
|
];
|
|
53
78
|
|
|
54
79
|
/**
|
package/src/commands/profiles.js
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { normalizeInstanceURL, saveConfig, setProfile } from '../config.js';
|
|
2
|
-
|
|
3
|
-
export function profilesCmd(wrap) {
|
|
4
|
-
return {
|
|
5
|
-
command: 'profiles [subcommand]',
|
|
6
|
-
aliases: ['profile'],
|
|
7
|
-
describe: 'Manage configuration profiles',
|
|
8
|
-
builder: (yargs) => {
|
|
9
|
-
return yargs
|
|
10
|
-
.command({
|
|
11
|
-
command: 'create <name>',
|
|
12
|
-
describe: 'Create a new profile',
|
|
13
|
-
builder: (sub) => sub
|
|
14
|
-
.positional('name', {
|
|
15
|
-
describe: 'Profile name',
|
|
16
|
-
type: 'string',
|
|
17
|
-
})
|
|
18
|
-
.option('instance', {
|
|
19
|
-
alias: 'i',
|
|
20
|
-
describe: 'ServiceNow instance URL (e.g. dev12345.service-now.com)',
|
|
21
|
-
type: 'string',
|
|
22
|
-
})
|
|
23
|
-
.option('username', {
|
|
24
|
-
alias: 'u',
|
|
25
|
-
describe: 'Username for basic auth',
|
|
26
|
-
type: 'string',
|
|
27
|
-
})
|
|
28
|
-
.option('password', {
|
|
29
|
-
alias: 'p',
|
|
30
|
-
describe: 'Password for basic auth',
|
|
31
|
-
type: 'string',
|
|
32
|
-
})
|
|
33
|
-
.option('read-only', {
|
|
34
|
-
describe: 'Mark profile as read-only (blocks mutation commands)',
|
|
35
|
-
type: 'boolean',
|
|
36
|
-
default: false,
|
|
37
|
-
}),
|
|
38
|
-
handler: wrap(async (argv, app) => {
|
|
39
|
-
const name = argv.name;
|
|
40
|
-
if (app.config.profiles && app.config.profiles[name]) {
|
|
41
|
-
throw new Error(`Profile "${name}" already exists. Use "jsn profiles update" or remove it first.`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
let instance = argv.instance;
|
|
45
|
-
if (!instance) {
|
|
46
|
-
throw new Error(`Instance URL is required.\n\n jsn profiles create ${name} --instance https://dev12345.service-now.com`);
|
|
47
|
-
}
|
|
48
|
-
instance = normalizeInstanceURL(instance);
|
|
49
|
-
|
|
50
|
-
const profile = { instance_url: instance };
|
|
51
|
-
|
|
52
|
-
// If username/password provided, save credentials too
|
|
53
|
-
if (argv.username && argv.password) {
|
|
54
|
-
const { saveCredentials } = await import('../auth.js');
|
|
55
|
-
saveCredentials(instance, {
|
|
56
|
-
auth_method: 'basic',
|
|
57
|
-
username: argv.username,
|
|
58
|
-
password: argv.password,
|
|
59
|
-
}, argv.username);
|
|
60
|
-
profile.auth_method = 'basic';
|
|
61
|
-
profile.username = argv.username;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// If read-only flag is set, mark the profile
|
|
65
|
-
if (argv['read-only']) {
|
|
66
|
-
profile.read_only = true;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
await setProfile(app.config, name, profile);
|
|
70
|
-
|
|
71
|
-
app.ok({
|
|
72
|
-
profile: name,
|
|
73
|
-
instance,
|
|
74
|
-
auth_method: profile.auth_method || 'oauth (not yet logged in)',
|
|
75
|
-
}, { summary: `Profile "${name}" created for ${instance}` });
|
|
76
|
-
}),
|
|
77
|
-
})
|
|
78
|
-
.command({
|
|
79
|
-
command: 'list',
|
|
80
|
-
describe: 'List all profiles',
|
|
81
|
-
handler: wrap(async (_argv, app) => {
|
|
82
|
-
const profiles = [];
|
|
83
|
-
for (const [name, profile] of Object.entries(app.config.profiles || {})) {
|
|
84
|
-
const instance = profile.instance_url || '';
|
|
85
|
-
const isAuth = app.auth.isAuthenticatedFor(instance);
|
|
86
|
-
const isDefault = name === app.config.defaultProfile;
|
|
87
|
-
profiles.push({
|
|
88
|
-
name,
|
|
89
|
-
instance,
|
|
90
|
-
username: profile.username || '',
|
|
91
|
-
authenticated: isAuth,
|
|
92
|
-
default: isDefault,
|
|
93
|
-
read_only: profile.read_only || false,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Build context-aware breadcrumb: suggest switching to a non-active profile
|
|
98
|
-
const breadcrumbs = [];
|
|
99
|
-
if (profiles.length > 0) {
|
|
100
|
-
const activeName = app.config.activeProfile || app.config.defaultProfile;
|
|
101
|
-
const nonActiveProfiles = profiles.filter(p => p.name !== activeName);
|
|
102
|
-
if (nonActiveProfiles.length > 0) {
|
|
103
|
-
const suggestName = nonActiveProfiles[0].name;
|
|
104
|
-
breadcrumbs.push({
|
|
105
|
-
action: 'switch',
|
|
106
|
-
cmd: `jsn profiles use ${suggestName}`,
|
|
107
|
-
description: 'Switch to a different profile',
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
app.ok({ profiles }, { summary: `${profiles.length} profile(s)`, breadcrumbs });
|
|
113
|
-
}),
|
|
114
|
-
})
|
|
115
|
-
.command({
|
|
116
|
-
command: 'use <name>',
|
|
117
|
-
describe: 'Set active profile',
|
|
118
|
-
handler: wrap(async (argv, app) => {
|
|
119
|
-
const name = argv.name;
|
|
120
|
-
if (!app.config.profiles[name]) {
|
|
121
|
-
throw new Error(`Profile not found: ${name}`);
|
|
122
|
-
}
|
|
123
|
-
app.config.defaultProfile = name;
|
|
124
|
-
app.config.activeProfile = name;
|
|
125
|
-
saveConfig(app.config);
|
|
126
|
-
app.ok({ active_profile: name }, { summary: `Active profile: ${name}` });
|
|
127
|
-
}),
|
|
128
|
-
})
|
|
129
|
-
.command({
|
|
130
|
-
command: 'show [name]',
|
|
131
|
-
describe: 'Show profile details',
|
|
132
|
-
handler: wrap(async (argv, app) => {
|
|
133
|
-
const name = argv.name || app.config.defaultProfile || Object.keys(app.config.profiles || {})[0];
|
|
134
|
-
if (!name || !app.config.profiles[name]) {
|
|
135
|
-
throw new Error('No profiles configured');
|
|
136
|
-
}
|
|
137
|
-
const profile = app.config.profiles[name];
|
|
138
|
-
const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
|
|
139
|
-
app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
|
|
140
|
-
}),
|
|
141
|
-
})
|
|
142
|
-
.command({
|
|
143
|
-
command: 'remove <name>',
|
|
144
|
-
describe: 'Remove a profile',
|
|
145
|
-
handler: wrap(async (argv, app) => {
|
|
146
|
-
const name = argv.name;
|
|
147
|
-
if (!app.config.profiles[name]) {
|
|
148
|
-
throw new Error(`Profile not found: ${name}`);
|
|
149
|
-
}
|
|
150
|
-
const instance = app.config.profiles[name].instance_url;
|
|
151
|
-
delete app.config.profiles[name];
|
|
152
|
-
if (app.config.defaultProfile === name) {
|
|
153
|
-
app.config.defaultProfile = '';
|
|
154
|
-
}
|
|
155
|
-
if (app.config.activeProfile === name) {
|
|
156
|
-
app.config.activeProfile = '';
|
|
157
|
-
}
|
|
158
|
-
saveConfig(app.config);
|
|
159
|
-
|
|
160
|
-
// Only clean up credentials if no other profile uses this instance URL
|
|
161
|
-
const stillInUse = instance && Object.values(app.config.profiles || {})
|
|
162
|
-
.some(p => p.instance_url === instance);
|
|
163
|
-
if (instance && !stillInUse) {
|
|
164
|
-
app.auth.logout(instance);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
|
|
168
|
-
}),
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
},
|
|
172
|
-
handler: (argv) => {
|
|
173
|
-
// When no subcommand is given, show help
|
|
174
|
-
if (!argv._[1]) {
|
|
175
|
-
console.log('Manage your ServiceNow instance profiles.\n');
|
|
176
|
-
console.log('Usage: jsn profiles <create|list|show|use|remove> [options]\n');
|
|
177
|
-
console.log('Commands:');
|
|
178
|
-
console.log(' create <name> Create a new profile (--instance required)');
|
|
179
|
-
console.log(' list List all profiles');
|
|
180
|
-
console.log(' show [name] Show profile details');
|
|
181
|
-
console.log(' use <name> Switch to a different profile');
|
|
182
|
-
console.log(' remove <name> Remove a profile');
|
|
183
|
-
console.log('\nRun "jsn profiles <command> --help" for more details.');
|
|
184
|
-
console.log('\nNote: You can also create a profile by authenticating:');
|
|
185
|
-
console.log(' jsn auth login --profile <name> <instance-url>');
|
|
186
|
-
console.log(' jsn auth login --profile staging --password https://dev12345.service-now.com');
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|