sap-ai-dev-toolkit 0.9.5 → 0.9.7
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 +4 -4
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +1 -1
- package/src/binary.mjs +12 -4
- package/src/launcher.mjs +39 -8
- package/src/local-sap-gui.mjs +24 -0
- package/src/mcp-config.mjs +2 -0
- package/src/setup.mjs +29 -9
- package/src/terminal-ui.mjs +19 -7
package/README.md
CHANGED
|
@@ -297,10 +297,10 @@ The guided setup can also offer companion MCP entries with `sap-ai-dev --setup -
|
|
|
297
297
|
|
|
298
298
|
The installer handles VSP provisioning automatically:
|
|
299
299
|
|
|
300
|
-
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`.
|
|
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
|
|
|
@@ -880,7 +880,7 @@ NPM_PUBLISH_TOKEN=npm_...
|
|
|
880
880
|
- To publish a patch bump, run `npm run publish:npm -- --patch`; it updates `package.json` and `package-lock.json` before publishing. If publishing fails after the bump, retry without `--patch`.
|
|
881
881
|
- npm's `prepublishOnly` hook builds the pinned VSP binaries for all platforms (including the Go toolchain if missing) and refuses to ship a tarball whose `dist/checksums.txt` does not list all five assets; it guards `npm run publish:npm` and a plain `npm publish` alike. Pass `--skip-build` to republish an already-built `dist/` without rebuilding.
|
|
882
882
|
- Preview the package without publishing, changing its version, or building with `npm run publish:npm -- --dry-run`.
|
|
883
|
-
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries, publishes that version to npm, and creates a `v<version>` GitHub release with generated notes.
|
|
883
|
+
- GitHub Actions runs the test suite on pushes to `main` and pull requests. A push to `main` that changes the `package.json` version runs the same checks, builds the VSP binaries, publishes that version to npm, and creates a `v<version>` GitHub release with generated notes and the VSP binaries plus `checksums.txt` attached for the download fallback.
|
|
884
884
|
- The publish workflow uses the `NPM_PUBLISH_TOKEN` GitHub repository secret. Verify it with `gh secret list`; the token value is never stored in the repository.
|
|
885
885
|
|
|
886
886
|
The token is not printed or stored in the repository.
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
a5b602573f54ce3d03d161aaaeb78559fd73bd51f2ae9a3b7549cee1ddc3fbf6 vsp-linux-x64
|
|
2
|
+
2e336e2999c3654e5eb1e3a8bb42616eb5d5e422ab7ae74ffbff4634be45189d vsp-linux-arm64
|
|
3
|
+
afc7fd0f2e40ae5ebfcd3ddb8ac3cb7e7501b2b66d76ac96e3b6054b09366bb5 vsp-darwin-x64
|
|
4
|
+
2d28a870c4a006900eec245384b4bbc2e84651e0ba911cca6107c132ace73575 vsp-darwin-arm64
|
|
5
|
+
7d0189aa7ff936a145b0e3f15bffce8de5aebc252465a720edc3a9b122bd2a28 vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
CHANGED
|
Binary file
|
package/dist/vsp-darwin-x64
CHANGED
|
Binary file
|
package/dist/vsp-linux-arm64
CHANGED
|
Binary file
|
package/dist/vsp-linux-x64
CHANGED
|
Binary file
|
package/dist/vsp-win32-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/binary.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { homedir
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
@@ -114,12 +114,14 @@ export async function findBinary(pkg, options = {}) {
|
|
|
114
114
|
await stat(bundled);
|
|
115
115
|
const verified = await verifyAgainstPackagedChecksums(bundled, target);
|
|
116
116
|
if (verified === false) {
|
|
117
|
-
log(`bundled VSP binary ${
|
|
117
|
+
log(`bundled VSP binary ${bundled} failed checksum verification; ignoring it`);
|
|
118
118
|
} else {
|
|
119
119
|
if (verified === null) log(`bundled VSP binary ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
120
120
|
return bundled;
|
|
121
121
|
}
|
|
122
|
-
} catch {
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`bundled VSP binary not found at ${bundled} (${error.code || error.message})`);
|
|
124
|
+
}
|
|
123
125
|
}
|
|
124
126
|
const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
125
127
|
try {
|
|
@@ -158,6 +160,11 @@ export async function installBinary(pkg, options = {}) {
|
|
|
158
160
|
if (String(error?.message || '').includes('checksum verification')) throw error;
|
|
159
161
|
}
|
|
160
162
|
}
|
|
163
|
+
return downloadBinary(pkg, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function downloadBinary(pkg, options = {}) {
|
|
167
|
+
const env = options.env || process.env;
|
|
161
168
|
const target = binaryTarget(options.platform, options.arch);
|
|
162
169
|
const base = releaseBaseUrl(pkg, pkg.version);
|
|
163
170
|
const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
|
|
@@ -184,7 +191,8 @@ export async function installBinary(pkg, options = {}) {
|
|
|
184
191
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
185
192
|
await chmod(dir, 0o700);
|
|
186
193
|
const destination = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
187
|
-
|
|
194
|
+
// Same directory as the destination: rename fails with EXDEV across filesystems (tmpfs /tmp).
|
|
195
|
+
const temporary = `${destination}.${process.pid}-${Date.now()}.tmp`;
|
|
188
196
|
await writeFile(temporary, bytes, { mode: 0o700 });
|
|
189
197
|
await chmod(temporary, 0o700);
|
|
190
198
|
await rename(temporary, destination);
|
package/src/launcher.mjs
CHANGED
|
@@ -4,8 +4,8 @@ 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';
|
|
8
|
-
import { findBinary } from './binary.mjs';
|
|
7
|
+
import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
|
|
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';
|
|
11
11
|
import { runSetup } from './setup.mjs';
|
|
@@ -163,10 +163,31 @@ function probeDiagnostic(destination) {
|
|
|
163
163
|
return `[sap-ai-dev] ${destination.name}: probe=${probe.status || 'unknown'}${details.length ? ` (${details.join('; ')})` : ''}`;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// npx MCP entries run with --ignore-scripts, so postinstall's verified
|
|
167
|
+
// download fallback never ran; a missing or corrupt bundled binary heals here.
|
|
166
168
|
async function binaryOrError() {
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
const notes = [];
|
|
170
|
+
const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
|
|
171
|
+
if (found) return found;
|
|
172
|
+
logLine(`[sap-ai-dev] ${notes.join('; ') || 'no VSP binary found'}; downloading a checksum-verified copy`);
|
|
173
|
+
try {
|
|
174
|
+
const downloaded = await downloadBinary(pkg, { env: runtimeEnv });
|
|
175
|
+
logLine(`[sap-ai-dev] VSP binary installed at ${downloaded}`);
|
|
176
|
+
return downloaded;
|
|
177
|
+
} catch (error) {
|
|
178
|
+
notes.push(`download fallback failed: ${error.message}${error.cause?.code ? ` (${error.cause.code})` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
const npxDirectory = root.match(/^(.*[\\/]_npx[\\/][^\\/]+)[\\/]node_modules[\\/]/)?.[1];
|
|
181
|
+
throw new Error([
|
|
182
|
+
`VSP binary ${binaryTarget().asset} is unavailable for ${pkg.name}@${pkg.version}: ${notes.join('; ')}.`,
|
|
183
|
+
npxDirectory
|
|
184
|
+
? `The npm package ships this binary, so an interrupted install or an antivirus quarantine usually removed it: delete ${npxDirectory} and restart the MCP server so npx reinstalls the package, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
|
|
185
|
+
: `Reinstall ${pkg.name} (a git checkout needs npm run build:vsp), check whether antivirus quarantined the binary, or set SAP_AI_DEV_TOOLKIT_BINARY to a trusted patched VSP binary.`
|
|
186
|
+
].join(' '));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function detectLocalAdtUrl(target) {
|
|
190
|
+
return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
async function discoverForCommand() {
|
|
@@ -188,10 +209,19 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
188
209
|
// Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
|
|
189
210
|
if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
|
|
190
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
|
+
}
|
|
191
221
|
return {
|
|
192
222
|
source: 'sap-gui-local',
|
|
193
223
|
name,
|
|
194
|
-
url
|
|
224
|
+
url,
|
|
195
225
|
client,
|
|
196
226
|
systemId,
|
|
197
227
|
authentication: 'SSO',
|
|
@@ -263,7 +293,8 @@ async function main() {
|
|
|
263
293
|
}
|
|
264
294
|
if (setup) {
|
|
265
295
|
const setupOptions = {
|
|
266
|
-
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
|
|
296
|
+
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools'),
|
|
297
|
+
detectAdtUrl: detectLocalAdtUrl
|
|
267
298
|
};
|
|
268
299
|
if (hasFlag('--npx')) {
|
|
269
300
|
// Windows has no npx.exe, so generated entries there launch through
|
|
@@ -318,7 +349,7 @@ async function main() {
|
|
|
318
349
|
if (!runtimeEnv.H2O_URL) {
|
|
319
350
|
if (args().length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
320
351
|
console.error('sap-ai-dev: no BAS environment detected; starting local interactive setup. Use --help for other commands.');
|
|
321
|
-
await runSetup();
|
|
352
|
+
await runSetup({ detectAdtUrl: detectLocalAdtUrl });
|
|
322
353
|
return;
|
|
323
354
|
}
|
|
324
355
|
const binary = await binaryOrError();
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -401,3 +401,27 @@ 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, timeoutMs = 60_000 } = {}) {
|
|
413
|
+
let configured;
|
|
414
|
+
try { configured = new URL(url); } catch { return ''; }
|
|
415
|
+
const args = ['detect', configured.hostname.replace(/^\[(.*)\]$/, '$1'), '--json', ...(client ? ['--client', String(client)] : [])];
|
|
416
|
+
const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
417
|
+
? [process.execPath, binary]
|
|
418
|
+
: (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
|
|
419
|
+
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 '';
|
|
426
|
+
}
|
|
427
|
+
}
|
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,23 @@ 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;
|
|
140
|
+
}
|
|
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'));
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
128
145
|
const answer = await textPrompt({
|
|
129
146
|
message,
|
|
@@ -181,7 +198,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
|
|
|
181
198
|
// collects the minimal ADT configuration directly from the user and produces
|
|
182
199
|
// the same local destination shape as the SAP GUI flow, so installation,
|
|
183
200
|
// credential inputs, and TLS handling stay identical.
|
|
184
|
-
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
|
|
201
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
|
|
185
202
|
print(output, '');
|
|
186
203
|
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
187
204
|
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 +246,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
229
246
|
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
230
247
|
const clients = await promptSapClients(destination, { input, output });
|
|
231
248
|
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
249
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { output, env, detectAdtUrl });
|
|
232
250
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
233
251
|
destination.authMode = authMode;
|
|
234
252
|
for (const client of clients) {
|
|
@@ -346,7 +364,7 @@ function printConnectionInstructions(output, result) {
|
|
|
346
364
|
|
|
347
365
|
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
348
366
|
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
349
|
-
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
|
|
367
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
350
368
|
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
351
369
|
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
352
370
|
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
@@ -371,9 +389,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
371
389
|
print(output, '');
|
|
372
390
|
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
373
391
|
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
392
|
print(output, '');
|
|
376
|
-
const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
|
|
393
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
|
|
377
394
|
value: destination,
|
|
378
395
|
name: `${destination.name} (manual, client ${destination.client})`
|
|
379
396
|
}));
|
|
@@ -382,7 +399,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
382
399
|
choices,
|
|
383
400
|
required: false,
|
|
384
401
|
shortcuts: { all: 'a' },
|
|
385
|
-
actions: { m: addManually }
|
|
402
|
+
actions: { m: addManually },
|
|
403
|
+
confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
|
|
386
404
|
}, { input, output });
|
|
387
405
|
|
|
388
406
|
if (!isBas && selected.length) {
|
|
@@ -417,6 +435,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
417
435
|
destination.url = url || fallback;
|
|
418
436
|
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
419
437
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
438
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { output, env, detectAdtUrl });
|
|
420
439
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
421
440
|
destination.authMode = authMode;
|
|
422
441
|
for (const client of clients) {
|
|
@@ -437,7 +456,8 @@ export async function runSetup({
|
|
|
437
456
|
discoverLocalSapGui = discoverSapGuiSystems,
|
|
438
457
|
confirmCfImport = confirmCloudFoundryImport,
|
|
439
458
|
install = installMcpConfig,
|
|
440
|
-
includeSapDevelopmentToolsPrompt = false
|
|
459
|
+
includeSapDevelopmentToolsPrompt = false,
|
|
460
|
+
detectAdtUrl
|
|
441
461
|
} = {}) {
|
|
442
462
|
const isBas = Boolean(env.H2O_URL);
|
|
443
463
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -584,10 +604,10 @@ export async function runSetup({
|
|
|
584
604
|
]
|
|
585
605
|
}, { input, output });
|
|
586
606
|
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
587
|
-
selected = await runManualEntryWizard({ env, input, output });
|
|
607
|
+
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
588
608
|
if (!selected.length) return await exitWithoutDestinations();
|
|
589
609
|
} else {
|
|
590
|
-
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
|
|
610
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
|
|
591
611
|
}
|
|
592
612
|
|
|
593
613
|
let sapDevelopmentServers = [];
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -213,7 +213,7 @@ 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 = '' } = {}, { 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.
|
|
@@ -225,6 +225,8 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
225
225
|
let renderedLines = 0;
|
|
226
226
|
let done = false;
|
|
227
227
|
let previousRawMode;
|
|
228
|
+
// With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
|
|
229
|
+
let awaitingEmptyConfirm = false;
|
|
228
230
|
|
|
229
231
|
const write = text => output.write(text);
|
|
230
232
|
const clearRendered = () => {
|
|
@@ -237,6 +239,12 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
237
239
|
if (cursor >= top + pageSize) top = cursor - pageSize + 1;
|
|
238
240
|
top = clamp(top, 0, Math.max(0, choices.length - pageSize));
|
|
239
241
|
};
|
|
242
|
+
const footer = error => {
|
|
243
|
+
if (error) return [[error, 'red']];
|
|
244
|
+
if (awaitingEmptyConfirm) return [['Nothing is selected yet. Press Space to select the highlighted item (❯).', 'yellow'], [confirmEmpty, 'yellow']];
|
|
245
|
+
if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
|
|
246
|
+
return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
|
|
247
|
+
};
|
|
240
248
|
const render = (error = '') => {
|
|
241
249
|
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
242
250
|
const pageSize = checkboxPageSize(output, choices.length);
|
|
@@ -247,17 +255,15 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
247
255
|
for (let index = top; index < end; index += 1) {
|
|
248
256
|
const choice = choices[index];
|
|
249
257
|
const pointer = index === cursor ? '❯' : ' ';
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
: colorText('✗', 'red', output);
|
|
258
|
+
// 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) : '[ ]');
|
|
253
260
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
254
|
-
|
|
255
|
-
lines.push(truncateToColumns(label, columns));
|
|
261
|
+
lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
|
|
256
262
|
}
|
|
257
263
|
if (choices.length > pageSize) {
|
|
258
264
|
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
|
|
259
265
|
}
|
|
260
|
-
|
|
266
|
+
for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
|
|
261
267
|
clearRendered();
|
|
262
268
|
write(`${lines.join('\n')}\n`);
|
|
263
269
|
renderedLines = physicalRows(lines, columns);
|
|
@@ -332,6 +338,7 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
332
338
|
selected.clear();
|
|
333
339
|
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
334
340
|
} else if (actionShortcutFor(_chunk, key)) {
|
|
341
|
+
awaitingEmptyConfirm = false;
|
|
335
342
|
void runAction(actionShortcutFor(_chunk, key));
|
|
336
343
|
return;
|
|
337
344
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
@@ -339,10 +346,15 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
339
346
|
const validation = typeof validate === 'function' ? validate(values) : true;
|
|
340
347
|
if (validation !== true) return render(String(validation));
|
|
341
348
|
if (required && !values.length) return render('Select at least one item.');
|
|
349
|
+
if (confirmEmpty && !values.length && !awaitingEmptyConfirm) {
|
|
350
|
+
awaitingEmptyConfirm = true;
|
|
351
|
+
return render();
|
|
352
|
+
}
|
|
342
353
|
return finish(values);
|
|
343
354
|
} else {
|
|
344
355
|
return;
|
|
345
356
|
}
|
|
357
|
+
awaitingEmptyConfirm = false;
|
|
346
358
|
render();
|
|
347
359
|
};
|
|
348
360
|
|