@jacebenson/jsn 1.0.7 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -243
- package/bin/jsn.js +2 -57
- package/package.json +35 -32
- package/skills/servicenow/SKILL.md +137 -0
- package/src/app.js +172 -0
- package/src/auth.js +517 -0
- package/src/cli.js +174 -0
- package/src/commands/_ticket.js +260 -0
- package/src/commands/auth.js +227 -0
- package/src/commands/catalog.js +325 -0
- package/src/commands/changes.js +7 -0
- package/src/commands/dev/_generic.js +342 -0
- package/src/commands/dev/_simple.js +89 -0
- package/src/commands/dev/eval.js +38 -0
- package/src/commands/dev/flows.js +573 -0
- package/src/commands/dev/forms.js +313 -0
- package/src/commands/dev/lists.js +233 -0
- package/src/commands/dev/logs.js +64 -0
- package/src/commands/dev/rest.js +64 -0
- package/src/commands/dev/scopes.js +156 -0
- package/src/commands/dev/updatesets.js +184 -0
- package/src/commands/dev.js +53 -0
- package/src/commands/groupmembers.js +152 -0
- package/src/commands/grouproles.js +152 -0
- package/src/commands/groups.js +141 -0
- package/src/commands/incidents.js +7 -0
- package/src/commands/profiles.js +91 -0
- package/src/commands/records.js +171 -0
- package/src/commands/requests.js +59 -0
- package/src/commands/setup.js +39 -0
- package/src/commands/skill.js +144 -0
- package/src/commands/tasks.js +7 -0
- package/src/commands/tickets.js +121 -0
- package/src/commands/users.js +144 -0
- package/src/commands/version.js +81 -0
- package/src/config.js +154 -0
- package/src/context.js +62 -0
- package/src/errors.js +101 -0
- package/src/help.js +85 -0
- package/src/helpers.js +182 -0
- package/src/output.js +440 -0
- package/src/sdk.js +615 -0
- package/scripts/install.sh +0 -227
- package/scripts/npm-install.js +0 -235
- package/scripts/pre-commit-check.sh +0 -61
package/src/auth.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import { globalConfigDir, normalizeInstanceURL } from './config.js';
|
|
11
|
+
import { errAuth } from './errors.js';
|
|
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
|
+
|
|
44
|
+
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
45
|
+
const REDIRECT_URI = '/sdk-oauth.do';
|
|
46
|
+
|
|
47
|
+
// ─── Basic auth from environment variables ───
|
|
48
|
+
// SN_USERNAME / SN_PASSWORD — global credentials
|
|
49
|
+
// SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
|
|
50
|
+
|
|
51
|
+
function envVarName(instance) {
|
|
52
|
+
// Normalize instance URL to an env-var-safe name
|
|
53
|
+
// e.g. https://dev328604.service-now.com → DEV328604
|
|
54
|
+
const host = instance.replace(/https?:\/\//, '').replace(/\/.*$/, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
55
|
+
return host;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getBasicAuthFromEnv(instance) {
|
|
59
|
+
if (!instance) {
|
|
60
|
+
// Try global env vars
|
|
61
|
+
const username = process.env.SN_USERNAME;
|
|
62
|
+
const password = process.env.SN_PASSWORD;
|
|
63
|
+
if (username && password) return { auth_method: 'basic', username, password };
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Try instance-specific env vars first (e.g. SN_DEV328604_USERNAME)
|
|
68
|
+
const host = envVarName(instance);
|
|
69
|
+
const instanceUser = process.env[`SN_${host}_USERNAME`];
|
|
70
|
+
const instancePass = process.env[`SN_${host}_PASSWORD`];
|
|
71
|
+
if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass };
|
|
72
|
+
|
|
73
|
+
// Fall back to global env vars
|
|
74
|
+
const globalUser = process.env.SN_USERNAME;
|
|
75
|
+
const globalPass = process.env.SN_PASSWORD;
|
|
76
|
+
if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass };
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Keychain constants — same as Go version (internal/auth/store.go)
|
|
82
|
+
const KEYRING_SERVICE = 'servicenow-cli';
|
|
83
|
+
const KEYRING_ATTR_SERVICE = 'service';
|
|
84
|
+
const KEYRING_ATTR_USERNAME = 'username';
|
|
85
|
+
|
|
86
|
+
function credentialsPath(instance) {
|
|
87
|
+
const dir = path.join(globalConfigDir(), 'credentials');
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
89
|
+
// Match Go's filename encoding: replace :// and / and : with _
|
|
90
|
+
const filename = instance.replace(/:\/\//g, '_').replace(/\//g, '_').replace(/:/g, '_') + '.json';
|
|
91
|
+
return path.join(dir, filename);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getOAuthClientID() {
|
|
95
|
+
return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function generatePKCE() {
|
|
99
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
100
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
101
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
102
|
+
return { code_verifier: verifier, code_challenge: challenge, state };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildAuthURL(instanceURL, clientID, pkce) {
|
|
106
|
+
const u = new URL('/oauth_auth.do', instanceURL);
|
|
107
|
+
u.searchParams.set('response_type', 'code');
|
|
108
|
+
u.searchParams.set('client_id', clientID);
|
|
109
|
+
u.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
110
|
+
u.searchParams.set('state', pkce.state);
|
|
111
|
+
u.searchParams.set('code_challenge', pkce.code_challenge);
|
|
112
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
113
|
+
u.searchParams.set('scope', 'openid');
|
|
114
|
+
u.searchParams.set('approval_prompt', 'force');
|
|
115
|
+
return u.toString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
|
|
119
|
+
|
|
120
|
+
function keyringLookup(instance) {
|
|
121
|
+
try {
|
|
122
|
+
const result = execSync(
|
|
123
|
+
`secret-tool lookup ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
124
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
125
|
+
);
|
|
126
|
+
const trimmed = result.trim();
|
|
127
|
+
if (!trimmed) return null;
|
|
128
|
+
const parsed = JSON.parse(trimmed);
|
|
129
|
+
// Normalize field names from Go's format to Node.js format
|
|
130
|
+
return {
|
|
131
|
+
auth_method: parsed.auth_method || 'oauth',
|
|
132
|
+
access_token: parsed.access_token || parsed.AccessToken || '',
|
|
133
|
+
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
134
|
+
expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
|
|
135
|
+
created_at: parsed.created_at || parsed.CreatedAt || 0,
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function keyringStore(instance, creds) {
|
|
143
|
+
try {
|
|
144
|
+
execSync(
|
|
145
|
+
`secret-tool store --label="Password for '${instance}' on '${KEYRING_SERVICE}'" ` +
|
|
146
|
+
`${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
147
|
+
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
148
|
+
);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function keyringDelete(instance) {
|
|
156
|
+
try {
|
|
157
|
+
execSync(
|
|
158
|
+
`secret-tool clear ${KEYRING_ATTR_SERVICE} ${KEYRING_SERVICE} ${KEYRING_ATTR_USERNAME} "${instance}"`,
|
|
159
|
+
{ stdio: 'ignore' }
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── File-based storage (fallback) ───
|
|
167
|
+
|
|
168
|
+
function loadCredentials(instance) {
|
|
169
|
+
// Try keyring first (shared with Go version)
|
|
170
|
+
const keyringCreds = keyringLookup(instance);
|
|
171
|
+
if (keyringCreds) return keyringCreds;
|
|
172
|
+
|
|
173
|
+
// Fall back to file-based storage
|
|
174
|
+
try {
|
|
175
|
+
const data = fs.readFileSync(credentialsPath(instance), 'utf-8');
|
|
176
|
+
return JSON.parse(data);
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function saveCredentials(instance, creds) {
|
|
183
|
+
// Try keyring first, fall back to file
|
|
184
|
+
if (!keyringStore(instance, creds)) {
|
|
185
|
+
fs.writeFileSync(credentialsPath(instance), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function deleteCredentials(instance) {
|
|
190
|
+
keyringDelete(instance);
|
|
191
|
+
try {
|
|
192
|
+
fs.unlinkSync(credentialsPath(instance));
|
|
193
|
+
} catch {
|
|
194
|
+
// ignore
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function askHidden(promptText) {
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
const rl = readline.createInterface({
|
|
201
|
+
input: process.stdin,
|
|
202
|
+
output: process.stdout,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const stdin = process.stdin;
|
|
206
|
+
const stdout = process.stdout;
|
|
207
|
+
|
|
208
|
+
if (!stdin.isTTY) {
|
|
209
|
+
rl.question(promptText, (answer) => {
|
|
210
|
+
rl.close();
|
|
211
|
+
resolve(answer.trim());
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
stdout.write(promptText);
|
|
217
|
+
|
|
218
|
+
stdin.setRawMode(true);
|
|
219
|
+
stdin.resume();
|
|
220
|
+
stdin.setEncoding('utf-8');
|
|
221
|
+
|
|
222
|
+
let input = '';
|
|
223
|
+
const onData = (key) => {
|
|
224
|
+
if (key === '\r' || key === '\n') {
|
|
225
|
+
stdin.removeListener('data', onData);
|
|
226
|
+
stdin.setRawMode(false);
|
|
227
|
+
stdin.pause();
|
|
228
|
+
stdout.write('\n');
|
|
229
|
+
rl.close();
|
|
230
|
+
resolve(input);
|
|
231
|
+
} else if (key === '\u0003') {
|
|
232
|
+
process.exit();
|
|
233
|
+
} else if (key === '\u007f') {
|
|
234
|
+
if (input.length > 0) {
|
|
235
|
+
input = input.slice(0, -1);
|
|
236
|
+
stdout.write('\b \b');
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
input += key;
|
|
240
|
+
stdout.write('*');
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
stdin.on('data', onData);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class AuthManager {
|
|
248
|
+
constructor(configProvider) {
|
|
249
|
+
this.configProvider = configProvider;
|
|
250
|
+
this.httpClient = { timeout: 30000 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
isAuthenticated() {
|
|
254
|
+
if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
|
|
255
|
+
if (getBasicAuthFromEnv()) return true;
|
|
256
|
+
const instance = this.configProvider.getEffectiveInstance();
|
|
257
|
+
if (!instance) return false;
|
|
258
|
+
try {
|
|
259
|
+
this.getCredentialsFor(instance);
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
isAuthenticatedFor(instance) {
|
|
267
|
+
if (!instance) return false;
|
|
268
|
+
if (getBasicAuthFromEnv(instance)) return true;
|
|
269
|
+
const creds = loadCredentials(instance);
|
|
270
|
+
if (!creds) return false;
|
|
271
|
+
if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
|
|
272
|
+
return !!creds.access_token;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async getCredentials() {
|
|
276
|
+
if (process.env.SERVICENOW_OAUTH_TOKEN) {
|
|
277
|
+
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN };
|
|
278
|
+
}
|
|
279
|
+
const instance = this.configProvider.getEffectiveInstance();
|
|
280
|
+
if (!instance) {
|
|
281
|
+
throw errAuth('No instance configured');
|
|
282
|
+
}
|
|
283
|
+
// Check basic auth from env vars first
|
|
284
|
+
const basicCreds = getBasicAuthFromEnv(instance);
|
|
285
|
+
if (basicCreds) return basicCreds;
|
|
286
|
+
return this.getCredentialsFor(instance);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getCredentialsFor(instance) {
|
|
290
|
+
// Check basic auth from env vars first
|
|
291
|
+
const basicCreds = getBasicAuthFromEnv(instance);
|
|
292
|
+
if (basicCreds) return basicCreds;
|
|
293
|
+
const creds = loadCredentials(instance);
|
|
294
|
+
if (!creds) {
|
|
295
|
+
throw errAuth(`Not authenticated for ${instance}`);
|
|
296
|
+
}
|
|
297
|
+
// Check expiry — refresh if less than 5 minutes remaining
|
|
298
|
+
if (creds.expires_at && Date.now() >= (creds.expires_at - 300) * 1000) {
|
|
299
|
+
if (creds.refresh_token) {
|
|
300
|
+
return this.refreshToken(instance, creds);
|
|
301
|
+
}
|
|
302
|
+
throw errAuth('Token expired, please login again');
|
|
303
|
+
}
|
|
304
|
+
return creds;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async login(instanceURL) {
|
|
308
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
309
|
+
const clientID = getOAuthClientID();
|
|
310
|
+
const pkce = generatePKCE();
|
|
311
|
+
const authURL = buildAuthURL(instanceURL, clientID, pkce);
|
|
312
|
+
|
|
313
|
+
console.log();
|
|
314
|
+
console.log('Opening browser for OAuth authentication...');
|
|
315
|
+
console.log('If the browser does not open automatically, visit:');
|
|
316
|
+
console.log(authURL);
|
|
317
|
+
console.log();
|
|
318
|
+
|
|
319
|
+
// Try to open browser
|
|
320
|
+
const open = (await import('node:child_process')).spawn;
|
|
321
|
+
const platform = process.platform;
|
|
322
|
+
let cmd, args;
|
|
323
|
+
if (platform === 'darwin') {
|
|
324
|
+
cmd = 'open';
|
|
325
|
+
args = [authURL];
|
|
326
|
+
} else if (platform === 'win32') {
|
|
327
|
+
cmd = 'cmd';
|
|
328
|
+
args = ['/c', 'start', authURL];
|
|
329
|
+
} else {
|
|
330
|
+
cmd = 'xdg-open';
|
|
331
|
+
args = [authURL];
|
|
332
|
+
}
|
|
333
|
+
const child = open(cmd, args, { detached: true, stdio: 'ignore' });
|
|
334
|
+
child.on('error', () => {
|
|
335
|
+
// Browser open command not available — user will open the URL manually
|
|
336
|
+
});
|
|
337
|
+
child.unref();
|
|
338
|
+
|
|
339
|
+
console.log('After authenticating in the browser, copy the authorization code shown on the page.');
|
|
340
|
+
console.log('(input is hidden for security — just paste and press Enter)');
|
|
341
|
+
console.log();
|
|
342
|
+
|
|
343
|
+
const authCode = await askHidden('Authorization code (hidden on paste for security): ');
|
|
344
|
+
const code = authCode.trim();
|
|
345
|
+
if (!code) {
|
|
346
|
+
throw errAuth('Authorization code is required');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log('\nExchanging authorization code for tokens...');
|
|
350
|
+
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
351
|
+
saveCredentials(instanceURL, newCreds);
|
|
352
|
+
return newCreds;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build an OAuth authorization URL and persist PKCE state for later use.
|
|
357
|
+
* After calling this, the user can visit the URL in a browser and then
|
|
358
|
+
* call loginWithCode() with the resulting authorization code.
|
|
359
|
+
* If waitFile is provided, the method will read the code from that file
|
|
360
|
+
* (waits for file to appear, polling every 2 seconds, up to 5 minutes).
|
|
361
|
+
*/
|
|
362
|
+
buildAuthURL(instanceURL, waitFile) {
|
|
363
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
364
|
+
const clientID = getOAuthClientID();
|
|
365
|
+
const pkce = generatePKCE();
|
|
366
|
+
savePKCEState(instanceURL, pkce);
|
|
367
|
+
const url = buildAuthURL(instanceURL, clientID, pkce);
|
|
368
|
+
if (waitFile) {
|
|
369
|
+
console.log(url);
|
|
370
|
+
console.log();
|
|
371
|
+
console.log(`Waiting for authorization code in file: ${waitFile}`);
|
|
372
|
+
console.log('(polling every 2 seconds — write the code to the file to complete login)');
|
|
373
|
+
return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile);
|
|
374
|
+
}
|
|
375
|
+
return url;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000) {
|
|
379
|
+
const start = Date.now();
|
|
380
|
+
const pollInterval = 2000;
|
|
381
|
+
while (Date.now() - start < timeout) {
|
|
382
|
+
try {
|
|
383
|
+
const code = fs.readFileSync(filePath, 'utf-8').trim();
|
|
384
|
+
if (code) {
|
|
385
|
+
console.log(`\nAuthorization code found in ${filePath}`);
|
|
386
|
+
removePKCEState(instanceURL);
|
|
387
|
+
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
388
|
+
saveCredentials(instanceURL, newCreds);
|
|
389
|
+
console.log('Token exchange successful!\n');
|
|
390
|
+
return newCreds;
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// File not ready yet
|
|
394
|
+
}
|
|
395
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
396
|
+
}
|
|
397
|
+
throw errAuth(`Timed out waiting for authorization code in ${filePath} (${timeout / 1000}s)`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Authenticate with basic auth via environment variables.
|
|
402
|
+
* Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
|
|
403
|
+
* Stores the basic auth credentials so they persist across sessions.
|
|
404
|
+
*/
|
|
405
|
+
async loginWithPassword(instanceURL) {
|
|
406
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
407
|
+
const creds = getBasicAuthFromEnv(instanceURL);
|
|
408
|
+
if (!creds) {
|
|
409
|
+
throw errAuth(
|
|
410
|
+
`No basic auth credentials found in environment.\n\n` +
|
|
411
|
+
`Set the following environment variables:\n` +
|
|
412
|
+
` SN_USERNAME=admin (or SN_${envVarName(instanceURL)}_USERNAME)\n` +
|
|
413
|
+
` SN_PASSWORD=<password> (or SN_${envVarName(instanceURL)}_PASSWORD)\n\n` +
|
|
414
|
+
`Then run:\n` +
|
|
415
|
+
` jsn auth login --password ${instanceURL}`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
saveCredentials(instanceURL, creds);
|
|
419
|
+
console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
|
|
420
|
+
return creds;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Complete login using an authorization code obtained from a prior buildAuthURL() call.
|
|
425
|
+
* The PKCE state must have been saved by an earlier buildAuthURL() call.
|
|
426
|
+
*/
|
|
427
|
+
async loginWithCode(instanceURL, code) {
|
|
428
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
429
|
+
const clientID = getOAuthClientID();
|
|
430
|
+
const pkce = loadPKCEState(instanceURL);
|
|
431
|
+
if (!pkce) {
|
|
432
|
+
throw errAuth(
|
|
433
|
+
`No pending login session for ${instanceURL}.\n\n` +
|
|
434
|
+
'Run without --code first to generate one:\n' +
|
|
435
|
+
` jsn auth login ${instanceURL} --print-url\n\n` +
|
|
436
|
+
'This generates the PKCE challenge and saves it. Then call:\n' +
|
|
437
|
+
` jsn auth login ${instanceURL} --code CODE`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
removePKCEState(instanceURL);
|
|
441
|
+
|
|
442
|
+
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
443
|
+
saveCredentials(instanceURL, newCreds);
|
|
444
|
+
return newCreds;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async exchangeCode(instanceURL, clientID, code, pkce) {
|
|
448
|
+
const tokenURL = `${instanceURL.replace(/\/$/, '')}/oauth_token.do`;
|
|
449
|
+
const body = new URLSearchParams();
|
|
450
|
+
body.set('grant_type', 'authorization_code');
|
|
451
|
+
body.set('client_id', clientID);
|
|
452
|
+
body.set('code', code);
|
|
453
|
+
body.set('redirect_uri', REDIRECT_URI);
|
|
454
|
+
body.set('code_verifier', pkce.code_verifier);
|
|
455
|
+
|
|
456
|
+
const resp = await fetch(tokenURL, {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
459
|
+
body: body.toString(),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const text = await resp.text();
|
|
463
|
+
if (!resp.ok) {
|
|
464
|
+
throw errAuth(`Token exchange failed (status ${resp.status}): ${text}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const tokenResp = JSON.parse(text);
|
|
468
|
+
const expiresAt = tokenResp.expires_in ? Math.floor(Date.now() / 1000) + tokenResp.expires_in : 0;
|
|
469
|
+
return {
|
|
470
|
+
auth_method: 'oauth',
|
|
471
|
+
access_token: tokenResp.access_token,
|
|
472
|
+
refresh_token: tokenResp.refresh_token,
|
|
473
|
+
expires_at: expiresAt,
|
|
474
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async refreshToken(instance, creds) {
|
|
479
|
+
const tokenURL = `${instance.replace(/\/$/, '')}/oauth_token.do`;
|
|
480
|
+
const clientID = getOAuthClientID();
|
|
481
|
+
const body = new URLSearchParams();
|
|
482
|
+
body.set('grant_type', 'refresh_token');
|
|
483
|
+
body.set('client_id', clientID);
|
|
484
|
+
body.set('refresh_token', creds.refresh_token);
|
|
485
|
+
|
|
486
|
+
const resp = await fetch(tokenURL, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
489
|
+
body: body.toString(),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
if (!resp.ok) {
|
|
493
|
+
const text = await resp.text();
|
|
494
|
+
throw errAuth(`Token refresh failed: ${text}`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const tokenResp = await resp.json();
|
|
498
|
+
const newCreds = {
|
|
499
|
+
auth_method: 'oauth',
|
|
500
|
+
access_token: tokenResp.access_token,
|
|
501
|
+
refresh_token: tokenResp.refresh_token,
|
|
502
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
503
|
+
};
|
|
504
|
+
if (tokenResp.expires_in) {
|
|
505
|
+
newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
|
|
506
|
+
}
|
|
507
|
+
saveCredentials(instance, newCreds);
|
|
508
|
+
return newCreds;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
logout(instance) {
|
|
512
|
+
if (!instance) {
|
|
513
|
+
throw errAuth('No instance specified');
|
|
514
|
+
}
|
|
515
|
+
deleteCredentials(instance);
|
|
516
|
+
}
|
|
517
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Root CLI using yargs
|
|
2
|
+
|
|
3
|
+
import yargs from 'yargs';
|
|
4
|
+
import { hideBin } from 'yargs/helpers';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { loadConfig, getEffectiveInstance } from './config.js';
|
|
7
|
+
import { App } from './app.js';
|
|
8
|
+
import { renderHelp } from './help.js';
|
|
9
|
+
|
|
10
|
+
// Command modules
|
|
11
|
+
import { setupCmd } from './commands/setup.js';
|
|
12
|
+
import { authCmd } from './commands/auth.js';
|
|
13
|
+
import { profilesCmd } from './commands/profiles.js';
|
|
14
|
+
import { recordsCmd } from './commands/records.js';
|
|
15
|
+
import { catalogCmd } from './commands/catalog.js';
|
|
16
|
+
import { incidentsCmd } from './commands/incidents.js';
|
|
17
|
+
import { changesCmd } from './commands/changes.js';
|
|
18
|
+
import { requestsCmd } from './commands/requests.js';
|
|
19
|
+
import { tasksCmd } from './commands/tasks.js';
|
|
20
|
+
import { usersCmd } from './commands/users.js';
|
|
21
|
+
import { groupsCmd } from './commands/groups.js';
|
|
22
|
+
import { groupMembersCmd } from './commands/groupmembers.js';
|
|
23
|
+
import { groupRolesCmd } from './commands/grouproles.js';
|
|
24
|
+
import { ticketsCmd } from './commands/tickets.js';
|
|
25
|
+
import { versionCmd } from './commands/version.js';
|
|
26
|
+
import { devCmd } from './commands/dev.js';
|
|
27
|
+
import { skillCmd, checkSkill } from './commands/skill.js';
|
|
28
|
+
|
|
29
|
+
function wrap(handler) {
|
|
30
|
+
return async (argv) => {
|
|
31
|
+
try {
|
|
32
|
+
const app = argv.app;
|
|
33
|
+
if (!app) {
|
|
34
|
+
process.stderr.write('Error: App context not initialized.\n');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
await handler(argv, app);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const app = argv.app;
|
|
40
|
+
if (app) {
|
|
41
|
+
app.err(err);
|
|
42
|
+
} else {
|
|
43
|
+
process.stderr.write(`Error: ${err.message || err}\n`);
|
|
44
|
+
}
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const cli = yargs(hideBin(process.argv))
|
|
51
|
+
.scriptName('jsn')
|
|
52
|
+
.usage('Usage: $0 <command> [options]')
|
|
53
|
+
.option('instance', {
|
|
54
|
+
describe: 'ServiceNow instance URL (e.g., https://dev12345.service-now.com)',
|
|
55
|
+
type: 'string',
|
|
56
|
+
global: true,
|
|
57
|
+
})
|
|
58
|
+
.option('profile', {
|
|
59
|
+
alias: 'p',
|
|
60
|
+
describe: 'Configuration profile to use',
|
|
61
|
+
type: 'string',
|
|
62
|
+
global: true,
|
|
63
|
+
})
|
|
64
|
+
.option('format', {
|
|
65
|
+
describe: 'Output format: auto, json, markdown, styled, quiet',
|
|
66
|
+
type: 'string',
|
|
67
|
+
global: true,
|
|
68
|
+
})
|
|
69
|
+
.option('json', {
|
|
70
|
+
describe: 'Output in JSON format',
|
|
71
|
+
type: 'boolean',
|
|
72
|
+
global: true,
|
|
73
|
+
})
|
|
74
|
+
.option('quiet', {
|
|
75
|
+
alias: 'q',
|
|
76
|
+
describe: 'Output only data, no envelope',
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
global: true,
|
|
79
|
+
})
|
|
80
|
+
.option('styled', {
|
|
81
|
+
describe: 'Force styled output',
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
global: true,
|
|
84
|
+
})
|
|
85
|
+
.option('markdown', {
|
|
86
|
+
describe: 'Output in Markdown format',
|
|
87
|
+
type: 'boolean',
|
|
88
|
+
global: true,
|
|
89
|
+
})
|
|
90
|
+
.option('no-skill-check', {
|
|
91
|
+
describe: 'Skip the automatic skill version check',
|
|
92
|
+
type: 'boolean',
|
|
93
|
+
global: true,
|
|
94
|
+
})
|
|
95
|
+
.middleware(async (argv) => {
|
|
96
|
+
// Determine format from flags
|
|
97
|
+
let format = 'auto';
|
|
98
|
+
if (argv.json) format = 'json';
|
|
99
|
+
else if (argv.quiet) format = 'quiet';
|
|
100
|
+
else if (argv.styled) format = 'styled';
|
|
101
|
+
else if (argv.markdown) format = 'markdown';
|
|
102
|
+
else if (argv.format) format = argv.format;
|
|
103
|
+
|
|
104
|
+
const cfg = loadConfig({
|
|
105
|
+
instance: argv.instance,
|
|
106
|
+
profile: argv.profile,
|
|
107
|
+
format,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
argv.app = new App(cfg);
|
|
111
|
+
|
|
112
|
+
// Check auth for non-auth commands
|
|
113
|
+
const cmd = argv._[0];
|
|
114
|
+
const skipAuth = ['help', 'version', 'setup', 'auth', 'profiles', 'profile', 'skill', undefined].includes(cmd);
|
|
115
|
+
if (!skipAuth) {
|
|
116
|
+
const instance = getEffectiveInstance(cfg);
|
|
117
|
+
if (!argv.app.auth.isAuthenticated() && instance) {
|
|
118
|
+
process.stderr.write(`\n⚠️ Not authenticated to ${instance}\n\n`);
|
|
119
|
+
process.stderr.write('To get started, run:\n');
|
|
120
|
+
process.stderr.write(' jsn setup # Interactive setup\n');
|
|
121
|
+
process.stderr.write(` jsn auth login ${instance} # Login to instance\n\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Auto-check skill on every command (fire-and-forget, non-blocking)
|
|
126
|
+
const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
127
|
+
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
|
128
|
+
|| argv['no-skill-check'];
|
|
129
|
+
if (!skipSkillCheck) {
|
|
130
|
+
// Fire check but don't await — it runs in background
|
|
131
|
+
checkSkill().then(result => {
|
|
132
|
+
if (result && !result.current && result.error) {
|
|
133
|
+
process.stderr.write(`\n⚠ ${result.error}\n\n`);
|
|
134
|
+
}
|
|
135
|
+
}).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Print context header for interactive terminals (at the TOP, before command output)
|
|
139
|
+
if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
|
|
140
|
+
await argv.app.printContextHeader();
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.command(setupCmd(wrap))
|
|
144
|
+
.command(authCmd(wrap))
|
|
145
|
+
.command(profilesCmd(wrap))
|
|
146
|
+
.command(recordsCmd(wrap))
|
|
147
|
+
.command(catalogCmd(wrap))
|
|
148
|
+
.command(incidentsCmd(wrap))
|
|
149
|
+
.command(changesCmd(wrap))
|
|
150
|
+
.command(requestsCmd(wrap))
|
|
151
|
+
.command(tasksCmd(wrap))
|
|
152
|
+
.command(usersCmd(wrap))
|
|
153
|
+
.command(groupsCmd(wrap))
|
|
154
|
+
.command(groupMembersCmd(wrap))
|
|
155
|
+
.command(groupRolesCmd(wrap))
|
|
156
|
+
.command(ticketsCmd(wrap))
|
|
157
|
+
.command(devCmd(wrap))
|
|
158
|
+
.command(skillCmd(wrap))
|
|
159
|
+
.command(versionCmd(wrap))
|
|
160
|
+
.demandCommand(1, 'You must specify a command')
|
|
161
|
+
.help('help', 'Show help')
|
|
162
|
+
.version(false)
|
|
163
|
+
.strictCommands()
|
|
164
|
+
.strictOptions(false)
|
|
165
|
+
.fail((msg, err) => {
|
|
166
|
+
if (err) throw err;
|
|
167
|
+
// No command given → show custom grouped help instead of yargs error
|
|
168
|
+
if (msg === 'You must specify a command') {
|
|
169
|
+
process.stdout.write(renderHelp());
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|