abapgit-agent 1.10.0 → 1.11.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/abap/CLAUDE.md +71 -35
- package/bin/abapgit-agent +14 -4
- package/package.json +3 -1
- package/src/commands/debug.js +3 -3
- package/src/commands/pull.js +95 -13
- package/src/commands/status.js +2 -0
- package/src/commands/transport.js +290 -0
- package/src/config.js +60 -1
- package/src/utils/adt-http.js +30 -5
- package/src/utils/debug-daemon.js +24 -1
- package/src/utils/debug-repl.js +23 -0
- package/src/utils/debug-session.js +162 -40
- package/src/utils/transport-selector.js +289 -0
|
@@ -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 = 12, delayMs = 2000) {
|
|
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)
|
|
@@ -33,6 +64,56 @@ class DebugSession {
|
|
|
33
64
|
constructor(adtHttp, sessionId) {
|
|
34
65
|
this.http = adtHttp;
|
|
35
66
|
this.sessionId = sessionId;
|
|
67
|
+
this.pinnedSessionId = null;
|
|
68
|
+
this._keepaliveTimer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Start a periodic keepalive that pings ADT every 30 seconds.
|
|
73
|
+
* SAP's ICM drops stateful session affinity after ~60 s of idle, causing
|
|
74
|
+
* subsequent debug requests to route to the wrong work process (HTTP 400).
|
|
75
|
+
* Calling getStack() regularly keeps the connection warm.
|
|
76
|
+
* Call stopKeepalive() before detach/terminate to avoid racing the close.
|
|
77
|
+
*/
|
|
78
|
+
startKeepalive() {
|
|
79
|
+
if (this._keepaliveTimer) return;
|
|
80
|
+
this._keepaliveTimer = setInterval(async () => {
|
|
81
|
+
try { await this.getStack(); } catch (e) { /* best-effort */ }
|
|
82
|
+
}, 30000);
|
|
83
|
+
if (this._keepaliveTimer.unref) this._keepaliveTimer.unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stopKeepalive() {
|
|
87
|
+
if (this._keepaliveTimer) {
|
|
88
|
+
clearInterval(this._keepaliveTimer);
|
|
89
|
+
this._keepaliveTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Restore the pinned SAP_SESSIONID into the HTTP client's cookie jar.
|
|
95
|
+
*
|
|
96
|
+
* SAP ADT debug sessions are bound to a specific frozen ABAP work process
|
|
97
|
+
* via the SAP_SESSIONID cookie. AdtHttp updates this cookie automatically
|
|
98
|
+
* from every response's Set-Cookie header and replaces it on CSRF refresh
|
|
99
|
+
* (401/403 retry). When the cookie rotates mid-session the next request
|
|
100
|
+
* routes to a different ABAP session that has no debug state, causing
|
|
101
|
+
* HTTP 400 "Service cannot be reached".
|
|
102
|
+
*
|
|
103
|
+
* This method reverts any rotation that occurred since attach() by replacing
|
|
104
|
+
* the current SAP_SESSIONID value with the one captured at attach time.
|
|
105
|
+
* It is a no-op when called before attach() (pinnedSessionId is null).
|
|
106
|
+
*/
|
|
107
|
+
_restorePinnedSession() {
|
|
108
|
+
if (!this.pinnedSessionId || !this.http.cookies) return;
|
|
109
|
+
|
|
110
|
+
// Replace whatever SAP_SESSIONID= value is currently in the cookie jar
|
|
111
|
+
// with the pinned one. The cookie jar is a semicolon-separated string,
|
|
112
|
+
// e.g. "SAP_SESSIONID=ABC123; sap-usercontext=xyz".
|
|
113
|
+
this.http.cookies = this.http.cookies.replace(
|
|
114
|
+
/SAP_SESSIONID=[^;]*/,
|
|
115
|
+
`SAP_SESSIONID=${this.pinnedSessionId}`
|
|
116
|
+
);
|
|
36
117
|
}
|
|
37
118
|
|
|
38
119
|
/**
|
|
@@ -64,6 +145,14 @@ class DebugSession {
|
|
|
64
145
|
this.sessionId = debugSessionId;
|
|
65
146
|
}
|
|
66
147
|
|
|
148
|
+
// Pin the SAP_SESSIONID cookie that was active when we attached.
|
|
149
|
+
// All subsequent stateful operations must present this exact cookie so
|
|
150
|
+
// that SAP routes them to the same frozen ABAP work process.
|
|
151
|
+
if (this.http.cookies) {
|
|
152
|
+
const match = this.http.cookies.match(/SAP_SESSIONID=([^;]*)/);
|
|
153
|
+
if (match) this.pinnedSessionId = match[1];
|
|
154
|
+
}
|
|
155
|
+
|
|
67
156
|
return this.sessionId;
|
|
68
157
|
}
|
|
69
158
|
|
|
@@ -94,30 +183,35 @@ class DebugSession {
|
|
|
94
183
|
// completion. When the program runs to completion ADT returns HTTP 500
|
|
95
184
|
// (no suspended session left). Treat both 200 and 500 as "continued".
|
|
96
185
|
if (method === 'stepContinue') {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
186
|
+
return retryOnIcmError(async () => {
|
|
187
|
+
this._restorePinnedSession();
|
|
188
|
+
try {
|
|
189
|
+
await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
|
|
190
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
191
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
192
|
+
});
|
|
193
|
+
// 200: program hit another breakpoint (or is still running).
|
|
194
|
+
// Position query is not meaningful until a new breakpoint fires via
|
|
195
|
+
// the listener, so return the sentinel and let the caller re-attach.
|
|
196
|
+
return { position: { continued: true }, source: [] };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// 500: debuggee ran to completion, session ended normally.
|
|
199
|
+
if (err && err.statusCode === 500) {
|
|
200
|
+
return { position: { continued: true, finished: true }, source: [] };
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
110
203
|
}
|
|
111
|
-
|
|
112
|
-
}
|
|
204
|
+
});
|
|
113
205
|
}
|
|
114
206
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
207
|
+
return retryOnIcmError(async () => {
|
|
208
|
+
this._restorePinnedSession();
|
|
209
|
+
await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
|
|
210
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
211
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
212
|
+
});
|
|
213
|
+
return this.getPosition();
|
|
118
214
|
});
|
|
119
|
-
|
|
120
|
-
return this.getPosition();
|
|
121
215
|
}
|
|
122
216
|
|
|
123
217
|
/**
|
|
@@ -139,6 +233,8 @@ class DebugSession {
|
|
|
139
233
|
* @returns {Promise<Array<{ name: string, type: string, value: string }>>}
|
|
140
234
|
*/
|
|
141
235
|
async getVariables(name = null) {
|
|
236
|
+
this._restorePinnedSession();
|
|
237
|
+
|
|
142
238
|
const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
|
|
143
239
|
const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
|
|
144
240
|
|
|
@@ -220,6 +316,8 @@ class DebugSession {
|
|
|
220
316
|
* @returns {Promise<Array<{ id: string, name: string, type: string, value: string }>>}
|
|
221
317
|
*/
|
|
222
318
|
async getVariableChildren(parentId, meta = {}) {
|
|
319
|
+
this._restorePinnedSession();
|
|
320
|
+
|
|
223
321
|
const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
|
|
224
322
|
const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
|
|
225
323
|
|
|
@@ -337,13 +435,31 @@ class DebugSession {
|
|
|
337
435
|
* @returns {Promise<Array<{ frame: number, class: string, method: string, line: number }>>}
|
|
338
436
|
*/
|
|
339
437
|
async getStack() {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
438
|
+
return retryOnIcmError(async () => {
|
|
439
|
+
this._restorePinnedSession();
|
|
440
|
+
// Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
|
|
441
|
+
try {
|
|
442
|
+
const { body } = await this.http.get(
|
|
443
|
+
'/sap/bc/adt/debugger/stack?emode=_&semanticURIs=true', {
|
|
444
|
+
accept: 'application/xml',
|
|
445
|
+
headers: STATEFUL_HEADER
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
const frames = parseStack(body);
|
|
449
|
+
if (frames.length > 0) return frames;
|
|
450
|
+
} catch (e) {
|
|
451
|
+
// Fall through to POST approach for any GET failure (including 400 on systems
|
|
452
|
+
// that don't support the dedicated /debugger/stack endpoint)
|
|
344
453
|
}
|
|
345
|
-
|
|
346
|
-
|
|
454
|
+
// Fallback: POST approach (older ADT versions)
|
|
455
|
+
const { body } = await this.http.post(
|
|
456
|
+
'/sap/bc/adt/debugger?method=getStack&emode=_&semanticURIs=true', '', {
|
|
457
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
458
|
+
headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
return parseStack(body);
|
|
462
|
+
});
|
|
347
463
|
}
|
|
348
464
|
|
|
349
465
|
/**
|
|
@@ -531,32 +647,38 @@ class DebugSession {
|
|
|
531
647
|
|
|
532
648
|
/**
|
|
533
649
|
* Terminate the debug session.
|
|
650
|
+
* Retries on transient ICM 400 errors so the ABAP work process is reliably
|
|
651
|
+
* released even when the system is under load (e.g. during test:all).
|
|
534
652
|
*/
|
|
535
653
|
async terminate() {
|
|
536
|
-
await
|
|
537
|
-
|
|
538
|
-
|
|
654
|
+
await retryOnIcmError(async () => {
|
|
655
|
+
this._restorePinnedSession();
|
|
656
|
+
await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
|
|
657
|
+
contentType: 'application/vnd.sap.as+xml',
|
|
658
|
+
headers: STATEFUL_HEADER
|
|
659
|
+
});
|
|
539
660
|
});
|
|
540
661
|
}
|
|
541
662
|
|
|
542
663
|
/**
|
|
543
664
|
* Detach from the debuggee without killing it.
|
|
544
|
-
* Issues a stepContinue so the ABAP program resumes running.
|
|
665
|
+
* Issues a single stepContinue so the ABAP program resumes running.
|
|
666
|
+
*
|
|
667
|
+
* ADT returns HTTP 200 when the WP resumes (regardless of whether it
|
|
668
|
+
* later hits another breakpoint — there is no way to distinguish "still
|
|
669
|
+
* running" from "re-hit breakpoint" in the stepContinue response alone).
|
|
670
|
+
* Sending a second stepContinue to an already-running WP races with the
|
|
671
|
+
* program's own execution and can stall the WP mid-run (e.g. while a
|
|
672
|
+
* Code Inspector job is in flight), so we issue exactly one request.
|
|
545
673
|
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* We use postFire() which resolves as soon as the request bytes are
|
|
549
|
-
* flushed to the TCP send buffer — no need to wait for a response.
|
|
550
|
-
* The existing session cookies are used so ADT recognises the request.
|
|
674
|
+
* Callers (e.g. the REPL 'q' handler) must delete all breakpoints before
|
|
675
|
+
* calling detach() to prevent an immediate re-hit on the same line.
|
|
551
676
|
*/
|
|
552
677
|
async detach() {
|
|
553
678
|
try {
|
|
554
|
-
await this.
|
|
555
|
-
contentType: 'application/vnd.sap.as+xml',
|
|
556
|
-
headers: STATEFUL_HEADER
|
|
557
|
-
});
|
|
679
|
+
await this.step('stepContinue');
|
|
558
680
|
} catch (e) {
|
|
559
|
-
// Ignore —
|
|
681
|
+
// Ignore — session may have already closed.
|
|
560
682
|
}
|
|
561
683
|
}
|
|
562
684
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport selector utility
|
|
3
|
+
* Resolves a transport request number for the pull command when none is configured.
|
|
4
|
+
*
|
|
5
|
+
* Strategy:
|
|
6
|
+
* - Non-interactive (AI mode, CI): run a project-configured Node.js hook
|
|
7
|
+
* - Interactive (TTY): show a numbered picker (list, scope-switch, create, skip)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true when running non-interactively (no TTY, CI, AI coding tool).
|
|
14
|
+
* Can be forced with NO_TTY=1 env var.
|
|
15
|
+
*/
|
|
16
|
+
function isNonInteractive() {
|
|
17
|
+
if (process.env.NO_TTY === '1') return true;
|
|
18
|
+
return !process.stdout.isTTY || !process.stdin.isTTY;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load and execute a hook module.
|
|
23
|
+
* The hook must export an async function({ config, http }) → string|null.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} hookPath - Absolute path to the hook module
|
|
26
|
+
* @param {object} context - { config, http }
|
|
27
|
+
* @returns {Promise<string|null>}
|
|
28
|
+
*/
|
|
29
|
+
async function runHook(hookPath, context) {
|
|
30
|
+
const hookFn = require(hookPath);
|
|
31
|
+
const result = await hookFn(context);
|
|
32
|
+
return typeof result === 'string' ? result : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fetch open transport requests from ABAP.
|
|
37
|
+
* Returns normalised lowercase-key objects.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} http
|
|
40
|
+
* @param {string} scope - 'mine' | 'task' | 'all'
|
|
41
|
+
* @returns {Promise<Array>}
|
|
42
|
+
*/
|
|
43
|
+
async function fetchTransports(http, scope = 'mine') {
|
|
44
|
+
try {
|
|
45
|
+
const result = await http.get(`/sap/bc/z_abapgit_agent/transport?scope=${scope}`);
|
|
46
|
+
const raw = result.TRANSPORTS || result.transports || [];
|
|
47
|
+
return raw.map(t => ({
|
|
48
|
+
number: t.NUMBER || t.number || '',
|
|
49
|
+
description: t.DESCRIPTION || t.description || '',
|
|
50
|
+
owner: t.OWNER || t.owner || '',
|
|
51
|
+
date: t.DATE || t.date || ''
|
|
52
|
+
}));
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a new transport request.
|
|
60
|
+
* @param {object} http
|
|
61
|
+
* @param {string} description
|
|
62
|
+
* @returns {Promise<string|null>} transport number or null
|
|
63
|
+
*/
|
|
64
|
+
async function createTransport(http, description) {
|
|
65
|
+
try {
|
|
66
|
+
const result = await http.post(
|
|
67
|
+
'/sap/bc/z_abapgit_agent/transport',
|
|
68
|
+
{ action: 'CREATE', description: description || '' },
|
|
69
|
+
{}
|
|
70
|
+
);
|
|
71
|
+
return result.NUMBER || result.number || null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Interactive picker — shown in TTY mode.
|
|
79
|
+
* Presents a numbered menu; supports scope switching, create, and skip.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} http
|
|
82
|
+
* @returns {Promise<string|null>}
|
|
83
|
+
*/
|
|
84
|
+
async function interactivePicker(http) {
|
|
85
|
+
const readline = require('readline');
|
|
86
|
+
|
|
87
|
+
let scope = 'mine';
|
|
88
|
+
let transports = [];
|
|
89
|
+
let fetchError = false;
|
|
90
|
+
|
|
91
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
92
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
93
|
+
|
|
94
|
+
const fetchAndDisplay = async () => {
|
|
95
|
+
transports = await fetchTransports(http, scope);
|
|
96
|
+
fetchError = transports.length === 0;
|
|
97
|
+
|
|
98
|
+
const scopeLabel = { mine: 'my transports', task: 'transports where I have a task', all: 'all open transports' }[scope];
|
|
99
|
+
process.stderr.write(`\nSelect a transport request (showing: ${scopeLabel}):\n\n`);
|
|
100
|
+
|
|
101
|
+
if (transports.length > 0) {
|
|
102
|
+
transports.forEach((t, i) => {
|
|
103
|
+
process.stderr.write(` ${i + 1}. ${t.number} ${t.description} (${t.owner}, ${t.date})\n`);
|
|
104
|
+
});
|
|
105
|
+
process.stderr.write(' ' + '─'.repeat(61) + '\n');
|
|
106
|
+
} else {
|
|
107
|
+
if (fetchError) {
|
|
108
|
+
process.stderr.write(' ⚠️ Could not fetch transports from ABAP system.\n');
|
|
109
|
+
} else {
|
|
110
|
+
process.stderr.write(' No open transport requests found.\n');
|
|
111
|
+
}
|
|
112
|
+
process.stderr.write(' ' + '─'.repeat(61) + '\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (scope !== 'task') process.stderr.write(' s. Show transports where I have a task\n');
|
|
116
|
+
if (scope !== 'all') process.stderr.write(' a. Show all open transports\n');
|
|
117
|
+
process.stderr.write(' c. Create new transport request\n');
|
|
118
|
+
process.stderr.write(' 0. Skip (no transport request)\n\n');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await fetchAndDisplay();
|
|
122
|
+
|
|
123
|
+
// eslint-disable-next-line no-constant-condition
|
|
124
|
+
while (true) {
|
|
125
|
+
const answer = (await ask('Enter number or option: ')).trim().toLowerCase();
|
|
126
|
+
|
|
127
|
+
if (answer === '0') {
|
|
128
|
+
rl.close();
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (answer === 's') {
|
|
133
|
+
scope = 'task';
|
|
134
|
+
await fetchAndDisplay();
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (answer === 'a') {
|
|
139
|
+
scope = 'all';
|
|
140
|
+
await fetchAndDisplay();
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (answer === 'c') {
|
|
145
|
+
const desc = (await ask('Description: ')).trim();
|
|
146
|
+
rl.close();
|
|
147
|
+
const number = await createTransport(http, desc);
|
|
148
|
+
if (number) {
|
|
149
|
+
process.stderr.write(`\n✅ Created transport ${number}\n`);
|
|
150
|
+
return number;
|
|
151
|
+
} else {
|
|
152
|
+
process.stderr.write('\n❌ Could not create transport request.\n');
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const idx = parseInt(answer, 10);
|
|
158
|
+
if (!isNaN(idx) && idx >= 1 && idx <= transports.length) {
|
|
159
|
+
rl.close();
|
|
160
|
+
return transports[idx - 1].number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
process.stderr.write(`Invalid selection '${answer}'. Enter a number, s, a, c, or 0.\n`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build the `run` helper that is passed to hooks in AI mode.
|
|
169
|
+
*
|
|
170
|
+
* run(command) — accepts a full CLI command string, e.g.:
|
|
171
|
+
* run('transport list --scope task')
|
|
172
|
+
* run('transport create --description "My transport"')
|
|
173
|
+
*
|
|
174
|
+
* Splits on whitespace, forces --json, captures output, returns parsed JSON.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} config - Loaded ABAP config
|
|
177
|
+
* @param {object} http - Pre-built AbapHttp instance
|
|
178
|
+
* @param {Function} loadConfig - Config factory (from pull context)
|
|
179
|
+
* @param {Function} AbapHttp - AbapHttp constructor (from pull context)
|
|
180
|
+
* @param {Function} getTransportSettings - Transport settings getter (from pull context)
|
|
181
|
+
* @returns {Function|undefined} - The run helper, or undefined if factories are missing
|
|
182
|
+
*/
|
|
183
|
+
function buildRun(config, http, loadConfig, AbapHttp, getTransportSettings) {
|
|
184
|
+
if (!loadConfig || !AbapHttp) return undefined;
|
|
185
|
+
|
|
186
|
+
return async function run(command) {
|
|
187
|
+
const [commandName, ...args] = command.trim().split(/\s+/);
|
|
188
|
+
const cmdModule = require(`../commands/${commandName}`);
|
|
189
|
+
|
|
190
|
+
// Always force --json so output is parseable
|
|
191
|
+
const runArgs = args.includes('--json') ? args : [...args, '--json'];
|
|
192
|
+
|
|
193
|
+
// Reuse the already-authenticated http instance
|
|
194
|
+
const MockAbapHttp = function MockAbapHttp() { return http; };
|
|
195
|
+
|
|
196
|
+
const runContext = {
|
|
197
|
+
loadConfig: () => config,
|
|
198
|
+
AbapHttp: MockAbapHttp,
|
|
199
|
+
getTransportSettings: getTransportSettings || (() => ({ allowCreate: true, allowRelease: true, reason: null }))
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Capture console.log output; override process.exit to throw instead of exit
|
|
203
|
+
const captured = [];
|
|
204
|
+
const origLog = console.log;
|
|
205
|
+
const origExit = process.exit;
|
|
206
|
+
console.log = (...a) => captured.push(a.map(String).join(' '));
|
|
207
|
+
process.exit = (code) => { throw new Error(`process.exit(${code})`); };
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await cmdModule.execute(runArgs, runContext);
|
|
211
|
+
} finally {
|
|
212
|
+
console.log = origLog;
|
|
213
|
+
process.exit = origExit;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const output = captured.join('');
|
|
217
|
+
if (!output) throw new Error(`run("${command}") produced no output`);
|
|
218
|
+
return JSON.parse(output);
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Main export — selects a transport request for use in the pull command.
|
|
224
|
+
* Returns the transport number, or null to proceed without one.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} config - Loaded ABAP config
|
|
227
|
+
* @param {object} http - Pre-built AbapHttp instance
|
|
228
|
+
* @param {Function} [loadConfig] - Config factory (enables run helper in hook context)
|
|
229
|
+
* @param {Function} [AbapHttp] - AbapHttp constructor (enables run helper in hook context)
|
|
230
|
+
* @param {Function} [getTransportSettings] - Transport settings getter
|
|
231
|
+
* @returns {Promise<string|null>}
|
|
232
|
+
*/
|
|
233
|
+
async function selectTransport(config, http, loadConfig, AbapHttp, getTransportSettings) {
|
|
234
|
+
// Hook takes precedence over the interactive picker — runs in both TTY and non-TTY mode
|
|
235
|
+
const hookConfig = module.exports._getTransportHookConfig();
|
|
236
|
+
if (hookConfig && hookConfig.hook) {
|
|
237
|
+
const hookPath = path.resolve(process.cwd(), hookConfig.hook);
|
|
238
|
+
const run = buildRun(config, http, loadConfig, AbapHttp, getTransportSettings);
|
|
239
|
+
try {
|
|
240
|
+
return await module.exports.runHook(hookPath, { config, http, run });
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No hook configured — fall back based on context
|
|
247
|
+
if (isNonInteractive()) {
|
|
248
|
+
return null; // AI/CI mode: proceed without transport
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Manual mode: interactive picker
|
|
252
|
+
return interactivePicker(http);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Read transport hook config from .abapgit-agent.json
|
|
257
|
+
* (mirrors getConflictSettings pattern in config.js)
|
|
258
|
+
*/
|
|
259
|
+
function _getTransportHookConfig() {
|
|
260
|
+
const fs = require('fs');
|
|
261
|
+
const projectConfigPath = path.join(process.cwd(), '.abapgit-agent.json');
|
|
262
|
+
|
|
263
|
+
if (!fs.existsSync(projectConfigPath)) return null;
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
|
|
267
|
+
if (projectConfig && projectConfig.transports?.hook) {
|
|
268
|
+
return {
|
|
269
|
+
hook: projectConfig.transports.hook.path || null,
|
|
270
|
+
description: projectConfig.transports.hook.description || null
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
// ignore parse errors
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
isNonInteractive,
|
|
282
|
+
runHook,
|
|
283
|
+
fetchTransports,
|
|
284
|
+
createTransport,
|
|
285
|
+
interactivePicker,
|
|
286
|
+
selectTransport,
|
|
287
|
+
buildRun,
|
|
288
|
+
_getTransportHookConfig
|
|
289
|
+
};
|