@snutils/snu 0.1.10 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/dist/cli/format.d.ts +1 -1
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +24 -2
- package/dist/cli/format.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +42 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -1
- package/dist/client.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +68 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +105 -2
- 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 +343 -25
- package/dist/server/dispatcher.js.map +1 -1
- package/dist/server/policy.d.ts.map +1 -1
- package/dist/server/policy.js +5 -0
- package/dist/server/policy.js.map +1 -1
- package/dist/server/standalone.d.ts.map +1 -1
- package/dist/server/standalone.js +3 -0
- 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,8 +33,10 @@ 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");
|
|
@@ -42,6 +44,49 @@ 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");
|
|
44
46
|
const FOLDERRECORDTABLES = ['sp_widget', 'sp_header_footer', 'sys_ui_page'];
|
|
47
|
+
// Human labels + the environment variable each gate is *actually* read from in
|
|
48
|
+
// config.ts. Deriving the variable name from the camelCase gate key produces
|
|
49
|
+
// SNU_ALLOW_RESTREQUEST, which nothing reads, so an agent told to set it hits
|
|
50
|
+
// the same wall twice and starts looking for a way around the gate. Keep these
|
|
51
|
+
// tables in step with resolveStandaloneConfig().
|
|
52
|
+
const GATE_LABELS = {
|
|
53
|
+
backgroundScripts: 'Background Scripts',
|
|
54
|
+
deleteRecords: 'Delete Records',
|
|
55
|
+
createArtifacts: 'Create Artifacts',
|
|
56
|
+
browserDebugger: 'Browser Debugger',
|
|
57
|
+
restRequest: 'REST Request API',
|
|
58
|
+
};
|
|
59
|
+
const REST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
60
|
+
// Mirrors codeForRest() in the VS Code agent so both hosts report the same
|
|
61
|
+
// code for the same HTTP failure.
|
|
62
|
+
function codeForRestStatus(status, message) {
|
|
63
|
+
if (status === 404)
|
|
64
|
+
return 'E_NOT_FOUND';
|
|
65
|
+
if (status === 409)
|
|
66
|
+
return 'E_REFERENCE_INTEGRITY';
|
|
67
|
+
if (status === 401 || status === 403)
|
|
68
|
+
return 'E_ACL';
|
|
69
|
+
const lower = (message || '').toLowerCase();
|
|
70
|
+
if (lower.includes('cannot delete') || lower.includes('referenc') || lower.includes('cascade')) {
|
|
71
|
+
return 'E_REFERENCE_INTEGRITY';
|
|
72
|
+
}
|
|
73
|
+
return 'E_COMMAND_FAILED';
|
|
74
|
+
}
|
|
75
|
+
const GATE_ENV_VARS = {
|
|
76
|
+
backgroundScripts: 'SNU_ALLOW_BACKGROUND_SCRIPTS',
|
|
77
|
+
deleteRecords: 'SNU_ALLOW_DELETE_RECORDS',
|
|
78
|
+
createArtifacts: 'SNU_ALLOW_CREATE_ARTIFACTS',
|
|
79
|
+
browserDebugger: 'SNU_ALLOW_BROWSER_DEBUGGER',
|
|
80
|
+
restRequest: 'SNU_ALLOW_REST_REQUEST',
|
|
81
|
+
};
|
|
82
|
+
// Steps a user must take to (re)connect the browser helper tab. Rendered by
|
|
83
|
+
// the CLI as a friendly block and relayed verbatim by the MCP server so
|
|
84
|
+
// agents guide the user instead of reporting a raw error.
|
|
85
|
+
exports.HELPER_CONNECT_GUIDANCE = [
|
|
86
|
+
'Open your ServiceNow instance in the browser (with the SN Utils extension installed).',
|
|
87
|
+
'On that page, type /token in the SN Utils slash palette: this opens the helper tab and connects it.',
|
|
88
|
+
'Keep the helper tab open, then retry.',
|
|
89
|
+
];
|
|
45
90
|
const FIELDTYPES = {
|
|
46
91
|
script: { extension: '.js' },
|
|
47
92
|
script_plain: { extension: '.js' },
|
|
@@ -141,6 +186,61 @@ function resolveFieldExtension(tableName, fieldName, cwd) {
|
|
|
141
186
|
ext = '.js';
|
|
142
187
|
return ext;
|
|
143
188
|
}
|
|
189
|
+
// Canonical filename resolution against a folder's _map.json, shared by
|
|
190
|
+
// pull_records and browser save pushes. Mirrors the VS Code extension: an
|
|
191
|
+
// existing mapping for the sys_id wins (so a record renamed on the instance
|
|
192
|
+
// keeps its stable local filename, reported via `renamedTo`), and a name
|
|
193
|
+
// collision with another sys_id gets a short sys_id suffix.
|
|
194
|
+
function resolveMappedFileName(mapPath, rawName, sysId) {
|
|
195
|
+
let nameToSysId = {};
|
|
196
|
+
if (fs.existsSync(mapPath)) {
|
|
197
|
+
try {
|
|
198
|
+
nameToSysId = JSON.parse(fs.readFileSync(mapPath, 'utf8')) || {};
|
|
199
|
+
}
|
|
200
|
+
catch { }
|
|
201
|
+
}
|
|
202
|
+
const computed = String(rawName).replace(/[^a-z0-9._\-+]+/gi, '').replace(/\./g, '-') || sysId;
|
|
203
|
+
let cleanName = computed;
|
|
204
|
+
let renamedTo;
|
|
205
|
+
const existingKey = Object.keys(nameToSysId).find((k) => nameToSysId[k] === sysId);
|
|
206
|
+
if (existingKey) {
|
|
207
|
+
cleanName = existingKey;
|
|
208
|
+
if (existingKey !== computed)
|
|
209
|
+
renamedTo = computed;
|
|
210
|
+
}
|
|
211
|
+
else if (nameToSysId[cleanName] && nameToSysId[cleanName] !== sysId) {
|
|
212
|
+
cleanName = cleanName + ('-' + sysId.slice(0, 2) + sysId.slice(-2)).toUpperCase();
|
|
213
|
+
}
|
|
214
|
+
nameToSysId[cleanName] = sysId;
|
|
215
|
+
return { cleanName, renamedTo, map: nameToSysId };
|
|
216
|
+
}
|
|
217
|
+
function writeMapFile(mapPath, map) {
|
|
218
|
+
fs.mkdirSync(path.dirname(mapPath), { recursive: true });
|
|
219
|
+
fs.writeFileSync(mapPath, JSON.stringify(map, null, 4), 'utf8');
|
|
220
|
+
}
|
|
221
|
+
// Extension for a browser save push, derived from the payload's fieldType the
|
|
222
|
+
// same way the VS Code extension does it (saveFieldAsFile).
|
|
223
|
+
function extensionForBrowserSave(fieldType, fieldName, tableName, cleanName) {
|
|
224
|
+
let ext = FIELDTYPES[fieldType]?.extension;
|
|
225
|
+
if (fieldType.includes('xml'))
|
|
226
|
+
ext = '.xml';
|
|
227
|
+
else if (fieldType.includes('html'))
|
|
228
|
+
ext = '.html';
|
|
229
|
+
else if (fieldType.includes('json'))
|
|
230
|
+
ext = '.json';
|
|
231
|
+
else if (fieldType.includes('css') || fieldType === 'properties' || fieldName === 'css')
|
|
232
|
+
ext = '.scss';
|
|
233
|
+
else if (cleanName.lastIndexOf('-') > -1 && tableName === 'ecc_agent_script_file') {
|
|
234
|
+
const suffix = cleanName.substring(cleanName.lastIndexOf('-') + 1);
|
|
235
|
+
if (suffix.length < 5)
|
|
236
|
+
ext = '.' + suffix;
|
|
237
|
+
}
|
|
238
|
+
else if (fieldType.includes('string') || fieldType === 'conditions')
|
|
239
|
+
ext = '.txt';
|
|
240
|
+
else if (fieldName === 'PowerShell')
|
|
241
|
+
ext = '.ps1';
|
|
242
|
+
return ext || '.js';
|
|
243
|
+
}
|
|
144
244
|
class StandaloneDispatcher {
|
|
145
245
|
cwd;
|
|
146
246
|
ws;
|
|
@@ -174,6 +274,133 @@ class StandaloneDispatcher {
|
|
|
174
274
|
}
|
|
175
275
|
return null;
|
|
176
276
|
}
|
|
277
|
+
// Browser save-icon pushes (action: 'saveFieldAsFile') arrive on the port
|
|
278
|
+
// 1978 socket whether VS Code or this daemon is listening; VS Code writes
|
|
279
|
+
// the field into the sync workspace, and historically the daemon dropped
|
|
280
|
+
// the message silently. Mirror the VS Code behavior so a daemon-only setup
|
|
281
|
+
// still lands pushes on disk. One-way by design: the daemon has no file
|
|
282
|
+
// watcher, so local edits flow back via agent commands or VS Code.
|
|
283
|
+
async handleBrowserFieldSave(msg) {
|
|
284
|
+
const echo = (payload) => {
|
|
285
|
+
try {
|
|
286
|
+
this.ws.sendToBrowser(payload);
|
|
287
|
+
}
|
|
288
|
+
catch { }
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
const instanceName = sanitizePathComponent(String(msg?.instance?.name || ''));
|
|
292
|
+
const table = sanitizePathComponent(String(msg?.table || ''));
|
|
293
|
+
const sysId = String(msg?.sys_id || '');
|
|
294
|
+
const rawName = String(msg?.name || '');
|
|
295
|
+
const field = String(msg?.field || '');
|
|
296
|
+
const content = typeof msg?.content === 'string' ? msg.content : String(msg?.content ?? '');
|
|
297
|
+
if (!sysId || !field)
|
|
298
|
+
throw new Error('Save push is missing sys_id or field');
|
|
299
|
+
// Refuse to scatter files when the daemon was clearly started outside a
|
|
300
|
+
// sync workspace.
|
|
301
|
+
const resolvedCwd = path.resolve(this.cwd);
|
|
302
|
+
if (resolvedCwd === path.resolve(os.homedir()) || resolvedCwd === path.parse(resolvedCwd).root) {
|
|
303
|
+
console.warn(`[snu] Ignored a save push from the browser: ${resolvedCwd} does not look like a ScriptSync workspace. Start snu from your sync folder.`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const scope = await this.resolveScopeFolderForSave(msg, instanceName);
|
|
307
|
+
// Variable fields arrive as inputs.<var>.script
|
|
308
|
+
const fieldName = field.split('.').length === 3 ? 'variable-' + field.split('.')[2] : field;
|
|
309
|
+
const isFolderRecordTable = FOLDERRECORDTABLES.includes(table);
|
|
310
|
+
const mapPath = safeJoinUnderRoot(this.cwd, instanceName, scope, table, '_map.json');
|
|
311
|
+
const { cleanName, renamedTo, map } = resolveMappedFileName(mapPath, rawName, sysId);
|
|
312
|
+
writeMapFile(mapPath, map);
|
|
313
|
+
if (renamedTo) {
|
|
314
|
+
console.log(`[snu] Record ${table}/${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
|
|
315
|
+
}
|
|
316
|
+
const ext = extensionForBrowserSave(String(msg?.fieldType || 'script'), fieldName, table, cleanName);
|
|
317
|
+
const targetPath = isFolderRecordTable
|
|
318
|
+
? safeJoinUnderRoot(this.cwd, instanceName, scope, table, cleanName, `${fieldName}${ext}`)
|
|
319
|
+
: safeJoinUnderRoot(this.cwd, instanceName, scope, table, `${cleanName}.${fieldName}${ext}`);
|
|
320
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
321
|
+
fs.writeFileSync(targetPath, content, 'utf8');
|
|
322
|
+
console.log(`[snu] Saved ${path.relative(this.cwd, targetPath)} (pushed from ${instanceName})`);
|
|
323
|
+
// Same success echo VS Code sends: the helper tab logs the push as
|
|
324
|
+
// delivered when it sees contentLength.
|
|
325
|
+
echo({ ...msg, result: '', contentLength: content.length, send: false });
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
const message = e?.message || String(e);
|
|
329
|
+
console.warn('[snu] Failed to save field pushed from browser:', message);
|
|
330
|
+
echo({ error: `Standalone snu could not save the pushed field: ${message}`, send: false, response: { result: {} } });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Resolve the scope folder name for a browser save push. The payload's
|
|
334
|
+
// `scope` is a sys_scope sys_id ('global' for global, '' when the form has
|
|
335
|
+
// no sys_scope field). Mirrors the VS Code extension: scopes.json first,
|
|
336
|
+
// then ask the instance, falling back to no_scope / unknown_scope.
|
|
337
|
+
async resolveScopeFolderForSave(msg, instanceName) {
|
|
338
|
+
const scopeVal = typeof msg?.scope === 'string' ? msg.scope.trim() : '';
|
|
339
|
+
if (scopeVal === 'global')
|
|
340
|
+
return 'global';
|
|
341
|
+
if (!scopeVal) {
|
|
342
|
+
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');
|
|
343
|
+
return fromRecord || 'no_scope';
|
|
344
|
+
}
|
|
345
|
+
if (/^[0-9a-f]{32}$/i.test(scopeVal)) {
|
|
346
|
+
const scopesPath = path.join(this.cwd, instanceName, 'scopes.json');
|
|
347
|
+
try {
|
|
348
|
+
const scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
|
|
349
|
+
const hit = Object.keys(scopes).find((k) => scopes[k] === scopeVal);
|
|
350
|
+
if (hit)
|
|
351
|
+
return hit;
|
|
352
|
+
}
|
|
353
|
+
catch { }
|
|
354
|
+
const fromScope = await this.queryScopeFromInstance(msg, `/api/now/table/sys_scope/${scopeVal}`, { sysparm_fields: 'scope' }, 'scope');
|
|
355
|
+
if (fromScope) {
|
|
356
|
+
// Best-effort persist name -> sys_id so the next save skips the round-trip.
|
|
357
|
+
try {
|
|
358
|
+
let scopes = {};
|
|
359
|
+
try {
|
|
360
|
+
scopes = JSON.parse(fs.readFileSync(scopesPath, 'utf8')) || {};
|
|
361
|
+
}
|
|
362
|
+
catch { }
|
|
363
|
+
if (scopes[fromScope] !== scopeVal) {
|
|
364
|
+
scopes[fromScope] = scopeVal;
|
|
365
|
+
fs.mkdirSync(path.dirname(scopesPath), { recursive: true });
|
|
366
|
+
fs.writeFileSync(scopesPath, JSON.stringify(scopes, null, 4), 'utf8');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch { }
|
|
370
|
+
return fromScope;
|
|
371
|
+
}
|
|
372
|
+
return 'unknown_scope';
|
|
373
|
+
}
|
|
374
|
+
// Already a scope name (e.g. flow action saves carry the name directly).
|
|
375
|
+
if (/^[a-z0-9_.\-]+$/i.test(scopeVal))
|
|
376
|
+
return scopeVal;
|
|
377
|
+
return 'unknown_scope';
|
|
378
|
+
}
|
|
379
|
+
async queryScopeFromInstance(msg, endpoint, queryParams, resultField) {
|
|
380
|
+
if (!this.ws.hasBrowserClient())
|
|
381
|
+
return undefined;
|
|
382
|
+
try {
|
|
383
|
+
const correlationId = crypto.randomUUID();
|
|
384
|
+
const pendingPromise = this.pending.register({ id: correlationId, command: 'resolve_scope_for_save', timeoutMs: 15_000 });
|
|
385
|
+
this.ws.sendToBrowser({
|
|
386
|
+
action: 'agentRestApi',
|
|
387
|
+
agentRequestId: correlationId,
|
|
388
|
+
endpoint,
|
|
389
|
+
method: 'GET',
|
|
390
|
+
queryParams,
|
|
391
|
+
instance: msg.instance,
|
|
392
|
+
appName: 'SN Utils CLI',
|
|
393
|
+
});
|
|
394
|
+
const res = await pendingPromise;
|
|
395
|
+
if (res?.success === false)
|
|
396
|
+
return undefined;
|
|
397
|
+
const value = res?.data?.result?.[resultField];
|
|
398
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
177
404
|
listInstanceFolders() {
|
|
178
405
|
try {
|
|
179
406
|
const entries = fs.readdirSync(this.cwd, { withFileTypes: true });
|
|
@@ -384,10 +611,13 @@ class StandaloneDispatcher {
|
|
|
384
611
|
},
|
|
385
612
|
};
|
|
386
613
|
}
|
|
387
|
-
// Check browser connection for all instance/remote commands
|
|
614
|
+
// Check browser connection for all instance/remote commands. Not a
|
|
615
|
+
// failure of the tool: a user-actionable setup state, so ship guidance
|
|
616
|
+
// steps in details for the CLI and MCP surfaces to render.
|
|
388
617
|
if (!this.ws.hasBrowserClient()) {
|
|
389
|
-
throw Object.assign(new Error('
|
|
618
|
+
throw Object.assign(new Error('ServiceNow is not connected: the SN Utils helper tab is not open.'), {
|
|
390
619
|
code: 'E_BROWSER_DISCONNECTED',
|
|
620
|
+
details: { guidance: exports.HELPER_CONNECT_GUIDANCE },
|
|
391
621
|
});
|
|
392
622
|
}
|
|
393
623
|
const inst = this.resolveInstance(req.instance);
|
|
@@ -397,16 +627,21 @@ class StandaloneDispatcher {
|
|
|
397
627
|
for (const gateName of policy.gates) {
|
|
398
628
|
// A. Check Host Gate (Fail-closed standalone authority)
|
|
399
629
|
if (!this.config.gates[gateName]) {
|
|
400
|
-
const label = gateName
|
|
401
|
-
|
|
630
|
+
const label = GATE_LABELS[gateName] || gateName;
|
|
631
|
+
const envVar = GATE_ENV_VARS[gateName];
|
|
632
|
+
throw Object.assign(new Error(`${label} is disabled in this snu host config, so ${req.command} cannot run. ` +
|
|
633
|
+
`Turn it on with ${envVar}=1 in the MCP server's env block, or "${gateName}": true in ~/.sn-scriptsync/settings.json, then restart snu. ` +
|
|
634
|
+
`This is the user's decision to make: ask them to enable it rather than routing around it through the browser UI.`), { code: 'E_DISABLED', details: { gate: gateName, envVar, source: 'host' } });
|
|
402
635
|
}
|
|
403
636
|
// B. Check Helper Instance Gate (if instanceSecurityGates capability is present)
|
|
404
637
|
const helperState = this.ws.getHelperState();
|
|
405
638
|
if (helperState.capabilities?.instanceSecurityGates && instanceOrigin) {
|
|
406
639
|
const helperGateMode = this.ws.getInstanceGate(instanceUrl, gateName);
|
|
407
640
|
if (helperGateMode === 'off' || helperGateMode === false) {
|
|
408
|
-
const label = gateName
|
|
409
|
-
throw Object.assign(new Error(`This instance (${instanceOrigin}) does not permit ${label
|
|
641
|
+
const label = GATE_LABELS[gateName] || gateName;
|
|
642
|
+
throw Object.assign(new Error(`This instance (${instanceOrigin}) does not permit ${label} in the SN Utils helper, so ${req.command} cannot run. ` +
|
|
643
|
+
`The user can turn it on in the helper tab's Agent Access tab for this instance. ` +
|
|
644
|
+
`This is the user's decision to make: ask them to enable it rather than routing around it through the browser UI.`), { code: 'E_DISABLED', details: { gate: gateName, instanceOrigin, source: 'instance' } });
|
|
410
645
|
}
|
|
411
646
|
}
|
|
412
647
|
}
|
|
@@ -699,6 +934,61 @@ class StandaloneDispatcher {
|
|
|
699
934
|
},
|
|
700
935
|
};
|
|
701
936
|
}
|
|
937
|
+
// Create Record (plain data row). Same insert as create_artifact minus the
|
|
938
|
+
// name requirement and the local _map.json tracking, which only makes
|
|
939
|
+
// sense for artifacts that have a file in the workspace.
|
|
940
|
+
if (req.command === 'create_record') {
|
|
941
|
+
const rawTable = req.params?.table;
|
|
942
|
+
if (!rawTable || typeof rawTable !== 'string' || !/^[a-zA-Z0-9_]+$/.test(rawTable.trim())) {
|
|
943
|
+
throw Object.assign(new Error('Missing or invalid param "table" (must be alphanumeric/underscore)'), { code: 'E_INVALID_PARAMS' });
|
|
944
|
+
}
|
|
945
|
+
const table = rawTable.trim();
|
|
946
|
+
const fields = req.params?.fields;
|
|
947
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) {
|
|
948
|
+
throw Object.assign(new Error('Missing required param "fields": provide at least one field value for the new record'), { code: 'E_INVALID_PARAMS' });
|
|
949
|
+
}
|
|
950
|
+
const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
|
|
951
|
+
this.ws.sendToBrowser({
|
|
952
|
+
action: 'agentRestApi',
|
|
953
|
+
agentRequestId: correlationId,
|
|
954
|
+
endpoint: `/api/now/table/${table}`,
|
|
955
|
+
method: 'POST',
|
|
956
|
+
body: fields,
|
|
957
|
+
queryParams: { sysparm_display_value: 'false', sysparm_exclude_reference_link: 'true' },
|
|
958
|
+
instance: inst.settings,
|
|
959
|
+
appName: 'SN Utils CLI',
|
|
960
|
+
});
|
|
961
|
+
const res = await pendingPromise;
|
|
962
|
+
if (res.success === false) {
|
|
963
|
+
const message = res.error || `Failed to create a record on ${table}`;
|
|
964
|
+
throw Object.assign(new Error(message), {
|
|
965
|
+
code: res.code || codeForRestStatus(res.status, message),
|
|
966
|
+
details: { status: res.status, detail: res.detail ?? null },
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
// POST /api/now/table returns the inserted row, so the write is already
|
|
970
|
+
// verified: no follow-up get_record needed.
|
|
971
|
+
const record = res.data?.result ?? null;
|
|
972
|
+
const readField = (name) => {
|
|
973
|
+
const value = record?.[name];
|
|
974
|
+
if (value && typeof value === 'object')
|
|
975
|
+
return String(value.value ?? value.display_value ?? '');
|
|
976
|
+
return value === undefined || value === null ? '' : String(value);
|
|
977
|
+
};
|
|
978
|
+
return {
|
|
979
|
+
id: req.id,
|
|
980
|
+
command: req.command,
|
|
981
|
+
status: 'success',
|
|
982
|
+
timestamp: Date.now(),
|
|
983
|
+
result: {
|
|
984
|
+
created: true,
|
|
985
|
+
table,
|
|
986
|
+
sys_id: readField('sys_id'),
|
|
987
|
+
name: readField('number') || readField('name') || readField('sys_name') || readField('short_description'),
|
|
988
|
+
record,
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
}
|
|
702
992
|
// Schema Metadata
|
|
703
993
|
if (req.command === 'get_table_metadata') {
|
|
704
994
|
const table = req.params?.table;
|
|
@@ -833,29 +1123,16 @@ class StandaloneDispatcher {
|
|
|
833
1123
|
warnings.push(`Could not resolve map path for ${scope}/${table}: ${e?.message || e}`);
|
|
834
1124
|
continue;
|
|
835
1125
|
}
|
|
836
|
-
|
|
837
|
-
if (fs.existsSync(mapPath)) {
|
|
838
|
-
try {
|
|
839
|
-
nameToSysId = JSON.parse(fs.readFileSync(mapPath, 'utf8')) || {};
|
|
840
|
-
}
|
|
841
|
-
catch { }
|
|
842
|
-
}
|
|
843
|
-
let cleanName = name.replace(/[^a-z0-9._\-+]+/gi, '').replace(/\./g, '-') || sysId;
|
|
844
|
-
const existingKey = Object.keys(nameToSysId).find((k) => nameToSysId[k] === sysId);
|
|
845
|
-
if (existingKey) {
|
|
846
|
-
cleanName = existingKey;
|
|
847
|
-
}
|
|
848
|
-
else if (nameToSysId[cleanName] && nameToSysId[cleanName] !== sysId) {
|
|
849
|
-
cleanName = `${cleanName}-${sysId.slice(0, 2)}${sysId.slice(-2)}`.toUpperCase();
|
|
850
|
-
}
|
|
851
|
-
nameToSysId[cleanName] = sysId;
|
|
1126
|
+
const { cleanName, renamedTo, map: nameToSysId } = resolveMappedFileName(mapPath, name, sysId);
|
|
852
1127
|
try {
|
|
853
|
-
|
|
854
|
-
fs.writeFileSync(mapPath, JSON.stringify(nameToSysId, null, 4), 'utf8');
|
|
1128
|
+
writeMapFile(mapPath, nameToSysId);
|
|
855
1129
|
}
|
|
856
1130
|
catch (e) {
|
|
857
1131
|
warnings.push(`Failed to write _map.json at ${mapPath}: ${e?.message || e}`);
|
|
858
1132
|
}
|
|
1133
|
+
if (renamedTo) {
|
|
1134
|
+
warnings.push(`Record ${sysId} is named '${renamedTo}' on the instance but keeps local file name '${cleanName}' (rename tracked in _map.json).`);
|
|
1135
|
+
}
|
|
859
1136
|
if (table === 'sp_widget') {
|
|
860
1137
|
try {
|
|
861
1138
|
const testUrlsPath = safeJoinUnderRoot(this.cwd, inst.name, scope, table, cleanName, '_test_urls.txt');
|
|
@@ -940,6 +1217,47 @@ class StandaloneDispatcher {
|
|
|
940
1217
|
},
|
|
941
1218
|
};
|
|
942
1219
|
}
|
|
1220
|
+
// Generic REST passthrough. The escape hatch the typed commands are built
|
|
1221
|
+
// on: the browser helper's agentRestApi action has always accepted every
|
|
1222
|
+
// method plus a body, so this needs no extension-side change. Gating is
|
|
1223
|
+
// handled above by getCommandPolicy (GET open, POST/PUT/PATCH behind
|
|
1224
|
+
// restRequest, DELETE behind deleteRecords).
|
|
1225
|
+
if (req.command === 'rest_request') {
|
|
1226
|
+
const endpoint = req.params?.endpoint;
|
|
1227
|
+
if (!endpoint || typeof endpoint !== 'string' || !endpoint.startsWith('/')) {
|
|
1228
|
+
throw Object.assign(new Error("Missing/invalid 'endpoint' (must be an instance-relative path beginning with '/', e.g. /api/now/table/incident)"), { code: 'E_INVALID_PARAMS' });
|
|
1229
|
+
}
|
|
1230
|
+
const method = String(req.params?.method || 'GET').toUpperCase();
|
|
1231
|
+
if (!REST_METHODS.includes(method)) {
|
|
1232
|
+
throw Object.assign(new Error(`Invalid method. Must be one of: ${REST_METHODS.join(', ')}`), { code: 'E_INVALID_PARAMS' });
|
|
1233
|
+
}
|
|
1234
|
+
const pendingPromise = this.pending.register({ id: correlationId, command: req.command, timeoutMs: 70_000 });
|
|
1235
|
+
this.ws.sendToBrowser({
|
|
1236
|
+
action: 'agentRestApi',
|
|
1237
|
+
agentRequestId: correlationId,
|
|
1238
|
+
endpoint,
|
|
1239
|
+
method,
|
|
1240
|
+
body: req.params?.body,
|
|
1241
|
+
queryParams: req.params?.queryParams && typeof req.params.queryParams === 'object' ? req.params.queryParams : undefined,
|
|
1242
|
+
instance: inst.settings,
|
|
1243
|
+
appName: 'SN Utils CLI',
|
|
1244
|
+
});
|
|
1245
|
+
const res = await pendingPromise;
|
|
1246
|
+
if (res.success === false) {
|
|
1247
|
+
const message = res.error || 'REST request failed';
|
|
1248
|
+
throw Object.assign(new Error(message), {
|
|
1249
|
+
code: res.code || codeForRestStatus(res.status, message),
|
|
1250
|
+
details: { status: res.status, detail: res.detail ?? null },
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
return {
|
|
1254
|
+
id: req.id,
|
|
1255
|
+
command: req.command,
|
|
1256
|
+
status: 'success',
|
|
1257
|
+
timestamp: Date.now(),
|
|
1258
|
+
result: { status: res.status, data: res.data },
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
943
1261
|
// Browser Form & UI Actions
|
|
944
1262
|
if (['get_form_state', 'set_field', 'run_ui_action', 'navigate', 'take_screenshot'].includes(req.command)) {
|
|
945
1263
|
const actionMap = {
|