@jacebenson/jsn 3.1.5 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/jsn.js +14 -2
- package/package.json +6 -4
- package/skills/servicenow/SKILL.md +31 -3
- package/src/app.js +15 -1
- package/src/auth.js +34 -3
- package/src/cli.js +59 -11
- package/src/commands/docs/db.js +140 -0
- package/src/commands/docs/docs.js +166 -0
- package/src/commands/docs/embed.js +46 -0
- package/src/commands/docs/hrr.js +95 -0
- package/src/commands/docs/ingest.js +95 -0
- package/src/commands/docs/public/index.html +178 -0
- package/src/commands/docs/public/vendor/marked.min.js +69 -0
- package/src/commands/docs/public/vendor/purify.min.js +3 -0
- package/src/commands/docs/refresh.js +135 -0
- package/src/commands/docs/search.js +139 -0
- package/src/commands/docs/serve.js +278 -0
- package/src/commands/docs/sync.js +75 -0
- package/src/commands/skill.js +203 -27
- package/src/commands/version.js +2 -2
- package/suppress-warnings.cjs +13 -0
package/bin/jsn.js
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Suppress the node:sqlite experimental warning on Node 22.x.
|
|
4
|
+
// SQLite is stable in Node 24+. The patch must be applied before any
|
|
5
|
+
// module imports node:sqlite, so we use a dynamic import after patching.
|
|
6
|
+
const originalEmitWarning = process.emitWarning;
|
|
7
|
+
process.emitWarning = function emitWarning(warning, ...args) {
|
|
8
|
+
const message = typeof warning === 'string'
|
|
9
|
+
? warning
|
|
10
|
+
: (warning?.message || '');
|
|
11
|
+
if (message.includes('SQLite')) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
return originalEmitWarning.apply(this, [warning, ...args]);
|
|
15
|
+
};
|
|
4
16
|
|
|
5
|
-
cli.parse();
|
|
17
|
+
import('../src/cli.js').then((m) => m.cli.parse());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "src/cli.js",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --test $(find test -name '*.test.js')",
|
|
11
|
+
"test": "NODE_OPTIONS=--require=./suppress-warnings.cjs node --test $(find test -name '*.test.js')",
|
|
12
12
|
"lint": "npx eslint src/ bin/ test/",
|
|
13
13
|
"postinstall": "node scripts/postinstall.mjs",
|
|
14
|
-
"start": "node bin/jsn.js",
|
|
14
|
+
"start": "NODE_OPTIONS=--require=./suppress-warnings.cjs node bin/jsn.js",
|
|
15
15
|
"tag-reminder": "node scripts/tag-reminder.js",
|
|
16
16
|
"release": "node scripts/release.js"
|
|
17
17
|
},
|
|
@@ -24,13 +24,14 @@
|
|
|
24
24
|
"author": "Jace Benson",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
27
|
+
"node": ">=22.5.0"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@inquirer/core": "^10.1.0",
|
|
31
31
|
"@inquirer/prompts": "^7.5.1",
|
|
32
32
|
"chalk": "^5.4.1",
|
|
33
33
|
"cli-table3": "^0.6.5",
|
|
34
|
+
"gray-matter": "^4.0.3",
|
|
34
35
|
"yargs": "^17.7.2"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
},
|
|
41
42
|
"files": [
|
|
42
43
|
"bin/jsn.js",
|
|
44
|
+
"suppress-warnings.cjs",
|
|
43
45
|
"src/",
|
|
44
46
|
"skills/",
|
|
45
47
|
"scripts/",
|
|
@@ -54,9 +54,10 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
54
54
|
|
|
55
55
|
1. **Specific commands** — `jsn flows`, `jsn rules`, `jsn catalogitems`, etc.
|
|
56
56
|
2. **`jsn records --table <name>`** — generic CRUD on any table
|
|
57
|
-
3. **`jsn
|
|
58
|
-
4. **`jsn
|
|
59
|
-
5.
|
|
57
|
+
3. **`jsn docs search <query>`** — offline ServiceNow documentation search (no instance needed)
|
|
58
|
+
4. **`jsn rest`** — raw Table API escape hatch
|
|
59
|
+
5. **`jsn eval`** — ⚠️ **LAST RESORT ONLY**
|
|
60
|
+
6. **Ask the human** — if none of the above work
|
|
60
61
|
|
|
61
62
|
## Key Commands
|
|
62
63
|
|
|
@@ -69,6 +70,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
69
70
|
| **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
|
|
70
71
|
| **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
|
|
71
72
|
| **Config** | `setup`, `auth`, `profiles`, `updatesets`, `scopes` |
|
|
73
|
+
| **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
|
|
72
74
|
| **Developer** | `eval`, `rest`, `skill`, `version` |
|
|
73
75
|
|
|
74
76
|
### `jsn rest` examples
|
|
@@ -84,6 +86,32 @@ jsn rest --table incident --sys-id abc123... --json
|
|
|
84
86
|
jsn rest "/api/now/table/incident?sysparm_limit=3" --json
|
|
85
87
|
```
|
|
86
88
|
|
|
89
|
+
### `jsn docs` — offline documentation search
|
|
90
|
+
|
|
91
|
+
No ServiceNow instance required. Searches the full ServiceNow docs locally with FTS5 + semantic hybrid search.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# First run: download docs and build the index (~45k files, ~3-5 min)
|
|
95
|
+
jsn docs sync
|
|
96
|
+
|
|
97
|
+
# Check state
|
|
98
|
+
jsn docs status
|
|
99
|
+
|
|
100
|
+
# Search (keyword + semantic hybrid)
|
|
101
|
+
jsn docs search "GlideRecord query" --limit 5 --json
|
|
102
|
+
|
|
103
|
+
# Filter by bundle
|
|
104
|
+
jsn docs search "REST API" --bundle it-service-management --json
|
|
105
|
+
|
|
106
|
+
# Start the web UI
|
|
107
|
+
jsn docs serve
|
|
108
|
+
|
|
109
|
+
# Share on your network
|
|
110
|
+
jsn docs serve --expose
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
|
|
114
|
+
|
|
87
115
|
## Troubleshooting Field Behavior
|
|
88
116
|
|
|
89
117
|
Check order (priority):
|
package/src/app.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { AuthManager } from './auth.js';
|
|
4
4
|
import { SDKClient } from './sdk.js';
|
|
5
5
|
import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled } from './output.js';
|
|
6
|
-
import { getEffectiveInstance } from './config.js';
|
|
6
|
+
import { getEffectiveInstance, normalizeInstanceURL } from './config.js';
|
|
7
7
|
import { extractProfileName } from './helpers.js';
|
|
8
8
|
import { getCurrentUser, getCurrentApplication, getCurrentUpdateSet } from './context.js';
|
|
9
9
|
import { errUsage, errAuth } from './errors.js';
|
|
@@ -55,6 +55,8 @@ export class App {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
getEffectiveInstance() {
|
|
58
|
+
// Command-line override (--instance, --profile) takes precedence
|
|
59
|
+
if (this._overrideInstance) return this._overrideInstance;
|
|
58
60
|
return getEffectiveInstance(this.config);
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -191,6 +193,18 @@ export class App {
|
|
|
191
193
|
throw errAuth('Not authenticated');
|
|
192
194
|
}
|
|
193
195
|
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Override the effective instance for this command run.
|
|
199
|
+
* Rebuilds the SDK client for the new instance.
|
|
200
|
+
*/
|
|
201
|
+
setEffectiveInstance(url) {
|
|
202
|
+
const normalizedUrl = normalizeInstanceURL(url);
|
|
203
|
+
if (normalizedUrl) {
|
|
204
|
+
this._overrideInstance = normalizedUrl;
|
|
205
|
+
this.sdk = new SDKClient(normalizedUrl, this.auth);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
194
208
|
}
|
|
195
209
|
|
|
196
210
|
function resolveFormat(fmt) {
|
package/src/auth.js
CHANGED
|
@@ -58,6 +58,24 @@ function removePKCEState(instance) {
|
|
|
58
58
|
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
59
59
|
const REDIRECT_URI = '/sdk-oauth.do';
|
|
60
60
|
|
|
61
|
+
// ─── WSL detection ───
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect if running under Windows Subsystem for Linux.
|
|
65
|
+
* Reads /proc/version which contains "Microsoft" or "WSL" on WSL.
|
|
66
|
+
*/
|
|
67
|
+
let _cachedIsWSL = null;
|
|
68
|
+
async function isWSLEnv() {
|
|
69
|
+
if (_cachedIsWSL !== null) return _cachedIsWSL;
|
|
70
|
+
try {
|
|
71
|
+
const data = fs.readFileSync('/proc/version', 'utf-8');
|
|
72
|
+
_cachedIsWSL = /microsoft|wsl/i.test(data);
|
|
73
|
+
} catch {
|
|
74
|
+
_cachedIsWSL = false;
|
|
75
|
+
}
|
|
76
|
+
return _cachedIsWSL;
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
// ─── Basic auth from environment variables ───
|
|
62
80
|
// SN_USERNAME / SN_PASSWORD — global credentials
|
|
63
81
|
// SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
|
|
@@ -386,10 +404,23 @@ export class AuthManager {
|
|
|
386
404
|
args = [authURL];
|
|
387
405
|
} else if (platform === 'win32') {
|
|
388
406
|
cmd = 'cmd';
|
|
389
|
-
|
|
407
|
+
// Windows: cmd.exe interprets & as a command separator, which truncates
|
|
408
|
+
// the URL after the first parameter. Passing an empty string as the first
|
|
409
|
+
// arg to `start` serves as the window title, and spawn auto-quotes the
|
|
410
|
+
// URL arg, protecting & and other special chars from cmd.exe's parser.
|
|
411
|
+
args = ['/c', 'start', '', authURL];
|
|
390
412
|
} else {
|
|
391
|
-
|
|
392
|
-
|
|
413
|
+
// WSL detection: check if we're running under Windows Subsystem for Linux
|
|
414
|
+
// On WSL, xdg-open delegates to cmd.exe /C start <url>, which truncates
|
|
415
|
+
// on &. We call cmd.exe directly with proper quoting instead.
|
|
416
|
+
const isWSL = platform === 'linux' && (await isWSLEnv());
|
|
417
|
+
if (isWSL) {
|
|
418
|
+
cmd = 'cmd.exe';
|
|
419
|
+
args = ['/c', 'start', '', authURL];
|
|
420
|
+
} else {
|
|
421
|
+
cmd = 'xdg-open';
|
|
422
|
+
args = [authURL];
|
|
423
|
+
}
|
|
393
424
|
}
|
|
394
425
|
const child = open(cmd, args, { detached: true, stdio: 'ignore' });
|
|
395
426
|
child.on('error', () => {
|
package/src/cli.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import yargs from 'yargs';
|
|
4
4
|
import { hideBin } from 'yargs/helpers';
|
|
5
5
|
import process from 'node:process';
|
|
6
|
-
import
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { loadConfig, getEffectiveInstance, globalConfigDir } from './config.js';
|
|
7
9
|
import { App } from './app.js';
|
|
8
10
|
import { renderHelp } from './help.js';
|
|
9
11
|
import { isMutationCommand } from './mutations.js';
|
|
@@ -26,6 +28,8 @@ import { ticketsCmd } from './commands/tickets.js';
|
|
|
26
28
|
import { versionCmd } from './commands/version.js';
|
|
27
29
|
import { devCmd } from './commands/dev.js';
|
|
28
30
|
import { skillCmd, checkSkill } from './commands/skill.js';
|
|
31
|
+
import { getVersion, checkLatest } from './commands/version.js';
|
|
32
|
+
import { docsCmd } from './commands/docs/docs.js';
|
|
29
33
|
|
|
30
34
|
// Dev subcommands promoted to root level for progressive disclosure
|
|
31
35
|
import {
|
|
@@ -153,19 +157,35 @@ export function buildCLI() {
|
|
|
153
157
|
if (argv.format) {
|
|
154
158
|
app.output.setFormat(argv.format);
|
|
155
159
|
}
|
|
160
|
+
// --profile: resolve profile name to its instance URL
|
|
161
|
+
// --instance wins if both are specified (explicit URL > profile reference)
|
|
162
|
+
if (argv.profile) {
|
|
163
|
+
const profile = (cfg.profiles || {})[argv.profile];
|
|
164
|
+
if (!profile) {
|
|
165
|
+
process.stderr.write(`Error: Unknown profile "${argv.profile}"\n`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const url = profile.instance_url;
|
|
169
|
+
app._overrideInstance = url;
|
|
170
|
+
argv._overrideInstance = url;
|
|
171
|
+
// Temporarily switch active profile so auth picks up the right user
|
|
172
|
+
cfg.activeProfile = argv.profile;
|
|
173
|
+
}
|
|
156
174
|
if (argv.instance) {
|
|
157
175
|
app._overrideInstance = argv.instance;
|
|
176
|
+
argv._overrideInstance = argv.instance;
|
|
158
177
|
}
|
|
159
178
|
},
|
|
160
179
|
// Guard mutation commands when no instance configured
|
|
161
180
|
(argv) => {
|
|
162
181
|
const cmd = (argv._[0] || '').toString();
|
|
163
|
-
if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill'].includes(cmd)) {
|
|
182
|
+
if (!cmd || ['help', 'version', 'completion', 'setup', 'auth', 'profiles', 'skill', 'docs'].includes(cmd)) {
|
|
164
183
|
return;
|
|
165
184
|
}
|
|
166
185
|
// Check for --instance override
|
|
167
186
|
if (argv.instance) {
|
|
168
187
|
app._overrideInstance = argv.instance;
|
|
188
|
+
argv._overrideInstance = argv.instance;
|
|
169
189
|
}
|
|
170
190
|
// Determine if this is a mutation
|
|
171
191
|
const subcommand = (argv._[1] || '').toString();
|
|
@@ -183,22 +203,49 @@ export function buildCLI() {
|
|
|
183
203
|
|
|
184
204
|
const cmd = (argv._[0] || '').toString();
|
|
185
205
|
|
|
206
|
+
// Daily npm version check (fire-and-forget, non-blocking)
|
|
207
|
+
// Checks npm for newer jsn releases, at most once per 24 hours.
|
|
208
|
+
const skipNpmCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
|
|
209
|
+
|| process.env.JSN_NO_VERSION_CHECK === '1'
|
|
210
|
+
|| argv.json
|
|
211
|
+
|| argv.quiet;
|
|
212
|
+
|
|
186
213
|
// Auto-check skill on every command (fire-and-forget, non-blocking)
|
|
187
|
-
const skipSkillCheck = ['help', 'version', 'completion', 'skill'].includes(cmd)
|
|
214
|
+
const skipSkillCheck = ['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)
|
|
188
215
|
|| process.env.JSN_NO_SKILL_CHECK === '1'
|
|
189
216
|
|| argv['no-skill-check']
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
217
|
+
if (!skipNpmCheck) {
|
|
218
|
+
const cacheFile = path.join(globalConfigDir(), '.last-npm-check');
|
|
219
|
+
let shouldCheck = true;
|
|
220
|
+
try {
|
|
221
|
+
const mtime = fs.statSync(cacheFile).mtimeMs;
|
|
222
|
+
const elapsed = Date.now() - mtime;
|
|
223
|
+
// Only check if >= 24 hours since last check
|
|
224
|
+
if (elapsed < 86400000) shouldCheck = false;
|
|
225
|
+
} catch {
|
|
226
|
+
// File doesn't exist — check
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (shouldCheck) {
|
|
230
|
+
const version = getVersion();
|
|
231
|
+
checkLatest().then(latest => {
|
|
232
|
+
if (latest && version !== latest) {
|
|
233
|
+
process.stderr.write(`\n⚠ jsn ${latest} available (you have ${version}) — run "npm install -g @jacebenson/jsn" to update\n\n`);
|
|
234
|
+
}
|
|
235
|
+
}).catch(() => {});
|
|
236
|
+
|
|
237
|
+
// Stamp the cache file (write async, don't block)
|
|
238
|
+
try {
|
|
239
|
+
fs.mkdirSync(globalConfigDir(), { recursive: true });
|
|
240
|
+
fs.writeFileSync(cacheFile, String(Date.now()), 'utf-8');
|
|
241
|
+
} catch {
|
|
242
|
+
// Non-fatal
|
|
196
243
|
}
|
|
197
|
-
}
|
|
244
|
+
}
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
// Print context header for interactive terminals
|
|
201
|
-
if (!['help', 'version', 'completion', 'skill'].includes(cmd)) {
|
|
248
|
+
if (!['help', 'version', 'completion', 'skill', 'docs'].includes(cmd)) {
|
|
202
249
|
await argv.app.printContextHeader(argv);
|
|
203
250
|
}
|
|
204
251
|
})
|
|
@@ -281,6 +328,7 @@ export function buildCLI() {
|
|
|
281
328
|
.command(evalCmd(wrap))
|
|
282
329
|
.command(restCmd(wrap))
|
|
283
330
|
.command(skillCmd(wrap))
|
|
331
|
+
.command(docsCmd(wrap))
|
|
284
332
|
.command(versionCmd(wrap))
|
|
285
333
|
// Legacy
|
|
286
334
|
.command(devCmd(wrap))
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Docs DB path resolution and schema helpers.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
|
|
8
|
+
const DOCS_DIR_NAME = 'servicenow-cli/docs';
|
|
9
|
+
|
|
10
|
+
function xdgCacheHome() {
|
|
11
|
+
if (process.env.XDG_CACHE_HOME) return process.env.XDG_CACHE_HOME;
|
|
12
|
+
return path.join(os.homedir(), '.cache');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getDocsCacheDir() {
|
|
16
|
+
return path.join(xdgCacheHome(), DOCS_DIR_NAME);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDocsDbPath() {
|
|
20
|
+
return path.join(getDocsCacheDir(), 'docs.db');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getDocsSourceDir() {
|
|
24
|
+
return path.join(getDocsCacheDir(), 'source');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDocsSourceMarkdownDir() {
|
|
28
|
+
return path.join(getDocsSourceDir(), 'markdown');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureDocsCacheDir() {
|
|
32
|
+
const dir = getDocsCacheDir();
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openDocsDb(opts = {}) {
|
|
38
|
+
const dbPath = opts.dbPath || getDocsDbPath();
|
|
39
|
+
ensureDocsCacheDir();
|
|
40
|
+
const db = new DatabaseSync(dbPath);
|
|
41
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function closeDocsDb(db) {
|
|
46
|
+
try {
|
|
47
|
+
db.close();
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function docsDbExists() {
|
|
54
|
+
return fs.existsSync(getDocsDbPath());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getSchemaVersion(db) {
|
|
58
|
+
try {
|
|
59
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
|
|
60
|
+
return row ? parseInt(row.value, 10) : 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function setSchemaVersion(db, version) {
|
|
67
|
+
db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
68
|
+
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('schema_version', ?)").run(String(version));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function initDocsSchema(db) {
|
|
72
|
+
db.exec(`
|
|
73
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
74
|
+
id INTEGER PRIMARY KEY,
|
|
75
|
+
path TEXT NOT NULL UNIQUE,
|
|
76
|
+
title TEXT,
|
|
77
|
+
release TEXT,
|
|
78
|
+
bundle TEXT,
|
|
79
|
+
doc_type TEXT,
|
|
80
|
+
locale TEXT,
|
|
81
|
+
frontmatter TEXT,
|
|
82
|
+
body TEXT,
|
|
83
|
+
size INTEGER,
|
|
84
|
+
mtime INTEGER,
|
|
85
|
+
hash TEXT,
|
|
86
|
+
hrr_vector BLOB
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
90
|
+
title, body,
|
|
91
|
+
content='docs',
|
|
92
|
+
content_rowid='id',
|
|
93
|
+
tokenize='porter unicode61'
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
|
|
97
|
+
INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
|
|
98
|
+
END;
|
|
99
|
+
CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
|
|
100
|
+
INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
|
|
101
|
+
END;
|
|
102
|
+
CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
|
|
103
|
+
INSERT INTO docs_fts(docs_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
|
|
104
|
+
INSERT INTO docs_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
|
|
105
|
+
END;
|
|
106
|
+
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_docs_bundle ON docs(bundle);
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_docs_doc_type ON docs(doc_type);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_docs_release ON docs(release);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
112
|
+
key TEXT PRIMARY KEY,
|
|
113
|
+
value TEXT
|
|
114
|
+
);
|
|
115
|
+
`);
|
|
116
|
+
setSchemaVersion(db, 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function hasEmbeddings(db) {
|
|
120
|
+
try {
|
|
121
|
+
const cols = db.prepare('PRAGMA table_info(docs)').all();
|
|
122
|
+
if (!cols.some((c) => c.name === 'hrr_vector')) return false;
|
|
123
|
+
return db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n > 0;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getMeta(db, key, defaultValue = null) {
|
|
130
|
+
try {
|
|
131
|
+
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
132
|
+
return row ? row.value : defaultValue;
|
|
133
|
+
} catch {
|
|
134
|
+
return defaultValue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function setMeta(db, key, value) {
|
|
139
|
+
db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES(?, ?)').run(key, value);
|
|
140
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// jsn docs — ServiceNow documentation search.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
import {
|
|
8
|
+
getDocsDbPath, getDocsSourceDir, getDocsSourceMarkdownDir, docsDbExists,
|
|
9
|
+
openDocsDb, closeDocsDb, getMeta,
|
|
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 } from './search.js';
|
|
15
|
+
import { serveDocs } from './serve.js';
|
|
16
|
+
|
|
17
|
+
function formatBytes(n) {
|
|
18
|
+
if (n == null) return '-';
|
|
19
|
+
if (n < 1024) return `${n} B`;
|
|
20
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
21
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function docsCmd(wrap) {
|
|
25
|
+
return {
|
|
26
|
+
command: 'docs [subcommand]',
|
|
27
|
+
describe: 'Search ServiceNow documentation locally (sync, status, search, serve)',
|
|
28
|
+
builder: (yargs) => {
|
|
29
|
+
return yargs
|
|
30
|
+
.command({
|
|
31
|
+
command: 'sync',
|
|
32
|
+
describe: 'Clone/update ServiceNowDocs and build the docs.db index',
|
|
33
|
+
builder: (y) => y
|
|
34
|
+
.option('no-embed', { type: 'boolean', describe: 'Skip HRR semantic embeddings' })
|
|
35
|
+
.option('no-ingest', { type: 'boolean', describe: 'Clone/pull only; do not rebuild index' }),
|
|
36
|
+
handler: wrap(async (argv, app) => {
|
|
37
|
+
const result = syncDocs({
|
|
38
|
+
markdownDir: getDocsSourceMarkdownDir(),
|
|
39
|
+
dbPath: getDocsDbPath(),
|
|
40
|
+
embed: argv['no-embed'] !== true,
|
|
41
|
+
noIngest: argv['no-ingest'] === true,
|
|
42
|
+
});
|
|
43
|
+
app.ok(result, { summary: 'Docs sync complete' });
|
|
44
|
+
}),
|
|
45
|
+
})
|
|
46
|
+
.command({
|
|
47
|
+
command: 'status',
|
|
48
|
+
describe: 'Show docs database status',
|
|
49
|
+
handler: wrap(async (argv, app) => {
|
|
50
|
+
const dbPath = getDocsDbPath();
|
|
51
|
+
const repoDir = getDocsSourceDir();
|
|
52
|
+
const markdownDir = getDocsSourceMarkdownDir();
|
|
53
|
+
const downloaded = fs.existsSync(path.join(repoDir, '.git')) && fs.existsSync(markdownDir);
|
|
54
|
+
const dbExists = docsDbExists();
|
|
55
|
+
|
|
56
|
+
let state, embedded, documents, bundles, docTypes, syncedAt, dbSize;
|
|
57
|
+
if (dbExists) {
|
|
58
|
+
const db = openDocsDb();
|
|
59
|
+
documents = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
|
|
60
|
+
bundles = db.prepare('SELECT COUNT(DISTINCT bundle) AS n FROM docs').get().n;
|
|
61
|
+
docTypes = db.prepare('SELECT COUNT(DISTINCT doc_type) AS n FROM docs').get().n;
|
|
62
|
+
const embCount = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n;
|
|
63
|
+
syncedAt = getMeta(db, 'synced_at');
|
|
64
|
+
const stat = fs.statSync(dbPath);
|
|
65
|
+
dbSize = stat.size;
|
|
66
|
+
closeDocsDb(db);
|
|
67
|
+
|
|
68
|
+
embedded = embCount >= documents && documents > 0;
|
|
69
|
+
if (embedded) state = 'ready';
|
|
70
|
+
else if (documents > 0) state = 'indexed (not embedded)';
|
|
71
|
+
else state = 'empty';
|
|
72
|
+
} else {
|
|
73
|
+
state = downloaded ? 'not indexed' : 'not downloaded';
|
|
74
|
+
embedded = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Quick probe to see if the server is running on the default port.
|
|
78
|
+
const served = await new Promise((resolve) => {
|
|
79
|
+
const sock = new net.Socket();
|
|
80
|
+
sock.setTimeout(500);
|
|
81
|
+
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
|
82
|
+
sock.on('error', () => resolve(false));
|
|
83
|
+
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
|
84
|
+
sock.connect(3000, '127.0.0.1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.ok({
|
|
88
|
+
state,
|
|
89
|
+
downloaded: { ok: downloaded, path: repoDir },
|
|
90
|
+
db: { ok: dbExists, path: dbPath },
|
|
91
|
+
embedded: { ok: embedded },
|
|
92
|
+
...(documents != null && { documents, bundles, docTypes, syncedAt: syncedAt ? new Date(parseInt(syncedAt, 10)).toISOString() : null }),
|
|
93
|
+
...(dbSize != null && { dbSizeHuman: formatBytes(dbSize) }),
|
|
94
|
+
...(served && { served: 'http://127.0.0.1:3000' }),
|
|
95
|
+
}, { summary: `Docs database: ${state}` });
|
|
96
|
+
}),
|
|
97
|
+
})
|
|
98
|
+
.command({
|
|
99
|
+
command: 'search <query>',
|
|
100
|
+
describe: 'Search docs (FTS5 + optional HRR hybrid rerank)',
|
|
101
|
+
builder: (y) => y
|
|
102
|
+
.positional('query', { type: 'string', describe: 'Search query' })
|
|
103
|
+
.option('limit', { type: 'number', default: 20, describe: 'Max results' })
|
|
104
|
+
.option('offset', { type: 'number', default: 0, describe: 'Result offset' })
|
|
105
|
+
.option('bundle', { type: 'string', describe: 'Filter by bundle' })
|
|
106
|
+
.option('doc-type', { type: 'string', describe: 'Filter by doc type' })
|
|
107
|
+
.option('mode', { type: 'string', choices: ['keyword', 'hybrid'], default: 'hybrid', describe: 'Search mode' }),
|
|
108
|
+
handler: wrap(async (argv, app) => {
|
|
109
|
+
const result = searchDocs({
|
|
110
|
+
query: argv.query,
|
|
111
|
+
limit: argv.limit,
|
|
112
|
+
offset: argv.offset,
|
|
113
|
+
bundle: argv.bundle,
|
|
114
|
+
docType: argv['doc-type'],
|
|
115
|
+
mode: argv.mode,
|
|
116
|
+
});
|
|
117
|
+
app.ok(result, { summary: `${result.count} result(s) for "${result.query}" (${result.mode})` });
|
|
118
|
+
}),
|
|
119
|
+
})
|
|
120
|
+
.command({
|
|
121
|
+
command: 'serve',
|
|
122
|
+
describe: 'Start the docs search web UI',
|
|
123
|
+
builder: (y) => y
|
|
124
|
+
.option('port', { type: 'number', default: 3000, describe: 'HTTP port' })
|
|
125
|
+
.option('host', { type: 'string', default: '127.0.0.1', describe: 'Bind address' })
|
|
126
|
+
.option('expose', { type: 'boolean', default: false, describe: 'Bind to 0.0.0.0 to expose on the network' }),
|
|
127
|
+
handler: wrap(async (argv, _app) => {
|
|
128
|
+
try {
|
|
129
|
+
const host = argv.expose ? '0.0.0.0' : argv.host;
|
|
130
|
+
await serveDocs({ port: argv.port, host });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
})
|
|
137
|
+
.command({
|
|
138
|
+
command: 'embed',
|
|
139
|
+
describe: 'Backfill or refresh HRR semantic embeddings',
|
|
140
|
+
handler: wrap(async (argv, app) => {
|
|
141
|
+
const result = embedDocs();
|
|
142
|
+
app.ok(result, { summary: `Embedded ${result.embedded} of ${result.total} docs` });
|
|
143
|
+
}),
|
|
144
|
+
})
|
|
145
|
+
.command({
|
|
146
|
+
command: 'refresh',
|
|
147
|
+
describe: 'Incrementally refresh docs.db from existing source clone',
|
|
148
|
+
handler: wrap(async (argv, app) => {
|
|
149
|
+
const result = refreshDocs({ docsDir: getDocsSourceMarkdownDir() });
|
|
150
|
+
app.ok(result, { summary: `Refresh complete: +${result.added} ~${result.updated} -${result.removed} =${result.unchanged}` });
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
handler: () => {
|
|
155
|
+
process.stdout.write('jsn docs — ServiceNow documentation search\n\n');
|
|
156
|
+
process.stdout.write('Available subcommands:\n');
|
|
157
|
+
process.stdout.write(' sync Download docs and build a searchable index (handles updates too)\n');
|
|
158
|
+
process.stdout.write(' status Show whether docs are downloaded, indexed, embedded, or served\n');
|
|
159
|
+
process.stdout.write(' search Full-text + semantic search across the local docs index\n');
|
|
160
|
+
process.stdout.write(' serve Start a local web UI for browsing and searching docs\n');
|
|
161
|
+
process.stdout.write('\n');
|
|
162
|
+
process.stdout.write('Tip: use "jsn docs serve --expose" to bind to 0.0.0.0 and share on your network.\n');
|
|
163
|
+
process.stdout.write('\nRun "jsn docs <command> --help" for details.\n');
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|