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.
- package/README.md +27 -0
- package/abap/CLAUDE.md +143 -0
- package/bin/abapgit-agent +2 -0
- package/package.json +7 -1
- package/src/commands/debug.js +1390 -0
- package/src/commands/dump.js +327 -0
- package/src/commands/help.js +6 -1
- package/src/utils/adt-http.js +344 -0
- package/src/utils/debug-daemon.js +207 -0
- package/src/utils/debug-render.js +69 -0
- package/src/utils/debug-repl.js +256 -0
- package/src/utils/debug-session.js +845 -0
- package/src/utils/debug-state.js +124 -0
|
@@ -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
|
+
};
|
package/src/commands/help.js
CHANGED
|
@@ -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.
|
|
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 };
|