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,207 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * debug-daemon.js — Long-lived daemon that holds the stateful ADT HTTP connection.
5
+ *
6
+ * Why this exists:
7
+ * SAP ADT debug sessions are pinned to a specific ABAP work process via the
8
+ * SAP_SESSIONID cookie. The cookie is maintained in-memory by AdtHttp. When a
9
+ * CLI process exits (e.g. `debug attach --json`) the in-memory session is lost
10
+ * and the ABAP work process is released from the debugger. Subsequent CLI
11
+ * invocations (`debug step`) get a different HTTP connection → `noSessionAttached`.
12
+ *
13
+ * The daemon keeps one Node.js process alive after attach, holding the open
14
+ * HTTP session. Individual CLI commands connect via Unix domain socket, send a
15
+ * JSON command, read a JSON response, and exit.
16
+ *
17
+ * Lifecycle:
18
+ * 1. Spawned by `debug attach --json` with detached:true + stdio:ignore + unref()
19
+ * 2. Reads config/session from env vars (JSON-encoded)
20
+ * 3. Creates Unix socket at DEBUG_DAEMON_SOCK_PATH
21
+ * 4. Handles JSON-line commands until `terminate` or 30-min idle timeout
22
+ * 5. Deletes socket file and exits
23
+ *
24
+ * IPC Protocol — newline-delimited JSON over Unix socket:
25
+ *
26
+ * Commands:
27
+ * { "cmd": "ping" }
28
+ * { "cmd": "step", "type": "stepOver|stepInto|stepReturn|stepContinue" }
29
+ * { "cmd": "vars", "name": null }
30
+ * { "cmd": "stack" }
31
+ * { "cmd": "terminate" }
32
+ *
33
+ * Responses (one JSON line per command):
34
+ * { "ok": true, "pong": true }
35
+ * { "ok": true, "position": {...}, "source": [...] }
36
+ * { "ok": true, "variables": [...] }
37
+ * { "ok": true, "frames": [...] }
38
+ * { "ok": true, "terminated": true }
39
+ * { "ok": false, "error": "message", "statusCode": 400 }
40
+ */
41
+
42
+ const net = require('net');
43
+ const fs = require('fs');
44
+ const { AdtHttp } = require('./adt-http');
45
+ const { DebugSession } = require('./debug-session');
46
+
47
+ const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
48
+
49
+ // ─── Public API ───────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Start the daemon server.
53
+ *
54
+ * @param {object} config - ABAP connection config
55
+ * @param {string} sessionId - debugSessionId returned by attach
56
+ * @param {string} socketPath - Unix socket path to listen on
57
+ * @param {object|null} snapshot - { csrfToken, cookies } captured from attach process
58
+ */
59
+ async function startDaemon(config, sessionId, socketPath, snapshot) {
60
+ const adt = new AdtHttp(config);
61
+
62
+ // Restore the exact SAP_SESSIONID cookie + CSRF token from the attach process.
63
+ // This guarantees the first IPC command reuses the same ABAP work process
64
+ // without another round-trip for a new token.
65
+ if (snapshot && snapshot.csrfToken) adt.csrfToken = snapshot.csrfToken;
66
+ if (snapshot && snapshot.cookies) adt.cookies = snapshot.cookies;
67
+
68
+ const session = new DebugSession(adt, sessionId);
69
+
70
+ // Remove stale socket file from a previous crash
71
+ try { fs.unlinkSync(socketPath); } catch (e) { /* ignore ENOENT */ }
72
+
73
+ let idleTimer = null;
74
+
75
+ function resetIdle() {
76
+ if (idleTimer) clearTimeout(idleTimer);
77
+ idleTimer = setTimeout(cleanupAndExit, DAEMON_IDLE_TIMEOUT_MS);
78
+ // unref so idle timer alone doesn't keep the process alive — if the
79
+ // server stops listening for another reason the process can exit naturally
80
+ if (idleTimer.unref) idleTimer.unref();
81
+ }
82
+
83
+ function cleanupAndExit(code) {
84
+ try { fs.unlinkSync(socketPath); } catch (e) { /* ignore */ }
85
+ process.exit(code || 0);
86
+ }
87
+
88
+ const server = net.createServer((socket) => {
89
+ resetIdle();
90
+ let buf = '';
91
+
92
+ socket.on('data', (chunk) => {
93
+ buf += chunk.toString();
94
+ let idx;
95
+ while ((idx = buf.indexOf('\n')) !== -1) {
96
+ const line = buf.slice(0, idx).trim();
97
+ buf = buf.slice(idx + 1);
98
+ if (line) {
99
+ _handleLine(socket, line, session, cleanupAndExit, resetIdle);
100
+ }
101
+ }
102
+ });
103
+
104
+ socket.on('error', () => { /* client disconnected abruptly — ignore */ });
105
+ });
106
+
107
+ server.listen(socketPath, () => {
108
+ resetIdle();
109
+ });
110
+
111
+ server.on('error', (err) => {
112
+ process.stderr.write(`[debug-daemon] server error: ${err.message}\n`);
113
+ cleanupAndExit(1);
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Handle one parsed JSON command line.
119
+ * Exported so unit tests can call it directly without spawning a real server.
120
+ */
121
+ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
122
+ let req;
123
+ try {
124
+ req = JSON.parse(line);
125
+ } catch (e) {
126
+ _send(socket, { ok: false, error: `Invalid JSON: ${e.message}` });
127
+ return;
128
+ }
129
+
130
+ resetIdle();
131
+
132
+ try {
133
+ switch (req.cmd) {
134
+ case 'ping': {
135
+ _send(socket, { ok: true, pong: true });
136
+ break;
137
+ }
138
+ case 'step': {
139
+ const result = await session.step(req.type || 'stepOver');
140
+ _send(socket, { ok: true, position: result.position, source: result.source });
141
+ break;
142
+ }
143
+ case 'vars': {
144
+ const variables = await session.getVariables(req.name || null);
145
+ _send(socket, { ok: true, variables });
146
+ break;
147
+ }
148
+ case 'expand': {
149
+ const children = await session.getVariableChildren(req.id, req.meta || {});
150
+ _send(socket, { ok: true, variables: children });
151
+ break;
152
+ }
153
+ case 'stack': {
154
+ const frames = await session.getStack();
155
+ _send(socket, { ok: true, frames });
156
+ break;
157
+ }
158
+ case 'terminate': {
159
+ await session.terminate();
160
+ _send(socket, { ok: true, terminated: true });
161
+ // Flush response before exiting — wait for client to close the socket
162
+ socket.end(() => cleanupAndExit(0));
163
+ break;
164
+ }
165
+ default: {
166
+ _send(socket, { ok: false, error: `Unknown command: ${req.cmd}` });
167
+ }
168
+ }
169
+ } catch (err) {
170
+ _send(socket, {
171
+ ok: false,
172
+ error: err.message || JSON.stringify(err),
173
+ statusCode: err.statusCode
174
+ });
175
+ }
176
+ }
177
+
178
+ function _send(socket, obj) {
179
+ try {
180
+ socket.write(JSON.stringify(obj) + '\n');
181
+ } catch (e) {
182
+ // Client disconnected — ignore
183
+ }
184
+ }
185
+
186
+ // ─── Entry point when run as standalone daemon process ────────────────────────
187
+
188
+ if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
189
+ const config = JSON.parse(process.env.DEBUG_DAEMON_CONFIG || '{}');
190
+ const sessionId = process.env.DEBUG_DAEMON_SESSION_ID || '';
191
+ const socketPath = process.env.DEBUG_DAEMON_SOCK_PATH || '';
192
+ const snapshot = process.env.DEBUG_DAEMON_SESSION_SNAPSHOT
193
+ ? JSON.parse(process.env.DEBUG_DAEMON_SESSION_SNAPSHOT)
194
+ : null;
195
+
196
+ if (!config.host || !sessionId || !socketPath) {
197
+ process.stderr.write('[debug-daemon] missing required env vars\n');
198
+ process.exit(1);
199
+ }
200
+
201
+ startDaemon(config, sessionId, socketPath, snapshot).catch((err) => {
202
+ process.stderr.write(`[debug-daemon] startup error: ${err.message}\n`);
203
+ process.exit(1);
204
+ });
205
+ }
206
+
207
+ module.exports = { startDaemon, _handleLine };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared display helpers for the debug command and REPL.
5
+ */
6
+
7
+ /**
8
+ * Format and print a variable list with dynamic column widths.
9
+ *
10
+ * Name display:
11
+ * {DATAAGING_TEMPERATURE_DEFAULT} → DATAAGING_TEMPERATURE_DEFAULT (strip braces)
12
+ *
13
+ * Value display:
14
+ * {O:N*\CLASS=FOO} → (FOO) object reference
15
+ * {O:INITIAL} → (null) null object reference
16
+ * {A:N*\CLASS=FOO\TYPE=BAR} → (ref: FOO) data reference
17
+ * table → [N rows] — use 'x NAME' to expand
18
+ * long string → truncated to 100 chars with …
19
+ *
20
+ * @param {Array} variables - [{ name, type, value, metaType, tableLines }]
21
+ */
22
+ function printVarList(variables) {
23
+ if (variables.length === 0) {
24
+ console.log('\n No variables at current position.');
25
+ return;
26
+ }
27
+
28
+ // Build display rows first so we can measure column widths.
29
+ const rows = variables.map(({ name, type, value, metaType, tableLines }) => {
30
+ const dispName = (name.startsWith('{') && name.endsWith('}')
31
+ ? name.slice(1, -1)
32
+ : name).toLowerCase();
33
+ const dispType = (type || '').toLowerCase();
34
+
35
+ let dispValue;
36
+ if (metaType === 'table') {
37
+ dispValue = `[${tableLines} rows] — use 'x ${dispName}' to expand`;
38
+ } else if (typeof value === 'string' && value.startsWith('{O:INITIAL}')) {
39
+ dispValue = '(null)';
40
+ } else if (typeof value === 'string' && value.startsWith('{O:')) {
41
+ const classMatch = value.match(/\\CLASS=([A-Z0-9_]+)/i);
42
+ dispValue = classMatch ? `(${classMatch[1].toLowerCase()})` : value;
43
+ } else if (typeof value === 'string' && value.startsWith('{A:')) {
44
+ const classMatch = value.match(/\\CLASS=([A-Z0-9_]+)/i);
45
+ dispValue = classMatch ? `(ref: ${classMatch[1].toLowerCase()})` : '(ref)';
46
+ } else {
47
+ const str = String(value || '');
48
+ dispValue = str.length > 100 ? str.slice(0, 100) + '…' : str;
49
+ }
50
+
51
+ return { dispName, dispType, dispValue };
52
+ });
53
+
54
+ const nameW = Math.min(40, Math.max(4, ...rows.map(r => r.dispName.length)));
55
+ const typeW = Math.min(30, Math.max(4, ...rows.map(r => r.dispType.length)));
56
+
57
+ console.log('\n Variables:\n');
58
+ console.log(' ' + 'Name'.padEnd(nameW + 2) + 'Type'.padEnd(typeW + 2) + 'Value');
59
+ console.log(' ' + '-'.repeat(nameW + typeW + 20));
60
+ rows.forEach(({ dispName, dispType, dispValue }) => {
61
+ const nameCol = dispName.length > nameW
62
+ ? dispName.slice(0, nameW - 1) + '…'
63
+ : dispName.padEnd(nameW + 2);
64
+ console.log(' ' + nameCol + dispType.padEnd(typeW + 2) + dispValue);
65
+ });
66
+ console.log('');
67
+ }
68
+
69
+ module.exports = { printVarList };
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Interactive readline REPL for human debug mode.
5
+ * Entered when `debug attach` is called without --json.
6
+ */
7
+ const readline = require('readline');
8
+ const { printVarList } = require('./debug-render');
9
+
10
+ const HELP = `
11
+ Commands:
12
+ s / step — Step into
13
+ n / next — Step over
14
+ o / out — Step out
15
+ c / continue — Continue execution
16
+ v / vars — Show variables
17
+ x / expand <var> — Drill into a complex variable (table / structure)
18
+ bt / stack — Show call stack
19
+ q / quit — Detach debugger (program continues running)
20
+ kill — Terminate the running program (hard abort)
21
+ h / help — Show this help
22
+ `;
23
+
24
+ /**
25
+ * Render the current debugger state to the terminal.
26
+ * @param {object} position - { class, method, include, line, ... }
27
+ * @param {Array} source - [{ lineNumber, text, current }]
28
+ * @param {Array} variables - [{ name, type, value }]
29
+ */
30
+ function renderState(position, source, variables) {
31
+ const where = position.class
32
+ ? `${position.class}->${position.method}`
33
+ : (position.method || position.program || '?');
34
+ const lineRef = position.line ? ` (line ${position.line})` : '';
35
+
36
+ console.log(`\n ABAP Debugger — ${where}${lineRef}`);
37
+ console.log(' ' + '─'.repeat(55));
38
+
39
+ if (source && source.length > 0) {
40
+ source.forEach(({ lineNumber, text, current }) => {
41
+ const marker = current ? '>' : ' ';
42
+ console.log(` ${marker}${String(lineNumber).padStart(4)} ${text}`);
43
+ });
44
+ }
45
+
46
+ if (variables && variables.length > 0) {
47
+ printVarList(variables);
48
+ }
49
+
50
+ console.log('\n [s]tep [n]ext [o]ut [c]ontinue [v]ars [x]pand [bt] [q]uit(detach) kill');
51
+ }
52
+
53
+ /**
54
+ * Start the interactive REPL.
55
+ * @param {import('./debug-session').DebugSession} session
56
+ * @param {{ position: object, source: string[] }} initialState
57
+ * @param {Function} [onBeforeExit] Optional async cleanup called before process.exit(0)
58
+ */
59
+ async function startRepl(session, initialState, onBeforeExit) {
60
+ let { position, source } = initialState;
61
+ let variables = [];
62
+ let exitCleanupDone = false;
63
+ async function runExitCleanup() {
64
+ if (exitCleanupDone) return;
65
+ exitCleanupDone = true;
66
+ if (onBeforeExit) {
67
+ try { await onBeforeExit(); } catch (e) { /* ignore */ }
68
+ }
69
+ }
70
+
71
+ try {
72
+ variables = await session.getVariables();
73
+ } catch (e) {
74
+ // Best-effort
75
+ }
76
+
77
+ renderState(position, source, variables);
78
+
79
+ const rl = readline.createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout,
82
+ prompt: '\n debug> '
83
+ });
84
+
85
+ rl.prompt();
86
+
87
+ /**
88
+ * Returns true when the step result contains a meaningful program position.
89
+ * After `continue` (or a step that runs off the end of a method), ADT returns
90
+ * an empty stack, leaving position.class / .method / .program all undefined.
91
+ * That signals the debuggee has completed — no further stepping is possible.
92
+ */
93
+ function _hasPosition(pos) {
94
+ return pos && (pos.class || pos.method || pos.program);
95
+ }
96
+
97
+ /**
98
+ * Common handler for step results.
99
+ * Returns true if the session ended (caller should close the REPL).
100
+ */
101
+ async function _handleStepResult(result) {
102
+ position = result.position;
103
+ source = result.source;
104
+ if (!_hasPosition(position)) {
105
+ console.log('\n Execution completed — no active breakpoint. Debug session ended.\n');
106
+ // Program already finished — nothing to terminate or detach; just close.
107
+ rl.close();
108
+ return true;
109
+ }
110
+ variables = await session.getVariables().catch(() => []);
111
+ renderState(position, source, variables);
112
+ return false;
113
+ }
114
+
115
+ rl.on('line', async (line) => {
116
+ const cmd = line.trim().toLowerCase();
117
+
118
+ try {
119
+ if (cmd === 's' || cmd === 'step') {
120
+ if (await _handleStepResult(await session.step('stepInto'))) return;
121
+
122
+ } else if (cmd === 'n' || cmd === 'next') {
123
+ if (await _handleStepResult(await session.step('stepOver'))) return;
124
+
125
+ } else if (cmd === 'o' || cmd === 'out') {
126
+ if (await _handleStepResult(await session.step('stepOut'))) return;
127
+
128
+ } else if (cmd === 'c' || cmd === 'continue') {
129
+ console.log('\n Continuing execution...');
130
+ if (await _handleStepResult(await session.step('continue'))) return;
131
+
132
+ } else if (cmd === 'v' || cmd === 'vars') {
133
+ variables = await session.getVariables();
134
+ printVarList(variables);
135
+
136
+ } else if (cmd.startsWith('x ') || cmd.startsWith('expand ')) {
137
+ const varExpr = cmd.replace(/^(x|expand)\s+/i, '').trim().toUpperCase();
138
+ if (!varExpr) {
139
+ console.log(' Usage: x <variable-name> or x <parent>-><child>');
140
+ } else {
141
+ // Normalize ABAP-style field accessors to use -> separator.
142
+ const normalizedExpr = varExpr
143
+ .replace(/\](-(?!>))/g, ']->') // [N]-FIELD → [N]->FIELD
144
+ .replace(/\*(-(?!>))/g, '*->'); // *-FIELD → *->FIELD
145
+ const pathParts = normalizedExpr.split('->').map(s => s.replace(/^-+|-+$/g, '').trim()).filter(Boolean);
146
+
147
+ if (pathParts.length > 1) {
148
+ // Multi-segment path — use session.expandPath
149
+ try {
150
+ const { variable: target, children } = await session.expandPath(pathParts);
151
+ _renderChildren(varExpr, target, children);
152
+ } catch (err) {
153
+ console.log(`\n Error: ${err.message}`);
154
+ }
155
+ } else {
156
+ // Single segment — find in last-fetched vars list
157
+ const target = variables.find(v => v.name.toUpperCase() === varExpr);
158
+ if (!target) {
159
+ console.log(`\n Variable '${varExpr}' not found. Run 'v' to list variables.`);
160
+ } else if (!target.id) {
161
+ console.log(`\n Variable '${varExpr}' has no ADT ID — cannot expand.`);
162
+ } else {
163
+ const meta = { metaType: target.metaType || '', tableLines: target.tableLines || 0 };
164
+ const children = await session.getVariableChildren(target.id, meta);
165
+ _renderChildren(varExpr, target, children);
166
+ }
167
+ }
168
+ }
169
+
170
+
171
+ } else if (cmd === 'bt' || cmd === 'stack') {
172
+ const frames = await session.getStack();
173
+ console.log('\n Call Stack:');
174
+ frames.forEach(({ frame, class: cls, method, line }) => {
175
+ const loc = cls ? `${cls}->${method}` : method;
176
+ console.log(` ${String(frame).padStart(3)} ${loc} (line ${line})`);
177
+ });
178
+
179
+ } else if (cmd === 'q' || cmd === 'quit') {
180
+ console.log('\n Detaching debugger — program will continue running...');
181
+ try {
182
+ await session.detach();
183
+ } catch (e) {
184
+ // Ignore detach errors
185
+ }
186
+ // Clear state AFTER detach so session 2 (takeover) exits via state-file check.
187
+ await runExitCleanup();
188
+ rl.close();
189
+ return;
190
+
191
+ } else if (cmd === 'kill') {
192
+ console.log('\n Terminating program (hard abort)...');
193
+ try {
194
+ await session.terminate();
195
+ } catch (e) {
196
+ // Ignore terminate errors
197
+ }
198
+ await runExitCleanup();
199
+ rl.close();
200
+ return;
201
+
202
+ } else if (cmd === 'h' || cmd === 'help' || cmd === '?') {
203
+ console.log(HELP);
204
+
205
+ } else if (cmd !== '') {
206
+ console.log(` Unknown command: ${cmd}. Type 'h' for help.`);
207
+ }
208
+ } catch (err) {
209
+ console.error(`\n Error: ${err.message || JSON.stringify(err)}`);
210
+ }
211
+
212
+ rl.prompt();
213
+ });
214
+
215
+ rl.on('close', async () => {
216
+ await runExitCleanup();
217
+ process.exit(0);
218
+ });
219
+
220
+ return new Promise((resolve) => {
221
+ rl.on('close', resolve);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Render the children of an expand operation.
227
+ * Tables show a row-count hint and a path hint for further expansion.
228
+ *
229
+ * @param {string} expr - The expression the user typed (e.g. 'LO_FACTORY->MT_COMMAND_MAP')
230
+ * @param {object} variable - The expanded variable metadata
231
+ * @param {Array} children - The children returned by getVariableChildren / expandPath
232
+ */
233
+ function _renderChildren(expr, variable, children) {
234
+ if (children.length === 0) {
235
+ console.log(`\n ${expr} — no children (scalar or empty).`);
236
+ return;
237
+ }
238
+
239
+ // Compute column widths from actual data (min 4/4, capped at 50/25).
240
+ const nameW = Math.min(50, Math.max(4, ...children.map(c => (c.name || '').length)));
241
+ const typeW = Math.min(25, Math.max(4, ...children.map(c => (c.type || c.metaType || '').length)));
242
+
243
+ console.log(`\n ${expr} (${(variable.type || variable.metaType || '?').toLowerCase()}):`);
244
+ console.log(' ' + 'Name'.padEnd(nameW + 2) + 'Type'.padEnd(typeW + 2) + 'Value');
245
+ console.log(' ' + '-'.repeat(nameW + typeW + 24));
246
+ children.forEach(({ name, type, value, metaType, tableLines }) => {
247
+ const displayType = (type || metaType || '').toLowerCase().slice(0, typeW);
248
+ const displayName = name.toLowerCase();
249
+ const displayValue = metaType === 'table'
250
+ ? `[${tableLines} rows] — use 'x ${expr}->${displayName}' to expand`
251
+ : value;
252
+ console.log(' ' + displayName.padEnd(nameW + 2) + displayType.padEnd(typeW + 2) + displayValue);
253
+ });
254
+ }
255
+
256
+ module.exports = { startRepl, renderState };