abapgit-agent 1.10.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/bin/abapgit-agent +8 -1
- package/package.json +2 -1
- package/src/commands/debug.js +2 -2
- package/src/commands/pull.js +17 -3
- package/src/utils/adt-http.js +30 -5
- package/src/utils/debug-daemon.js +2 -1
- package/src/utils/debug-session.js +78 -26
package/bin/abapgit-agent
CHANGED
|
@@ -121,4 +121,11 @@ To enable integration:
|
|
|
121
121
|
process.exit(1);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
main()
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
// Known CLI errors (e.g. pull conflict) already printed their own output — skip re-printing.
|
|
126
|
+
// For all other unexpected errors, print the message without the stack trace.
|
|
127
|
+
if (!err._isPullError) {
|
|
128
|
+
console.error(err.message || err);
|
|
129
|
+
}
|
|
130
|
+
process.exit(1);
|
|
131
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "abapgit-agent",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "ABAP Git Agent - Pull and activate ABAP code via abapGit from any git repository",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"test:cmd:upgrade": "node tests/run-all.js --cmd --command=upgrade",
|
|
43
43
|
"test:lifecycle": "node tests/run-all.js --lifecycle",
|
|
44
44
|
"test:pull": "node tests/run-all.js --pull",
|
|
45
|
+
"test:conflict": "node tests/run-all.js --conflict",
|
|
45
46
|
"pull": "node bin/abapgit-agent",
|
|
46
47
|
"release": "node scripts/release.js",
|
|
47
48
|
"unrelease": "node scripts/unrelease.js"
|
package/src/commands/debug.js
CHANGED
|
@@ -806,7 +806,7 @@ async function cmdStep(args, config, adt) {
|
|
|
806
806
|
process.exit(1);
|
|
807
807
|
}
|
|
808
808
|
if (!resp.ok) {
|
|
809
|
-
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}\n`);
|
|
809
|
+
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}${resp.body ? '\n Body: ' + resp.body.substring(0, 400) : ''}\n`);
|
|
810
810
|
process.exit(1);
|
|
811
811
|
}
|
|
812
812
|
// continued+finished (or empty position) means the program ran to completion
|
|
@@ -1074,7 +1074,7 @@ async function cmdStack(args, config, adt) {
|
|
|
1074
1074
|
process.exit(1);
|
|
1075
1075
|
}
|
|
1076
1076
|
if (!resp.ok) {
|
|
1077
|
-
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}\n`);
|
|
1077
|
+
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}${resp.body ? '\n Body: ' + resp.body.substring(0, 400) : ''}\n`);
|
|
1078
1078
|
process.exit(1);
|
|
1079
1079
|
}
|
|
1080
1080
|
const frames = resp.frames;
|
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) => {
|
package/src/utils/adt-http.js
CHANGED
|
@@ -199,12 +199,37 @@ class AdtHttp {
|
|
|
199
199
|
return;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// Update cookies from any response that sets them
|
|
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").
|
|
203
207
|
if (res.headers['set-cookie']) {
|
|
204
|
-
const
|
|
205
|
-
? res.headers['set-cookie'].map(c => c.split(';')[0])
|
|
206
|
-
: res.headers['set-cookie'].split(';')[0];
|
|
207
|
-
|
|
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('; ');
|
|
208
233
|
}
|
|
209
234
|
|
|
210
235
|
let respBody = '';
|
|
@@ -170,7 +170,8 @@ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
|
|
|
170
170
|
_send(socket, {
|
|
171
171
|
ok: false,
|
|
172
172
|
error: err.message || JSON.stringify(err),
|
|
173
|
-
statusCode: err.statusCode
|
|
173
|
+
statusCode: err.statusCode,
|
|
174
|
+
body: err.body || null
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
}
|
|
@@ -25,6 +25,37 @@ const { AdtHttp } = require('./adt-http');
|
|
|
25
25
|
// Header required to pin all requests to the same ABAP work process.
|
|
26
26
|
const STATEFUL_HEADER = { 'X-sap-adt-sessiontype': 'stateful' };
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Retry a debug ADT call up to maxRetries times on transient ICM errors.
|
|
30
|
+
*
|
|
31
|
+
* The SAP ICM (load balancer) returns HTTP 400 with an HTML "Service cannot
|
|
32
|
+
* be reached" body when the target ABAP work process is momentarily unavailable
|
|
33
|
+
* (e.g. finishing a previous step, or briefly between requests). This is a
|
|
34
|
+
* transient condition that resolves within a second or two — retrying is safe
|
|
35
|
+
* for all debug read/navigation operations.
|
|
36
|
+
*
|
|
37
|
+
* @param {function} fn - Async function to retry (takes no args)
|
|
38
|
+
* @param {number} maxRetries - Max additional attempts after the first (default 3)
|
|
39
|
+
* @param {number} delayMs - Wait between retries in ms (default 1000)
|
|
40
|
+
*/
|
|
41
|
+
async function retryOnIcmError(fn, maxRetries = 3, delayMs = 1000) {
|
|
42
|
+
let lastErr;
|
|
43
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
44
|
+
try {
|
|
45
|
+
return await fn();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const isIcmError = err && err.statusCode === 400 &&
|
|
48
|
+
err.body && err.body.includes('Service cannot be reached');
|
|
49
|
+
if (!isIcmError) throw err;
|
|
50
|
+
lastErr = err;
|
|
51
|
+
if (attempt < maxRetries) {
|
|
52
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw lastErr;
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
class DebugSession {
|
|
29
60
|
/**
|
|
30
61
|
* @param {AdtHttp} adtHttp - ADT HTTP client instance (carries session cookie)
|
|
@@ -94,30 +125,33 @@ class DebugSession {
|
|
|
94
125
|
// completion. When the program runs to completion ADT returns HTTP 500
|
|
95
126
|
// (no suspended session left). Treat both 200 and 500 as "continued".
|
|
96
127
|
if (method === 'stepContinue') {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
128
|
+
return retryOnIcmError(async () => {
|
|
129
|
+
try {
|
|
130
|
+
await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
|
|
131
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
132
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
133
|
+
});
|
|
134
|
+
// 200: program hit another breakpoint (or is still running).
|
|
135
|
+
// Position query is not meaningful until a new breakpoint fires via
|
|
136
|
+
// the listener, so return the sentinel and let the caller re-attach.
|
|
137
|
+
return { position: { continued: true }, source: [] };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// 500: debuggee ran to completion, session ended normally.
|
|
140
|
+
if (err && err.statusCode === 500) {
|
|
141
|
+
return { position: { continued: true, finished: true }, source: [] };
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
110
144
|
}
|
|
111
|
-
|
|
112
|
-
}
|
|
145
|
+
});
|
|
113
146
|
}
|
|
114
147
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
148
|
+
return retryOnIcmError(async () => {
|
|
149
|
+
await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
|
|
150
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
151
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
152
|
+
});
|
|
153
|
+
return this.getPosition();
|
|
118
154
|
});
|
|
119
|
-
|
|
120
|
-
return this.getPosition();
|
|
121
155
|
}
|
|
122
156
|
|
|
123
157
|
/**
|
|
@@ -337,13 +371,31 @@ class DebugSession {
|
|
|
337
371
|
* @returns {Promise<Array<{ frame: number, class: string, method: string, line: number }>>}
|
|
338
372
|
*/
|
|
339
373
|
async getStack() {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
374
|
+
return retryOnIcmError(async () => {
|
|
375
|
+
// Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
|
|
376
|
+
try {
|
|
377
|
+
const { body } = await this.http.get(
|
|
378
|
+
'/sap/bc/adt/debugger/stack?emode=_&semanticURIs=true', {
|
|
379
|
+
accept: 'application/xml',
|
|
380
|
+
headers: STATEFUL_HEADER
|
|
381
|
+
}
|
|
382
|
+
);
|
|
383
|
+
const frames = parseStack(body);
|
|
384
|
+
if (frames.length > 0) return frames;
|
|
385
|
+
} catch (e) {
|
|
386
|
+
// Re-throw ICM errors so the outer retryOnIcmError can catch them
|
|
387
|
+
if (e && e.statusCode === 400 && e.body && e.body.includes('Service cannot be reached')) throw e;
|
|
388
|
+
// Otherwise fall through to POST approach
|
|
344
389
|
}
|
|
345
|
-
|
|
346
|
-
|
|
390
|
+
// Fallback: POST approach (older ADT versions)
|
|
391
|
+
const { body } = await this.http.post(
|
|
392
|
+
'/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
|
|
393
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
394
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
return parseStack(body);
|
|
398
|
+
});
|
|
347
399
|
}
|
|
348
400
|
|
|
349
401
|
/**
|