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.
@@ -29,12 +29,14 @@ module.exports = {
29
29
  const branchArgIndex = args.indexOf('--branch');
30
30
  const filesArgIndex = args.indexOf('--files');
31
31
  const transportArgIndex = args.indexOf('--transport');
32
+ const conflictModeArgIndex = args.indexOf('--conflict-mode');
32
33
  const jsonOutput = args.includes('--json');
33
34
 
34
35
  // Auto-detect git remote URL if not provided
35
36
  let gitUrl = urlArgIndex !== -1 ? args[urlArgIndex + 1] : null;
36
37
  let branch = branchArgIndex !== -1 ? args[branchArgIndex + 1] : gitUtils.getBranch();
37
38
  let files = null;
39
+ let conflictMode = conflictModeArgIndex !== -1 ? args[conflictModeArgIndex + 1] : 'abort';
38
40
 
39
41
  // Transport: CLI arg takes priority, then config/environment, then null
40
42
  let transportRequest = null;
@@ -74,10 +76,10 @@ module.exports = {
74
76
  }
75
77
  }
76
78
 
77
- await this.pull(gitUrl, branch, files, transportRequest, loadConfig, AbapHttp, jsonOutput);
79
+ await this.pull(gitUrl, branch, files, transportRequest, loadConfig, AbapHttp, jsonOutput, undefined, conflictMode);
78
80
  },
79
81
 
80
- async pull(gitUrl, branch = 'main', files = null, transportRequest = null, loadConfig, AbapHttp, jsonOutput = false, gitCredentials = undefined) {
82
+ async pull(gitUrl, branch = 'main', files = null, transportRequest = null, loadConfig, AbapHttp, jsonOutput = false, gitCredentials = undefined, conflictMode = 'abort') {
81
83
  const TERM_WIDTH = process.stdout.columns || 80;
82
84
 
83
85
  if (!jsonOutput) {
@@ -107,6 +109,7 @@ module.exports = {
107
109
  const data = {
108
110
  url: gitUrl,
109
111
  branch: branch,
112
+ conflict_mode: conflictMode,
110
113
  ...(resolvedCredentials ? { username: resolvedCredentials.username, password: resolvedCredentials.password } : {})
111
114
  };
112
115
 
@@ -140,12 +143,23 @@ module.exports = {
140
143
  const jobId = result.JOB_ID || result.job_id;
141
144
  const message = result.MESSAGE || result.message;
142
145
  const errorDetail = result.ERROR_DETAIL || result.error_detail;
143
- const transportRequestUsed = result.TRANSPORT_REQUEST || result.transport_request;
144
146
  const activatedCount = result.ACTIVATED_COUNT || result.activated_count || 0;
145
147
  const failedCount = result.FAILED_COUNT || result.failed_count || 0;
146
148
  const logMessages = result.LOG_MESSAGES || result.log_messages || [];
147
149
  const activatedObjects = result.ACTIVATED_OBJECTS || result.activated_objects || [];
148
150
  const failedObjects = result.FAILED_OBJECTS || result.failed_objects || [];
151
+ const conflictReport = result.CONFLICT_REPORT || result.conflict_report || '';
152
+ const conflictCount = result.CONFLICT_COUNT || result.conflict_count || 0;
153
+
154
+ // --- Conflict report (pull was aborted) ---
155
+ if (conflictCount > 0 && conflictReport) {
156
+ console.error(`⚠️ Pull aborted — ${conflictCount} conflict(s) detected\n`);
157
+ console.error('─'.repeat(TERM_WIDTH));
158
+ console.error(conflictReport.replace(/\\n/g, '\n'));
159
+ const err = new Error(message || `Pull aborted — ${conflictCount} conflict(s) detected`);
160
+ err._isPullError = true;
161
+ throw err;
162
+ }
149
163
 
150
164
  // Icon mapping for message types
151
165
  const getIcon = (type) => {
@@ -0,0 +1,369 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ADT HTTP client for SAP ABAP Development Tools REST API
5
+ * Handles XML/AtomPub content, CSRF token, cookie session caching.
6
+ */
7
+ const https = require('https');
8
+ const http = require('http');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const crypto = require('crypto');
13
+
14
+ /**
15
+ * ADT HTTP client with CSRF token, cookie, and session caching.
16
+ * Mirrors AbapHttp but targets /sap/bc/adt/* with XML content-type.
17
+ */
18
+ class AdtHttp {
19
+ constructor(config) {
20
+ this.config = config;
21
+ this.csrfToken = null;
22
+ this.cookies = null;
23
+
24
+ const configHash = crypto.createHash('md5')
25
+ .update(`${config.host}:${config.user}:${config.client}`)
26
+ .digest('hex')
27
+ .substring(0, 8);
28
+
29
+ this.sessionFile = path.join(os.tmpdir(), `abapgit-adt-session-${configHash}.json`);
30
+ this.loadSession();
31
+ }
32
+
33
+ loadSession() {
34
+ if (!fs.existsSync(this.sessionFile)) return;
35
+ try {
36
+ const session = JSON.parse(fs.readFileSync(this.sessionFile, 'utf8'));
37
+ const safetyMargin = 2 * 60 * 1000;
38
+ if (session.expiresAt > Date.now() + safetyMargin) {
39
+ this.csrfToken = session.csrfToken;
40
+ this.cookies = session.cookies;
41
+ } else {
42
+ this.clearSession();
43
+ }
44
+ } catch (e) {
45
+ this.clearSession();
46
+ }
47
+ }
48
+
49
+ saveSession() {
50
+ const expiresAt = Date.now() + (15 * 60 * 1000);
51
+ try {
52
+ fs.writeFileSync(this.sessionFile, JSON.stringify({
53
+ csrfToken: this.csrfToken,
54
+ cookies: this.cookies,
55
+ expiresAt,
56
+ savedAt: Date.now()
57
+ }));
58
+ } catch (e) {
59
+ // Ignore write errors
60
+ }
61
+ }
62
+
63
+ clearSession() {
64
+ this.csrfToken = null;
65
+ this.cookies = null;
66
+ try {
67
+ if (fs.existsSync(this.sessionFile)) fs.unlinkSync(this.sessionFile);
68
+ } catch (e) {
69
+ // Ignore deletion errors
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Fetch CSRF token via GET /sap/bc/adt/discovery with X-CSRF-Token: fetch
75
+ */
76
+ async fetchCsrfToken() {
77
+ return new Promise((resolve, reject) => {
78
+ const url = new URL('/sap/bc/adt/discovery', `https://${this.config.host}:${this.config.sapport}`);
79
+ const options = {
80
+ hostname: url.hostname,
81
+ port: url.port,
82
+ path: url.pathname,
83
+ method: 'GET',
84
+ headers: {
85
+ 'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
86
+ 'sap-client': this.config.client,
87
+ 'sap-language': this.config.language || 'EN',
88
+ 'X-CSRF-Token': 'fetch',
89
+ 'Accept': 'application/atomsvc+xml'
90
+ },
91
+ agent: new https.Agent({ rejectUnauthorized: false })
92
+ };
93
+
94
+ const req = https.request(options, (res) => {
95
+ const csrfToken = res.headers['x-csrf-token'];
96
+ const setCookie = res.headers['set-cookie'];
97
+ if (setCookie) {
98
+ this.cookies = Array.isArray(setCookie)
99
+ ? setCookie.map(c => c.split(';')[0]).join('; ')
100
+ : setCookie.split(';')[0];
101
+ }
102
+
103
+ let body = '';
104
+ res.on('data', chunk => body += chunk);
105
+ res.on('end', () => {
106
+ this.csrfToken = csrfToken;
107
+ this.saveSession();
108
+ resolve(csrfToken);
109
+ });
110
+ });
111
+
112
+ req.on('error', reject);
113
+ req.end();
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Make HTTP request to ADT endpoint with automatic retry on auth failure.
119
+ * Returns { body: string, headers: object, statusCode: number }.
120
+ */
121
+ async request(method, urlPath, body = null, options = {}) {
122
+ try {
123
+ return await this._makeRequest(method, urlPath, body, options);
124
+ } catch (error) {
125
+ if (this._isAuthError(error) && !options.isRetry) {
126
+ this.clearSession();
127
+ await this.fetchCsrfToken();
128
+ return await this._makeRequest(method, urlPath, body, { ...options, isRetry: true });
129
+ }
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ _isAuthError(error) {
135
+ if (error.statusCode === 401) return true;
136
+ if (error.statusCode === 403) return true;
137
+ const msg = (error.message || '').toLowerCase();
138
+ return msg.includes('csrf') || msg.includes('unauthorized') || msg.includes('forbidden');
139
+ }
140
+
141
+ async _makeRequest(method, urlPath, body = null, options = {}) {
142
+ return new Promise((resolve, reject) => {
143
+ const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
144
+
145
+ const headers = {
146
+ 'Content-Type': options.contentType || 'application/atom+xml',
147
+ 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
148
+ 'sap-client': this.config.client,
149
+ 'sap-language': this.config.language || 'EN',
150
+ ...options.headers
151
+ };
152
+
153
+ headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
154
+
155
+ if (['POST', 'PUT', 'DELETE'].includes(method) && this.csrfToken) {
156
+ headers['X-CSRF-Token'] = this.csrfToken;
157
+ }
158
+
159
+ if (this.cookies) {
160
+ headers['Cookie'] = this.cookies;
161
+ }
162
+
163
+ const bodyStr = body || '';
164
+ headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
165
+
166
+ const reqOptions = {
167
+ hostname: url.hostname,
168
+ port: url.port,
169
+ path: url.pathname + url.search,
170
+ method,
171
+ headers,
172
+ agent: new https.Agent({ rejectUnauthorized: false })
173
+ };
174
+
175
+ const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (res) => {
176
+ if (res.statusCode === 401) {
177
+ reject({ statusCode: 401, message: 'Authentication failed: 401' });
178
+ return;
179
+ }
180
+ if (res.statusCode === 403) {
181
+ const errMsg = 'Missing debug authorization. Grant S_ADT_RES (ACTVT=16) to user.';
182
+ reject({ statusCode: 403, message: errMsg });
183
+ return;
184
+ }
185
+ if (res.statusCode === 404) {
186
+ let respBody = '';
187
+ res.on('data', chunk => respBody += chunk);
188
+ res.on('end', () => {
189
+ reject({ statusCode: 404, message: `HTTP 404: ${reqOptions.path}`, body: respBody });
190
+ });
191
+ return;
192
+ }
193
+ if (res.statusCode >= 400) {
194
+ let respBody = '';
195
+ res.on('data', chunk => respBody += chunk);
196
+ res.on('end', () => {
197
+ reject({ statusCode: res.statusCode, message: `HTTP ${res.statusCode} error`, body: respBody });
198
+ });
199
+ return;
200
+ }
201
+
202
+ // Update cookies from any response that sets them.
203
+ // Merge by name so that updated values (e.g. SAP_SESSIONID) replace
204
+ // their old counterparts rather than accumulating duplicates.
205
+ // Duplicate SAP_SESSIONID cookies would cause the ICM to route requests
206
+ // to a stale work process ("Service cannot be reached").
207
+ if (res.headers['set-cookie']) {
208
+ const incoming = Array.isArray(res.headers['set-cookie'])
209
+ ? res.headers['set-cookie'].map(c => c.split(';')[0])
210
+ : [res.headers['set-cookie'].split(';')[0]];
211
+ // Parse existing cookies into a Map (preserves insertion order)
212
+ const jar = new Map();
213
+ if (this.cookies) {
214
+ this.cookies.split(';').forEach(pair => {
215
+ const trimmed = pair.trim();
216
+ if (trimmed) {
217
+ const eq = trimmed.indexOf('=');
218
+ const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
219
+ jar.set(k.trim(), trimmed);
220
+ }
221
+ });
222
+ }
223
+ // Overwrite with incoming cookies
224
+ incoming.forEach(pair => {
225
+ const trimmed = pair.trim();
226
+ if (trimmed) {
227
+ const eq = trimmed.indexOf('=');
228
+ const k = eq === -1 ? trimmed : trimmed.slice(0, eq);
229
+ jar.set(k.trim(), trimmed);
230
+ }
231
+ });
232
+ this.cookies = [...jar.values()].join('; ');
233
+ }
234
+
235
+ let respBody = '';
236
+ res.on('data', chunk => respBody += chunk);
237
+ res.on('end', () => {
238
+ resolve({ body: respBody, headers: res.headers, statusCode: res.statusCode });
239
+ });
240
+ });
241
+
242
+ req.on('error', reject);
243
+ if (bodyStr) req.write(bodyStr);
244
+ req.end();
245
+ });
246
+ }
247
+
248
+ async get(urlPath, options = {}) {
249
+ return this.request('GET', urlPath, null, options);
250
+ }
251
+
252
+ async post(urlPath, body = null, options = {}) {
253
+ return this.request('POST', urlPath, body, options);
254
+ }
255
+
256
+ /**
257
+ * Fire-and-forget POST: resolves when the request bytes have been flushed
258
+ * to the TCP send buffer — does NOT wait for a response.
259
+ *
260
+ * Used by detach() (stepContinue) where:
261
+ * - ADT long-polls until the next breakpoint fires → response may never come
262
+ * - We only need ADT to *receive* the request, not respond to it
263
+ * - Using the existing stateful session (cookies/CSRF) is mandatory
264
+ *
265
+ * The socket is deliberately left open so the OS TCP stack can finish
266
+ * delivering the data to ADT after we return from this method.
267
+ *
268
+ * @param {string} urlPath - URL path
269
+ * @param {string} body - Request body (may be empty string)
270
+ * @param {object} options - Same options as post() (contentType, headers, etc.)
271
+ * @returns {Promise<void>} Resolves when req.end() callback fires
272
+ */
273
+ async postFire(urlPath, body = null, options = {}) {
274
+ return new Promise((resolve, reject) => {
275
+ const url = new URL(urlPath, `https://${this.config.host}:${this.config.sapport}`);
276
+
277
+ const headers = {
278
+ 'Content-Type': options.contentType || 'application/atom+xml',
279
+ 'Accept': options.accept || 'application/vnd.sap.as+xml, application/atom+xml, application/xml',
280
+ 'sap-client': this.config.client,
281
+ 'sap-language': this.config.language || 'EN',
282
+ ...options.headers
283
+ };
284
+
285
+ headers['Authorization'] = `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`;
286
+
287
+ if (this.csrfToken) {
288
+ headers['X-CSRF-Token'] = this.csrfToken;
289
+ }
290
+
291
+ if (this.cookies) {
292
+ headers['Cookie'] = this.cookies;
293
+ }
294
+
295
+ const bodyStr = body || '';
296
+ headers['Content-Length'] = bodyStr ? Buffer.byteLength(bodyStr) : 0;
297
+
298
+ const reqOptions = {
299
+ hostname: url.hostname,
300
+ port: url.port,
301
+ path: url.pathname + url.search,
302
+ method: 'POST',
303
+ headers,
304
+ agent: new https.Agent({ rejectUnauthorized: false })
305
+ };
306
+
307
+ const req = (url.protocol === 'https:' ? https : http).request(reqOptions, (_res) => {
308
+ // Drain response body to prevent socket hang; we don't use the data.
309
+ _res.resume();
310
+ });
311
+
312
+ // Resolve as soon as the request is fully written and flushed.
313
+ req.on('error', resolve); // ignore errors — fire-and-forget
314
+ if (bodyStr) req.write(bodyStr);
315
+ req.end(() => resolve());
316
+ });
317
+ }
318
+
319
+ async put(urlPath, body = null, options = {}) {
320
+ return this.request('PUT', urlPath, body, options);
321
+ }
322
+
323
+ async delete(urlPath, options = {}) {
324
+ return this.request('DELETE', urlPath, null, options);
325
+ }
326
+
327
+ /**
328
+ * Extract an attribute value from a simple XML element using regex.
329
+ * e.g. extractXmlAttr(xml, 'adtcore:uri', null) for text content
330
+ * extractXmlAttr(xml, 'entry', 'id') for attribute
331
+ * @param {string} xml - XML string
332
+ * @param {string} tag - Tag name (may include namespace prefix)
333
+ * @param {string|null} attr - Attribute name, or null for text content
334
+ * @returns {string|null} Extracted value or null
335
+ */
336
+ static extractXmlAttr(xml, tag, attr) {
337
+ if (attr) {
338
+ const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'i');
339
+ const m = xml.match(re);
340
+ return m ? m[1] : null;
341
+ }
342
+ const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'i');
343
+ const m = xml.match(re);
344
+ return m ? m[1].trim() : null;
345
+ }
346
+
347
+ /**
348
+ * Extract all occurrences of a tag's content or attribute from XML.
349
+ * @param {string} xml - XML string
350
+ * @param {string} tag - Tag name
351
+ * @param {string|null} attr - Attribute name, or null for text content
352
+ * @returns {string[]} Array of matched values
353
+ */
354
+ static extractXmlAll(xml, tag, attr) {
355
+ const results = [];
356
+ if (attr) {
357
+ const re = new RegExp(`<${tag}[^>]*\\s${attr}="([^"]*)"`, 'gi');
358
+ let m;
359
+ while ((m = re.exec(xml)) !== null) results.push(m[1]);
360
+ } else {
361
+ const re = new RegExp(`<${tag}[^>]*>([^<]*)<\/${tag}>`, 'gi');
362
+ let m;
363
+ while ((m = re.exec(xml)) !== null) results.push(m[1].trim());
364
+ }
365
+ return results;
366
+ }
367
+ }
368
+
369
+ module.exports = { AdtHttp };
@@ -0,0 +1,208 @@
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
+ body: err.body || null
175
+ });
176
+ }
177
+ }
178
+
179
+ function _send(socket, obj) {
180
+ try {
181
+ socket.write(JSON.stringify(obj) + '\n');
182
+ } catch (e) {
183
+ // Client disconnected — ignore
184
+ }
185
+ }
186
+
187
+ // ─── Entry point when run as standalone daemon process ────────────────────────
188
+
189
+ if (require.main === module || process.env.DEBUG_DAEMON_MODE === '1') {
190
+ const config = JSON.parse(process.env.DEBUG_DAEMON_CONFIG || '{}');
191
+ const sessionId = process.env.DEBUG_DAEMON_SESSION_ID || '';
192
+ const socketPath = process.env.DEBUG_DAEMON_SOCK_PATH || '';
193
+ const snapshot = process.env.DEBUG_DAEMON_SESSION_SNAPSHOT
194
+ ? JSON.parse(process.env.DEBUG_DAEMON_SESSION_SNAPSHOT)
195
+ : null;
196
+
197
+ if (!config.host || !sessionId || !socketPath) {
198
+ process.stderr.write('[debug-daemon] missing required env vars\n');
199
+ process.exit(1);
200
+ }
201
+
202
+ startDaemon(config, sessionId, socketPath, snapshot).catch((err) => {
203
+ process.stderr.write(`[debug-daemon] startup error: ${err.message}\n`);
204
+ process.exit(1);
205
+ });
206
+ }
207
+
208
+ module.exports = { startDaemon, _handleLine };