@jacebenson/jsn 4.3.13-dev.86.1 → 4.3.13-dev.87.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/docs/db.js +9 -1
- package/src/commands/docs/docs.js +25 -2
- package/src/commands/docs/ingest.js +20 -3
- package/src/commands/docs/install.js +86 -0
- package/src/commands/docs/locks.js +34 -0
- package/src/commands/docs/refresh.js +22 -2
- package/src/commands/docs/replace.js +30 -0
- package/src/commands/docs/snapshot-check.js +11 -0
- package/src/commands/docs/snapshot.js +130 -0
- package/src/commands/docs/sync.js +16 -6
- package/src/commands/setup.js +17 -1
package/package.json
CHANGED
package/src/commands/docs/db.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import Database from 'better-sqlite3';
|
|
7
|
+
import { docsReaderLease } from './locks.js';
|
|
7
8
|
|
|
8
9
|
const LEGACY_DOCS_DIR_NAME = 'servicenow-cli/docs';
|
|
9
10
|
const DOCS_DIR_NAME = 'docs';
|
|
@@ -131,7 +132,14 @@ export function defaultDocsRoots(opts = {}) {
|
|
|
131
132
|
export function openDocsDb(opts = {}) {
|
|
132
133
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
133
134
|
ensureDocsCacheDir();
|
|
134
|
-
|
|
135
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
136
|
+
const release = docsReaderLease(dbPath);
|
|
137
|
+
let db;
|
|
138
|
+
try {
|
|
139
|
+
db = new Database(dbPath);
|
|
140
|
+
} catch (error) { release(); throw error; }
|
|
141
|
+
const close = db.close.bind(db);
|
|
142
|
+
db.close = () => { try { return close(); } finally { release(); } };
|
|
135
143
|
try {
|
|
136
144
|
db.exec('PRAGMA journal_mode = WAL');
|
|
137
145
|
return db;
|
|
@@ -19,9 +19,24 @@ function formatBytes(n) {
|
|
|
19
19
|
export function docsCmd(wrap) {
|
|
20
20
|
return {
|
|
21
21
|
command: 'docs [subcommand]',
|
|
22
|
-
describe: 'Search ServiceNow documentation locally (sync, status, search, serve)',
|
|
22
|
+
describe: 'Search ServiceNow documentation locally (install, sync, status, search, serve)',
|
|
23
23
|
builder: (yargs) => {
|
|
24
24
|
return yargs
|
|
25
|
+
.command({ command: 'install', describe: 'Install optional docs: raw GitHub files or a verified prebuilt searchable database',
|
|
26
|
+
builder: y => y.option('source', { type: 'string', choices: ['snapshot', 'github', 'skip'], describe: 'Explicit source; raw GitHub estimated ~5 minutes; snapshot estimate comes from manifest' })
|
|
27
|
+
.option('manifest-url', { type: 'string', describe: 'HTTPS snapshot manifest mirror' }),
|
|
28
|
+
handler: wrap(async (argv, app) => {
|
|
29
|
+
const { installDocumentation, offerDocumentation } = await import('./install.js');
|
|
30
|
+
const { canUsePicker } = await import('../../helpers.js');
|
|
31
|
+
const result = argv.source ? await installDocumentation(argv.source, { manifestURL: argv['manifest-url'] })
|
|
32
|
+
: canUsePicker(app, argv) ? await offerDocumentation(app, argv) : await installDocumentation(undefined);
|
|
33
|
+
app.ok(result, { summary: result.skipped ? 'Documentation skipped' : 'Documentation installed' });
|
|
34
|
+
}),
|
|
35
|
+
})
|
|
36
|
+
.command({ command: 'rollback', describe: 'Restore the previously installed local docs database', handler: wrap(async (_argv, app) => {
|
|
37
|
+
const { rollbackSnapshot } = await import('./snapshot.js');
|
|
38
|
+
app.ok(rollbackSnapshot(), { summary: 'Previous documentation index restored' });
|
|
39
|
+
}) })
|
|
25
40
|
.command({
|
|
26
41
|
command: 'sync',
|
|
27
42
|
describe: 'Clone/update ServiceNowDocs and build the docs.db index',
|
|
@@ -77,8 +92,14 @@ export function docsCmd(wrap) {
|
|
|
77
92
|
sock.connect(3000, '127.0.0.1');
|
|
78
93
|
});
|
|
79
94
|
|
|
95
|
+
const { docsSnapshotStatus } = await import('./snapshot.js');
|
|
96
|
+
const snapshot = docsSnapshotStatus();
|
|
97
|
+
let available = null;
|
|
98
|
+
try { available = JSON.parse(fs.readFileSync(`${dbPath}.available.json`, 'utf8')); } catch { /* No background update information. */ }
|
|
80
99
|
app.ok({
|
|
81
100
|
state,
|
|
101
|
+
...(snapshot ? { snapshot: { revision: snapshot.revision, schema: snapshot.schema } } : {}),
|
|
102
|
+
...(snapshot && available?.installed === snapshot.revision && available.revision !== snapshot.revision ? { updateAvailable: { revision: available.revision, command: 'jsn docs install --source snapshot' } } : {}),
|
|
82
103
|
downloaded: { ok: downloaded, path: repoDir },
|
|
83
104
|
db: { ok: dbExists, path: dbPath },
|
|
84
105
|
embedded: { ok: embedded },
|
|
@@ -99,6 +120,8 @@ export function docsCmd(wrap) {
|
|
|
99
120
|
.option('doc-type', { type: 'string', describe: 'Filter by doc type' })
|
|
100
121
|
.option('mode', { type: 'string', choices: ['keyword', 'hybrid'], default: 'hybrid', describe: 'Search mode' }),
|
|
101
122
|
handler: wrap(async (argv, app) => {
|
|
123
|
+
const { ensureDocumentation } = await import('./install.js');
|
|
124
|
+
await ensureDocumentation(app, argv);
|
|
102
125
|
const { searchDocs } = await import('./search.js');
|
|
103
126
|
const result = searchDocs({
|
|
104
127
|
query: argv.query,
|
|
@@ -259,7 +282,7 @@ export function docsCmd(wrap) {
|
|
|
259
282
|
handler: wrap(async (argv, app) => {
|
|
260
283
|
const { getDocsDbPath } = await import('./db.js');
|
|
261
284
|
const subcommands = argv.subcommands || [];
|
|
262
|
-
const summary = 'jsn docs —
|
|
285
|
+
const summary = 'jsn docs — optional local documentation (install, sync, status, search, show, serve, rollback).';
|
|
263
286
|
app.ok({
|
|
264
287
|
command: 'docs',
|
|
265
288
|
subcommands,
|
|
@@ -1,18 +1,35 @@
|
|
|
1
1
|
// Ingest ServiceNow markdown docs into a SQLite database with FTS5 full-text search.
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { docsReaderLease } from './locks.js';
|
|
7
|
+
import { installDatabase } from './replace.js';
|
|
4
8
|
import { createHash } from 'node:crypto';
|
|
5
9
|
import { parseFrontmatter } from '../../frontmatter.js';
|
|
6
|
-
import { withDocsDb, initDocsSchema, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
|
|
10
|
+
import { withDocsDb, initDocsSchema, getDocsDbPath, walkDocs, defaultDocsRoots, getDocsSourceDir } from './db.js';
|
|
7
11
|
import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
|
|
8
12
|
|
|
9
13
|
export function ingestDocs(opts = {}) {
|
|
14
|
+
const dbPath = opts.dbPath || getDocsDbPath();
|
|
15
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
16
|
+
const staging = `${dbPath}.${randomUUID()}.building`;
|
|
17
|
+
const resource = opts.sourceResource || (!opts.roots ? getDocsSourceDir() : null);
|
|
18
|
+
if (resource) fs.mkdirSync(path.dirname(resource), { recursive: true });
|
|
19
|
+
const releaseSource = resource && !opts.sourceLeaseHeld ? docsReaderLease(resource) : () => {};
|
|
20
|
+
try {
|
|
21
|
+
const result = ingestStagedDocs({ ...opts, dbPath: staging });
|
|
22
|
+
if (result.errors) throw new Error(`Documentation ingestion failed for ${result.errors} files; existing index retained`);
|
|
23
|
+
installDatabase(staging, dbPath, { afterInstall: () => fs.rmSync(`${dbPath}.snapshot.json`, { force: true }) });
|
|
24
|
+
return { ...result, dbPath };
|
|
25
|
+
} finally { releaseSource(); fs.rmSync(staging, { force: true }); }
|
|
26
|
+
}
|
|
27
|
+
function ingestStagedDocs(opts = {}) {
|
|
10
28
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
11
29
|
const embed = opts.embed !== false;
|
|
12
30
|
const roots = opts.roots || defaultDocsRoots(opts);
|
|
13
31
|
|
|
14
|
-
//
|
|
15
|
-
if (fs.existsSync(dbPath)) fs.rmSync(dbPath);
|
|
32
|
+
// The caller provides a fresh staging path; never unlink the installed index.
|
|
16
33
|
|
|
17
34
|
return withDocsDb({ dbPath }, (db) => {
|
|
18
35
|
initDocsSchema(db);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { docsReaderLease } from './locks.js';
|
|
6
|
+
import { getDocsDbPath, getDocsCacheDir, getDocsSourceDir } from './db.js';
|
|
7
|
+
import { fetchManifest, installSnapshot, snapshotEstimate, validateDatabase, installDatabase, docsSnapshotStatus, DEFAULT_MANIFEST_URL } from './snapshot.js';
|
|
8
|
+
import { canUsePicker } from '../../helpers.js';
|
|
9
|
+
import { errUsageHint } from '../../errors.js';
|
|
10
|
+
|
|
11
|
+
export async function installDocumentation(source, options = {}) {
|
|
12
|
+
if (source === 'skip') return { source, skipped: true };
|
|
13
|
+
if (source === 'snapshot') {
|
|
14
|
+
const manifestURL = options.manifestURL || DEFAULT_MANIFEST_URL;
|
|
15
|
+
const manifest = options.manifest || await fetchManifest(manifestURL);
|
|
16
|
+
process.stderr.write(`Prebuilt searchable database: ${snapshotEstimate(manifest)}.\n`);
|
|
17
|
+
return installSnapshot({ manifestURL, manifest });
|
|
18
|
+
}
|
|
19
|
+
if (source !== 'github') throw errUsageHint('Choose a documentation source.', 'Use jsn docs install --source snapshot|github|skip');
|
|
20
|
+
process.stderr.write('Raw GitHub source: approximately 51,000 files plus local indexing; estimated 5–25 minutes including semantic indexing (prior runs ~5 minutes; validation full build ~21 minutes; actual time and size vary).\n');
|
|
21
|
+
fs.mkdirSync(getDocsCacheDir(), { recursive: true });
|
|
22
|
+
const staging = fs.mkdtempSync(path.join(getDocsCacheDir(), '.github-install-'));
|
|
23
|
+
try {
|
|
24
|
+
const { syncDocs } = await import('./sync.js');
|
|
25
|
+
const { ingestDocs } = await import('./ingest.js');
|
|
26
|
+
const repoDir = getDocsSourceDir(), dbPath = path.join(staging, 'docs.db');
|
|
27
|
+
syncDocs({ repoDir, markdownDir: path.join(repoDir, 'markdown'), noIngest: true, embed: false });
|
|
28
|
+
const releaseSource = docsReaderLease(repoDir);
|
|
29
|
+
let result;
|
|
30
|
+
try { result = ingestDocs({ dbPath, roots: [{ dir: path.join(repoDir, 'markdown'), prefix: '' }], embed: true }); }
|
|
31
|
+
finally { releaseSource(); }
|
|
32
|
+
if (result.errors) throw new Error('Raw documentation indexing failed; existing index retained');
|
|
33
|
+
validateDatabase(dbPath);
|
|
34
|
+
installDatabase(dbPath, getDocsDbPath(), { afterInstall: () => fs.rmSync(`${getDocsDbPath()}.snapshot.json`, { force: true }) });
|
|
35
|
+
return { source, documents: result.count, dbPath: getDocsDbPath() };
|
|
36
|
+
} finally { fs.rmSync(staging, { recursive: true, force: true }); }
|
|
37
|
+
}
|
|
38
|
+
export async function chooseDocumentationSource() {
|
|
39
|
+
const { select } = await import('../../prompts.js');
|
|
40
|
+
return select({ message: 'Install optional offline documentation?', choices: [
|
|
41
|
+
{ name: 'Skip for now (first search will explain installation)', value: 'skip' },
|
|
42
|
+
{ name: 'Prebuilt searchable database from jsn.jace.pro (size and estimate before approval)', value: 'snapshot' },
|
|
43
|
+
{ name: 'GitHub raw source + local indexing (~51,000 files; estimated 5–25 minutes)', value: 'github' },
|
|
44
|
+
] });
|
|
45
|
+
}
|
|
46
|
+
export async function offerDocumentation(_app, _argv = {}) {
|
|
47
|
+
let source = await chooseDocumentationSource();
|
|
48
|
+
let approvedManifest;
|
|
49
|
+
if (source === 'snapshot') {
|
|
50
|
+
try {
|
|
51
|
+
const manifest = await fetchManifest(DEFAULT_MANIFEST_URL, { fetchFn: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(5000) }) });
|
|
52
|
+
approvedManifest = manifest;
|
|
53
|
+
const { confirm } = await import('../../prompts.js');
|
|
54
|
+
if (!await confirm({ message: `Download prebuilt searchable database: ${snapshotEstimate(manifest)}?`, default: false })) source = 'skip';
|
|
55
|
+
} catch {
|
|
56
|
+
throw errUsageHint('Prebuilt documentation is unavailable; nothing downloaded.', 'Choose GitHub raw source with jsn docs install --source github, or retry the snapshot later.');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return installDocumentation(source, { manifest: approvedManifest });
|
|
60
|
+
}
|
|
61
|
+
export async function ensureDocumentation(app, argv) {
|
|
62
|
+
if (fs.existsSync(getDocsDbPath())) { scheduleFreshnessCheck(); return; }
|
|
63
|
+
if (!canUsePicker(app, argv)) throw errUsageHint('No local documentation index. Documentation installation is optional and requires a source choice.',
|
|
64
|
+
'Run jsn docs install --source snapshot for a verified prebuilt database, or --source github for raw files + local indexing (estimated 5–25 minutes). Use --source skip to defer.');
|
|
65
|
+
const result = await offerDocumentation(app, argv);
|
|
66
|
+
if (result.skipped) throw errUsageHint('Documentation installation skipped.', 'Run jsn docs install --source snapshot|github when ready.');
|
|
67
|
+
}
|
|
68
|
+
export function scheduleFreshnessCheck() {
|
|
69
|
+
if (process.env.JSN_NO_DOCS_CHECK === '1') return;
|
|
70
|
+
const receipt = docsSnapshotStatus();
|
|
71
|
+
if (!receipt) return;
|
|
72
|
+
try {
|
|
73
|
+
const stamp = `${getDocsDbPath()}.check-stamp`;
|
|
74
|
+
try {
|
|
75
|
+
if (Date.now() - fs.statSync(stamp).mtimeMs < 86400000) return;
|
|
76
|
+
fs.rmSync(stamp);
|
|
77
|
+
} catch (error) { if (error.code !== 'ENOENT') return; }
|
|
78
|
+
fs.writeFileSync(stamp, '', { flag: 'wx' });
|
|
79
|
+
// A separate bounded worker prevents network from delaying local search.
|
|
80
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./snapshot-check.js', import.meta.url)), getDocsDbPath()], {
|
|
81
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
82
|
+
});
|
|
83
|
+
child.on('error', () => {});
|
|
84
|
+
child.unref();
|
|
85
|
+
} catch { /* Local search remains available offline. */ }
|
|
86
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Coordinate atomic snapshot replacement with short-lived local search readers.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
function alive(pid) {
|
|
6
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
7
|
+
}
|
|
8
|
+
function clearStale(filename) {
|
|
9
|
+
try {
|
|
10
|
+
const pid = Number(fs.readFileSync(filename, 'utf8'));
|
|
11
|
+
if (pid > 0 && alive(pid)) return false;
|
|
12
|
+
if (!(pid > 0) && Date.now() - fs.statSync(filename).mtimeMs < 120000) return false;
|
|
13
|
+
fs.rmSync(filename, { force: true });
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
16
|
+
}
|
|
17
|
+
export function docsReaderLease(dbPath) {
|
|
18
|
+
const lock = `${dbPath}.install-lock`;
|
|
19
|
+
if (!clearStale(lock)) throw new Error('Docs snapshot installation is in progress; retry shortly');
|
|
20
|
+
const lease = `${dbPath}.reader-${process.pid}-${randomUUID()}`;
|
|
21
|
+
fs.writeFileSync(lease, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
22
|
+
if (fs.existsSync(lock)) { fs.rmSync(lease, { force: true }); throw new Error('Docs snapshot installation is in progress; retry shortly'); }
|
|
23
|
+
return () => fs.rmSync(lease, { force: true });
|
|
24
|
+
}
|
|
25
|
+
export function docsInstallLease(dbPath) {
|
|
26
|
+
const lock = `${dbPath}.install-lock`;
|
|
27
|
+
clearStale(lock);
|
|
28
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
29
|
+
return () => fs.rmSync(lock, { force: true });
|
|
30
|
+
}
|
|
31
|
+
export function docsHaveReaders(dbPath) {
|
|
32
|
+
return fs.readdirSync(path.dirname(dbPath)).filter(name => name.startsWith(path.basename(dbPath) + '.reader-'))
|
|
33
|
+
.some(name => !clearStale(path.join(path.dirname(dbPath), name)));
|
|
34
|
+
}
|
|
@@ -2,20 +2,36 @@
|
|
|
2
2
|
// after a ServiceNow docs update (e.g. `git pull` in the docs repo).
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { docsReaderLease } from './locks.js';
|
|
7
|
+
import { errUsageHint } from '../../errors.js';
|
|
5
8
|
import { createHash } from 'node:crypto';
|
|
6
9
|
import { parseFrontmatter } from '../../frontmatter.js';
|
|
7
|
-
import { withDocsDb, getDocsDbPath, walkDocs, defaultDocsRoots } from './db.js';
|
|
10
|
+
import { withDocsDb, getDocsDbPath, walkDocs, defaultDocsRoots, getDocsSourceDir } from './db.js';
|
|
8
11
|
import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
|
|
9
12
|
|
|
10
13
|
export function refreshDocs(opts = {}) {
|
|
14
|
+
const resource = opts.sourceResource || (!opts.roots ? getDocsSourceDir() : null);
|
|
15
|
+
if (resource) fs.mkdirSync(path.dirname(resource), { recursive: true });
|
|
16
|
+
const release = resource && !opts.sourceLeaseHeld ? docsReaderLease(resource) : () => {};
|
|
17
|
+
try { return refreshDocsLocked(opts); } finally { release(); }
|
|
18
|
+
}
|
|
19
|
+
function refreshDocsLocked(opts = {}) {
|
|
11
20
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
12
21
|
const roots = opts.roots || defaultDocsRoots(opts);
|
|
13
22
|
|
|
23
|
+
if (roots.some(root => (!root.prefix || opts.roots) && (!fs.existsSync(root.dir) || !fs.statSync(root.dir).isDirectory()))) {
|
|
24
|
+
throw errUsageHint('Raw documentation source is missing; existing index retained.', 'Use jsn docs install --source snapshot to update a prebuilt index, or --source github to install raw source before refreshing.');
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
if (!fs.existsSync(dbPath)) {
|
|
15
28
|
throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first for the initial build.`);
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
return withDocsDb({ dbPath }, (db) => {
|
|
32
|
+
if (!opts.sourceLeaseHeld && db.prepare("SELECT value FROM meta WHERE key='snapshot_revision'").get()) {
|
|
33
|
+
throw errUsageHint('A prebuilt snapshot cannot refresh from an unrelated raw clone; existing index retained.', 'Use jsn docs install --source snapshot, or jsn docs sync to explicitly update raw source and rebuild.');
|
|
34
|
+
}
|
|
19
35
|
db.exec('PRAGMA journal_mode = WAL');
|
|
20
36
|
|
|
21
37
|
const cols = db.prepare('PRAGMA table_info(docs)').all().map((c) => c.name);
|
|
@@ -48,9 +64,11 @@ export function refreshDocs(opts = {}) {
|
|
|
48
64
|
const seen = new Set();
|
|
49
65
|
const started = Date.now();
|
|
50
66
|
|
|
67
|
+
const missingPrefixes = roots.filter(root => root.prefix && !fs.existsSync(root.dir)).map(root => root.prefix);
|
|
51
68
|
const files = [...walkDocs(roots)];
|
|
52
69
|
|
|
53
70
|
db.exec('BEGIN');
|
|
71
|
+
db.prepare("DELETE FROM meta WHERE key='snapshot_revision'").run();
|
|
54
72
|
for (const { file, rel } of files) {
|
|
55
73
|
try {
|
|
56
74
|
seen.add(rel);
|
|
@@ -105,7 +123,7 @@ export function refreshDocs(opts = {}) {
|
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
for (const [rel, row] of existing) {
|
|
108
|
-
if (!seen.has(rel)) {
|
|
126
|
+
if (!missingPrefixes.some(prefix => rel.startsWith(prefix)) && !seen.has(rel)) {
|
|
109
127
|
del.run(row.id);
|
|
110
128
|
removed++;
|
|
111
129
|
}
|
|
@@ -116,6 +134,8 @@ export function refreshDocs(opts = {}) {
|
|
|
116
134
|
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
117
135
|
db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('synced_at', ?)").run(String(Date.now()));
|
|
118
136
|
|
|
137
|
+
fs.rmSync(`${dbPath}.snapshot.json`, { force: true });
|
|
138
|
+
fs.rmSync(`${dbPath}.available.json`, { force: true });
|
|
119
139
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
120
140
|
return { added, updated, removed, unchanged, errors, secs, dbPath, embeddingsEnabled };
|
|
121
141
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import { docsInstallLease, docsHaveReaders } from './locks.js';
|
|
4
|
+
import { errUsageHint } from '../../errors.js';
|
|
5
|
+
|
|
6
|
+
export function installDatabase(staged, dbPath, { afterInstall = () => {} } = {}) {
|
|
7
|
+
let release;
|
|
8
|
+
try {
|
|
9
|
+
release = docsInstallLease(dbPath);
|
|
10
|
+
// Refuse active databases rather than detach a WAL from its live database.
|
|
11
|
+
if (docsHaveReaders(dbPath)) throw errUsageHint('Docs index is in use; existing index retained.', 'Close other jsn docs commands and retry installation.');
|
|
12
|
+
if (['-wal', '-shm'].some(suffix => fs.existsSync(dbPath + suffix))) {
|
|
13
|
+
// Recover an interrupted reader/writer through SQLite, never by unlinking WAL.
|
|
14
|
+
const previous = new Database(dbPath, { fileMustExist: true });
|
|
15
|
+
try {
|
|
16
|
+
previous.pragma('busy_timeout = 2000');
|
|
17
|
+
const [checkpoint] = previous.pragma('wal_checkpoint(TRUNCATE)');
|
|
18
|
+
if (checkpoint.busy || previous.pragma('journal_mode = DELETE', { simple: true }) !== 'delete') {
|
|
19
|
+
throw errUsageHint('Docs index is in use; existing index retained.', 'Close other jsn docs commands and retry installation.');
|
|
20
|
+
}
|
|
21
|
+
} finally { previous.close(); }
|
|
22
|
+
}
|
|
23
|
+
if (fs.existsSync(dbPath)) fs.copyFileSync(dbPath, `${dbPath}.previous`);
|
|
24
|
+
fs.renameSync(staged, dbPath);
|
|
25
|
+
fs.rmSync(`${dbPath}.available.json`, { force: true });
|
|
26
|
+
afterInstall();
|
|
27
|
+
} finally {
|
|
28
|
+
release?.();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Detached read-only freshness worker. Never replaces the installed index.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { fetchManifest } from './snapshot.js';
|
|
4
|
+
const dbPath = process.argv[2];
|
|
5
|
+
try {
|
|
6
|
+
const receipt = JSON.parse(fs.readFileSync(`${dbPath}.snapshot.json`, 'utf8'));
|
|
7
|
+
const manifest = await fetchManifest(receipt.manifestURL, { fetchFn: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(5000) }) });
|
|
8
|
+
const filename = `${dbPath}.available.json`;
|
|
9
|
+
fs.writeFileSync(`${filename}.${process.pid}.tmp`, JSON.stringify({ revision: manifest.revision, installed: receipt.revision, checkedAt: Date.now() }));
|
|
10
|
+
fs.renameSync(`${filename}.${process.pid}.tmp`, filename);
|
|
11
|
+
} catch { /* Offline and interrupted freshness checks leave the local index intact. */ }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Shared, versioned docs artifact format. Never touches mutation history.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import { getDocsDbPath, getDocsCacheDir } from './db.js';
|
|
8
|
+
import { docsReaderLease } from './locks.js';
|
|
9
|
+
import { ingestDocs } from './ingest.js';
|
|
10
|
+
import { installDatabase } from './replace.js';
|
|
11
|
+
export { installDatabase } from './replace.js';
|
|
12
|
+
|
|
13
|
+
export const SNAPSHOT_SCHEMA = 1;
|
|
14
|
+
export const DEFAULT_MANIFEST_URL = 'https://jsn.jace.pro/docs-index/manifest.json';
|
|
15
|
+
const MAX_COMPRESSED = 512 * 1024 * 1024;
|
|
16
|
+
const MAX_DATABASE = 2 * 1024 * 1024 * 1024;
|
|
17
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
export function validateManifest(manifest) {
|
|
19
|
+
if (manifest?.format !== 1 || manifest.schema !== SNAPSHOT_SCHEMA || manifest.cliCompatibility !== 'docs-schema-1') throw new Error('Incompatible docs snapshot format/schema');
|
|
20
|
+
if (!/^[a-f0-9]{64}$/.test(manifest.sha256 || '') || !/^[a-f0-9]{7,64}$/.test(manifest.revision || '')) throw new Error('Invalid snapshot checksum or revision');
|
|
21
|
+
for (const [field, max] of [['compressedBytes', MAX_COMPRESSED], ['databaseBytes', MAX_DATABASE]]) {
|
|
22
|
+
if (!Number.isSafeInteger(manifest[field]) || manifest[field] <= 0 || manifest[field] > max) throw new Error(`Invalid snapshot ${field}`);
|
|
23
|
+
}
|
|
24
|
+
const url = new URL(manifest.url);
|
|
25
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Snapshot downloads require HTTPS');
|
|
26
|
+
return manifest;
|
|
27
|
+
}
|
|
28
|
+
export function validateDatabase(filename) {
|
|
29
|
+
const db = new Database(filename, { readonly: true, fileMustExist: true });
|
|
30
|
+
try {
|
|
31
|
+
if (db.pragma('integrity_check', { simple: true }) !== 'ok') throw new Error('Snapshot SQLite integrity check failed');
|
|
32
|
+
if (db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== String(SNAPSHOT_SCHEMA)) throw new Error('Incompatible snapshot database schema');
|
|
33
|
+
const count = db.prepare('SELECT count(*) AS n FROM docs').get().n;
|
|
34
|
+
if (!count) throw new Error('Snapshot index is empty');
|
|
35
|
+
db.prepare("SELECT rowid FROM docs_fts WHERE docs_fts MATCH 'documentation' LIMIT 1").all();
|
|
36
|
+
return count;
|
|
37
|
+
} finally { db.close(); }
|
|
38
|
+
}
|
|
39
|
+
export function buildSnapshot({ docsDir, outputDir, revision, artifactBaseURL, embed = true }) {
|
|
40
|
+
if (!/^[a-f0-9]{7,64}$/.test(revision || '')) throw new Error('A source commit revision is required');
|
|
41
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
42
|
+
const temporary = fs.mkdtempSync(path.join(outputDir, '.build-'));
|
|
43
|
+
try {
|
|
44
|
+
const filename = path.join(temporary, 'docs.db');
|
|
45
|
+
const result = ingestDocs({ dbPath: filename, roots: [{ dir: docsDir, prefix: '' }], embed });
|
|
46
|
+
if (result.errors || !result.count) throw new Error(`Docs build rejected: ${result.errors} ingestion errors, ${result.count} documents`);
|
|
47
|
+
const db = new Database(filename);
|
|
48
|
+
try {
|
|
49
|
+
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('snapshot_revision', ?)").run(revision);
|
|
50
|
+
db.pragma('wal_checkpoint(TRUNCATE)'); db.pragma('journal_mode = DELETE');
|
|
51
|
+
} finally { db.close(); }
|
|
52
|
+
const documents = validateDatabase(filename);
|
|
53
|
+
const bytes = fs.readFileSync(filename), compressed = gzipSync(bytes);
|
|
54
|
+
const name = `docs-schema-${SNAPSHOT_SCHEMA}-${revision}.sqlite.gz`;
|
|
55
|
+
const manifest = validateManifest({ format: 1, schema: SNAPSHOT_SCHEMA, cliCompatibility: 'docs-schema-1', revision,
|
|
56
|
+
sha256: digest(compressed), compressedBytes: compressed.length, databaseBytes: bytes.length,
|
|
57
|
+
documents, buildSeconds: result.secs, builtAt: new Date().toISOString(), url: new URL(name, artifactBaseURL.endsWith('/') ? artifactBaseURL : artifactBaseURL + '/').href });
|
|
58
|
+
const artifact = path.join(outputDir, name);
|
|
59
|
+
if (fs.existsSync(artifact) && digest(fs.readFileSync(artifact)) !== manifest.sha256) throw new Error('Immutable artifact already exists with different content');
|
|
60
|
+
fs.writeFileSync(path.join(temporary, name), compressed);
|
|
61
|
+
fs.renameSync(path.join(temporary, name), artifact);
|
|
62
|
+
// Publish this manifest only after uploading the immutable artifact successfully.
|
|
63
|
+
fs.writeFileSync(path.join(temporary, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
64
|
+
fs.renameSync(path.join(temporary, 'manifest.json'), path.join(outputDir, 'manifest.json'));
|
|
65
|
+
return manifest;
|
|
66
|
+
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
|
67
|
+
}
|
|
68
|
+
async function download(url, maxBytes, fetchFn, timeout = 600000) {
|
|
69
|
+
const response = await fetchFn(url, { signal: AbortSignal.timeout(timeout) });
|
|
70
|
+
if (response.url && new URL(response.url).protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(response.url).hostname)) throw new Error('Docs download redirected to insecure transport');
|
|
71
|
+
if (!response.ok) throw new Error(`Docs download rejected (HTTP ${response.status})`);
|
|
72
|
+
if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new Error('Docs download exceeds size limit'); }
|
|
73
|
+
const parts = []; let size = 0;
|
|
74
|
+
for await (const chunk of response.body) {
|
|
75
|
+
size += chunk.length;
|
|
76
|
+
if (size > maxBytes) throw new Error('Docs download exceeds size limit');
|
|
77
|
+
parts.push(chunk);
|
|
78
|
+
}
|
|
79
|
+
return Buffer.concat(parts);
|
|
80
|
+
}
|
|
81
|
+
export async function fetchManifest(url = DEFAULT_MANIFEST_URL, { fetchFn = fetch } = {}) {
|
|
82
|
+
// Validate transport before making a request, including configured mirrors.
|
|
83
|
+
validateManifest({ format: 1, schema: 1, cliCompatibility: 'docs-schema-1', revision: 'abcdef0', sha256: '0'.repeat(64), compressedBytes: 1, databaseBytes: 1, url });
|
|
84
|
+
return validateManifest(JSON.parse((await download(url, 65536, fetchFn, 15000)).toString('utf8')));
|
|
85
|
+
}
|
|
86
|
+
export function snapshotEstimate(manifest) {
|
|
87
|
+
return `${(manifest.compressedBytes / 1048576).toFixed(1)} MiB; estimated ${Math.max(1, Math.ceil(manifest.compressedBytes / 5242880))} seconds at 5 MiB/s plus verification (actual time varies)`;
|
|
88
|
+
}
|
|
89
|
+
export async function installSnapshot({ manifestURL = DEFAULT_MANIFEST_URL, manifest, dbPath = getDocsDbPath(), fetchFn = fetch } = {}) {
|
|
90
|
+
manifest = manifest ? validateManifest(manifest) : await fetchManifest(manifestURL, { fetchFn });
|
|
91
|
+
const compressed = await download(manifest.url, manifest.compressedBytes, fetchFn);
|
|
92
|
+
if (compressed.length !== manifest.compressedBytes || digest(compressed) !== manifest.sha256) throw new Error('Docs snapshot checksum/size mismatch; existing index retained');
|
|
93
|
+
const bytes = gunzipSync(compressed, { maxOutputLength: manifest.databaseBytes });
|
|
94
|
+
if (bytes.length !== manifest.databaseBytes) throw new Error('Docs database size mismatch');
|
|
95
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
96
|
+
const staged = `${dbPath}.${randomUUID()}.tmp`;
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(staged, bytes, { mode: 0o600, flag: 'wx' });
|
|
99
|
+
validateDatabase(staged);
|
|
100
|
+
const db = new Database(staged, { readonly: true });
|
|
101
|
+
try {
|
|
102
|
+
if (db.prepare("SELECT value FROM meta WHERE key='snapshot_revision'").get()?.value !== manifest.revision) throw new Error('Snapshot source revision mismatch');
|
|
103
|
+
} finally { db.close(); }
|
|
104
|
+
const receipt = `${dbPath}.snapshot.json`, receiptStage = `${staged}.receipt`;
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(receiptStage, JSON.stringify({ ...manifest, manifestURL, checkedAt: Date.now() }), { mode: 0o600 });
|
|
107
|
+
installDatabase(staged, dbPath, { afterInstall: () => fs.renameSync(receiptStage, receipt) });
|
|
108
|
+
} finally { fs.rmSync(receiptStage, { force: true }); }
|
|
109
|
+
return { revision: manifest.revision, documents: manifest.documents, dbPath, source: 'snapshot' };
|
|
110
|
+
} finally { fs.rmSync(staged, { force: true }); }
|
|
111
|
+
}
|
|
112
|
+
export function rollbackSnapshot(dbPath = getDocsDbPath()) {
|
|
113
|
+
const previous = `${dbPath}.previous`;
|
|
114
|
+
if (!fs.existsSync(previous)) throw new Error('No previous docs snapshot is available');
|
|
115
|
+
validateDatabase(previous);
|
|
116
|
+
const staged = `${dbPath}.${randomUUID()}.tmp`;
|
|
117
|
+
try { fs.copyFileSync(previous, staged); installDatabase(staged, dbPath, { afterInstall: () => fs.rmSync(`${dbPath}.snapshot.json`, { force: true }) }); }
|
|
118
|
+
finally { fs.rmSync(staged, { force: true }); }
|
|
119
|
+
return { dbPath, rolledBack: true };
|
|
120
|
+
}
|
|
121
|
+
export function docsSnapshotStatus() {
|
|
122
|
+
const receipt = path.join(getDocsCacheDir(), 'docs.db.snapshot.json');
|
|
123
|
+
let release, db;
|
|
124
|
+
try {
|
|
125
|
+
release = docsReaderLease(getDocsDbPath());
|
|
126
|
+
const value = JSON.parse(fs.readFileSync(receipt, 'utf8'));
|
|
127
|
+
db = new Database(getDocsDbPath(), { readonly: true, fileMustExist: true });
|
|
128
|
+
return db.prepare("SELECT value FROM meta WHERE key='snapshot_revision'").get()?.value === value.revision ? value : null;
|
|
129
|
+
} catch { return null; } finally { db?.close(); release?.(); }
|
|
130
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { docsInstallLease, docsHaveReaders } from './locks.js';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { getDocsSourceDir, getDocsSourceMarkdownDir, getDocsDbPath, docsDbExists } from './db.js';
|
|
7
8
|
import { ingestDocs } from './ingest.js';
|
|
@@ -21,6 +22,15 @@ function git(args, cwd, stdio = 'inherit') {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function syncDocs(opts = {}) {
|
|
25
|
+
const repoDir = opts.repoDir || getDocsSourceDir();
|
|
26
|
+
fs.mkdirSync(path.dirname(repoDir), { recursive: true });
|
|
27
|
+
const release = docsInstallLease(repoDir);
|
|
28
|
+
try {
|
|
29
|
+
if (docsHaveReaders(repoDir)) throw new Error('Raw documentation source is in use; retry after the current refresh finishes.');
|
|
30
|
+
return syncDocsLocked(opts);
|
|
31
|
+
} finally { release(); }
|
|
32
|
+
}
|
|
33
|
+
function syncDocsLocked(opts = {}) {
|
|
24
34
|
const repoDir = opts.repoDir || getDocsSourceDir();
|
|
25
35
|
const markdownDir = opts.markdownDir || getDocsSourceMarkdownDir();
|
|
26
36
|
const dbPath = opts.dbPath || getDocsDbPath();
|
|
@@ -29,13 +39,13 @@ export function syncDocs(opts = {}) {
|
|
|
29
39
|
|
|
30
40
|
const initialStatus = {
|
|
31
41
|
clone: !fs.existsSync(path.join(repoDir, '.git')),
|
|
32
|
-
docsApprox:
|
|
42
|
+
docsApprox: 51000,
|
|
33
43
|
willEmbed: embed && noIngest === false,
|
|
34
|
-
minutesHint: embed ?
|
|
44
|
+
minutesHint: embed ? 5 : 1,
|
|
35
45
|
};
|
|
36
46
|
|
|
37
|
-
process.stderr.write('Note: docs sync downloads ~
|
|
38
|
-
process.stderr.write(`
|
|
47
|
+
process.stderr.write('Note: docs sync downloads ~51k markdown files and builds an FTS5 + HRR index. ');
|
|
48
|
+
process.stderr.write(`Estimated ${embed ? '5–25' : '1–5'} minutes; actual time varies by host and indexing options.\n`);
|
|
39
49
|
if (initialStatus.willEmbed) {
|
|
40
50
|
process.stderr.write('Pass --no-embed to skip semantic embeddings and finish faster.\n');
|
|
41
51
|
}
|
|
@@ -68,11 +78,11 @@ export function syncDocs(opts = {}) {
|
|
|
68
78
|
// If the DB already exists, use incremental refresh. Otherwise, full ingest.
|
|
69
79
|
if (docsDbExists()) {
|
|
70
80
|
process.stderr.write(`Docs DB found — running incremental refresh...\n`);
|
|
71
|
-
const result = refreshDocs({ docsDir: markdownDir, dbPath });
|
|
81
|
+
const result = refreshDocs({ docsDir: markdownDir, dbPath, sourceResource: repoDir, sourceLeaseHeld: true });
|
|
72
82
|
return { repoDir, markdownDir, dbPath, ingested: true, incremental: true, ...result };
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
process.stderr.write('No existing DB — running full ingest...\n');
|
|
76
|
-
const result = ingestDocs({ docsDir: markdownDir, dbPath, embed });
|
|
86
|
+
const result = ingestDocs({ docsDir: markdownDir, dbPath, embed, sourceResource: repoDir, sourceLeaseHeld: true });
|
|
77
87
|
return { repoDir, markdownDir, dbPath, ingested: true, incremental: false, ...result };
|
|
78
88
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -14,6 +14,7 @@ const HUB_CHOICES = [
|
|
|
14
14
|
{ name: 'Change settings', value: 'modify', command: 'jsn auth modify --profile <name> --read-only --confirm-deletes --no-include-counts' },
|
|
15
15
|
{ name: 'Sign out (keep the profile)', value: 'logout', command: 'jsn auth logout --profile <name> --force (only after explicit approval)' },
|
|
16
16
|
{ name: 'Remove a connection', value: 'remove', command: 'jsn auth remove <name>' },
|
|
17
|
+
{ name: 'Install optional offline documentation', value: 'docs', command: 'jsn docs install --source snapshot|github|skip' },
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
const setupHelp = () => [
|
|
@@ -101,8 +102,13 @@ export function setupCmd(wrap) {
|
|
|
101
102
|
return {
|
|
102
103
|
command: 'setup',
|
|
103
104
|
describe: 'Set up and manage your ServiceNow instances (interactive)',
|
|
104
|
-
builder: y => y.epilogue(setupHelp()),
|
|
105
|
+
builder: y => y.option('docs-source', { type: 'string', choices: ['snapshot', 'github', 'skip'], describe: 'Install optional documentation directly without the authentication wizard' }).epilogue(setupHelp()),
|
|
105
106
|
handler: wrap(async (argv, app) => {
|
|
107
|
+
if (argv['docs-source']) {
|
|
108
|
+
const { installDocumentation } = await import('./docs/install.js');
|
|
109
|
+
app.ok(await installDocumentation(argv['docs-source']));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
106
112
|
const { canUsePicker } = await import('../helpers.js');
|
|
107
113
|
if (!app.isInteractive() || !canUsePicker(app, argv)) {
|
|
108
114
|
throw errUsageHint('"jsn setup" is interactive. Use explicit auth commands instead.', setupHelp());
|
|
@@ -111,10 +117,20 @@ export function setupCmd(wrap) {
|
|
|
111
117
|
if (profileNames.length === 0) {
|
|
112
118
|
const result = await loginWizard(app, argv);
|
|
113
119
|
await showAuthStatus({ ...argv, profile: result.profileName }, app);
|
|
120
|
+
const { docsDbExists } = await import('./docs/db.js');
|
|
121
|
+
if (!docsDbExists()) {
|
|
122
|
+
const { offerDocumentation } = await import('./docs/install.js');
|
|
123
|
+
app.ok(await offerDocumentation(app, argv));
|
|
124
|
+
}
|
|
114
125
|
return;
|
|
115
126
|
}
|
|
116
127
|
const action = await authHubMenu();
|
|
117
128
|
if (action === undefined) return;
|
|
129
|
+
if (action === 'docs') {
|
|
130
|
+
const { offerDocumentation } = await import('./docs/install.js');
|
|
131
|
+
app.ok(await offerDocumentation(app, argv));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
118
134
|
const result = await dispatchSetupAction(app, argv, action);
|
|
119
135
|
if (action !== 'status') await showAuthStatus({ ...argv, profile: result?.profile || result?.profileName }, app);
|
|
120
136
|
}),
|