abapgit-agent 1.10.1 → 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.
package/abap/CLAUDE.md CHANGED
@@ -308,32 +308,26 @@ abapgit-agent debug list # confirm it was registered
308
308
 
309
309
  **Step 2 — attach and trigger**
310
310
 
311
- Two modes depending on context:
311
+ Best practice: individual sequential calls. Once the daemon is running and
312
+ the session is saved to the state file, each `vars/stack/step` command is a
313
+ plain standalone call — no `--session` flag needed.
312
314
 
313
- *Interactive (human in a terminal):*
314
315
  ```bash
315
- # Terminal 1 attach (blocks waiting for breakpoint, opens REPL on hit)
316
- abapgit-agent debug attach
317
-
318
- # Terminal 2 — trigger (any command that calls the backend)
319
- abapgit-agent unit --files src/zcl_my_class.clas.testclasses.abap
320
- abapgit-agent inspect --files src/zcl_my_class.clas.abap
321
- ```
322
-
323
- *Scripted (AI / automation) — best practice: individual sequential calls:*
324
-
325
- Once the daemon is running and the session is saved to the state file, each
326
- `vars/stack/step` command is a plain standalone call — no bash script needed.
327
-
328
- ```bash
329
- # Step 1: start attach listener in background (spawns a daemon, saves session to state file)
316
+ # Start attach listener in background (spawns a daemon, saves session to state file)
330
317
  abapgit-agent debug attach --json > /tmp/attach.json 2>&1 &
331
- sleep 2
332
318
 
333
- # Step 2: trigger in background MUST stay alive for the whole session
319
+ # Rule 1: wait for "Listener active" in the output, THEN fire the trigger.
320
+ # attach --json prints "Listener active" to stderr (captured in attach.json) the
321
+ # moment the long-poll POST is about to be sent to ADT. Waiting for this marker
322
+ # is reliable under any system load; a blind sleep may fire the trigger before
323
+ # ADT has a registered listener, causing the breakpoint hit to be missed.
324
+ until grep -q "Listener active" /tmp/attach.json 2>/dev/null; do sleep 0.3; done
325
+ sleep 1 # brief extra window for the POST to reach ADT
326
+
327
+ # Trigger in background — MUST stay alive for the whole session
334
328
  abapgit-agent unit --files src/zcl_my_class.clas.testclasses.abap > /tmp/trigger.json 2>&1 &
335
329
 
336
- # Step 3: poll until breakpoint fires and session JSON appears in attach output
330
+ # Poll until breakpoint fires and session JSON appears in attach output
337
331
  SESSION=""
338
332
  for i in $(seq 1 30); do
339
333
  sleep 0.5
@@ -341,40 +335,29 @@ for i in $(seq 1 30); do
341
335
  [ -n "$SESSION" ] && break
342
336
  done
343
337
 
344
- # Step 4: inspect and step — each is an individual call, no --session needed
338
+ # Inspect and step — each is an individual call, no --session needed
345
339
  abapgit-agent debug stack --json
346
340
  abapgit-agent debug vars --json
347
341
  abapgit-agent debug vars --expand LS_OBJECT --json
348
342
  abapgit-agent debug step --type over --json
349
343
  abapgit-agent debug vars --json
350
344
 
351
- # Step 5: ALWAYS release the ABAP work process before finishing
345
+ # ALWAYS release the ABAP work process before finishing
352
346
  abapgit-agent debug step --type continue --json
353
347
 
354
- # Step 6: check trigger result
348
+ # Check trigger result
355
349
  cat /tmp/trigger.json
356
350
  rm -f /tmp/attach.json /tmp/trigger.json
357
351
  ```
358
352
 
359
353
  > **Four rules for scripted mode:**
360
- > 1. `sleep 2` after starting attach — the listener must register on the server before the trigger fires
354
+ > 1. Wait for `"Listener active"` in the attach output before firing the trigger this message is printed to stderr (captured in `attach.json`) the moment the listener POST is about to reach ADT. A blind `sleep` is not reliable under system load.
361
355
  > 2. Keep the trigger process alive in the background for the entire session — if it exits, the ABAP work process is released and the session ends
362
356
  > 3. Always finish with `step --type continue` — this releases the frozen work process so the trigger can complete normally
363
357
  > 4. **Never pass `--session` to `step/vars/stack`** — it bypasses the daemon IPC and causes `noSessionAttached`. Omit it and let commands auto-load from the saved state file.
364
358
 
365
359
  **Step 3 — step through and inspect**
366
360
 
367
- *Interactive REPL commands (after `attach` without `--json`):*
368
- ```
369
- debug> v — show all variables
370
- debug> x LT_DATA — expand a table or structure
371
- debug> n — step over
372
- debug> s — step into
373
- debug> bt — call stack
374
- debug> q — detach (program continues); kill — hard abort
375
- ```
376
-
377
- *Scripted commands (after `attach --json`) — omit `--session`, commands auto-load from state file:*
378
361
  ```bash
379
362
  abapgit-agent debug vars --json # all variables
380
363
  abapgit-agent debug vars --name LV_RESULT --json # one variable
@@ -396,6 +379,24 @@ abapgit-agent debug delete --all
396
379
 
397
380
  This project's workflow mode is configured in `.abapGitAgent` under `workflow.mode`.
398
381
 
382
+ ### Project-Level Config (`.abapgit-agent.json`)
383
+
384
+ Checked into the repository — applies to all developers. **Read this file at the start of every session.**
385
+
386
+ | Setting | Values | Default | Effect |
387
+ |---------|--------|---------|--------|
388
+ | `safeguards.requireFilesForPull` | `true`/`false` | `false` | Requires `--files` on every pull |
389
+ | `safeguards.disablePull` | `true`/`false` | `false` | Disables pull entirely (CI/CD-only projects) |
390
+ | `conflictDetection.mode` | `"abort"`/`"ignore"` | `"abort"` | Whether to abort pull on conflict |
391
+ | `transports.hook.path` | string | `null` | Path to JS module that auto-selects a transport for pull |
392
+ | `transports.hook.description` | string | `null` | Optional label shown when the hook runs |
393
+ | `transports.allowCreate` | `true`/`false` | `true` | When `false`, `transport create` is blocked |
394
+ | `transports.allowRelease` | `true`/`false` | `true` | When `false`, `transport release` is blocked |
395
+
396
+ CLI `--conflict-mode` always overrides the project config for a single run.
397
+
398
+ See **AI Tool Guidelines** below for how to react to each setting.
399
+
399
400
  ### Workflow Modes
400
401
 
401
402
  | Mode | Branch Strategy | Rebase Before Pull | Create PR |
@@ -571,6 +572,37 @@ abapgit-agent pull --files src/zcl_auth_handler.clas.abap
571
572
  3. ✓ `git pull origin <default>` before push
572
573
  4. ✗ Don't create feature branches
573
574
 
575
+ **Read `.abapgit-agent.json` to determine project safeguards and conflict detection:**
576
+
577
+ **When `safeguards.requireFilesForPull = true`:**
578
+ 1. ✓ Always include `--files` in every `pull` command
579
+ 2. ✓ Never run `abapgit-agent pull` without `--files`
580
+ 3. ✗ Don't suggest or run a full pull without specifying files
581
+
582
+ **When `safeguards.requireFilesForPull = false` or not set:**
583
+ 1. ✓ `--files` is optional — use it for speed, omit for full pull
584
+
585
+ **When `safeguards.disablePull = true`:**
586
+ 1. ✗ Do not run `abapgit-agent pull` at all
587
+ 2. ✓ Inform the user that pull is disabled for this project (CI/CD only)
588
+
589
+ **When `conflictDetection.mode = "ignore"` or not set:**
590
+ 1. ✓ Run `pull` normally — no conflict flags needed
591
+ 2. ✗ Don't add `--conflict-mode` unless user explicitly asks
592
+
593
+ **When `conflictDetection.mode = "abort"`:**
594
+ 1. ✓ Conflict detection is active — pull aborts if ABAP system was edited since last pull
595
+ 2. ✓ If pull is aborted with conflict error, inform user and suggest `--conflict-mode ignore` to override for that run
596
+ 3. ✗ Don't silently add `--conflict-mode ignore` — always tell the user about the conflict
597
+
598
+ **When `transports.allowCreate = false`:**
599
+ 1. ✗ Do not run `abapgit-agent transport create`
600
+ 2. ✓ Inform the user that transport creation is disabled for this project
601
+
602
+ **When `transports.allowRelease = false`:**
603
+ 1. ✗ Do not run `abapgit-agent transport release`
604
+ 2. ✓ Inform the user that transport release is disabled for this project
605
+
574
606
  ---
575
607
 
576
608
  ## Development Workflow (Detailed)
@@ -600,6 +632,7 @@ abapgit-agent pull --files src/zcl_auth_handler.clas.abap
600
632
 
601
633
 
602
634
  6. Activate → abapgit-agent pull --files src/file.clas.abap
635
+ │ (behaviour depends on .abapgit-agent.json — see AI Tool Guidelines)
603
636
 
604
637
 
605
638
  7. Verify → Check pull output
@@ -791,6 +824,9 @@ abapgit-agent syntax --files src/zcl_class1.clas.abap,src/zif_intf1.intf.abap,sr
791
824
  # 2. Pull/activate AFTER pushing to git
792
825
  abapgit-agent pull --files src/zcl_class1.clas.abap,src/zcl_class2.clas.abap
793
826
 
827
+ # Override conflict detection for a single pull (e.g. deliberate branch switch)
828
+ abapgit-agent pull --files src/zcl_class1.clas.abap --conflict-mode ignore
829
+
794
830
  # 3. Inspect AFTER pull (for errors or unsupported types)
795
831
  abapgit-agent inspect --files src/zcl_class1.clas.abap
796
832
 
package/bin/abapgit-agent CHANGED
@@ -24,7 +24,7 @@ const gitUtils = require('../src/utils/git-utils');
24
24
  const versionCheck = require('../src/utils/version-check');
25
25
  const validators = require('../src/utils/validators');
26
26
  const { AbapHttp } = require('../src/utils/abap-http');
27
- const { loadConfig, getTransport, isAbapIntegrationEnabled, getSafeguards } = require('../src/config');
27
+ const { loadConfig, getTransport, isAbapIntegrationEnabled, getSafeguards, getConflictSettings, getTransportSettings } = require('../src/config');
28
28
 
29
29
  // Get terminal width for responsive table
30
30
  const getTermWidth = () => process.stdout.columns || 80;
@@ -55,7 +55,8 @@ async function main() {
55
55
  ref: require('../src/commands/ref'),
56
56
  init: require('../src/commands/init'),
57
57
  pull: require('../src/commands/pull'),
58
- upgrade: require('../src/commands/upgrade')
58
+ upgrade: require('../src/commands/upgrade'),
59
+ transport: require('../src/commands/transport')
59
60
  };
60
61
 
61
62
  // Check if this is a modular command
@@ -102,7 +103,9 @@ To enable integration:
102
103
  loadConfig,
103
104
  AbapHttp,
104
105
  getTransport,
105
- getSafeguards
106
+ getSafeguards,
107
+ getConflictSettings,
108
+ getTransportSettings
106
109
  };
107
110
 
108
111
  // Execute command
@@ -124,7 +127,7 @@ To enable integration:
124
127
  main().catch((err) => {
125
128
  // Known CLI errors (e.g. pull conflict) already printed their own output — skip re-printing.
126
129
  // For all other unexpected errors, print the message without the stack trace.
127
- if (!err._isPullError) {
130
+ if (!err._isPullError && !err._isTransportError) {
128
131
  console.error(err.message || err);
129
132
  }
130
133
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abapgit-agent",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "ABAP Git Agent - Pull and activate ABAP code via abapGit from any git repository",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -42,6 +42,7 @@
42
42
  "test:cmd:upgrade": "node tests/run-all.js --cmd --command=upgrade",
43
43
  "test:lifecycle": "node tests/run-all.js --lifecycle",
44
44
  "test:pull": "node tests/run-all.js --pull",
45
+ "test:full-pull": "node tests/run-all.js --full-pull",
45
46
  "test:conflict": "node tests/run-all.js --conflict",
46
47
  "pull": "node bin/abapgit-agent",
47
48
  "release": "node scripts/release.js",
@@ -583,7 +583,7 @@ async function cmdAttach(args, config, adt) {
583
583
  let firstPoll = true;
584
584
 
585
585
  for (let i = 0; i < (takenOver ? MAX_TAKEOVER_POLLS : MAX_POLLS); i++) {
586
- if (firstPoll && !jsonOutput) {
586
+ if (firstPoll) {
587
587
  process.stderr.write(' Listener active — trigger your ABAP program now.\n');
588
588
  firstPoll = false;
589
589
  }
@@ -9,7 +9,7 @@ module.exports = {
9
9
  requiresVersionCheck: true,
10
10
 
11
11
  async execute(args, context) {
12
- const { loadConfig, AbapHttp, gitUtils, getTransport, getSafeguards } = context;
12
+ const { loadConfig, AbapHttp, gitUtils, getTransport, getSafeguards, getConflictSettings, getTransportSettings } = context;
13
13
 
14
14
  // Check project-level safeguards
15
15
  const safeguards = getSafeguards();
@@ -36,9 +36,9 @@ module.exports = {
36
36
  let gitUrl = urlArgIndex !== -1 ? args[urlArgIndex + 1] : null;
37
37
  let branch = branchArgIndex !== -1 ? args[branchArgIndex + 1] : gitUtils.getBranch();
38
38
  let files = null;
39
- let conflictMode = conflictModeArgIndex !== -1 ? args[conflictModeArgIndex + 1] : 'abort';
39
+ let conflictMode = conflictModeArgIndex !== -1 ? args[conflictModeArgIndex + 1] : getConflictSettings().mode;
40
40
 
41
- // Transport: CLI arg takes priority, then config/environment, then null
41
+ // Transport: CLI arg takes priority, then config/environment, then selector
42
42
  let transportRequest = null;
43
43
  if (transportArgIndex !== -1 && transportArgIndex + 1 < args.length) {
44
44
  // Explicit --transport argument
@@ -48,8 +48,39 @@ module.exports = {
48
48
  transportRequest = getTransport();
49
49
  }
50
50
 
51
+ // Auto-detect URL early (needed for transport_required check)
52
+ if (!gitUrl) {
53
+ gitUrl = gitUtils.getRemoteUrl();
54
+ if (!gitUrl) {
55
+ console.error('Error: Not in a git repository or no remote configured.');
56
+ console.error('Either run from a git repo, or specify --url <git-url>');
57
+ process.exit(1);
58
+ }
59
+ if (!jsonOutput) {
60
+ console.log(`📌 Auto-detected git remote: ${gitUrl}`);
61
+ }
62
+ }
63
+
51
64
  if (filesArgIndex !== -1 && filesArgIndex + 1 < args.length) {
52
65
  files = args[filesArgIndex + 1].split(',').map(f => f.trim());
66
+
67
+ // Validate that every file has a recognised ABAP source extension
68
+ // (.abap or .asddls) — XML metadata files must NOT be passed here
69
+ const ABAP_SOURCE_EXTS = new Set(['abap', 'asddls']);
70
+ const nonSourceFiles = files.filter(f => {
71
+ const base = f.split('/').pop(); // strip directory
72
+ const parts = base.split('.');
73
+ const ext = parts[parts.length - 1].toLowerCase();
74
+ return parts.length < 3 || !ABAP_SOURCE_EXTS.has(ext);
75
+ });
76
+ if (nonSourceFiles.length > 0) {
77
+ console.error('❌ Error: --files only accepts ABAP source files (.abap, .asddls).');
78
+ console.error(' The following file(s) are not ABAP source files:');
79
+ nonSourceFiles.forEach(f => console.error(` ${f}`));
80
+ console.error(' Tip: pass the source file, not the XML metadata file.');
81
+ console.error(' Example: --files src/zcl_my_class.clas.abap');
82
+ process.exit(1);
83
+ }
53
84
  }
54
85
 
55
86
  // SAFEGUARD 2: Check if files are required but not provided
@@ -64,15 +95,52 @@ module.exports = {
64
95
  process.exit(1);
65
96
  }
66
97
 
67
- if (!gitUrl) {
68
- gitUrl = gitUtils.getRemoteUrl();
69
- if (!gitUrl) {
70
- console.error('Error: Not in a git repository or no remote configured.');
71
- console.error('Either run from a git repo, or specify --url <git-url>');
72
- process.exit(1);
98
+ // Auto-select transport when none configured and not in JSON mode
99
+ if (!transportRequest && !jsonOutput) {
100
+ const { selectTransport, isNonInteractive, _getTransportHookConfig } = require('../utils/transport-selector');
101
+
102
+ // Check if this package requires a transport before showing the interactive
103
+ // picker. Skip the status round-trip in non-interactive mode — selectTransport
104
+ // already returns null there without prompting, so the extra HTTP call is wasted.
105
+ let transportRequired = true; // Safe default: assume transport needed
106
+ if (!isNonInteractive()) {
107
+ try {
108
+ const config = loadConfig();
109
+ const http = new AbapHttp(config);
110
+ const csrfToken = await http.fetchCsrfToken();
111
+ const statusResult = await http.post('/sap/bc/z_abapgit_agent/status', { url: gitUrl }, { csrfToken });
112
+ if (statusResult.transport_required === false || statusResult.transport_required === 'false') {
113
+ transportRequired = false;
114
+ }
115
+ } catch (e) {
116
+ // Status check failed — proceed with selector as safe default
117
+ }
73
118
  }
74
- if (!jsonOutput) {
75
- console.log(`📌 Auto-detected git remote: ${gitUrl}`);
119
+
120
+ if (transportRequired) {
121
+ const config = loadConfig();
122
+ const http = new AbapHttp(config);
123
+ transportRequest = await selectTransport(config, http, loadConfig, AbapHttp, getTransportSettings);
124
+
125
+ // If a hook was configured but returned no transport, handle based on context
126
+ if (transportRequest === null) {
127
+ const hookConfig = _getTransportHookConfig();
128
+ if (hookConfig && hookConfig.hook) {
129
+ if (isNonInteractive()) {
130
+ // Non-interactive (AI/CI): fail — a configured hook must return a transport
131
+ console.error('❌ Error: transport hook returned no transport request.');
132
+ console.error(` Hook: ${hookConfig.hook}`);
133
+ if (hookConfig.description) console.error(` ${hookConfig.description}`);
134
+ process.exit(1);
135
+ } else {
136
+ // Interactive (TTY): warn and fall through to the picker
137
+ process.stderr.write(`⚠️ Transport hook returned no transport request (${hookConfig.hook}).\n`);
138
+ process.stderr.write(' Please select one manually:\n');
139
+ const { interactivePicker } = require('../utils/transport-selector');
140
+ transportRequest = await interactivePicker(http);
141
+ }
142
+ }
143
+ }
76
144
  }
77
145
  }
78
146
 
@@ -32,7 +32,9 @@ module.exports = {
32
32
  console.log(' Repository: ✅ Created');
33
33
  const pkg = result.package || result.PACKAGE || 'N/A';
34
34
  const key = result.key || result.KEY || result.REPO_KEY || result.repo_key || 'N/A';
35
+ const transportRequired = result.transport_required === true || result.transport_required === 'true';
35
36
  console.log(` Package: ${pkg}`);
37
+ console.log(` Transport required: ${transportRequired ? 'Yes' : 'No'}`);
36
38
  console.log(` URL: ${repoUrl}`);
37
39
  console.log(` Key: ${key}`);
38
40
  } else {
@@ -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
  };
@@ -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 = '';
@@ -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
 
@@ -38,7 +38,7 @@ const STATEFUL_HEADER = { 'X-sap-adt-sessiontype': 'stateful' };
38
38
  * @param {number} maxRetries - Max additional attempts after the first (default 3)
39
39
  * @param {number} delayMs - Wait between retries in ms (default 1000)
40
40
  */
41
- async function retryOnIcmError(fn, maxRetries = 3, delayMs = 1000) {
41
+ async function retryOnIcmError(fn, maxRetries = 12, delayMs = 2000) {
42
42
  let lastErr;
43
43
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
44
44
  try {
@@ -64,6 +64,56 @@ class DebugSession {
64
64
  constructor(adtHttp, sessionId) {
65
65
  this.http = adtHttp;
66
66
  this.sessionId = sessionId;
67
+ this.pinnedSessionId = null;
68
+ this._keepaliveTimer = null;
69
+ }
70
+
71
+ /**
72
+ * Start a periodic keepalive that pings ADT every 30 seconds.
73
+ * SAP's ICM drops stateful session affinity after ~60 s of idle, causing
74
+ * subsequent debug requests to route to the wrong work process (HTTP 400).
75
+ * Calling getStack() regularly keeps the connection warm.
76
+ * Call stopKeepalive() before detach/terminate to avoid racing the close.
77
+ */
78
+ startKeepalive() {
79
+ if (this._keepaliveTimer) return;
80
+ this._keepaliveTimer = setInterval(async () => {
81
+ try { await this.getStack(); } catch (e) { /* best-effort */ }
82
+ }, 30000);
83
+ if (this._keepaliveTimer.unref) this._keepaliveTimer.unref();
84
+ }
85
+
86
+ stopKeepalive() {
87
+ if (this._keepaliveTimer) {
88
+ clearInterval(this._keepaliveTimer);
89
+ this._keepaliveTimer = null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Restore the pinned SAP_SESSIONID into the HTTP client's cookie jar.
95
+ *
96
+ * SAP ADT debug sessions are bound to a specific frozen ABAP work process
97
+ * via the SAP_SESSIONID cookie. AdtHttp updates this cookie automatically
98
+ * from every response's Set-Cookie header and replaces it on CSRF refresh
99
+ * (401/403 retry). When the cookie rotates mid-session the next request
100
+ * routes to a different ABAP session that has no debug state, causing
101
+ * HTTP 400 "Service cannot be reached".
102
+ *
103
+ * This method reverts any rotation that occurred since attach() by replacing
104
+ * the current SAP_SESSIONID value with the one captured at attach time.
105
+ * It is a no-op when called before attach() (pinnedSessionId is null).
106
+ */
107
+ _restorePinnedSession() {
108
+ if (!this.pinnedSessionId || !this.http.cookies) return;
109
+
110
+ // Replace whatever SAP_SESSIONID= value is currently in the cookie jar
111
+ // with the pinned one. The cookie jar is a semicolon-separated string,
112
+ // e.g. "SAP_SESSIONID=ABC123; sap-usercontext=xyz".
113
+ this.http.cookies = this.http.cookies.replace(
114
+ /SAP_SESSIONID=[^;]*/,
115
+ `SAP_SESSIONID=${this.pinnedSessionId}`
116
+ );
67
117
  }
68
118
 
69
119
  /**
@@ -95,6 +145,14 @@ class DebugSession {
95
145
  this.sessionId = debugSessionId;
96
146
  }
97
147
 
148
+ // Pin the SAP_SESSIONID cookie that was active when we attached.
149
+ // All subsequent stateful operations must present this exact cookie so
150
+ // that SAP routes them to the same frozen ABAP work process.
151
+ if (this.http.cookies) {
152
+ const match = this.http.cookies.match(/SAP_SESSIONID=([^;]*)/);
153
+ if (match) this.pinnedSessionId = match[1];
154
+ }
155
+
98
156
  return this.sessionId;
99
157
  }
100
158
 
@@ -126,6 +184,7 @@ class DebugSession {
126
184
  // (no suspended session left). Treat both 200 and 500 as "continued".
127
185
  if (method === 'stepContinue') {
128
186
  return retryOnIcmError(async () => {
187
+ this._restorePinnedSession();
129
188
  try {
130
189
  await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
131
190
  contentType: 'application/vnd.sap.as+xml',
@@ -146,6 +205,7 @@ class DebugSession {
146
205
  }
147
206
 
148
207
  return retryOnIcmError(async () => {
208
+ this._restorePinnedSession();
149
209
  await this.http.post(`/sap/bc/adt/debugger?method=${method}`, '', {
150
210
  contentType: 'application/vnd.sap.as+xml',
151
211
  headers: { ...STATEFUL_HEADER, 'Accept': 'application/xml' }
@@ -173,6 +233,8 @@ class DebugSession {
173
233
  * @returns {Promise<Array<{ name: string, type: string, value: string }>>}
174
234
  */
175
235
  async getVariables(name = null) {
236
+ this._restorePinnedSession();
237
+
176
238
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
177
239
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
178
240
 
@@ -254,6 +316,8 @@ class DebugSession {
254
316
  * @returns {Promise<Array<{ id: string, name: string, type: string, value: string }>>}
255
317
  */
256
318
  async getVariableChildren(parentId, meta = {}) {
319
+ this._restorePinnedSession();
320
+
257
321
  const CT_VARS = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.Variables';
258
322
  const CT_CHILD = 'application/vnd.sap.as+xml; charset=UTF-8; dataname=com.sap.adt.debugger.ChildVariables';
259
323
 
@@ -372,6 +436,7 @@ class DebugSession {
372
436
  */
373
437
  async getStack() {
374
438
  return retryOnIcmError(async () => {
439
+ this._restorePinnedSession();
375
440
  // Try newer dedicated stack endpoint first (abap-adt-api v7+ approach)
376
441
  try {
377
442
  const { body } = await this.http.get(
@@ -383,9 +448,8 @@ class DebugSession {
383
448
  const frames = parseStack(body);
384
449
  if (frames.length > 0) return frames;
385
450
  } catch (e) {
386
- // Re-throw ICM errors so the outer retryOnIcmError can catch them
387
- if (e && e.statusCode === 400 && e.body && e.body.includes('Service cannot be reached')) throw e;
388
- // Otherwise fall through to POST approach
451
+ // Fall through to POST approach for any GET failure (including 400 on systems
452
+ // that don't support the dedicated /debugger/stack endpoint)
389
453
  }
390
454
  // Fallback: POST approach (older ADT versions)
391
455
  const { body } = await this.http.post(
@@ -583,32 +647,38 @@ class DebugSession {
583
647
 
584
648
  /**
585
649
  * Terminate the debug session.
650
+ * Retries on transient ICM 400 errors so the ABAP work process is reliably
651
+ * released even when the system is under load (e.g. during test:all).
586
652
  */
587
653
  async terminate() {
588
- await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
589
- contentType: 'application/vnd.sap.as+xml',
590
- headers: STATEFUL_HEADER
654
+ await retryOnIcmError(async () => {
655
+ this._restorePinnedSession();
656
+ await this.http.post('/sap/bc/adt/debugger?method=terminateDebuggee', '', {
657
+ contentType: 'application/vnd.sap.as+xml',
658
+ headers: STATEFUL_HEADER
659
+ });
591
660
  });
592
661
  }
593
662
 
594
663
  /**
595
664
  * Detach from the debuggee without killing it.
596
- * Issues a stepContinue so the ABAP program resumes running.
665
+ * Issues a single stepContinue so the ABAP program resumes running.
666
+ *
667
+ * ADT returns HTTP 200 when the WP resumes (regardless of whether it
668
+ * later hits another breakpoint — there is no way to distinguish "still
669
+ * running" from "re-hit breakpoint" in the stepContinue response alone).
670
+ * Sending a second stepContinue to an already-running WP races with the
671
+ * program's own execution and can stall the WP mid-run (e.g. while a
672
+ * Code Inspector job is in flight), so we issue exactly one request.
597
673
  *
598
- * stepContinue is a long-poll in ADT it only responds when the program
599
- * hits another breakpoint (200) or finishes (500), which may be never.
600
- * We use postFire() which resolves as soon as the request bytes are
601
- * flushed to the TCP send buffer — no need to wait for a response.
602
- * The existing session cookies are used so ADT recognises the request.
674
+ * Callers (e.g. the REPL 'q' handler) must delete all breakpoints before
675
+ * calling detach() to prevent an immediate re-hit on the same line.
603
676
  */
604
677
  async detach() {
605
678
  try {
606
- await this.http.postFire('/sap/bc/adt/debugger?method=stepContinue', '', {
607
- contentType: 'application/vnd.sap.as+xml',
608
- headers: STATEFUL_HEADER
609
- });
679
+ await this.step('stepContinue');
610
680
  } catch (e) {
611
- // Ignore — fire-and-forget; errors here mean the session already closed.
681
+ // Ignore — session may have already closed.
612
682
  }
613
683
  }
614
684
  }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Transport selector utility
3
+ * Resolves a transport request number for the pull command when none is configured.
4
+ *
5
+ * Strategy:
6
+ * - Non-interactive (AI mode, CI): run a project-configured Node.js hook
7
+ * - Interactive (TTY): show a numbered picker (list, scope-switch, create, skip)
8
+ */
9
+
10
+ const path = require('path');
11
+
12
+ /**
13
+ * Returns true when running non-interactively (no TTY, CI, AI coding tool).
14
+ * Can be forced with NO_TTY=1 env var.
15
+ */
16
+ function isNonInteractive() {
17
+ if (process.env.NO_TTY === '1') return true;
18
+ return !process.stdout.isTTY || !process.stdin.isTTY;
19
+ }
20
+
21
+ /**
22
+ * Load and execute a hook module.
23
+ * The hook must export an async function({ config, http }) → string|null.
24
+ *
25
+ * @param {string} hookPath - Absolute path to the hook module
26
+ * @param {object} context - { config, http }
27
+ * @returns {Promise<string|null>}
28
+ */
29
+ async function runHook(hookPath, context) {
30
+ const hookFn = require(hookPath);
31
+ const result = await hookFn(context);
32
+ return typeof result === 'string' ? result : null;
33
+ }
34
+
35
+ /**
36
+ * Fetch open transport requests from ABAP.
37
+ * Returns normalised lowercase-key objects.
38
+ *
39
+ * @param {object} http
40
+ * @param {string} scope - 'mine' | 'task' | 'all'
41
+ * @returns {Promise<Array>}
42
+ */
43
+ async function fetchTransports(http, scope = 'mine') {
44
+ try {
45
+ const result = await http.get(`/sap/bc/z_abapgit_agent/transport?scope=${scope}`);
46
+ const raw = result.TRANSPORTS || result.transports || [];
47
+ return raw.map(t => ({
48
+ number: t.NUMBER || t.number || '',
49
+ description: t.DESCRIPTION || t.description || '',
50
+ owner: t.OWNER || t.owner || '',
51
+ date: t.DATE || t.date || ''
52
+ }));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create a new transport request.
60
+ * @param {object} http
61
+ * @param {string} description
62
+ * @returns {Promise<string|null>} transport number or null
63
+ */
64
+ async function createTransport(http, description) {
65
+ try {
66
+ const result = await http.post(
67
+ '/sap/bc/z_abapgit_agent/transport',
68
+ { action: 'CREATE', description: description || '' },
69
+ {}
70
+ );
71
+ return result.NUMBER || result.number || null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Interactive picker — shown in TTY mode.
79
+ * Presents a numbered menu; supports scope switching, create, and skip.
80
+ *
81
+ * @param {object} http
82
+ * @returns {Promise<string|null>}
83
+ */
84
+ async function interactivePicker(http) {
85
+ const readline = require('readline');
86
+
87
+ let scope = 'mine';
88
+ let transports = [];
89
+ let fetchError = false;
90
+
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
92
+ const ask = (q) => new Promise(resolve => rl.question(q, resolve));
93
+
94
+ const fetchAndDisplay = async () => {
95
+ transports = await fetchTransports(http, scope);
96
+ fetchError = transports.length === 0;
97
+
98
+ const scopeLabel = { mine: 'my transports', task: 'transports where I have a task', all: 'all open transports' }[scope];
99
+ process.stderr.write(`\nSelect a transport request (showing: ${scopeLabel}):\n\n`);
100
+
101
+ if (transports.length > 0) {
102
+ transports.forEach((t, i) => {
103
+ process.stderr.write(` ${i + 1}. ${t.number} ${t.description} (${t.owner}, ${t.date})\n`);
104
+ });
105
+ process.stderr.write(' ' + '─'.repeat(61) + '\n');
106
+ } else {
107
+ if (fetchError) {
108
+ process.stderr.write(' ⚠️ Could not fetch transports from ABAP system.\n');
109
+ } else {
110
+ process.stderr.write(' No open transport requests found.\n');
111
+ }
112
+ process.stderr.write(' ' + '─'.repeat(61) + '\n');
113
+ }
114
+
115
+ if (scope !== 'task') process.stderr.write(' s. Show transports where I have a task\n');
116
+ if (scope !== 'all') process.stderr.write(' a. Show all open transports\n');
117
+ process.stderr.write(' c. Create new transport request\n');
118
+ process.stderr.write(' 0. Skip (no transport request)\n\n');
119
+ };
120
+
121
+ await fetchAndDisplay();
122
+
123
+ // eslint-disable-next-line no-constant-condition
124
+ while (true) {
125
+ const answer = (await ask('Enter number or option: ')).trim().toLowerCase();
126
+
127
+ if (answer === '0') {
128
+ rl.close();
129
+ return null;
130
+ }
131
+
132
+ if (answer === 's') {
133
+ scope = 'task';
134
+ await fetchAndDisplay();
135
+ continue;
136
+ }
137
+
138
+ if (answer === 'a') {
139
+ scope = 'all';
140
+ await fetchAndDisplay();
141
+ continue;
142
+ }
143
+
144
+ if (answer === 'c') {
145
+ const desc = (await ask('Description: ')).trim();
146
+ rl.close();
147
+ const number = await createTransport(http, desc);
148
+ if (number) {
149
+ process.stderr.write(`\n✅ Created transport ${number}\n`);
150
+ return number;
151
+ } else {
152
+ process.stderr.write('\n❌ Could not create transport request.\n');
153
+ return null;
154
+ }
155
+ }
156
+
157
+ const idx = parseInt(answer, 10);
158
+ if (!isNaN(idx) && idx >= 1 && idx <= transports.length) {
159
+ rl.close();
160
+ return transports[idx - 1].number;
161
+ }
162
+
163
+ process.stderr.write(`Invalid selection '${answer}'. Enter a number, s, a, c, or 0.\n`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Build the `run` helper that is passed to hooks in AI mode.
169
+ *
170
+ * run(command) — accepts a full CLI command string, e.g.:
171
+ * run('transport list --scope task')
172
+ * run('transport create --description "My transport"')
173
+ *
174
+ * Splits on whitespace, forces --json, captures output, returns parsed JSON.
175
+ *
176
+ * @param {object} config - Loaded ABAP config
177
+ * @param {object} http - Pre-built AbapHttp instance
178
+ * @param {Function} loadConfig - Config factory (from pull context)
179
+ * @param {Function} AbapHttp - AbapHttp constructor (from pull context)
180
+ * @param {Function} getTransportSettings - Transport settings getter (from pull context)
181
+ * @returns {Function|undefined} - The run helper, or undefined if factories are missing
182
+ */
183
+ function buildRun(config, http, loadConfig, AbapHttp, getTransportSettings) {
184
+ if (!loadConfig || !AbapHttp) return undefined;
185
+
186
+ return async function run(command) {
187
+ const [commandName, ...args] = command.trim().split(/\s+/);
188
+ const cmdModule = require(`../commands/${commandName}`);
189
+
190
+ // Always force --json so output is parseable
191
+ const runArgs = args.includes('--json') ? args : [...args, '--json'];
192
+
193
+ // Reuse the already-authenticated http instance
194
+ const MockAbapHttp = function MockAbapHttp() { return http; };
195
+
196
+ const runContext = {
197
+ loadConfig: () => config,
198
+ AbapHttp: MockAbapHttp,
199
+ getTransportSettings: getTransportSettings || (() => ({ allowCreate: true, allowRelease: true, reason: null }))
200
+ };
201
+
202
+ // Capture console.log output; override process.exit to throw instead of exit
203
+ const captured = [];
204
+ const origLog = console.log;
205
+ const origExit = process.exit;
206
+ console.log = (...a) => captured.push(a.map(String).join(' '));
207
+ process.exit = (code) => { throw new Error(`process.exit(${code})`); };
208
+
209
+ try {
210
+ await cmdModule.execute(runArgs, runContext);
211
+ } finally {
212
+ console.log = origLog;
213
+ process.exit = origExit;
214
+ }
215
+
216
+ const output = captured.join('');
217
+ if (!output) throw new Error(`run("${command}") produced no output`);
218
+ return JSON.parse(output);
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Main export — selects a transport request for use in the pull command.
224
+ * Returns the transport number, or null to proceed without one.
225
+ *
226
+ * @param {object} config - Loaded ABAP config
227
+ * @param {object} http - Pre-built AbapHttp instance
228
+ * @param {Function} [loadConfig] - Config factory (enables run helper in hook context)
229
+ * @param {Function} [AbapHttp] - AbapHttp constructor (enables run helper in hook context)
230
+ * @param {Function} [getTransportSettings] - Transport settings getter
231
+ * @returns {Promise<string|null>}
232
+ */
233
+ async function selectTransport(config, http, loadConfig, AbapHttp, getTransportSettings) {
234
+ // Hook takes precedence over the interactive picker — runs in both TTY and non-TTY mode
235
+ const hookConfig = module.exports._getTransportHookConfig();
236
+ if (hookConfig && hookConfig.hook) {
237
+ const hookPath = path.resolve(process.cwd(), hookConfig.hook);
238
+ const run = buildRun(config, http, loadConfig, AbapHttp, getTransportSettings);
239
+ try {
240
+ return await module.exports.runHook(hookPath, { config, http, run });
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ // No hook configured — fall back based on context
247
+ if (isNonInteractive()) {
248
+ return null; // AI/CI mode: proceed without transport
249
+ }
250
+
251
+ // Manual mode: interactive picker
252
+ return interactivePicker(http);
253
+ }
254
+
255
+ /**
256
+ * Read transport hook config from .abapgit-agent.json
257
+ * (mirrors getConflictSettings pattern in config.js)
258
+ */
259
+ function _getTransportHookConfig() {
260
+ const fs = require('fs');
261
+ const projectConfigPath = path.join(process.cwd(), '.abapgit-agent.json');
262
+
263
+ if (!fs.existsSync(projectConfigPath)) return null;
264
+
265
+ try {
266
+ const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
267
+ if (projectConfig && projectConfig.transports?.hook) {
268
+ return {
269
+ hook: projectConfig.transports.hook.path || null,
270
+ description: projectConfig.transports.hook.description || null
271
+ };
272
+ }
273
+ } catch {
274
+ // ignore parse errors
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ module.exports = {
281
+ isNonInteractive,
282
+ runHook,
283
+ fetchTransports,
284
+ createTransport,
285
+ interactivePicker,
286
+ selectTransport,
287
+ buildRun,
288
+ _getTransportHookConfig
289
+ };