abapgit-agent 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Transport command - List and manage SAP transport requests
3
+ */
4
+
5
+ const VALID_SCOPES = ['mine', 'task', 'all'];
6
+ const VALID_SUBCOMMANDS = ['list', 'create', 'check', 'release'];
7
+ const VALID_TYPES = ['workbench', 'customizing'];
8
+
9
+ module.exports = {
10
+ name: 'transport',
11
+ description: 'List and manage SAP transport requests',
12
+ requiresAbapConfig: true,
13
+ requiresVersionCheck: false,
14
+
15
+ async execute(args, context) {
16
+ const { loadConfig, AbapHttp, getTransportSettings } = context;
17
+
18
+ const jsonOutput = args.includes('--json');
19
+
20
+ // Determine subcommand (first positional arg, default to 'list')
21
+ const subcommand = args[0] && !args[0].startsWith('-') ? args[0] : 'list';
22
+
23
+ if (!VALID_SUBCOMMANDS.includes(subcommand)) {
24
+ console.error(`❌ Error: Unknown subcommand '${subcommand}'. Use: ${VALID_SUBCOMMANDS.join(', ')}`);
25
+ process.exit(1);
26
+ }
27
+
28
+ // Check project-level transport settings
29
+ const transportSettings = getTransportSettings();
30
+
31
+ if (subcommand === 'create' && !transportSettings.allowCreate) {
32
+ console.error(`❌ transport create is disabled for this project.`);
33
+ if (transportSettings.reason) console.error(` Reason: ${transportSettings.reason}`);
34
+ console.error(` This safeguard is configured in .abapgit-agent.json`);
35
+ const err = new Error('transport create disabled');
36
+ err._isTransportError = true;
37
+ throw err;
38
+ }
39
+
40
+ if (subcommand === 'release' && !transportSettings.allowRelease) {
41
+ console.error(`❌ transport release is disabled for this project.`);
42
+ if (transportSettings.reason) console.error(` Reason: ${transportSettings.reason}`);
43
+ console.error(` This safeguard is configured in .abapgit-agent.json`);
44
+ const err = new Error('transport release disabled');
45
+ err._isTransportError = true;
46
+ throw err;
47
+ }
48
+
49
+ const config = loadConfig();
50
+ const http = new AbapHttp(config);
51
+
52
+ try {
53
+ switch (subcommand) {
54
+ case 'list':
55
+ await this._list(args, http, jsonOutput);
56
+ break;
57
+ case 'create':
58
+ await this._create(args, http, jsonOutput);
59
+ break;
60
+ case 'check':
61
+ await this._check(args, http, jsonOutput);
62
+ break;
63
+ case 'release':
64
+ await this._release(args, http, jsonOutput);
65
+ break;
66
+ }
67
+ } catch (error) {
68
+ console.error(`❌ Error: ${error.message}`);
69
+ process.exit(1);
70
+ }
71
+ },
72
+
73
+ async _list(args, http, jsonOutput) {
74
+ const scopeIdx = args.indexOf('--scope');
75
+ const scope = scopeIdx !== -1 ? args[scopeIdx + 1] : 'mine';
76
+
77
+ if (!VALID_SCOPES.includes(scope)) {
78
+ console.error(`❌ Error: Invalid scope '${scope}'. Valid values: ${VALID_SCOPES.join(', ')}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ const result = await http.get(`/sap/bc/z_abapgit_agent/transport?scope=${scope}`);
83
+
84
+ if (jsonOutput) {
85
+ console.log(JSON.stringify(result, null, 2));
86
+ return;
87
+ }
88
+
89
+ const transports = result.TRANSPORTS || result.transports || [];
90
+ const scopeLabel = { mine: 'mine', task: 'task — where I own or have a task', all: 'all' }[scope];
91
+
92
+ console.log(`\n📋 Open Transport Requests (${scopeLabel})\n`);
93
+
94
+ if (transports.length === 0) {
95
+ console.log(' No open transport requests found.');
96
+ console.log('');
97
+ console.log(' To create one: abapgit-agent transport create');
98
+ console.log(' To see more: abapgit-agent transport list --scope task');
99
+ return;
100
+ }
101
+
102
+ // Table header
103
+ const numW = 2;
104
+ const trW = 12;
105
+ const descW = 33;
106
+ const ownerW = 12;
107
+ const dateW = 10;
108
+
109
+ const pad = (s, w) => String(s || '').substring(0, w).padEnd(w);
110
+ const header = ` ${'#'.padEnd(numW)} ${pad('Number', trW)} ${pad('Description', descW)} ${pad('Owner', ownerW)} ${'Date'}`;
111
+ const divider = ` ${'─'.repeat(numW)} ${'─'.repeat(trW)} ${'─'.repeat(descW)} ${'─'.repeat(ownerW)} ${'─'.repeat(dateW)}`;
112
+
113
+ console.log(header);
114
+ console.log(divider);
115
+
116
+ transports.forEach((t, i) => {
117
+ const num = t.NUMBER || t.number || '';
118
+ const desc = t.DESCRIPTION || t.description || '';
119
+ const owner = t.OWNER || t.owner || '';
120
+ const date = t.DATE || t.date || '';
121
+ console.log(` ${String(i + 1).padEnd(numW)} ${pad(num, trW)} ${pad(desc, descW)} ${pad(owner, ownerW)} ${date}`);
122
+ });
123
+
124
+ console.log('');
125
+ console.log(` ${transports.length} transport(s) found.`);
126
+ console.log('');
127
+ console.log(` To use one: abapgit-agent pull --transport ${(transports[0].NUMBER || transports[0].number || 'DEVK900001')}`);
128
+
129
+ if (scope === 'mine') {
130
+ console.log(' To switch scope:');
131
+ console.log(' transport list --scope task transports where I have a task');
132
+ console.log(' transport list --scope all all open transports');
133
+ }
134
+ },
135
+
136
+ async _create(args, http, jsonOutput) {
137
+ const descIdx = args.indexOf('--description');
138
+ let description = descIdx !== -1 ? args[descIdx + 1] : null;
139
+
140
+ const typeIdx = args.indexOf('--type');
141
+ const type = typeIdx !== -1 ? args[typeIdx + 1] : 'workbench';
142
+
143
+ if (!VALID_TYPES.includes(type)) {
144
+ console.error(`❌ Error: Invalid type '${type}'. Valid values: ${VALID_TYPES.join(', ')}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ // Prompt for description if not provided and running in TTY
149
+ if (!description && !jsonOutput && process.stdin.isTTY) {
150
+ description = await this._prompt('Description: ');
151
+ }
152
+
153
+ const csrfToken = await http.fetchCsrfToken();
154
+ const result = await http.post(
155
+ '/sap/bc/z_abapgit_agent/transport',
156
+ { action: 'CREATE', description: description || '', type },
157
+ { csrfToken }
158
+ );
159
+
160
+ if (jsonOutput) {
161
+ console.log(JSON.stringify(result, null, 2));
162
+ return;
163
+ }
164
+
165
+ const number = result.NUMBER || result.number;
166
+ const success = result.SUCCESS === true || result.success === true ||
167
+ result.SUCCESS === 'X' || result.success === 'X';
168
+ const typeLabel = type === 'customizing' ? 'Customizing' : 'Workbench';
169
+
170
+ if (success && number) {
171
+ console.log(`\n✅ Transport ${number} created (${typeLabel} request).\n`);
172
+ console.log(` To use it now: abapgit-agent pull --transport ${number}`);
173
+ } else {
174
+ const error = result.ERROR || result.error || result.MESSAGE || result.message || 'Could not create transport request';
175
+ console.error(`❌ Error: ${error}`);
176
+ process.exit(1);
177
+ }
178
+ },
179
+
180
+ async _check(args, http, jsonOutput) {
181
+ const numIdx = args.indexOf('--number');
182
+ if (numIdx === -1 || !args[numIdx + 1]) {
183
+ console.error('❌ Error: --number is required for transport check');
184
+ process.exit(1);
185
+ }
186
+ const number = args[numIdx + 1];
187
+
188
+ const csrfToken = await http.fetchCsrfToken();
189
+ const result = await http.post(
190
+ '/sap/bc/z_abapgit_agent/transport',
191
+ { action: 'CHECK', number },
192
+ { csrfToken }
193
+ );
194
+
195
+ if (jsonOutput) {
196
+ console.log(JSON.stringify(result, null, 2));
197
+ return;
198
+ }
199
+
200
+ const passed = result.PASSED === true || result.passed === true ||
201
+ result.PASSED === 'X' || result.passed === 'X';
202
+ const issues = result.ISSUES || result.issues || [];
203
+ const desc = result.DESCRIPTION || result.description || '';
204
+ const owner = result.OWNER || result.owner || '';
205
+ const date = result.DATE || result.date || '';
206
+
207
+ console.log(`\n🔍 Checking transport ${number}...`);
208
+ if (desc) console.log(`\n Description: ${desc}`);
209
+ if (owner) console.log(` Owner: ${owner}`);
210
+ if (date) console.log(` Date: ${date}`);
211
+
212
+ if (passed || issues.length === 0) {
213
+ console.log(`\n✅ Transport check passed — no issues found.`);
214
+ console.log(` Ready to release: abapgit-agent transport release --number ${number}`);
215
+ } else {
216
+ console.log(`\n⚠️ Transport check completed with warnings/errors:\n`);
217
+
218
+ const typeW = 6;
219
+ const objW = 21;
220
+ const msgW = 44;
221
+ const pad = (s, w) => String(s || '').substring(0, w).padEnd(w);
222
+
223
+ console.log(` ${'Type'.padEnd(typeW)} ${'Object'.padEnd(objW)} Message`);
224
+ console.log(` ${'─'.repeat(typeW)} ${'─'.repeat(objW)} ${'─'.repeat(msgW)}`);
225
+
226
+ for (const issue of issues) {
227
+ const type = issue.TYPE || issue.type || '';
228
+ const objType = issue.OBJ_TYPE || issue.obj_type || '';
229
+ const objName = issue.OBJ_NAME || issue.obj_name || '';
230
+ const text = issue.TEXT || issue.text || '';
231
+ const icon = type === 'E' ? '❌' : type === 'W' ? '⚠️ ' : 'ℹ️ ';
232
+ const obj = objType && objName ? `${objType} ${objName}` : objType || objName;
233
+ console.log(` ${icon.padEnd(typeW)} ${pad(obj, objW)} ${text}`);
234
+ }
235
+
236
+ console.log(`\n ${issues.length} issue(s) found. Fix before releasing.`);
237
+ }
238
+ },
239
+
240
+ async _release(args, http, jsonOutput) {
241
+ const numIdx = args.indexOf('--number');
242
+ if (numIdx === -1 || !args[numIdx + 1]) {
243
+ console.error('❌ Error: --number is required for transport release');
244
+ process.exit(1);
245
+ }
246
+ const number = args[numIdx + 1];
247
+
248
+ const csrfToken = await http.fetchCsrfToken();
249
+ const result = await http.post(
250
+ '/sap/bc/z_abapgit_agent/transport',
251
+ { action: 'RELEASE', number },
252
+ { csrfToken }
253
+ );
254
+
255
+ if (jsonOutput) {
256
+ console.log(JSON.stringify(result, null, 2));
257
+ return;
258
+ }
259
+
260
+ const success = result.SUCCESS === true || result.success === true ||
261
+ result.SUCCESS === 'X' || result.success === 'X';
262
+ const message = result.MESSAGE || result.message || '';
263
+ const error = result.ERROR || result.error || '';
264
+ const desc = result.DESCRIPTION || result.description || '';
265
+
266
+ if (success) {
267
+ console.log(`\n🚀 Releasing transport ${number}...`);
268
+ if (desc) console.log(`\n Description: ${desc}`);
269
+ console.log(`\n✅ Transport ${number} released successfully.`);
270
+ } else {
271
+ console.error(`\n❌ Could not release transport ${number}.`);
272
+ if (error) {
273
+ console.error(`\n Error: ${error}`);
274
+ } else if (message) {
275
+ console.error(`\n Error: ${message}`);
276
+ }
277
+ }
278
+ },
279
+
280
+ _prompt(question) {
281
+ const readline = require('readline');
282
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
283
+ return new Promise((resolve) => {
284
+ rl.question(question, (answer) => {
285
+ rl.close();
286
+ resolve(answer.trim());
287
+ });
288
+ });
289
+ }
290
+ };
package/src/config.js CHANGED
@@ -141,6 +141,62 @@ function getProjectInfo() {
141
141
  return projectConfig?.project || null;
142
142
  }
143
143
 
144
+ /**
145
+ * Get conflict detection configuration from project-level config
146
+ * Precedence: CLI flag > project config > default ('abort')
147
+ * @returns {Object} Conflict detection config with mode and reason
148
+ */
149
+ function getConflictSettings() {
150
+ const projectConfig = loadProjectConfig();
151
+
152
+ if (projectConfig?.conflictDetection) {
153
+ const validModes = ['ignore', 'abort'];
154
+ const mode = projectConfig.conflictDetection.mode;
155
+ if (mode && !validModes.includes(mode)) {
156
+ console.warn(`⚠️ Warning: Invalid conflictDetection.mode '${mode}' in .abapgit-agent.json. Must be one of: ${validModes.join(', ')}. Falling back to 'abort'.`);
157
+ return { mode: 'abort', reason: null };
158
+ }
159
+ return {
160
+ mode: mode || 'abort',
161
+ reason: projectConfig.conflictDetection.reason || null
162
+ };
163
+ }
164
+
165
+ // Default: abort (conflict detection on by default)
166
+ return { mode: 'abort', reason: null };
167
+ }
168
+
169
+ /**
170
+ * Get transport hook configuration from project-level config
171
+ * @returns {{ hook: string|null, description: string|null }}
172
+ */
173
+ function getTransportHookConfig() {
174
+ const projectConfig = loadProjectConfig();
175
+ if (projectConfig?.transports?.hook) {
176
+ return {
177
+ hook: projectConfig.transports.hook.path || null,
178
+ description: projectConfig.transports.hook.description || null
179
+ };
180
+ }
181
+ return { hook: null, description: null };
182
+ }
183
+
184
+ /**
185
+ * Get transport operation settings from project-level config
186
+ * @returns {{ allowCreate: boolean, allowRelease: boolean, reason: string|null }}
187
+ */
188
+ function getTransportSettings() {
189
+ const projectConfig = loadProjectConfig();
190
+ if (projectConfig?.transports) {
191
+ return {
192
+ allowCreate: projectConfig.transports.allowCreate !== false,
193
+ allowRelease: projectConfig.transports.allowRelease !== false,
194
+ reason: projectConfig.transports.reason || null
195
+ };
196
+ }
197
+ return { allowCreate: true, allowRelease: true, reason: null };
198
+ }
199
+
144
200
  module.exports = {
145
201
  loadConfig,
146
202
  getAbapConfig,
@@ -150,5 +206,8 @@ module.exports = {
150
206
  getWorkflowConfig,
151
207
  getSafeguards,
152
208
  getProjectInfo,
153
- loadProjectConfig
209
+ getConflictSettings,
210
+ loadProjectConfig,
211
+ getTransportHookConfig,
212
+ getTransportSettings
154
213
  };
@@ -199,12 +199,37 @@ class AdtHttp {
199
199
  return;
200
200
  }
201
201
 
202
- // Update cookies from any response that sets them
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").
203
207
  if (res.headers['set-cookie']) {
204
- const newCookies = Array.isArray(res.headers['set-cookie'])
205
- ? res.headers['set-cookie'].map(c => c.split(';')[0]).join('; ')
206
- : res.headers['set-cookie'].split(';')[0];
207
- this.cookies = this.cookies ? this.cookies + '; ' + newCookies : newCookies;
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('; ');
208
233
  }
209
234
 
210
235
  let respBody = '';
@@ -67,6 +67,17 @@ async function startDaemon(config, sessionId, socketPath, snapshot) {
67
67
 
68
68
  const session = new DebugSession(adt, sessionId);
69
69
 
70
+ // Pin the SAP_SESSIONID so _restorePinnedSession() works inside the daemon.
71
+ // DebugSession.attach() normally sets pinnedSessionId, but the daemon skips
72
+ // attach() and reconstructs the session from the snapshot. Without this,
73
+ // any CSRF refresh that AdtHttp performs internally (401/403 retry → HEAD
74
+ // request → new Set-Cookie) silently overwrites the session cookie and routes
75
+ // subsequent IPC calls to the wrong ABAP work process → HTTP 400.
76
+ if (snapshot && snapshot.cookies) {
77
+ const m = snapshot.cookies.match(/SAP_SESSIONID=([^;]*)/);
78
+ if (m) session.pinnedSessionId = m[1];
79
+ }
80
+
70
81
  // Remove stale socket file from a previous crash
71
82
  try { fs.unlinkSync(socketPath); } catch (e) { /* ignore ENOENT */ }
72
83
 
@@ -85,6 +96,17 @@ async function startDaemon(config, sessionId, socketPath, snapshot) {
85
96
  process.exit(code || 0);
86
97
  }
87
98
 
99
+ // On SIGTERM (e.g. pkill from ensure_breakpoint cleanup), attempt to release
100
+ // the frozen ABAP work process before exiting. Without this, killing the
101
+ // daemon leaves the work process paused at the breakpoint until SAP's own
102
+ // session-timeout fires (up to several minutes).
103
+ process.once('SIGTERM', async () => {
104
+ try {
105
+ await session.terminate();
106
+ } catch (e) { /* ignore — best effort */ }
107
+ cleanupAndExit(0);
108
+ });
109
+
88
110
  const server = net.createServer((socket) => {
89
111
  resetIdle();
90
112
  let buf = '';
@@ -170,7 +192,8 @@ async function _handleLine(socket, line, session, cleanupAndExit, resetIdle) {
170
192
  _send(socket, {
171
193
  ok: false,
172
194
  error: err.message || JSON.stringify(err),
173
- statusCode: err.statusCode
195
+ statusCode: err.statusCode,
196
+ body: err.body || null
174
197
  });
175
198
  }
176
199
  }
@@ -76,6 +76,11 @@ async function startRepl(session, initialState, onBeforeExit) {
76
76
 
77
77
  renderState(position, source, variables);
78
78
 
79
+ // Keep the ADT stateful session alive with periodic getStack() pings.
80
+ // SAP's ICM drops session affinity after ~60 s of idle, causing stepContinue
81
+ // to route to the wrong work process (HTTP 400) when the user eventually quits.
82
+ session.startKeepalive();
83
+
79
84
  const rl = readline.createInterface({
80
85
  input: process.stdin,
81
86
  output: process.stdout,
@@ -178,6 +183,7 @@ async function startRepl(session, initialState, onBeforeExit) {
178
183
 
179
184
  } else if (cmd === 'q' || cmd === 'quit') {
180
185
  console.log('\n Detaching debugger — program will continue running...');
186
+ session.stopKeepalive();
181
187
  try {
182
188
  await session.detach();
183
189
  } catch (e) {
@@ -190,6 +196,7 @@ async function startRepl(session, initialState, onBeforeExit) {
190
196
 
191
197
  } else if (cmd === 'kill') {
192
198
  console.log('\n Terminating program (hard abort)...');
199
+ session.stopKeepalive();
193
200
  try {
194
201
  await session.terminate();
195
202
  } catch (e) {
@@ -213,7 +220,23 @@ async function startRepl(session, initialState, onBeforeExit) {
213
220
  });
214
221
 
215
222
  rl.on('close', async () => {
223
+ // stdin closed (EOF or Ctrl+D) without an explicit 'q' or 'kill' command.
224
+ // Detach so the ABAP work process is released — without this the WP stays
225
+ // frozen in SM50 (e.g. when attach is started with </dev/null stdin and
226
+ // readline gets immediate EOF before the user can type a command).
227
+ if (!exitCleanupDone) {
228
+ session.stopKeepalive();
229
+ try { await session.detach(); } catch (e) { /* ignore */ }
230
+ }
216
231
  await runExitCleanup();
232
+ // Drain stdout/stderr before exiting so the OS TCP stack has flushed the
233
+ // outbound stepContinue request bytes. process.exit() tears down file
234
+ // descriptors immediately — without this the WP can stay frozen if the
235
+ // socket buffer hasn't been written to the NIC yet.
236
+ await new Promise(resolve => {
237
+ if (process.stdout.writableEnded) return resolve();
238
+ process.stdout.write('', resolve);
239
+ });
217
240
  process.exit(0);
218
241
  });
219
242