@snutils/snu 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +2 -1
  2. package/dist/cli/doctor.d.ts +10 -0
  3. package/dist/cli/doctor.d.ts.map +1 -1
  4. package/dist/cli/doctor.js +25 -0
  5. package/dist/cli/doctor.js.map +1 -1
  6. package/dist/cli/format.d.ts.map +1 -1
  7. package/dist/cli/format.js +7 -1
  8. package/dist/cli/format.js.map +1 -1
  9. package/dist/cli/index.d.ts +11 -0
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/index.js +49 -1
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/mcp/index.d.ts.map +1 -1
  14. package/dist/mcp/index.js +12 -0
  15. package/dist/mcp/index.js.map +1 -1
  16. package/dist/registry.d.ts.map +1 -1
  17. package/dist/registry.js +63 -4
  18. package/dist/registry.js.map +1 -1
  19. package/dist/server/authProbe.d.ts +29 -0
  20. package/dist/server/authProbe.d.ts.map +1 -0
  21. package/dist/server/authProbe.js +67 -0
  22. package/dist/server/authProbe.js.map +1 -0
  23. package/dist/server/bridgeIdentity.d.ts +24 -0
  24. package/dist/server/bridgeIdentity.d.ts.map +1 -0
  25. package/dist/server/bridgeIdentity.js +120 -0
  26. package/dist/server/bridgeIdentity.js.map +1 -0
  27. package/dist/server/dispatcher.d.ts +11 -1
  28. package/dist/server/dispatcher.d.ts.map +1 -1
  29. package/dist/server/dispatcher.js +239 -78
  30. package/dist/server/dispatcher.js.map +1 -1
  31. package/dist/server/helperConnection.d.ts +24 -1
  32. package/dist/server/helperConnection.d.ts.map +1 -1
  33. package/dist/server/helperConnection.js +54 -4
  34. package/dist/server/helperConnection.js.map +1 -1
  35. package/dist/server/httpBridge.d.ts.map +1 -1
  36. package/dist/server/httpBridge.js +1 -0
  37. package/dist/server/httpBridge.js.map +1 -1
  38. package/dist/server/policy.d.ts.map +1 -1
  39. package/dist/server/policy.js +10 -1
  40. package/dist/server/policy.js.map +1 -1
  41. package/dist/server/scopeDiscovery.d.ts +37 -0
  42. package/dist/server/scopeDiscovery.d.ts.map +1 -0
  43. package/dist/server/scopeDiscovery.js +147 -0
  44. package/dist/server/scopeDiscovery.js.map +1 -0
  45. package/dist/server/wsBridge.d.ts +4 -1
  46. package/dist/server/wsBridge.d.ts.map +1 -1
  47. package/dist/server/wsBridge.js +59 -6
  48. package/dist/server/wsBridge.js.map +1 -1
  49. package/package.json +1 -1
@@ -33,8 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.StandaloneDispatcher = exports.HELPER_CONNECT_GUIDANCE = void 0;
36
+ exports.StandaloneDispatcher = exports.HELPER_CONNECT_GUIDANCE = exports.BROWSER_ACTIONS = void 0;
37
37
  exports.resolveMappedFileName = resolveMappedFileName;
38
+ const authProbe_js_1 = require("./authProbe.js");
39
+ const scopeDiscovery_js_1 = require("./scopeDiscovery.js");
38
40
  const fs = __importStar(require("fs"));
39
41
  const os = __importStar(require("os"));
40
42
  const path = __importStar(require("path"));
@@ -46,6 +48,27 @@ const canonical_js_1 = require("./canonical.js");
46
48
  const types_js_1 = require("../types.js");
47
49
  const scopeResolver_js_1 = require("./scopeResolver.js");
48
50
  const FOLDERRECORDTABLES = ['sp_widget', 'sp_header_footer', 'sys_ui_page'];
51
+ // Helper tab action names this host sends for the browser-relayed commands.
52
+ // The helper tab dispatches on the exact string and drops an unknown action
53
+ // without answering, so a wrong name here only ever surfaces as E_TIMEOUT
54
+ // 70 seconds later (take_screenshot shipped as 'agentTakeScreenshot' and never
55
+ // worked in standalone mode). Keep in step with the dispatch chain in the
56
+ // extension's scriptsync.js; browserRelay.test.ts pins these values.
57
+ exports.BROWSER_ACTIONS = {
58
+ get_form_state: 'agentGetFormState',
59
+ set_field: 'agentSetField',
60
+ run_ui_action: 'agentRunUiAction',
61
+ navigate: 'agentNavigate',
62
+ take_screenshot: 'takeScreenshot',
63
+ switch_context: 'switchContext',
64
+ };
65
+ const SWITCH_CONTEXT_TYPES = ['updateset', 'application', 'domain'];
66
+ const SWITCH_CONTEXT_ALIASES = {
67
+ app: 'application',
68
+ scope: 'application',
69
+ update_set: 'updateset',
70
+ 'update-set': 'updateset',
71
+ };
49
72
  // Human labels + the environment variable each gate is *actually* read from in
50
73
  // config.ts. Deriving the variable name from the camelCase gate key produces
51
74
  // SNU_ALLOW_RESTREQUEST, which nothing reads, so an agent told to set it hits
@@ -166,11 +189,11 @@ function resolveTableCodeFields(tableName, cwd) {
166
189
  }
167
190
  return ['script'];
168
191
  }
169
- function resolveFieldExtension(tableName, fieldName, cwd) {
192
+ function resolveFieldExtension(tableName, fieldName, cwd, liveType) {
170
193
  const meta = getMetaDataRelations(cwd);
171
194
  let fieldType = 'script';
172
195
  try {
173
- fieldType = meta?.tableFields?.[tableName]?.codeFields?.[fieldName]?.type || fieldName;
196
+ fieldType = liveType || meta?.tableFields?.[tableName]?.codeFields?.[fieldName]?.type || fieldName;
174
197
  }
175
198
  catch { }
176
199
  let ext = FIELDTYPES[fieldType]?.extension;
@@ -251,6 +274,8 @@ class StandaloneDispatcher {
251
274
  pending;
252
275
  config;
253
276
  pendingReviewRequests = new Map(); // correlationId/requestId -> reviewId
277
+ /** Pause before the single retry after E_SCREENSHOT_PERMISSION; tests shorten it. */
278
+ screenshotRetryDelayMs = 10_000;
254
279
  requestIdToCorrelationId = new Map(); // requestId -> correlationId
255
280
  constructor(opts) {
256
281
  this.cwd = opts.cwd || process.cwd();
@@ -475,7 +500,7 @@ class StandaloneDispatcher {
475
500
  sysparm_offset: String(offset),
476
501
  sysparm_display_value: 'false',
477
502
  sysparm_exclude_reference_link: 'true',
478
- sysparm_no_count: 'true',
503
+ sysparm_no_count: 'false',
479
504
  };
480
505
  if (combinedQuery) {
481
506
  queryParams.sysparm_query = combinedQuery;
@@ -499,6 +524,7 @@ class StandaloneDispatcher {
499
524
  const isFolderRecordTable = FOLDERRECORDTABLES.includes(table);
500
525
  let filesWritten = 0;
501
526
  let skippedEmpty = 0;
527
+ let skippedUnreadable = 0;
502
528
  const warnings = [];
503
529
  const pulledRecordsList = [];
504
530
  for (const rec of matchedRecords) {
@@ -553,7 +579,12 @@ class StandaloneDispatcher {
553
579
  }
554
580
  const recordFiles = [];
555
581
  for (const field of codeFields) {
556
- const ext = resolveFieldExtension(table, field, this.cwd);
582
+ if (!Object.prototype.hasOwnProperty.call(rec, field)) {
583
+ skippedUnreadable++;
584
+ warnings.push(`${table}/${sysId}.${field}: field not returned; local file preserved.`);
585
+ continue;
586
+ }
587
+ const ext = resolveFieldExtension(table, field, this.cwd, opts.fieldDefinitions?.[field]?.type);
557
588
  let targetPath;
558
589
  try {
559
590
  targetPath = isFolderRecordTable
@@ -603,27 +634,18 @@ class StandaloneDispatcher {
603
634
  });
604
635
  }
605
636
  return {
637
+ pagination: res.pagination,
606
638
  table,
607
639
  matchedRecords: matchedRecords.length,
608
640
  pulledRecords: pulledRecordsList.length,
609
641
  filesWritten,
610
642
  skippedEmpty,
643
+ skippedUnreadable,
611
644
  warnings,
612
645
  records: pulledRecordsList,
613
646
  };
614
647
  }
615
- // Which artifact tables snu knows how to write to disk: the ones in
616
- // resources/metaDataRelations.json that declare code fields.
617
- allCodeTables() {
618
- const meta = getMetaDataRelations(this.cwd);
619
- const out = new Set();
620
- for (const [table, def] of Object.entries(meta?.tableFields || {})) {
621
- if (def?.codeFields && typeof def.codeFields === 'object')
622
- out.add(table);
623
- }
624
- return out;
625
- }
626
- async restGet(inst, reqCommand, endpoint, queryParams) {
648
+ async restGet(inst, reqCommand, endpoint, queryParams, withPagination = false) {
627
649
  const correlationId = crypto.randomUUID();
628
650
  const pendingPromise = this.pending.register({ id: correlationId, command: reqCommand, timeoutMs: 70_000 });
629
651
  this.ws.sendToBrowser({
@@ -637,9 +659,10 @@ class StandaloneDispatcher {
637
659
  });
638
660
  const res = await pendingPromise;
639
661
  if (res?.success === false) {
640
- throw Object.assign(new Error(res.error || `REST request failed: ${endpoint}`), { code: res.code || 'E_COMMAND_FAILED' });
662
+ const hint = res?.status ? '' : (0, authProbe_js_1.helperFailureHint)(typeof res?.error === 'string' ? res.error : '', inst.settings?.url);
663
+ throw Object.assign(new Error([res.error || `REST request failed: ${endpoint}`, hint].filter(Boolean).join(' ')), { code: res.code || 'E_COMMAND_FAILED' });
641
664
  }
642
- return res?.data;
665
+ return withPagination ? { rows: res?.data?.result, pagination: res?.pagination } : res?.data;
643
666
  }
644
667
  resolveInstance(requestInstance) {
645
668
  const folders = this.listInstanceFolders();
@@ -680,8 +703,16 @@ class StandaloneDispatcher {
680
703
  if (live) {
681
704
  return { name: live.name, folder: this.cwd, settings: live };
682
705
  }
683
- throw Object.assign(new Error(`No authenticated instance named "${requestInstance}" is connected. Run /token on that ServiceNow instance and retry.`), {
706
+ const known = [
707
+ ...liveInstances.map((candidate) => `${candidate.name} (${candidate.url})`),
708
+ ...folders.map((folder) => path.basename(folder)),
709
+ ];
710
+ const knownText = known.length
711
+ ? ` Known here: ${[...new Set(known)].join(', ')}. Pass one of these as the instance, by name, hostname or origin.`
712
+ : ' This bridge has not received any session since it started.';
713
+ throw Object.assign(new Error(`No authenticated instance named "${requestInstance}" is connected.${knownText} Otherwise open that exact hostname in the browser, run /token and retry.`), {
684
714
  code: 'E_INSTANCE_NOT_FOUND',
715
+ details: { requested: requestInstance, liveInstances: liveInstances.map((candidate) => ({ name: candidate.name, url: candidate.url })) },
685
716
  });
686
717
  }
687
718
  if (folders.length === 1) {
@@ -731,6 +762,88 @@ class StandaloneDispatcher {
731
762
  this.pending.cancel(requestId, reason);
732
763
  this.requestIdToCorrelationId.delete(requestId);
733
764
  }
765
+ // Screenshot round trip, mirroring the VS Code host: the helper answers a
766
+ // takeScreenshot with base64 PNG data and this side writes the file under
767
+ // <workspace>/screenshots. allowDebugger carries the user's browserDebugger
768
+ // gate so a tab without an activeTab grant can be captured through the
769
+ // Chrome debugger (Debug edition + Pro; the helper re-checks both). Without
770
+ // it, or when the debugger path is not available, the helper asks for the
771
+ // extension-icon click and this side retries once after a pause.
772
+ async captureScreenshot(req, correlationId) {
773
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
774
+ let fileName = typeof req.params?.fileName === 'string' && req.params.fileName.trim()
775
+ ? req.params.fileName.trim()
776
+ : `screenshot_${timestamp}.png`;
777
+ if (!/\.png$/i.test(fileName))
778
+ fileName += '.png';
779
+ const savePath = safeJoinUnderRoot(this.cwd, 'screenshots', fileName);
780
+ const request = async (id, tabId) => {
781
+ const pendingPromise = this.pending.register({ id, command: req.command, timeoutMs: 70_000 });
782
+ this.ws.sendToBrowser({
783
+ action: exports.BROWSER_ACTIONS.take_screenshot,
784
+ agentRequestId: id,
785
+ url: req.params?.url,
786
+ tabId,
787
+ exactUrl: req.params?.exactUrl === true,
788
+ focus: req.params?.focus === true,
789
+ fileName,
790
+ savePath,
791
+ allowDebugger: this.config.gates.browserDebugger === true,
792
+ appName: 'SN Utils CLI',
793
+ });
794
+ try {
795
+ return await pendingPromise;
796
+ }
797
+ catch (err) {
798
+ // The bridge rejects a failed helper reply; a permission refusal is
799
+ // handled here (retry), so hand it back as a plain result.
800
+ if (err?.code === 'E_SCREENSHOT_PERMISSION') {
801
+ return {
802
+ success: false,
803
+ code: err.code,
804
+ error: err.message,
805
+ tabId: err.details?.tabId,
806
+ tabUrl: err.details?.tabUrl,
807
+ cdpFallbackAvailable: err.details?.cdpFallbackAvailable,
808
+ };
809
+ }
810
+ throw err;
811
+ }
812
+ };
813
+ let res = await request(correlationId, req.params?.tabId);
814
+ if (res?.code === 'E_SCREENSHOT_PERMISSION') {
815
+ // Give the user time to click the extension icon on the tab, then retry
816
+ // once on the tab the helper named.
817
+ await new Promise((resolve) => setTimeout(resolve, this.screenshotRetryDelayMs));
818
+ if (!this.ws.hasBrowserClient()) {
819
+ throw Object.assign(new Error('Browser helper disconnected while waiting for screenshot permission.'), { code: 'E_BROWSER_DISCONNECTED' });
820
+ }
821
+ res = await request(`${correlationId}_retry`, res.tabId ?? req.params?.tabId);
822
+ }
823
+ if (res?.code === 'E_SCREENSHOT_PERMISSION') {
824
+ throw Object.assign(new Error(res.error || 'Browser denied the screenshot (tab not capturable / permission).'), {
825
+ code: 'E_SCREENSHOT_PERMISSION',
826
+ details: { tabId: res.tabId, tabUrl: res.tabUrl, cdpFallbackAvailable: res.cdpFallbackAvailable === true },
827
+ });
828
+ }
829
+ if (res?.success === false) {
830
+ throw Object.assign(new Error(res.error || 'Screenshot failed'), { code: res.code || 'E_COMMAND_FAILED' });
831
+ }
832
+ if (typeof res?.imageData !== 'string' || !res.imageData) {
833
+ throw Object.assign(new Error('No image data received from browser'), { code: 'E_INTERNAL' });
834
+ }
835
+ fs.mkdirSync(path.dirname(savePath), { recursive: true });
836
+ fs.writeFileSync(savePath, Buffer.from(res.imageData, 'base64'));
837
+ return {
838
+ saved: true,
839
+ filePath: savePath,
840
+ fileName,
841
+ url: res.url || res.tabUrl || req.params?.url,
842
+ tabId: res.tabId ?? req.params?.tabId,
843
+ tabTitle: res.tabTitle,
844
+ capturedVia: res.capturedVia || 'activeTab',
845
+ };
846
+ }
734
847
  async dispatch(req) {
735
848
  const correlationId = `snu_req_${req.id}_${Date.now()}`;
736
849
  this.requestIdToCorrelationId.set(req.id, correlationId);
@@ -792,6 +905,18 @@ class StandaloneDispatcher {
792
905
  message: 'The SN Utils helper tab is not connected, so the session cannot be checked.',
793
906
  });
794
907
  }
908
+ const hasLiveSession = !!origin && this.ws.getLiveInstances().some((live) => {
909
+ try {
910
+ return new URL(live.url).origin.toLowerCase() === origin;
911
+ }
912
+ catch {
913
+ return false;
914
+ }
915
+ });
916
+ if (!inst.settings?.g_ck) {
917
+ const verdict = (0, authProbe_js_1.classifyProbe)({ error: 'Missing instance URL or authentication token', origin, hasLiveSession });
918
+ return reply({ ok: false, state: verdict.state, message: verdict.message });
919
+ }
795
920
  const correlationId = crypto.randomUUID();
796
921
  const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 15_000 });
797
922
  this.ws.sendToBrowser({
@@ -805,32 +930,25 @@ class StandaloneDispatcher {
805
930
  });
806
931
  try {
807
932
  const res = await pendingPromise;
808
- const status = res?.status;
809
933
  // 401 and 403 mean opposite things: the session is gone, versus the
810
- // session authenticated and an ACL refused the row.
811
- const state = res?.success === false
812
- ? status === 401
813
- ? 'AUTH_EXPIRED'
814
- : status === 403
815
- ? 'AUTH_OK'
816
- : 'AUTH_UNKNOWN'
817
- : 'AUTH_OK';
934
+ // session authenticated and an ACL refused the row. Failures without
935
+ // a status are classified from the helper tab's own error text.
936
+ const verdict = res?.success === false
937
+ ? (0, authProbe_js_1.classifyProbe)({ status: res?.status, error: typeof res?.error === 'string' ? res.error : res?.error?.message, origin, hasLiveSession })
938
+ : (0, authProbe_js_1.classifyProbe)({ status: 200, origin, hasLiveSession });
818
939
  return reply({
819
- ok: state === 'AUTH_OK',
820
- state,
821
- message: state === 'AUTH_OK'
822
- ? 'ServiceNow accepted the session.'
823
- : state === 'AUTH_EXPIRED'
824
- ? 'ServiceNow rejected the session (401). Open the instance in the browser and run /token to refresh it.'
825
- : 'The session check could not complete. This is not a verdict — retry before acting on it.',
940
+ ok: verdict.state === 'AUTH_OK',
941
+ state: verdict.state,
942
+ message: verdict.message,
826
943
  detail: res?.error,
827
944
  });
828
945
  }
829
946
  catch (err) {
947
+ const verdict = (0, authProbe_js_1.classifyProbe)({ status: err?.details?.status, error: err?.message || String(err), origin, hasLiveSession });
830
948
  return reply({
831
949
  ok: false,
832
- state: 'AUTH_UNKNOWN',
833
- message: 'The session check could not complete. This is not a verdict — retry before acting on it.',
950
+ state: verdict.state,
951
+ message: verdict.message,
834
952
  detail: err?.message || String(err),
835
953
  });
836
954
  }
@@ -1427,64 +1545,66 @@ class StandaloneDispatcher {
1427
1545
  }
1428
1546
  catch { }
1429
1547
  }
1430
- // Which artifact tables does this application actually use?
1548
+ const fetchPage = (table, query) => this.restGet(inst, req.command, `/api/now/table/${table}`, query, true);
1549
+ const discovery = await (0, scopeDiscovery_js_1.readAllPages)(fetchPage, 'sys_metadata', `sys_scope=${scopeSysId}^sys_class_name!=sys_metadata_delete^sys_update_name!=NULL^ORDERBYsys_id`, 'sys_class_name');
1431
1550
  const classCounts = new Map();
1432
- const metaPage = 1000;
1433
- for (let offset = 0;; offset += metaPage) {
1434
- const data = await this.restGet(inst, req.command, '/api/now/table/sys_metadata', {
1435
- sysparm_query: `sys_scope=${scopeSysId}^sys_class_name!=sys_metadata_delete^sys_update_name!=NULL^ORDERBYsys_id`,
1436
- sysparm_fields: 'sys_class_name',
1437
- sysparm_limit: String(metaPage),
1438
- sysparm_offset: String(offset),
1439
- sysparm_exclude_reference_link: 'true',
1440
- sysparm_no_count: 'true',
1441
- });
1442
- const rows = Array.isArray(data?.result) ? data.result : [];
1443
- for (const r of rows) {
1444
- const cls = String(typeof r.sys_class_name === 'object' ? r.sys_class_name.value : r.sys_class_name || '');
1445
- if (cls)
1446
- classCounts.set(cls, (classCounts.get(cls) || 0) + 1);
1447
- }
1448
- if (rows.length < metaPage)
1449
- break;
1551
+ for (const row of discovery.rows) {
1552
+ const cls = String(typeof row.sys_class_name === 'object' ? row.sys_class_name.value : row.sys_class_name || '');
1553
+ if (/^[a-zA-Z0-9_]+$/.test(cls))
1554
+ classCounts.set(cls, (classCounts.get(cls) || 0) + 1);
1450
1555
  }
1451
- const codeTables = this.allCodeTables();
1452
- const tables = [...classCounts.keys()].filter((t) => codeTables.has(t) && (!tableFilter || tableFilter.has(t))).sort();
1453
- const skippedTables = [...classCounts.keys()].filter((t) => !codeTables.has(t)).sort()
1454
- .map((t) => ({ table: t, records: classCounts.get(t) || 0 }));
1556
+ const schema = await (0, scopeDiscovery_js_1.discoverScopeFields)([...classCounts.keys()], getMetaDataRelations(this.cwd)?.tableFields || {}, fetchPage);
1557
+ const tables = [...classCounts.keys()].filter(t => Object.keys(schema.definitions[t]?.codeFields || {}).length && (!tableFilter || tableFilter.has(t))).sort();
1558
+ const skippedTables = [...classCounts.keys()].filter(t => !Object.keys(schema.definitions[t]?.codeFields || {}).length).sort()
1559
+ .map(table => ({ table, records: classCounts.get(table) || 0, reason: schema.warnings.some(w => w.startsWith(`${table}:`)) ? 'dictionary_unavailable' : 'no_scriptable_fields' }));
1455
1560
  const missingTables = tableFilter ? [...tableFilter].filter((t) => !classCounts.has(t)).sort() : [];
1456
1561
  const perTable = [];
1457
- const warnings = [];
1458
- let totalRecords = 0, totalFiles = 0, totalSkippedEmpty = 0;
1562
+ const warnings = [...discovery.warnings, ...schema.warnings];
1563
+ let complete = discovery.complete && schema.warnings.length === 0;
1564
+ let totalRecords = 0, totalFiles = 0, totalSkippedEmpty = 0, totalSkippedUnreadable = 0;
1459
1565
  for (const table of tables) {
1460
- const codeFields = resolveTableCodeFields(table, this.cwd);
1566
+ const fieldDefinitions = schema.definitions[table].codeFields;
1567
+ const codeFields = Object.keys(fieldDefinitions);
1461
1568
  const query = `sys_scope=${scopeSysId}^sys_class_name=${table}^ORDERBYsys_id`;
1462
- const entry = { table, matchedRecords: 0, pulledRecords: 0, filesWritten: 0, skippedEmpty: 0, truncated: false };
1569
+ const entry = { table, matchedRecords: 0, pulledRecords: 0, filesWritten: 0, skippedEmpty: 0, skippedUnreadable: 0, truncated: false };
1463
1570
  if (includeRecords)
1464
1571
  entry.records = [];
1465
- for (let offset = 0;; offset += PAGE) {
1572
+ let reportedTotal;
1573
+ for (let offset = 0;;) {
1466
1574
  const pageLimit = Math.min(PAGE, limit - entry.matchedRecords);
1467
1575
  if (pageLimit <= 0) {
1468
1576
  entry.truncated = true;
1469
1577
  break;
1470
1578
  }
1471
- const page = await this.pullTablePage(inst, req.command, { table, query, codeFields, limit: pageLimit, offset });
1579
+ const page = await this.pullTablePage(inst, req.command, { table, query, codeFields, fieldDefinitions, limit: pageLimit, offset });
1580
+ if (page.pagination?.totalCount && /^\d+$/.test(page.pagination.totalCount))
1581
+ reportedTotal = Number(page.pagination.totalCount);
1472
1582
  entry.matchedRecords += page.matchedRecords;
1473
1583
  entry.pulledRecords += page.pulledRecords;
1474
1584
  entry.filesWritten += page.filesWritten;
1475
1585
  entry.skippedEmpty += page.skippedEmpty;
1586
+ entry.skippedUnreadable += page.skippedUnreadable;
1476
1587
  if (includeRecords)
1477
1588
  entry.records.push(...page.records);
1478
1589
  for (const w of page.warnings)
1479
1590
  warnings.push(`${table}: ${w}`);
1480
- if (page.matchedRecords < pageLimit)
1591
+ const paging = (0, scopeDiscovery_js_1.pageState)(page.pagination, offset, pageLimit, page.matchedRecords);
1592
+ if (paging.nextOffset === undefined) {
1593
+ complete = complete && paging.complete;
1594
+ if (paging.warning)
1595
+ warnings.push(`${table}: ${paging.warning}`);
1481
1596
  break;
1597
+ }
1598
+ offset = paging.nextOffset;
1482
1599
  }
1600
+ if (!entry.truncated && reportedTotal !== undefined && reportedTotal > entry.matchedRecords)
1601
+ warnings.push(`${table}: ${reportedTotal - entry.matchedRecords} matching records were not returned; access controls or changing records may account for the difference.`);
1483
1602
  if (entry.truncated)
1484
1603
  warnings.push(`${table}: stopped at the per-table limit of ${limit} records; raise "limit" or pull the rest with pull_records.`);
1485
1604
  totalRecords += entry.pulledRecords;
1486
1605
  totalFiles += entry.filesWritten;
1487
1606
  totalSkippedEmpty += entry.skippedEmpty;
1607
+ totalSkippedUnreadable += entry.skippedUnreadable;
1488
1608
  perTable.push(entry);
1489
1609
  }
1490
1610
  for (const t of missingTables)
@@ -1495,10 +1615,12 @@ class StandaloneDispatcher {
1495
1615
  status: 'success',
1496
1616
  timestamp: Date.now(),
1497
1617
  result: {
1618
+ complete: complete && !perTable.some(t => t.truncated) && warnings.length === 0,
1619
+ metadataRecords: discovery.rows.length,
1498
1620
  scope: { name: scopeName, sys_id: scopeSysId },
1499
1621
  folder: path.join(inst.name, scopeName).replace(/\\/g, '/'),
1500
1622
  tables: perTable,
1501
- totals: { tables: perTable.length, records: totalRecords, filesWritten: totalFiles, skippedEmpty: totalSkippedEmpty },
1623
+ totals: { tables: perTable.length, records: totalRecords, filesWritten: totalFiles, skippedEmpty: totalSkippedEmpty, skippedUnreadable: totalSkippedUnreadable },
1502
1624
  skippedTables,
1503
1625
  warnings,
1504
1626
  },
@@ -1603,16 +1725,55 @@ class StandaloneDispatcher {
1603
1725
  result: { status: res.status, data: res.data },
1604
1726
  };
1605
1727
  }
1606
- // Browser Form & UI Actions
1607
- if (['get_form_state', 'set_field', 'run_ui_action', 'navigate', 'take_screenshot'].includes(req.command)) {
1608
- const actionMap = {
1609
- get_form_state: 'agentGetFormState',
1610
- set_field: 'agentSetField',
1611
- run_ui_action: 'agentRunUiAction',
1612
- navigate: 'agentNavigate',
1613
- take_screenshot: 'agentTakeScreenshot',
1728
+ if (req.command === 'take_screenshot') {
1729
+ const result = await this.captureScreenshot(req, correlationId);
1730
+ return { id: req.id, command: req.command, status: 'success', timestamp: Date.now(), result };
1731
+ }
1732
+ // Session context switch: the helper PUTs to the same concourse picker
1733
+ // endpoint the header pickers use, so no form is driven. Needs the
1734
+ // instance session (url + g_ck), which is why it is not in the generic
1735
+ // browser relay below.
1736
+ if (req.command === 'switch_context') {
1737
+ const rawType = String(req.params?.switchType || '').trim().toLowerCase();
1738
+ const switchType = SWITCH_CONTEXT_ALIASES[rawType] || rawType;
1739
+ if (!SWITCH_CONTEXT_TYPES.includes(switchType)) {
1740
+ throw Object.assign(new Error(`Missing or invalid switchType. Must be one of: ${SWITCH_CONTEXT_TYPES.join(', ')}`), { code: 'E_INVALID_PARAMS' });
1741
+ }
1742
+ const value = String(req.params?.value ?? req.params?.sysId ?? req.params?.sys_id ?? '').trim();
1743
+ if (!value || /[\s\0]/.test(value)) {
1744
+ throw Object.assign(new Error('Missing required param: value (sys_id of the update set, application or domain)'), { code: 'E_INVALID_PARAMS' });
1745
+ }
1746
+ const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
1747
+ this.ws.sendToBrowser({
1748
+ action: exports.BROWSER_ACTIONS.switch_context,
1749
+ agentRequestId: correlationId,
1750
+ switchType,
1751
+ value,
1752
+ reloadTab: req.params?.reloadTab !== false,
1753
+ tabUrl: typeof req.params?.tabUrl === 'string' && req.params.tabUrl ? req.params.tabUrl : 'https://*.service-now.com/*',
1754
+ instance: inst.settings,
1755
+ appName: 'SN Utils CLI',
1756
+ });
1757
+ const res = await pendingPromise;
1758
+ if (res?.success === false) {
1759
+ throw Object.assign(new Error(res.error || `Could not switch ${switchType}`), { code: res.code || 'E_COMMAND_FAILED' });
1760
+ }
1761
+ return {
1762
+ id: req.id,
1763
+ command: req.command,
1764
+ status: 'success',
1765
+ timestamp: Date.now(),
1766
+ result: {
1767
+ switched: true,
1768
+ switchType: res?.switchType || switchType,
1769
+ value: res?.value || value,
1770
+ reloaded: res?.reloaded === true,
1771
+ },
1614
1772
  };
1615
- const action = actionMap[req.command];
1773
+ }
1774
+ // Browser Form & UI Actions
1775
+ if (['get_form_state', 'set_field', 'run_ui_action', 'navigate'].includes(req.command)) {
1776
+ const action = exports.BROWSER_ACTIONS[req.command];
1616
1777
  const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
1617
1778
  this.ws.sendToBrowser({
1618
1779
  action,