sap-ai-dev-toolkit 0.9.6 → 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/README.md +2 -2
- package/package.json +1 -1
- package/src/launcher.mjs +24 -4
- package/src/local-sap-gui.mjs +28 -0
- package/src/mcp-config.mjs +2 -0
- package/src/setup.mjs +50 -9
- package/src/terminal-ui.mjs +80 -23
package/README.md
CHANGED
|
@@ -300,7 +300,7 @@ The installer handles VSP provisioning automatically:
|
|
|
300
300
|
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. When the bundled asset is missing (an interrupted install, an antivirus quarantine, or a git checkout without `dist/`), a checksum-anchored download from the matching GitHub release is the fallback; MCP server startup runs it too, because npx entries skip install scripts.
|
|
301
301
|
- No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
|
|
302
302
|
|
|
303
|
-
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default
|
|
303
|
+
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default: every row starts with an empty box `[ ]`. Use **Space** to tick a destination (`[✓]`) and **Enter** to confirm; the line under the list always names the next key to press. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Pressing **Enter** with nothing selected asks for a second **Enter**, because confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
|
|
304
304
|
|
|
305
305
|
Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. Local SAP GUI MCP server keys use the SAP system ID and client (for example `t4d-100`) instead of the longer SAP GUI description, while `SAP_AI_DEV_TOOLKIT_DESTINATION` still preserves the display name. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup offers Windows / smart card SSO (the default) instead, which writes `SAP_AUTH_MODE=sso` without username/password prompts. At server start, VSP opens Microsoft Edge on the ADT URL; Edge completes the login the same way it does in your browser (smart card certificate and PIN, Kerberos, or company SAML/Entra login), and the session is cached and refreshed automatically. SSO entries need an ADT URL with a host name, not an IP address. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config, and you should run `sap-ai-dev --setup` afterwards.
|
|
306
306
|
|
|
@@ -447,7 +447,7 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
|
|
|
447
447
|
If the `cf` CLI is version 8.18 or newer and authenticated to a targeted space, setup offers an opt-in import from that space's SAP Destination service. It does not create service keys unless you accept. The imported records are limited to the current space and are merged with BAS destinations for selection. OnPremise destinations require choosing a Connectivity service instance; runtime traffic uses that service's proxy, and PrincipalPropagation uses the current CF user's token. Internet destinations use the configured HTTP(S) proxy environment. Setup stores only service-instance/key references in `mcp.json`, not the service-key credentials. At runtime the generated entry verifies the active CF space and resolves those references. Setup removes an add-on-created key only when no remaining CF entry references it and the CLI is targeted to the key's recorded space; keys are left untouched when the space cannot be verified.
|
|
448
448
|
Cloud Foundry HTTP destination probes use forward-form HTTP requests, not CONNECT tunnels; SAP Connectivity requires HTTP from the application to its proxy. OnPremise requests are canceled when the client disconnects or the runtime route closes.
|
|
449
449
|
|
|
450
|
-
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
|
|
450
|
+
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked takes a second **Enter** and removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
|
|
451
451
|
|
|
452
452
|
When nothing is detected — no BAS destinations, no Cloud Foundry imports, or no local SAP GUI entries — setup offers the **manual entry wizard** instead of stopping. The same wizard is available at any point in the destination picker via the **m** shortcut. It asks for a system name, the ADT HTTP(S) base URL (for example `https://myhost.example:44300`), one or more 3-digit SAP clients, and (on Windows) the login mode. You can add several systems in one run; each becomes the same isolated per-system/client MCP server the discovery flows generate, including credential input prompts and TLS self-healing for IP-based HTTPS URLs. Declining the wizard when nothing was detected exits setup without changes; aborting it inside the picker (Ctrl+C) returns to the selection unchanged.
|
|
453
453
|
|
package/package.json
CHANGED
package/src/launcher.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
|
-
import { discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
7
|
+
import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
|
|
8
8
|
import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
|
|
9
9
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
10
10
|
import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
@@ -186,6 +186,10 @@ async function binaryOrError() {
|
|
|
186
186
|
].join(' '));
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
async function detectLocalAdtUrl(target) {
|
|
190
|
+
return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
|
|
191
|
+
}
|
|
192
|
+
|
|
189
193
|
async function discoverForCommand() {
|
|
190
194
|
const env = { ...runtimeEnv };
|
|
191
195
|
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
@@ -205,10 +209,25 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
205
209
|
// Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
|
|
206
210
|
if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
|
|
207
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
|
+
}
|
|
208
227
|
return {
|
|
209
228
|
source: 'sap-gui-local',
|
|
210
229
|
name,
|
|
211
|
-
url
|
|
230
|
+
url,
|
|
212
231
|
client,
|
|
213
232
|
systemId,
|
|
214
233
|
authentication: 'SSO',
|
|
@@ -280,7 +299,8 @@ async function main() {
|
|
|
280
299
|
}
|
|
281
300
|
if (setup) {
|
|
282
301
|
const setupOptions = {
|
|
283
|
-
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
|
|
302
|
+
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools'),
|
|
303
|
+
detectAdtUrl: detectLocalAdtUrl
|
|
284
304
|
};
|
|
285
305
|
if (hasFlag('--npx')) {
|
|
286
306
|
// Windows has no npx.exe, so generated entries there launch through
|
|
@@ -335,7 +355,7 @@ async function main() {
|
|
|
335
355
|
if (!runtimeEnv.H2O_URL) {
|
|
336
356
|
if (args().length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
337
357
|
console.error('sap-ai-dev: no BAS environment detected; starting local interactive setup. Use --help for other commands.');
|
|
338
|
-
await runSetup();
|
|
358
|
+
await runSetup({ detectAdtUrl: detectLocalAdtUrl });
|
|
339
359
|
return;
|
|
340
360
|
}
|
|
341
361
|
const binary = await binaryOrError();
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -401,3 +401,31 @@ export function defaultAdtUrl(system) {
|
|
|
401
401
|
const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
402
402
|
return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
|
|
403
403
|
}
|
|
404
|
+
|
|
405
|
+
// new URL() drops a default port, so `https://host:443` must be read from the text.
|
|
406
|
+
export function hasExplicitPort(url) {
|
|
407
|
+
return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 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 } = {}) {
|
|
413
|
+
let configured;
|
|
414
|
+
try { configured = new URL(url); } catch { return ''; }
|
|
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 };
|
|
420
|
+
const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
421
|
+
? [process.execPath, binary]
|
|
422
|
+
: (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
|
|
423
|
+
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 '';
|
|
430
|
+
}
|
|
431
|
+
}
|
package/src/mcp-config.mjs
CHANGED
|
@@ -106,6 +106,8 @@ export async function resolveMcpServerCommand(env = process.env) {
|
|
|
106
106
|
: ['sap-ai-dev'];
|
|
107
107
|
const searchPath = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
|
|
108
108
|
for (const directory of searchPath) {
|
|
109
|
+
// npx prepends its disposable cache bin dir; entries pointing there go stale or vanish with the cache.
|
|
110
|
+
if (/[\\/]_npx[\\/][^\\/]+[\\/]node_modules[\\/]\.bin[\\/]?$/.test(directory)) continue;
|
|
109
111
|
for (const executableName of executableNames) {
|
|
110
112
|
const candidate = resolve(directory, executableName);
|
|
111
113
|
try {
|
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 } from './local-sap-gui.mjs';
|
|
7
|
+
import { defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort } 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,6 +124,43 @@ 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(); }
|
|
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
|
+
}
|
|
159
|
+
}
|
|
160
|
+
destination.url = detected;
|
|
161
|
+
print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
|
|
162
|
+
}
|
|
163
|
+
|
|
127
164
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
128
165
|
const answer = await textPrompt({
|
|
129
166
|
message,
|
|
@@ -181,7 +218,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
|
|
|
181
218
|
// collects the minimal ADT configuration directly from the user and produces
|
|
182
219
|
// the same local destination shape as the SAP GUI flow, so installation,
|
|
183
220
|
// credential inputs, and TLS handling stay identical.
|
|
184
|
-
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
|
|
221
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
|
|
185
222
|
print(output, '');
|
|
186
223
|
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
187
224
|
print(output, colorText(' The ADT URL is the base URL that Eclipse ADT or VS Code ADT tools use, for example https://myhost.example:44300.', 'cyan', output));
|
|
@@ -229,6 +266,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
229
266
|
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
230
267
|
const clients = await promptSapClients(destination, { input, output });
|
|
231
268
|
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
269
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { input, output, env, detectAdtUrl });
|
|
232
270
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
233
271
|
destination.authMode = authMode;
|
|
234
272
|
for (const client of clients) {
|
|
@@ -346,7 +384,7 @@ function printConnectionInstructions(output, result) {
|
|
|
346
384
|
|
|
347
385
|
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
348
386
|
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
349
|
-
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
|
|
387
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
350
388
|
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
351
389
|
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
352
390
|
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
@@ -371,9 +409,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
371
409
|
print(output, '');
|
|
372
410
|
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
373
411
|
print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
|
|
374
|
-
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
375
412
|
print(output, '');
|
|
376
|
-
const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
|
|
413
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
|
|
377
414
|
value: destination,
|
|
378
415
|
name: `${destination.name} (manual, client ${destination.client})`
|
|
379
416
|
}));
|
|
@@ -381,8 +418,10 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
381
418
|
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
382
419
|
choices,
|
|
383
420
|
required: false,
|
|
421
|
+
searchable: true,
|
|
384
422
|
shortcuts: { all: 'a' },
|
|
385
|
-
actions: { m: addManually }
|
|
423
|
+
actions: { m: addManually },
|
|
424
|
+
confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
|
|
386
425
|
}, { input, output });
|
|
387
426
|
|
|
388
427
|
if (!isBas && selected.length) {
|
|
@@ -417,6 +456,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
417
456
|
destination.url = url || fallback;
|
|
418
457
|
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
419
458
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
459
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { input, output, env, detectAdtUrl });
|
|
420
460
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
421
461
|
destination.authMode = authMode;
|
|
422
462
|
for (const client of clients) {
|
|
@@ -437,7 +477,8 @@ export async function runSetup({
|
|
|
437
477
|
discoverLocalSapGui = discoverSapGuiSystems,
|
|
438
478
|
confirmCfImport = confirmCloudFoundryImport,
|
|
439
479
|
install = installMcpConfig,
|
|
440
|
-
includeSapDevelopmentToolsPrompt = false
|
|
480
|
+
includeSapDevelopmentToolsPrompt = false,
|
|
481
|
+
detectAdtUrl
|
|
441
482
|
} = {}) {
|
|
442
483
|
const isBas = Boolean(env.H2O_URL);
|
|
443
484
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -584,10 +625,10 @@ export async function runSetup({
|
|
|
584
625
|
]
|
|
585
626
|
}, { input, output });
|
|
586
627
|
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
587
|
-
selected = await runManualEntryWizard({ env, input, output });
|
|
628
|
+
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
588
629
|
if (!selected.length) return await exitWithoutDestinations();
|
|
589
630
|
} else {
|
|
590
|
-
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
|
|
631
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
|
|
591
632
|
}
|
|
592
633
|
|
|
593
634
|
let sapDevelopmentServers = [];
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -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 } = {}, { 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);
|
|
@@ -225,6 +227,31 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
225
227
|
let renderedLines = 0;
|
|
226
228
|
let done = false;
|
|
227
229
|
let previousRawMode;
|
|
230
|
+
// With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
|
|
231
|
+
let awaitingEmptyConfirm = false;
|
|
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
|
+
};
|
|
228
255
|
|
|
229
256
|
const write = text => output.write(text);
|
|
230
257
|
const clearRendered = () => {
|
|
@@ -232,32 +259,43 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
232
259
|
write(`\u001b[${renderedLines}A\u001b[J`);
|
|
233
260
|
renderedLines = 0;
|
|
234
261
|
};
|
|
235
|
-
const ensureVisible = pageSize => {
|
|
236
|
-
if (
|
|
237
|
-
if (
|
|
238
|
-
top = clamp(top, 0, Math.max(0,
|
|
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));
|
|
266
|
+
};
|
|
267
|
+
const footer = error => {
|
|
268
|
+
if (error) return [[error, 'red']];
|
|
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']];
|
|
271
|
+
if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
|
|
272
|
+
return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
|
|
239
273
|
};
|
|
240
274
|
const render = (error = '') => {
|
|
241
275
|
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
242
|
-
const
|
|
243
|
-
|
|
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);
|
|
244
282
|
const lines = [];
|
|
245
283
|
lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
|
|
246
|
-
|
|
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);
|
|
247
286
|
for (let index = top; index < end; index += 1) {
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
const choiceIndex = indexes[index];
|
|
288
|
+
const choice = choices[choiceIndex];
|
|
289
|
+
const pointer = choiceIndex === cursor ? '❯' : ' ';
|
|
290
|
+
// A red ✗ for "not selected yet" was read as an error, so unselected rows show an empty box.
|
|
291
|
+
const box = choice.disabled ? '[-]' : (selected.has(choiceIndex) ? colorText('[✓]', 'green', output) : '[ ]');
|
|
253
292
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
254
|
-
|
|
255
|
-
lines.push(truncateToColumns(label, columns));
|
|
293
|
+
lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
|
|
256
294
|
}
|
|
257
|
-
if (
|
|
258
|
-
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${
|
|
295
|
+
if (indexes.length > pageSize) {
|
|
296
|
+
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${indexes.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
|
|
259
297
|
}
|
|
260
|
-
|
|
298
|
+
for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
|
|
261
299
|
clearRendered();
|
|
262
300
|
write(`${lines.join('\n')}\n`);
|
|
263
301
|
renderedLines = physicalRows(lines, columns);
|
|
@@ -322,16 +360,26 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
322
360
|
};
|
|
323
361
|
const onKeypress = (_chunk, key = {}) => {
|
|
324
362
|
if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
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) {
|
|
328
375
|
if (selected.has(cursor)) selected.delete(cursor);
|
|
329
376
|
else selected.add(cursor);
|
|
330
|
-
} else if (shortcuts?.all && key.name === shortcuts.all) {
|
|
377
|
+
} else if ((!searchable || !searchMode) && shortcuts?.all && key.name === shortcuts.all) {
|
|
331
378
|
const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
|
|
332
379
|
selected.clear();
|
|
333
380
|
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
334
|
-
} else if (actionShortcutFor(_chunk, key)) {
|
|
381
|
+
} else if ((!searchable || !searchMode) && actionShortcutFor(_chunk, key)) {
|
|
382
|
+
awaitingEmptyConfirm = false;
|
|
335
383
|
void runAction(actionShortcutFor(_chunk, key));
|
|
336
384
|
return;
|
|
337
385
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
@@ -339,10 +387,19 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
339
387
|
const validation = typeof validate === 'function' ? validate(values) : true;
|
|
340
388
|
if (validation !== true) return render(String(validation));
|
|
341
389
|
if (required && !values.length) return render('Select at least one item.');
|
|
390
|
+
if (confirmEmpty && !values.length && !awaitingEmptyConfirm) {
|
|
391
|
+
awaitingEmptyConfirm = true;
|
|
392
|
+
return render();
|
|
393
|
+
}
|
|
342
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;
|
|
343
399
|
} else {
|
|
344
400
|
return;
|
|
345
401
|
}
|
|
402
|
+
awaitingEmptyConfirm = false;
|
|
346
403
|
render();
|
|
347
404
|
};
|
|
348
405
|
|