sap-ai-dev-toolkit 0.9.7 → 0.9.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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",
@@ -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,68 @@ 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 = target => detectLocalAdtUrl({ client, ...target }).catch(error => ({ url: '', reason: error.message, failed: true }));
226
+ const explicit = hasExplicitPort(configured);
227
+ let verdict = { url: '', reason: '' };
228
+ if (explicit) {
229
+ verdict = await detect({ url: configured, ports: [adtPort(configured)] });
230
+ if (verdict.url) return { url: verdict.url, verified: true };
231
+ log(`ADT did not answer at ${configured}: ${verdict.reason}`);
232
+ if (verdict.unresolved || verdict.failed) return { url: configured, verified: false, reason: verdict.reason };
233
+ }
234
+ const remembered = (await cachedAdtUrls())[configured.toLowerCase()];
235
+ if (remembered) {
236
+ const recheck = await detect({ url: remembered, ports: [adtPort(remembered)] });
237
+ if (recheck.url) {
238
+ log(`ADT answers at ${recheck.url} (found by an earlier port scan)`);
239
+ return { url: recheck.url, verified: true };
240
+ }
241
+ }
242
+ const host = new URL(configured).hostname;
243
+ log(`port scan in progress on ${host} (usual ADT ports)`);
244
+ verdict = await detect({ url: configured });
245
+ if (!verdict.url && !explicit && !verdict.unresolved && !verdict.failed) {
246
+ 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)`);
247
+ verdict = await detect({ url: configured, exhaustive: true });
248
+ }
249
+ if (verdict.url) {
250
+ log(`port scan finished; ADT answers at ${verdict.url}`);
251
+ await rememberAdtUrl(configured, verdict.url);
252
+ return { url: verdict.url, verified: true };
253
+ }
254
+ log(`port scan finished; ${verdict.reason}; keeping ${configured}. Add the ADT port to SAP_URL or rerun sap-ai-dev --setup.`);
255
+ return { url: configured, verified: false, reason: verdict.reason };
256
+ }
257
+
258
+ function noProxyEnv(env, host) {
259
+ const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
260
+ return { NO_PROXY: noProxy, no_proxy: noProxy };
261
+ }
262
+
193
263
  async function discoverForCommand() {
194
264
  const env = { ...runtimeEnv };
195
265
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
@@ -206,50 +276,54 @@ async function configuredLocalSapGuiDestination(env) {
206
276
  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
277
  const client = env.SAP_CLIENT || '001';
208
278
  const systemId = env.SAP_SYSTEM_ID || '';
279
+ const configuredUrl = adtBaseUrl(env.SAP_URL);
209
280
  // 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 detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
216
- logLine(detected
217
- ? `[sap-ai-dev] ${name}: SAP_URL has no port; ADT answers at ${detected}`
218
- : `[sap-ai-dev] ${name}: SAP_URL has no port and no ADT port answered on ${new URL(url).hostname}; add the port to SAP_URL or rerun sap-ai-dev --setup`);
219
- url = detected || url;
220
- }
281
+ const sso = !['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase());
282
+ const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
283
+ const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
284
+ const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
285
+ // SNI-routed Basic entries pin port and certificate names on purpose; SSO ignores those names.
286
+ const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
287
+ ? await resolveLocalAdtUrl(name, configuredUrl, client)
288
+ : { url: configuredUrl };
289
+ // The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute VSP.
290
+ const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
291
+ const probe = adt.verified === undefined
292
+ ? { status: 'configured', available: true }
293
+ : (adt.verified ? { status: `adt-verified at ${adt.url}`, available: true } : { status: `adt-unverified: ${adt.reason}`, available: false });
294
+ if (sso) {
295
+ const cacheKey = `${systemId || new URL(configuredUrl).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
296
+ 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`);
221
297
  return {
222
298
  source: 'sap-gui-local',
223
299
  name,
224
- url,
300
+ url: adt.url,
225
301
  client,
226
302
  systemId,
227
303
  authentication: 'SSO',
228
304
  // SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
229
- childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '' },
230
- probe: { status: 'configured', available: true }
305
+ childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', ...direct },
306
+ probe
231
307
  };
232
308
  }
233
- const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
234
- const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
235
- const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
236
309
  const tlsRoute = tlsServerNames.length
237
- ? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
310
+ ? await createTlsServerNameAdtProxy({ destinationUrl: configuredUrl, tlsServerNames, caFile: tlsCaFile || undefined })
238
311
  : null;
239
312
  return {
240
313
  source: 'sap-gui-local',
241
314
  name,
242
- url: tlsRoute?.url || env.SAP_URL,
243
- backendUrl: tlsRoute ? env.SAP_URL : undefined,
315
+ url: tlsRoute?.url || adt.url,
316
+ backendUrl: tlsRoute ? configuredUrl : undefined,
244
317
  client,
245
318
  systemId,
246
319
  authentication: 'Basic',
247
320
  childEnv: {
248
321
  SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
249
- SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
322
+ SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || '',
323
+ ...direct
250
324
  },
251
325
  close: tlsRoute?.close,
252
- probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
326
+ probe: tlsRoute ? { status: 'configured-tls-server-name', available: true } : probe
253
327
  };
254
328
  }
255
329
 
@@ -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,21 +412,110 @@ 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
464
  // probes the conventional ADT ports and reports which one answers.
412
- export async function detectAdtUrl(binary, { url, client, env = process.env, timeoutMs = 60_000 } = {}) {
465
+ export async function detectAdtUrl(binary, { url, client, instance, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
413
466
  let configured;
414
- try { configured = new URL(url); } catch { return ''; }
415
- const args = ['detect', configured.hostname.replace(/^\[(.*)\]$/, '$1'), '--json', ...(client ? ['--client', String(client)] : [])];
467
+ try { configured = new URL(url); } catch { return { url: '', reason: `${url} is not a valid URL` }; }
468
+ const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
469
+ const args = ['detect', host, '--json',
470
+ ...(client ? ['--client', String(client)] : []),
471
+ ...(/^\d{2}$/.test(String(instance || '')) ? ['--instance', String(instance)] : []),
472
+ ...(exhaustive ? ['--all'] : []),
473
+ ...ports.flatMap(port => ['--port', String(port)])];
474
+ // A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
475
+ const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
416
476
  const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
417
477
  ? [process.execPath, binary]
418
478
  : (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
479
+ let stdout;
419
480
  try {
420
- const { stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 });
421
- const adt = (JSON.parse(stdout)?.findings || []).filter(finding => finding?.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')));
422
- // The SSO session cookie is the whole credential; never trade https for http.
423
- return (adt.find(finding => finding.secure) || (configured.protocol === 'http:' ? adt[0] : undefined))?.url || '';
424
- } catch {
425
- return '';
481
+ ({ stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env: { ...env, NO_PROXY: noProxy, no_proxy: noProxy }, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 }));
482
+ } catch (error) {
483
+ const why = error.killed ? `timed out after ${Math.round(timeoutMs / 1000)} s` : (typeof error.code === 'string' ? error.code : `exit code ${error.code}`);
484
+ const blocked = ['EACCES', 'EPERM', 'UNKNOWN'].includes(error.code) ? '; antivirus or application control may block it' : '';
485
+ return { url: '', reason: `VSP detect could not run (${why}) from ${binary}${blocked}` };
426
486
  }
487
+ try { return adtVerdict(JSON.parse(stdout), configured.protocol); }
488
+ catch { return { url: '', reason: 'VSP detect returned unreadable output' }; }
489
+ }
490
+
491
+ // Application servers appear by their SM51 names, host_SID_NN.
492
+ export function parseMessageServerServers(body, { host = '', systemId = '' } = {}) {
493
+ const domain = isIP(host) ? '' : (/^[^.]+\.(.+)$/.exec(host)?.[1] || '');
494
+ const servers = new Map();
495
+ 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)) {
496
+ if (systemId && sid.toUpperCase() !== systemId.toUpperCase()) continue;
497
+ const appHost = name.includes('.') || !domain ? name : `${name}.${domain}`;
498
+ servers.set(`${appHost.toLowerCase()}:${instance}`, { host: appHost, instance });
499
+ }
500
+ return [...servers.values()];
501
+ }
502
+
503
+ // A load-balanced landscape entry names the message server, which serves no
504
+ // ADT; its HTTP port lists the application servers that do, the same source
505
+ // SAP Web Dispatcher reads.
506
+ export async function messageServerAppServers({ host, httpPort, systemId } = {}, { timeoutMs = 5000 } = {}) {
507
+ if (!host || !httpPort) return [];
508
+ const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
509
+ for (const path of ['/msgserver/text/logon', '/msgserver/text/serverlist']) {
510
+ let body = '';
511
+ try {
512
+ const response = await fetch(`http://${authority}:${httpPort}${path}`, { signal: AbortSignal.timeout(timeoutMs) });
513
+ if (response.ok) body = await response.text();
514
+ } catch {
515
+ continue;
516
+ }
517
+ const servers = parseMessageServerServers(body, { host, systemId });
518
+ if (servers.length) return servers;
519
+ }
520
+ return [];
427
521
  }
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,21 +124,96 @@ 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, { output = stdout, env = process.env, detectAdtUrl } = {}) {
130
- if (!detectAdtUrl || hasExplicitPort(destination.url)) return;
131
- const stopProgress = startProgress(`Detecting the ADT port for ${label}`, { output, env, label: 'SSO' });
132
- let detected = '';
133
- try { detected = await detectAdtUrl({ url: destination.url, client }); }
134
- catch { detected = ''; }
135
- finally { stopProgress(); }
136
- if (detected) {
137
- destination.url = detected;
138
- print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
139
- return;
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 (message, target) => {
145
+ print(output, formatStatus(message, 'progress', output, 'ADT'));
146
+ const stopProgress = startProgress(message, { output, env, label: 'ADT' });
147
+ try { return await detectAdtUrl({ client, ...target }); }
148
+ catch (error) { return { url: '', reason: error.message }; }
149
+ finally { stopProgress(); }
150
+ };
151
+ let verdict = { url: '', reason: `no port was checked for ${label}` };
152
+ for (const candidate of [...new Set([destination.url, ...candidates].filter(url => url && hasExplicitPort(url)))]) {
153
+ verdict = await detect(`Checking ADT at ${candidate}`, { url: candidate, ports: [adtPort(candidate)] });
154
+ if (verdict.url || verdict.unresolved) return verdict;
155
+ }
156
+ if (!scan) return verdict;
157
+ const host = new URL(destination.url).hostname;
158
+ verdict = await detect(`Port scan in progress on ${host} to find the ADT port for ${label}`, { url: destination.url, ...(destination.instance ? { instance: destination.instance } : {}) });
159
+ if (verdict.url || verdict.unresolved) return verdict;
160
+ if (destination.messageServer) {
161
+ print(output, formatStatus(`Asking the message server ${destination.messageServer.host}:${destination.messageServer.httpPort} for the application servers of ${label}`, 'progress', output, 'ADT'));
162
+ for (const server of (await messageServerAppServers({ ...destination.messageServer, systemId: destination.systemId })).slice(0, 3)) {
163
+ const found = await detect(`Port scan in progress on application server ${server.host} (instance ${server.instance})`, { url: `${new URL(destination.url).protocol}//${server.host}`, instance: server.instance });
164
+ if (found.url) return found;
165
+ }
140
166
  }
141
- print(output, formatStatus(`No ADT port answered for ${label}; SSO keeps ${destination.url}. If the browser cannot reach it, rerun setup and enter the ADT URL with its port, for example ${new URL(destination.url).origin}:44300.`, 'warning', output, 'SSO'));
167
+ return detect(`No ADT port on the usual ports for ${label} (${verdict.reason}); port scan in progress on every conventional SAP port of ${host} (this can take a minute)`, { url: destination.url, exhaustive: true });
168
+ }
169
+
170
+ // An SSO sign-in can only start where ADT answers, so for SSO setup does not
171
+ // end on an unverified URL unless the user chooses to.
172
+ async function settleAdtUrl(destination, label, client, { insist = false, candidates = [], input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
173
+ if (!detectAdtUrl) return 'unchecked';
174
+ if (hasPathPrefix(destination.url)) {
175
+ print(output, formatStatus(`${destination.url} has a gateway path, so setup uses it as entered without a port scan.`, 'info', output, 'ADT'));
176
+ return 'unchecked';
177
+ }
178
+ const options = { candidates, output, env, detectAdtUrl };
179
+ let verdict = await findAdtUrl(destination, label, client, options);
180
+ while (!verdict.url) {
181
+ print(output, formatStatus(`No ADT answer for ${label}: ${verdict.reason}.`, 'warning', output, 'ADT'));
182
+ if (!insist) return 'unverified';
183
+ const choice = await selectPrompt({
184
+ message: `ADT for ${label}`,
185
+ defaultValue: 'enter',
186
+ choices: [
187
+ { name: 'Enter the ADT port or URL (basis finds it in SMICM > Goto > Services)', value: 'enter', shortcut: 'e' },
188
+ { name: 'Scan again, for example after connecting the VPN', value: 'retry', shortcut: 'r' },
189
+ { name: `Keep ${destination.url} without an ADT answer`, value: 'keep', shortcut: 'k' },
190
+ { name: 'Skip this system', value: 'skip', shortcut: 's' }
191
+ ]
192
+ }, { input, output });
193
+ if (choice === 'keep') return 'unverified';
194
+ if (choice === 'skip') return 'skipped';
195
+ if (choice === 'enter') {
196
+ const answer = await textPrompt({
197
+ message: `ADT port or URL for ${label}`,
198
+ placeholder: '44300 or https://host:44300',
199
+ validate: value => {
200
+ try { adtCandidate(value, destination.url); return true; }
201
+ catch (error) { return error.message; }
202
+ }
203
+ }, { input, output });
204
+ destination.url = adtCandidate(answer, destination.url);
205
+ }
206
+ const retry = choice === 'retry';
207
+ verdict = await findAdtUrl(destination, label, client, { ...options, candidates: retry ? candidates : [], scan: retry || !hasExplicitPort(destination.url) });
208
+ }
209
+ destination.url = verdict.url;
210
+ print(output, formatStatus(`ADT answers at ${verdict.url}.`, 'success', output, 'ADT'));
211
+ return 'verified';
212
+ }
213
+
214
+ function warnSsoIpHost(destination, label, output) {
215
+ if (!isIP(new URL(destination.url).hostname.replace(/^\[(.*)\]$/, '$1'))) return;
216
+ 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'));
142
217
  }
143
218
 
144
219
  async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
@@ -232,7 +307,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
232
307
  source: 'sap-gui-local',
233
308
  name,
234
309
  serverName: name,
235
- url,
310
+ url: adtBaseUrl(url),
236
311
  host: parsedUrl?.hostname || '',
237
312
  client: '001',
238
313
  authentication: 'Basic',
@@ -243,16 +318,21 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
243
318
  // destination picker.
244
319
  configuredManually: true
245
320
  };
246
- await maybeConfigureTlsSelfHeal(destination, name, { input, output });
247
321
  const clients = await promptSapClients(destination, { input, output });
248
322
  const authMode = await chooseLocalAuthMode(destination, { input, output, env });
249
- if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { output, env, detectAdtUrl });
250
- destination.authentication = localAuthenticationLabel(authMode);
251
- destination.authMode = authMode;
252
- for (const client of clients) {
253
- expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
323
+ const adt = await settleAdtUrl(destination, name, clients[0], { insist: authMode === 'sso', input, output, env, detectAdtUrl });
324
+ if (adt === 'skipped') {
325
+ print(output, formatStatus(`Skipped ${name}; no MCP server was added for it.`, 'info', output, 'Manual entry'));
326
+ } else {
327
+ if (authMode === 'sso') warnSsoIpHost(destination, name, output);
328
+ else await maybeConfigureTlsSelfHeal(destination, name, { input, output });
329
+ destination.authentication = localAuthenticationLabel(authMode);
330
+ destination.authMode = authMode;
331
+ for (const client of clients) {
332
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
333
+ }
334
+ print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
254
335
  }
255
- print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
256
336
  addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
257
337
  }
258
338
  return expanded;
@@ -273,6 +353,18 @@ function localClientDestinationName(destination, client, { multipleClients = fal
273
353
  }
274
354
 
275
355
 
356
+ // Two systems sharing a SID cannot be told apart, so neither URL is offered for them.
357
+ function previousLocalAdtUrls(config) {
358
+ const urls = new Map();
359
+ for (const entry of Object.values(config?.servers || {})) {
360
+ const entryEnv = entry?.env || {};
361
+ if (entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE !== 'sap-gui-local' || !entryEnv.SAP_SYSTEM_ID || !entryEnv.SAP_URL) continue;
362
+ const key = String(entryEnv.SAP_SYSTEM_ID).toUpperCase();
363
+ urls.set(key, urls.has(key) && urls.get(key) !== entryEnv.SAP_URL ? '' : entryEnv.SAP_URL);
364
+ }
365
+ return urls;
366
+ }
367
+
276
368
  function safeProbe(probe) {
277
369
  const result = {};
278
370
  if (typeof probe?.status === 'string') result.status = probe.status;
@@ -310,6 +402,7 @@ function safeLocalSapGuiDestination(destination) {
310
402
  proxyType: 'Internet',
311
403
  tlsServerName: destination.tlsServerName,
312
404
  tlsServerNames: destination.tlsServerNames,
405
+ messageServer: destination.messageServer,
313
406
  probe: safeProbe(destination.probe),
314
407
  childEnv: destination.childEnv || {}
315
408
  };
@@ -364,7 +457,7 @@ function printConnectionInstructions(output, result) {
364
457
 
365
458
  // Checkbox selection over the discovered destinations plus, outside BAS, the
366
459
  // per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
367
- async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
460
+ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map() } = {}) {
368
461
  const orderedDestinations = [...destinations].sort((left, right) => {
369
462
  const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
370
463
  const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
@@ -398,6 +491,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
398
491
  message: colorText('🧭 Select destinations', 'cyan', output),
399
492
  choices,
400
493
  required: false,
494
+ searchable: true,
401
495
  shortcuts: { all: 'a' },
402
496
  actions: { m: addManually },
403
497
  confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
@@ -425,17 +519,24 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
425
519
  const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
426
520
  const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
427
521
  const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
428
- const fallback = defaultAdtUrl(destination);
522
+ const guess = defaultAdtUrl(destination);
523
+ // A URL an earlier setup verified beats the 443NN naming convention.
524
+ const fallback = previousAdtUrls.get(String(destination.systemId || '').toUpperCase()) || guess;
429
525
  const url = await textPrompt({
430
526
  message: `ADT URL for ${promptLabel}`,
431
527
  placeholder: fallback || 'https://host:44300',
432
528
  validate: value => validateAdtUrlCandidate(value || fallback),
433
529
  required: false
434
530
  }, { input, output });
435
- destination.url = url || fallback;
436
- await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
531
+ destination.url = adtBaseUrl(url || fallback);
437
532
  const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
438
- if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { output, env, detectAdtUrl });
533
+ const adt = await settleAdtUrl(destination, promptLabel, clients[0], { insist: authMode === 'sso', candidates: url ? [] : [guess], input, output, env, detectAdtUrl });
534
+ if (adt === 'skipped') {
535
+ print(output, formatStatus(`Skipped ${promptLabel}; no MCP server was added for it.`, 'info', output, 'Local SAP GUI'));
536
+ continue;
537
+ }
538
+ if (authMode === 'sso') warnSsoIpHost(destination, promptLabel, output);
539
+ else await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
439
540
  destination.authentication = localAuthenticationLabel(authMode);
440
541
  destination.authMode = authMode;
441
542
  for (const client of clients) {
@@ -503,9 +604,12 @@ export async function runSetup({
503
604
  let createdKeys = [];
504
605
  let configPath;
505
606
  let managedKeys = [];
607
+ let previousAdtUrls = new Map();
506
608
  try {
507
609
  configPath = await resolveMcpConfigPath(env);
508
- managedKeys = collectManagedCloudFoundryKeyReferences(await readMcpConfig(configPath));
610
+ const existingConfig = await readMcpConfig(configPath);
611
+ managedKeys = collectManagedCloudFoundryKeyReferences(existingConfig);
612
+ previousAdtUrls = previousLocalAdtUrls(existingConfig);
509
613
  } catch {
510
614
  warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
511
615
  }
@@ -607,7 +711,7 @@ export async function runSetup({
607
711
  selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
608
712
  if (!selected.length) return await exitWithoutDestinations();
609
713
  } else {
610
- selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
714
+ selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
611
715
  }
612
716
 
613
717
  let sapDevelopmentServers = [];
@@ -213,11 +213,13 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
213
213
  });
214
214
  }
215
215
 
216
- export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate, confirmEmpty = '' } = {}, { input = process.stdin, output = process.stdout } = {}) {
216
+ export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate, confirmEmpty = '', searchable = false } = {}, { input = process.stdin, output = process.stdout } = {}) {
217
217
  if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
218
218
  // Action shortcuts (for example 'm' → manual entry wizard) may append new
219
219
  // choices while the picker is open, so the list itself stays mutable.
220
220
  let choices = initialChoices;
221
+ let searchQuery = '';
222
+ let searchMode = false;
221
223
  const selectable = enabledChoices(choices);
222
224
  const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
223
225
  let cursor = firstEnabledIndex(choices);
@@ -228,40 +230,73 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
228
230
  // With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
229
231
  let awaitingEmptyConfirm = false;
230
232
 
233
+ const matchingIndexes = () => choices
234
+ .map((choice, index) => ({ choice, index }))
235
+ .filter(({ choice }) => !searchQuery || String(choice.name || '').toLowerCase().startsWith(searchQuery.toLowerCase()))
236
+ .map(({ index }) => index);
237
+ const visibleIndexes = () => {
238
+ const indexes = matchingIndexes();
239
+ if (searchable && (!indexes.includes(cursor) || choices[cursor]?.disabled)) {
240
+ cursor = indexes.find(index => !choices[index]?.disabled) ?? -1;
241
+ }
242
+ return indexes;
243
+ };
244
+ const moveCursor = step => {
245
+ if (!searchable) {
246
+ cursor = nextEnabledIndex(choices, cursor, step);
247
+ return;
248
+ }
249
+ const indexes = visibleIndexes();
250
+ const visibleChoices = indexes.map(index => choices[index]);
251
+ if (!visibleChoices.some(choice => !choice.disabled)) return;
252
+ const position = nextEnabledIndex(visibleChoices, indexes.indexOf(cursor), step);
253
+ cursor = indexes[position] ?? -1;
254
+ };
255
+
231
256
  const write = text => output.write(text);
232
257
  const clearRendered = () => {
233
258
  if (!renderedLines) return;
234
259
  write(`\u001b[${renderedLines}A\u001b[J`);
235
260
  renderedLines = 0;
236
261
  };
237
- const ensureVisible = pageSize => {
238
- if (cursor < top) top = cursor;
239
- if (cursor >= top + pageSize) top = cursor - pageSize + 1;
240
- top = clamp(top, 0, Math.max(0, choices.length - pageSize));
262
+ const ensureVisible = (pageSize, visibleCount, cursorPosition) => {
263
+ if (cursorPosition >= 0 && cursorPosition < top) top = cursorPosition;
264
+ if (cursorPosition >= top + pageSize) top = cursorPosition - pageSize + 1;
265
+ top = clamp(top, 0, Math.max(0, visibleCount - pageSize));
241
266
  };
242
267
  const footer = error => {
243
268
  if (error) return [[error, 'red']];
244
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.', 'yellow']];
245
271
  if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
246
272
  return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
247
273
  };
248
274
  const render = (error = '') => {
249
275
  const columns = Number.isFinite(output?.columns) ? output.columns : 80;
250
- const pageSize = checkboxPageSize(output, choices.length);
251
- ensureVisible(pageSize);
276
+ const indexes = visibleIndexes();
277
+ const pageSize = searchable
278
+ ? Math.max(1, checkboxPageSize(output, indexes.length + 1) - 1)
279
+ : checkboxPageSize(output, indexes.length);
280
+ const cursorPosition = indexes.indexOf(cursor);
281
+ ensureVisible(pageSize, indexes.length, cursorPosition);
252
282
  const lines = [];
253
283
  lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
254
- const end = Math.min(choices.length, top + pageSize);
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
+ }
288
+ const end = Math.min(indexes.length, top + pageSize);
255
289
  for (let index = top; index < end; index += 1) {
256
- const choice = choices[index];
257
- const pointer = index === cursor ? '❯' : ' ';
290
+ const choiceIndex = indexes[index];
291
+ const choice = choices[choiceIndex];
292
+ const pointer = choiceIndex === cursor ? '❯' : ' ';
258
293
  // A red ✗ for "not selected yet" was read as an error, so unselected rows show an empty box.
259
- const box = choice.disabled ? '[-]' : (selected.has(index) ? colorText('[✓]', 'green', output) : '[ ]');
294
+ const box = choice.disabled ? '[-]' : (selected.has(choiceIndex) ? colorText('[✓]', 'green', output) : '[ ]');
260
295
  const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
261
296
  lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
262
297
  }
263
- if (choices.length > pageSize) {
264
- lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
298
+ if (indexes.length > pageSize) {
299
+ lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${indexes.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
265
300
  }
266
301
  for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
267
302
  clearRendered();
@@ -328,16 +363,25 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
328
363
  };
329
364
  const onKeypress = (_chunk, key = {}) => {
330
365
  if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
331
- if (key.name === 'up' || key.name === 'k') cursor = nextEnabledIndex(choices, cursor, -1);
332
- else if (key.name === 'down' || key.name === 'j') cursor = nextEnabledIndex(choices, cursor, 1);
333
- else if (key.name === 'space' && !choices[cursor]?.disabled) {
366
+ else if (searchable && key.name === 'escape') {
367
+ searchQuery = '';
368
+ searchMode = false;
369
+ top = 0;
370
+ } else if (searchable && searchMode && (key.name === 'backspace' || key.name === 'delete')) {
371
+ searchQuery = Array.from(searchQuery).slice(0, -1).join('');
372
+ if (!searchQuery) searchMode = false;
373
+ top = 0;
374
+ } else if (searchable && !searchMode && String(_chunk ?? '') === '/' && !key.ctrl && !key.meta) searchMode = true;
375
+ else if (key.name === 'up' || (!searchable && key.name === 'k')) moveCursor(-1);
376
+ else if (key.name === 'down' || (!searchable && key.name === 'j')) moveCursor(1);
377
+ else if (key.name === 'space' && cursor >= 0 && !choices[cursor]?.disabled) {
334
378
  if (selected.has(cursor)) selected.delete(cursor);
335
379
  else selected.add(cursor);
336
- } else if (shortcuts?.all && key.name === shortcuts.all) {
380
+ } else if ((!searchable || !searchMode) && shortcuts?.all && key.name === shortcuts.all) {
337
381
  const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
338
382
  selected.clear();
339
383
  if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
340
- } else if (actionShortcutFor(_chunk, key)) {
384
+ } else if ((!searchable || !searchMode) && actionShortcutFor(_chunk, key)) {
341
385
  awaitingEmptyConfirm = false;
342
386
  void runAction(actionShortcutFor(_chunk, key));
343
387
  return;
@@ -351,6 +395,10 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
351
395
  return render();
352
396
  }
353
397
  return finish(values);
398
+ } else if (searchable && !key.ctrl && !key.meta && Array.from(String(_chunk ?? '')).length === 1 && !/[\u0000-\u001f\u007f]/u.test(String(_chunk))) {
399
+ searchMode = true;
400
+ searchQuery += String(_chunk);
401
+ top = 0;
354
402
  } else {
355
403
  return;
356
404
  }