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
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Debug command — Interactive ABAP debugger via ADT REST API
|
|
5
|
+
*
|
|
6
|
+
* Sub-commands:
|
|
7
|
+
* set — Set a breakpoint
|
|
8
|
+
* list — List breakpoints
|
|
9
|
+
* delete — Delete breakpoint(s)
|
|
10
|
+
* attach — Attach to a debug session (blocks until breakpoint hit)
|
|
11
|
+
* step — Step (over/into/out/continue) — AI mode
|
|
12
|
+
* vars — Show variables — AI mode
|
|
13
|
+
* stack — Show call stack — AI mode
|
|
14
|
+
* terminate — Terminate active session — AI mode
|
|
15
|
+
*
|
|
16
|
+
* Breakpoint management uses the POST synchronize model (verified against abap-adt-api):
|
|
17
|
+
* - POST /sap/bc/adt/debugger/breakpoints with the full desired list each time.
|
|
18
|
+
* - XML uses dbg: namespace, scope/terminalId/ideId attrs, syncScope element, #start=<line>.
|
|
19
|
+
* - Local state persisted in tmp so stateless CLI calls share the breakpoint list.
|
|
20
|
+
*
|
|
21
|
+
* Session management for AI/scripting mode (--json):
|
|
22
|
+
* attach --json spawns a background daemon (debug-daemon.js) that holds the
|
|
23
|
+
* stateful ADT HTTP connection open. step/vars/stack/terminate are thin IPC
|
|
24
|
+
* clients that connect to the daemon's Unix socket and exchange one JSON command
|
|
25
|
+
* per invocation. The daemon exits when terminate is called or after 30 min idle.
|
|
26
|
+
*
|
|
27
|
+
* Human REPL mode (attach without --json) does NOT use the daemon — the process
|
|
28
|
+
* stays alive for the full session, so the HTTP connection is maintained naturally.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const net = require('net');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { spawn } = require('child_process');
|
|
34
|
+
const { AdtHttp } = require('../utils/adt-http');
|
|
35
|
+
const { DebugSession } = require('../utils/debug-session');
|
|
36
|
+
const debugStateModule = require('../utils/debug-state');
|
|
37
|
+
const {
|
|
38
|
+
saveActiveSession,
|
|
39
|
+
loadActiveSession,
|
|
40
|
+
clearActiveSession
|
|
41
|
+
} = debugStateModule;
|
|
42
|
+
|
|
43
|
+
// Breakpoint state helpers — may be absent in unit-test mocks
|
|
44
|
+
const _saveBpState = debugStateModule.saveBreakpointState;
|
|
45
|
+
const _loadBpState = debugStateModule.loadBreakpointState;
|
|
46
|
+
// Daemon socket path — may be absent in unit-test mocks
|
|
47
|
+
const _getDaemonSocketPath = debugStateModule.getDaemonSocketPath;
|
|
48
|
+
|
|
49
|
+
const ADT_CLIENT_ID = 'ABAPGIT-AGENT-CLI';
|
|
50
|
+
|
|
51
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function val(args, flag) {
|
|
54
|
+
const i = args.indexOf(flag);
|
|
55
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasFlag(args, flag) {
|
|
59
|
+
return args.includes(flag);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determine ADT object URI from object name (class/interface vs program).
|
|
64
|
+
* Must use /source/main suffix for classes — verified by live testing: ADT
|
|
65
|
+
* rejects breakpoints set on the class root URI but accepts /source/main.
|
|
66
|
+
* classes → /sap/bc/adt/oo/classes/<name_lowercase>/source/main
|
|
67
|
+
* programs → /sap/bc/adt/programs/programs/<name_lowercase>
|
|
68
|
+
*/
|
|
69
|
+
function objectUri(name) {
|
|
70
|
+
const upper = (name || '').toUpperCase();
|
|
71
|
+
const lower = upper.toLowerCase();
|
|
72
|
+
if (/^[ZY](CL|IF)_/.test(upper) || /^(ZCL|ZIF|YCL|YIF)/.test(upper)) {
|
|
73
|
+
return `/sap/bc/adt/oo/classes/${lower}/source/main`;
|
|
74
|
+
}
|
|
75
|
+
return `/sap/bc/adt/programs/programs/${lower}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the breakpoints XML body for POST /sap/bc/adt/debugger/breakpoints.
|
|
80
|
+
*
|
|
81
|
+
* Format verified against abap-adt-api (marcellourbani/abap-adt-api):
|
|
82
|
+
* - Root element uses dbg: namespace (http://www.sap.com/adt/debugger)
|
|
83
|
+
* - Root attributes: scope, debuggingMode, requestUser, terminalId, ideId
|
|
84
|
+
* - Child <syncScope mode="full"> — required for the ADT handler to process
|
|
85
|
+
* - Each <breakpoint> uses adtcore:uri with #start=<line> fragment
|
|
86
|
+
* - kind="line" (not "breakpoint")
|
|
87
|
+
*
|
|
88
|
+
* @param {string} user - SAP logon user (requestUser)
|
|
89
|
+
* @param {Array} bps - Array of { uri, line }
|
|
90
|
+
* @param {string} [syncMode='full'] - 'full' replaces all; 'partial' merges
|
|
91
|
+
*/
|
|
92
|
+
function buildBreakpointsXml(user, bps, syncMode = 'full') {
|
|
93
|
+
const inner = bps.map(bp => {
|
|
94
|
+
const uriWithLine = `${bp.uri}#start=${bp.line}`;
|
|
95
|
+
return ` <breakpoint xmlns:adtcore="http://www.sap.com/adt/core"` +
|
|
96
|
+
` kind="line" clientId="${ADT_CLIENT_ID}" skipCount="0"` +
|
|
97
|
+
` adtcore:uri="${uriWithLine}"/>`;
|
|
98
|
+
}).join('\n');
|
|
99
|
+
|
|
100
|
+
const requestUser = (user || '').toUpperCase();
|
|
101
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
102
|
+
`<dbg:breakpoints xmlns:dbg="http://www.sap.com/adt/debugger"\n` +
|
|
103
|
+
` scope="external"\n` +
|
|
104
|
+
` debuggingMode="user"\n` +
|
|
105
|
+
` requestUser="${requestUser}"\n` +
|
|
106
|
+
` terminalId="${ADT_CLIENT_ID}"\n` +
|
|
107
|
+
` ideId="${ADT_CLIENT_ID}"\n` +
|
|
108
|
+
` systemDebugging="false"\n` +
|
|
109
|
+
` deactivated="false">\n` +
|
|
110
|
+
` <syncScope mode="${syncMode}"></syncScope>\n` +
|
|
111
|
+
(inner ? inner + '\n' : '') +
|
|
112
|
+
`</dbg:breakpoints>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse breakpoints from AtomPub feed XML.
|
|
117
|
+
* @param {string} xml
|
|
118
|
+
* @returns {Array<{ id: string, object: string, line: string }>}
|
|
119
|
+
*/
|
|
120
|
+
function parseBreakpoints(xml) {
|
|
121
|
+
const bps = [];
|
|
122
|
+
// Match <entry> blocks
|
|
123
|
+
const entryRe = /<entry>([\s\S]*?)<\/entry>/gi;
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = entryRe.exec(xml)) !== null) {
|
|
126
|
+
const inner = m[1];
|
|
127
|
+
const id = AdtHttp.extractXmlAttr(inner, 'id', null) || AdtHttp.extractXmlAttr(inner, 'adtdbg:id', null) || '';
|
|
128
|
+
// Try to get URI from adtcore:objectReference
|
|
129
|
+
const uriM = inner.match(/adtcore:uri="([^"]*)"/);
|
|
130
|
+
const uri = uriM ? uriM[1] : '';
|
|
131
|
+
const line = AdtHttp.extractXmlAttr(inner, 'adtdbg:line', null) || AdtHttp.extractXmlAttr(inner, 'line', null) || '';
|
|
132
|
+
// Extract object name from URI (last path segment), uppercase for display
|
|
133
|
+
const object = uri ? uri.split('/').pop().toUpperCase() : '';
|
|
134
|
+
if (id || uri) bps.push({ id, object, line, uri });
|
|
135
|
+
}
|
|
136
|
+
return bps;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse server-assigned breakpoint IDs from the POST /breakpoints response body.
|
|
141
|
+
* ADT returns:
|
|
142
|
+
* <dbg:breakpoints>
|
|
143
|
+
* <breakpoint id="KIND=0.SOURCETYPE=ABAP...." adtcore:uri="...#start=30" .../>
|
|
144
|
+
* <breakpoint errorMessage="Cannot create a breakpoint at this position" .../>
|
|
145
|
+
* </dbg:breakpoints>
|
|
146
|
+
*
|
|
147
|
+
* Returns an array parallel to the posted `bps` list with the server-assigned id
|
|
148
|
+
* (or null if ADT rejected that breakpoint).
|
|
149
|
+
*
|
|
150
|
+
* @param {string} xml - POST response body
|
|
151
|
+
* @param {Array} bps - The breakpoints that were posted (in order)
|
|
152
|
+
* @returns {Array<{ id: string|null, uri: string, line: number, error: string|null }>}
|
|
153
|
+
*/
|
|
154
|
+
function parseBreakpointResponse(xml, bps) {
|
|
155
|
+
const results = [];
|
|
156
|
+
const bpRe = /<breakpoint([^>]*)\/>/gi;
|
|
157
|
+
let m;
|
|
158
|
+
while ((m = bpRe.exec(xml)) !== null) {
|
|
159
|
+
const attrs = m[1];
|
|
160
|
+
const id = (attrs.match(/\bid="([^"]*)"/) || [])[1] || null;
|
|
161
|
+
const uriAttr = (attrs.match(/adtcore:uri="([^"]*)"/) || [])[1] || '';
|
|
162
|
+
const errMsg = (attrs.match(/errorMessage="([^"]*)"/) || [])[1] || null;
|
|
163
|
+
// Extract line from #start=N fragment
|
|
164
|
+
const lineM = uriAttr.match(/#start=(\d+)/);
|
|
165
|
+
const line = lineM ? parseInt(lineM[1], 10) : null;
|
|
166
|
+
const uri = uriAttr.split('#')[0];
|
|
167
|
+
results.push({ id: errMsg ? null : id, uri, line, error: errMsg || null });
|
|
168
|
+
}
|
|
169
|
+
return results;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Re-POST the full breakpoint list to refresh them on the server.
|
|
174
|
+
* Returns the updated bp list with server-assigned IDs and filters out
|
|
175
|
+
* any that the server rejected (invalid position).
|
|
176
|
+
*
|
|
177
|
+
* @param {object} config
|
|
178
|
+
* @param {AdtHttp} adt
|
|
179
|
+
* @param {Array} bps - current local bp list
|
|
180
|
+
* @returns {Promise<{ valid: Array, stale: Array }>}
|
|
181
|
+
*/
|
|
182
|
+
async function refreshBreakpoints(config, adt, bps) {
|
|
183
|
+
if (!bps || bps.length === 0) return { valid: [], stale: [] };
|
|
184
|
+
|
|
185
|
+
let resp;
|
|
186
|
+
try {
|
|
187
|
+
const body = buildBreakpointsXml(config.user, bps);
|
|
188
|
+
resp = await adt.post('/sap/bc/adt/debugger/breakpoints', body, {
|
|
189
|
+
contentType: 'application/xml',
|
|
190
|
+
headers: { Accept: 'application/xml' }
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Non-fatal — return original list unchanged
|
|
194
|
+
return { valid: bps, stale: [] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const serverResults = parseBreakpointResponse(resp.body || '', bps);
|
|
198
|
+
|
|
199
|
+
// Match server results back to local bps by uri+line
|
|
200
|
+
const valid = [];
|
|
201
|
+
const stale = [];
|
|
202
|
+
for (const bp of bps) {
|
|
203
|
+
const match = serverResults.find(r => r.uri === bp.uri && r.line === bp.line);
|
|
204
|
+
if (match && match.error) {
|
|
205
|
+
stale.push({ ...bp, error: match.error });
|
|
206
|
+
} else if (match && match.id) {
|
|
207
|
+
valid.push({ ...bp, id: match.id });
|
|
208
|
+
} else {
|
|
209
|
+
// No match in response — server silently dropped it (e.g. expired)
|
|
210
|
+
stale.push({ ...bp, error: 'Not registered on server' });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { valid, stale };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Parse a "name:line" token into { name, line }.
|
|
221
|
+
* Accepts:
|
|
222
|
+
* - "ZCL_MY_CLASS:42" (--objects)
|
|
223
|
+
* - "src/zcl_my_class.clas.abap:42" (--files)
|
|
224
|
+
*
|
|
225
|
+
* For file paths the object name is derived from the basename the same way
|
|
226
|
+
* syntax.js does: first dot-segment uppercased.
|
|
227
|
+
* Returns null if the token cannot be parsed.
|
|
228
|
+
*/
|
|
229
|
+
function parseBreakpointToken(token) {
|
|
230
|
+
const lastColon = token.lastIndexOf(':');
|
|
231
|
+
if (lastColon === -1) return null;
|
|
232
|
+
|
|
233
|
+
const raw = token.slice(0, lastColon);
|
|
234
|
+
const lineN = parseInt(token.slice(lastColon + 1), 10);
|
|
235
|
+
if (isNaN(lineN) || lineN < 1) return null;
|
|
236
|
+
|
|
237
|
+
// Derive object name: if it looks like a file path use the basename
|
|
238
|
+
const base = path.basename(raw);
|
|
239
|
+
const name = base.includes('.') ? base.split('.')[0].toUpperCase() : raw.toUpperCase();
|
|
240
|
+
return { name, line: lineN };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function cmdSet(args, config, adt) {
|
|
244
|
+
const objectName = val(args, '--object');
|
|
245
|
+
const lineRaw = val(args, '--line');
|
|
246
|
+
const filesArg = val(args, '--files');
|
|
247
|
+
const objectsArg = val(args, '--objects');
|
|
248
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
249
|
+
|
|
250
|
+
// Collect all breakpoints to add from every accepted input form
|
|
251
|
+
const toAdd = []; // [{ name, line }]
|
|
252
|
+
|
|
253
|
+
// --files src/zcl_foo.clas.abap:42,src/zcl_bar.clas.abap:10
|
|
254
|
+
if (filesArg) {
|
|
255
|
+
for (const token of filesArg.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
256
|
+
const parsed = parseBreakpointToken(token);
|
|
257
|
+
if (!parsed) {
|
|
258
|
+
console.error(` Error: --files value "${token}" must include a line number (e.g. src/zcl_foo.clas.abap:42)`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
toAdd.push(parsed);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --objects ZCL_FOO:42,ZCL_BAR:10
|
|
266
|
+
if (objectsArg) {
|
|
267
|
+
for (const token of objectsArg.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
268
|
+
const parsed = parseBreakpointToken(token);
|
|
269
|
+
if (!parsed) {
|
|
270
|
+
console.error(` Error: --objects value "${token}" must include a line number (e.g. ZCL_MY_CLASS:42)`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
toAdd.push(parsed);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --object ZCL_FOO --line 42 (legacy / single-value form)
|
|
278
|
+
if (objectName || lineRaw) {
|
|
279
|
+
if (!objectName) {
|
|
280
|
+
console.error(' Error: --object is required (e.g. --object ZCL_MY_CLASS)');
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (!lineRaw) {
|
|
284
|
+
console.error(' Error: --line is required (e.g. --line 42)');
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
const line = parseInt(lineRaw, 10);
|
|
288
|
+
if (isNaN(line) || line < 1) {
|
|
289
|
+
console.error(' Error: --line must be a positive integer');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
toAdd.push({ name: objectName, line });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (toAdd.length === 0) {
|
|
296
|
+
console.error(' Error: specify breakpoint(s) via --files, --objects, or --object/--line');
|
|
297
|
+
console.error(' Examples:');
|
|
298
|
+
console.error(' debug set --files src/zcl_my_class.clas.abap:42');
|
|
299
|
+
console.error(' debug set --objects ZCL_MY_CLASS:42');
|
|
300
|
+
console.error(' debug set --object ZCL_MY_CLASS --line 42');
|
|
301
|
+
process.exit(1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const existing = _loadBpState ? _loadBpState(config) : [];
|
|
305
|
+
let updated = [...existing];
|
|
306
|
+
const added = [];
|
|
307
|
+
|
|
308
|
+
for (const { name, line } of toAdd) {
|
|
309
|
+
const uri = objectUri(name);
|
|
310
|
+
const objUpper = name.toUpperCase();
|
|
311
|
+
// Skip if an identical breakpoint already exists
|
|
312
|
+
if (existing.some(bp => bp.object === objUpper && bp.line === line)) {
|
|
313
|
+
if (!jsonOutput) console.log(`\n Already set: ${objUpper}:${line}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const newId = `bp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
317
|
+
updated.push({ id: newId, object: objUpper, uri, line });
|
|
318
|
+
added.push({ name: objUpper, uri, line });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (added.length === 0) {
|
|
322
|
+
// Nothing new to register — all positions were already set
|
|
323
|
+
if (jsonOutput) console.log(JSON.stringify([]));
|
|
324
|
+
else console.log('');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await adt.fetchCsrfToken();
|
|
329
|
+
const body = buildBreakpointsXml(config.user, updated);
|
|
330
|
+
|
|
331
|
+
let resp;
|
|
332
|
+
try {
|
|
333
|
+
resp = await adt.post('/sap/bc/adt/debugger/breakpoints', body, {
|
|
334
|
+
contentType: 'application/xml',
|
|
335
|
+
headers: { Accept: 'application/xml' }
|
|
336
|
+
});
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const serverResults = parseBreakpointResponse(resp ? resp.body || '' : '', updated);
|
|
343
|
+
|
|
344
|
+
// Check for errors on newly added breakpoints
|
|
345
|
+
for (const a of added) {
|
|
346
|
+
const match = serverResults.find(r => r.uri === a.uri && r.line === a.line);
|
|
347
|
+
if (match && match.error) {
|
|
348
|
+
console.error(`\n Error: ${match.error}\n`);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Update local state with server-assigned IDs
|
|
354
|
+
const updatedWithServerIds = updated.map(bp => {
|
|
355
|
+
const sr = serverResults.find(r => r.uri === bp.uri && r.line === bp.line);
|
|
356
|
+
return sr && sr.id ? { ...bp, id: sr.id } : bp;
|
|
357
|
+
});
|
|
358
|
+
if (_saveBpState) _saveBpState(config, updatedWithServerIds);
|
|
359
|
+
|
|
360
|
+
if (jsonOutput) {
|
|
361
|
+
const out = added.map(a => {
|
|
362
|
+
const sr = serverResults.find(r => r.uri === a.uri && r.line === a.line);
|
|
363
|
+
return { id: (sr && sr.id) || null, object: a.name, line: a.line };
|
|
364
|
+
});
|
|
365
|
+
console.log(JSON.stringify(out.length === 1 ? out[0] : out));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const a of added) {
|
|
370
|
+
console.log(`\n Breakpoint set at ${a.name}:${a.line}`);
|
|
371
|
+
}
|
|
372
|
+
console.log('');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Sub-command: list ────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
async function cmdList(args, config, adt) {
|
|
378
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
379
|
+
|
|
380
|
+
const localBps = _loadBpState ? _loadBpState(config) : [];
|
|
381
|
+
|
|
382
|
+
if (localBps.length === 0) {
|
|
383
|
+
if (jsonOutput) {
|
|
384
|
+
console.log(JSON.stringify({ breakpoints: [] }));
|
|
385
|
+
} else {
|
|
386
|
+
console.log('\n No breakpoints set.\n');
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Always verify local breakpoints against the server.
|
|
392
|
+
// Re-POSTing the full list refreshes them; the response body reveals which
|
|
393
|
+
// ones ADT accepted vs rejected (invalid/expired position).
|
|
394
|
+
await adt.fetchCsrfToken();
|
|
395
|
+
const { valid, stale } = await refreshBreakpoints(config, adt, localBps);
|
|
396
|
+
|
|
397
|
+
// Persist the refreshed list (with server IDs, stale ones removed)
|
|
398
|
+
if (_saveBpState) _saveBpState(config, valid);
|
|
399
|
+
|
|
400
|
+
const bps = valid;
|
|
401
|
+
|
|
402
|
+
if (jsonOutput) {
|
|
403
|
+
console.log(JSON.stringify({ breakpoints: bps, stale: stale.map(b => ({ object: b.object, line: b.line, error: b.error })) }));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (stale.length > 0) {
|
|
408
|
+
console.log(`\n Warning: ${stale.length} breakpoint(s) were no longer valid on the server and have been removed:`);
|
|
409
|
+
stale.forEach(({ object, line, error }) => {
|
|
410
|
+
console.log(` ${(object || '').padEnd(30)} line ${line} (${error})`);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (bps.length === 0) {
|
|
415
|
+
console.log('\n No breakpoints set.\n');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
console.log(`\n Breakpoints (${bps.length})\n`);
|
|
420
|
+
console.log(' ' + '#'.padEnd(4) + 'Object'.padEnd(35) + 'Line');
|
|
421
|
+
console.log(' ' + '-'.repeat(50));
|
|
422
|
+
bps.forEach(({ object, line }, i) => {
|
|
423
|
+
console.log(' ' + String(i + 1).padEnd(4) + (object || '').padEnd(35) + (line || ''));
|
|
424
|
+
});
|
|
425
|
+
console.log(' ' + '-'.repeat(50));
|
|
426
|
+
console.log(' Use "debug delete --object <name> --line <n>" or "debug delete --all"\n');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── Sub-command: delete ──────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
async function cmdDelete(args, config, adt) {
|
|
432
|
+
const bpId = val(args, '--id');
|
|
433
|
+
const delObject = val(args, '--object');
|
|
434
|
+
const delLine = val(args, '--line') ? parseInt(val(args, '--line'), 10) : null;
|
|
435
|
+
const deleteAll = hasFlag(args, '--all');
|
|
436
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
437
|
+
|
|
438
|
+
if (!bpId && !deleteAll && !(delObject && delLine)) {
|
|
439
|
+
console.error(' Error: Provide --object <name> --line <n>, --id <id>, or --all');
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const existing = _loadBpState ? _loadBpState(config) : [];
|
|
444
|
+
|
|
445
|
+
let remaining;
|
|
446
|
+
if (deleteAll) {
|
|
447
|
+
remaining = [];
|
|
448
|
+
} else if (delObject && delLine) {
|
|
449
|
+
// Match by object name + line (case-insensitive)
|
|
450
|
+
const upperObj = delObject.toUpperCase();
|
|
451
|
+
remaining = existing.filter(bp => !(bp.object === upperObj && bp.line === delLine));
|
|
452
|
+
if (remaining.length === existing.length) {
|
|
453
|
+
console.error(`\n Error: No breakpoint found at ${upperObj}:${delLine}\n`);
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
remaining = existing.filter(bp => bp.id !== bpId);
|
|
458
|
+
if (remaining.length === existing.length) {
|
|
459
|
+
// ID not found in local state (or no local state) — DELETE directly on server
|
|
460
|
+
await adt.fetchCsrfToken();
|
|
461
|
+
try {
|
|
462
|
+
await adt.delete(`/sap/bc/adt/debugger/breakpoints/${encodeURIComponent(bpId)}`);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
465
|
+
process.exit(1);
|
|
466
|
+
}
|
|
467
|
+
if (jsonOutput) {
|
|
468
|
+
console.log(JSON.stringify({ deleted: bpId }));
|
|
469
|
+
} else {
|
|
470
|
+
console.log(`\n Breakpoint ${bpId} deleted.\n`);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// POST the remaining list (synchronize model — absent = deleted)
|
|
477
|
+
await adt.fetchCsrfToken();
|
|
478
|
+
const body = buildBreakpointsXml(config.user, remaining);
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
await adt.post('/sap/bc/adt/debugger/breakpoints', body, {
|
|
482
|
+
contentType: 'application/xml',
|
|
483
|
+
headers: { Accept: 'application/xml' }
|
|
484
|
+
});
|
|
485
|
+
} catch (err) {
|
|
486
|
+
// For delete-all, a DELETE to the collection endpoint is also valid
|
|
487
|
+
if (deleteAll) {
|
|
488
|
+
try {
|
|
489
|
+
await adt.delete(`/sap/bc/adt/debugger/breakpoints?clientId=${encodeURIComponent(ADT_CLIENT_ID)}`);
|
|
490
|
+
} catch (err2) {
|
|
491
|
+
console.error(`\n Error: ${err2.message || JSON.stringify(err2)}\n`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (_saveBpState) _saveBpState(config, remaining);
|
|
501
|
+
|
|
502
|
+
const deletedLabel = deleteAll ? 'all'
|
|
503
|
+
: (delObject && delLine) ? `${delObject.toUpperCase()}:${delLine}`
|
|
504
|
+
: bpId;
|
|
505
|
+
|
|
506
|
+
if (deleteAll) {
|
|
507
|
+
if (jsonOutput) {
|
|
508
|
+
console.log(JSON.stringify({ deleted: 'all' }));
|
|
509
|
+
} else {
|
|
510
|
+
console.log('\n All breakpoints deleted.\n');
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
if (jsonOutput) {
|
|
514
|
+
console.log(JSON.stringify({ deleted: deletedLabel }));
|
|
515
|
+
} else {
|
|
516
|
+
console.log(`\n Breakpoint ${deletedLabel} deleted.\n`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── Sub-command: attach ──────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
async function cmdAttach(args, config, adt) {
|
|
524
|
+
const sessionIdOverride = val(args, '--session');
|
|
525
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
526
|
+
// Per-poll timeout in seconds sent to ADT (ADT blocks the POST for this long)
|
|
527
|
+
const pollTimeout = parseInt(val(args, '--timeout') || '30', 10);
|
|
528
|
+
// Shorter timeout used in takeover mode — keeps the connection alive but
|
|
529
|
+
// lets ADT process stepContinue quickly and lets session 2 exit within
|
|
530
|
+
// ~5 seconds of clearActiveSession being written.
|
|
531
|
+
const takeoverPollTimeout = 5;
|
|
532
|
+
// terminalId/ideId must match the breakpoint's ideId (ADT_CLIENT_ID) so that
|
|
533
|
+
// ADT routes breakpoint hit notifications to this listener.
|
|
534
|
+
const listenTerminalId = ADT_CLIENT_ID;
|
|
535
|
+
|
|
536
|
+
if (!jsonOutput) {
|
|
537
|
+
process.stderr.write('\n Waiting for breakpoint hit... (run your ABAP program in a separate window)\n');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
await adt.fetchCsrfToken();
|
|
541
|
+
|
|
542
|
+
// Re-POST local breakpoints to refresh them on the server before listening.
|
|
543
|
+
// Breakpoints expire when the SAP session or work process is restarted.
|
|
544
|
+
// This ensures they are active regardless of when they were originally set.
|
|
545
|
+
if (!sessionIdOverride) {
|
|
546
|
+
const localBps = _loadBpState ? _loadBpState(config) : [];
|
|
547
|
+
if (localBps.length === 0) {
|
|
548
|
+
console.error('\n No breakpoints set. Use "debug set --object <name> --line <n>" first.\n');
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
const { valid, stale } = await refreshBreakpoints(config, adt, localBps);
|
|
552
|
+
if (_saveBpState) _saveBpState(config, valid);
|
|
553
|
+
if (stale.length > 0 && !jsonOutput) {
|
|
554
|
+
process.stderr.write(` Warning: ${stale.length} breakpoint(s) could not be registered (invalid position):\n`);
|
|
555
|
+
stale.forEach(({ object, line, error }) => {
|
|
556
|
+
process.stderr.write(` ${(object || '').padEnd(30)} line ${line} (${error})\n`);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (valid.length === 0) {
|
|
560
|
+
console.error('\n Error: All breakpoints were rejected by the server. Check line numbers point to executable statements.\n');
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
let sessionId = sessionIdOverride;
|
|
565
|
+
let positionResult = null;
|
|
566
|
+
|
|
567
|
+
if (!sessionId) {
|
|
568
|
+
// ADT listeners: POST long-polls until a breakpoint is hit (or timeout).
|
|
569
|
+
// On breakpoint hit the response body contains <DEBUGGEE_ID>; on timeout
|
|
570
|
+
// (no breakpoint) returns 200 with empty body — poll again.
|
|
571
|
+
const listenUrlBase = `/sap/bc/adt/debugger/listeners` +
|
|
572
|
+
`?debuggingMode=user` +
|
|
573
|
+
`&requestUser=${encodeURIComponent((config.user || '').toUpperCase())}` +
|
|
574
|
+
`&terminalId=${encodeURIComponent(listenTerminalId)}` +
|
|
575
|
+
`&ideId=${encodeURIComponent(listenTerminalId)}`;
|
|
576
|
+
|
|
577
|
+
const MAX_POLLS = Math.ceil(240 / pollTimeout); // up to 4 minutes total
|
|
578
|
+
// In takeover mode we switch to takeoverPollTimeout — recalculate the limit then.
|
|
579
|
+
const MAX_TAKEOVER_POLLS = Math.ceil(240 / takeoverPollTimeout);
|
|
580
|
+
let dots = 0;
|
|
581
|
+
const attachStartedAt = Date.now(); // used to detect if another session takes over
|
|
582
|
+
let takenOver = false;
|
|
583
|
+
let firstPoll = true;
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < (takenOver ? MAX_TAKEOVER_POLLS : MAX_POLLS); i++) {
|
|
586
|
+
if (firstPoll && !jsonOutput) {
|
|
587
|
+
process.stderr.write(' Listener active — trigger your ABAP program now.\n');
|
|
588
|
+
firstPoll = false;
|
|
589
|
+
}
|
|
590
|
+
// Check whether another attach has won the race and saved a session since
|
|
591
|
+
// this process started waiting.
|
|
592
|
+
if (!takenOver) {
|
|
593
|
+
const existingSession = loadActiveSession(config);
|
|
594
|
+
if (existingSession && existingSession.savedAt && existingSession.savedAt > attachStartedAt) {
|
|
595
|
+
const pos = existingSession.position;
|
|
596
|
+
const where = pos && pos.line ? ` (${pos.class || pos.program || ''}:${pos.line})` : '';
|
|
597
|
+
if (!jsonOutput) {
|
|
598
|
+
process.stderr.write(`\n\n Another session attached${where}. Waiting for it to finish...\n`);
|
|
599
|
+
}
|
|
600
|
+
takenOver = true;
|
|
601
|
+
i = 0; // reset counter — takeover uses MAX_TAKEOVER_POLLS with shorter timeout
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let resp;
|
|
606
|
+
try {
|
|
607
|
+
const t = takenOver ? takeoverPollTimeout : pollTimeout;
|
|
608
|
+
resp = await adt.post(`${listenUrlBase}&timeout=${t}`, '', {
|
|
609
|
+
contentType: 'application/xml'
|
|
610
|
+
});
|
|
611
|
+
} catch (err) {
|
|
612
|
+
if (err && err.statusCode === 406) {
|
|
613
|
+
// Another listener (e.g. Eclipse) is active. Wait briefly and retry.
|
|
614
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (err && err.statusCode === 404) {
|
|
618
|
+
console.error(
|
|
619
|
+
'\n Debug session commands require ADT Debugger service (listeners).' +
|
|
620
|
+
'\n Check SICF node /sap/bc/adt/debugger/listeners is active.' +
|
|
621
|
+
'\n (The breakpoint management commands set/list/delete still work.)\n'
|
|
622
|
+
);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
console.error(`\n Error from ADT listener: ${err.message || JSON.stringify(err)}\n`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (resp.body && resp.body.trim().length > 0) {
|
|
630
|
+
// Breakpoint hit — listener response contains <DEBUGGEE_ID> tag.
|
|
631
|
+
// We extract the debuggeeId here; the actual attach (which returns
|
|
632
|
+
// the debugSessionId) is done after the loop.
|
|
633
|
+
const debuggeeIdMatch = resp.body.match(/<DEBUGGEE_ID>([^<]+)<\/DEBUGGEE_ID>/i) ||
|
|
634
|
+
resp.body.match(/DEBUGGEE_ID="([^"]+)"/i);
|
|
635
|
+
sessionId = debuggeeIdMatch ? debuggeeIdMatch[1].trim() : null;
|
|
636
|
+
|
|
637
|
+
if (!sessionId) {
|
|
638
|
+
// Fallback: some ADT versions put session info in different attributes
|
|
639
|
+
sessionId = AdtHttp.extractXmlAttr(resp.body, 'adtdbg:id', null) ||
|
|
640
|
+
AdtHttp.extractXmlAttr(resp.body, 'id', null) ||
|
|
641
|
+
(resp.headers && resp.headers['location']
|
|
642
|
+
? resp.headers['location'].split('/').pop() : null);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!sessionId) {
|
|
646
|
+
console.error('\n Error: Breakpoint hit but could not parse DEBUGGEE_ID.\n');
|
|
647
|
+
console.error('Response body:', resp.body.substring(0, 500));
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Store the raw debuggee ID — it will be used in attach() below
|
|
652
|
+
sessionId = sessionId; // debuggeeId, not yet the debugSessionId
|
|
653
|
+
|
|
654
|
+
// In takeover mode a new breakpoint hit means the program was handed off
|
|
655
|
+
// to us (e.g. after the other session's q). Don't steal the session —
|
|
656
|
+
// just keep polling so the connection stays alive. But check for session
|
|
657
|
+
// end first (same post-poll check as the empty-body path).
|
|
658
|
+
if (takenOver) {
|
|
659
|
+
sessionId = null;
|
|
660
|
+
const currentSession = loadActiveSession(config);
|
|
661
|
+
if (!currentSession || !currentSession.sessionId) {
|
|
662
|
+
if (!jsonOutput) process.stderr.write(' Session ended.\n\n');
|
|
663
|
+
process.exit(0);
|
|
664
|
+
}
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Empty body = timeout, no breakpoint hit yet — poll again
|
|
671
|
+
// After a takeover, check here (post-poll) whether the other session ended.
|
|
672
|
+
// Checking after the poll ensures ADT had a live listener during its
|
|
673
|
+
// stepContinue processing window before we disconnect.
|
|
674
|
+
if (takenOver) {
|
|
675
|
+
const currentSession = loadActiveSession(config);
|
|
676
|
+
if (!currentSession || !currentSession.sessionId) {
|
|
677
|
+
if (!jsonOutput) process.stderr.write(' Session ended.\n\n');
|
|
678
|
+
process.exit(0);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (!jsonOutput && !takenOver) {
|
|
682
|
+
dots++;
|
|
683
|
+
if (dots % 3 === 0) process.stderr.write('.');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!sessionId) {
|
|
688
|
+
if (takenOver) {
|
|
689
|
+
// The other session was still active when we hit the poll limit.
|
|
690
|
+
if (!jsonOutput) process.stderr.write(' Listener timed out waiting for other session to finish.\n\n');
|
|
691
|
+
process.exit(0);
|
|
692
|
+
}
|
|
693
|
+
console.error('\n Timeout: No breakpoint was hit within 4 minutes.\n');
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const session = new DebugSession(adt, sessionId);
|
|
699
|
+
|
|
700
|
+
// If we got here via the listener (not --session override), we have a
|
|
701
|
+
// DEBUGGEE_ID and need to call ?method=attach to register as the active
|
|
702
|
+
// debugger for that work process. Without this step, all subsequent calls
|
|
703
|
+
// (getStack, getVariables, step) return "noSessionAttached" (T100-530).
|
|
704
|
+
if (!sessionIdOverride) {
|
|
705
|
+
if (!jsonOutput) {
|
|
706
|
+
process.stderr.write('\n Attaching to debug session...\n');
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
await session.attach(sessionId, (config.user || '').toUpperCase());
|
|
710
|
+
} catch (e) {
|
|
711
|
+
console.error(`\n Error during attach: ${e.message || JSON.stringify(e)}\n`);
|
|
712
|
+
if (e.body) console.error(' Response body:', e.body.substring(0, 400));
|
|
713
|
+
process.exit(1);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Fetch position + variables now that we have a live session
|
|
718
|
+
try {
|
|
719
|
+
positionResult = await session.getPosition();
|
|
720
|
+
} catch (e) {
|
|
721
|
+
// Position fetch is best-effort; proceed with empty state
|
|
722
|
+
positionResult = { position: {}, source: [] };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Dummy loop body retained for structural compatibility (never executes)
|
|
726
|
+
if (false) {
|
|
727
|
+
const MAX_POLLS = 0;
|
|
728
|
+
const POLL_INTERVAL = 0;
|
|
729
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
730
|
+
try {
|
|
731
|
+
const frames = await session.getStack();
|
|
732
|
+
if (frames && frames.length > 0 && frames[0].line > 0) {
|
|
733
|
+
positionResult = await session.getPosition();
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
} catch (e) {
|
|
737
|
+
// Stack not ready yet — keep polling
|
|
738
|
+
}
|
|
739
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { position, source } = positionResult;
|
|
744
|
+
saveActiveSession(config, { sessionId, position });
|
|
745
|
+
|
|
746
|
+
if (jsonOutput) {
|
|
747
|
+
// Spawn the background daemon to hold the stateful ADT HTTP connection.
|
|
748
|
+
// The daemon process inherits the exact SAP_SESSIONID cookie + CSRF token
|
|
749
|
+
// via the snapshot env var, so it reuses the same ABAP work process.
|
|
750
|
+
const socketPath = _getDaemonSocketPath ? _getDaemonSocketPath(config) : null;
|
|
751
|
+
if (socketPath) {
|
|
752
|
+
const snapshot = { csrfToken: adt.csrfToken, cookies: adt.cookies };
|
|
753
|
+
const daemonEnv = {
|
|
754
|
+
...process.env,
|
|
755
|
+
DEBUG_DAEMON_MODE: '1',
|
|
756
|
+
DEBUG_DAEMON_CONFIG: JSON.stringify(config),
|
|
757
|
+
DEBUG_DAEMON_SESSION_ID: sessionId,
|
|
758
|
+
DEBUG_DAEMON_SOCK_PATH: socketPath,
|
|
759
|
+
DEBUG_DAEMON_SESSION_SNAPSHOT: JSON.stringify(snapshot)
|
|
760
|
+
};
|
|
761
|
+
const daemonScript = path.resolve(__dirname, '../utils/debug-daemon.js');
|
|
762
|
+
const child = spawn(process.execPath, [daemonScript], {
|
|
763
|
+
detached: true,
|
|
764
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
765
|
+
env: daemonEnv
|
|
766
|
+
});
|
|
767
|
+
child.unref();
|
|
768
|
+
|
|
769
|
+
// Wait for daemon socket to appear (up to 5 s) before returning JSON
|
|
770
|
+
try {
|
|
771
|
+
await waitForSocket(socketPath, 5000);
|
|
772
|
+
// Persist socket path in session state so step/vars/stack/terminate find it
|
|
773
|
+
saveActiveSession(config, { sessionId, position, socketPath });
|
|
774
|
+
} catch (e) {
|
|
775
|
+
// Non-fatal: fall back to stateless direct-ADT mode
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
console.log(JSON.stringify({ session: sessionId, position, source }));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Human mode — enter interactive REPL
|
|
784
|
+
process.stderr.write('\n');
|
|
785
|
+
const { startRepl } = require('../utils/debug-repl');
|
|
786
|
+
await startRepl(session, positionResult, () => clearActiveSession(config));
|
|
787
|
+
clearActiveSession(config); // fallback if onBeforeExit path isn't reached
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ─── Sub-command: step ────────────────────────────────────────────────────────
|
|
791
|
+
|
|
792
|
+
async function cmdStep(args, config, adt) {
|
|
793
|
+
const typeMap = { over: 'stepOver', into: 'stepInto', out: 'stepReturn', continue: 'stepContinue' };
|
|
794
|
+
const typeRaw = val(args, '--type') || 'over';
|
|
795
|
+
const type = typeMap[typeRaw] || 'stepOver';
|
|
796
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
797
|
+
const { sessionId, socketPath } = resolveSessionState(args, config);
|
|
798
|
+
|
|
799
|
+
// Prefer daemon IPC when a socket path is known
|
|
800
|
+
if (socketPath) {
|
|
801
|
+
let resp;
|
|
802
|
+
try {
|
|
803
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'step', type }, 60000);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
806
|
+
process.exit(1);
|
|
807
|
+
}
|
|
808
|
+
if (!resp.ok) {
|
|
809
|
+
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}${resp.body ? '\n Body: ' + resp.body.substring(0, 400) : ''}\n`);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
// continued+finished (or empty position) means the program ran to completion
|
|
813
|
+
const doneByFlag = resp.position && resp.position.finished;
|
|
814
|
+
const doneByEmpty = !resp.position || (!resp.position.class && !resp.position.method && !resp.position.program);
|
|
815
|
+
if (doneByFlag || doneByEmpty) {
|
|
816
|
+
clearActiveSession(config);
|
|
817
|
+
if (jsonOutput) {
|
|
818
|
+
console.log(JSON.stringify({ position: resp.position || {}, source: [], finished: true }));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
console.log('\n Execution completed — no active breakpoint. Debug session ended.\n');
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
saveActiveSession(config, { sessionId, position: resp.position, socketPath });
|
|
825
|
+
if (jsonOutput) {
|
|
826
|
+
console.log(JSON.stringify({ position: resp.position, source: resp.source }));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const { renderState } = require('../utils/debug-repl');
|
|
830
|
+
renderState(resp.position, resp.source, []);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Fallback: direct ADT call (no daemon running — e.g. human REPL or test mode)
|
|
835
|
+
if (!adt.csrfToken) await adt.fetchCsrfToken();
|
|
836
|
+
const session = new DebugSession(adt, sessionId);
|
|
837
|
+
|
|
838
|
+
let result;
|
|
839
|
+
try {
|
|
840
|
+
result = await session.step(type);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
if (err && err.statusCode === 404) {
|
|
843
|
+
console.error(
|
|
844
|
+
'\n Debug session commands require ADT Debugger service (listeners).' +
|
|
845
|
+
'\n Check SICF node /sap/bc/adt/debugger/listeners is active.\n'
|
|
846
|
+
);
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}${err.body ? '\n Body: ' + err.body.substring(0, 600) : ''}\n`);
|
|
850
|
+
process.exit(1);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
saveActiveSession(config, { sessionId, position: result.position });
|
|
854
|
+
|
|
855
|
+
const doneByFlag = result.position && result.position.finished;
|
|
856
|
+
const doneByEmpty = !result.position || (!result.position.class && !result.position.method && !result.position.program);
|
|
857
|
+
if (doneByFlag || doneByEmpty) {
|
|
858
|
+
clearActiveSession(config);
|
|
859
|
+
if (jsonOutput) {
|
|
860
|
+
console.log(JSON.stringify({ position: result.position || {}, source: [], finished: true }));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
console.log('\n Execution completed — no active breakpoint. Debug session ended.\n');
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (jsonOutput) {
|
|
868
|
+
console.log(JSON.stringify({ position: result.position, source: result.source }));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const { renderState } = require('../utils/debug-repl');
|
|
873
|
+
renderState(result.position, result.source, []);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ─── Sub-command: vars ────────────────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
async function cmdVars(args, config, adt) {
|
|
879
|
+
const nameFilter = val(args, '--name');
|
|
880
|
+
const expandName = val(args, '--expand');
|
|
881
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
882
|
+
const { sessionId, socketPath } = resolveSessionState(args, config);
|
|
883
|
+
|
|
884
|
+
// --expand <name>: drill into a named complex variable (internal table / structure)
|
|
885
|
+
if (expandName) {
|
|
886
|
+
return cmdExpand(expandName, sessionId, socketPath, config, adt, jsonOutput);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Prefer daemon IPC
|
|
890
|
+
if (socketPath) {
|
|
891
|
+
let resp;
|
|
892
|
+
try {
|
|
893
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'vars', name: nameFilter }, 60000);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
if (!resp.ok) {
|
|
899
|
+
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}\n`);
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
const variables = resp.variables;
|
|
903
|
+
if (jsonOutput) {
|
|
904
|
+
console.log(JSON.stringify({ variables }));
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
_printVars(variables);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Fallback: direct ADT call
|
|
912
|
+
if (!adt.csrfToken) await adt.fetchCsrfToken();
|
|
913
|
+
const session = new DebugSession(adt, sessionId);
|
|
914
|
+
|
|
915
|
+
let variables;
|
|
916
|
+
try {
|
|
917
|
+
variables = await session.getVariables(nameFilter);
|
|
918
|
+
} catch (err) {
|
|
919
|
+
if (err && err.statusCode === 404) {
|
|
920
|
+
console.error(
|
|
921
|
+
'\n Debug session commands require ADT Debugger service (listeners).' +
|
|
922
|
+
'\n Check SICF node /sap/bc/adt/debugger/listeners is active.\n'
|
|
923
|
+
);
|
|
924
|
+
process.exit(1);
|
|
925
|
+
}
|
|
926
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (jsonOutput) {
|
|
931
|
+
console.log(JSON.stringify({ variables }));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
_printVars(variables);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Drill one level into a named complex variable.
|
|
940
|
+
* Supports `->` path notation for nested expansion, e.g.:
|
|
941
|
+
* LO_FACTORY->MT_COMMAND_MAP (object attr → table)
|
|
942
|
+
* LS_DATA->COMPONENT (structure field)
|
|
943
|
+
*
|
|
944
|
+
* When a daemon socket is active, uses daemon IPC for single-segment paths.
|
|
945
|
+
* Multi-segment paths always run directly against ADT (DebugSession.expandPath).
|
|
946
|
+
*/
|
|
947
|
+
async function cmdExpand(expandName, sessionId, socketPath, config, adt, jsonOutput) {
|
|
948
|
+
// Split on -> to detect path notation. Also handle --> typos by stripping stray dashes.
|
|
949
|
+
// Normalize ABAP-style field accessors to use -> separator:
|
|
950
|
+
// [N]-FIELD → [N]->FIELD (array row then struct field)
|
|
951
|
+
// *-FIELD → *->FIELD (dereference then struct field: lr_request->*-files)
|
|
952
|
+
const normalizedName = expandName
|
|
953
|
+
.replace(/\](-(?!>))/g, ']->') // [N]-FIELD → [N]->FIELD
|
|
954
|
+
.replace(/\*(-(?!>))/g, '*->'); // *-FIELD → *->FIELD
|
|
955
|
+
const pathParts = normalizedName.split('->').map(s => s.replace(/^-+|-+$/g, '').trim()).filter(Boolean);
|
|
956
|
+
|
|
957
|
+
// Multi-segment path: must go direct (daemon IPC only handles one level at a time)
|
|
958
|
+
if (pathParts.length > 1) {
|
|
959
|
+
if (!adt.csrfToken) await adt.fetchCsrfToken();
|
|
960
|
+
const session = new DebugSession(adt, sessionId);
|
|
961
|
+
let result;
|
|
962
|
+
try {
|
|
963
|
+
result = await session.expandPath(pathParts);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
const { variable: target, children } = result;
|
|
969
|
+
return _printExpandResult(expandName, target, children, jsonOutput);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Single-segment path — existing logic (daemon-aware)
|
|
973
|
+
// Step 1: get all vars to find the ID of the target variable
|
|
974
|
+
let allVars;
|
|
975
|
+
if (socketPath) {
|
|
976
|
+
let resp;
|
|
977
|
+
try {
|
|
978
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'vars', name: null }, 60000);
|
|
979
|
+
} catch (err) {
|
|
980
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
if (!resp.ok) {
|
|
984
|
+
console.error(`\n Error: ${resp.error}\n`);
|
|
985
|
+
process.exit(1);
|
|
986
|
+
}
|
|
987
|
+
allVars = resp.variables;
|
|
988
|
+
} else {
|
|
989
|
+
if (!adt.csrfToken) await adt.fetchCsrfToken();
|
|
990
|
+
const session = new DebugSession(adt, sessionId);
|
|
991
|
+
allVars = await session.getVariables(null).catch(() => []);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const target = allVars.find(v => v.name.toUpperCase() === expandName.toUpperCase());
|
|
995
|
+
if (!target) {
|
|
996
|
+
console.error(`\n Error: Variable '${expandName}' not found. Run 'debug vars' to list variables.\n`);
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
if (!target.id) {
|
|
1000
|
+
console.error(`\n Error: Variable '${expandName}' has no ADT ID — cannot expand.\n`);
|
|
1001
|
+
process.exit(1);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Step 2: expand (drill one level)
|
|
1005
|
+
const meta = { metaType: target.metaType || '', tableLines: target.tableLines || 0 };
|
|
1006
|
+
let children;
|
|
1007
|
+
if (socketPath) {
|
|
1008
|
+
let resp;
|
|
1009
|
+
try {
|
|
1010
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'expand', id: target.id, meta }, 60000);
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
if (!resp.ok) {
|
|
1016
|
+
console.error(`\n Error: ${resp.error}\n`);
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
children = resp.variables;
|
|
1020
|
+
} else {
|
|
1021
|
+
const session = new DebugSession(adt, sessionId);
|
|
1022
|
+
children = await session.getVariableChildren(target.id, meta).catch(() => []);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return _printExpandResult(expandName, target, children, jsonOutput);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function _printExpandResult(label, variable, children, jsonOutput) {
|
|
1029
|
+
if (jsonOutput) {
|
|
1030
|
+
console.log(JSON.stringify({ variable, children }));
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (children.length === 0) {
|
|
1035
|
+
console.log(`\n ${label} — no children (scalar or empty).\n`);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Compute column widths from actual data (min 4/4, capped at 50/25).
|
|
1040
|
+
const nameW = Math.min(50, Math.max(4, ...children.map(c => (c.name || '').length)));
|
|
1041
|
+
const typeW = Math.min(25, Math.max(4, ...children.map(c => (c.type || c.metaType || '').length)));
|
|
1042
|
+
|
|
1043
|
+
console.log(`\n ${label} (${variable.type || variable.metaType || '?'}):\n`);
|
|
1044
|
+
console.log(' ' + 'Name'.padEnd(nameW + 2) + 'Type'.padEnd(typeW + 2) + 'Value');
|
|
1045
|
+
console.log(' ' + '-'.repeat(nameW + typeW + 24));
|
|
1046
|
+
children.forEach(({ name, type, value, metaType, tableLines }) => {
|
|
1047
|
+
const displayType = (type || metaType || '').slice(0, typeW);
|
|
1048
|
+
const displayValue = metaType === 'table'
|
|
1049
|
+
? `[${tableLines} rows] — use 'x ${label}->${name}' to expand`
|
|
1050
|
+
: value;
|
|
1051
|
+
console.log(' ' + name.padEnd(nameW + 2) + displayType.padEnd(typeW + 2) + displayValue);
|
|
1052
|
+
});
|
|
1053
|
+
console.log('');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function _printVars(variables) {
|
|
1057
|
+
const { printVarList } = require('../utils/debug-render');
|
|
1058
|
+
printVarList(variables);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ─── Sub-command: stack ───────────────────────────────────────────────────────
|
|
1062
|
+
|
|
1063
|
+
async function cmdStack(args, config, adt) {
|
|
1064
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
1065
|
+
const { sessionId, socketPath } = resolveSessionState(args, config);
|
|
1066
|
+
|
|
1067
|
+
// Prefer daemon IPC
|
|
1068
|
+
if (socketPath) {
|
|
1069
|
+
let resp;
|
|
1070
|
+
try {
|
|
1071
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'stack' }, 60000);
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
console.error(`\n Error: ${err.message}\n`);
|
|
1074
|
+
process.exit(1);
|
|
1075
|
+
}
|
|
1076
|
+
if (!resp.ok) {
|
|
1077
|
+
console.error(`\n Error: ${resp.error}${resp.statusCode ? ` (HTTP ${resp.statusCode})` : ''}${resp.body ? '\n Body: ' + resp.body.substring(0, 400) : ''}\n`);
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
const frames = resp.frames;
|
|
1081
|
+
if (jsonOutput) {
|
|
1082
|
+
console.log(JSON.stringify({ frames }));
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
_printStack(frames);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Fallback: direct ADT call
|
|
1090
|
+
if (!adt.csrfToken) await adt.fetchCsrfToken();
|
|
1091
|
+
const session = new DebugSession(adt, sessionId);
|
|
1092
|
+
|
|
1093
|
+
let frames;
|
|
1094
|
+
try {
|
|
1095
|
+
frames = await session.getStack();
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
if (err && err.statusCode === 404) {
|
|
1098
|
+
console.error(
|
|
1099
|
+
'\n Debug session commands require ADT Debugger service (listeners).' +
|
|
1100
|
+
'\n Check SICF node /sap/bc/adt/debugger/listeners is active.\n'
|
|
1101
|
+
);
|
|
1102
|
+
process.exit(1);
|
|
1103
|
+
}
|
|
1104
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
1105
|
+
process.exit(1);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (jsonOutput) {
|
|
1109
|
+
console.log(JSON.stringify({ frames }));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
_printStack(frames);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function _printStack(frames) {
|
|
1117
|
+
if (frames.length === 0) {
|
|
1118
|
+
console.log('\n No call stack available.\n');
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
console.log('\n Call Stack:\n');
|
|
1122
|
+
frames.forEach(({ frame, class: cls, method, line }) => {
|
|
1123
|
+
const loc = cls ? `${cls}->${method}` : method;
|
|
1124
|
+
console.log(` ${String(frame).padStart(3)} ${loc} (line ${line})`);
|
|
1125
|
+
});
|
|
1126
|
+
console.log('');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ─── Sub-command: terminate ───────────────────────────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
async function cmdTerminate(args, config, adt) {
|
|
1132
|
+
const jsonOutput = hasFlag(args, '--json');
|
|
1133
|
+
const { sessionId, socketPath } = resolveSessionState(args, config);
|
|
1134
|
+
|
|
1135
|
+
// Prefer daemon IPC — daemon calls session.terminate() then exits itself
|
|
1136
|
+
if (socketPath) {
|
|
1137
|
+
let resp;
|
|
1138
|
+
try {
|
|
1139
|
+
resp = await sendDaemonCommand(socketPath, { cmd: 'terminate' }, 30000);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
// Socket may be gone already (daemon crashed or timed out) — treat as terminated
|
|
1142
|
+
resp = { ok: true, terminated: true };
|
|
1143
|
+
}
|
|
1144
|
+
clearActiveSession(config);
|
|
1145
|
+
if (jsonOutput) {
|
|
1146
|
+
console.log(JSON.stringify({ terminated: resp.ok ? true : resp.error }));
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
console.log('\n Debug session terminated.\n');
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Fallback: direct ADT call
|
|
1154
|
+
const session = new DebugSession(adt, sessionId);
|
|
1155
|
+
|
|
1156
|
+
try {
|
|
1157
|
+
await adt.fetchCsrfToken();
|
|
1158
|
+
await session.terminate();
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
if (err && err.statusCode === 404) {
|
|
1161
|
+
console.error(
|
|
1162
|
+
'\n Debug session commands require ADT Debugger service (listeners).' +
|
|
1163
|
+
'\n Check SICF node /sap/bc/adt/debugger/listeners is active.\n'
|
|
1164
|
+
);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
console.error(`\n Error: ${err.message || JSON.stringify(err)}\n`);
|
|
1168
|
+
process.exit(1);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
clearActiveSession(config);
|
|
1172
|
+
|
|
1173
|
+
if (jsonOutput) {
|
|
1174
|
+
console.log(JSON.stringify({ terminated: true }));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
console.log('\n Debug session terminated.\n');
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ─── Daemon IPC helpers ───────────────────────────────────────────────────────
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Wait for the daemon's Unix socket to appear (after spawn).
|
|
1185
|
+
* @param {string} socketPath
|
|
1186
|
+
* @param {number} timeoutMs
|
|
1187
|
+
*/
|
|
1188
|
+
function waitForSocket(socketPath, timeoutMs) {
|
|
1189
|
+
return new Promise((resolve, reject) => {
|
|
1190
|
+
const deadline = Date.now() + timeoutMs;
|
|
1191
|
+
function check() {
|
|
1192
|
+
const client = net.createConnection(socketPath);
|
|
1193
|
+
client.on('connect', () => {
|
|
1194
|
+
client.destroy();
|
|
1195
|
+
resolve();
|
|
1196
|
+
});
|
|
1197
|
+
client.on('error', () => {
|
|
1198
|
+
if (Date.now() >= deadline) {
|
|
1199
|
+
reject(new Error('Timeout waiting for debug daemon to start'));
|
|
1200
|
+
} else {
|
|
1201
|
+
setTimeout(check, 100);
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
check();
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Send one JSON command to the daemon and return the parsed JSON response.
|
|
1211
|
+
* @param {string} socketPath
|
|
1212
|
+
* @param {object} command
|
|
1213
|
+
* @param {number} timeoutMs
|
|
1214
|
+
* @returns {Promise<object>}
|
|
1215
|
+
*/
|
|
1216
|
+
function sendDaemonCommand(socketPath, command, timeoutMs) {
|
|
1217
|
+
return new Promise((resolve, reject) => {
|
|
1218
|
+
const client = net.createConnection(socketPath);
|
|
1219
|
+
let buf = '';
|
|
1220
|
+
let timer = null;
|
|
1221
|
+
|
|
1222
|
+
function cleanup() {
|
|
1223
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
1224
|
+
try { client.destroy(); } catch (e) { /* ignore */ }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
timer = setTimeout(() => {
|
|
1228
|
+
cleanup();
|
|
1229
|
+
reject(new Error('Timeout waiting for daemon response'));
|
|
1230
|
+
}, timeoutMs);
|
|
1231
|
+
|
|
1232
|
+
client.on('connect', () => {
|
|
1233
|
+
client.write(JSON.stringify(command) + '\n');
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
client.on('data', (chunk) => {
|
|
1237
|
+
buf += chunk.toString();
|
|
1238
|
+
const idx = buf.indexOf('\n');
|
|
1239
|
+
if (idx !== -1) {
|
|
1240
|
+
const line = buf.slice(0, idx).trim();
|
|
1241
|
+
cleanup();
|
|
1242
|
+
try {
|
|
1243
|
+
resolve(JSON.parse(line));
|
|
1244
|
+
} catch (e) {
|
|
1245
|
+
reject(new Error(`Invalid JSON from daemon: ${line}`));
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
client.on('error', (err) => {
|
|
1251
|
+
cleanup();
|
|
1252
|
+
reject(err);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
client.on('close', () => {
|
|
1256
|
+
if (timer) {
|
|
1257
|
+
cleanup();
|
|
1258
|
+
reject(new Error('Daemon closed connection without responding'));
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ─── Session ID resolution ────────────────────────────────────────────────────
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Resolve session state for AI-mode sub-commands.
|
|
1268
|
+
* Returns { sessionId, socketPath } where socketPath may be null if the
|
|
1269
|
+
* daemon is not running (falls back to direct ADT calls).
|
|
1270
|
+
*/
|
|
1271
|
+
function resolveSessionState(args, config) {
|
|
1272
|
+
if (hasFlag(args, '--session')) {
|
|
1273
|
+
console.error(
|
|
1274
|
+
'\n Error: --session is not valid for this command.' +
|
|
1275
|
+
'\n step/vars/stack/terminate communicate via the daemon socket, not a raw session ID.' +
|
|
1276
|
+
'\n Just run the command without --session — the active session is loaded automatically.\n'
|
|
1277
|
+
);
|
|
1278
|
+
process.exit(1);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const state = loadActiveSession(config);
|
|
1282
|
+
if (state && state.sessionId) {
|
|
1283
|
+
return { sessionId: state.sessionId, socketPath: state.socketPath || null };
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
console.error('\n Error: No active debug session. Run "debug attach" first.\n');
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** @deprecated Use resolveSessionState */
|
|
1291
|
+
function resolveSessionId(args, config) {
|
|
1292
|
+
return resolveSessionState(args, config).sessionId;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ─── Usage ───────────────────────────────────────────────────────────────────
|
|
1296
|
+
|
|
1297
|
+
function printUsage() {
|
|
1298
|
+
console.log(`
|
|
1299
|
+
Usage: abapgit-agent debug <subcommand> [options]
|
|
1300
|
+
|
|
1301
|
+
Breakpoint Management:
|
|
1302
|
+
set --files <file>:<n>[,...] Set breakpoint(s) from local source files
|
|
1303
|
+
set --objects <name>:<n>[,...] Set breakpoint(s) by object name (no local file needed)
|
|
1304
|
+
set --object <name> --line <n> Set a single breakpoint (legacy form)
|
|
1305
|
+
list List all breakpoints
|
|
1306
|
+
delete --object <name> --line <n> Delete a specific breakpoint
|
|
1307
|
+
delete --id <id> Delete by server ID (from --json output)
|
|
1308
|
+
delete --all Delete all breakpoints
|
|
1309
|
+
|
|
1310
|
+
Debug Session (Human REPL mode):
|
|
1311
|
+
attach Attach and enter interactive REPL
|
|
1312
|
+
|
|
1313
|
+
Debug Session (AI / scripting mode):
|
|
1314
|
+
attach --json Attach, wait for breakpoint, return JSON
|
|
1315
|
+
step [--type over|into|out|continue] [--json] Execute a step
|
|
1316
|
+
vars [--name <var>] [--json] Show variables
|
|
1317
|
+
vars --expand <var> [--json] Drill into a complex variable (table/structure)
|
|
1318
|
+
stack [--json] Show call stack
|
|
1319
|
+
terminate [--json] Terminate session
|
|
1320
|
+
|
|
1321
|
+
Examples:
|
|
1322
|
+
abapgit-agent debug set --files src/zcl_my_class.clas.abap:42
|
|
1323
|
+
abapgit-agent debug set --objects ZCL_MY_CLASS:42
|
|
1324
|
+
abapgit-agent debug set --object ZCL_MY_CLASS --line 42
|
|
1325
|
+
abapgit-agent debug list
|
|
1326
|
+
abapgit-agent debug attach
|
|
1327
|
+
abapgit-agent debug attach --json
|
|
1328
|
+
abapgit-agent debug step --type over --json
|
|
1329
|
+
abapgit-agent debug vars --json
|
|
1330
|
+
abapgit-agent debug stack --json
|
|
1331
|
+
abapgit-agent debug terminate --json
|
|
1332
|
+
abapgit-agent debug delete --all
|
|
1333
|
+
`);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ─── Module export ────────────────────────────────────────────────────────────
|
|
1337
|
+
|
|
1338
|
+
module.exports = {
|
|
1339
|
+
name: 'debug',
|
|
1340
|
+
description: 'Interactive ABAP debugger via ADT REST API',
|
|
1341
|
+
requiresAbapConfig: true,
|
|
1342
|
+
requiresVersionCheck: false,
|
|
1343
|
+
|
|
1344
|
+
async execute(args, context) {
|
|
1345
|
+
const { loadConfig, AdtHttp: AdtHttpCtx } = context;
|
|
1346
|
+
const subcommand = args[0];
|
|
1347
|
+
const subArgs = args.slice(1);
|
|
1348
|
+
|
|
1349
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
1350
|
+
printUsage();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const config = loadConfig();
|
|
1355
|
+
// Allow test injection via context; otherwise use the real AdtHttp
|
|
1356
|
+
const HttpClass = (context && context.AdtHttpClass) || AdtHttp;
|
|
1357
|
+
const adt = new HttpClass(config);
|
|
1358
|
+
|
|
1359
|
+
switch (subcommand) {
|
|
1360
|
+
case 'set':
|
|
1361
|
+
return cmdSet(subArgs, config, adt);
|
|
1362
|
+
|
|
1363
|
+
case 'list':
|
|
1364
|
+
return cmdList(subArgs, config, adt);
|
|
1365
|
+
|
|
1366
|
+
case 'delete':
|
|
1367
|
+
return cmdDelete(subArgs, config, adt);
|
|
1368
|
+
|
|
1369
|
+
case 'attach':
|
|
1370
|
+
return cmdAttach(subArgs, config, adt);
|
|
1371
|
+
|
|
1372
|
+
case 'step':
|
|
1373
|
+
return cmdStep(subArgs, config, adt);
|
|
1374
|
+
|
|
1375
|
+
case 'vars':
|
|
1376
|
+
return cmdVars(subArgs, config, adt);
|
|
1377
|
+
|
|
1378
|
+
case 'stack':
|
|
1379
|
+
return cmdStack(subArgs, config, adt);
|
|
1380
|
+
|
|
1381
|
+
case 'terminate':
|
|
1382
|
+
return cmdTerminate(subArgs, config, adt);
|
|
1383
|
+
|
|
1384
|
+
default:
|
|
1385
|
+
console.error(` Unknown debug subcommand: ${subcommand}`);
|
|
1386
|
+
printUsage();
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|