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 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.0",
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"
@@ -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;
@@ -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) => {
@@ -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 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
+ 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
- try {
98
- await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
99
- contentType: 'application/vnd.sap.as+xml',
100
- headers: STATEFUL_HEADER
101
- });
102
- // 200: program hit another breakpoint (or is still running).
103
- // Position query is not meaningful until a new breakpoint fires via
104
- // the listener, so return the sentinel and let the caller re-attach.
105
- return { position: { continued: true }, source: [] };
106
- } catch (err) {
107
- // 500: debuggee ran to completion, session ended normally.
108
- if (err && err.statusCode === 500) {
109
- return { position: { continued: true, finished: true }, source: [] };
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
- throw err;
112
- }
145
+ });
113
146
  }
114
147
 
115
- await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
116
- contentType: 'application/vnd.sap.as+xml',
117
- headers: STATEFUL_HEADER
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
- const { body } = await this.http.post(
341
- '/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
342
- contentType: 'application/vnd.sap.as+xml',
343
- headers: STATEFUL_HEADER
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
- return parseStack(body);
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
  /**