sap-ai-dev-toolkit 0.9.8 → 0.9.91

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.
@@ -1,5 +1,5 @@
1
- a5b602573f54ce3d03d161aaaeb78559fd73bd51f2ae9a3b7549cee1ddc3fbf6 vsp-linux-x64
2
- 2e336e2999c3654e5eb1e3a8bb42616eb5d5e422ab7ae74ffbff4634be45189d vsp-linux-arm64
3
- afc7fd0f2e40ae5ebfcd3ddb8ac3cb7e7501b2b66d76ac96e3b6054b09366bb5 vsp-darwin-x64
4
- 2d28a870c4a006900eec245384b4bbc2e84651e0ba911cca6107c132ace73575 vsp-darwin-arm64
5
- 7d0189aa7ff936a145b0e3f15bffce8de5aebc252465a720edc3a9b122bd2a28 vsp-win32-x64.exe
1
+ 02831a47fa28a1807d94f6b8485c21f1221c7b3aa0a2b9b7bf0682ab4fb5618f vsp-linux-x64
2
+ 4398dac4c9d304d63e03abbb86523b61e5180b5b355dd1a2ed0d2405454cc8f7 vsp-linux-arm64
3
+ 356a10913b7a46a8ed67132e9b15ea7ff92b8adeb1c799111866e9af176e85fe vsp-darwin-x64
4
+ fb5e72a59618578187eb8e4374571b32baab34397766fc6ea8ab328c6cea849b vsp-darwin-arm64
5
+ 50d5c74194063bc9bcc641fb580f1c1232cce0c1577cdad6dc503f41efd3344c vsp-win32-x64.exe
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.8",
3
+ "version": "0.9.91",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -492,3 +492,56 @@ index 0000000..80741da
492
492
  + }
493
493
  +}
494
494
  +
495
+ diff --git a/pkg/adt/sso.go b/pkg/adt/sso.go
496
+ index d67c4e8..4e9dc45 100644
497
+ --- a/pkg/adt/sso.go
498
+ +++ b/pkg/adt/sso.go
499
+ @@ -155,7 +155,7 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
500
+ // below is the real verdict.
501
+ if err := chromedp.Run(timeoutCtx, chromedp.Navigate(opts.TriggerURL)); err != nil {
502
+ if timeoutCtx.Err() != nil {
503
+ - return nil, fmt.Errorf("SSO capture timed out after %s", timeout)
504
+ + return nil, navigationTimeout(opts.Headless, timeout)
505
+ }
506
+ if opts.Verbose {
507
+ fmt.Fprintf(os.Stderr, "[SSO] navigation reported %v (normal during SSO)\n", err)
508
+ @@ -174,6 +174,17 @@ func CaptureSSOCookies(ctx context.Context, opts SSOOptions) (map[string]string,
509
+ return cookies, nil
510
+ }
511
+
512
+ +// navigationTimeout explains a first page that never finished loading. In a
513
+ +// silent browser that page is waiting on a person — a smart-card certificate
514
+ +// picker or a login prompt headless mode cannot show — so a visible window is
515
+ +// the next step, not a failed start.
516
+ +func navigationTimeout(headless bool, timeout time.Duration) error {
517
+ + if headless {
518
+ + return fmt.Errorf("%w: the silent sign-in page did not finish loading within %s", ErrSSOInteractiveRequired, timeout)
519
+ + }
520
+ + return fmt.Errorf("SSO capture timed out after %s", timeout)
521
+ +}
522
+ +
523
+ // DefaultSSOProfileDir returns a per-host browser profile directory under the
524
+ // user's cache location. Keeping one profile per host means a silent refresh
525
+ // for one system is never disturbed by a login to another.
526
+ diff --git a/pkg/adt/sso_navigation_test.go b/pkg/adt/sso_navigation_test.go
527
+ new file mode 100644
528
+ index 0000000..c851021
529
+ --- /dev/null
530
+ +++ b/pkg/adt/sso_navigation_test.go
531
+ @@ -0,0 +1,16 @@
532
+ +package adt
533
+ +
534
+ +import (
535
+ + "errors"
536
+ + "testing"
537
+ + "time"
538
+ +)
539
+ +
540
+ +func TestSilentNavigationTimeoutAsksForAPerson(t *testing.T) {
541
+ + if err := navigationTimeout(true, time.Second); !errors.Is(err, ErrSSOInteractiveRequired) {
542
+ + t.Fatalf("silent timeout = %v, want ErrSSOInteractiveRequired so a visible window opens", err)
543
+ + }
544
+ + if err := navigationTimeout(false, time.Second); errors.Is(err, ErrSSOInteractiveRequired) {
545
+ + t.Fatalf("headed timeout = %v, must not ask for yet another window", err)
546
+ + }
547
+ +}
@@ -3,6 +3,7 @@ import { closeSync, openSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { installBinary } from '../src/binary.mjs';
6
+ import { detectAdtUrl } from '../src/local-sap-gui.mjs';
6
7
  import { runSetup } from '../src/setup.mjs';
7
8
  import { installUserAssetsForHarnesses } from '../src/install-user-assets.mjs';
8
9
  import { installMcpServersForHarnesses } from '../src/harness-mcp-config.mjs';
@@ -76,8 +77,9 @@ async function withInstallTerminal(action) {
76
77
  }
77
78
  }
78
79
 
79
- async function runInstallSetup() {
80
- return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}) }));
80
+ async function runInstallSetup(binary) {
81
+ const detect = binary ? target => detectAdtUrl(binary, { ...target, env: runtimeEnv }) : undefined;
82
+ return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}), detectAdtUrl: detect }));
81
83
  }
82
84
 
83
85
  function harnessSelectionLabels(ids) {
@@ -300,15 +302,16 @@ async function main() {
300
302
  // runs `go` (only the repository-only build:vsp script does), so a toolchain
301
303
  // download at install time was pure cost. The pinned, checksum-verified VSP
302
304
  // binary ships with the package; the download below is the fallback.
305
+ let binary = brandedEnvValue(runtimeEnv, 'BINARY');
303
306
  try {
304
- if (brandedEnvValue(runtimeEnv, 'BINARY')) {
307
+ if (binary) {
305
308
  await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
306
309
  } else {
307
310
  // The bundled binary resolves in milliseconds; the animation only
308
311
  // appears when provisioning actually takes time (download fallback).
309
312
  const stopBinaryProgress = startProgress('Preparing the pinned VSP runtime for this platform', { output: process.stderr, env: runtimeEnv });
310
313
  try {
311
- await installBinary(pkg, { env: runtimeEnv });
314
+ binary = await installBinary(pkg, { env: runtimeEnv });
312
315
  stopBinaryProgress();
313
316
  await announce('Pinned VSP binary installed and ready.', 'success');
314
317
  } catch (error) {
@@ -326,7 +329,7 @@ async function main() {
326
329
  let setupResult;
327
330
  let setupCompleted = false;
328
331
  try {
329
- setupResult = await runInstallSetup();
332
+ setupResult = await runInstallSetup(binary);
330
333
  setupCompleted = true;
331
334
  } catch (error) {
332
335
  await announce(`BAS MCP setup failed: ${error.message}`, 'error');
package/src/launcher.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { readFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { isIP } from 'node:net';
4
5
  import { dirname, join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
7
- import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
8
- import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
8
+ import { adtBaseUrl, adtPort, detectAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix } from './local-sap-gui.mjs';
9
+ import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './binary.mjs';
9
10
  import { MCPProxy } from './mcp-proxy.mjs';
10
11
  import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
11
12
  import { runSetup } from './setup.mjs';
@@ -165,7 +166,14 @@ function probeDiagnostic(destination) {
165
166
 
166
167
  // npx MCP entries run with --ignore-scripts, so postinstall's verified
167
168
  // download fallback never ran; a missing or corrupt bundled binary heals here.
168
- async function binaryOrError() {
169
+ // Port detection and the server share one resolution, so a failed download is not repeated.
170
+ let binaryResolution;
171
+ function binaryOrError() {
172
+ binaryResolution ??= resolveBinary();
173
+ return binaryResolution;
174
+ }
175
+
176
+ async function resolveBinary() {
169
177
  const notes = [];
170
178
  const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
171
179
  if (found) return found;
@@ -190,6 +198,75 @@ async function detectLocalAdtUrl(target) {
190
198
  return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
191
199
  }
192
200
 
201
+ const adtUrlCachePath = () => join(cacheDirectory(runtimeEnv), 'adt-urls.json');
202
+
203
+ async function cachedAdtUrls() {
204
+ try { return JSON.parse(await readFile(adtUrlCachePath(), 'utf8')) || {}; }
205
+ catch { return {}; }
206
+ }
207
+
208
+ async function rememberAdtUrl(configured, found) {
209
+ try {
210
+ const path = adtUrlCachePath();
211
+ const urls = { ...await cachedAdtUrls(), [configured.toLowerCase()]: found };
212
+ await mkdir(dirname(path), { recursive: true });
213
+ const temporary = `${path}.${process.pid}.tmp`;
214
+ await writeFile(temporary, `${JSON.stringify(urls, null, 2)}\n`);
215
+ await rename(temporary, path);
216
+ } catch {
217
+ // The cache only saves a later scan.
218
+ }
219
+ }
220
+
221
+ // A named port gets one probe, a scan result is remembered, and every port is
222
+ // swept only when the configuration never named one.
223
+ async function resolveLocalAdtUrl(name, configured, client) {
224
+ const log = message => logLine(`[sap-ai-dev] ${name}: ${message}`);
225
+ const detect = async target => {
226
+ const started = Date.now();
227
+ // MCP output channels cannot animate, so a long scan reports that it is still running.
228
+ const heartbeat = setInterval(() => log(`port scan still running on ${new URL(target.url).hostname} (${Math.round((Date.now() - started) / 1000)} s)`), 20_000);
229
+ try { return await detectLocalAdtUrl({ client, ...target }); }
230
+ catch (error) { return { url: '', reason: error.message, failed: true }; }
231
+ finally { clearInterval(heartbeat); }
232
+ };
233
+ const explicit = hasExplicitPort(configured);
234
+ let verdict = { url: '', reason: '' };
235
+ if (explicit) {
236
+ verdict = await detect({ url: configured, ports: [adtPort(configured)] });
237
+ if (verdict.url) return { url: verdict.url, verified: true };
238
+ log(`ADT did not answer at ${configured}: ${verdict.reason}`);
239
+ if (verdict.unresolved || verdict.failed) return { url: configured, verified: false, reason: verdict.reason };
240
+ }
241
+ const remembered = (await cachedAdtUrls())[configured.toLowerCase()];
242
+ if (remembered) {
243
+ const recheck = await detect({ url: remembered, ports: [adtPort(remembered)] });
244
+ if (recheck.url) {
245
+ log(`ADT answers at ${recheck.url} (found by an earlier port scan)`);
246
+ return { url: recheck.url, verified: true };
247
+ }
248
+ }
249
+ const host = new URL(configured).hostname;
250
+ log(`port scan in progress on ${host} (usual ADT ports)`);
251
+ verdict = await detect({ url: configured });
252
+ if (!verdict.url && !explicit && !verdict.unresolved && !verdict.failed) {
253
+ log(`no ADT port on the usual ports (${verdict.reason}); port scan in progress on every conventional SAP port of ${host} (can take a minute)`);
254
+ verdict = await detect({ url: configured, exhaustive: true });
255
+ }
256
+ if (verdict.url) {
257
+ log(`port scan finished; ADT answers at ${verdict.url}`);
258
+ await rememberAdtUrl(configured, verdict.url);
259
+ return { url: verdict.url, verified: true };
260
+ }
261
+ log(`port scan finished; ${verdict.reason}; keeping ${configured}. Add the ADT port to SAP_URL or rerun sap-ai-dev --setup.`);
262
+ return { url: configured, verified: false, reason: verdict.reason };
263
+ }
264
+
265
+ function noProxyEnv(env, host) {
266
+ const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
267
+ return { NO_PROXY: noProxy, no_proxy: noProxy };
268
+ }
269
+
193
270
  async function discoverForCommand() {
194
271
  const env = { ...runtimeEnv };
195
272
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
@@ -206,56 +283,54 @@ async function configuredLocalSapGuiDestination(env) {
206
283
  if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
207
284
  const client = env.SAP_CLIENT || '001';
208
285
  const systemId = env.SAP_SYSTEM_ID || '';
286
+ const configuredUrl = adtBaseUrl(env.SAP_URL);
209
287
  // Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
210
- if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
211
- const cacheKey = `${systemId || new URL(env.SAP_URL).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
212
- let url = env.SAP_URL;
213
- // SAP GUI landscapes carry no HTTP port, and the SSO browser cannot sign in on a guessed one.
214
- if (!hasExplicitPort(url)) {
215
- const host = new URL(url).hostname;
216
- logLine(`[sap-ai-dev] ${name}: SAP_URL has no port; port scan in progress on ${host} (usual ADT ports)`);
217
- let detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
218
- if (!detected) {
219
- logLine(`[sap-ai-dev] ${name}: no ADT port on the usual ports; port scan in progress on every conventional SAP port of ${host} (can take a minute)`);
220
- detected = await detectLocalAdtUrl({ url, client, exhaustive: true }).catch(() => '');
221
- }
222
- logLine(detected
223
- ? `[sap-ai-dev] ${name}: port scan finished; ADT answers at ${detected}`
224
- : `[sap-ai-dev] ${name}: port scan finished; no ADT port answered on ${host}; add the port to SAP_URL or rerun sap-ai-dev --setup`);
225
- url = detected || url;
226
- }
288
+ const sso = !['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase());
289
+ const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
290
+ const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
291
+ const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
292
+ // SNI-routed Basic entries pin port and certificate names on purpose; SSO ignores those names.
293
+ const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
294
+ ? await resolveLocalAdtUrl(name, configuredUrl, client)
295
+ : { url: configuredUrl };
296
+ // The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute VSP.
297
+ const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
298
+ const probe = adt.verified === undefined
299
+ ? { status: 'configured', available: true }
300
+ : (adt.verified ? { status: `adt-verified at ${adt.url}`, available: true } : { status: `adt-unverified: ${adt.reason}`, available: false });
301
+ if (sso) {
302
+ const cacheKey = `${systemId || new URL(configuredUrl).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
303
+ if (isIP(new URL(adt.url).hostname.replace(/^\[(.*)\]$/, '$1'))) logLine(`[sap-ai-dev] ${name}: SSO is configured for an IP address; Kerberos and the browser certificate check need the DNS host name`);
227
304
  return {
228
305
  source: 'sap-gui-local',
229
306
  name,
230
- url,
307
+ url: adt.url,
231
308
  client,
232
309
  systemId,
233
310
  authentication: 'SSO',
234
311
  // SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
235
- childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '' },
236
- probe: { status: 'configured', available: true }
312
+ childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', ...direct },
313
+ probe
237
314
  };
238
315
  }
239
- const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
240
- const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
241
- const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
242
316
  const tlsRoute = tlsServerNames.length
243
- ? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
317
+ ? await createTlsServerNameAdtProxy({ destinationUrl: configuredUrl, tlsServerNames, caFile: tlsCaFile || undefined })
244
318
  : null;
245
319
  return {
246
320
  source: 'sap-gui-local',
247
321
  name,
248
- url: tlsRoute?.url || env.SAP_URL,
249
- backendUrl: tlsRoute ? env.SAP_URL : undefined,
322
+ url: tlsRoute?.url || adt.url,
323
+ backendUrl: tlsRoute ? configuredUrl : undefined,
250
324
  client,
251
325
  systemId,
252
326
  authentication: 'Basic',
253
327
  childEnv: {
254
328
  SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
255
- SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
329
+ SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || '',
330
+ ...direct
256
331
  },
257
332
  close: tlsRoute?.close,
258
- probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
333
+ probe: tlsRoute ? { status: 'configured-tls-server-name', available: true } : probe
259
334
  };
260
335
  }
261
336
 
@@ -1,4 +1,5 @@
1
1
  import { platform, homedir } from 'node:os';
2
+ import { isIP } from 'node:net';
2
3
  import { promisify } from 'node:util';
3
4
  import { execFile } from 'node:child_process';
4
5
  import { fileURLToPath } from 'node:url';
@@ -149,6 +150,8 @@ function normalizeRecord(record, sourcePath) {
149
150
  const port = endpoint ? Number(endpoint[2]) : 0;
150
151
  const instance = instanceFrom(record) || (port >= 3200 && port <= 3299 ? String(port - 3200).padStart(2, '0') : '');
151
152
  const client = text(record.client || record.sapclient) || '001';
153
+ // A load-balanced entry names the message server, whose port is 3600 + its instance.
154
+ const messageServerPort = text(record.msport).match(/^36(\d{2})$/);
152
155
  return {
153
156
  source: 'sap-gui-local',
154
157
  name,
@@ -157,6 +160,7 @@ function normalizeRecord(record, sourcePath) {
157
160
  systemId,
158
161
  instance,
159
162
  client,
163
+ ...(messageServerPort ? { messageServer: { host, httpPort: 8100 + Number(messageServerPort[1]) } } : {}),
160
164
  authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
161
165
  authMode: 'basic',
162
166
  ssoHint: ssoHintFrom(record),
@@ -182,6 +186,7 @@ function parseLandscapeXml(content, sourcePath) {
182
186
  // For load-balanced entries `server` is a logon group, not a host.
183
187
  if (!messageServer?.host) continue;
184
188
  record.server = messageServer.host;
189
+ record.msport = messageServer.port;
185
190
  }
186
191
  const normalized = normalizeRecord(record, sourcePath);
187
192
  if (normalized) records.push(normalized);
@@ -407,25 +412,112 @@ export function hasExplicitPort(url) {
407
412
  return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
408
413
  }
409
414
 
415
+ // VSP appends every ADT path to the base URL, so a pasted ADT path would double up.
416
+ export function adtBaseUrl(url) {
417
+ return String(url || '').trim().replace(/[?#].*$/, '').replace(/\/sap\/bc\/adt(?:\/.*)?$/i, '').replace(/\/+$/, '');
418
+ }
419
+
420
+ // A gateway path prefix cannot be probed by host and port alone.
421
+ export function hasPathPrefix(url) {
422
+ return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+\/[^?#]/i.test(adtBaseUrl(url));
423
+ }
424
+
425
+ export function adtPort(url) {
426
+ const parsed = new URL(url);
427
+ return Number(parsed.port) || (parsed.protocol === 'http:' ? 80 : 443);
428
+ }
429
+
430
+ // A redirect proves less than a 401 or 200: a gateway can redirect every path.
431
+ function adtRank(finding) {
432
+ return (finding.secure ? 0 : 2) + (finding.status >= 300 && finding.status < 400 ? 1 : 0);
433
+ }
434
+
435
+ // Picks the ADT URL from VSP detect's JSON, or explains why there is none.
436
+ export function adtVerdict(result, protocol = 'https:') {
437
+ const findings = Array.isArray(result?.findings) ? result.findings.filter(Boolean) : [];
438
+ const usable = findings
439
+ .filter(finding => finding.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')) && (finding.secure || protocol === 'http:'))
440
+ .sort((left, right) => adtRank(left) - adtRank(right));
441
+ if (usable.length) return { url: usable[0].url, reason: '' };
442
+ const host = result?.host || 'the host';
443
+ const unsearched = Array.isArray(result?.unsearched) ? result.unsearched[0] : undefined;
444
+ if (unsearched) {
445
+ const unresolved = /does not resolve/i.test(String(unsearched.reason));
446
+ return { url: '', unresolved, reason: unresolved ? `${unsearched.object} does not resolve; connect to the company network or VPN` : `${unsearched.object}: ${unsearched.reason}` };
447
+ }
448
+ const plain = findings.find(finding => finding.kind === 'adt');
449
+ // The SSO session cookie is the whole credential; https is never traded for http silently.
450
+ if (plain) return { url: '', reason: `only plain HTTP answers ADT (${plain.url}); enter it explicitly to accept an unencrypted connection` };
451
+ const inactive = findings.find(finding => finding.kind === 'sap-without-adt');
452
+ if (inactive) return { url: '', reason: `SAP answers at ${inactive.url} but /sap/bc/adt is not active; ask basis to activate it in SICF` };
453
+ const tls = findings.find(finding => finding.kind === 'tls-name-mismatch');
454
+ if (tls && /certificate required|bad certificate/i.test(String(tls.detail))) {
455
+ return { url: '', reason: `port ${tls.port} demands a client certificate (smart card), which VSP cannot present` };
456
+ }
457
+ if (tls) return { url: '', reason: `port ${tls.port} presents a certificate ${tls.certHost ? `for ${tls.certHost}` : 'this machine does not trust'}${tls.detail ? ` (${tls.detail})` : ''}` };
458
+ const open = findings.filter(finding => ['open', 'http'].includes(finding.kind)).map(finding => finding.port);
459
+ if (open.length) return { url: '', reason: `${open.length === 1 ? `port ${open[0]} on ${host} accepts` : `ports ${open.join(', ')} on ${host} accept`} connections but none answered as ADT` };
460
+ return { url: '', reason: `no port answered on ${host}` };
461
+ }
462
+
410
463
  // A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
411
- // probes the conventional ADT ports and reports which one answers.
412
- export async function detectAdtUrl(binary, { url, client, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
464
+ // probes the conventional ADT ports and reports which one answers. A host that
465
+ // accepts every port makes each probe wait out its HTTP timeouts, so the full
466
+ // sweep gets minutes.
467
+ export async function detectAdtUrl(binary, { url, client, instance, env = process.env, exhaustive = false, ports = [], timeoutMs = exhaustive ? 600_000 : 120_000 } = {}) {
413
468
  let configured;
414
- try { configured = new URL(url); } catch { return ''; }
469
+ try { configured = new URL(url); } catch { return { url: '', reason: `${url} is not a valid URL` }; }
415
470
  const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
416
- const args = ['detect', host, '--json', ...(client ? ['--client', String(client)] : []), ...(exhaustive ? ['--all'] : []), ...ports.flatMap(port => ['--port', String(port)])];
471
+ const args = ['detect', host, '--json',
472
+ ...(client ? ['--client', String(client)] : []),
473
+ ...(/^\d{2}$/.test(String(instance || '')) ? ['--instance', String(instance)] : []),
474
+ ...(exhaustive ? ['--all'] : []),
475
+ ...ports.flatMap(port => ['--port', String(port)])];
417
476
  // A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
418
477
  const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
419
- env = { ...env, NO_PROXY: noProxy, no_proxy: noProxy };
420
478
  const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
421
479
  ? [process.execPath, binary]
422
480
  : (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
481
+ let stdout;
423
482
  try {
424
- const { stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 });
425
- const adt = (JSON.parse(stdout)?.findings || []).filter(finding => finding?.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')));
426
- // The SSO session cookie is the whole credential; never trade https for http.
427
- return (adt.find(finding => finding.secure) || (configured.protocol === 'http:' ? adt[0] : undefined))?.url || '';
428
- } catch {
429
- return '';
483
+ ({ stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env: { ...env, NO_PROXY: noProxy, no_proxy: noProxy }, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 }));
484
+ } catch (error) {
485
+ const why = error.killed ? `timed out after ${Math.round(timeoutMs / 1000)} s` : (typeof error.code === 'string' ? error.code : `exit code ${error.code}`);
486
+ const blocked = ['EACCES', 'EPERM', 'UNKNOWN'].includes(error.code) ? '; antivirus or application control may block it' : '';
487
+ return { url: '', reason: `VSP detect could not run (${why}) from ${binary}${blocked}` };
430
488
  }
489
+ try { return adtVerdict(JSON.parse(stdout), configured.protocol); }
490
+ catch { return { url: '', reason: 'VSP detect returned unreadable output' }; }
491
+ }
492
+
493
+ // Application servers appear by their SM51 names, host_SID_NN.
494
+ export function parseMessageServerServers(body, { host = '', systemId = '' } = {}) {
495
+ const domain = isIP(host) ? '' : (/^[^.]+\.(.+)$/.exec(host)?.[1] || '');
496
+ const servers = new Map();
497
+ for (const [, name, sid, instance] of String(body).matchAll(/\b([A-Za-z0-9][A-Za-z0-9.-]*)_([A-Za-z][A-Za-z0-9]{2})_(\d{2})\b/g)) {
498
+ if (systemId && sid.toUpperCase() !== systemId.toUpperCase()) continue;
499
+ const appHost = name.includes('.') || !domain ? name : `${name}.${domain}`;
500
+ servers.set(`${appHost.toLowerCase()}:${instance}`, { host: appHost, instance });
501
+ }
502
+ return [...servers.values()];
503
+ }
504
+
505
+ // A load-balanced landscape entry names the message server, which serves no
506
+ // ADT; its HTTP port lists the application servers that do, the same source
507
+ // SAP Web Dispatcher reads.
508
+ export async function messageServerAppServers({ host, httpPort, systemId } = {}, { timeoutMs = 5000 } = {}) {
509
+ if (!host || !httpPort) return [];
510
+ const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
511
+ for (const path of ['/msgserver/text/logon', '/msgserver/text/serverlist']) {
512
+ let body = '';
513
+ try {
514
+ const response = await fetch(`http://${authority}:${httpPort}${path}`, { signal: AbortSignal.timeout(timeoutMs) });
515
+ if (response.ok) body = await response.text();
516
+ } catch {
517
+ continue;
518
+ }
519
+ const servers = parseMessageServerServers(body, { host, systemId });
520
+ if (servers.length) return servers;
521
+ }
522
+ return [];
431
523
  }
package/src/setup.mjs CHANGED
@@ -4,7 +4,7 @@ import { platform } from 'node:os';
4
4
  import { isIP } from 'node:net';
5
5
  import { checkboxPrompt, colorText, formatStatus, selectPrompt, startProgress, textPrompt } from './terminal-ui.mjs';
6
6
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
7
- import { defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
7
+ import { adtBaseUrl, adtPort, defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix, messageServerAppServers } from './local-sap-gui.mjs';
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';
@@ -124,41 +124,108 @@ function localAuthenticationLabel(authMode) {
124
124
  return authMode === 'sso' ? 'SSO' : 'Basic';
125
125
  }
126
126
 
127
- // The SSO browser signs in on the ADT URL itself, and SAP GUI landscapes never
128
- // carry the HTTP port, so a portless URL would send it to 443.
129
- async function detectSsoAdtPort(destination, label, client, { input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
130
- if (!detectAdtUrl || hasExplicitPort(destination.url)) return;
131
- const scan = async (message, options) => {
132
- const stopProgress = startProgress(message, { output, env, label: 'SSO' });
133
- try { return await detectAdtUrl({ url: destination.url, client, ...options }); }
134
- catch { return ''; }
135
- finally { stopProgress(); }
127
+ // Ports typed at the prompt are range-checked before anything parses them as a URL.
128
+ export function adtCandidate(answer, baseUrl) {
129
+ const value = String(answer || '').trim();
130
+ if (/^\d+$/.test(value)) {
131
+ const port = Number(value);
132
+ if (port < 1 || port > 65535) throw new Error('Enter a port between 1 and 65535, or a full URL.');
133
+ const base = new URL(baseUrl);
134
+ return `${base.protocol}//${base.hostname}:${port}`;
135
+ }
136
+ const validation = validateAdtUrlCandidate(value);
137
+ if (validation !== true) throw new Error(validation);
138
+ return adtBaseUrl(value);
139
+ }
140
+
141
+ // Cheapest evidence first: named ports get one probe, then the host's usual
142
+ // ports, then the message server's application servers, then every port.
143
+ async function findAdtUrl(destination, label, client, { candidates = [], scan = true, output = stdout, env = process.env, detectAdtUrl } = {}) {
144
+ const detect = async (line, activity, target) => {
145
+ print(output, formatStatus(line, 'progress', output, 'ADT'));
146
+ const started = Date.now();
147
+ const progress = startProgress(activity, { output, env, label: 'ADT' });
148
+ // A short, ticking spinner: a long line would truncate the animation away and look frozen.
149
+ const ticker = setInterval(() => progress.update(`${activity} (${Math.round((Date.now() - started) / 1000)} s)`), 1000);
150
+ try { return await detectAdtUrl({ client, ...target }); }
151
+ catch (error) { return { url: '', reason: error.message }; }
152
+ finally {
153
+ clearInterval(ticker);
154
+ progress();
155
+ }
136
156
  };
137
- print(output, formatStatus(`Port scan in progress on ${new URL(destination.url).hostname} to find the ADT port for ${label}.`, 'progress', output, 'SSO'));
138
- let detected = await scan(`Detecting the ADT port for ${label}`, {});
139
- if (!detected) {
140
- print(output, formatStatus(`No ADT port answered on the usual ports for ${label}; port scan in progress on every conventional SAP port (this can take a minute).`, 'progress', output, 'SSO'));
141
- detected = await scan(`Sweeping SAP HTTP(S) ports for ${label}`, { exhaustive: true });
157
+ let verdict = { url: '', reason: `no port was checked for ${label}` };
158
+ for (const candidate of [...new Set([destination.url, ...candidates].filter(url => url && hasExplicitPort(url)))]) {
159
+ verdict = await detect(`Checking ADT at ${candidate}`, `Checking ${candidate}`, { url: candidate, ports: [adtPort(candidate)] });
160
+ if (verdict.url || verdict.unresolved) return verdict;
142
161
  }
143
- // Nothing on the machine knows the port, so the person who does is asked until one answers.
144
- while (!detected) {
145
- const host = new URL(destination.url).hostname;
146
- print(output, formatStatus(`No ADT port answered on ${host}. Ask basis or check SMICM (Goto > Services) for the HTTPS port.`, 'warning', output, 'SSO'));
147
- const answer = String(await textPrompt({
148
- message: `ADT port or URL for ${label}`,
149
- placeholder: '44300 or https://host:44300',
150
- validate: value => /^\d{2,5}$/.test(String(value).trim()) || validateAdtUrlCandidate(String(value).trim())
151
- }, { input, output })).trim();
152
- const candidate = /^\d+$/.test(answer) ? `${new URL(destination.url).origin}:${answer}` : answer;
153
- const port = Number(new URL(candidate).port || (candidate.startsWith('http:') ? 80 : 443));
154
- detected = await detectAdtUrl({ url: candidate, client, ports: [port] }).catch(() => '');
155
- if (!detected && await confirmPrompt(`${candidate} did not answer as ADT. Use it anyway? (y/Enter=yes, n=try another)`, { input, output })) {
156
- destination.url = candidate;
157
- return;
162
+ if (!scan) return verdict;
163
+ const host = new URL(destination.url).hostname;
164
+ verdict = await detect(`Port scan in progress on ${host} to find the ADT port for ${label}`, `Scanning the usual ADT ports on ${host}`, { url: destination.url, ...(destination.instance ? { instance: destination.instance } : {}) });
165
+ if (verdict.url || verdict.unresolved) return verdict;
166
+ if (destination.messageServer) {
167
+ const { host: messageHost, httpPort } = destination.messageServer;
168
+ print(output, formatStatus(`Asking the message server ${messageHost}:${httpPort} for the application servers of ${label}`, 'progress', output, 'ADT'));
169
+ const servers = (await messageServerAppServers({ ...destination.messageServer, systemId: destination.systemId })).slice(0, 3);
170
+ print(output, formatStatus(servers.length
171
+ ? `The message server lists ${servers.map(server => `${server.host} (instance ${server.instance})`).join(', ')}.`
172
+ : `The message server at ${messageHost}:${httpPort} listed no application servers.`, 'info', output, 'ADT'));
173
+ for (const server of servers) {
174
+ const found = await detect(`Port scan in progress on application server ${server.host} (instance ${server.instance})`, `Scanning ${server.host}`, { url: `${new URL(destination.url).protocol}//${server.host}`, instance: server.instance });
175
+ if (found.url) return found;
158
176
  }
159
177
  }
160
- destination.url = detected;
161
- print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
178
+ print(output, formatStatus(`No ADT port on the usual ports for ${label}: ${verdict.reason}.`, 'info', output, 'ADT'));
179
+ return detect(`Scanning the remaining conventional SAP ports on ${host} (about 300 ports; this can take a few minutes)`, `Scanning the remaining SAP ports on ${host}`, { url: destination.url, exhaustive: true });
180
+ }
181
+
182
+ // An SSO sign-in can only start where ADT answers, so for SSO setup does not
183
+ // end on an unverified URL unless the user chooses to.
184
+ async function settleAdtUrl(destination, label, client, { insist = false, candidates = [], input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
185
+ if (!detectAdtUrl) return 'unchecked';
186
+ if (hasPathPrefix(destination.url)) {
187
+ print(output, formatStatus(`${destination.url} has a gateway path, so setup uses it as entered without a port scan.`, 'info', output, 'ADT'));
188
+ return 'unchecked';
189
+ }
190
+ const options = { candidates, output, env, detectAdtUrl };
191
+ let verdict = await findAdtUrl(destination, label, client, options);
192
+ while (!verdict.url) {
193
+ print(output, formatStatus(`No ADT answer for ${label}: ${verdict.reason}.`, 'warning', output, 'ADT'));
194
+ if (!insist) return 'unverified';
195
+ const choice = await selectPrompt({
196
+ message: `ADT for ${label}`,
197
+ defaultValue: 'enter',
198
+ choices: [
199
+ { name: 'Enter the ADT port or URL (basis finds it in SMICM > Goto > Services)', value: 'enter', shortcut: 'e' },
200
+ { name: 'Scan again, for example after connecting the VPN', value: 'retry', shortcut: 'r' },
201
+ { name: `Keep ${destination.url} without an ADT answer`, value: 'keep', shortcut: 'k' },
202
+ { name: 'Skip this system', value: 'skip', shortcut: 's' }
203
+ ]
204
+ }, { input, output });
205
+ if (choice === 'keep') return 'unverified';
206
+ if (choice === 'skip') return 'skipped';
207
+ if (choice === 'enter') {
208
+ const answer = await textPrompt({
209
+ message: `ADT port or URL for ${label}`,
210
+ placeholder: '44300 or https://host:44300',
211
+ validate: value => {
212
+ try { adtCandidate(value, destination.url); return true; }
213
+ catch (error) { return error.message; }
214
+ }
215
+ }, { input, output });
216
+ destination.url = adtCandidate(answer, destination.url);
217
+ }
218
+ const retry = choice === 'retry';
219
+ verdict = await findAdtUrl(destination, label, client, { ...options, candidates: retry ? candidates : [], scan: retry || !hasExplicitPort(destination.url) });
220
+ }
221
+ destination.url = verdict.url;
222
+ print(output, formatStatus(`ADT answers at ${verdict.url}.`, 'success', output, 'ADT'));
223
+ return 'verified';
224
+ }
225
+
226
+ function warnSsoIpHost(destination, label, output) {
227
+ if (!isIP(new URL(destination.url).hostname.replace(/^\[(.*)\]$/, '$1'))) return;
228
+ print(output, formatStatus(`${label} uses an IP address; SSO needs the DNS host name for Kerberos and the browser certificate check. Enter the host name if the sign-in fails.`, 'warning', output, 'SSO'));
162
229
  }
163
230
 
164
231
  async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
@@ -252,7 +319,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
252
319
  source: 'sap-gui-local',
253
320
  name,
254
321
  serverName: name,
255
- url,
322
+ url: adtBaseUrl(url),
256
323
  host: parsedUrl?.hostname || '',
257
324
  client: '001',
258
325
  authentication: 'Basic',
@@ -263,16 +330,21 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
263
330
  // destination picker.
264
331
  configuredManually: true
265
332
  };
266
- await maybeConfigureTlsSelfHeal(destination, name, { input, output });
267
333
  const clients = await promptSapClients(destination, { input, output });
268
334
  const authMode = await chooseLocalAuthMode(destination, { input, output, env });
269
- if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { input, output, env, detectAdtUrl });
270
- destination.authentication = localAuthenticationLabel(authMode);
271
- destination.authMode = authMode;
272
- for (const client of clients) {
273
- expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
335
+ const adt = await settleAdtUrl(destination, name, clients[0], { insist: authMode === 'sso', input, output, env, detectAdtUrl });
336
+ if (adt === 'skipped') {
337
+ print(output, formatStatus(`Skipped ${name}; no MCP server was added for it.`, 'info', output, 'Manual entry'));
338
+ } else {
339
+ if (authMode === 'sso') warnSsoIpHost(destination, name, output);
340
+ else await maybeConfigureTlsSelfHeal(destination, name, { input, output });
341
+ destination.authentication = localAuthenticationLabel(authMode);
342
+ destination.authMode = authMode;
343
+ for (const client of clients) {
344
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
345
+ }
346
+ print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
274
347
  }
275
- print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
276
348
  addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
277
349
  }
278
350
  return expanded;
@@ -293,6 +365,18 @@ function localClientDestinationName(destination, client, { multipleClients = fal
293
365
  }
294
366
 
295
367
 
368
+ // Two systems sharing a SID cannot be told apart, so neither URL is offered for them.
369
+ function previousLocalAdtUrls(config) {
370
+ const urls = new Map();
371
+ for (const entry of Object.values(config?.servers || {})) {
372
+ const entryEnv = entry?.env || {};
373
+ if (entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE !== 'sap-gui-local' || !entryEnv.SAP_SYSTEM_ID || !entryEnv.SAP_URL) continue;
374
+ const key = String(entryEnv.SAP_SYSTEM_ID).toUpperCase();
375
+ urls.set(key, urls.has(key) && urls.get(key) !== entryEnv.SAP_URL ? '' : entryEnv.SAP_URL);
376
+ }
377
+ return urls;
378
+ }
379
+
296
380
  function safeProbe(probe) {
297
381
  const result = {};
298
382
  if (typeof probe?.status === 'string') result.status = probe.status;
@@ -330,6 +414,7 @@ function safeLocalSapGuiDestination(destination) {
330
414
  proxyType: 'Internet',
331
415
  tlsServerName: destination.tlsServerName,
332
416
  tlsServerNames: destination.tlsServerNames,
417
+ messageServer: destination.messageServer,
333
418
  probe: safeProbe(destination.probe),
334
419
  childEnv: destination.childEnv || {}
335
420
  };
@@ -384,7 +469,7 @@ function printConnectionInstructions(output, result) {
384
469
 
385
470
  // Checkbox selection over the discovered destinations plus, outside BAS, the
386
471
  // per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
387
- async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
472
+ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map() } = {}) {
388
473
  const orderedDestinations = [...destinations].sort((left, right) => {
389
474
  const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
390
475
  const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
@@ -446,17 +531,24 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
446
531
  const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
447
532
  const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
448
533
  const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
449
- const fallback = defaultAdtUrl(destination);
534
+ const guess = defaultAdtUrl(destination);
535
+ // A URL an earlier setup verified beats the 443NN naming convention.
536
+ const fallback = previousAdtUrls.get(String(destination.systemId || '').toUpperCase()) || guess;
450
537
  const url = await textPrompt({
451
538
  message: `ADT URL for ${promptLabel}`,
452
539
  placeholder: fallback || 'https://host:44300',
453
540
  validate: value => validateAdtUrlCandidate(value || fallback),
454
541
  required: false
455
542
  }, { input, output });
456
- destination.url = url || fallback;
457
- await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
543
+ destination.url = adtBaseUrl(url || fallback);
458
544
  const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
459
- if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { input, output, env, detectAdtUrl });
545
+ const adt = await settleAdtUrl(destination, promptLabel, clients[0], { insist: authMode === 'sso', candidates: url ? [] : [guess], input, output, env, detectAdtUrl });
546
+ if (adt === 'skipped') {
547
+ print(output, formatStatus(`Skipped ${promptLabel}; no MCP server was added for it.`, 'info', output, 'Local SAP GUI'));
548
+ continue;
549
+ }
550
+ if (authMode === 'sso') warnSsoIpHost(destination, promptLabel, output);
551
+ else await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
460
552
  destination.authentication = localAuthenticationLabel(authMode);
461
553
  destination.authMode = authMode;
462
554
  for (const client of clients) {
@@ -524,9 +616,12 @@ export async function runSetup({
524
616
  let createdKeys = [];
525
617
  let configPath;
526
618
  let managedKeys = [];
619
+ let previousAdtUrls = new Map();
527
620
  try {
528
621
  configPath = await resolveMcpConfigPath(env);
529
- managedKeys = collectManagedCloudFoundryKeyReferences(await readMcpConfig(configPath));
622
+ const existingConfig = await readMcpConfig(configPath);
623
+ managedKeys = collectManagedCloudFoundryKeyReferences(existingConfig);
624
+ previousAdtUrls = previousLocalAdtUrls(existingConfig);
530
625
  } catch {
531
626
  warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
532
627
  }
@@ -628,7 +723,7 @@ export async function runSetup({
628
723
  selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
629
724
  if (!selected.length) return await exitWithoutDestinations();
630
725
  } else {
631
- selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
726
+ selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
632
727
  }
633
728
 
634
729
  let sapDevelopmentServers = [];
@@ -267,7 +267,7 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
267
267
  const footer = error => {
268
268
  if (error) return [[error, 'red']];
269
269
  if (awaitingEmptyConfirm) return [['Nothing is selected yet. Press Space to select the highlighted item (❯).', 'yellow'], [confirmEmpty, 'yellow']];
270
- if (searchable && matchingIndexes().length === 0) return [['No choices match. Backspace or Esc clears search.', 'yellow']];
270
+ if (searchable && matchingIndexes().length === 0) return [['No choices match.', 'yellow']];
271
271
  if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
272
272
  return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
273
273
  };
@@ -281,7 +281,10 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
281
281
  ensureVisible(pageSize, indexes.length, cursorPosition);
282
282
  const lines = [];
283
283
  lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
284
- if (searchable) lines.push(truncateToColumns(`Search: ${searchQuery || (searchMode ? '' : 'type a prefix; / for a or m')}`, columns));
284
+ if (searchable) {
285
+ const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix; / before a or m';
286
+ lines.push(truncateToColumns(`Search: ${searchQuery || 'type prefix'} (${hint})`, columns));
287
+ }
285
288
  const end = Math.min(indexes.length, top + pageSize);
286
289
  for (let index = top; index < end; index += 1) {
287
290
  const choiceIndex = indexes[index];