@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.
Files changed (80) hide show
  1. package/README.md +137 -0
  2. package/package.json +4 -3
  3. package/skills/servicenow/SKILL.md +20 -172
  4. package/src/app.js +29 -40
  5. package/src/auth-recovery.js +83 -0
  6. package/src/auth.js +2 -0
  7. package/src/capabilities.js +9 -0
  8. package/src/cli-adapter.js +12 -0
  9. package/src/cli.js +45 -9
  10. package/src/command-audience.js +38 -0
  11. package/src/commands/_generic.js +45 -28
  12. package/src/commands/_simple.js +30 -33
  13. package/src/commands/_ticket.js +24 -20
  14. package/src/commands/attachments.js +88 -0
  15. package/src/commands/auth.js +94 -19
  16. package/src/commands/b4rules.js +8 -3
  17. package/src/commands/catalog-order.js +93 -0
  18. package/src/commands/catalog.js +8 -10
  19. package/src/commands/changes.js +2 -4
  20. package/src/commands/cmdb-search.js +38 -0
  21. package/src/commands/cmdb.js +2 -0
  22. package/src/commands/columns.js +2 -1
  23. package/src/commands/customizations.js +13 -7
  24. package/src/commands/discover.js +54 -0
  25. package/src/commands/docs/db.js +9 -1
  26. package/src/commands/docs/docs.js +25 -2
  27. package/src/commands/docs/ingest.js +20 -3
  28. package/src/commands/docs/install.js +86 -0
  29. package/src/commands/docs/locks.js +34 -0
  30. package/src/commands/docs/refresh.js +22 -2
  31. package/src/commands/docs/replace.js +30 -0
  32. package/src/commands/docs/snapshot-check.js +11 -0
  33. package/src/commands/docs/snapshot.js +130 -0
  34. package/src/commands/docs/sync.js +16 -6
  35. package/src/commands/forms.js +22 -97
  36. package/src/commands/history.js +57 -0
  37. package/src/commands/incidents.js +2 -4
  38. package/src/commands/knowledge.js +75 -0
  39. package/src/commands/midservers.js +117 -10
  40. package/src/commands/records.js +57 -104
  41. package/src/commands/requests.js +9 -15
  42. package/src/commands/rest.js +15 -2
  43. package/src/commands/scan.js +234 -0
  44. package/src/commands/scopes.js +1 -1
  45. package/src/commands/setup.js +46 -9
  46. package/src/commands/skill.js +73 -76
  47. package/src/commands/tasks.js +2 -4
  48. package/src/commands/tickets.js +20 -19
  49. package/src/commands/uipolicies.js +2 -27
  50. package/src/commands/updatesets.js +66 -13
  51. package/src/commands/upgrade.js +69 -0
  52. package/src/commands/version.js +14 -8
  53. package/src/constrained-query.js +20 -0
  54. package/src/context.js +6 -6
  55. package/src/customization-guidance.js +31 -0
  56. package/src/customizations-report.js +6 -0
  57. package/src/customizations.js +28 -13
  58. package/src/errors.js +23 -1
  59. package/src/essential-records.js +40 -0
  60. package/src/field-behavior.js +15 -0
  61. package/src/help.js +12 -2
  62. package/src/metadata/records.js +249 -0
  63. package/src/midserver-linux-remote.js +210 -0
  64. package/src/midserver-remote-recipe.js +49 -0
  65. package/src/midserver-remote.js +221 -0
  66. package/src/midserver-windows-remote.js +318 -0
  67. package/src/midserver-zip.js +2 -2
  68. package/src/mutation-context.js +74 -0
  69. package/src/mutation-history.js +75 -0
  70. package/src/mutations.js +3 -2
  71. package/src/output.js +34 -2
  72. package/src/record-command-profiles.js +70 -0
  73. package/src/record-profiles.js +93 -0
  74. package/src/records-engine.js +57 -0
  75. package/src/related-lists.js +153 -0
  76. package/src/resolve-record.js +50 -28
  77. package/src/scan.js +249 -0
  78. package/src/sdk-batch.js +130 -0
  79. package/src/sdk.js +157 -18
  80. package/src/update-set-xml.js +89 -0
@@ -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
  }
@@ -1,105 +1,29 @@
1
1
  import { getStringField, interactiveList } from '../helpers.js';
2
2
 
3
- function getDisplayValue(record, key) {
4
- if (!record || typeof record !== 'object') return '';
5
- const val = record[key];
6
- if (val == null) return '';
7
- if (typeof val === 'string') return val;
8
- if (typeof val === 'object') {
9
- if (val.display_value != null && val.display_value !== '') return String(val.display_value);
10
- if (val.value != null) return String(val.value);
11
- }
12
- return String(val);
13
- }
14
-
15
- function getIntValue(record, key) {
16
- if (!record || typeof record !== 'object') return 0;
17
- const val = record[key];
18
- if (val == null) return 0;
19
- if (typeof val === 'number') return Math.floor(val);
20
- if (typeof val === 'string') { const n = parseInt(val, 10); return isNaN(n) ? 0 : n; }
21
- if (typeof val === 'object' && val.value != null) { const n = parseInt(val.value, 10); return isNaN(n) ? 0 : n; }
22
- return 0;
23
- }
24
-
25
- async function showForm(app, table, viewName) {
26
- // 1) Look up the view
27
- const viewParams = new URLSearchParams();
28
- viewParams.set('sysparm_limit', '1');
29
- viewParams.set('sysparm_fields', 'sys_id');
30
- viewParams.set('sysparm_display_value', 'all');
31
- viewParams.set('sysparm_query', `name=${viewName}`);
32
- let viewSysID = '';
33
- try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
34
- if (!viewSysID) {
35
- viewParams.set('sysparm_query', `title=${viewName}`);
36
- try { const vr = await app.sdk.list('sys_ui_view', viewParams); if (vr.length > 0) viewSysID = getStringField(vr[0], 'sys_id'); } catch { /* ignore */ }
37
- }
38
-
39
- // 2) Get sections for this table + view
40
- const secParams = new URLSearchParams();
41
- secParams.set('sysparm_limit', '200');
42
- secParams.set('sysparm_fields', 'sys_id,caption');
43
- secParams.set('sysparm_display_value', 'all');
44
- secParams.set('sysparm_query', `name=${table}^view=${viewName}^ORDERBYposition`);
45
- if (viewSysID) {
46
- secParams.set('sysparm_query', `name=${table}^view=${viewSysID}^ORDERBYposition`);
47
- }
48
- const sections = await app.sdk.list('sys_ui_section', secParams);
49
-
50
- // 3) Get elements for each section
51
- const sectionsOut = [];
52
- for (const sec of sections) {
53
- const secSysID = getStringField(sec, 'sys_id');
54
- const sectionCaption = getDisplayValue(sec, 'caption') || '(unnamed)';
3
+ import { createRecordMetadataReader } from '../metadata/records.js';
55
4
 
56
- let elements = [];
57
- if (secSysID) {
58
- const elemParams = new URLSearchParams();
59
- elemParams.set('sysparm_limit', '500');
60
- elemParams.set('sysparm_fields', 'element,type,label,position');
61
- elemParams.set('sysparm_display_value', 'all');
62
- elemParams.set('sysparm_query', `sys_ui_section=${secSysID}^ORDERBYposition`);
63
- try { elements = await app.sdk.list('sys_ui_element', elemParams); } catch { /* ignore */ }
64
- }
65
-
66
- sectionsOut.push({
67
- caption: sectionCaption,
68
- position: getIntValue(sec, 'position'),
69
- elements: elements
70
- .map(e => ({
71
- type: getDisplayValue(e, 'type'),
72
- label: getDisplayValue(e, 'label'),
73
- element: getDisplayValue(e, 'element'),
74
- position: getIntValue(e, 'position'),
75
- }))
76
- .sort((a, b) => a.position - b.position),
77
- });
5
+ export function formatFormMetadata(metadata) {
6
+ const lines = [`Form: ${metadata.table} → ${metadata.requested_view}`];
7
+ if (metadata.form?.inherited) lines.push(` Inherited layout: ${metadata.form.table}`);
8
+ if (!metadata.form) lines.push(' No unambiguous shared form layout is visible.');
9
+ for (const section of metadata.form?.sections || []) {
10
+ lines.push('', `── ${section.caption || '(unnamed)'} ──`);
11
+ for (const element of section.elements) lines.push(` ${element.label || element.element || '(unnamed)'}${element.type ? ` (${element.type})` : ''}`);
12
+ if (!section.elements.length) lines.push(' (no visible elements)');
78
13
  }
79
-
80
- sectionsOut.sort((a, b) => a.position - b.position);
81
-
82
- // Build formatted output
83
- const lines = [];
84
- lines.push(`Form: ${table} → ${viewName}`);
85
- if (viewSysID) lines.push(` View: ${app.getEffectiveInstance()}/nav_to.do?uri=sys_ui_view.do?sys_id=${viewSysID}`);
86
- lines.push('');
87
-
88
- for (const sec of sectionsOut) {
89
- const secLabel = sec.caption || '(unnamed)';
90
- lines.push(`── ${secLabel} ──`);
91
- if (sec.elements.length === 0) {
92
- lines.push(' (no elements)');
93
- }
94
- for (const el of sec.elements) {
95
- const elLabel = el.label || el.element || '(unnamed)';
96
- const typeStr = el.type ? ` (${el.type})` : '';
97
- lines.push(` ${elLabel}${typeStr}`);
98
- }
99
- lines.push('');
14
+ const configured = metadata.related_lists.filter(item => item.configured);
15
+ if (configured.length) {
16
+ lines.push('', 'Configured related lists:');
17
+ for (const list of configured) lines.push(` ${list.label || list.id} (${list.query_status})`);
100
18
  }
19
+ lines.push('', metadata.coverage.scope);
20
+ for (const warning of metadata.coverage.warnings) lines.push(` ${warning.source}: ${warning.reason}`);
21
+ return lines.join('\n');
22
+ }
101
23
 
102
- process.stdout.write(lines.join('\n') + '\n');
24
+ async function showForm(app, table, view) {
25
+ const metadata = await createRecordMetadataReader(app.sdk).read({ table, view });
26
+ app.ok({ ...metadata, _formatted: formatFormMetadata(metadata) }, { summary: `Form metadata for ${table}` });
103
27
  }
104
28
 
105
29
  export function formsCmd(wrap) {
@@ -164,7 +88,8 @@ export function formsCmd(wrap) {
164
88
  aliases: ['get'],
165
89
  describe: 'Show form layout',
166
90
  builder: (y) => y
167
- .option('view', { type: 'string', default: 'Default view', describe: 'View name' }),
91
+ .option('view', { type: 'string', default: 'Default view', describe: 'Shared view name, title or sys_id' })
92
+ .epilogue('Discovers shared layouts, inherited forms and related-list metadata. JSON includes coverage limits; custom relationship scripts are never executed. Generic records get presentation is unchanged.'),
168
93
  handler: wrap(async (argv, app) => {
169
94
  app.requireInstance();
170
95
  await showForm(app, argv.table, argv.view);
@@ -0,0 +1,57 @@
1
+ import { declareCapabilities } from '../capabilities.js';
2
+ import { listHistory, pruneHistory } from '../mutation-history.js';
3
+
4
+ export function historyCmd(wrap) {
5
+ declareCapabilities('history', { noInstance: true, skipDailyChecks: true });
6
+ const list = wrap(async (argv, app) => {
7
+ const entries = listHistory({ ...argv, instance: argv['instance-url'], profile: argv['profile-name'] });
8
+ app.ok({ entries, retentionDays: 7, _formatted: renderHistory(entries, { details: argv.details }) });
9
+ });
10
+ const filters = b => b
11
+ .option('details', { type: 'boolean', default: false, describe: 'Show full record IDs, instance URLs and reasons' })
12
+ .option('since', { type: 'string', describe: 'Earliest ISO timestamp' })
13
+ .option('until', { type: 'string', describe: 'Latest ISO timestamp' })
14
+ .option('instance-url', { type: 'string', describe: 'Filter by instance origin' })
15
+ .option('profile-name', { type: 'string', describe: 'Filter by recorded profile name (including removed profiles)' })
16
+ .option('table', { type: 'string', describe: 'Filter by table' })
17
+ .option('operation', { choices: ['create', 'update', 'delete'], type: 'string' })
18
+ .option('record', { type: 'string', describe: 'Filter by full resolved sys_id' })
19
+ .option('limit', { type: 'number', default: 50, describe: 'Maximum entries (1–10000)' });
20
+ return { command: 'history [subcommand]', describe: 'Local seven-day mutation history (not a durable ServiceNow audit)',
21
+ builder: y => filters(y).command({ command: 'list', describe: 'List local mutations', builder: filters, handler: list })
22
+ .command({ command: 'prune', describe: 'Remove expired entries only', handler: wrap(async (_argv, app) => {
23
+ app.ok({ ...pruneHistory(), _formatted: 'Expired local history removed; seven days retained.' });
24
+ }) }), handler: list };
25
+ }
26
+
27
+ export function renderHistory(entries, { details = false, timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone } = {}) {
28
+ if (!entries.length) return 'No local mutations in the last seven days.';
29
+ const date = new Intl.DateTimeFormat('en-GB', { timeZone, month: 'short', day: '2-digit',
30
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
31
+ // Strip terminal control characters from stored identifiers and labels.
32
+ // eslint-disable-next-line no-control-regex
33
+ const clean = value => String(value ?? '').replace(/[\x00-\x1f\x7f]/g, ' ');
34
+ const clip = (value, length) => value.length > length ? `${value.slice(0, length - 1)}…` : value;
35
+ const rows = entries.map(entry => {
36
+ let connection = entry.profile;
37
+ if (!connection) {
38
+ try { connection = new URL(entry.instance).host; } catch { connection = entry.instance; }
39
+ }
40
+ return [date.format(new Date(entry.timestamp)), entry.operation, clip(clean(entry.table), 22),
41
+ clip(clean(entry.sys_id || '(unknown)'), 12), entry.outcome, clip(clean(connection), 22)];
42
+ });
43
+ const headers = ['WHEN', 'ACTION', 'TABLE', 'RECORD', 'RESULT', 'CONNECTION'];
44
+ const widths = headers.map((header, i) => Math.max(header.length, ...rows.map(row => row[i].length)));
45
+ const format = row => row.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
46
+ const lines = [`Recent mutations · ${timeZone}`, '', format(headers)];
47
+ rows.forEach((row, i) => {
48
+ lines.push(format(row));
49
+ if (details) {
50
+ const entry = entries[i];
51
+ lines.push(` ${clean(entry.timestamp)} · ${clean(entry.table)}/${clean(entry.sys_id || '(unknown)')}`,
52
+ ` ${clean(entry.instance)}${entry.reason ? ` · ${clean(entry.reason)}` : ''}`);
53
+ }
54
+ });
55
+ if (!details) lines.push('', 'Use --details for full IDs, URLs and reasons; --json for structured history.');
56
+ return lines.join('\n');
57
+ }
@@ -1,7 +1,5 @@
1
- import { buildTicketCommands } from './_ticket.js';
2
-
3
- const incidentDefaultColumns = ['number', 'short_description', 'priority', 'state', 'assigned_to'];
1
+ import { buildProfileCommand } from '../record-profiles.js';
4
2
 
5
3
  export function incidentsCmd(wrap) {
6
- return buildTicketCommands('incident', 'incidents', 'inc', incidentDefaultColumns, {}, null, wrap);
4
+ return buildProfileCommand('incidents', wrap);
7
5
  }
@@ -0,0 +1,75 @@
1
+ import TurndownService from 'turndown';
2
+ import { formatCommandList, quoteShellArg } from '../helpers.js';
3
+ import { errAPI, errUsage } from '../errors.js';
4
+
5
+ const API = '/api/sn_km_api/knowledge/articles';
6
+ const markdown = new TurndownService();
7
+ const sysId = /^[a-f0-9]{32}$/i;
8
+ const articleId = value => typeof value === 'string' && (sysId.test(value) || /^KB\d+$/i.test(value));
9
+
10
+ function emit(app, argv, data, summary, breadcrumbs = []) {
11
+ const connection = argv.profile ? ` --profile ${quoteShellArg(argv.profile)}`
12
+ : argv.instance ? ` --instance ${quoteShellArg(argv.instance)}` : '';
13
+ app.ok(data, { summary, breadcrumbs: breadcrumbs.map(h => ({ ...h, cmd: h.cmd + connection })) });
14
+ }
15
+
16
+ async function read(sdk, path, params) {
17
+ const response = await sdk.request(`${sdk.baseURL}${API}${path}?${params}`, { method: 'GET' });
18
+ const result = response?.result;
19
+ if (!result || typeof result !== 'object' || Array.isArray(result)) throw errAPI(200, 'Malformed Knowledge API response.');
20
+ const code = result.meta?.status?.code;
21
+ if (code !== undefined && Number(code) !== 200) throw errAPI(Number(code) || 500, 'Knowledge API rejected the request.');
22
+ return result;
23
+ }
24
+
25
+ export function knowledgeCmd(wrap) {
26
+ return {
27
+ command: 'knowledge [subcommand]', aliases: ['kb'],
28
+ describe: 'Search and read knowledge articles through the Knowledge API',
29
+ handler: wrap(async (argv, app) => emit(app, argv,
30
+ { subcommands: argv.subcommands || [], _formatted: formatCommandList({ title: 'Knowledge articles', commands: argv.subcommands || [] }) },
31
+ 'Search and read knowledge articles', [{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find accessible articles' }])),
32
+ builder: y => y
33
+ .epilogue('Requires the Knowledge API (sn_km_api). Uses its access checks; no fallback to direct knowledge-table reads. Reading does not request view-count updates.')
34
+ .command({ command: 'search <text>', describe: 'Search accessible knowledge articles',
35
+ builder: p => p.positional('text', { type: 'string', describe: 'Search text; quote multiple words' })
36
+ .option('limit', { type: 'number', default: 20, describe: 'Maximum articles (1–100)' })
37
+ .option('offset', { type: 'number', default: 0, describe: 'Starting result offset' })
38
+ .option('kb', { type: 'string', describe: 'Comma-separated knowledge-base sys_ids' })
39
+ .option('language', { type: 'string', describe: 'Two-letter language codes, comma-separated, or all' }),
40
+ handler: wrap(async (argv, app) => {
41
+ if (typeof argv.text !== 'string' || !argv.text.trim()) throw errUsage('Provide nonempty search text.');
42
+ if (!Number.isInteger(argv.limit) || argv.limit < 1 || argv.limit > 100) throw errUsage('--limit must be from 1 to 100.');
43
+ if (!Number.isInteger(argv.offset) || argv.offset < 0) throw errUsage('--offset must be a nonnegative integer.');
44
+ if (argv.kb !== undefined && !String(argv.kb).split(',').every(id => sysId.test(id))) throw errUsage('--kb requires knowledge-base sys_ids.');
45
+ if (argv.language !== undefined && !/^(all|[a-z]{2}(,[a-z]{2})*)$/i.test(argv.language)) throw errUsage('Use two-letter --language codes or all.');
46
+ const params = new URLSearchParams({ query: argv.text.trim(), limit: String(argv.limit), offset: String(argv.offset) });
47
+ if (argv.kb) params.set('kb', argv.kb);
48
+ if (argv.language) params.set('language', argv.language);
49
+ const result = await read(app.sdk, '', params);
50
+ if (!Array.isArray(result.articles) || result.articles.some(a => !a || typeof a !== 'object' || Array.isArray(a))) throw errAPI(200, 'Knowledge API returned no valid article list.');
51
+ const breadcrumbs = result.articles.flatMap(a => {
52
+ const id = articleId(a.number) ? a.number : String(a.id || '').replace(/^kb_knowledge:/, '');
53
+ return articleId(id) ? [{ action: 'show', cmd: `jsn knowledge show ${id}`, description: a.title || id }] : [];
54
+ });
55
+ const base = `jsn knowledge search ${quoteShellArg(argv.text.trim())} --limit ${argv.limit}`
56
+ + (argv.kb ? ` --kb ${quoteShellArg(argv.kb)}` : '') + (argv.language ? ` --language ${quoteShellArg(argv.language)}` : '');
57
+ const total = result.meta?.count;
58
+ const hasMore = typeof total === 'number' ? argv.offset + result.articles.length < total : result.articles.length === argv.limit;
59
+ if (hasMore) breadcrumbs.push({ action: 'next', cmd: `${base} --offset ${argv.offset + argv.limit}`, description: 'Next result page' });
60
+ emit(app, argv, { ...result, count: result.articles.length,
61
+ _formatted: result.articles.map(a => `${a.number || a.id || ''} ${a.title || ''}\n${a.snippet || ''}`).join('\n\n') || 'No accessible articles matched.' },
62
+ `${result.articles.length} knowledge article(s)`, breadcrumbs);
63
+ }) })
64
+ .command({ command: 'show <id>', describe: 'Read an article by KB number or sys_id',
65
+ builder: p => p.positional('id', { type: 'string' }),
66
+ handler: wrap(async (argv, app) => {
67
+ if (!articleId(argv.id)) throw errUsage('Use a KB number or full article sys_id.');
68
+ const article = await read(app.sdk, `/${encodeURIComponent(argv.id)}`, new URLSearchParams());
69
+ if (!sysId.test(article.sys_id || '') || typeof article.content !== 'string') throw errAPI(200, 'Knowledge API returned no readable article content.');
70
+ emit(app, argv, { ...article, _formatted: `${article.number || argv.id}: ${article.short_description || ''}\n\n${markdown.turndown(article.content)}` },
71
+ article.short_description || article.number || argv.id,
72
+ [{ action: 'search', cmd: 'jsn knowledge search <text>', description: 'Find more articles' }]);
73
+ }) }),
74
+ };
75
+ }