sap-ai-dev-toolkit 0.9.9904 → 0.9.9908

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/src/setup.mjs CHANGED
@@ -8,6 +8,7 @@ import { adtBaseUrl, adtPort, defaultAdtUrl, discoverSapGuiSystems, hasExplicitP
8
8
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
9
9
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
10
10
  import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
11
+ import { findRemoteControllableSsoBrowsers } from './sso-browsers.mjs';
11
12
 
12
13
  const SETUP_COMMAND = 'sap-ai-dev --setup';
13
14
 
@@ -66,6 +67,45 @@ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout
66
67
  }, { input, output });
67
68
  }
68
69
 
70
+ function createSsoBrowserSelector({ discoverSsoBrowsers, input, output, env }) {
71
+ let selectedPath;
72
+ let selectionComplete = false;
73
+ return async destination => {
74
+ if (selectionComplete) return selectedPath;
75
+ selectionComplete = true;
76
+ print(output, formatStatus('Checking installed browsers for remote DevTools access.', 'progress', output, 'SSO'));
77
+ let result;
78
+ try {
79
+ result = await discoverSsoBrowsers({ env });
80
+ } catch (error) {
81
+ const reason = String(error?.message || error).replace(/\s+/g, ' ').slice(0, 240);
82
+ print(output, formatStatus(`Browser control check failed (${reason}). Setup will keep automatic browser discovery.`, 'warning', output, 'SSO'));
83
+ return undefined;
84
+ }
85
+ const { browsers = [], unavailable = [] } = result || {};
86
+ if (!browsers.length) {
87
+ const details = unavailable.map(browser => `${browser.name}: ${browser.reason}`).join('; ').slice(0, 300);
88
+ print(output, formatStatus(`No remotely controllable SSO browser was found${details ? ` (${details})` : ''}. Setup will keep automatic browser discovery.`, 'warning', output, 'SSO'));
89
+ return undefined;
90
+ }
91
+ if (browsers.length === 1) {
92
+ selectedPath = browsers[0].path;
93
+ print(output, formatStatus(`Using ${browsers[0].name}; it responded to a remote DevTools command.`, 'success', output, 'SSO browser'));
94
+ return selectedPath;
95
+ }
96
+ selectedPath = await selectPrompt({
97
+ message: `Browser for SSO (${destination.name})`,
98
+ defaultValue: browsers[0].path,
99
+ choices: browsers.map((browser, index) => ({
100
+ name: `${browser.name} (${browser.path})`,
101
+ value: browser.path,
102
+ shortcut: String(index + 1)
103
+ }))
104
+ }, { input, output });
105
+ return selectedPath;
106
+ };
107
+ }
108
+
69
109
  async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
70
110
  const readline = createInterface({ input, output });
71
111
  try {
@@ -238,7 +278,7 @@ async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
238
278
  return !/^n(?:o)?$/i.test(String(answer || '').trim());
239
279
  }
240
280
 
241
- function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false } = {}) {
281
+ function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false, browserPath } = {}) {
242
282
  const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
243
283
  const localDestination = { ...destination, name, serverName: name, client };
244
284
  const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
@@ -248,7 +288,7 @@ function buildLocalClientDestination(destination, client, authMode, { multipleCl
248
288
  { id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
249
289
  ];
250
290
  if (authMode === 'sso') {
251
- localDestination.childEnv = { SAP_AUTH_MODE: 'sso' };
291
+ localDestination.childEnv = { SAP_AUTH_MODE: 'sso', ...(browserPath ? { SAP_BROWSER_EXEC: browserPath } : {}) };
252
292
  localDestination.inputs = [];
253
293
  } else {
254
294
  localDestination.childEnv = {
@@ -285,7 +325,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
285
325
  // collects the minimal ADT configuration directly from the user and produces
286
326
  // the same local destination shape as the SAP GUI flow, so installation,
287
327
  // credential inputs, and TLS handling stay identical.
288
- async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
328
+ async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl, selectSsoBrowser } = {}) {
289
329
  print(output, '');
290
330
  print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
291
331
  print(output, colorText(' The ADT URL is the base URL that Eclipse ADT or VS Code ADT tools use, for example https://myhost.example:44300.', 'cyan', output));
@@ -338,10 +378,11 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
338
378
  } else {
339
379
  if (authMode === 'sso') warnSsoIpHost(destination, name, output);
340
380
  else await maybeConfigureTlsSelfHeal(destination, name, { input, output });
381
+ const browserPath = authMode === 'sso' ? await selectSsoBrowser(destination) : undefined;
341
382
  destination.authentication = localAuthenticationLabel(authMode);
342
383
  destination.authMode = authMode;
343
384
  for (const client of clients) {
344
- expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
385
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, browserPath }));
345
386
  }
346
387
  print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
347
388
  }
@@ -469,7 +510,7 @@ function printConnectionInstructions(output, result) {
469
510
 
470
511
  // Checkbox selection over the discovered destinations plus, outside BAS, the
471
512
  // per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
472
- async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map() } = {}) {
513
+ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map(), selectSsoBrowser } = {}) {
473
514
  const orderedDestinations = [...destinations].sort((left, right) => {
474
515
  const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
475
516
  const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
@@ -495,7 +536,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
495
536
  print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
496
537
  print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
497
538
  print(output, '');
498
- const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
539
+ const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl, selectSsoBrowser })).map(destination => ({
499
540
  value: destination,
500
541
  name: `${destination.name} (manual, client ${destination.client})`
501
542
  }));
@@ -549,10 +590,11 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
549
590
  }
550
591
  if (authMode === 'sso') warnSsoIpHost(destination, promptLabel, output);
551
592
  else await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
593
+ const browserPath = authMode === 'sso' ? await selectSsoBrowser(destination) : undefined;
552
594
  destination.authentication = localAuthenticationLabel(authMode);
553
595
  destination.authMode = authMode;
554
596
  for (const client of clients) {
555
- expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName }));
597
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName, browserPath }));
556
598
  }
557
599
  }
558
600
  selected = expanded;
@@ -570,7 +612,8 @@ export async function runSetup({
570
612
  confirmCfImport = confirmCloudFoundryImport,
571
613
  install = installMcpConfig,
572
614
  includeSapDevelopmentToolsPrompt = false,
573
- detectAdtUrl
615
+ detectAdtUrl,
616
+ discoverSsoBrowsers = findRemoteControllableSsoBrowsers
574
617
  } = {}) {
575
618
  const isBas = Boolean(env.H2O_URL);
576
619
  if (!isBas && (!input.isTTY || !output.isTTY)) {
@@ -581,6 +624,7 @@ export async function runSetup({
581
624
  print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
582
625
  return { skipped: true, reason: 'non-tty' };
583
626
  }
627
+ const selectSsoBrowser = createSsoBrowserSelector({ discoverSsoBrowsers, input, output, env });
584
628
  print(output, '');
585
629
  print(output, colorText('💡 Tip: you can skip auto-discovery and add a system manually — press m in the destination picker, or take the wizard offer when nothing is found.', 'magenta', output));
586
630
  if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
@@ -721,11 +765,11 @@ export async function runSetup({
721
765
  ]
722
766
  }, { input, output });
723
767
  if (choice !== 'manual') return await exitWithoutDestinations();
724
- selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
768
+ selected = await runManualEntryWizard({ env, input, output, detectAdtUrl, selectSsoBrowser });
725
769
  additiveSetup = true;
726
770
  if (!selected.length) return await exitWithoutDestinations();
727
771
  } else {
728
- selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
772
+ selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls, selectSsoBrowser });
729
773
  additiveSetup = selected.length > 0;
730
774
  }
731
775
 
@@ -1,8 +1,9 @@
1
- import { execFile } from 'node:child_process';
2
- import { access, constants } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { posix, win32 } from 'node:path';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { access, constants, mkdtemp, rm } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { join, posix, win32 } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
+ import { WebSocket } from 'undici';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
 
@@ -59,6 +60,155 @@ async function fileExists(path) {
59
60
  }
60
61
  }
61
62
 
63
+ function remoteDebuggingEndpoint(output) {
64
+ return /DevTools listening on (ws:\/\/[^\s]+)/.exec(output)?.[1] || '';
65
+ }
66
+
67
+ function waitForRemoteDebuggingEndpoint(child, timeoutMs) {
68
+ return new Promise((resolve, reject) => {
69
+ let output = '';
70
+ let settled = false;
71
+ const finish = (error, endpoint) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ child.stdout?.off('data', onData);
76
+ child.stderr?.off('data', onData);
77
+ child.off('error', onError);
78
+ child.off('close', onClose);
79
+ if (error) reject(error);
80
+ else resolve(endpoint);
81
+ };
82
+ const onData = chunk => {
83
+ output = `${output}${chunk}`.slice(-8192);
84
+ const endpoint = remoteDebuggingEndpoint(output);
85
+ if (endpoint) finish(null, endpoint);
86
+ };
87
+ const onError = error => finish(error);
88
+ const onClose = (code, signal) => finish(new Error(`browser exited before exposing DevTools (code ${code ?? 'unknown'}, signal ${signal || 'none'})`));
89
+ const timer = setTimeout(() => finish(new Error(`browser did not expose DevTools within ${timeoutMs}ms`)), timeoutMs);
90
+ child.stdout?.on('data', onData);
91
+ child.stderr?.on('data', onData);
92
+ child.once('error', onError);
93
+ child.once('close', onClose);
94
+ });
95
+ }
96
+
97
+ function controlBrowser(endpoint, timeoutMs) {
98
+ const url = new URL(endpoint);
99
+ if (url.protocol !== 'ws:' || !['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname)) {
100
+ throw new Error('browser exposed a non-local DevTools endpoint');
101
+ }
102
+
103
+ return new Promise((resolve, reject) => {
104
+ let settled = false;
105
+ const socket = new WebSocket(url.href);
106
+ const finish = error => {
107
+ if (settled) return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ try { socket.close(); } catch {}
111
+ if (error) reject(error);
112
+ else resolve();
113
+ };
114
+ const timer = setTimeout(() => finish(new Error(`DevTools command timed out after ${timeoutMs}ms`)), timeoutMs);
115
+ socket.addEventListener('open', () => socket.send(JSON.stringify({ id: 1, method: 'Browser.getVersion' })));
116
+ socket.addEventListener('message', event => {
117
+ let response;
118
+ try { response = JSON.parse(event.data); }
119
+ catch { return finish(new Error('DevTools returned an invalid response')); }
120
+ if (response.id !== 1) return;
121
+ if (response.error) return finish(new Error(response.error.message || 'DevTools rejected Browser.getVersion'));
122
+ if (!response.result?.product) return finish(new Error('DevTools returned no browser version'));
123
+ finish();
124
+ });
125
+ socket.addEventListener('error', () => finish(new Error('could not connect to the browser DevTools endpoint')));
126
+ socket.addEventListener('close', () => {
127
+ if (!settled) finish(new Error('browser closed the DevTools connection'));
128
+ });
129
+ });
130
+ }
131
+
132
+ function waitForClose(child, timeoutMs) {
133
+ return new Promise(resolve => {
134
+ if (child.exitCode !== null || child.signalCode !== null) return resolve(true);
135
+ const timer = setTimeout(() => {
136
+ child.off('close', onClose);
137
+ resolve(false);
138
+ }, timeoutMs);
139
+ const onClose = () => {
140
+ clearTimeout(timer);
141
+ resolve(true);
142
+ };
143
+ child.once('close', onClose);
144
+ });
145
+ }
146
+
147
+ async function signalBrowserTree(child, platform, signal) {
148
+ if (platform === 'win32' && child.pid) {
149
+ try {
150
+ await execFileAsync('taskkill', [
151
+ '/pid', String(child.pid), '/t',
152
+ ...(signal === 'SIGKILL' ? ['/f'] : [])
153
+ ], { windowsHide: true, timeout: 1000 });
154
+ return;
155
+ } catch {}
156
+ }
157
+ try {
158
+ if (platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
159
+ else child.kill(signal);
160
+ } catch {
161
+ try { child.kill(signal); } catch {}
162
+ }
163
+ }
164
+
165
+ async function stopBrowser(child, platform) {
166
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
167
+ await signalBrowserTree(child, platform, 'SIGTERM');
168
+ if (await waitForClose(child, 1000)) return;
169
+ await signalBrowserTree(child, platform, 'SIGKILL');
170
+ await waitForClose(child, 1000);
171
+ }
172
+
173
+ export async function probeSsoBrowser(browser, {
174
+ platform = process.platform,
175
+ timeoutMs = 5000,
176
+ spawnBrowser = spawn,
177
+ controlRemote = controlBrowser
178
+ } = {}) {
179
+ let profile;
180
+ let child;
181
+ try {
182
+ profile = await mkdtemp(join(tmpdir(), 'sap-ai-dev-sso-probe-'));
183
+ child = spawnBrowser(browser.path, [
184
+ `--user-data-dir=${profile}`,
185
+ '--remote-debugging-port=0',
186
+ '--headless=new',
187
+ '--no-first-run',
188
+ '--no-default-browser-check',
189
+ 'about:blank'
190
+ ], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, detached: platform !== 'win32' });
191
+ const endpoint = await waitForRemoteDebuggingEndpoint(child, timeoutMs);
192
+ await controlRemote(endpoint, timeoutMs);
193
+ return { usable: true };
194
+ } catch (error) {
195
+ const reason = String(error?.message || error).replace(/\s+/g, ' ').slice(0, 240);
196
+ return { usable: false, reason };
197
+ } finally {
198
+ await stopBrowser(child, platform);
199
+ if (profile) await rm(profile, { recursive: true, force: true });
200
+ }
201
+ }
202
+
203
+ export async function findRemoteControllableSsoBrowsers({ probe = probeSsoBrowser, ...options } = {}) {
204
+ const candidates = await findSsoBrowsers(options);
205
+ const results = await Promise.all(candidates.map(async browser => ({ browser, ...await probe(browser) })));
206
+ return {
207
+ browsers: results.filter(result => result.usable).map(result => result.browser),
208
+ unavailable: results.filter(result => !result.usable).map(({ browser, reason }) => ({ ...browser, reason }))
209
+ };
210
+ }
211
+
62
212
  // Every Chromium-based browser the runtime can drive, best first; SAP_BROWSER_EXEC pins one ahead of the rest.
63
213
  export async function findSsoBrowsers({ env = process.env, platform = process.platform, exists = fileExists, appPaths = registeredAppPaths } = {}) {
64
214
  const candidates = [];
package/tools.md CHANGED
@@ -72,11 +72,12 @@ Hidden upstream tools are not directly callable through the proxy. Local workflo
72
72
 
73
73
  Broad/destructive surfaces such as object deletion, SQL trace, arbitrary SAP router calls, transport release/delete, RFC execution, debugger listen/delete-breakpoint helpers, and one-off generator/update helpers are intentionally not advertised by the BAS proxy. UI5 repository writes are hidden too: the ADT filestore only reads UI5 apps, so deploy them with SAP Fiori tools instead.
74
74
 
75
- ## Destination-scoped workflow tools
75
+ ## Local and destination-scoped workflow tools
76
76
 
77
- The proxy adds local multi-step tools to each destination when their required upstream capabilities are available. Like the upstream tools above, they are exposed publicly in lowercase snake_case (`lint_abap`, `get_application_log`, `prepare_abap_change_set`, and so on):
77
+ The proxy adds local tools to the MCP surface. Some are workspace-wide; others are added to each destination when their required upstream capabilities are available. Like the upstream tools above, they are exposed publicly in lowercase snake_case (`lint_abap`, `lint_abap_project`, `get_application_log`, and so on):
78
78
 
79
79
  - `LintABAP` — local in-memory ABAP linting.
80
+ - `LintABAPProject` — scan serialized ABAP objects in the configured workspace root (SAP_AI_DEV_TOOLKIT_WORKSPACE_ROOT, defaulting to the MCP server working directory) using the project abaplint config and available local dependency folders. This is static analysis only; it does not contact SAP or activate objects.
80
81
  - `GetApplicationLog` — bounded convenience mapping to the upstream SAP application-log route when available.
81
82
  - `PrepareABAPChangeSet` and `ApplyABAPChangeSet` — review full-source diffs and recheck source before explicit writes.
82
83
  - `CheckTransportReadiness` — collect a whitelisted bundle of transport, dependency, inactive-object, ABAP Unit, and ATC results.