@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.
@@ -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: 8,
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('Browser helper disconnected. Open the SN Utils helper tab via /token in ServiceNow.'), {
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 = {