@jacebenson/jsn 4.3.11 → 4.3.12-dev.70
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/bin/jsn.js +7 -1
- package/package.json +3 -3
- package/src/app.js +10 -23
- package/src/auth/browser-session.js +21 -0
- package/src/auth/credential-state.js +124 -0
- package/src/auth/credential-store.js +177 -0
- package/src/auth/oauth.js +66 -0
- package/src/auth.js +11 -378
- package/src/cli-adapter.js +12 -4
- package/src/cli.js +3 -1
- package/src/commands/_generic.js +13 -9
- package/src/commands/_ticket.js +14 -11
- package/src/commands/docs/db.js +22 -2
- package/src/commands/docs/docs.js +13 -13
- package/src/commands/docs/embed.js +33 -33
- package/src/commands/docs/ingest.js +58 -58
- package/src/commands/docs/refresh.js +91 -91
- package/src/commands/docs/search.js +108 -108
- package/src/commands/docs/serve.js +4 -1
- package/src/commands/groups.js +11 -8
- package/src/commands/records.js +11 -16
- package/src/commands/requests.js +1 -1
- package/src/commands/setup.js +27 -25
- package/src/commands/tickets.js +14 -6
- package/src/commands/users.js +11 -8
- package/src/commands/version.js +16 -2
- package/src/frontmatter.js +5 -3
- package/src/helpers.js +45 -1
- package/src/paginated-search.js +162 -161
- package/src/prompts.js +15 -9
- package/src/sdk.js +79 -92
package/src/auth.js
CHANGED
|
@@ -5,76 +5,20 @@
|
|
|
5
5
|
// same instance have separate credential slots.
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import crypto from 'node:crypto';
|
|
10
|
-
import { execFileSync } from 'node:child_process';
|
|
11
8
|
import { password as passwordPrompt } from './prompts.js';
|
|
12
|
-
import {
|
|
9
|
+
import { normalizeInstanceURL } from './config.js';
|
|
13
10
|
import { errAuth } from './errors.js';
|
|
14
11
|
import { pendingBrowserLogin } from './browser-auth.js';
|
|
12
|
+
import { defaultCredentialStore } from './auth/credential-store.js';
|
|
13
|
+
import { savePKCEState, loadPKCEState, removePKCEState, getOAuthClientID, generatePKCE, buildAuthURL, REDIRECT_URI } from './auth/oauth.js';
|
|
14
|
+
import { parseBrowserSessionInput } from './auth/browser-session.js';
|
|
15
|
+
import { normalizeAuthMethod, normalizeAuthSource, sourceMethod, probeFailure, classifyProbeError, classifyCredentialState } from './auth/credential-state.js';
|
|
15
16
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
* Whitelist approach (issue #143 findings #2/#3): first map the `://`
|
|
22
|
-
* protocol separator to `_` exactly as the Go version's encoding does
|
|
23
|
-
* (so existing keyring keys like `https_dev437538.service-now.com` keep
|
|
24
|
-
* matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
|
|
25
|
-
* stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
|
|
26
|
-
* spaces, and backslashes — which broke out of double-quoted shell args
|
|
27
|
-
* in keyring calls and, on Windows, out of path.join() credential dirs.
|
|
28
|
-
*/
|
|
29
|
-
export function sanitizeKeyPart(instance) {
|
|
30
|
-
return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build a compound key for credential storage: <username>@<instance>
|
|
35
|
-
* When username is omitted (legacy), just the normalized instance URL is used.
|
|
36
|
-
*/
|
|
37
|
-
function credKey(instance, username) {
|
|
38
|
-
const normalized = sanitizeKeyPart(instance);
|
|
39
|
-
if (!username) return normalized;
|
|
40
|
-
return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ─── PKCE state persistence (shared with Go version) ───
|
|
44
|
-
|
|
45
|
-
function pkceStatePath(instance, username) {
|
|
46
|
-
const dir = path.join(globalConfigDir(), 'pkce');
|
|
47
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
-
// A known profile identity gets its own pending-login slot. Keep the bare
|
|
49
|
-
// instance slot for legacy/no-profile flows, where no identity exists yet.
|
|
50
|
-
const filename = credKey(instance, username) + '.json';
|
|
51
|
-
return path.join(dir, filename);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function savePKCEState(instance, pkce, username) {
|
|
55
|
-
const filePath = pkceStatePath(instance, username);
|
|
56
|
-
fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function loadPKCEState(instance, username) {
|
|
60
|
-
try {
|
|
61
|
-
const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
|
|
62
|
-
return JSON.parse(data);
|
|
63
|
-
} catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function removePKCEState(instance, username) {
|
|
69
|
-
try {
|
|
70
|
-
fs.unlinkSync(pkceStatePath(instance, username));
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore
|
|
73
|
-
}
|
|
74
|
-
}
|
|
17
|
+
// Preserve the public auth module API while implementation stays separated.
|
|
18
|
+
export { sanitizeKeyPart } from './auth/credential-store.js';
|
|
19
|
+
export { parseBrowserSessionInput } from './auth/browser-session.js';
|
|
20
|
+
export { classifyCredentialState } from './auth/credential-state.js';
|
|
75
21
|
|
|
76
|
-
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
77
|
-
const REDIRECT_URI = '/sdk-oauth.do';
|
|
78
22
|
|
|
79
23
|
// ─── Basic auth from environment variables ───
|
|
80
24
|
// SN_USERNAME / SN_PASSWORD — global credentials
|
|
@@ -110,322 +54,10 @@ function getBasicAuthFromEnv(instance) {
|
|
|
110
54
|
return null;
|
|
111
55
|
}
|
|
112
56
|
|
|
113
|
-
// Keychain constants — same as Go version (internal/auth/store.go)
|
|
114
|
-
const KEYRING_SERVICE = 'servicenow-cli';
|
|
115
|
-
const KEYRING_ATTR_SERVICE = 'service';
|
|
116
|
-
const KEYRING_ATTR_USERNAME = 'username';
|
|
117
|
-
|
|
118
|
-
function credentialsFilePath(key) {
|
|
119
|
-
const dir = path.join(globalConfigDir(), 'credentials');
|
|
120
|
-
// Match Go's filename encoding: replace :// and / and : with _
|
|
121
|
-
const filename = key + '.json';
|
|
122
|
-
return path.join(dir, filename);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function credentialsPath(key) {
|
|
126
|
-
const filePath = credentialsFilePath(key);
|
|
127
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
128
|
-
return filePath;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function getOAuthClientID() {
|
|
132
|
-
return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function generatePKCE() {
|
|
136
|
-
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
137
|
-
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
138
|
-
const state = crypto.randomBytes(16).toString('base64url');
|
|
139
|
-
return { code_verifier: verifier, code_challenge: challenge, state };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function buildAuthURL(instanceURL, clientID, pkce) {
|
|
143
|
-
const u = new URL('/oauth_auth.do', instanceURL);
|
|
144
|
-
u.searchParams.set('response_type', 'code');
|
|
145
|
-
u.searchParams.set('client_id', clientID);
|
|
146
|
-
u.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
147
|
-
u.searchParams.set('state', pkce.state);
|
|
148
|
-
u.searchParams.set('code_challenge', pkce.code_challenge);
|
|
149
|
-
u.searchParams.set('code_challenge_method', 'S256');
|
|
150
|
-
u.searchParams.set('scope', 'openid');
|
|
151
|
-
u.searchParams.set('approval_prompt', 'force');
|
|
152
|
-
return u.toString();
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
|
|
156
|
-
|
|
157
|
-
function keyringLookup(key) {
|
|
158
|
-
try {
|
|
159
|
-
// execFileSync with an arg array — no shell, so `key` can never
|
|
160
|
-
// break out into command injection (issue #143 finding #2).
|
|
161
|
-
const result = execFileSync(
|
|
162
|
-
'secret-tool',
|
|
163
|
-
['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
164
|
-
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
165
|
-
);
|
|
166
|
-
const trimmed = result.trim();
|
|
167
|
-
if (!trimmed) return null;
|
|
168
|
-
const parsed = JSON.parse(trimmed);
|
|
169
|
-
// Spread all fields from keyring first, then normalize known fields
|
|
170
|
-
// so username, password, and other extras are preserved.
|
|
171
|
-
return {
|
|
172
|
-
...parsed,
|
|
173
|
-
auth_method: parsed.auth_method || 'oauth',
|
|
174
|
-
access_token: parsed.access_token || parsed.AccessToken || '',
|
|
175
|
-
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
176
|
-
expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
|
|
177
|
-
created_at: parsed.created_at || parsed.CreatedAt || 0,
|
|
178
|
-
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(parsed.auth_source)
|
|
179
|
-
? (parsed.auth_method === 'gck' ? 'gck' : 'keyring')
|
|
180
|
-
: parsed.auth_source,
|
|
181
|
-
};
|
|
182
|
-
} catch {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function keyringStore(key, creds) {
|
|
188
|
-
try {
|
|
189
|
-
execFileSync(
|
|
190
|
-
'secret-tool',
|
|
191
|
-
['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
|
|
192
|
-
KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
193
|
-
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
194
|
-
);
|
|
195
|
-
return true;
|
|
196
|
-
} catch {
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function keyringDelete(key) {
|
|
202
|
-
try {
|
|
203
|
-
execFileSync(
|
|
204
|
-
'secret-tool',
|
|
205
|
-
['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
206
|
-
{ stdio: 'ignore' }
|
|
207
|
-
);
|
|
208
|
-
} catch {
|
|
209
|
-
// ignore
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ─── File-based storage (fallback) ───
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Load credentials for an instance keyed by <username>@<instance>.
|
|
217
|
-
* When username is omitted (legacy configs without profile user), uses
|
|
218
|
-
* bare instance key for lookup.
|
|
219
|
-
*/
|
|
220
|
-
function loadCredentials(instance, username, injectedStore) {
|
|
221
|
-
if (injectedStore) return injectedStore.load(instance, username);
|
|
222
|
-
const key = username ? credKey(instance, username) : credKey(instance);
|
|
223
|
-
const keyringCreds = keyringLookup(key);
|
|
224
|
-
if (keyringCreds) {
|
|
225
|
-
return {
|
|
226
|
-
...keyringCreds,
|
|
227
|
-
auth_source: keyringCreds.auth_source || (keyringCreds.auth_method === 'gck' ? 'gck' : 'keyring'),
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
const data = fs.readFileSync(credentialsFilePath(key), 'utf-8');
|
|
232
|
-
const creds = JSON.parse(data);
|
|
233
|
-
return {
|
|
234
|
-
...creds,
|
|
235
|
-
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(creds.auth_source)
|
|
236
|
-
? (creds.auth_method === 'gck' ? 'gck' : 'file')
|
|
237
|
-
: creds.auth_source,
|
|
238
|
-
};
|
|
239
|
-
} catch {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Save credentials for an instance.
|
|
246
|
-
* Uses <username>@<instance> key when username is available.
|
|
247
|
-
*/
|
|
248
|
-
function saveCredentials(instance, creds, username, injectedStore) {
|
|
249
|
-
if (injectedStore) {
|
|
250
|
-
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
251
|
-
return injectedStore.save(instance, creds, username);
|
|
252
|
-
}
|
|
253
|
-
const key = username ? credKey(instance, username) : credKey(instance);
|
|
254
|
-
// Stamp last_seen on every credential save
|
|
255
|
-
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
256
|
-
// Try keyring first, fall back to file
|
|
257
|
-
if (!keyringStore(key, creds)) {
|
|
258
|
-
fs.writeFileSync(credentialsPath(key), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Delete credentials for an instance keyed by <username>@<instance>.
|
|
264
|
-
*/
|
|
265
|
-
function deleteCredentials(instance, username, injectedStore) {
|
|
266
|
-
if (injectedStore) return injectedStore.delete(instance, username);
|
|
267
|
-
const key = username ? credKey(instance, username) : credKey(instance);
|
|
268
|
-
keyringDelete(key);
|
|
269
|
-
try {
|
|
270
|
-
fs.unlinkSync(credentialsPath(key));
|
|
271
|
-
} catch {
|
|
272
|
-
// ignore
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const defaultCredentialStore = {
|
|
277
|
-
load: loadCredentials,
|
|
278
|
-
save: saveCredentials,
|
|
279
|
-
delete: deleteCredentials,
|
|
280
|
-
};
|
|
281
|
-
|
|
282
57
|
function askHidden(promptText) {
|
|
283
58
|
return passwordPrompt({ message: promptText });
|
|
284
59
|
}
|
|
285
60
|
|
|
286
|
-
/**
|
|
287
|
-
* Extract the browser session token and cookies from pasted DevTools headers
|
|
288
|
-
* or a "Copy as cURL" command. Only these two values are retained.
|
|
289
|
-
*/
|
|
290
|
-
export function parseBrowserSessionInput(input) {
|
|
291
|
-
const text = String(input || '');
|
|
292
|
-
const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
|
|
293
|
-
const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
|
|
294
|
-
const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
|
|
295
|
-
const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
|
|
296
|
-
const token = tokenMatch?.[1]?.trim() || '';
|
|
297
|
-
const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
|
|
298
|
-
.trim()
|
|
299
|
-
.replace(/['"`]\s*(?:\\\s*)?$/, '');
|
|
300
|
-
if (!token) throw errAuth('No X-UserToken header found');
|
|
301
|
-
if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
|
|
302
|
-
return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const AUTH_METHODS = new Set(['oauth', 'basic', 'gck']);
|
|
306
|
-
const AUTH_SOURCES = new Set(['env_token', 'env_basic', 'gck', 'keyring', 'file', 'unavailable']);
|
|
307
|
-
|
|
308
|
-
function normalizeAuthMethod(value, fallback = 'unconfigured') {
|
|
309
|
-
return AUTH_METHODS.has(value) ? value : fallback;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function normalizeAuthSource(value, fallback = 'unavailable') {
|
|
313
|
-
return AUTH_SOURCES.has(value) ? value : fallback;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function sourceMethod(source) {
|
|
317
|
-
return { env_token: 'oauth', env_basic: 'basic', gck: 'gck' }[source] || null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const PROBE_FAILURES = {
|
|
321
|
-
missing_credentials: {
|
|
322
|
-
message: 'Credentials are not available for this profile.',
|
|
323
|
-
hint: 'Run: jsn auth login',
|
|
324
|
-
},
|
|
325
|
-
credentials_expired: {
|
|
326
|
-
message: 'Credentials have expired and cannot be refreshed.',
|
|
327
|
-
hint: 'Run: jsn auth login',
|
|
328
|
-
},
|
|
329
|
-
refresh_required: {
|
|
330
|
-
message: 'Credentials require refresh before probing.',
|
|
331
|
-
hint: 'Run: jsn auth refresh',
|
|
332
|
-
},
|
|
333
|
-
refresh_failed: {
|
|
334
|
-
message: 'Credential refresh failed before the authenticated probe.',
|
|
335
|
-
hint: 'Run: jsn auth login',
|
|
336
|
-
},
|
|
337
|
-
invalid_browser_session: {
|
|
338
|
-
message: 'Browser session credentials are incomplete or invalid.',
|
|
339
|
-
hint: 'Capture a fresh ServiceNow browser request and run: jsn auth login --gck',
|
|
340
|
-
},
|
|
341
|
-
malformed_credentials: {
|
|
342
|
-
message: 'Credentials are malformed for the selected authentication method.',
|
|
343
|
-
hint: 'Run: jsn auth login',
|
|
344
|
-
},
|
|
345
|
-
permission_denied: {
|
|
346
|
-
message: 'The authenticated user lacks permission for the current-user probe.',
|
|
347
|
-
hint: 'Ask an administrator to grant access to the sys_user table/API.',
|
|
348
|
-
},
|
|
349
|
-
unauthorized: {
|
|
350
|
-
message: 'The instance rejected the selected credentials.',
|
|
351
|
-
hint: 'Run: jsn auth login',
|
|
352
|
-
},
|
|
353
|
-
network: {
|
|
354
|
-
message: 'The authenticated probe could not reach the instance.',
|
|
355
|
-
hint: 'Check your internet connection and instance URL.',
|
|
356
|
-
},
|
|
357
|
-
unknown: {
|
|
358
|
-
message: 'The authenticated probe failed for an unknown reason.',
|
|
359
|
-
hint: 'Retry the probe; if it persists, check the instance logs.',
|
|
360
|
-
},
|
|
361
|
-
missing_instance: {
|
|
362
|
-
message: 'No instance is configured for this profile.',
|
|
363
|
-
hint: 'Set an instance URL and run: jsn auth login',
|
|
364
|
-
},
|
|
365
|
-
sdk_construction_failed: {
|
|
366
|
-
message: 'The authenticated probe could not be initialized for this profile.',
|
|
367
|
-
hint: 'Check the profile configuration and run: jsn auth status again',
|
|
368
|
-
},
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
const PROBE_CLASSIFICATIONS = {
|
|
372
|
-
missing_credentials: 'unavailable', credentials_expired: 'expired', refresh_required: 'refresh_required',
|
|
373
|
-
refresh_failed: 'refresh_failed', invalid_browser_session: 'browser_session_invalid',
|
|
374
|
-
malformed_credentials: 'malformed', permission_denied: 'permission_denied', unauthorized: 'unauthorized',
|
|
375
|
-
network: 'network_error', unknown: 'unknown',
|
|
376
|
-
missing_instance: 'unavailable', sdk_construction_failed: 'configuration_error',
|
|
377
|
-
};
|
|
378
|
-
|
|
379
|
-
function probeFailure(code, status = 'failed') {
|
|
380
|
-
return { status, code, classification: PROBE_CLASSIFICATIONS[code] || 'unknown', ...PROBE_FAILURES[code] };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function classifyProbeError(error, method) {
|
|
384
|
-
if (error?.status === 401 || error?.status === 403) {
|
|
385
|
-
return error.status === 403 ? 'permission_denied' : 'unauthorized';
|
|
386
|
-
}
|
|
387
|
-
if (error?.code === 'network_error' || ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'].includes(error?.code)) {
|
|
388
|
-
return 'network';
|
|
389
|
-
}
|
|
390
|
-
const text = String(error?.message || '').toLowerCase();
|
|
391
|
-
if (error?.code === 'auth_error' && text.includes('refresh')) return 'refresh_failed';
|
|
392
|
-
if (method === 'gck' && error?.code === 'auth_error') return 'invalid_browser_session';
|
|
393
|
-
return 'unknown';
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Classify credentials without returning or logging any credential material.
|
|
398
|
-
* This is deliberately pure so diagnostics cannot refresh, persist, or probe.
|
|
399
|
-
*/
|
|
400
|
-
export function classifyCredentialState(credentials, method, now = Date.now()) {
|
|
401
|
-
if (!credentials) return 'missing';
|
|
402
|
-
if (!AUTH_METHODS.has(method)) return 'malformed';
|
|
403
|
-
|
|
404
|
-
if (method === 'basic') {
|
|
405
|
-
const hasUser = typeof credentials.username === 'string' && credentials.username.length > 0;
|
|
406
|
-
const hasPassword = typeof credentials.password === 'string' && credentials.password.length > 0;
|
|
407
|
-
return hasUser && hasPassword ? 'available' : 'malformed';
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (method === 'gck') {
|
|
411
|
-
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
412
|
-
const hasCookies = typeof credentials.cookies === 'string' && credentials.cookies.length > 0;
|
|
413
|
-
return hasToken && hasCookies ? 'available' : 'malformed';
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (credentials.access_token !== undefined && typeof credentials.access_token !== 'string') return 'malformed';
|
|
417
|
-
if (credentials.refresh_token !== undefined && typeof credentials.refresh_token !== 'string') return 'malformed';
|
|
418
|
-
|
|
419
|
-
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
420
|
-
if (!hasToken) return credentials.refresh_token ? 'refreshable' : 'missing';
|
|
421
|
-
if (credentials.expires_at !== undefined && credentials.expires_at !== null && credentials.expires_at !== 0) {
|
|
422
|
-
const expiresAt = Number(credentials.expires_at);
|
|
423
|
-
if (!Number.isFinite(expiresAt)) return 'malformed';
|
|
424
|
-
if (now >= (expiresAt - 300) * 1000) return credentials.refresh_token ? 'refreshable' : 'expired';
|
|
425
|
-
}
|
|
426
|
-
return 'available';
|
|
427
|
-
}
|
|
428
|
-
|
|
429
61
|
export class AuthManager {
|
|
430
62
|
/**
|
|
431
63
|
* @param {object} identity — the resolved-identity provider. Production
|
|
@@ -1002,4 +634,5 @@ export class AuthManager {
|
|
|
1002
634
|
}
|
|
1003
635
|
}
|
|
1004
636
|
|
|
1005
|
-
export { saveCredentials, loadCredentials, deleteCredentials
|
|
637
|
+
export { saveCredentials, loadCredentials, deleteCredentials } from './auth/credential-store.js';
|
|
638
|
+
export { askHidden };
|
package/src/cli-adapter.js
CHANGED
|
@@ -3,6 +3,14 @@ import process from 'node:process';
|
|
|
3
3
|
|
|
4
4
|
const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
5
5
|
const names = (x) => typeof x === 'string' ? [x] : (x || []);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A repeatable option may be declared either way: `type: 'array'` (this
|
|
9
|
+
* adapter's historic spelling) or `array: true` (the yargs spelling most
|
|
10
|
+
* contributors reach for). Both must accumulate; honoring only the first
|
|
11
|
+
* silently kept the LAST value of `-f a=1 -f b=2` and dropped the rest.
|
|
12
|
+
*/
|
|
13
|
+
const isArrayOption = (spec) => spec?.type === 'array' || spec?.array === true;
|
|
6
14
|
const commandName = (command) => command.trim().split(/\s+/)[0];
|
|
7
15
|
const positionSpec = (command) => [...command.matchAll(/([<[])([^>\]]+)[>\]]/g)].map((m) => ({ name: m[2], required: m[1] === '<' }));
|
|
8
16
|
|
|
@@ -189,7 +197,7 @@ export class LocalCLI {
|
|
|
189
197
|
_assign(argv, key, value, spec, opts = {}) {
|
|
190
198
|
const validate = opts.validate !== false;
|
|
191
199
|
const actual = spec?.name || key;
|
|
192
|
-
const values = spec
|
|
200
|
+
const values = isArrayOption(spec) ? (Array.isArray(value) ? value : [value]) : [value];
|
|
193
201
|
if (validate && spec?.choices && values.some((item) => !spec.choices.includes(item))) {
|
|
194
202
|
this._fail(`Invalid value for --${actual}: ${value}. Choose from: ${spec.choices.join(', ')}`);
|
|
195
203
|
return false;
|
|
@@ -201,7 +209,7 @@ export class LocalCLI {
|
|
|
201
209
|
const converted = spec?.type === 'boolean'
|
|
202
210
|
? (value === 'true' ? true : value === 'false' ? false : value)
|
|
203
211
|
: spec?.type === 'number' ? Number(value) : value;
|
|
204
|
-
if (spec
|
|
212
|
+
if (isArrayOption(spec)) argv[actual] = [...(argv[actual] || []), ...values];
|
|
205
213
|
else argv[actual] = converted;
|
|
206
214
|
argv[camel(actual)] = argv[actual];
|
|
207
215
|
for (const alias of names(spec?.alias)) argv[alias] = argv[actual];
|
|
@@ -210,8 +218,8 @@ export class LocalCLI {
|
|
|
210
218
|
const options = this._optionConfig(path);
|
|
211
219
|
const defs = {};
|
|
212
220
|
for (const [key, spec] of options) defs[key] = {
|
|
213
|
-
type: spec.type === 'number' || spec
|
|
214
|
-
multiple: spec
|
|
221
|
+
type: spec.type === 'number' || isArrayOption(spec) ? 'string' : (spec.type || 'boolean'),
|
|
222
|
+
multiple: isArrayOption(spec),
|
|
215
223
|
};
|
|
216
224
|
args = args.map((arg) => {
|
|
217
225
|
if (/^-[^-]=/.test(arg)) return `--${arg.slice(1)}`;
|
package/src/cli.js
CHANGED
|
@@ -303,7 +303,7 @@ export function buildCLI() {
|
|
|
303
303
|
|
|
304
304
|
if (shouldCheck) {
|
|
305
305
|
const version = getVersion();
|
|
306
|
-
checkLatest().then(latest => {
|
|
306
|
+
checkLatest({ background: true }).then(latest => {
|
|
307
307
|
if (latest && version !== latest) {
|
|
308
308
|
process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
|
|
309
309
|
}
|
|
@@ -584,3 +584,5 @@ export function buildCLI() {
|
|
|
584
584
|
|
|
585
585
|
// Run if invoked directly (bin/jsn.js calls cli.parse())
|
|
586
586
|
export const cli = buildCLI();
|
|
587
|
+
|
|
588
|
+
export { cancelBackgroundChecks } from './commands/version.js';
|
package/src/commands/_generic.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generic table-based CRUD command builder
|
|
2
2
|
|
|
3
|
-
import { formatRecordForDisplay, getStringField,
|
|
3
|
+
import { formatRecordForDisplay, getStringField, buildRecordPayload, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
|
|
4
4
|
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
5
5
|
import { getCurrentUser, getCurrentApplication } from '../context.js';
|
|
6
6
|
import { FormatAuto } from '../output.js';
|
|
@@ -108,7 +108,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
108
108
|
if (!readOnly) {
|
|
109
109
|
crumbs.unshift(
|
|
110
110
|
{ action: 'delete', cmd: `jsn ${name} delete ${id}`, description: `Delete this ${singular}` },
|
|
111
|
-
{ action: 'update', cmd: `jsn ${name} update ${id}
|
|
111
|
+
{ action: 'update', cmd: `jsn ${name} update ${id} -f name=value`, description: `Update this ${singular}` }
|
|
112
112
|
);
|
|
113
113
|
}
|
|
114
114
|
return crumbs;
|
|
@@ -152,7 +152,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
152
152
|
app.ok(record, {
|
|
153
153
|
summary: `${singular.charAt(0).toUpperCase() + singular.slice(1)}: ${recordName}`,
|
|
154
154
|
breadcrumbs: [
|
|
155
|
-
...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName}
|
|
155
|
+
...(readOnly ? [] : [{ action: 'update', cmd: `jsn ${name} update ${recordName} -f name=value`, description: `Update this ${singular}` }]),
|
|
156
156
|
{ action: 'list', cmd: `jsn ${name} list`, description: `Back to all ${name}` },
|
|
157
157
|
],
|
|
158
158
|
});
|
|
@@ -215,10 +215,12 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
215
215
|
command: 'create',
|
|
216
216
|
describe: `Create a new ${singular}`,
|
|
217
217
|
builder: (y) => y
|
|
218
|
-
.option('data', { type: 'string', describe: 'JSON fields
|
|
219
|
-
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
218
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
219
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
220
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
221
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
220
222
|
handler: wrap(async (argv, app) => {
|
|
221
|
-
const recordData =
|
|
223
|
+
const recordData = buildRecordPayload(argv);
|
|
222
224
|
const record = await app.sdk.create(table, recordData);
|
|
223
225
|
app.ok(record, {
|
|
224
226
|
summary: `Created ${singular}`,
|
|
@@ -232,8 +234,10 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
232
234
|
command: 'update <identifier>',
|
|
233
235
|
describe: `Update ${vowelArticle(singular)} ${singular}`,
|
|
234
236
|
builder: (y) => y
|
|
235
|
-
.option('data', { type: 'string', describe: 'JSON fields
|
|
236
|
-
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
237
|
+
.option('data', { type: 'string', describe: 'JSON fields' })
|
|
238
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
239
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
240
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
237
241
|
handler: wrap(async (argv, app) => {
|
|
238
242
|
const id = argv.identifier;
|
|
239
243
|
const record = await resolveRecord(app.sdk, { table, identifier: id, matchField: 'name', resource: singular });
|
|
@@ -242,7 +246,7 @@ export function buildDevCmd(name, table, aliases, defaultColumns, wrap, opts = {
|
|
|
242
246
|
}
|
|
243
247
|
|
|
244
248
|
const sysID = unwrapSysId(record);
|
|
245
|
-
const recordData =
|
|
249
|
+
const recordData = buildRecordPayload(argv);
|
|
246
250
|
const updated = await app.sdk.update(table, sysID, recordData);
|
|
247
251
|
app.ok(updated, { summary: `Updated ${singular} ${id}` });
|
|
248
252
|
}),
|
package/src/commands/_ticket.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Generic command builder for CRUD operations on a ServiceNow table
|
|
2
2
|
// Used by incidents, changes, requests, tasks, and most dev subcommands
|
|
3
3
|
|
|
4
|
-
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt } from '../helpers.js';
|
|
4
|
+
import { getStringField, formatRecordForDisplay, buildQuerySuffix, resolveFieldsParam, confirmDelete, interactiveList, canPrompt, buildRecordPayload } from '../helpers.js';
|
|
5
5
|
import { resolveRecord, resolveSysId } from '../resolve-record.js';
|
|
6
6
|
import { FormatAuto } from '../output.js';
|
|
7
7
|
import { declareCapabilities } from '../capabilities.js';
|
|
@@ -73,7 +73,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
73
73
|
app.ok(record, {
|
|
74
74
|
summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
|
|
75
75
|
breadcrumbs: [
|
|
76
|
-
{ action: 'update', cmd: `jsn ${alias} update ${number}
|
|
76
|
+
{ action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
|
|
77
77
|
{ action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
|
|
78
78
|
],
|
|
79
79
|
});
|
|
@@ -139,7 +139,7 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
139
139
|
app.ok(record, {
|
|
140
140
|
summary: `${displayName.charAt(0).toUpperCase() + displayName.slice(1)} ${number}`,
|
|
141
141
|
breadcrumbs: [
|
|
142
|
-
{ action: 'update', cmd: `jsn ${alias} update ${number}
|
|
142
|
+
{ action: 'update', cmd: `jsn ${alias} update ${number} -f state=2`, description: `Update this ${displayName}` },
|
|
143
143
|
{ action: 'list', cmd: `jsn ${alias} list`, description: `Back to all ${table}` },
|
|
144
144
|
],
|
|
145
145
|
});
|
|
@@ -151,16 +151,16 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
151
151
|
builder: (y) => y
|
|
152
152
|
.option('description', { alias: 'd', type: 'string', describe: 'Short description' })
|
|
153
153
|
.option('priority', { type: 'string', describe: 'Priority (1-5)' })
|
|
154
|
-
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
154
|
+
.option('data', { type: 'string', describe: 'JSON data for additional fields' })
|
|
155
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
156
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
157
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
155
158
|
handler: wrap(async (argv, app) => {
|
|
156
|
-
const recordData = {};
|
|
157
|
-
if (argv.data) {
|
|
158
|
-
Object.assign(recordData, JSON.parse(argv.data));
|
|
159
|
-
}
|
|
159
|
+
const recordData = buildRecordPayload(argv, { required: false });
|
|
160
160
|
if (argv.description) recordData.short_description = argv.description;
|
|
161
161
|
if (argv.priority) recordData.priority = argv.priority;
|
|
162
162
|
if (!recordData.short_description) {
|
|
163
|
-
throw new Error('short_description is required (use --description or --data)');
|
|
163
|
+
throw new Error('short_description is required (use --description, -f short_description=..., or --data)');
|
|
164
164
|
}
|
|
165
165
|
const record = await app.sdk.create(table, recordData);
|
|
166
166
|
app.ok(record, {
|
|
@@ -175,10 +175,13 @@ export function buildTicketCommands(table, displayName, alias, defaultColumns, s
|
|
|
175
175
|
command: 'update <number>',
|
|
176
176
|
describe: `Update a ${displayName}`,
|
|
177
177
|
builder: (y) => y
|
|
178
|
-
.option('data', { type: 'string',
|
|
178
|
+
.option('data', { type: 'string', describe: 'JSON data to update' })
|
|
179
|
+
.option('data-file', { type: 'string', describe: 'Read JSON payload from file' })
|
|
180
|
+
.option('data-stdin', { type: 'boolean', describe: 'Read JSON payload from stdin' })
|
|
181
|
+
.option('field', { alias: 'f', type: 'string', array: true, describe: 'Set a field as name=value; repeatable' }),
|
|
179
182
|
handler: wrap(async (argv, app) => {
|
|
180
183
|
const number = argv.number;
|
|
181
|
-
const recordData =
|
|
184
|
+
const recordData = buildRecordPayload(argv);
|
|
182
185
|
const sysID = await resolveSysId(app.sdk, { table, identifier: number, matchField: 'number', resource: displayName });
|
|
183
186
|
const updated = await app.sdk.update(table, sysID, recordData);
|
|
184
187
|
app.ok(updated, {
|
package/src/commands/docs/db.js
CHANGED
|
@@ -132,8 +132,13 @@ export function openDocsDb(opts = {}) {
|
|
|
132
132
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
133
133
|
ensureDocsCacheDir();
|
|
134
134
|
const db = new Database(dbPath);
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
try {
|
|
136
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
137
|
+
return db;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
closeDocsDb(db);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
export function closeDocsDb(db) {
|
|
@@ -246,3 +251,18 @@ export function getDocsIndexStats(db) {
|
|
|
246
251
|
export function setMeta(db, key, value) {
|
|
247
252
|
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
|
|
248
253
|
}
|
|
254
|
+
|
|
255
|
+
/** Own a synchronous docs connection and roll back unfinished work on failure. */
|
|
256
|
+
export function withDocsDb(opts, operation) {
|
|
257
|
+
const db = openDocsDb(opts);
|
|
258
|
+
try {
|
|
259
|
+
return operation(db);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (db.inTransaction) {
|
|
262
|
+
try { db.exec('ROLLBACK'); } catch { /* Preserve the original error. */ }
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
} finally {
|
|
266
|
+
closeDocsDb(db);
|
|
267
|
+
}
|
|
268
|
+
}
|