@jacebenson/jsn 2.0.3 → 3.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/package.json +1 -1
- package/src/app.js +8 -0
- package/src/auth.js +126 -43
- package/src/cli.js +19 -0
- package/src/commands/auth.js +33 -1
- package/src/commands/profiles.js +40 -3
- package/src/commands/setup.js +66 -6
- package/src/config.js +16 -0
- package/src/mutations.js +52 -0
- package/src/output.js +6 -3
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -33,6 +33,14 @@ export class App {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
loadContext() {
|
|
36
|
+
const activeName = this.config.activeProfile || this.config.defaultProfile;
|
|
37
|
+
if (activeName && this.config.profiles[activeName]) {
|
|
38
|
+
this.context.profileName = activeName;
|
|
39
|
+
this.context.username = this.config.profiles[activeName].username || '';
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Fallback: no active profile name — find by instance URL (legacy config)
|
|
36
44
|
const instance = getEffectiveInstance(this.config);
|
|
37
45
|
if (!instance) return;
|
|
38
46
|
this.context.profileName = extractProfileName(instance);
|
package/src/auth.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// OAuth 2.0 with PKCE authentication
|
|
2
2
|
// Credentials are stored in the OS keyring (shared with Go version via libsecret/secret-tool)
|
|
3
3
|
// Falls back to file-based storage when keyring is unavailable.
|
|
4
|
+
// Credentials are keyed by <username>@<instance> so different users on the
|
|
5
|
+
// same instance have separate credential slots.
|
|
4
6
|
|
|
5
7
|
import fs from 'node:fs';
|
|
6
8
|
import path from 'node:path';
|
|
@@ -10,6 +12,18 @@ import { execSync } from 'node:child_process';
|
|
|
10
12
|
import { globalConfigDir, normalizeInstanceURL } from './config.js';
|
|
11
13
|
import { errAuth } from './errors.js';
|
|
12
14
|
|
|
15
|
+
// ─── Credential key helpers ───
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a compound key for credential storage: <username>@<instance>
|
|
19
|
+
* When username is omitted (legacy), just the normalized instance URL is used.
|
|
20
|
+
*/
|
|
21
|
+
function credKey(instance, username) {
|
|
22
|
+
const normalized = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_');
|
|
23
|
+
if (!username) return normalized;
|
|
24
|
+
return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
// ─── PKCE state persistence (shared with Go version) ───
|
|
14
28
|
|
|
15
29
|
function pkceStatePath(instance) {
|
|
@@ -60,7 +74,7 @@ function getBasicAuthFromEnv(instance) {
|
|
|
60
74
|
// Try global env vars
|
|
61
75
|
const username = process.env.SN_USERNAME;
|
|
62
76
|
const password = process.env.SN_PASSWORD;
|
|
63
|
-
if (username && password) return { auth_method: 'basic', username, password };
|
|
77
|
+
if (username && password) return { auth_method: 'basic', username, password, auth_source: 'env_basic' };
|
|
64
78
|
return null;
|
|
65
79
|
}
|
|
66
80
|
|
|
@@ -68,12 +82,12 @@ function getBasicAuthFromEnv(instance) {
|
|
|
68
82
|
const host = envVarName(instance);
|
|
69
83
|
const instanceUser = process.env[`SN_${host}_USERNAME`];
|
|
70
84
|
const instancePass = process.env[`SN_${host}_PASSWORD`];
|
|
71
|
-
if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass };
|
|
85
|
+
if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass, auth_source: 'env_basic' };
|
|
72
86
|
|
|
73
87
|
// Fall back to global env vars
|
|
74
88
|
const globalUser = process.env.SN_USERNAME;
|
|
75
89
|
const globalPass = process.env.SN_PASSWORD;
|
|
76
|
-
if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass };
|
|
90
|
+
if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass, auth_source: 'env_basic' };
|
|
77
91
|
|
|
78
92
|
return null;
|
|
79
93
|
}
|
|
@@ -83,11 +97,11 @@ const KEYRING_SERVICE = 'servicenow-cli';
|
|
|
83
97
|
const KEYRING_ATTR_SERVICE = 'service';
|
|
84
98
|
const KEYRING_ATTR_USERNAME = 'username';
|
|
85
99
|
|
|
86
|
-
function credentialsPath(
|
|
100
|
+
function credentialsPath(key) {
|
|
87
101
|
const dir = path.join(globalConfigDir(), 'credentials');
|
|
88
102
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
89
103
|
// Match Go's filename encoding: replace :// and / and : with _
|
|
90
|
-
const filename =
|
|
104
|
+
const filename = key + '.json';
|
|
91
105
|
return path.join(dir, filename);
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -117,17 +131,19 @@ function buildAuthURL(instanceURL, clientID, pkce) {
|
|
|
117
131
|
|
|
118
132
|
// ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
|
|
119
133
|
|
|
120
|
-
function keyringLookup(
|
|
134
|
+
function keyringLookup(key) {
|
|
121
135
|
try {
|
|
122
136
|
const result = execSync(
|
|
123
|
-
`secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${
|
|
137
|
+
`secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
|
|
124
138
|
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
125
139
|
);
|
|
126
140
|
const trimmed = result.trim();
|
|
127
141
|
if (!trimmed) return null;
|
|
128
142
|
const parsed = JSON.parse(trimmed);
|
|
129
|
-
//
|
|
143
|
+
// Spread all fields from keyring first, then normalize known fields
|
|
144
|
+
// so username, password, and other extras are preserved.
|
|
130
145
|
return {
|
|
146
|
+
...parsed,
|
|
131
147
|
auth_method: parsed.auth_method || 'oauth',
|
|
132
148
|
access_token: parsed.access_token || parsed.AccessToken || '',
|
|
133
149
|
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
@@ -139,11 +155,11 @@ function keyringLookup(instance) {
|
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
function keyringStore(
|
|
158
|
+
function keyringStore(key, creds) {
|
|
143
159
|
try {
|
|
144
160
|
execSync(
|
|
145
|
-
`secret-tool store --label="Password for '${
|
|
146
|
-
`${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${
|
|
161
|
+
`secret-tool store --label="Password for '${key}' on '${KEYRING_SERVICE}'" ` +
|
|
162
|
+
`${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
|
|
147
163
|
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
148
164
|
);
|
|
149
165
|
return true;
|
|
@@ -152,10 +168,10 @@ function keyringStore(instance, creds) {
|
|
|
152
168
|
}
|
|
153
169
|
}
|
|
154
170
|
|
|
155
|
-
function keyringDelete(
|
|
171
|
+
function keyringDelete(key) {
|
|
156
172
|
try {
|
|
157
173
|
execSync(
|
|
158
|
-
`secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${
|
|
174
|
+
`secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${key}"`,
|
|
159
175
|
{ stdio: 'ignore' }
|
|
160
176
|
);
|
|
161
177
|
} catch {
|
|
@@ -165,34 +181,45 @@ function keyringDelete(instance) {
|
|
|
165
181
|
|
|
166
182
|
// ─── File-based storage (fallback) ───
|
|
167
183
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Load credentials for an instance keyed by <username>@<instance>.
|
|
186
|
+
* When username is omitted (legacy configs without profile user), uses
|
|
187
|
+
* bare instance key for lookup.
|
|
188
|
+
*/
|
|
189
|
+
function loadCredentials(instance, username) {
|
|
190
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
191
|
+
const keyringCreds = keyringLookup(key);
|
|
192
|
+
if (keyringCreds) {
|
|
193
|
+
return { ...keyringCreds, auth_source: 'keyring' };
|
|
194
|
+
}
|
|
174
195
|
try {
|
|
175
|
-
const data = fs.readFileSync(credentialsPath(
|
|
176
|
-
|
|
196
|
+
const data = fs.readFileSync(credentialsPath(key), 'utf-8');
|
|
197
|
+
const creds = JSON.parse(data);
|
|
198
|
+
return { ...creds, auth_source: 'file' };
|
|
177
199
|
} catch {
|
|
178
200
|
return null;
|
|
179
201
|
}
|
|
180
202
|
}
|
|
181
203
|
|
|
182
|
-
|
|
204
|
+
/**
|
|
205
|
+
* Save credentials for an instance.
|
|
206
|
+
* Uses <username>@<instance> key when username is available.
|
|
207
|
+
*/
|
|
208
|
+
function saveCredentials(instance, creds, username) {
|
|
209
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
183
210
|
// Stamp last_seen on every credential save
|
|
184
211
|
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
185
212
|
// Try keyring first, fall back to file
|
|
186
|
-
if (!keyringStore(
|
|
187
|
-
fs.writeFileSync(credentialsPath(
|
|
213
|
+
if (!keyringStore(key, creds)) {
|
|
214
|
+
fs.writeFileSync(credentialsPath(key), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
188
215
|
}
|
|
189
216
|
}
|
|
190
217
|
|
|
191
218
|
/**
|
|
192
219
|
* Get the last_seen timestamp for an instance, if available.
|
|
193
220
|
*/
|
|
194
|
-
function getLastSeen(instance) {
|
|
195
|
-
const creds = loadCredentials(instance);
|
|
221
|
+
function getLastSeen(instance, username) {
|
|
222
|
+
const creds = loadCredentials(instance, username);
|
|
196
223
|
if (!creds || !creds.last_seen) return null;
|
|
197
224
|
return creds.last_seen;
|
|
198
225
|
}
|
|
@@ -200,17 +227,21 @@ function getLastSeen(instance) {
|
|
|
200
227
|
/**
|
|
201
228
|
* Update the last_seen timestamp for an instance to now.
|
|
202
229
|
*/
|
|
203
|
-
function touchLastSeen(instance) {
|
|
204
|
-
const creds = loadCredentials(instance);
|
|
230
|
+
function touchLastSeen(instance, username) {
|
|
231
|
+
const creds = loadCredentials(instance, username);
|
|
205
232
|
if (!creds) return;
|
|
206
233
|
creds.last_seen = Math.floor(Date.now() / 1000);
|
|
207
|
-
saveCredentials(instance, creds);
|
|
234
|
+
saveCredentials(instance, creds, username);
|
|
208
235
|
}
|
|
209
236
|
|
|
210
|
-
|
|
211
|
-
|
|
237
|
+
/**
|
|
238
|
+
* Delete credentials for an instance keyed by <username>@<instance>.
|
|
239
|
+
*/
|
|
240
|
+
function deleteCredentials(instance, username) {
|
|
241
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
242
|
+
keyringDelete(key);
|
|
212
243
|
try {
|
|
213
|
-
fs.unlinkSync(credentialsPath(
|
|
244
|
+
fs.unlinkSync(credentialsPath(key));
|
|
214
245
|
} catch {
|
|
215
246
|
// ignore
|
|
216
247
|
}
|
|
@@ -271,12 +302,58 @@ export class AuthManager {
|
|
|
271
302
|
this.httpClient = { timeout: 30000 };
|
|
272
303
|
}
|
|
273
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Resolve the username for credential keying from the active profile.
|
|
307
|
+
* Returns null when no profile is configured — callers fall back to bare
|
|
308
|
+
* instance key for backward compatibility with legacy / no-profile usage.
|
|
309
|
+
*/
|
|
310
|
+
_activeUsername() {
|
|
311
|
+
const cfg = this.configProvider.config;
|
|
312
|
+
const name = cfg.activeProfile || cfg.defaultProfile;
|
|
313
|
+
if (name && cfg.profiles[name] && cfg.profiles[name].username) {
|
|
314
|
+
return cfg.profiles[name].username;
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
274
319
|
getLastSeen(instance) {
|
|
275
|
-
return getLastSeen(instance);
|
|
320
|
+
return getLastSeen(instance, this._activeUsername());
|
|
276
321
|
}
|
|
277
322
|
|
|
278
323
|
touchLastSeen(instance) {
|
|
279
|
-
return touchLastSeen(instance);
|
|
324
|
+
return touchLastSeen(instance, this._activeUsername());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Check if legacy bare-instance credentials exist (stored before
|
|
329
|
+
* user@instance keying). Returns true when old-style creds are found
|
|
330
|
+
* but wouldn't be picked up by the current username-scoped lookup.
|
|
331
|
+
*/
|
|
332
|
+
hasLegacyCredentials(instance) {
|
|
333
|
+
if (!instance) return false;
|
|
334
|
+
const bareCreds = loadCredentials(instance);
|
|
335
|
+
if (!bareCreds) return false;
|
|
336
|
+
// If active profile has a username, the bare key won't be found
|
|
337
|
+
// by loadCredentials(instance, username). That's the legacy case.
|
|
338
|
+
const username = this._activeUsername();
|
|
339
|
+
if (!username) return false; // No username set — bare key IS the active path
|
|
340
|
+
const userCreds = loadCredentials(instance, username);
|
|
341
|
+
return !!bareCreds && !userCreds;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Return the auth source for an instance without throwing.
|
|
346
|
+
* Mirrors the auth resolution order in getCredentials().
|
|
347
|
+
* For older stored credentials without auth_source, falls back
|
|
348
|
+
* to the saved auth_method field.
|
|
349
|
+
*/
|
|
350
|
+
getAuthSource(instance) {
|
|
351
|
+
if (process.env.SERVICENOW_OAUTH_TOKEN) return 'env_token';
|
|
352
|
+
if (getBasicAuthFromEnv(instance)) return 'env_basic';
|
|
353
|
+
const creds = loadCredentials(instance, this._activeUsername());
|
|
354
|
+
if (!creds) return null;
|
|
355
|
+
// New creds have auth_source; older ones only have auth_method
|
|
356
|
+
return creds.auth_source || creds.auth_method || 'stored';
|
|
280
357
|
}
|
|
281
358
|
|
|
282
359
|
isAuthenticated() {
|
|
@@ -295,7 +372,7 @@ export class AuthManager {
|
|
|
295
372
|
isAuthenticatedFor(instance) {
|
|
296
373
|
if (!instance) return false;
|
|
297
374
|
if (getBasicAuthFromEnv(instance)) return true;
|
|
298
|
-
const creds = loadCredentials(instance);
|
|
375
|
+
const creds = loadCredentials(instance, this._activeUsername());
|
|
299
376
|
if (!creds) return false;
|
|
300
377
|
if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
|
|
301
378
|
return !!creds.access_token;
|
|
@@ -303,7 +380,7 @@ export class AuthManager {
|
|
|
303
380
|
|
|
304
381
|
async getCredentials() {
|
|
305
382
|
if (process.env.SERVICENOW_OAUTH_TOKEN) {
|
|
306
|
-
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN };
|
|
383
|
+
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
|
|
307
384
|
}
|
|
308
385
|
const instance = this.configProvider.getEffectiveInstance();
|
|
309
386
|
if (!instance) {
|
|
@@ -319,7 +396,7 @@ export class AuthManager {
|
|
|
319
396
|
// Check basic auth from env vars first
|
|
320
397
|
const basicCreds = getBasicAuthFromEnv(instance);
|
|
321
398
|
if (basicCreds) return basicCreds;
|
|
322
|
-
const creds = loadCredentials(instance);
|
|
399
|
+
const creds = loadCredentials(instance, this._activeUsername());
|
|
323
400
|
if (!creds) {
|
|
324
401
|
throw errAuth(`Not authenticated for ${instance}`);
|
|
325
402
|
}
|
|
@@ -377,7 +454,8 @@ export class AuthManager {
|
|
|
377
454
|
|
|
378
455
|
console.log('\nExchanging authorization code for tokens...');
|
|
379
456
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
380
|
-
|
|
457
|
+
// Save with username for per-user credential keying
|
|
458
|
+
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
381
459
|
return newCreds;
|
|
382
460
|
}
|
|
383
461
|
|
|
@@ -414,7 +492,7 @@ export class AuthManager {
|
|
|
414
492
|
console.log(`\nAuthorization code found in ${filePath}`);
|
|
415
493
|
removePKCEState(instanceURL);
|
|
416
494
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
417
|
-
saveCredentials(instanceURL, newCreds);
|
|
495
|
+
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
418
496
|
console.log('Token exchange successful!\n');
|
|
419
497
|
return newCreds;
|
|
420
498
|
}
|
|
@@ -444,7 +522,7 @@ export class AuthManager {
|
|
|
444
522
|
` jsn auth login --password ${instanceURL}`
|
|
445
523
|
);
|
|
446
524
|
}
|
|
447
|
-
saveCredentials(instanceURL, creds);
|
|
525
|
+
saveCredentials(instanceURL, creds, creds.username);
|
|
448
526
|
console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
|
|
449
527
|
return creds;
|
|
450
528
|
}
|
|
@@ -469,7 +547,7 @@ export class AuthManager {
|
|
|
469
547
|
removePKCEState(instanceURL);
|
|
470
548
|
|
|
471
549
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
472
|
-
saveCredentials(instanceURL, newCreds);
|
|
550
|
+
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
473
551
|
return newCreds;
|
|
474
552
|
}
|
|
475
553
|
|
|
@@ -501,6 +579,8 @@ export class AuthManager {
|
|
|
501
579
|
refresh_token: tokenResp.refresh_token,
|
|
502
580
|
expires_at: expiresAt,
|
|
503
581
|
created_at: Math.floor(Date.now() / 1000),
|
|
582
|
+
// The username will be set after post-login verification in the auth command
|
|
583
|
+
auth_source: 'oauth',
|
|
504
584
|
};
|
|
505
585
|
}
|
|
506
586
|
|
|
@@ -529,11 +609,12 @@ export class AuthManager {
|
|
|
529
609
|
access_token: tokenResp.access_token,
|
|
530
610
|
refresh_token: tokenResp.refresh_token,
|
|
531
611
|
created_at: Math.floor(Date.now() / 1000),
|
|
612
|
+
auth_source: 'oauth',
|
|
532
613
|
};
|
|
533
614
|
if (tokenResp.expires_in) {
|
|
534
615
|
newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
|
|
535
616
|
}
|
|
536
|
-
saveCredentials(instance, newCreds);
|
|
617
|
+
saveCredentials(instance, newCreds, creds.username);
|
|
537
618
|
return newCreds;
|
|
538
619
|
}
|
|
539
620
|
|
|
@@ -541,6 +622,8 @@ export class AuthManager {
|
|
|
541
622
|
if (!instance) {
|
|
542
623
|
throw errAuth('No instance specified');
|
|
543
624
|
}
|
|
544
|
-
deleteCredentials(instance);
|
|
625
|
+
deleteCredentials(instance, this._activeUsername());
|
|
545
626
|
}
|
|
546
627
|
}
|
|
628
|
+
|
|
629
|
+
export { saveCredentials, loadCredentials, deleteCredentials, askHidden };
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import process from 'node:process';
|
|
|
6
6
|
import { loadConfig, getEffectiveInstance } from './config.js';
|
|
7
7
|
import { App } from './app.js';
|
|
8
8
|
import { renderHelp } from './help.js';
|
|
9
|
+
import { isMutationCommand } from './mutations.js';
|
|
9
10
|
|
|
10
11
|
// Command modules
|
|
11
12
|
import { setupCmd } from './commands/setup.js';
|
|
@@ -122,6 +123,24 @@ export const cli = yargs(hideBin(process.argv))
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
// Read-only profile check — block mutation commands on read-only profiles
|
|
127
|
+
const activeProfileName = cfg.activeProfile || cfg.defaultProfile;
|
|
128
|
+
if (activeProfileName && cfg.profiles[activeProfileName] && cfg.profiles[activeProfileName].read_only) {
|
|
129
|
+
const skipReadOnlyCheck = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
|
|
130
|
+
if (!skipReadOnlyCheck && isMutationCommand(argv)) {
|
|
131
|
+
process.stderr.write(`\n🔒 Profile "${activeProfileName}" is read-only.\n`);
|
|
132
|
+
process.stderr.write(` Mutation commands are disabled on this profile.\n`);
|
|
133
|
+
const nonReadOnlyProfiles = Object.entries(cfg.profiles || {})
|
|
134
|
+
.filter(([n, p]) => n !== activeProfileName && !p.read_only)
|
|
135
|
+
.map(([n]) => n);
|
|
136
|
+
if (nonReadOnlyProfiles.length > 0) {
|
|
137
|
+
process.stderr.write(` Switch to a writable profile: jsn profiles use ${nonReadOnlyProfiles[0]}\n`);
|
|
138
|
+
}
|
|
139
|
+
process.stderr.write('\n');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
125
144
|
// Auto-check skill on every command (fire-and-forget, non-blocking)
|
|
126
145
|
const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
127
146
|
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
package/src/commands/auth.js
CHANGED
|
@@ -125,11 +125,25 @@ Find your instance URL in your browser's address bar when logged into ServiceNow
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
app.config.profiles[profileName] = {
|
|
128
|
+
...(app.config.profiles[profileName] || {}),
|
|
128
129
|
instance_url: instanceURL,
|
|
129
|
-
auth_method: 'oauth',
|
|
130
|
+
auth_method: argv.password ? 'basic' : 'oauth',
|
|
130
131
|
username: username || undefined,
|
|
131
132
|
};
|
|
132
133
|
|
|
134
|
+
// Re-save OAuth credentials with username now that we have it,
|
|
135
|
+
// so they're keyed by <user>@<instance> for per-user isolation.
|
|
136
|
+
// Also clean up the old bare-instance key so migration is clean.
|
|
137
|
+
if (!argv.password && username) {
|
|
138
|
+
const { saveCredentials, loadCredentials, deleteCredentials } = await import('../auth.js');
|
|
139
|
+
const stored = loadCredentials(instanceURL);
|
|
140
|
+
if (stored && stored.access_token) {
|
|
141
|
+
stored.username = username;
|
|
142
|
+
saveCredentials(instanceURL, stored, username);
|
|
143
|
+
deleteCredentials(instanceURL); // remove bare-instance legacy key
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
133
147
|
// Set as default if this is the first one
|
|
134
148
|
const setDefault = !app.config.instanceURL && !app.config.defaultProfile;
|
|
135
149
|
if (setDefault) {
|
|
@@ -188,6 +202,8 @@ Examples:
|
|
|
188
202
|
const instance = profile.instance_url;
|
|
189
203
|
const isAuth = app.auth.isAuthenticatedFor(instance);
|
|
190
204
|
const lastSeen = app.auth.getLastSeen(instance);
|
|
205
|
+
const authSource = isAuth ? app.auth.getAuthSource(instance) : null;
|
|
206
|
+
const legacy = !isAuth && app.auth.hasLegacyCredentials(instance) ? true : undefined;
|
|
191
207
|
|
|
192
208
|
// Try live verification
|
|
193
209
|
let verified = null;
|
|
@@ -216,6 +232,8 @@ Examples:
|
|
|
216
232
|
name,
|
|
217
233
|
instance,
|
|
218
234
|
authenticated: isAuth,
|
|
235
|
+
legacy,
|
|
236
|
+
auth_source: authSource || (legacy ? 'legacy' : undefined),
|
|
219
237
|
verified,
|
|
220
238
|
verified_as: verifiedAt || undefined,
|
|
221
239
|
last_seen: lastSeen || undefined,
|
|
@@ -233,6 +251,20 @@ Examples:
|
|
|
233
251
|
};
|
|
234
252
|
|
|
235
253
|
app.ok(result, { summary: `${profiles.length} profile(s)` });
|
|
254
|
+
|
|
255
|
+
// Warn about legacy credentials that need re-authentication
|
|
256
|
+
const legacyProfiles = profiles.filter(p => p.legacy);
|
|
257
|
+
if (legacyProfiles.length > 0) {
|
|
258
|
+
process.stderr.write(
|
|
259
|
+
'\n⚠️ This release changed how credentials are stored.\n' +
|
|
260
|
+
' Some profiles still use the old format.\n' +
|
|
261
|
+
' Run the following to re-authenticate:\n'
|
|
262
|
+
);
|
|
263
|
+
for (const p of legacyProfiles) {
|
|
264
|
+
process.stderr.write(` jsn auth login ${p.name}\n`);
|
|
265
|
+
}
|
|
266
|
+
process.stderr.write('\n');
|
|
267
|
+
}
|
|
236
268
|
}),
|
|
237
269
|
})
|
|
238
270
|
.command({
|
package/src/commands/profiles.js
CHANGED
|
@@ -29,6 +29,11 @@ export function profilesCmd(wrap) {
|
|
|
29
29
|
alias: 'p',
|
|
30
30
|
describe: 'Password for basic auth',
|
|
31
31
|
type: 'string',
|
|
32
|
+
})
|
|
33
|
+
.option('read-only', {
|
|
34
|
+
describe: 'Mark profile as read-only (blocks mutation commands)',
|
|
35
|
+
type: 'boolean',
|
|
36
|
+
default: false,
|
|
32
37
|
}),
|
|
33
38
|
handler: wrap(async (argv, app) => {
|
|
34
39
|
const name = argv.name;
|
|
@@ -51,11 +56,16 @@ export function profilesCmd(wrap) {
|
|
|
51
56
|
auth_method: 'basic',
|
|
52
57
|
username: argv.username,
|
|
53
58
|
password: argv.password,
|
|
54
|
-
});
|
|
59
|
+
}, argv.username);
|
|
55
60
|
profile.auth_method = 'basic';
|
|
56
61
|
profile.username = argv.username;
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// If read-only flag is set, mark the profile
|
|
65
|
+
if (argv['read-only']) {
|
|
66
|
+
profile.read_only = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
await setProfile(app.config, name, profile);
|
|
60
70
|
|
|
61
71
|
app.ok({
|
|
@@ -80,9 +90,26 @@ export function profilesCmd(wrap) {
|
|
|
80
90
|
username: profile.username || '',
|
|
81
91
|
authenticated: isAuth,
|
|
82
92
|
default: isDefault,
|
|
93
|
+
read_only: profile.read_only || false,
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
|
-
|
|
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 });
|
|
86
113
|
}),
|
|
87
114
|
})
|
|
88
115
|
.command({
|
|
@@ -108,7 +135,8 @@ export function profilesCmd(wrap) {
|
|
|
108
135
|
throw new Error('No profiles configured');
|
|
109
136
|
}
|
|
110
137
|
const profile = app.config.profiles[name];
|
|
111
|
-
|
|
138
|
+
const readOnlyNote = profile.read_only ? ' [READ-ONLY]' : '';
|
|
139
|
+
app.ok({ name, ...profile }, { summary: `Profile: ${name}${readOnlyNote}` });
|
|
112
140
|
}),
|
|
113
141
|
})
|
|
114
142
|
.command({
|
|
@@ -119,6 +147,7 @@ export function profilesCmd(wrap) {
|
|
|
119
147
|
if (!app.config.profiles[name]) {
|
|
120
148
|
throw new Error(`Profile not found: ${name}`);
|
|
121
149
|
}
|
|
150
|
+
const instance = app.config.profiles[name].instance_url;
|
|
122
151
|
delete app.config.profiles[name];
|
|
123
152
|
if (app.config.defaultProfile === name) {
|
|
124
153
|
app.config.defaultProfile = '';
|
|
@@ -127,6 +156,14 @@ export function profilesCmd(wrap) {
|
|
|
127
156
|
app.config.activeProfile = '';
|
|
128
157
|
}
|
|
129
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
|
+
|
|
130
167
|
app.ok({ removed: name }, { summary: `Removed profile: ${name}` });
|
|
131
168
|
}),
|
|
132
169
|
})
|
package/src/commands/setup.js
CHANGED
|
@@ -5,6 +5,14 @@ export function setupCmd(wrap) {
|
|
|
5
5
|
return {
|
|
6
6
|
command: 'setup',
|
|
7
7
|
describe: 'Interactive first-time setup',
|
|
8
|
+
builder: (yargs) => {
|
|
9
|
+
return yargs
|
|
10
|
+
.option('read-only', {
|
|
11
|
+
describe: 'Mark profile as read-only (blocks mutation commands)',
|
|
12
|
+
type: 'boolean',
|
|
13
|
+
default: false,
|
|
14
|
+
});
|
|
15
|
+
},
|
|
8
16
|
handler: wrap(async (_argv, app) => {
|
|
9
17
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
18
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
@@ -19,17 +27,69 @@ export function setupCmd(wrap) {
|
|
|
19
27
|
}
|
|
20
28
|
console.log(`Instance: ${instance}`);
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
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);
|
|
24
65
|
// Set as active profile so subsequent commands know which instance to use
|
|
25
66
|
app.config.activeProfile = profileName;
|
|
26
67
|
app.config.defaultProfile = profileName;
|
|
27
68
|
saveConfig(app.config);
|
|
28
69
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|
|
33
93
|
}
|
|
34
94
|
|
|
35
95
|
rl.close();
|
package/src/config.js
CHANGED
|
@@ -120,6 +120,22 @@ export function saveConfig(cfg) {
|
|
|
120
120
|
format: cfg.format || undefined,
|
|
121
121
|
};
|
|
122
122
|
fs.writeFileSync(globalConfigPath(), JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
123
|
+
|
|
124
|
+
// Sync managed fields to local config if it exists, so profile operations
|
|
125
|
+
// (use, remove, create, auth login) aren't silently reverted on next load.
|
|
126
|
+
if (fs.existsSync(localConfigPath())) {
|
|
127
|
+
try {
|
|
128
|
+
const localData = JSON.parse(fs.readFileSync(localConfigPath(), 'utf-8'));
|
|
129
|
+
const merged = { ...localData };
|
|
130
|
+
if (payload.profiles !== undefined) merged.profiles = payload.profiles;
|
|
131
|
+
if (payload.default_profile !== undefined) merged.default_profile = payload.default_profile;
|
|
132
|
+
if (payload.instance_url !== undefined) merged.instance_url = payload.instance_url;
|
|
133
|
+
if (payload.format !== undefined) merged.format = payload.format;
|
|
134
|
+
fs.writeFileSync(localConfigPath(), JSON.stringify(merged, null, 2), { mode: 0o600 });
|
|
135
|
+
} catch {
|
|
136
|
+
// Local config unreadable — skip sync, global save already succeeded
|
|
137
|
+
}
|
|
138
|
+
}
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
export function saveLocalConfig(cfg) {
|
package/src/mutations.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Centralized mutation command registry
|
|
2
|
+
// Used by the read-only profile middleware to block mutation commands.
|
|
3
|
+
// Each entry is a command path array matching argv._ tokens.
|
|
4
|
+
|
|
5
|
+
export const MUTATION_COMMANDS = [
|
|
6
|
+
// Incidents
|
|
7
|
+
['incidents', 'create'],
|
|
8
|
+
['incidents', 'update'],
|
|
9
|
+
['incidents', 'delete'],
|
|
10
|
+
// Changes
|
|
11
|
+
['changes', 'create'],
|
|
12
|
+
['changes', 'update'],
|
|
13
|
+
['changes', 'delete'],
|
|
14
|
+
// Records
|
|
15
|
+
['records', 'create'],
|
|
16
|
+
['records', 'update'],
|
|
17
|
+
['records', 'delete'],
|
|
18
|
+
// Requests
|
|
19
|
+
['requests', 'create'],
|
|
20
|
+
['requests', 'update'],
|
|
21
|
+
['requests', 'delete'],
|
|
22
|
+
// Tasks
|
|
23
|
+
['tasks', 'create'],
|
|
24
|
+
['tasks', 'update'],
|
|
25
|
+
['tasks', 'delete'],
|
|
26
|
+
// Dev
|
|
27
|
+
['dev', 'eval'],
|
|
28
|
+
['dev', 'updatesets', 'set'],
|
|
29
|
+
['dev', 'updatesets', 'create'],
|
|
30
|
+
['dev', 'scopes', 'set'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if the parsed argv matches any mutation command pattern.
|
|
35
|
+
* @param {object} argv — yargs parsed argv with `_` array
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isMutationCommand(argv) {
|
|
39
|
+
const cmd = argv._ || [];
|
|
40
|
+
for (const pattern of MUTATION_COMMANDS) {
|
|
41
|
+
if (pattern.length !== cmd.length) continue;
|
|
42
|
+
let match = true;
|
|
43
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
44
|
+
if (pattern[i] !== cmd[i]) {
|
|
45
|
+
match = false;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (match) return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
package/src/output.js
CHANGED
|
@@ -192,12 +192,15 @@ export class OutputWriter {
|
|
|
192
192
|
staleStr = ` (${p.days_since_last_seen}d ago — may have been released)`;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Show lock icon for read-only profiles
|
|
196
|
+
const lockIcon = p.read_only ? ' 🔒' : '';
|
|
197
|
+
|
|
195
198
|
if (p.name) {
|
|
196
|
-
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${verifiedStr}${staleStr}\n`);
|
|
199
|
+
this.writer.write(`${prefix}${authIcon} ${p.name} — ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
197
200
|
} else if (p.username) {
|
|
198
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${verifiedStr}${staleStr}\n`);
|
|
201
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance} (as ${p.username})${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
199
202
|
} else {
|
|
200
|
-
this.writer.write(`${prefix}${authIcon} ${p.instance}${verifiedStr}${staleStr}\n`);
|
|
203
|
+
this.writer.write(`${prefix}${authIcon} ${p.instance}${lockIcon}${verifiedStr}${staleStr}\n`);
|
|
201
204
|
}
|
|
202
205
|
}
|
|
203
206
|
}
|