abapgit-agent 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/abap/CLAUDE.md +104 -72
- package/bin/abapgit-agent +9 -1
- package/package.json +7 -1
- package/src/commands/debug.js +1390 -0
- package/src/commands/pull.js +17 -3
- package/src/utils/adt-http.js +369 -0
- package/src/utils/debug-daemon.js +208 -0
- package/src/utils/debug-render.js +69 -0
- package/src/utils/debug-repl.js +256 -0
- package/src/utils/debug-session.js +897 -0
- package/src/utils/debug-state.js +124 -0
package/src/commands/pull.js
CHANGED
|
@@ -29,12 +29,14 @@ module.exports = {
|
|
|
29
29
|
const branchArgIndex = args.indexOf('--branch');
|
|
30
30
|
const filesArgIndex = args.indexOf('--files');
|
|
31
31
|
const transportArgIndex = args.indexOf('--transport');
|
|
32
|
+
const conflictModeArgIndex = args.indexOf('--conflict-mode');
|
|
32
33
|
const jsonOutput = args.includes('--json');
|
|
33
34
|
|
|
34
35
|
// Auto-detect git remote URL if not provided
|
|
35
36
|
let gitUrl = urlArgIndex !== -1 ? args[urlArgIndex + 1] : null;
|
|
36
37
|
let branch = branchArgIndex !== -1 ? args[branchArgIndex + 1] : gitUtils.getBranch();
|
|
37
38
|
let files = null;
|
|
39
|
+
let conflictMode = conflictModeArgIndex !== -1 ? args[conflictModeArgIndex + 1] : 'abort';
|
|
38
40
|
|
|
39
41
|
// Transport: CLI arg takes priority, then config/environment, then null
|
|
40
42
|
let transportRequest = null;
|
|
@@ -74,10 +76,10 @@ module.exports = {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
await this.pull(gitUrl, branch, files, transportRequest, loadConfig, AbapHttp, jsonOutput);
|
|
79
|
+
await this.pull(gitUrl, branch, files, transportRequest, loadConfig, AbapHttp, jsonOutput, undefined, conflictMode);
|
|
78
80
|
},
|
|
79
81
|
|
|
80
|
-
async pull(gitUrl, branch = 'main', files = null, transportRequest = null, loadConfig, AbapHttp, jsonOutput = false, gitCredentials = undefined) {
|
|
82
|
+
async pull(gitUrl, branch = 'main', files = null, transportRequest = null, loadConfig, AbapHttp, jsonOutput = false, gitCredentials = undefined, conflictMode = 'abort') {
|
|
81
83
|
const TERM_WIDTH = process.stdout.columns || 80;
|
|
82
84
|
|
|
83
85
|
if (!jsonOutput) {
|
|
@@ -107,6 +109,7 @@ module.exports = {
|
|
|
107
109
|
const data = {
|
|
108
110
|
url: gitUrl,
|
|
109
111
|
branch: branch,
|
|
112
|
+
conflict_mode: conflictMode,
|
|
110
113
|
...(resolvedCredentials ? { username: resolvedCredentials.username, password: resolvedCredentials.password } : {})
|
|
111
114
|
};
|
|
112
115
|
|
|
@@ -140,12 +143,23 @@ module.exports = {
|
|
|
140
143
|
const jobId = result.JOB_ID || result.job_id;
|
|
141
144
|
const message = result.MESSAGE || result.message;
|
|
142
145
|
const errorDetail = result.ERROR_DETAIL || result.error_detail;
|
|
143
|
-
const transportRequestUsed = result.TRANSPORT_REQUEST || result.transport_request;
|
|
144
146
|
const activatedCount = result.ACTIVATED_COUNT || result.activated_count || 0;
|
|
145
147
|
const failedCount = result.FAILED_COUNT || result.failed_count || 0;
|
|
146
148
|
const logMessages = result.LOG_MESSAGES || result.log_messages || [];
|
|
147
149
|
const activatedObjects = result.ACTIVATED_OBJECTS || result.activated_objects || [];
|
|
148
150
|
const failedObjects = result.FAILED_OBJECTS || result.failed_objects || [];
|
|
151
|
+
const conflictReport = result.CONFLICT_REPORT || result.conflict_report || '';
|
|
152
|
+
const conflictCount = result.CONFLICT_COUNT || result.conflict_count || 0;
|
|
153
|
+
|
|
154
|
+
// --- Conflict report (pull was aborted) ---
|
|
155
|
+
if (conflictCount > 0 && conflictReport) {
|
|
156
|
+
console.error(`⚠️ Pull aborted — ${conflictCount} conflict(s) detected\n`);
|
|
157
|
+
console.error('─'.repeat(TERM_WIDTH));
|
|
158
|
+
console.error(conflictReport.replace(/\\n/g, '\n'));
|
|
159
|
+
const err = new Error(message || `Pull aborted — ${conflictCount} conflict(s) detected`);
|
|
160
|
+
err._isPullError = true;
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
149
163
|
|
|
150
164
|
// Icon mapping for message types
|
|
151
165
|
const getIcon = (type) => {
|
|
@@ -0,0 +1,369 @@
|
|
|
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
|
+
// Merge by name so that updated values (e.g. SAP_SESSIONID) replace
|
|
204
|
+
// their old counterparts rather than accumulating duplicates.
|
|
205
|
+
// Duplicate SAP_SESSIONID cookies would cause the ICM to route requests
|
|
206
|
+
// to a stale work process ("Service cannot be reached").
|
|
207
|
+
if (res.headers['set-cookie']) {
|
|
208
|
+
const incoming = Array.isArray(res.headers['set-cookie'])
|
|
209
|
+
? res.headers['set-cookie'].map(c => c.split(';')[0])
|
|
210
|
+
: [res.headers['set-cookie'].split(';')[0]];
|
|
211
|
+
// Parse existing cookies into a Map (preserves insertion order)
|
|
212
|
+
const jar = new Map();
|
|
213
|
+
if (this.cookies) {
|
|
214
|
+
this.cookies.split(';').forEach(pair => {
|
|
215
|
+
const trimmed = pair.trim();
|
|
216
|
+
if (trimmed) {
|
|
217
|
+
const eq = trimmed.indexOf('=');
|
|
218
|
+
const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
|
|
219
|
+
jar.set(k.trim(), trimmed);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// Overwrite with incoming cookies
|
|
224
|
+
incoming.forEach(pair => {
|
|
225
|
+
const trimmed = pair.trim();
|
|
226
|
+
if (trimmed) {
|
|
227
|
+
const eq = trimmed.indexOf('=');
|
|
228
|
+
const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
|
|
229
|
+
jar.set(k.trim(), trimmed);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
this.cookies = [...jar.values()].join('; ');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let respBody = '';
|
|
236
|
+
res.on('data', chunk => respBody += chunk);
|
|
237
|
+
res.on('end', () => {
|
|
238
|
+
resolve({ body: respBody, headers: res.headers, statusCode: res.statusCode });
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
req.on('error', reject);
|
|
243
|
+
if (bodyStr) req.write(bodyStr);
|
|
244
|
+
req.end();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async get(urlPath, options = {}) {
|
|
249
|
+
return this.request('GET', urlPath, null, options);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async post(urlPath, body = null, options = {}) {
|
|
253
|
+
return this.request('POST', urlPath, body, options);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Fire-and-forget POST: resolves when the request bytes have been flushed
|
|
258
|
+
* to the TCP send buffer — does NOT wait for a response.
|
|
259
|
+
*
|
|
260
|
+
* Used by detach() (stepContinue) where:
|
|
261
|
+
* - ADT long-polls until the next breakpoint fires → response may never come
|
|
262
|
+
* - We only need ADT to *receive* the request, not respond to it
|
|
263
|
+
* - Using the existing stateful session (cookies/CSRF) is mandatory
|
|
264
|
+
*
|
|
265
|
+
* The socket is deliberately left open so the OS TCP stack can finish
|
|
266
|
+
* delivering the data to ADT after we return from this method.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} urlPath - URL path
|
|
269
|
+
* @param {string} body - Request body (may be empty string)
|
|
270
|
+
* @param {object} options - Same options as post() (contentType, headers, etc.)
|
|
271
|
+
* @returns {Promise<void>} Resolves when req.end() callback fires
|
|
272
|
+
*/
|
|
273
|
+
async postFire(urlPath, body = null, options = {}) {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
|
|
276
|
+
|
|
277
|
+
const headers = {
|
|
278
|
+
'Content-Type': options.contentType || 'application/atom+xml',
|
|
279
|
+
'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
|
|
280
|
+
'sap-client': this.config.client,
|
|
281
|
+
'sap-language': this.config.language || 'EN',
|
|
282
|
+
...options.headers
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
|
|
286
|
+
|
|
287
|
+
if (this.csrfToken) {
|
|
288
|
+
headers['X-CSRF-Token'] = this.csrfToken;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (this.cookies) {
|
|
292
|
+
headers['Cookie'] = this.cookies;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const bodyStr = body || '';
|
|
296
|
+
headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
|
|
297
|
+
|
|
298
|
+
const reqOptions = {
|
|
299
|
+
hostname: url.hostname,
|
|
300
|
+
port: url.port,
|
|
301
|
+
path: url.pathname + url.search,
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers,
|
|
304
|
+
agent: new https.Agent({ rejectUnauthorized: false })
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (_res) => {
|
|
308
|
+
// Drain response body to prevent socket hang; we don't use the data.
|
|
309
|
+
_res.resume();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Resolve as soon as the request is fully written and flushed.
|
|
313
|
+
req.on('error', resolve); // ignore errors — fire-and-forget
|
|
314
|
+
if (bodyStr) req.write(bodyStr);
|
|
315
|
+
req.end(() => resolve());
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async put(urlPath, body = null, options = {}) {
|
|
320
|
+
return this.request('PUT', urlPath, body, options);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async delete(urlPath, options = {}) {
|
|
324
|
+
return this.request('DELETE', urlPath, null, options);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Extract an attribute value from a simple XML element using regex.
|
|
329
|
+
* e.g. extractXmlAttr(xml, 'adtcore:uri', null) for text content
|
|
330
|
+
* extractXmlAttr(xml, 'entry', 'id') for attribute
|
|
331
|
+
* @param {string} xml - XML string
|
|
332
|
+
* @param {string} tag - Tag name (may include namespace prefix)
|
|
333
|
+
* @param {string|null} attr - Attribute name, or null for text content
|
|
334
|
+
* @returns {string|null} Extracted value or null
|
|
335
|
+
*/
|
|
336
|
+
static extractXmlAttr(xml, tag, attr) {
|
|
337
|
+
if (attr) {
|
|
338
|
+
const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'i');
|
|
339
|
+
const m = xml.match(re);
|
|
340
|
+
return m ? m[1] : null;
|
|
341
|
+
}
|
|
342
|
+
const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'i');
|
|
343
|
+
const m = xml.match(re);
|
|
344
|
+
return m ? m[1].trim() : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Extract all occurrences of a tag's content or attribute from XML.
|
|
349
|
+
* @param {string} xml - XML string
|
|
350
|
+
* @param {string} tag - Tag name
|
|
351
|
+
* @param {string|null} attr - Attribute name, or null for text content
|
|
352
|
+
* @returns {string[]} Array of matched values
|
|
353
|
+
*/
|
|
354
|
+
static extractXmlAll(xml, tag, attr) {
|
|
355
|
+
const results = [];
|
|
356
|
+
if (attr) {
|
|
357
|
+
const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'gi');
|
|
358
|
+
let m;
|
|
359
|
+
while ((m = re.exec(xml)) !== null) results.push(m[1]);
|
|
360
|
+
} else {
|
|
361
|
+
const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'gi');
|
|
362
|
+
let m;
|
|
363
|
+
while ((m = re.exec(xml)) !== null) results.push(m[1].trim());
|
|
364
|
+
}
|
|
365
|
+
return results;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = { AdtHttp };
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
body: err.body || null
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _send(socket, obj) {
|
|
180
|
+
try {
|
|
181
|
+
socket.write(JSON.stringify(obj) + '\n');
|
|
182
|
+
} catch (e) {
|
|
183
|
+
// Client disconnected — ignore
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Entry point when run as standalone daemon process ────────────────────────
|
|
188
|
+
|
|
189
|
+
if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
|
|
190
|
+
const config = JSON.parse(process.env.DEBUG_DAEMON_CONFIG || '{}');
|
|
191
|
+
const sessionId = process.env.DEBUG_DAEMON_SESSION_ID || '';
|
|
192
|
+
const socketPath = process.env.DEBUG_DAEMON_SOCK_PATH || '';
|
|
193
|
+
const snapshot = process.env.DEBUG_DAEMON_SESSION_SNAPSHOT
|
|
194
|
+
? JSON.parse(process.env.DEBUG_DAEMON_SESSION_SNAPSHOT)
|
|
195
|
+
: null;
|
|
196
|
+
|
|
197
|
+
if (!config.host || !sessionId || !socketPath) {
|
|
198
|
+
process.stderr.write('[debug-daemon] missing required env vars\n');
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
startDaemon(config, sessionId, socketPath, snapshot).catch((err) => {
|
|
203
|
+
process.stderr.write(`[debug-daemon] startup error: ${err.message}\n`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = { startDaemon, _handleLine };
|