abapgit-agent 1.8.9 → 1.10.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.
@@ -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})` : ''}\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})` : ''}\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
+ };