sap-ai-dev-toolkit 0.9.7 β†’ 0.9.8

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.8",
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",
package/src/launcher.mjs CHANGED
@@ -212,10 +212,16 @@ async function configuredLocalSapGuiDestination(env) {
212
212
  let url = env.SAP_URL;
213
213
  // SAP GUI landscapes carry no HTTP port, and the SSO browser cannot sign in on a guessed one.
214
214
  if (!hasExplicitPort(url)) {
215
- const detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
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
+ }
216
222
  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`);
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`);
219
225
  url = detected || url;
220
226
  }
221
227
  return {
@@ -409,10 +409,14 @@ export function hasExplicitPort(url) {
409
409
 
410
410
  // A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
411
411
  // probes the conventional ADT ports and reports which one answers.
412
- export async function detectAdtUrl(binary, { url, client, env = process.env, timeoutMs = 60_000 } = {}) {
412
+ export async function detectAdtUrl(binary, { url, client, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
413
413
  let configured;
414
414
  try { configured = new URL(url); } catch { return ''; }
415
- const args = ['detect', configured.hostname.replace(/^\[(.*)\]$/, '$1'), '--json', ...(client ? ['--client', String(client)] : [])];
415
+ const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
416
+ const args = ['detect', host, '--json', ...(client ? ['--client', String(client)] : []), ...(exhaustive ? ['--all'] : []), ...ports.flatMap(port => ['--port', String(port)])];
417
+ // A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
418
+ const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
419
+ env = { ...env, NO_PROXY: noProxy, no_proxy: noProxy };
416
420
  const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
417
421
  ? [process.execPath, binary]
418
422
  : (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
package/src/setup.mjs CHANGED
@@ -126,19 +126,39 @@ function localAuthenticationLabel(authMode) {
126
126
 
127
127
  // The SSO browser signs in on the ADT URL itself, and SAP GUI landscapes never
128
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 } = {}) {
129
+ async function detectSsoAdtPort(destination, label, client, { input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
130
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;
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(); }
136
+ };
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 });
142
+ }
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;
158
+ }
140
159
  }
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'));
160
+ destination.url = detected;
161
+ print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
142
162
  }
143
163
 
144
164
  async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
@@ -246,7 +266,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
246
266
  await maybeConfigureTlsSelfHeal(destination, name, { input, output });
247
267
  const clients = await promptSapClients(destination, { input, output });
248
268
  const authMode = await chooseLocalAuthMode(destination, { input, output, env });
249
- if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { output, env, detectAdtUrl });
269
+ if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { input, output, env, detectAdtUrl });
250
270
  destination.authentication = localAuthenticationLabel(authMode);
251
271
  destination.authMode = authMode;
252
272
  for (const client of clients) {
@@ -398,6 +418,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
398
418
  message: colorText('🧭 Select destinations', 'cyan', output),
399
419
  choices,
400
420
  required: false,
421
+ searchable: true,
401
422
  shortcuts: { all: 'a' },
402
423
  actions: { m: addManually },
403
424
  confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
@@ -435,7 +456,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
435
456
  destination.url = url || fallback;
436
457
  await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
437
458
  const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
438
- if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { output, env, detectAdtUrl });
459
+ if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { input, output, env, detectAdtUrl });
439
460
  destination.authentication = localAuthenticationLabel(authMode);
440
461
  destination.authMode = authMode;
441
462
  for (const client of clients) {
@@ -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,70 @@ 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. Backspace or Esc clears search.', '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) lines.push(truncateToColumns(`Search: ${searchQuery || (searchMode ? '' : 'type a prefix; / for a or m')}`, columns));
285
+ const end = Math.min(indexes.length, top + pageSize);
255
286
  for (let index = top; index < end; index += 1) {
256
- const choice = choices[index];
257
- const pointer = index === cursor ? '❯' : ' ';
287
+ const choiceIndex = indexes[index];
288
+ const choice = choices[choiceIndex];
289
+ const pointer = choiceIndex === cursor ? '❯' : ' ';
258
290
  // 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) : '[ ]');
291
+ const box = choice.disabled ? '[-]' : (selected.has(choiceIndex) ? colorText('[βœ“]', 'green', output) : '[ ]');
260
292
  const disabled = choice.disabled ? ` β€” ${choice.disabled}` : '';
261
293
  lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
262
294
  }
263
- if (choices.length > pageSize) {
264
- lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
295
+ if (indexes.length > pageSize) {
296
+ lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${indexes.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
265
297
  }
266
298
  for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
267
299
  clearRendered();
@@ -328,16 +360,25 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
328
360
  };
329
361
  const onKeypress = (_chunk, key = {}) => {
330
362
  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) {
363
+ else if (searchable && key.name === 'escape') {
364
+ searchQuery = '';
365
+ searchMode = false;
366
+ top = 0;
367
+ } else if (searchable && searchMode && (key.name === 'backspace' || key.name === 'delete')) {
368
+ searchQuery = Array.from(searchQuery).slice(0, -1).join('');
369
+ if (!searchQuery) searchMode = false;
370
+ top = 0;
371
+ } else if (searchable && !searchMode && String(_chunk ?? '') === '/' && !key.ctrl && !key.meta) searchMode = true;
372
+ else if (key.name === 'up' || (!searchable && key.name === 'k')) moveCursor(-1);
373
+ else if (key.name === 'down' || (!searchable && key.name === 'j')) moveCursor(1);
374
+ else if (key.name === 'space' && cursor >= 0 && !choices[cursor]?.disabled) {
334
375
  if (selected.has(cursor)) selected.delete(cursor);
335
376
  else selected.add(cursor);
336
- } else if (shortcuts?.all && key.name === shortcuts.all) {
377
+ } else if ((!searchable || !searchMode) && shortcuts?.all && key.name === shortcuts.all) {
337
378
  const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
338
379
  selected.clear();
339
380
  if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
340
- } else if (actionShortcutFor(_chunk, key)) {
381
+ } else if ((!searchable || !searchMode) && actionShortcutFor(_chunk, key)) {
341
382
  awaitingEmptyConfirm = false;
342
383
  void runAction(actionShortcutFor(_chunk, key));
343
384
  return;
@@ -351,6 +392,10 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
351
392
  return render();
352
393
  }
353
394
  return finish(values);
395
+ } else if (searchable && !key.ctrl && !key.meta && Array.from(String(_chunk ?? '')).length === 1 && !/[\u0000-\u001f\u007f]/u.test(String(_chunk))) {
396
+ searchMode = true;
397
+ searchQuery += String(_chunk);
398
+ top = 0;
354
399
  } else {
355
400
  return;
356
401
  }