@jacebenson/jsn 4.3.13-dev.85.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.85.1",
3
+ "version": "4.3.13-dev.87.1",
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": {
@@ -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
- const db = new Database(dbPath);
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 — ServiceNow documentation search (sync, status, search, show, serve).';
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
- // Fresh build each run.
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: 45000,
42
+ docsApprox: 51000,
33
43
  willEmbed: embed && noIngest === false,
34
- minutesHint: embed ? 3 : 1,
44
+ minutesHint: embed ? 5 : 1,
35
45
  };
36
46
 
37
- process.stderr.write('Note: docs sync downloads ~45k markdown files and builds an FTS5 + HRR index. ');
38
- process.stderr.write(`This normally takes ${initialStatus.minutesHint}-${initialStatus.minutesHint + 2} minutes.\n`);
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
  }
@@ -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
  }),