abapgit-agent 1.9.0 → 1.10.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.
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ADT HTTP client for SAP ABAP Development Tools REST API
5
+ * Handles XML/AtomPub content, CSRF token, cookie session caching.
6
+ */
7
+ const https = require('https');
8
+ const http = require('http');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const crypto = require('crypto');
13
+
14
+ /**
15
+ * ADT HTTP client with CSRF token, cookie, and session caching.
16
+ * Mirrors AbapHttp but targets /sap/bc/adt/* with XML content-type.
17
+ */
18
+ class AdtHttp {
19
+ constructor(config) {
20
+ this.config = config;
21
+ this.csrfToken = null;
22
+ this.cookies = null;
23
+
24
+ const configHash = crypto.createHash('md5')
25
+ .update(`${config.host}:${config.user}:${config.client}`)
26
+ .digest('hex')
27
+ .substring(0, 8);
28
+
29
+ this.sessionFile = path.join(os.tmpdir(), `abapgit-adt-session-${configHash}.json`);
30
+ this.loadSession();
31
+ }
32
+
33
+ loadSession() {
34
+ if (!fs.existsSync(this.sessionFile)) return;
35
+ try {
36
+ const session = JSON.parse(fs.readFileSync(this.sessionFile, 'utf8'));
37
+ const safetyMargin = 2 * 60 * 1000;
38
+ if (session.expiresAt > Date.now() + safetyMargin) {
39
+ this.csrfToken = session.csrfToken;
40
+ this.cookies = session.cookies;
41
+ } else {
42
+ this.clearSession();
43
+ }
44
+ } catch (e) {
45
+ this.clearSession();
46
+ }
47
+ }
48
+
49
+ saveSession() {
50
+ const expiresAt = Date.now() + (15 * 60 * 1000);
51
+ try {
52
+ fs.writeFileSync(this.sessionFile, JSON.stringify({
53
+ csrfToken: this.csrfToken,
54
+ cookies: this.cookies,
55
+ expiresAt,
56
+ savedAt: Date.now()
57
+ }));
58
+ } catch (e) {
59
+ // Ignore write errors
60
+ }
61
+ }
62
+
63
+ clearSession() {
64
+ this.csrfToken = null;
65
+ this.cookies = null;
66
+ try {
67
+ if (fs.existsSync(this.sessionFile)) fs.unlinkSync(this.sessionFile);
68
+ } catch (e) {
69
+ // Ignore deletion errors
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Fetch CSRF token via GET /sap/bc/adt/discovery with X-CSRF-Token: fetch
75
+ */
76
+ async fetchCsrfToken() {
77
+ return new Promise((resolve, reject) => {
78
+ const url = new URL('/sap/bc/adt/discovery', `https://${this.config.host}:${this.config.sapport}`);
79
+ const options = {
80
+ hostname: url.hostname,
81
+ port: url.port,
82
+ path: url.pathname,
83
+ method: 'GET',
84
+ headers: {
85
+ 'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
86
+ 'sap-client': this.config.client,
87
+ 'sap-language': this.config.language || 'EN',
88
+ 'X-CSRF-Token': 'fetch',
89
+ 'Accept': 'application/atomsvc+xml'
90
+ },
91
+ agent: new https.Agent({ rejectUnauthorized: false })
92
+ };
93
+
94
+ const req = https.request(options, (res) => {
95
+ const csrfToken = res.headers['x-csrf-token'];
96
+ const setCookie = res.headers['set-cookie'];
97
+ if (setCookie) {
98
+ this.cookies = Array.isArray(setCookie)
99
+ ? setCookie.map(c => c.split(';')[0]).join('; ')
100
+ : setCookie.split(';')[0];
101
+ }
102
+
103
+ let body = '';
104
+ res.on('data', chunk => body += chunk);
105
+ res.on('end', () => {
106
+ this.csrfToken = csrfToken;
107
+ this.saveSession();
108
+ resolve(csrfToken);
109
+ });
110
+ });
111
+
112
+ req.on('error', reject);
113
+ req.end();
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Make HTTP request to ADT endpoint with automatic retry on auth failure.
119
+ * Returns { body: string, headers: object, statusCode: number }.
120
+ */
121
+ async request(method, urlPath, body = null, options = {}) {
122
+ try {
123
+ return await this._makeRequest(method, urlPath, body, options);
124
+ } catch (error) {
125
+ if (this._isAuthError(error) && !options.isRetry) {
126
+ this.clearSession();
127
+ await this.fetchCsrfToken();
128
+ return await this._makeRequest(method, urlPath, body, { ...options, isRetry: true });
129
+ }
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ _isAuthError(error) {
135
+ if (error.statusCode === 401) return true;
136
+ if (error.statusCode === 403) return true;
137
+ const msg = (error.message || '').toLowerCase();
138
+ return msg.includes('csrf') || msg.includes('unauthorized') || msg.includes('forbidden');
139
+ }
140
+
141
+ async _makeRequest(method, urlPath, body = null, options = {}) {
142
+ return new Promise((resolve, reject) => {
143
+ const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
144
+
145
+ const headers = {
146
+ 'Content-Type': options.contentType || 'application/atom+xml',
147
+ 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
148
+ 'sap-client': this.config.client,
149
+ 'sap-language': this.config.language || 'EN',
150
+ ...options.headers
151
+ };
152
+
153
+ headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
154
+
155
+ if (['POST', 'PUT', 'DELETE'].includes(method) && this.csrfToken) {
156
+ headers['X-CSRF-Token'] = this.csrfToken;
157
+ }
158
+
159
+ if (this.cookies) {
160
+ headers['Cookie'] = this.cookies;
161
+ }
162
+
163
+ const bodyStr = body || '';
164
+ headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
165
+
166
+ const reqOptions = {
167
+ hostname: url.hostname,
168
+ port: url.port,
169
+ path: url.pathname + url.search,
170
+ method,
171
+ headers,
172
+ agent: new https.Agent({ rejectUnauthorized: false })
173
+ };
174
+
175
+ const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
176
+ if (res.statusCode === 401) {
177
+ reject({ statusCode: 401, message: 'Authentication failed: 401' });
178
+ return;
179
+ }
180
+ if (res.statusCode === 403) {
181
+ const errMsg = 'Missing debug authorization. Grant S_ADT_RES (ACTVT=16) to user.';
182
+ reject({ statusCode: 403, message: errMsg });
183
+ return;
184
+ }
185
+ if (res.statusCode === 404) {
186
+ let respBody = '';
187
+ res.on('data', chunk => respBody += chunk);
188
+ res.on('end', () => {
189
+ reject({ statusCode: 404, message: `HTTP 404: ${reqOptions.path}`, body: respBody });
190
+ });
191
+ return;
192
+ }
193
+ if (res.statusCode >= 400) {
194
+ let respBody = '';
195
+ res.on('data', chunk => respBody += chunk);
196
+ res.on('end', () => {
197
+ reject({ statusCode: res.statusCode, message: `HTTP ${res.statusCode} error`, body: respBody });
198
+ });
199
+ return;
200
+ }
201
+
202
+ // Update cookies from any response that sets them
203
+ if (res.headers['set-cookie']) {
204
+ const newCookies = Array.isArray(res.headers['set-cookie'])
205
+ ? res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ')
206
+ : res.headers['set-cookie'].split(';')[0];
207
+ this.cookies = this.cookies ? this.cookies + '; ' + newCookies : newCookies;
208
+ }
209
+
210
+ let respBody = '';
211
+ res.on('data', chunk => respBody += chunk);
212
+ res.on('end', () => {
213
+ resolve({ body: respBody, headers: res.headers, statusCode: res.statusCode });
214
+ });
215
+ });
216
+
217
+ req.on('error', reject);
218
+ if (bodyStr) req.write(bodyStr);
219
+ req.end();
220
+ });
221
+ }
222
+
223
+ async get(urlPath, options = {}) {
224
+ return this.request('GET', urlPath, null, options);
225
+ }
226
+
227
+ async post(urlPath, body = null, options = {}) {
228
+ return this.request('POST', urlPath, body, options);
229
+ }
230
+
231
+ /**
232
+ * Fire-and-forget POST: resolves when the request bytes have been flushed
233
+ * to the TCP send buffer — does NOT wait for a response.
234
+ *
235
+ * Used by detach() (stepContinue) where:
236
+ * - ADT long-polls until the next breakpoint fires → response may never come
237
+ * - We only need ADT to *receive* the request, not respond to it
238
+ * - Using the existing stateful session (cookies/CSRF) is mandatory
239
+ *
240
+ * The socket is deliberately left open so the OS TCP stack can finish
241
+ * delivering the data to ADT after we return from this method.
242
+ *
243
+ * @param {string} urlPath - URL path
244
+ * @param {string} body - Request body (may be empty string)
245
+ * @param {object} options - Same options as post() (contentType, headers, etc.)
246
+ * @returns {Promise<void>} Resolves when req.end() callback fires
247
+ */
248
+ async postFire(urlPath, body = null, options = {}) {
249
+ return new Promise((resolve, reject) => {
250
+ const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
251
+
252
+ const headers = {
253
+ 'Content-Type': options.contentType || 'application/atom+xml',
254
+ 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
255
+ 'sap-client': this.config.client,
256
+ 'sap-language': this.config.language || 'EN',
257
+ ...options.headers
258
+ };
259
+
260
+ headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
261
+
262
+ if (this.csrfToken) {
263
+ headers['X-CSRF-Token'] = this.csrfToken;
264
+ }
265
+
266
+ if (this.cookies) {
267
+ headers['Cookie'] = this.cookies;
268
+ }
269
+
270
+ const bodyStr = body || '';
271
+ headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
272
+
273
+ const reqOptions = {
274
+ hostname: url.hostname,
275
+ port: url.port,
276
+ path: url.pathname + url.search,
277
+ method: 'POST',
278
+ headers,
279
+ agent: new https.Agent({ rejectUnauthorized: false })
280
+ };
281
+
282
+ const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (_res) => {
283
+ // Drain response body to prevent socket hang; we don't use the data.
284
+ _res.resume();
285
+ });
286
+
287
+ // Resolve as soon as the request is fully written and flushed.
288
+ req.on('error', resolve); // ignore errors — fire-and-forget
289
+ if (bodyStr) req.write(bodyStr);
290
+ req.end(() => resolve());
291
+ });
292
+ }
293
+
294
+ async put(urlPath, body = null, options = {}) {
295
+ return this.request('PUT', urlPath, body, options);
296
+ }
297
+
298
+ async delete(urlPath, options = {}) {
299
+ return this.request('DELETE', urlPath, null, options);
300
+ }
301
+
302
+ /**
303
+ * Extract an attribute value from a simple XML element using regex.
304
+ * e.g. extractXmlAttr(xml, 'adtcore:uri', null) for text content
305
+ * extractXmlAttr(xml, 'entry', 'id') for attribute
306
+ * @param {string} xml - XML string
307
+ * @param {string} tag - Tag name (may include namespace prefix)
308
+ * @param {string|null} attr - Attribute name, or null for text content
309
+ * @returns {string|null} Extracted value or null
310
+ */
311
+ static extractXmlAttr(xml, tag, attr) {
312
+ if (attr) {
313
+ const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'i');
314
+ const m = xml.match(re);
315
+ return m ? m[1] : null;
316
+ }
317
+ const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'i');
318
+ const m = xml.match(re);
319
+ return m ? m[1].trim() : null;
320
+ }
321
+
322
+ /**
323
+ * Extract all occurrences of a tag's content or attribute from XML.
324
+ * @param {string} xml - XML string
325
+ * @param {string} tag - Tag name
326
+ * @param {string|null} attr - Attribute name, or null for text content
327
+ * @returns {string[]} Array of matched values
328
+ */
329
+ static extractXmlAll(xml, tag, attr) {
330
+ const results = [];
331
+ if (attr) {
332
+ const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'gi');
333
+ let m;
334
+ while ((m = re.exec(xml)) !== null) results.push(m[1]);
335
+ } else {
336
+ const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'gi');
337
+ let m;
338
+ while ((m = re.exec(xml)) !== null) results.push(m[1].trim());
339
+ }
340
+ return results;
341
+ }
342
+ }
343
+
344
+ module.exports = { AdtHttp };
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * debug-daemon.js — Long-lived daemon that holds the stateful ADT HTTP connection.
5
+ *
6
+ * Why this exists:
7
+ * SAP ADT debug sessions are pinned to a specific ABAP work process via the
8
+ * SAP_SESSIONID cookie. The cookie is maintained in-memory by AdtHttp. When a
9
+ * CLI process exits (e.g. `debug attach --json`) the in-memory session is lost
10
+ * and the ABAP work process is released from the debugger. Subsequent CLI
11
+ * invocations (`debug step`) get a different HTTP connection → `noSessionAttached`.
12
+ *
13
+ * The daemon keeps one Node.js process alive after attach, holding the open
14
+ * HTTP session. Individual CLI commands connect via Unix domain socket, send a
15
+ * JSON command, read a JSON response, and exit.
16
+ *
17
+ * Lifecycle:
18
+ * 1. Spawned by `debug attach --json` with detached:true + stdio:ignore + unref()
19
+ * 2. Reads config/session from env vars (JSON-encoded)
20
+ * 3. Creates Unix socket at DEBUG_DAEMON_SOCK_PATH
21
+ * 4. Handles JSON-line commands until `terminate` or 30-min idle timeout
22
+ * 5. Deletes socket file and exits
23
+ *
24
+ * IPC Protocol — newline-delimited JSON over Unix socket:
25
+ *
26
+ * Commands:
27
+ * { "cmd": "ping" }
28
+ * { "cmd": "step", "type": "stepOver|stepInto|stepReturn|stepContinue" }
29
+ * { "cmd": "vars", "name": null }
30
+ * { "cmd": "stack" }
31
+ * { "cmd": "terminate" }
32
+ *
33
+ * Responses (one JSON line per command):
34
+ * { "ok": true, "pong": true }
35
+ * { "ok": true, "position": {...}, "source": [...] }
36
+ * { "ok": true, "variables": [...] }
37
+ * { "ok": true, "frames": [...] }
38
+ * { "ok": true, "terminated": true }
39
+ * { "ok": false, "error": "message", "statusCode": 400 }
40
+ */
41
+
42
+ const net = require('net');
43
+ const fs = require('fs');
44
+ const { AdtHttp } = require('./adt-http');
45
+ const { DebugSession } = require('./debug-session');
46
+
47
+ const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
48
+
49
+ // ─── Public API ───────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Start the daemon server.
53
+ *
54
+ * @param {object} config - ABAP connection config
55
+ * @param {string} sessionId - debugSessionId returned by attach
56
+ * @param {string} socketPath - Unix socket path to listen on
57
+ * @param {object|null} snapshot - { csrfToken, cookies } captured from attach process
58
+ */
59
+ async function startDaemon(config, sessionId, socketPath, snapshot) {
60
+ const adt = new AdtHttp(config);
61
+
62
+ // Restore the exact SAP_SESSIONID cookie + CSRF token from the attach process.
63
+ // This guarantees the first IPC command reuses the same ABAP work process
64
+ // without another round-trip for a new token.
65
+ if (snapshot && snapshot.csrfToken) adt.csrfToken = snapshot.csrfToken;
66
+ if (snapshot && snapshot.cookies) adt.cookies = snapshot.cookies;
67
+
68
+ const session = new DebugSession(adt, sessionId);
69
+
70
+ // Remove stale socket file from a previous crash
71
+ try { fs.unlinkSync(socketPath); } catch (e) { /* ignore ENOENT */ }
72
+
73
+ let idleTimer = null;
74
+
75
+ function resetIdle() {
76
+ if (idleTimer) clearTimeout(idleTimer);
77
+ idleTimer = setTimeout(cleanupAndExit, DAEMON_IDLE_TIMEOUT_MS);
78
+ // unref so idle timer alone doesn't keep the process alive — if the
79
+ // server stops listening for another reason the process can exit naturally
80
+ if (idleTimer.unref) idleTimer.unref();
81
+ }
82
+
83
+ function cleanupAndExit(code) {
84
+ try { fs.unlinkSync(socketPath); } catch (e) { /* ignore */ }
85
+ process.exit(code || 0);
86
+ }
87
+
88
+ const server = net.createServer((socket) => {
89
+ resetIdle();
90
+ let buf = '';
91
+
92
+ socket.on('data', (chunk) => {
93
+ buf += chunk.toString();
94
+ let idx;
95
+ while ((idx = buf.indexOf('\n')) !== -1) {
96
+ const line = buf.slice(0, idx).trim();
97
+ buf = buf.slice(idx + 1);
98
+ if (line) {
99
+ _handleLine(socket, line, session, cleanupAndExit, resetIdle);
100
+ }
101
+ }
102
+ });
103
+
104
+ socket.on('error', () => { /* client disconnected abruptly — ignore */ });
105
+ });
106
+
107
+ server.listen(socketPath, () => {
108
+ resetIdle();
109
+ });
110
+
111
+ server.on('error', (err) => {
112
+ process.stderr.write(`[debug-daemon] server error: ${err.message}\n`);
113
+ cleanupAndExit(1);
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Handle one parsed JSON command line.
119
+ * Exported so unit tests can call it directly without spawning a real server.
120
+ */
121
+ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
122
+ let req;
123
+ try {
124
+ req = JSON.parse(line);
125
+ } catch (e) {
126
+ _send(socket, { ok: false, error: `Invalid JSON: ${e.message}` });
127
+ return;
128
+ }
129
+
130
+ resetIdle();
131
+
132
+ try {
133
+ switch (req.cmd) {
134
+ case 'ping': {
135
+ _send(socket, { ok: true, pong: true });
136
+ break;
137
+ }
138
+ case 'step': {
139
+ const result = await session.step(req.type || 'stepOver');
140
+ _send(socket, { ok: true, position: result.position, source: result.source });
141
+ break;
142
+ }
143
+ case 'vars': {
144
+ const variables = await session.getVariables(req.name || null);
145
+ _send(socket, { ok: true, variables });
146
+ break;
147
+ }
148
+ case 'expand': {
149
+ const children = await session.getVariableChildren(req.id, req.meta || {});
150
+ _send(socket, { ok: true, variables: children });
151
+ break;
152
+ }
153
+ case 'stack': {
154
+ const frames = await session.getStack();
155
+ _send(socket, { ok: true, frames });
156
+ break;
157
+ }
158
+ case 'terminate': {
159
+ await session.terminate();
160
+ _send(socket, { ok: true, terminated: true });
161
+ // Flush response before exiting — wait for client to close the socket
162
+ socket.end(() => cleanupAndExit(0));
163
+ break;
164
+ }
165
+ default: {
166
+ _send(socket, { ok: false, error: `Unknown command: ${req.cmd}` });
167
+ }
168
+ }
169
+ } catch (err) {
170
+ _send(socket, {
171
+ ok: false,
172
+ error: err.message || JSON.stringify(err),
173
+ statusCode: err.statusCode
174
+ });
175
+ }
176
+ }
177
+
178
+ function _send(socket, obj) {
179
+ try {
180
+ socket.write(JSON.stringify(obj) + '\n');
181
+ } catch (e) {
182
+ // Client disconnected — ignore
183
+ }
184
+ }
185
+
186
+ // ─── Entry point when run as standalone daemon process ────────────────────────
187
+
188
+ if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
189
+ const config = JSON.parse(process.env.DEBUG_DAEMON_CONFIG || '{}');
190
+ const sessionId = process.env.DEBUG_DAEMON_SESSION_ID || '';
191
+ const socketPath = process.env.DEBUG_DAEMON_SOCK_PATH || '';
192
+ const snapshot = process.env.DEBUG_DAEMON_SESSION_SNAPSHOT
193
+ ? JSON.parse(process.env.DEBUG_DAEMON_SESSION_SNAPSHOT)
194
+ : null;
195
+
196
+ if (!config.host || !sessionId || !socketPath) {
197
+ process.stderr.write('[debug-daemon] missing required env vars\n');
198
+ process.exit(1);
199
+ }
200
+
201
+ startDaemon(config, sessionId, socketPath, snapshot).catch((err) => {
202
+ process.stderr.write(`[debug-daemon] startup error: ${err.message}\n`);
203
+ process.exit(1);
204
+ });
205
+ }
206
+
207
+ module.exports = { startDaemon, _handleLine };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared display helpers for the debug command and REPL.
5
+ */
6
+
7
+ /**
8
+ * Format and print a variable list with dynamic column widths.
9
+ *
10
+ * Name display:
11
+ * {DATAAGING_TEMPERATURE_DEFAULT} → DATAAGING_TEMPERATURE_DEFAULT (strip braces)
12
+ *
13
+ * Value display:
14
+ * {O:N*\CLASS=FOO} → (FOO) object reference
15
+ * {O:INITIAL} → (null) null object reference
16
+ * {A:N*\CLASS=FOO\TYPE=BAR} → (ref: FOO) data reference
17
+ * table → [N rows] — use 'x NAME' to expand
18
+ * long string → truncated to 100 chars with …
19
+ *
20
+ * @param {Array} variables - [{ name, type, value, metaType, tableLines }]
21
+ */
22
+ function printVarList(variables) {
23
+ if (variables.length === 0) {
24
+ console.log('\n No variables at current position.');
25
+ return;
26
+ }
27
+
28
+ // Build display rows first so we can measure column widths.
29
+ const rows = variables.map(({ name, type, value, metaType, tableLines }) => {
30
+ const dispName = (name.startsWith('{') && name.endsWith('}')
31
+ ? name.slice(1, -1)
32
+ : name).toLowerCase();
33
+ const dispType = (type || '').toLowerCase();
34
+
35
+ let dispValue;
36
+ if (metaType === 'table') {
37
+ dispValue = `[${tableLines} rows] — use 'x ${dispName}' to expand`;
38
+ } else if (typeof value === 'string' && value.startsWith('{O:INITIAL}')) {
39
+ dispValue = '(null)';
40
+ } else if (typeof value === 'string' && value.startsWith('{O:')) {
41
+ const classMatch = value.match(/\\CLASS=([A-Z0-9_]+)/i);
42
+ dispValue = classMatch ? `(${classMatch[1].toLowerCase()})` : value;
43
+ } else if (typeof value === 'string' && value.startsWith('{A:')) {
44
+ const classMatch = value.match(/\\CLASS=([A-Z0-9_]+)/i);
45
+ dispValue = classMatch ? `(ref: ${classMatch[1].toLowerCase()})` : '(ref)';
46
+ } else {
47
+ const str = String(value || '');
48
+ dispValue = str.length > 100 ? str.slice(0, 100) + '…' : str;
49
+ }
50
+
51
+ return { dispName, dispType, dispValue };
52
+ });
53
+
54
+ const nameW = Math.min(40, Math.max(4, ...rows.map(r => r.dispName.length)));
55
+ const typeW = Math.min(30, Math.max(4, ...rows.map(r => r.dispType.length)));
56
+
57
+ console.log('\n Variables:\n');
58
+ console.log(' ' + 'Name'.padEnd(nameW + 2) + 'Type'.padEnd(typeW + 2) + 'Value');
59
+ console.log(' ' + '-'.repeat(nameW + typeW + 20));
60
+ rows.forEach(({ dispName, dispType, dispValue }) => {
61
+ const nameCol = dispName.length > nameW
62
+ ? dispName.slice(0, nameW - 1) + '…'
63
+ : dispName.padEnd(nameW + 2);
64
+ console.log(' ' + nameCol + dispType.padEnd(typeW + 2) + dispValue);
65
+ });
66
+ console.log('');
67
+ }
68
+
69
+ module.exports = { printVarList };