@snutils/snu 0.1.9 → 0.2.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/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +9 -0
- package/dist/cli/format.js.map +1 -1
- package/dist/client.js +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +19 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +39 -1
- package/dist/registry.js.map +1 -1
- package/dist/server/dispatcher.d.ts +9 -0
- package/dist/server/dispatcher.d.ts.map +1 -1
- package/dist/server/dispatcher.js +503 -4
- package/dist/server/dispatcher.js.map +1 -1
- package/dist/server/httpBridge.d.ts.map +1 -1
- package/dist/server/httpBridge.js +3 -1
- package/dist/server/httpBridge.js.map +1 -1
- package/dist/server/policy.d.ts.map +1 -1
- package/dist/server/policy.js +3 -0
- package/dist/server/policy.js.map +1 -1
- package/dist/server/standalone.d.ts.map +1 -1
- package/dist/server/standalone.js +4 -1
- package/dist/server/standalone.js.map +1 -1
- package/dist/server/wsBridge.d.ts +1 -0
- package/dist/server/wsBridge.d.ts.map +1 -1
- package/dist/server/wsBridge.js +15 -1
- package/dist/server/wsBridge.js.map +1 -1
- package/package.json +2 -1
|
@@ -33,14 +33,179 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.StandaloneDispatcher = void 0;
|
|
36
|
+
exports.StandaloneDispatcher = exports.HELPER_CONNECT_GUIDANCE = void 0;
|
|
37
|
+
exports.resolveMappedFileName = resolveMappedFileName;
|
|
37
38
|
const fs = __importStar(require("fs"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
38
40
|
const path = __importStar(require("path"));
|
|
39
41
|
const crypto = __importStar(require("crypto"));
|
|
40
42
|
const pendingRegistry_js_1 = require("./pendingRegistry.js");
|
|
41
43
|
const policy_js_1 = require("./policy.js");
|
|
42
44
|
const config_js_1 = require("./config.js");
|
|
43
45
|
const canonical_js_1 = require("./canonical.js");
|
|
46
|
+
const FOLDERRECORDTABLES = ['sp_widget', 'sp_header_footer', 'sys_ui_page'];
|
|
47
|
+
// Steps a user must take to (re)connect the browser helper tab. Rendered by
|
|
48
|
+
// the CLI as a friendly block and relayed verbatim by the MCP server so
|
|
49
|
+
// agents guide the user instead of reporting a raw error.
|
|
50
|
+
exports.HELPER_CONNECT_GUIDANCE = [
|
|
51
|
+
'Open your ServiceNow instance in the browser (with the SN Utils extension installed).',
|
|
52
|
+
'On that page, type /token in the SN Utils slash palette: this opens the helper tab and connects it.',
|
|
53
|
+
'Keep the helper tab open, then retry.',
|
|
54
|
+
];
|
|
55
|
+
const FIELDTYPES = {
|
|
56
|
+
script: { extension: '.js' },
|
|
57
|
+
script_plain: { extension: '.js' },
|
|
58
|
+
script_server: { extension: '.js' },
|
|
59
|
+
script_client: { extension: '.js' },
|
|
60
|
+
email_script: { extension: '.js' },
|
|
61
|
+
html_script: { extension: '.html' },
|
|
62
|
+
xml: { extension: '.xml' },
|
|
63
|
+
html: { extension: '.html' },
|
|
64
|
+
html_template: { extension: '.html' },
|
|
65
|
+
template: { extension: '.html' },
|
|
66
|
+
json: { extension: '.json' },
|
|
67
|
+
css: { extension: '.scss' },
|
|
68
|
+
condition_string: { extension: '.js' },
|
|
69
|
+
expression: { extension: '.js' },
|
|
70
|
+
graphql_schema: { extension: '.graphql' },
|
|
71
|
+
json_translations: { extension: '.json' },
|
|
72
|
+
translated_html: { extension: '.html' },
|
|
73
|
+
string: { extension: '.txt' },
|
|
74
|
+
};
|
|
75
|
+
function sanitizePathComponent(component) {
|
|
76
|
+
if (typeof component !== 'string') {
|
|
77
|
+
throw new Error('Path component is not a string');
|
|
78
|
+
}
|
|
79
|
+
const value = component.trim();
|
|
80
|
+
if (!value || value === '.' || value === '..') {
|
|
81
|
+
throw new Error(`Unsafe path component ${JSON.stringify(component)}`);
|
|
82
|
+
}
|
|
83
|
+
if (/[\\/\0]/.test(value) || /^[A-Za-z]:/.test(value) || value.startsWith('~')) {
|
|
84
|
+
throw new Error(`Unsafe path component ${JSON.stringify(component)}`);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function safeJoinUnderRoot(root, ...components) {
|
|
89
|
+
if (!root) {
|
|
90
|
+
throw new Error('No root path provided');
|
|
91
|
+
}
|
|
92
|
+
const resolvedRoot = path.resolve(root);
|
|
93
|
+
const resolved = path.resolve(resolvedRoot, ...components.map(sanitizePathComponent));
|
|
94
|
+
const rel = path.relative(resolvedRoot, resolved);
|
|
95
|
+
if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) {
|
|
96
|
+
throw new Error(`Path escapes root: ${resolved}`);
|
|
97
|
+
}
|
|
98
|
+
return resolved;
|
|
99
|
+
}
|
|
100
|
+
let metaDataRelationsCache = null;
|
|
101
|
+
function getMetaDataRelations(cwd) {
|
|
102
|
+
if (!metaDataRelationsCache) {
|
|
103
|
+
const candidates = [
|
|
104
|
+
path.resolve(cwd, 'resources', 'metaDataRelations.json'),
|
|
105
|
+
path.resolve(__dirname, '../../../../resources/metaDataRelations.json'),
|
|
106
|
+
path.resolve(__dirname, '../../../resources/metaDataRelations.json'),
|
|
107
|
+
];
|
|
108
|
+
for (const c of candidates) {
|
|
109
|
+
if (fs.existsSync(c)) {
|
|
110
|
+
try {
|
|
111
|
+
metaDataRelationsCache = JSON.parse(fs.readFileSync(c, 'utf8'));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return metaDataRelationsCache;
|
|
119
|
+
}
|
|
120
|
+
function resolveTableCodeFields(tableName, cwd) {
|
|
121
|
+
const meta = getMetaDataRelations(cwd);
|
|
122
|
+
const fields = meta?.tableFields?.[tableName]?.codeFields;
|
|
123
|
+
if (fields && typeof fields === 'object') {
|
|
124
|
+
const keys = Object.keys(fields).filter((k) => !k.startsWith('_'));
|
|
125
|
+
if (keys.length > 0)
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
128
|
+
return ['script'];
|
|
129
|
+
}
|
|
130
|
+
function resolveFieldExtension(tableName, fieldName, cwd) {
|
|
131
|
+
const meta = getMetaDataRelations(cwd);
|
|
132
|
+
let fieldType = 'script';
|
|
133
|
+
try {
|
|
134
|
+
fieldType = meta?.tableFields?.[tableName]?.codeFields?.[fieldName]?.type || fieldName;
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
let ext = FIELDTYPES[fieldType]?.extension;
|
|
138
|
+
if (fieldType.includes('xml'))
|
|
139
|
+
ext = '.xml';
|
|
140
|
+
else if (fieldType.includes('html'))
|
|
141
|
+
ext = '.html';
|
|
142
|
+
else if (fieldType.includes('json'))
|
|
143
|
+
ext = '.json';
|
|
144
|
+
else if (fieldType.includes('css') || fieldType === 'properties' || fieldName === 'css')
|
|
145
|
+
ext = '.scss';
|
|
146
|
+
else if (fieldType.includes('string') || fieldType === 'conditions')
|
|
147
|
+
ext = '.txt';
|
|
148
|
+
else if (fieldType.includes('graphql'))
|
|
149
|
+
ext = '.graphql';
|
|
150
|
+
else if (!ext)
|
|
151
|
+
ext = '.js';
|
|
152
|
+
return ext;
|
|
153
|
+
}
|
|
154
|
+
// Canonical filename resolution against a folder's _map.json, shared by
|
|
155
|
+
// pull_records and browser save pushes. Mirrors the VS Code extension: an
|
|
156
|
+
// existing mapping for the sys_id wins (so a record renamed on the instance
|
|
157
|
+
// keeps its stable local filename, reported via `renamedTo`), and a name
|
|
158
|
+
// collision with another sys_id gets a short sys_id suffix.
|
|
159
|
+
function resolveMappedFileName(mapPath, rawName, sysId) {
|
|
160
|
+
let nameToSysId = {};
|
|
161
|
+
if (fs.existsSync(mapPath)) {
|
|
162
|
+
try {
|
|
163
|
+
nameToSysId = JSON.parse(fs.readFileSync(mapPath, 'utf8')) || {};
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
}
|
|
167
|
+
const computed = String(rawName).replace(/[^a-z0-9._\-+]+/gi, '').replace(/\./g, '-') || sysId;
|
|
168
|
+
let cleanName = computed;
|
|
169
|
+
let renamedTo;
|
|
170
|
+
const existingKey = Object.keys(nameToSysId).find((k) => nameToSysId[k] === sysId);
|
|
171
|
+
if (existingKey) {
|
|
172
|
+
cleanName = existingKey;
|
|
173
|
+
if (existingKey !== computed)
|
|
174
|
+
renamedTo = computed;
|
|
175
|
+
}
|
|
176
|
+
else if (nameToSysId[cleanName] && nameToSysId[cleanName] !== sysId) {
|
|
177
|
+
cleanName = cleanName + ('-' + sysId.slice(0, 2) + sysId.slice(-2)).toUpperCase();
|
|
178
|
+
}
|
|
179
|
+
nameToSysId[cleanName] = sysId;
|
|
180
|
+
return { cleanName, renamedTo, map: nameToSysId };
|
|
181
|
+
}
|
|
182
|
+
function writeMapFile(mapPath, map) {
|
|
183
|
+
fs.mkdirSync(path.dirname(mapPath), { recursive: true });
|
|
184
|
+
fs.writeFileSync(mapPath, JSON.stringify(map, null, 4), 'utf8');
|
|
185
|
+
}
|
|
186
|
+
// Extension for a browser save push, derived from the payload's fieldType the
|
|
187
|
+
// same way the VS Code extension does it (saveFieldAsFile).
|
|
188
|
+
function extensionForBrowserSave(fieldType, fieldName, tableName, cleanName) {
|
|
189
|
+
let ext = FIELDTYPES[fieldType]?.extension;
|
|
190
|
+
if (fieldType.includes('xml'))
|
|
191
|
+
ext = '.xml';
|
|
192
|
+
else if (fieldType.includes('html'))
|
|
193
|
+
ext = '.html';
|
|
194
|
+
else if (fieldType.includes('json'))
|
|
195
|
+
ext = '.json';
|
|
196
|
+
else if (fieldType.includes('css') || fieldType === 'properties' || fieldName === 'css')
|
|
197
|
+
ext = '.scss';
|
|
198
|
+
else if (cleanName.lastIndexOf('-') > -1 && tableName === 'ecc_agent_script_file') {
|
|
199
|
+
const suffix = cleanName.substring(cleanName.lastIndexOf('-') + 1);
|
|
200
|
+
if (suffix.length < 5)
|
|
201
|
+
ext = '.' + suffix;
|
|
202
|
+
}
|
|
203
|
+
else if (fieldType.includes('string') || fieldType === 'conditions')
|
|
204
|
+
ext = '.txt';
|
|
205
|
+
else if (fieldName === 'PowerShell')
|
|
206
|
+
ext = '.ps1';
|
|
207
|
+
return ext || '.js';
|
|
208
|
+
}
|
|
44
209
|
class StandaloneDispatcher {
|
|
45
210
|
cwd;
|
|
46
211
|
ws;
|
|
@@ -74,6 +239,133 @@ class StandaloneDispatcher {
|
|
|
74
239
|
}
|
|
75
240
|
return null;
|
|
76
241
|
}
|
|
242
|
+
// Browser save-icon pushes (action: 'saveFieldAsFile') arrive on the port
|
|
243
|
+
// 1978 socket whether VS Code or this daemon is listening; VS Code writes
|
|
244
|
+
// the field into the sync workspace, and historically the daemon dropped
|
|
245
|
+
// the message silently. Mirror the VS Code behavior so a daemon-only setup
|
|
246
|
+
// still lands pushes on disk. One-way by design: the daemon has no file
|
|
247
|
+
// watcher, so local edits flow back via agent commands or VS Code.
|
|
248
|
+
async handleBrowserFieldSave(msg) {
|
|
249
|
+
const echo = (payload) => {
|
|
250
|
+
try {
|
|
251
|
+
this.ws.sendToBrowser(payload);
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
};
|
|
255
|
+
try {
|
|
256
|
+
const instanceName = sanitizePathComponent(String(msg?.instance?.name || ''));
|
|
257
|
+
const table = sanitizePathComponent(String(msg?.table || ''));
|
|
258
|
+
const sysId = String(msg?.sys_id || '');
|
|
259
|
+
const rawName = String(msg?.name || '');
|
|
260
|
+
const field = String(msg?.field || '');
|
|
261
|
+
const content = typeof msg?.content === 'string' ? msg.content : String(msg?.content ?? '');
|
|
262
|
+
if (!sysId || !field)
|
|
263
|
+
throw new Error('Save push is missing sys_id or field');
|
|
264
|
+
// Refuse to scatter files when the daemon was clearly started outside a
|
|
265
|
+
// sync workspace.
|
|
266
|
+
const resolvedCwd = path.resolve(this.cwd);
|
|
267
|
+
if (resolvedCwd === path.resolve(os.homedir()) || resolvedCwd === path.parse(resolvedCwd).root) {
|
|
268
|
+
console.warn(`[snu] Ignored a save push from the browser: ${resolvedCwd} does not look like a ScriptSync workspace. Start snu from your sync folder.`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const scope = await this.resolveScopeFolderForSave(msg, instanceName);
|
|
272
|
+
// Variable fields arrive as inputs.<var>.script
|
|
273
|
+
const fieldName = field.split('.').length === 3 ? 'variable-' + field.split('.')[2] : field;
|
|
274
|
+
const isFolderRecordTable = FOLDERRECORDTABLES.includes(table);
|
|
275
|
+
const mapPath = safeJoinUnderRoot(this.cwd, instanceName, scope, table, '_map.json');
|
|
276
|
+
const { cleanName, renamedTo, map } = resolveMappedFileName(mapPath, rawName, sysId);
|
|
277
|
+
writeMapFile(mapPath, map);
|
|
278
|
+
if (renamedTo) {
|
|
279
|
+
console.log(`[snu] Record ${table}/${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
|
|
280
|
+
}
|
|
281
|
+
const ext = extensionForBrowserSave(String(msg?.fieldType || 'script'), fieldName, table, cleanName);
|
|
282
|
+
const targetPath = isFolderRecordTable
|
|
283
|
+
? safeJoinUnderRoot(this.cwd, instanceName, scope, table, cleanName, `${fieldName}${ext}`)
|
|
284
|
+
: safeJoinUnderRoot(this.cwd, instanceName, scope, table, `${cleanName}.${fieldName}${ext}`);
|
|
285
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
286
|
+
fs.writeFileSync(targetPath, content, 'utf8');
|
|
287
|
+
console.log(`[snu] Saved ${path.relative(this.cwd, targetPath)} (pushed from ${instanceName})`);
|
|
288
|
+
// Same success echo VS Code sends: the helper tab logs the push as
|
|
289
|
+
// delivered when it sees contentLength.
|
|
290
|
+
echo({ ...msg, result: '', contentLength: content.length, send: false });
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
const message = e?.message || String(e);
|
|
294
|
+
console.warn('[snu] Failed to save field pushed from browser:', message);
|
|
295
|
+
echo({ error: `Standalone snu could not save the pushed field: ${message}`, send: false, response: { result: {} } });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Resolve the scope folder name for a browser save push. The payload's
|
|
299
|
+
// `scope` is a sys_scope sys_id ('global' for global, '' when the form has
|
|
300
|
+
// no sys_scope field). Mirrors the VS Code extension: scopes.json first,
|
|
301
|
+
// then ask the instance, falling back to no_scope / unknown_scope.
|
|
302
|
+
async resolveScopeFolderForSave(msg, instanceName) {
|
|
303
|
+
const scopeVal = typeof msg?.scope === 'string' ? msg.scope.trim() : '';
|
|
304
|
+
if (scopeVal === 'global')
|
|
305
|
+
return 'global';
|
|
306
|
+
if (!scopeVal) {
|
|
307
|
+
const fromRecord = await this.queryScopeFromInstance(msg, `/api/now/table/${msg.table}/${msg.sys_id}`, { sysparm_fields: 'sys_scope.scope', sysparm_exclude_reference_link: 'true' }, 'sys_scope.scope');
|
|
308
|
+
return fromRecord || 'no_scope';
|
|
309
|
+
}
|
|
310
|
+
if (/^[0-9a-f]{32}$/i.test(scopeVal)) {
|
|
311
|
+
const scopesPath = path.join(this.cwd, instanceName, 'scopes.json');
|
|
312
|
+
try {
|
|
313
|
+
const scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
|
|
314
|
+
const hit = Object.keys(scopes).find((k) => scopes[k] === scopeVal);
|
|
315
|
+
if (hit)
|
|
316
|
+
return hit;
|
|
317
|
+
}
|
|
318
|
+
catch { }
|
|
319
|
+
const fromScope = await this.queryScopeFromInstance(msg, `/api/now/table/sys_scope/${scopeVal}`, { sysparm_fields: 'scope' }, 'scope');
|
|
320
|
+
if (fromScope) {
|
|
321
|
+
// Best-effort persist name -> sys_id so the next save skips the round-trip.
|
|
322
|
+
try {
|
|
323
|
+
let scopes = {};
|
|
324
|
+
try {
|
|
325
|
+
scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
if (scopes[fromScope] !== scopeVal) {
|
|
329
|
+
scopes[fromScope] = scopeVal;
|
|
330
|
+
fs.mkdirSync(path.dirname(scopesPath), { recursive: true });
|
|
331
|
+
fs.writeFileSync(scopesPath, JSON.stringify(scopes, null, 4), 'utf8');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch { }
|
|
335
|
+
return fromScope;
|
|
336
|
+
}
|
|
337
|
+
return 'unknown_scope';
|
|
338
|
+
}
|
|
339
|
+
// Already a scope name (e.g. flow action saves carry the name directly).
|
|
340
|
+
if (/^[a-z0-9_.\-]+$/i.test(scopeVal))
|
|
341
|
+
return scopeVal;
|
|
342
|
+
return 'unknown_scope';
|
|
343
|
+
}
|
|
344
|
+
async queryScopeFromInstance(msg, endpoint, queryParams, resultField) {
|
|
345
|
+
if (!this.ws.hasBrowserClient())
|
|
346
|
+
return undefined;
|
|
347
|
+
try {
|
|
348
|
+
const correlationId = crypto.randomUUID();
|
|
349
|
+
const pendingPromise = this.pending.register({ id: correlationId, command: 'resolve_scope_for_save', timeoutMs: 15_000 });
|
|
350
|
+
this.ws.sendToBrowser({
|
|
351
|
+
action: 'agentRestApi',
|
|
352
|
+
agentRequestId: correlationId,
|
|
353
|
+
endpoint,
|
|
354
|
+
method: 'GET',
|
|
355
|
+
queryParams,
|
|
356
|
+
instance: msg.instance,
|
|
357
|
+
appName: 'SN Utils CLI',
|
|
358
|
+
});
|
|
359
|
+
const res = await pendingPromise;
|
|
360
|
+
if (res?.success === false)
|
|
361
|
+
return undefined;
|
|
362
|
+
const value = res?.data?.result?.[resultField];
|
|
363
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
77
369
|
listInstanceFolders() {
|
|
78
370
|
try {
|
|
79
371
|
const entries = fs.readdirSync(this.cwd, { withFileTypes: true });
|
|
@@ -274,7 +566,7 @@ class StandaloneDispatcher {
|
|
|
274
566
|
status: 'success',
|
|
275
567
|
timestamp: Date.now(),
|
|
276
568
|
result: {
|
|
277
|
-
apiVersion:
|
|
569
|
+
apiVersion: 9,
|
|
278
570
|
tier: state.tier,
|
|
279
571
|
proFeatures: state.proFeatures,
|
|
280
572
|
cdp: state.cdp,
|
|
@@ -284,10 +576,13 @@ class StandaloneDispatcher {
|
|
|
284
576
|
},
|
|
285
577
|
};
|
|
286
578
|
}
|
|
287
|
-
// Check browser connection for all instance/remote commands
|
|
579
|
+
// Check browser connection for all instance/remote commands. Not a
|
|
580
|
+
// failure of the tool: a user-actionable setup state, so ship guidance
|
|
581
|
+
// steps in details for the CLI and MCP surfaces to render.
|
|
288
582
|
if (!this.ws.hasBrowserClient()) {
|
|
289
|
-
throw Object.assign(new Error('
|
|
583
|
+
throw Object.assign(new Error('ServiceNow is not connected: the SN Utils helper tab is not open.'), {
|
|
290
584
|
code: 'E_BROWSER_DISCONNECTED',
|
|
585
|
+
details: { guidance: exports.HELPER_CONNECT_GUIDANCE },
|
|
291
586
|
});
|
|
292
587
|
}
|
|
293
588
|
const inst = this.resolveInstance(req.instance);
|
|
@@ -623,6 +918,210 @@ class StandaloneDispatcher {
|
|
|
623
918
|
result: res.data?.result || res.data,
|
|
624
919
|
};
|
|
625
920
|
}
|
|
921
|
+
// Pull Records / Pull Artifacts
|
|
922
|
+
if (req.command === 'pull_records' || req.command === 'pull_artifacts') {
|
|
923
|
+
const rawTable = req.params?.table;
|
|
924
|
+
if (!rawTable || typeof rawTable !== 'string' || !/^[a-zA-Z0-9_]+$/.test(rawTable.trim())) {
|
|
925
|
+
throw Object.assign(new Error('Missing or invalid required param "table" (must be alphanumeric/underscore)'), { code: 'E_INVALID_PARAMS' });
|
|
926
|
+
}
|
|
927
|
+
const table = rawTable.trim();
|
|
928
|
+
let limit = 50;
|
|
929
|
+
if (req.params?.limit !== undefined) {
|
|
930
|
+
if (typeof req.params.limit !== 'number' || !Number.isInteger(req.params.limit) || req.params.limit < 1 || req.params.limit > 500) {
|
|
931
|
+
throw Object.assign(new Error('Parameter "limit" must be an integer between 1 and 500.'), { code: 'E_INVALID_PARAMS' });
|
|
932
|
+
}
|
|
933
|
+
limit = req.params.limit;
|
|
934
|
+
}
|
|
935
|
+
// Normalize & validate sys_ids
|
|
936
|
+
const rawIds = [];
|
|
937
|
+
if (typeof req.params?.sys_id === 'string' && req.params.sys_id.trim()) {
|
|
938
|
+
rawIds.push(req.params.sys_id.trim());
|
|
939
|
+
}
|
|
940
|
+
if (Array.isArray(req.params?.sys_ids)) {
|
|
941
|
+
for (const id of req.params.sys_ids) {
|
|
942
|
+
if (typeof id === 'string' && id.trim())
|
|
943
|
+
rawIds.push(id.trim());
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const validHexOrGlobal = /^(?:[0-9a-fA-F]{32}|global)$/;
|
|
947
|
+
const normalizedIds = Array.from(new Set(rawIds.map((id) => id.toLowerCase())));
|
|
948
|
+
for (const id of normalizedIds) {
|
|
949
|
+
if (!validHexOrGlobal.test(id)) {
|
|
950
|
+
throw Object.assign(new Error(`Invalid sys_id "${id}". Must be a 32-character hexadecimal string or 'global'.`), { code: 'E_INVALID_PARAMS' });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// Selection combination with ^ (AND)
|
|
954
|
+
const queryParts = [];
|
|
955
|
+
if (normalizedIds.length === 1) {
|
|
956
|
+
queryParts.push(`sys_id=${normalizedIds[0]}`);
|
|
957
|
+
}
|
|
958
|
+
else if (normalizedIds.length > 1) {
|
|
959
|
+
queryParts.push(`sys_idIN${normalizedIds.join(',')}`);
|
|
960
|
+
}
|
|
961
|
+
if (typeof req.params?.query === 'string' && req.params.query.trim()) {
|
|
962
|
+
queryParts.push(req.params.query.trim());
|
|
963
|
+
}
|
|
964
|
+
const combinedQuery = queryParts.join('^');
|
|
965
|
+
// Resolve code fields
|
|
966
|
+
let codeFields = [];
|
|
967
|
+
if (Array.isArray(req.params?.fields)) {
|
|
968
|
+
codeFields = req.params.fields.filter((f) => typeof f === 'string' && /^[a-zA-Z0-9_]+$/.test(f.trim())).map((f) => f.trim());
|
|
969
|
+
}
|
|
970
|
+
else if (typeof req.params?.field === 'string' && /^[a-zA-Z0-9_]+$/.test(req.params.field.trim())) {
|
|
971
|
+
codeFields = [req.params.field.trim()];
|
|
972
|
+
}
|
|
973
|
+
if (codeFields.length === 0) {
|
|
974
|
+
codeFields = resolveTableCodeFields(table, this.cwd);
|
|
975
|
+
}
|
|
976
|
+
const displayFields = ['sys_id', 'name', 'sys_name', 'short_description', 'sys_scope', 'sys_scope.scope'];
|
|
977
|
+
const allRequestedFields = Array.from(new Set([...displayFields, ...codeFields])).join(',');
|
|
978
|
+
const queryParams = {
|
|
979
|
+
sysparm_fields: allRequestedFields,
|
|
980
|
+
sysparm_limit: String(limit),
|
|
981
|
+
sysparm_display_value: 'false',
|
|
982
|
+
sysparm_exclude_reference_link: 'true',
|
|
983
|
+
sysparm_no_count: 'true',
|
|
984
|
+
};
|
|
985
|
+
if (combinedQuery) {
|
|
986
|
+
queryParams.sysparm_query = combinedQuery;
|
|
987
|
+
}
|
|
988
|
+
const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
|
|
989
|
+
this.ws.sendToBrowser({
|
|
990
|
+
action: 'agentRestApi',
|
|
991
|
+
agentRequestId: correlationId,
|
|
992
|
+
endpoint: `/api/now/table/${table}`,
|
|
993
|
+
method: 'GET',
|
|
994
|
+
queryParams,
|
|
995
|
+
instance: inst.settings,
|
|
996
|
+
appName: 'SN Utils CLI',
|
|
997
|
+
});
|
|
998
|
+
const res = await pendingPromise;
|
|
999
|
+
if (res.success === false) {
|
|
1000
|
+
throw Object.assign(new Error(res.error || 'Failed to pull records'), { code: 'E_COMMAND_FAILED' });
|
|
1001
|
+
}
|
|
1002
|
+
const matchedRecords = Array.isArray(res.data?.result) ? res.data.result : (res.data?.result ? [res.data.result] : []);
|
|
1003
|
+
const isFolderRecordTable = FOLDERRECORDTABLES.includes(table);
|
|
1004
|
+
let filesWritten = 0;
|
|
1005
|
+
let skippedEmpty = 0;
|
|
1006
|
+
const warnings = [];
|
|
1007
|
+
const pulledRecordsList = [];
|
|
1008
|
+
for (const rec of matchedRecords) {
|
|
1009
|
+
const sysId = typeof rec.sys_id === 'object' ? rec.sys_id.value : String(rec.sys_id || '');
|
|
1010
|
+
if (!sysId)
|
|
1011
|
+
continue;
|
|
1012
|
+
let scope = 'global';
|
|
1013
|
+
if (rec['sys_scope.scope']) {
|
|
1014
|
+
scope = String(rec['sys_scope.scope']);
|
|
1015
|
+
}
|
|
1016
|
+
else if (rec.sys_scope) {
|
|
1017
|
+
scope = typeof rec.sys_scope === 'object' ? String(rec.sys_scope.value || rec.sys_scope.display_value || 'global') : String(rec.sys_scope);
|
|
1018
|
+
}
|
|
1019
|
+
if (!scope || scope === 'null' || scope === 'undefined')
|
|
1020
|
+
scope = 'global';
|
|
1021
|
+
const rawName = rec.name || rec.sys_name || rec.short_description || sysId;
|
|
1022
|
+
const name = String(rawName).trim();
|
|
1023
|
+
let mapPath;
|
|
1024
|
+
try {
|
|
1025
|
+
mapPath = safeJoinUnderRoot(this.cwd, inst.name, scope, table, '_map.json');
|
|
1026
|
+
}
|
|
1027
|
+
catch (e) {
|
|
1028
|
+
warnings.push(`Could not resolve map path for ${scope}/${table}: ${e?.message || e}`);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const { cleanName, renamedTo, map: nameToSysId } = resolveMappedFileName(mapPath, name, sysId);
|
|
1032
|
+
try {
|
|
1033
|
+
writeMapFile(mapPath, nameToSysId);
|
|
1034
|
+
}
|
|
1035
|
+
catch (e) {
|
|
1036
|
+
warnings.push(`Failed to write _map.json at ${mapPath}: ${e?.message || e}`);
|
|
1037
|
+
}
|
|
1038
|
+
if (renamedTo) {
|
|
1039
|
+
warnings.push(`Record ${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
|
|
1040
|
+
}
|
|
1041
|
+
if (table === 'sp_widget') {
|
|
1042
|
+
try {
|
|
1043
|
+
const testUrlsPath = safeJoinUnderRoot(this.cwd, inst.name, scope, table, cleanName, '_test_urls.txt');
|
|
1044
|
+
if (!fs.existsSync(testUrlsPath)) {
|
|
1045
|
+
const dispVal = name.toLowerCase().replace(/\s+/g, '_');
|
|
1046
|
+
const testUrls = [
|
|
1047
|
+
`${inst.settings.url}/$sp.do?id=sp-preview&sys_id=${sysId}`,
|
|
1048
|
+
`${inst.settings.url}/sp_config?id=${dispVal}`,
|
|
1049
|
+
`${inst.settings.url}/sp?id=${dispVal}`,
|
|
1050
|
+
`${inst.settings.url}/esc?id=${dispVal}`,
|
|
1051
|
+
].join('\n');
|
|
1052
|
+
fs.mkdirSync(path.dirname(testUrlsPath), { recursive: true });
|
|
1053
|
+
fs.writeFileSync(testUrlsPath, testUrls, 'utf8');
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
catch { }
|
|
1057
|
+
}
|
|
1058
|
+
const recordFiles = [];
|
|
1059
|
+
for (const field of codeFields) {
|
|
1060
|
+
const ext = resolveFieldExtension(table, field, this.cwd);
|
|
1061
|
+
let targetPath;
|
|
1062
|
+
try {
|
|
1063
|
+
targetPath = isFolderRecordTable
|
|
1064
|
+
? safeJoinUnderRoot(this.cwd, inst.name, scope, table, cleanName, `${field}${ext}`)
|
|
1065
|
+
: safeJoinUnderRoot(this.cwd, inst.name, scope, table, `${cleanName}.${field}${ext}`);
|
|
1066
|
+
}
|
|
1067
|
+
catch (e) {
|
|
1068
|
+
warnings.push(`Unsafe path for ${scope}/${table}/${cleanName}.${field}: ${e?.message || e}`);
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
const relPath = path.relative(this.cwd, targetPath).replace(/\\/g, '/');
|
|
1072
|
+
const rawVal = rec[field];
|
|
1073
|
+
const content = rawVal !== null && rawVal !== undefined ? String(rawVal) : '';
|
|
1074
|
+
const fileExisted = fs.existsSync(targetPath);
|
|
1075
|
+
if (content.length > 0) {
|
|
1076
|
+
try {
|
|
1077
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
1078
|
+
fs.writeFileSync(targetPath, content, 'utf8');
|
|
1079
|
+
filesWritten++;
|
|
1080
|
+
const action = fileExisted ? 'updated' : 'created';
|
|
1081
|
+
recordFiles.push({ field, path: relPath, bytes: Buffer.byteLength(content, 'utf8'), action });
|
|
1082
|
+
}
|
|
1083
|
+
catch (e) {
|
|
1084
|
+
warnings.push(`Failed to write ${relPath}: ${e?.message || e}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else if (fileExisted) {
|
|
1088
|
+
try {
|
|
1089
|
+
fs.writeFileSync(targetPath, '', 'utf8');
|
|
1090
|
+
filesWritten++;
|
|
1091
|
+
recordFiles.push({ field, path: relPath, bytes: 0, action: 'cleared' });
|
|
1092
|
+
}
|
|
1093
|
+
catch (e) {
|
|
1094
|
+
warnings.push(`Failed to clear ${relPath}: ${e?.message || e}`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
skippedEmpty++;
|
|
1099
|
+
recordFiles.push({ field, path: relPath, bytes: 0, action: 'skipped_empty' });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
pulledRecordsList.push({
|
|
1103
|
+
sys_id: sysId,
|
|
1104
|
+
name,
|
|
1105
|
+
scope,
|
|
1106
|
+
files: recordFiles,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
return {
|
|
1110
|
+
id: req.id,
|
|
1111
|
+
command: req.command,
|
|
1112
|
+
status: 'success',
|
|
1113
|
+
timestamp: Date.now(),
|
|
1114
|
+
result: {
|
|
1115
|
+
table,
|
|
1116
|
+
matchedRecords: matchedRecords.length,
|
|
1117
|
+
pulledRecords: pulledRecordsList.length,
|
|
1118
|
+
filesWritten,
|
|
1119
|
+
skippedEmpty,
|
|
1120
|
+
warnings,
|
|
1121
|
+
records: pulledRecordsList,
|
|
1122
|
+
},
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
626
1125
|
// Browser Form & UI Actions
|
|
627
1126
|
if (['get_form_state', 'set_field', 'run_ui_action', 'navigate', 'take_screenshot'].includes(req.command)) {
|
|
628
1127
|
const actionMap = {
|