@jacebenson/jsn 4.3.10-dev.67 → 4.3.11-dev.68
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 +2 -2
- 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.js +3 -1
- 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/setup.js +27 -25
- package/src/commands/version.js +16 -2
- package/src/helpers.js +1 -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.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/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
|
+
}
|
|
@@ -4,16 +4,6 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import net from 'node:net';
|
|
6
6
|
import process from 'node:process';
|
|
7
|
-
import {
|
|
8
|
-
getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists,
|
|
9
|
-
openDocsDb, closeDocsDb, getDocsIndexStats,
|
|
10
|
-
} from './db.js';
|
|
11
|
-
import { syncDocs } from './sync.js';
|
|
12
|
-
import { refreshDocs } from './refresh.js';
|
|
13
|
-
import { embedDocs } from './embed.js';
|
|
14
|
-
import { searchDocs, getDocByIdOrPath } from './search.js';
|
|
15
|
-
import { serveDocs } from './serve.js';
|
|
16
|
-
import { syncCommunity, listCommunityDocs } from './community.js';
|
|
17
7
|
import { declareCapabilities } from '../../capabilities.js';
|
|
18
8
|
import { formatCommandList } from '../../helpers.js';
|
|
19
9
|
|
|
@@ -39,6 +29,8 @@ export function docsCmd(wrap) {
|
|
|
39
29
|
.option('no-embed', { type: 'boolean', describe: 'Skip HRR semantic embeddings' })
|
|
40
30
|
.option('no-ingest', { type: 'boolean', describe: 'Clone/pull only; do not rebuild index' }),
|
|
41
31
|
handler: wrap(async (argv, app) => {
|
|
32
|
+
const { getDocsDbPath, getDocsSourceMarkdownDir } = await import('./db.js');
|
|
33
|
+
const { syncDocs } = await import('./sync.js');
|
|
42
34
|
const result = syncDocs({
|
|
43
35
|
markdownDir: getDocsSourceMarkdownDir(),
|
|
44
36
|
dbPath: getDocsDbPath(),
|
|
@@ -52,6 +44,7 @@ export function docsCmd(wrap) {
|
|
|
52
44
|
command: 'status',
|
|
53
45
|
describe: 'Show docs database status',
|
|
54
46
|
handler: wrap(async (argv, app) => {
|
|
47
|
+
const { getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists, withDocsDb, getDocsIndexStats } = await import('./db.js');
|
|
55
48
|
const dbPath = getDocsDbPath();
|
|
56
49
|
const repoDir = getDocsSourceDir();
|
|
57
50
|
const markdownDir = getDocsSourceMarkdownDir();
|
|
@@ -60,12 +53,10 @@ export function docsCmd(wrap) {
|
|
|
60
53
|
|
|
61
54
|
let state, embedded, documents, bundles, docTypes, syncedAt, dbSize;
|
|
62
55
|
if (dbExists) {
|
|
63
|
-
const
|
|
64
|
-
const stats = getDocsIndexStats(db);
|
|
56
|
+
const stats = withDocsDb({}, (db) => getDocsIndexStats(db));
|
|
65
57
|
({ documents, bundles, docTypes, syncedAt } = stats);
|
|
66
58
|
const stat = fs.statSync(dbPath);
|
|
67
59
|
dbSize = stat.size;
|
|
68
|
-
closeDocsDb(db);
|
|
69
60
|
|
|
70
61
|
embedded = stats.embeddedCount >= documents && documents > 0;
|
|
71
62
|
if (embedded) state = 'ready';
|
|
@@ -108,6 +99,7 @@ export function docsCmd(wrap) {
|
|
|
108
99
|
.option('doc-type', { type: 'string', describe: 'Filter by doc type' })
|
|
109
100
|
.option('mode', { type: 'string', choices: ['keyword', 'hybrid'], default: 'hybrid', describe: 'Search mode' }),
|
|
110
101
|
handler: wrap(async (argv, app) => {
|
|
102
|
+
const { searchDocs } = await import('./search.js');
|
|
111
103
|
const result = searchDocs({
|
|
112
104
|
query: argv.query,
|
|
113
105
|
limit: argv.limit,
|
|
@@ -154,6 +146,7 @@ export function docsCmd(wrap) {
|
|
|
154
146
|
.positional('id-or-path', { type: 'string', describe: 'Document id (number) or file path' })
|
|
155
147
|
.option('lines', { type: 'number', default: 80, describe: 'Lines of body to show in terminal (use --json for full content)' }),
|
|
156
148
|
handler: wrap(async (argv, app) => {
|
|
149
|
+
const { getDocByIdOrPath } = await import('./search.js');
|
|
157
150
|
const idOrPath = argv['id-or-path'];
|
|
158
151
|
const row = getDocByIdOrPath(idOrPath);
|
|
159
152
|
|
|
@@ -192,6 +185,7 @@ export function docsCmd(wrap) {
|
|
|
192
185
|
.option('host', { type: 'string', default: '127.0.0.1', describe: 'Bind address' })
|
|
193
186
|
.option('expose', { type: 'boolean', default: false, describe: 'Bind to 0.0.0.0 to expose on the network' }),
|
|
194
187
|
handler: wrap(async (argv, _app) => {
|
|
188
|
+
const { serveDocs } = await import('./serve.js');
|
|
195
189
|
try {
|
|
196
190
|
const host = argv.expose ? '0.0.0.0' : argv.host;
|
|
197
191
|
await serveDocs({ port: argv.port, host });
|
|
@@ -205,6 +199,7 @@ export function docsCmd(wrap) {
|
|
|
205
199
|
command: 'embed',
|
|
206
200
|
describe: 'Backfill or refresh HRR semantic embeddings',
|
|
207
201
|
handler: wrap(async (argv, app) => {
|
|
202
|
+
const { embedDocs } = await import('./embed.js');
|
|
208
203
|
const result = embedDocs();
|
|
209
204
|
app.ok(result, { summary: `Embedded ${result.embedded} of ${result.total} docs` });
|
|
210
205
|
}),
|
|
@@ -213,6 +208,8 @@ export function docsCmd(wrap) {
|
|
|
213
208
|
command: 'refresh',
|
|
214
209
|
describe: 'Incrementally refresh docs.db from existing source clone',
|
|
215
210
|
handler: wrap(async (argv, app) => {
|
|
211
|
+
const { getDocsSourceMarkdownDir } = await import('./db.js');
|
|
212
|
+
const { refreshDocs } = await import('./refresh.js');
|
|
216
213
|
const result = refreshDocs({ docsDir: getDocsSourceMarkdownDir() });
|
|
217
214
|
app.ok(result, { summary: `Refresh complete: +${result.added} ~${result.updated} -${result.removed} =${result.unchanged}` });
|
|
218
215
|
}),
|
|
@@ -230,6 +227,7 @@ export function docsCmd(wrap) {
|
|
|
230
227
|
.option('sources', { type: 'string', describe: 'Path to a community-sources.json (default: bundled)' })
|
|
231
228
|
.option('delay-ms', { type: 'number', describe: 'Delay between article fetches (default: 500)' }),
|
|
232
229
|
handler: wrap(async (argv, app) => {
|
|
230
|
+
const { syncCommunity } = await import('./community.js');
|
|
233
231
|
const result = await syncCommunity({
|
|
234
232
|
sourcesFile: argv.sources,
|
|
235
233
|
expand: argv.expand === true,
|
|
@@ -245,6 +243,7 @@ export function docsCmd(wrap) {
|
|
|
245
243
|
command: 'list',
|
|
246
244
|
describe: 'List community docs currently in the index',
|
|
247
245
|
handler: wrap(async (argv, app) => {
|
|
246
|
+
const { listCommunityDocs } = await import('./community.js');
|
|
248
247
|
const result = listCommunityDocs();
|
|
249
248
|
const lines = result.docs.map((d) => `[${d.id}] ${d.title || d.path} (${d.doc_type})`);
|
|
250
249
|
app.ok({
|
|
@@ -258,6 +257,7 @@ export function docsCmd(wrap) {
|
|
|
258
257
|
});
|
|
259
258
|
},
|
|
260
259
|
handler: wrap(async (argv, app) => {
|
|
260
|
+
const { getDocsDbPath } = await import('./db.js');
|
|
261
261
|
const subcommands = argv.subcommands || [];
|
|
262
262
|
const summary = 'jsn docs — ServiceNow documentation search (sync, status, search, show, serve).';
|
|
263
263
|
app.ok({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Populate HRR phase-vector embeddings for semantic reranking.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import {
|
|
4
|
+
import { withDocsDb, getDocsDbPath } from './db.js';
|
|
5
5
|
import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
|
|
6
6
|
|
|
7
7
|
export function embedDocs(opts = {}) {
|
|
@@ -11,36 +11,36 @@ export function embedDocs(opts = {}) {
|
|
|
11
11
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
14
|
+
return withDocsDb({ dbPath }, (db) => {
|
|
15
|
+
|
|
16
|
+
const cols = db.prepare('PRAGMA table_info(docs)').all();
|
|
17
|
+
if (!cols.some((c) => c.name === 'hrr_vector')) {
|
|
18
|
+
db.exec('ALTER TABLE docs ADD COLUMN hrr_vector BLOB');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
22
|
+
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('hrr_dim', ?)").run(String(DEFAULT_DIM));
|
|
23
|
+
|
|
24
|
+
const rows = db.prepare('SELECT id, title, body FROM docs WHERE hrr_vector IS NULL').all();
|
|
25
|
+
const total = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
|
|
26
|
+
|
|
27
|
+
const update = db.prepare('UPDATE docs SET hrr_vector = ? WHERE id = ?');
|
|
28
|
+
const started = Date.now();
|
|
29
|
+
let n = 0;
|
|
30
|
+
|
|
31
|
+
db.exec('BEGIN');
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
const surface = docSurface(row.title, row.body);
|
|
34
|
+
const vec = encodeText(surface, DEFAULT_DIM);
|
|
35
|
+
update.run(phasesToBytes(vec), row.id);
|
|
36
|
+
n++;
|
|
37
|
+
if (n % 5000 === 0) process.stderr.write(` ...${n}\n`);
|
|
38
|
+
}
|
|
39
|
+
db.exec('COMMIT');
|
|
40
|
+
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
41
|
+
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('embeddings', ?)").run('true');
|
|
42
|
+
|
|
43
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
44
|
+
return { embedded: n, total, secs, dbPath };
|
|
45
|
+
});
|
|
46
46
|
}
|