@jacebenson/jsn 4.3.13 → 4.4.0-dev.114.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/README.md +137 -0
- package/package.json +4 -3
- package/skills/servicenow/SKILL.md +20 -172
- package/src/app.js +29 -40
- package/src/auth-recovery.js +83 -0
- package/src/auth.js +2 -0
- package/src/capabilities.js +9 -0
- package/src/cli-adapter.js +12 -0
- package/src/cli.js +45 -9
- package/src/command-audience.js +38 -0
- package/src/commands/_generic.js +45 -28
- package/src/commands/_simple.js +30 -33
- package/src/commands/_ticket.js +24 -20
- package/src/commands/attachments.js +88 -0
- package/src/commands/auth.js +94 -19
- package/src/commands/b4rules.js +8 -3
- package/src/commands/catalog-order.js +93 -0
- package/src/commands/catalog.js +8 -10
- package/src/commands/changes.js +2 -4
- package/src/commands/cmdb-search.js +38 -0
- package/src/commands/cmdb.js +2 -0
- package/src/commands/columns.js +2 -1
- package/src/commands/customizations.js +13 -7
- package/src/commands/discover.js +54 -0
- 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/forms.js +22 -97
- package/src/commands/history.js +57 -0
- package/src/commands/incidents.js +2 -4
- package/src/commands/knowledge.js +75 -0
- package/src/commands/midservers.js +117 -10
- package/src/commands/records.js +57 -104
- package/src/commands/requests.js +9 -15
- package/src/commands/rest.js +15 -2
- package/src/commands/scan.js +234 -0
- package/src/commands/scopes.js +1 -1
- package/src/commands/setup.js +46 -9
- package/src/commands/skill.js +73 -76
- package/src/commands/tasks.js +2 -4
- package/src/commands/tickets.js +20 -19
- package/src/commands/uipolicies.js +2 -27
- package/src/commands/updatesets.js +66 -13
- package/src/commands/upgrade.js +69 -0
- package/src/commands/version.js +14 -8
- package/src/constrained-query.js +20 -0
- package/src/context.js +6 -6
- package/src/customization-guidance.js +31 -0
- package/src/customizations-report.js +6 -0
- package/src/customizations.js +28 -13
- package/src/errors.js +23 -1
- package/src/essential-records.js +40 -0
- package/src/field-behavior.js +15 -0
- package/src/help.js +12 -2
- package/src/metadata/records.js +249 -0
- package/src/midserver-linux-remote.js +210 -0
- package/src/midserver-remote-recipe.js +49 -0
- package/src/midserver-remote.js +221 -0
- package/src/midserver-windows-remote.js +318 -0
- package/src/midserver-zip.js +2 -2
- package/src/mutation-context.js +74 -0
- package/src/mutation-history.js +75 -0
- package/src/mutations.js +3 -2
- package/src/output.js +34 -2
- package/src/record-command-profiles.js +70 -0
- package/src/record-profiles.js +93 -0
- package/src/records-engine.js +57 -0
- package/src/related-lists.js +153 -0
- package/src/resolve-record.js +50 -28
- package/src/scan.js +249 -0
- package/src/sdk-batch.js +130 -0
- package/src/sdk.js +157 -18
- package/src/update-set-xml.js +89 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { assertSafeExactMatch, quoteShellArg } from '../helpers.js';
|
|
2
|
+
import { errAPI, errUsage } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
const FIELDS = ['name', 'fqdn', 'ip_address', 'serial_number'];
|
|
5
|
+
const raw = v => v && typeof v === 'object' ? v.value : v;
|
|
6
|
+
|
|
7
|
+
export function cmdbSearchCmd(wrap) {
|
|
8
|
+
return { command: 'search <text>', describe: 'Find CIs by name, FQDN, IP address, or serial number',
|
|
9
|
+
builder: y => y.positional('text', { type: 'string', describe: 'Literal text; quote multiple words' })
|
|
10
|
+
.option('class', { type: 'string', describe: 'Exact CI class name (not descendants)' })
|
|
11
|
+
.option('limit', { type: 'number', default: 20, describe: 'Maximum CIs (1–100)' })
|
|
12
|
+
.option('offset', { type: 'number', default: 0, describe: 'Starting result offset' }),
|
|
13
|
+
handler: wrap(async (argv, app) => {
|
|
14
|
+
if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty CI search text.');
|
|
15
|
+
const text = argv.text.trim();
|
|
16
|
+
assertSafeExactMatch(text);
|
|
17
|
+
if ([...text].some(c => c.codePointAt(0) < 32 || c.codePointAt(0) === 127) || /javascript:/i.test(text)) throw errUsage('Search text must not contain query scripts or control characters.');
|
|
18
|
+
if (argv.class !== undefined && !/^[a-z][a-z0-9_]*$/.test(argv.class)) throw errUsage('--class must be an exact CI table name.');
|
|
19
|
+
if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
|
|
20
|
+
if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
|
|
21
|
+
// Repeat the class restriction in each OR branch: no branch may escape it.
|
|
22
|
+
const query = FIELDS.map(f => `${f}LIKE${text}${argv.class ? `^sys_class_name=${argv.class}` : ''}`).join('^NQ') + '^ORDERBYname^ORDERBYsys_id';
|
|
23
|
+
const params = new URLSearchParams({ sysparm_query: query, sysparm_limit: String(argv.limit + 1), sysparm_offset: String(argv.offset),
|
|
24
|
+
sysparm_fields: 'sys_id,name,sys_class_name,fqdn,ip_address,serial_number', sysparm_display_value: 'all' });
|
|
25
|
+
const rows = await app.sdk.listStrict('cmdb_ci', params);
|
|
26
|
+
if (!Array.isArray(rows) || rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw errAPI(200, 'CMDB search returned no valid record list.');
|
|
27
|
+
const hasMore = rows.length > argv.limit, records = rows.slice(0, argv.limit);
|
|
28
|
+
const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}` : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
|
|
29
|
+
const breadcrumbs = records.flatMap(r => /^[a-f0-9]{32}$/i.test(raw(r.sys_id) || '') ? [
|
|
30
|
+
{ action: 'show', cmd: `jsn cmdb show ${raw(r.sys_id)}`, description: String(raw(r.name) || 'Inspect CI') },
|
|
31
|
+
{ action: 'relationships', cmd: `jsn cmdb relationships --ci ${raw(r.sys_id)}`, description: 'Inspect CI dependencies' },
|
|
32
|
+
] : []);
|
|
33
|
+
if (hasMore) breadcrumbs.push({ action: 'next', cmd: `jsn cmdb search ${quoteShellArg(text)} --limit ${argv.limit} --offset ${argv.offset + argv.limit}${argv.class ? ` --class ${argv.class}` : ''}`, description: 'Next matching CIs' });
|
|
34
|
+
app.ok({ table: 'cmdb_ci', search: text, fields: FIELDS, records, count: records.length,
|
|
35
|
+
pagination: { limit: argv.limit, offset: argv.offset, has_more: hasMore } },
|
|
36
|
+
{ summary: `${records.length} matching CI(s)`, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
|
|
37
|
+
}) };
|
|
38
|
+
}
|
package/src/commands/cmdb.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
resolveFieldsParam,
|
|
16
16
|
} from '../helpers.js';
|
|
17
17
|
import { errNotFound, errUsage } from '../errors.js';
|
|
18
|
+
import { cmdbSearchCmd } from './cmdb-search.js';
|
|
18
19
|
|
|
19
20
|
const SYS_ID_RE = /^[0-9a-f]{32}$/i;
|
|
20
21
|
const MAX_DEPTH = 5;
|
|
@@ -371,6 +372,7 @@ export function cmdbCmd(wrap) {
|
|
|
371
372
|
}),
|
|
372
373
|
builder: (yargs) => {
|
|
373
374
|
return yargs
|
|
375
|
+
.command(cmdbSearchCmd(wrap))
|
|
374
376
|
.command({
|
|
375
377
|
command: 'list',
|
|
376
378
|
describe: 'List CIs from the CMDB (cmdb_ci)',
|
package/src/commands/columns.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { fieldBehaviorHelp } from '../field-behavior.js';
|
|
1
2
|
import { buildDevCmd } from './_generic.js';
|
|
2
3
|
|
|
3
4
|
export const columnsCmd = (wrap) => {
|
|
4
5
|
const base = buildDevCmd('columns', 'sys_dictionary', ['column', 'col'],
|
|
5
6
|
['element', 'column_label', 'internal_type', 'mandatory', 'max_length', 'active'],
|
|
6
|
-
wrap, { singular: 'column', scopeValidation: true });
|
|
7
|
+
wrap, { singular: 'column', scopeValidation: true, help: fieldBehaviorHelp });
|
|
7
8
|
|
|
8
9
|
// Add --table filter
|
|
9
10
|
const origBuilder = base.builder;
|
|
@@ -4,6 +4,7 @@ import { canUsePicker } from '../helpers.js';
|
|
|
4
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
5
5
|
import { captureReport, listReports, getReport, compareReports, formatReport, formatReportList, formatReportComparison } from '../customizations.js';
|
|
6
6
|
import { buildActionableReport, writeCustomizationHtml } from '../customizations-report.js';
|
|
7
|
+
import { customizationGuidance, formatCustomizationGuidance } from '../customization-guidance.js';
|
|
7
8
|
|
|
8
9
|
// Only capture needs an instance. Saved reports never need network access.
|
|
9
10
|
declareCapabilities('customizations', { noInstance: true, skipDailyChecks: true, mutationSubcommands: [] });
|
|
@@ -25,7 +26,7 @@ async function pickReport(app, argv, exclude = null, message = 'Select a customi
|
|
|
25
26
|
function show(app, report, options = {}) {
|
|
26
27
|
const actionable = buildActionableReport(report);
|
|
27
28
|
const triage = actionable.artifacts.map(({ category, category_label, identity, triage: classification }) => ({ category, category_label, identity, ...classification }));
|
|
28
|
-
app.ok({ ...report, triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
29
|
+
app.ok({ ...report, interpretation: customizationGuidance(), triage, _formatted: formatReport(report, options) }, { summary: `Customization capture ${report.run_id}: ${report.status}`, breadcrumbs: [listHint, showHint(report.run_id), compareHint(report.run_id)] });
|
|
29
30
|
}
|
|
30
31
|
export function customizationsCmd(wrap) {
|
|
31
32
|
return {
|
|
@@ -36,7 +37,8 @@ export function customizationsCmd(wrap) {
|
|
|
36
37
|
builder: b => b.option('label', { type: 'string', describe: 'Label for this capture' })
|
|
37
38
|
.option('customer-scope', { type: 'string', array: true, default: [], describe: 'Declared customer-owned scope value or sys_id, repeatable; ownership is not inferred' })
|
|
38
39
|
.option('page-size', { type: 'number', default: 100, describe: 'Records per table page, 1..1000' })
|
|
39
|
-
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
40
|
+
.option('max-pages', { type: 'number', default: 10, describe: 'Maximum pages per source, 1..100' })
|
|
41
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nExample: jsn customizations capture --label review --customer-scope x_acme_app --json`),
|
|
40
42
|
handler: wrap(async (argv, app) => {
|
|
41
43
|
app.requireInstance();
|
|
42
44
|
if (!Number.isInteger(argv.pageSize) || argv.pageSize < 1 || argv.pageSize > 1000 || !Number.isInteger(argv.maxPages) || argv.maxPages < 1 || argv.maxPages > 100) throw errUsage('page-size must be 1..1000 and max-pages 1..100');
|
|
@@ -55,18 +57,21 @@ export function customizationsCmd(wrap) {
|
|
|
55
57
|
})
|
|
56
58
|
.command({ command: 'show [run_id]', describe: 'Review a saved capture offline', builder: b => b
|
|
57
59
|
.option('group-by', { type: 'string', choices: ['type', 'scope', 'updated-by'], default: 'type', describe: 'Group summary by file type, scope or last updater' })
|
|
58
|
-
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
|
|
60
|
+
.option('filter', { type: 'string', describe: 'Expand a group shown in the summary (friendly or table name for file types)' })
|
|
61
|
+
.epilogue(`Offline review: inspect coverage, interpretation and triage in --json output.\n${formatCustomizationGuidance()}\nExample: jsn customizations show <run_id> --get data.triage`), handler: wrap(async (argv, app) => {
|
|
59
62
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
60
63
|
if (report) show(app, report, { groupBy: argv.groupBy, filter: argv.filter || '' });
|
|
61
64
|
}) })
|
|
62
65
|
.command({ command: 'html [run_id]', describe: 'Write a searchable, self-contained HTML review of a saved capture', builder: b => b
|
|
63
|
-
.option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
|
|
66
|
+
.option('out', { type: 'string', describe: 'Output HTML path (must not already exist)' })
|
|
67
|
+
.epilogue('Offline review with the same interpretation and coverage as show; writes a local file without changing instance records.\nExample: jsn customizations html <run_id> --out review.html'), handler: wrap(async (argv, app) => {
|
|
64
68
|
const report = argv.run_id ? requireReport(argv.run_id) : await pickReport(app, argv);
|
|
65
69
|
if (!report) return;
|
|
66
70
|
const file = writeCustomizationHtml(report, { out: argv.out });
|
|
67
|
-
app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0) }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
|
|
71
|
+
app.ok({ run_id: report.run_id, file, artifacts: Object.values(report.categories).reduce((total, items) => total + items.length, 0), interpretation: customizationGuidance() }, { summary: `Wrote offline customization review: ${file}`, breadcrumbs: [listHint, showHint(report.run_id)] });
|
|
68
72
|
}) })
|
|
69
|
-
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
73
|
+
.command({ command: 'compare [baseline] [new]', describe: 'Compare two saved captures from the same instance',
|
|
74
|
+
builder: b => b.epilogue(`Offline metadata/evidence comparison; inspect both captures' coverage before interpreting changes.\n${formatCustomizationGuidance()}\nExample: jsn customizations compare <baseline_id> <new_id> --json`), handler: wrap(async (argv, app) => {
|
|
70
75
|
if (!!argv.baseline !== !!argv.new) throw errUsage('Compare requires exactly two report IDs');
|
|
71
76
|
if (argv.baseline && argv.baseline === argv.new) throw errUsage('Compare requires two different report IDs');
|
|
72
77
|
const baseline = argv.baseline ? requireReport(argv.baseline) : await pickReport(app, argv, null, 'Select the baseline capture');
|
|
@@ -76,7 +81,8 @@ export function customizationsCmd(wrap) {
|
|
|
76
81
|
if (baseline.instance !== newer.instance) throw errUsage('Customization reports must describe the same instance');
|
|
77
82
|
const result = compareReports(baseline, newer);
|
|
78
83
|
app.ok({ ...result, _formatted: formatReportComparison(result) }, { summary: `Customization comparison: ${result.status}`, breadcrumbs: [listHint, showHint(baseline.run_id), showHint(newer.run_id)] });
|
|
79
|
-
}) })
|
|
84
|
+
}) })
|
|
85
|
+
.epilogue(`${customizationGuidance().capture}\n${formatCustomizationGuidance()}\nStart: jsn customizations capture --help\nReview: jsn customizations show --help\nCompare: jsn customizations compare --help`),
|
|
80
86
|
handler: wrap(async (_argv, app) => app.ok({ command: 'customizations', subcommands: ['capture', 'list', 'show', 'html', 'compare'] }, { summary: 'Use `jsn customizations <subcommand> --help` for details.' })),
|
|
81
87
|
};
|
|
82
88
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
|
|
3
|
+
declareCapabilities('discover', { noInstance: true, skipDailyChecks: true });
|
|
4
|
+
|
|
5
|
+
// The live registrations supply names, descriptions, aliases, actions, and
|
|
6
|
+
// factory configuration. No separate inventory of table commands lives here.
|
|
7
|
+
export function discoverCommands(registry, { query = '', table = '' } = {}) {
|
|
8
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
9
|
+
return registry.commands.filter(command => {
|
|
10
|
+
const metadata = command.discovery;
|
|
11
|
+
if (table && metadata?.table !== table) return false;
|
|
12
|
+
const haystack = [command.name, ...command.aliases, command.description,
|
|
13
|
+
metadata?.table, ...(metadata?.benefits || []),
|
|
14
|
+
...(command.actions || []).map(action => `${action.name} ${action.description}`)].join(' ').toLowerCase();
|
|
15
|
+
return words.every(word => haystack.includes(word));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function discoverCmd(wrap, getRegistry) {
|
|
20
|
+
return {
|
|
21
|
+
command: 'discover [query]',
|
|
22
|
+
describe: 'Find commands by words in their names, aliases, descriptions, or configured table',
|
|
23
|
+
builder: y => y
|
|
24
|
+
.positional('query', { type: 'string', describe: 'Search words (quote multiple words)' })
|
|
25
|
+
.option('table', { type: 'string', describe: 'Find specialized commands configured for this exact table' })
|
|
26
|
+
.epilogue([
|
|
27
|
+
'Choose by intent:',
|
|
28
|
+
' Cross-table tasks: jsn records list --table task --query "active=true" --json',
|
|
29
|
+
' Custom tables: jsn records list --table x_example_record --limit 5 --json',
|
|
30
|
+
' Table-specific defaults and views: jsn discover --table incident',
|
|
31
|
+
' Rich request views (attachments and variables): jsn requests show --help',
|
|
32
|
+
' Raw endpoints when needed: jsn rest --help',
|
|
33
|
+
'Generic records supports any accessible table; specialized commands provide configured defaults and table-specific behavior.',
|
|
34
|
+
'Discovery matches registered text; try fewer words if there are no matches.',
|
|
35
|
+
'Use --json for structured results or --json --get data.commands for only matches.',
|
|
36
|
+
].join('\n')),
|
|
37
|
+
handler: wrap(async (argv, app) => {
|
|
38
|
+
const commands = discoverCommands(getRegistry(argv), { query: argv.query, table: argv.table });
|
|
39
|
+
const lines = commands.flatMap(command => [
|
|
40
|
+
`${command.name}: ${command.description}`,
|
|
41
|
+
...(command.discovery ? [
|
|
42
|
+
` Table: ${command.discovery.table}; default columns: ${command.discovery.defaultColumns.join(', ')}`,
|
|
43
|
+
...command.discovery.benefits.map(benefit => ` ${benefit}`),
|
|
44
|
+
] : []),
|
|
45
|
+
` ${command.help}`,
|
|
46
|
+
...(command.actions || []).map(action => ` ${action.help} — ${action.description}`),
|
|
47
|
+
]);
|
|
48
|
+
if (!commands.length) lines.push('No registered matches. Try fewer words or use generic records for any accessible table: jsn records list --help');
|
|
49
|
+
app.ok({ commands, _formatted: lines.join('\n') }, {
|
|
50
|
+
summary: `${commands.length} matching command(s). Use jsn discover --help for guidance by intent.`,
|
|
51
|
+
});
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
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. */ }
|