abapgit-agent 1.8.9 → 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,327 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Dump command - Query short dumps (ST22) from ABAP system
5
+ */
6
+
7
+ // Convert a local date/time in the given IANA timezone to a UTC Date.
8
+ // Uses an iterative approach to reliably handle DST transitions.
9
+ function localToUTC(dateStr, timeStr, timezone) {
10
+ const iso = `${dateStr.slice(0,4)}-${dateStr.slice(4,6)}-${dateStr.slice(6,8)}` +
11
+ `T${timeStr.slice(0,2)}:${timeStr.slice(2,4)}:${timeStr.slice(4,6)}`;
12
+ let candidate = new Date(iso + 'Z');
13
+ for (let i = 0; i < 3; i++) {
14
+ const localStr = candidate.toLocaleString('sv-SE', { timeZone: timezone }).replace(' ', 'T');
15
+ const diff = new Date(iso + 'Z').getTime() - new Date(localStr + 'Z').getTime();
16
+ candidate = new Date(candidate.getTime() + diff);
17
+ }
18
+ return candidate;
19
+ }
20
+
21
+ // Format a UTC Date as a local date/time in the given IANA timezone.
22
+ // Returns { date: 'YYYY-MM-DD', time: 'HH:MM:SS' }.
23
+ function utcToLocal(utcDate, timezone) {
24
+ const local = utcDate.toLocaleString('sv-SE', { timeZone: timezone });
25
+ const [date, time] = local.split(' ');
26
+ return { date, time };
27
+ }
28
+
29
+ // Parse a 14-char UTC timestamp string (YYYYMMDDhhmmss) to a Date.
30
+ function parseUTCTimestamp(ts) {
31
+ const s = String(ts).padStart(14, '0');
32
+ return new Date(`${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}T${s.slice(8,10)}:${s.slice(10,12)}:${s.slice(12,14)}Z`);
33
+ }
34
+
35
+ // Format a UTC Date as a 14-char timestamp string (YYYYMMDDhhmmss).
36
+ function toTimestampStr(date) {
37
+ return date.toISOString().replace(/[-T:Z]/g, '').slice(0, 14);
38
+ }
39
+
40
+ // Parse the user's --date argument into { from, to } as YYYYMMDD strings,
41
+ // respecting the given timezone for TODAY/YESTERDAY keywords.
42
+ function parseDateArg(dateStr, timezone) {
43
+ const nowLocal = utcToLocal(new Date(), timezone);
44
+ const todayStr = nowLocal.date.replace(/-/g, '');
45
+
46
+ const upper = dateStr.toUpperCase();
47
+ if (upper === 'TODAY') {
48
+ return { from: todayStr, to: todayStr };
49
+ }
50
+ if (upper === 'YESTERDAY') {
51
+ const d = new Date(nowLocal.date + 'T00:00:00Z');
52
+ d.setUTCDate(d.getUTCDate() - 1);
53
+ const yStr = d.toISOString().slice(0, 10).replace(/-/g, '');
54
+ return { from: yStr, to: yStr };
55
+ }
56
+ if (dateStr.includes('..')) {
57
+ const [a, b] = dateStr.split('..');
58
+ return { from: a.replace(/-/g, ''), to: b.replace(/-/g, '') };
59
+ }
60
+ const d = dateStr.replace(/-/g, '');
61
+ return { from: d, to: d };
62
+ }
63
+
64
+ function parseTimeArg(timeStr) {
65
+ const padTime = (t) => (t.replace(/:/g, '') + '000000').substring(0, 6);
66
+ if (timeStr.includes('..')) {
67
+ const [a, b] = timeStr.split('..');
68
+ return { from: padTime(a), to: padTime(b) };
69
+ }
70
+ const t = padTime(timeStr);
71
+ return { from: t, to: t };
72
+ }
73
+
74
+ // Resolve display date/time for a dump entry, using utc_timestamp when available.
75
+ function resolveDateTime(dump, timezone) {
76
+ const ts = dump.UTC_TIMESTAMP || dump.utc_timestamp;
77
+ if (ts && timezone) {
78
+ const utcDate = parseUTCTimestamp(ts);
79
+ return utcToLocal(utcDate, timezone);
80
+ }
81
+ return {
82
+ date: dump.DATE || dump.date || '',
83
+ time: dump.TIME || dump.time || '',
84
+ };
85
+ }
86
+
87
+ function renderList(dumps, total, limit, timezone) {
88
+ const totalNum = Number(total) || dumps.length;
89
+ const tzLabel = timezone ? ` [${timezone}]` : '';
90
+ console.log(`\n Short Dumps (${totalNum} found)${tzLabel}\n`);
91
+
92
+ if (dumps.length === 0) {
93
+ console.log(' No short dumps found for the given filters.\n');
94
+ return;
95
+ }
96
+
97
+ const W = { num: 3, date: 10, time: 8, user: 12, prog: 30, err: 40 };
98
+ const hdr = ` ${'#'.padStart(W.num)} ${'Date'.padEnd(W.date)} ${'Time'.padEnd(W.time)} ${'User'.padEnd(W.user)} ${'Program'.padEnd(W.prog)} Error`;
99
+ const sep = ' ' + '-'.repeat(W.num + 2 + W.date + 2 + W.time + 2 + W.user + 2 + W.prog + 2 + W.err);
100
+ console.log(hdr);
101
+ console.log(sep);
102
+
103
+ dumps.forEach((d, i) => {
104
+ const { date, time } = resolveDateTime(d, timezone);
105
+ const user = (d.USER || d.user || '').substring(0, W.user);
106
+ const prog = (d.PROGRAM || d.program || '').substring(0, W.prog);
107
+ const err = (d.ERROR || d.error || '');
108
+ console.log(` ${String(i + 1).padStart(W.num)} ${date.padEnd(W.date)} ${time.padEnd(W.time)} ${user.padEnd(W.user)} ${prog.padEnd(W.prog)} ${err}`);
109
+ });
110
+
111
+ console.log('');
112
+ if (totalNum > limit) {
113
+ console.log(` Showing ${dumps.length} of ${totalNum} dumps. Use --limit to see more.`);
114
+ }
115
+ console.log(' Use --detail <number> to see full dump details\n');
116
+ }
117
+
118
+ function renderDetail(dump, timezone) {
119
+ console.log('\n Short Dump Detail\n');
120
+
121
+ const { date, time } = resolveDateTime(dump, timezone);
122
+ const tzLabel = timezone ? ` (${timezone})` : '';
123
+
124
+ const fields = [
125
+ ['Error', dump.ERROR || dump.error || ''],
126
+ ['Date', date + tzLabel],
127
+ ['Time', time],
128
+ ['User', dump.USER || dump.user || ''],
129
+ ['Program', dump.PROGRAM || dump.program || ''],
130
+ ['Object', dump.OBJECT || dump.object || ''],
131
+ ['Package', dump.PACKAGE || dump.package || ''],
132
+ ['Exception', dump.EXCEPTION || dump.exception || ''],
133
+ ];
134
+ fields.forEach(([label, val]) => {
135
+ if (val) {
136
+ console.log(` ${label.padEnd(12)}${val}`);
137
+ }
138
+ });
139
+
140
+ const normalize = (s) => s.replace(/\\n/g, '\n').replace(/\\r/g, '');
141
+ const whatHappened = normalize(dump.WHAT_HAPPENED || dump.what_happened || '');
142
+ const errorAnalysis = normalize(dump.ERROR_ANALYSIS || dump.error_analysis || '');
143
+
144
+ if (whatHappened) {
145
+ console.log('\n What happened:');
146
+ console.log(' ' + '-'.repeat(55));
147
+ console.log(whatHappened.split('\n').map(l => ' ' + l).join('\n'));
148
+ }
149
+ if (errorAnalysis) {
150
+ console.log('\n Error analysis:');
151
+ console.log(' ' + '-'.repeat(55));
152
+ console.log(errorAnalysis.split('\n').map(l => ' ' + l).join('\n'));
153
+ }
154
+
155
+ const stack = dump.CALL_STACK || dump.call_stack || [];
156
+
157
+ // Separate structured frames (have level) from source block (no level, has raw text)
158
+ const frames = stack.filter(f => (f.LEVEL || f.level));
159
+ const srcEntries = stack.filter(f => !(f.LEVEL || f.level) && (f.METHOD || f.method));
160
+
161
+ if (frames.length > 0) {
162
+ console.log('\n Call stack:');
163
+ console.log(' ' + '-'.repeat(55));
164
+ frames.forEach((frame) => {
165
+ const level = frame.LEVEL || frame.level || '';
166
+ const cls = frame.CLASS || frame.class || '';
167
+ const method = frame.METHOD || frame.method || '';
168
+ const program = frame.PROGRAM || frame.program || '';
169
+ const include = frame.INCLUDE || frame.include || '';
170
+ const line = frame.LINE || frame.line || '';
171
+ const location = cls ? `${cls}->${method}` : (method ? `${program} ${method}` : program || include);
172
+ const lineRef = line ? ` (line ${line})` : '';
173
+ console.log(` ${String(level).padStart(3)} ${location}${lineRef}`);
174
+ });
175
+ }
176
+
177
+ if (srcEntries.length > 0) {
178
+ const srcInc = dump.SOURCE_INCLUDE || dump.source_include || '';
179
+ const srcLine = dump.SOURCE_LINE || dump.source_line || 0;
180
+ const heading = srcInc ? ` Source (${srcInc}, line ${srcLine}):` : ' Source:';
181
+ console.log('\n' + heading);
182
+ console.log(' ' + '-'.repeat(55));
183
+ const rawText = srcEntries[0].METHOD || srcEntries[0].method || '';
184
+ console.log(normalize(rawText).split('\n').map(l => ' ' + l).join('\n'));
185
+ }
186
+
187
+ console.log('');
188
+ }
189
+
190
+ module.exports = {
191
+ name: 'dump',
192
+ description: 'Query short dumps (ST22) from ABAP system',
193
+ requiresAbapConfig: true,
194
+ requiresVersionCheck: false,
195
+
196
+ async execute(args, context) {
197
+ const { loadConfig, AbapHttp } = context;
198
+
199
+ const idx = (flag) => args.indexOf(flag);
200
+ const val = (flag) => {
201
+ const i = idx(flag);
202
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
203
+ };
204
+
205
+ const userRaw = val('--user');
206
+ const dateRaw = val('--date');
207
+ const timeRaw = val('--time');
208
+ const programRaw = val('--program');
209
+ const errorRaw = val('--error');
210
+ const limitRaw = val('--limit');
211
+ const detailRaw = val('--detail');
212
+ const timezoneRaw = val('--timezone');
213
+ const jsonOutput = args.includes('--json');
214
+
215
+ const user = userRaw ? userRaw.toUpperCase() : null;
216
+ const program = programRaw ? programRaw.toUpperCase() : null;
217
+ const error = errorRaw ? errorRaw.toUpperCase() : null;
218
+
219
+ // Resolve timezone: explicit flag > system default
220
+ const timezone = timezoneRaw || Intl.DateTimeFormat().resolvedOptions().timeZone;
221
+
222
+ let limit = 20;
223
+ if (limitRaw) {
224
+ const parsed = parseInt(limitRaw, 10);
225
+ if (!isNaN(parsed) && parsed > 0) {
226
+ limit = Math.min(parsed, 100);
227
+ }
228
+ }
229
+
230
+ // Build UTC timestamp range from user's date/time in their timezone
231
+ let tsFrom = null, tsTo = null;
232
+ if (dateRaw) {
233
+ const dates = parseDateArg(dateRaw, timezone);
234
+ const timeF = timeRaw ? parseTimeArg(timeRaw).from : '000000';
235
+ const timeT = timeRaw ? parseTimeArg(timeRaw).to : '235959';
236
+ tsFrom = toTimestampStr(localToUTC(dates.from, timeF, timezone));
237
+ tsTo = toTimestampStr(localToUTC(dates.to, timeT, timezone));
238
+ } else if (timeRaw) {
239
+ // Time filter without date: apply to the default 7-day window
240
+ const now = utcToLocal(new Date(), timezone);
241
+ const today = now.date.replace(/-/g, '');
242
+ const sevenAgo = (() => {
243
+ const d = new Date(now.date + 'T00:00:00Z');
244
+ d.setUTCDate(d.getUTCDate() - 7);
245
+ return d.toISOString().slice(0, 10).replace(/-/g, '');
246
+ })();
247
+ const times = parseTimeArg(timeRaw);
248
+ tsFrom = toTimestampStr(localToUTC(sevenAgo, times.from, timezone));
249
+ tsTo = toTimestampStr(localToUTC(today, times.to, timezone));
250
+ }
251
+
252
+ const detailN = detailRaw ? parseInt(detailRaw, 10) : null;
253
+
254
+ const config = loadConfig();
255
+ const http = new AbapHttp(config);
256
+ const csrfToken = await http.fetchCsrfToken();
257
+
258
+ const buildData = (extra) => {
259
+ const data = { limit };
260
+ if (user) data.user = user;
261
+ if (program) data.program = program;
262
+ if (error) data.error = error;
263
+ if (tsFrom) {
264
+ data.ts_from = tsFrom;
265
+ data.ts_to = tsTo;
266
+ }
267
+ return Object.assign(data, extra);
268
+ };
269
+
270
+ if (detailN) {
271
+ // Two-step: first list (using limit 100 to get full page), then detail
272
+ const listResult = await http.post('/sap/bc/z_abapgit_agent/dump', buildData({ limit: 100 }), { csrfToken });
273
+ const listSuccess = listResult.SUCCESS || listResult.success;
274
+ const listErr = listResult.ERROR || listResult.error;
275
+
276
+ if (!listSuccess || listErr) {
277
+ console.error(`\n Error: ${listErr || 'Failed to query short dumps'}\n`);
278
+ return;
279
+ }
280
+
281
+ const dumps = listResult.DUMPS || listResult.dumps || [];
282
+ if (detailN < 1 || detailN > dumps.length) {
283
+ console.error(`\n Error: Row number ${detailN} not found in results (found ${dumps.length} dump(s))\n`);
284
+ return;
285
+ }
286
+
287
+ const targetId = dumps[detailN - 1].ID || dumps[detailN - 1].id;
288
+ const detailResult = await http.post('/sap/bc/z_abapgit_agent/dump', buildData({ detail: targetId }), { csrfToken });
289
+
290
+ const success = detailResult.SUCCESS || detailResult.success;
291
+ const errMsg = detailResult.ERROR || detailResult.error;
292
+
293
+ if (!success || errMsg) {
294
+ console.error(`\n Error: ${errMsg || 'Failed to load dump detail'}\n`);
295
+ return;
296
+ }
297
+
298
+ if (jsonOutput) {
299
+ console.log(JSON.stringify(detailResult, null, 2));
300
+ return;
301
+ }
302
+
303
+ const detailDumps = detailResult.DUMPS || detailResult.dumps || [];
304
+ renderDetail(detailDumps[0] || {}, timezone);
305
+ return;
306
+ }
307
+
308
+ // List mode
309
+ const result = await http.post('/sap/bc/z_abapgit_agent/dump', buildData({}), { csrfToken });
310
+ const success = result.SUCCESS || result.success;
311
+ const errMsg = result.ERROR || result.error;
312
+
313
+ if (!success || errMsg) {
314
+ console.error(`\n Error: ${errMsg || 'Failed to query short dumps'}\n`);
315
+ return;
316
+ }
317
+
318
+ if (jsonOutput) {
319
+ console.log(JSON.stringify(result, null, 2));
320
+ return;
321
+ }
322
+
323
+ const dumps = result.DUMPS || result.dumps || [];
324
+ const total = result.TOTAL || result.total || dumps.length;
325
+ renderList(dumps, total, limit, timezone);
326
+ }
327
+ };
@@ -58,6 +58,9 @@ Commands:
58
58
  where --objects <obj1>,<obj2>,... [--type <type>] [--limit <n>] [--json]
59
59
  Find where-used list for ABAP objects (classes, interfaces, programs)
60
60
 
61
+ dump [--user <user>] [--date <date>] [--time <HH:MM..HH:MM>] [--timezone <tz>] [--program <prog>] [--error <error>] [--limit <n>] [--detail <n>] [--json]
62
+ Query short dumps (ST22) from ABAP system. Use --detail <n> to view full details.
63
+
61
64
  ref <pattern> [--json]
62
65
  Search ABAP reference repositories for patterns. Requires referenceFolder in .abapGitAgent.
63
66
 
@@ -100,12 +103,14 @@ Examples:
100
103
  abapgit-agent list --package $MY_PACKAGE --type CLAS,INTF # List classes & interfaces
101
104
  abapgit-agent view --objects ZCL_MY_CLASS # View class definition
102
105
  abapgit-agent where --objects ZCL_MY_CLASS # Find where class is used
106
+ abapgit-agent dump --date TODAY # Recent short dumps
107
+ abapgit-agent dump --user DEVELOPER --detail 1 # Full detail of first result
103
108
  abapgit-agent ref "CORRESPONDING" # Search for pattern
104
109
  abapgit-agent ref --topic exceptions # View exceptions topic
105
110
  abapgit-agent health # Health check
106
111
  abapgit-agent status # Configuration status
107
112
 
108
- For more info: https://github.tools.sap/I045696/abapgit-agent
113
+ For more info: https://github.com/SylvosCai/abapgit-agent
109
114
  `);
110
115
  }
111
116
  };
@@ -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 };