@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.
- package/README.md +2 -1
- package/dist/cli/doctor.d.ts +10 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +25 -0
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +7 -1
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +49 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +12 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +63 -4
- package/dist/registry.js.map +1 -1
- package/dist/server/authProbe.d.ts +29 -0
- package/dist/server/authProbe.d.ts.map +1 -0
- package/dist/server/authProbe.js +67 -0
- package/dist/server/authProbe.js.map +1 -0
- package/dist/server/bridgeIdentity.d.ts +24 -0
- package/dist/server/bridgeIdentity.d.ts.map +1 -0
- package/dist/server/bridgeIdentity.js +120 -0
- package/dist/server/bridgeIdentity.js.map +1 -0
- package/dist/server/dispatcher.d.ts +11 -1
- package/dist/server/dispatcher.d.ts.map +1 -1
- package/dist/server/dispatcher.js +239 -78
- package/dist/server/dispatcher.js.map +1 -1
- package/dist/server/helperConnection.d.ts +24 -1
- package/dist/server/helperConnection.d.ts.map +1 -1
- package/dist/server/helperConnection.js +54 -4
- package/dist/server/helperConnection.js.map +1 -1
- package/dist/server/httpBridge.d.ts.map +1 -1
- package/dist/server/httpBridge.js +1 -0
- package/dist/server/httpBridge.js.map +1 -1
- package/dist/server/policy.d.ts.map +1 -1
- package/dist/server/policy.js +10 -1
- package/dist/server/policy.js.map +1 -1
- package/dist/server/scopeDiscovery.d.ts +37 -0
- package/dist/server/scopeDiscovery.d.ts.map +1 -0
- package/dist/server/scopeDiscovery.js +147 -0
- package/dist/server/scopeDiscovery.js.map +1 -0
- package/dist/server/wsBridge.d.ts +4 -1
- package/dist/server/wsBridge.d.ts.map +1 -1
- package/dist/server/wsBridge.js +59 -6
- package/dist/server/wsBridge.js.map +1 -1
- 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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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:
|
|
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:
|
|
833
|
-
message:
|
|
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
|
-
|
|
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
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
|
1452
|
-
const tables = [...classCounts.keys()].filter(
|
|
1453
|
-
const skippedTables = [...classCounts.keys()].filter(
|
|
1454
|
-
.map(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
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,
|