sap-ai-dev-toolkit 0.9.2 → 0.9.4
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 +25 -12
- package/package.json +1 -1
- package/src/bas-discovery.mjs +55 -13
- package/src/harness-mcp-config.mjs +5 -2
- package/src/mcp-config.mjs +197 -22
- package/src/setup.mjs +268 -149
- package/src/terminal-ui.mjs +46 -4
package/README.md
CHANGED
|
@@ -41,15 +41,17 @@ SAP AI Dev Toolkit connects your AI coding assistant to the SAP development tool
|
|
|
41
41
|
|
|
42
42
|
## ⚡ Quick start
|
|
43
43
|
|
|
44
|
+
The default installation is a single npx command — no global install, nothing left behind except the generated MCP configuration:
|
|
45
|
+
|
|
44
46
|
```sh
|
|
45
|
-
|
|
46
|
-
sap-ai-dev --setup
|
|
47
|
+
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
Prefer a persistent global install instead?
|
|
50
51
|
|
|
51
52
|
```sh
|
|
52
|
-
|
|
53
|
+
npm install --global sap-ai-dev-toolkit
|
|
54
|
+
sap-ai-dev --setup
|
|
53
55
|
```
|
|
54
56
|
|
|
55
57
|
To also add optional full-stack SAP companion MCP servers for Fiori, UI5, CAP, and browser validation, run:
|
|
@@ -248,17 +250,23 @@ Once the destination server is enabled in your chat's tools picker, ask for outc
|
|
|
248
250
|
|
|
249
251
|
## 📦 Installation
|
|
250
252
|
|
|
251
|
-
|
|
253
|
+
The default installation is a single npx command that runs the guided setup directly through npm — no global install required. It works in SAP Business Application Studio and on local workstations alike:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Prefer a persistent global install? Install globally from SAP Business Application Studio or from a local workstation with SAP GUI installed, then run setup explicitly for your environment:
|
|
252
260
|
|
|
253
261
|
| Environment / device | What to run | What setup does |
|
|
254
262
|
| --- | --- | --- |
|
|
255
|
-
| **
|
|
256
|
-
| **
|
|
257
|
-
| **
|
|
263
|
+
| **Any environment (default — no global install)** | `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` | Runs the guided setup through npm and writes generated entries that launch the pinned package through `npx` (`cmd /c npx` on Windows). Add `--npx` to force the npx launcher even when a global `sap-ai-dev` command exists. |
|
|
264
|
+
| **SAP Business Application Studio dev space (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers BAS destinations, optionally imports Cloud Foundry Destination service records, and writes VS Code/BAS MCP entries. |
|
|
265
|
+
| **Windows workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries. Windows SSO can be selected if the ADT endpoint supports Negotiate/SPNEGO. |
|
|
266
|
+
| **macOS workstation with SAP GUI (global install)** | `npm install --global sap-ai-dev-toolkit`<br>`sap-ai-dev --setup` | Discovers SAP GUI landscape entries, asks for SAP client(s) and ADT URL, then writes local VS Code MCP entries with secure login prompts. |
|
|
258
267
|
| **Any local interactive terminal, already installed** | `sap-ai-dev --setup` | Re-runs the wizard to add, update, or remove generated MCP entries. Plain `sap-ai-dev` with no arguments also starts local setup when no BAS environment is detected. |
|
|
259
268
|
| **npm install hook did not show a wizard** | `sap-ai-dev --setup` | This is expected on some npm/Windows combinations because lifecycle scripts may run without an interactive console. The explicit setup command is the reliable path. |
|
|
260
269
|
| **Force npm to show install-time script output** | `npm install --global --foreground-scripts sap-ai-dev-toolkit` | Optional troubleshooting mode if you specifically want to see postinstall output during installation. Still run `sap-ai-dev --setup` afterwards if no destinations were configured. |
|
|
261
|
-
| **No global install / temporary setup** | `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` | Runs the guided setup through npm and writes generated entries that launch the pinned package through `npx` (`cmd /c npx` on Windows). Add `--npx` to force the npx launcher even when a global `sap-ai-dev` command exists. |
|
|
262
270
|
|
|
263
271
|
### 🪟 Windows notes
|
|
264
272
|
|
|
@@ -267,6 +275,7 @@ Everything works natively on Windows — PowerShell, cmd, and Windows Terminal a
|
|
|
267
275
|
- `npm install --global sap-ai-dev-toolkit` puts three commands on `PATH`: `sap-ai-dev`, its alias `sap-ai-dev-toolkit`, and `sap-ai-hana`. If `sap-ai-dev` is reported as unknown, npm's global bin directory is missing from `PATH` (run `npm prefix -g` to find it) or the install did not complete — `npx --yes --ignore-scripts sap-ai-dev-toolkit --setup` works without any global install.
|
|
268
276
|
- The pinned VSP runtime ships inside the package as a native `vsp-win32-x64.exe`; no Go toolchain, WSL, or extra downloads are required.
|
|
269
277
|
- MCP entries that launch through npm are written as `cmd /c npx ...` on Windows — the form VS Code, Claude Code, and Cursor can spawn there. Entries written on Linux/macOS use plain `npx`.
|
|
278
|
+
- Setup reads `mcp.json` even when Windows tools saved it with a UTF-8/UTF-16 BOM (PowerShell `>`/`Set-Content`, Notepad "UTF-8 with BOM") and rewrites it as plain UTF-8. If the existing file cannot be used as-is, setup self-heals before falling back to a reset: VS Code-style comments and trailing commas (JSONC) are recovered in place, torn reads from a concurrent editor save are re-read once, and broken `servers`/`inputs` fields are repaired individually. Every lossy change first backs up the original next to the real file as `mcp.json.healed|repaired|invalid-<timestamp>.bak` (exclusive create, so reruns never overwrite an earlier backup; symlinked configs keep their link). `sap-ai-dev --doctor` stays strict and reports such files instead of rewriting them — run `sap-ai-dev --setup` to heal.
|
|
270
279
|
- Local SAP GUI discovery reads the Windows landscape files and registry, and setup can write an experimental Windows SSO (`Negotiate`/SPNEGO) entry; see the “Local machine SAP GUI support” section below.
|
|
271
280
|
|
|
272
281
|
If you previously installed the old package, remove it first because the legacy `bas-vsp-mcp` command is no longer shipped:
|
|
@@ -316,7 +325,7 @@ Typical local setup flow:
|
|
|
316
325
|
sap-ai-dev --setup
|
|
317
326
|
```
|
|
318
327
|
|
|
319
|
-
1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**.
|
|
328
|
+
1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**. If no SAP GUI entries are found, setup offers the manual entry wizard: enter a system name, ADT URL, client(s), and login mode instead.
|
|
320
329
|
2. Confirm the detected/default SAP client, or enter multiple clients such as `100,200`.
|
|
321
330
|
3. Accept the proposed ADT URL when it is correct, or edit it if your landscape exposes ADT through a different HTTP(S) host/port.
|
|
322
331
|
4. If setup discovers certificate DNS names for an IP-based HTTPS URL, accept the yes/no TLS self-healing prompt.
|
|
@@ -343,7 +352,7 @@ To configure without a global install, run the guided setup directly through npm
|
|
|
343
352
|
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
344
353
|
```
|
|
345
354
|
|
|
346
|
-
This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm,
|
|
355
|
+
This lists discovered systems in the same checkbox picker, with nothing 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. Because no global `sap-ai-dev` command is on `PATH`, it writes MCP entries that launch the pinned package version through npx — through `cmd /c npx` on Windows, which is the form VS Code, Claude Code, and Cursor can spawn there. `--ignore-scripts` avoids running the install-time wizard a second time; add `--npx` to force this launcher shape even when the toolkit is installed globally. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
|
|
347
356
|
|
|
348
357
|
For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
|
|
349
358
|
|
|
@@ -438,7 +447,9 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
|
|
|
438
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.
|
|
439
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.
|
|
440
449
|
|
|
441
|
-
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,
|
|
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.
|
|
451
|
+
|
|
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.
|
|
442
453
|
|
|
443
454
|
Setup tidies its own footprint: it reconciles MCP entries managed by this package and removes legacy `sapAiDev_*` / `basVspMcp_*` entries from earlier releases. Existing unrelated MCP servers and top-level configuration such as `inputs` stay untouched.
|
|
444
455
|
|
|
@@ -848,6 +859,8 @@ Runtime diagnostics take the stderr lane. In the MCP server's Output view, check
|
|
|
848
859
|
|
|
849
860
|
If `GetSystemInfo` or `RunQuery` reports a CSRF-token fetch failure, VSP tries `/sap/bc/adt/core/discovery` first and falls back to `/sap/bc/adt/discovery` when the core endpoint returns HTTP 404 or 5xx without a token. If both endpoints fail, the error includes their HTTP statuses; check BAS destination routing and the SAP ADT service. `GetInstalledComponents` is a separate GET request and a failure there is not evidence of a CSRF problem.
|
|
850
861
|
|
|
862
|
+
**`MCP config ... contains invalid JSON`** usually means one of three things, all healed automatically by setup: the file starts with a byte-order mark (Windows PowerShell `>` redirection, `Set-Content`, or Notepad's "UTF-8 with BOM" mode — invisible in editors and terminals), it contains comments or trailing commas (VS Code reads `mcp.json` as JSONC), or setup raced an editor save and read a torn file (retried once before healing). Unrecoverable content is backed up as `mcp.json.<kind>-<timestamp>.bak` and replaced; the backup path is printed in the setup warning. On older versions, re-save the file as UTF-8 without BOM (VS Code: "Change File Encoding → Save with Encoding → UTF-8") and rerun `sap-ai-dev --setup`.
|
|
863
|
+
|
|
851
864
|
If automatic Go or VSP provisioning fails, check network access and the package's supported platform. You can install Go manually or set `GO_BINARY` as an explicit fallback. A trusted prebuilt VSP can be supplied with `SAP_AI_DEV_TOOLKIT_BINARY`.
|
|
852
865
|
|
|
853
866
|
## 🧑💻 Development
|
package/package.json
CHANGED
package/src/bas-discovery.mjs
CHANGED
|
@@ -17,19 +17,26 @@ function scalar(value) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function propertiesOf(item) {
|
|
20
|
+
// Nested property maps (BAS wraps destination attributes in `properties`,
|
|
21
|
+
// some proxies use `configuration` or a key/value array) only fill gaps:
|
|
22
|
+
// a nested `Name` — e.g. the Cloud Connector system name `_SIDCLNTOX` —
|
|
23
|
+
// must never overwrite the real destination name at the top level.
|
|
20
24
|
const result = {};
|
|
21
|
-
|
|
22
|
-
const maps =
|
|
25
|
+
const entries = Object.entries(item || {});
|
|
26
|
+
const maps = entries.filter(([key]) => ['properties', 'propertymap', 'destinationproperties', 'configuration'].includes(keyName(key))).map(([, value]) => value);
|
|
23
27
|
for (const map of maps) {
|
|
24
28
|
if (Array.isArray(map)) {
|
|
25
29
|
for (const entry of map) {
|
|
26
30
|
const key = entry?.key ?? entry?.name ?? entry?.property;
|
|
27
|
-
if (key != null) result[keyName(key)] = scalar(entry?.value ?? entry?.val);
|
|
31
|
+
if (key != null && !(keyName(key) in result)) result[keyName(key)] = scalar(entry?.value ?? entry?.val);
|
|
28
32
|
}
|
|
29
33
|
} else if (map && typeof map === 'object') {
|
|
30
|
-
for (const [key, value] of Object.entries(map))
|
|
34
|
+
for (const [key, value] of Object.entries(map)) {
|
|
35
|
+
if (!(keyName(key) in result)) result[keyName(key)] = scalar(value);
|
|
36
|
+
}
|
|
31
37
|
}
|
|
32
38
|
}
|
|
39
|
+
for (const [key, value] of entries) result[keyName(key)] = scalar(value);
|
|
33
40
|
return result;
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -42,23 +49,36 @@ function listFromBody(body) {
|
|
|
42
49
|
if (Array.isArray(body)) return body;
|
|
43
50
|
if (!body || typeof body !== 'object') return null;
|
|
44
51
|
for (const [key, value] of Object.entries(body)) {
|
|
45
|
-
if (['destinations', 'data'].includes(keyName(key))
|
|
52
|
+
if (!['destinations', 'destinationlist', 'data', 'items', 'value'].includes(keyName(key))) continue;
|
|
53
|
+
if (Array.isArray(value)) return value;
|
|
54
|
+
// Some destination services unwrap records into a name-keyed map
|
|
55
|
+
// instead of an array; heal the shape so discovery still proceeds.
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
const records = Object.entries(value).filter(([, entry]) => entry && typeof entry === 'object').map(([mapName, entry]) => ({ name: mapName, ...entry }));
|
|
58
|
+
if (records.length) return records;
|
|
59
|
+
}
|
|
46
60
|
}
|
|
47
61
|
return null;
|
|
48
62
|
}
|
|
49
63
|
|
|
50
64
|
export function normalizeDestination(item) {
|
|
51
65
|
const values = propertiesOf(item);
|
|
52
|
-
const name = text(values.name || values.destinationname || values.destname);
|
|
66
|
+
const name = text(values.name || values.destinationname || values.destname || values.destination || values.sapdestinationname);
|
|
53
67
|
const authentication = text(values.authentication || values.authtype || values.auth);
|
|
54
|
-
const client = text(values.sapclient || values.client) || '001';
|
|
68
|
+
const client = text(values.sapclient || values.client || values.mandt) || '001';
|
|
55
69
|
const backendUrl = text(values.url || values.host || values.webideexposedhost);
|
|
56
70
|
const proxyType = text(values.proxytype || values.proxy);
|
|
71
|
+
// One destination with an unusable name must never abort discovery; the
|
|
72
|
+
// probe reports `invalid-name` for entries whose url stays null.
|
|
73
|
+
let url = null;
|
|
74
|
+
if (name) {
|
|
75
|
+
try { url = destinationUrl(name); } catch { url = null; }
|
|
76
|
+
}
|
|
57
77
|
return {
|
|
58
78
|
name,
|
|
59
79
|
authentication: authentication || 'Unknown',
|
|
60
80
|
client,
|
|
61
|
-
url
|
|
81
|
+
url,
|
|
62
82
|
backendUrl: backendUrl || null,
|
|
63
83
|
proxyType: proxyType || null,
|
|
64
84
|
rawKeys: Object.keys(item || {}).map(String)
|
|
@@ -67,7 +87,10 @@ export function normalizeDestination(item) {
|
|
|
67
87
|
|
|
68
88
|
export function destinationUrl(name) {
|
|
69
89
|
const value = text(name);
|
|
70
|
-
|
|
90
|
+
// A leading underscore is valid: BAS destination names allow it (Cloud
|
|
91
|
+
// Connector system names like `_SIDCLNTOX`), and `<name>.dest` resolution
|
|
92
|
+
// inside a dev space is Host-header matching in the BAS proxy, not DNS.
|
|
93
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid BAS destination name: ${value || '<empty>'}`);
|
|
71
94
|
return `http://${value}.dest`;
|
|
72
95
|
}
|
|
73
96
|
|
|
@@ -137,7 +160,7 @@ function proxyDispatcher(target, env) {
|
|
|
137
160
|
|
|
138
161
|
export async function fetchDestinationList(h2oUrl, options = {}) {
|
|
139
162
|
if (!h2oUrl) throw new Error('H2O_URL is required for BAS destination discovery');
|
|
140
|
-
const url = `${String(h2oUrl).replace(
|
|
163
|
+
const url = `${String(h2oUrl).trim().replace(/\/+$/, '')}/api/listDestinations`;
|
|
141
164
|
const env = options.env || process.env;
|
|
142
165
|
const dispatcher = proxyDispatcher(url, env);
|
|
143
166
|
try {
|
|
@@ -147,7 +170,15 @@ export async function fetchDestinationList(h2oUrl, options = {}) {
|
|
|
147
170
|
...(dispatcher ? { dispatcher } : {})
|
|
148
171
|
});
|
|
149
172
|
if (!response.ok) throw new Error(`BAS destination discovery failed with HTTP ${response.status}`);
|
|
150
|
-
|
|
173
|
+
// Parse from text so UTF-8 BOMs and text/plain content types from
|
|
174
|
+
// proxies in front of BAS do not break discovery.
|
|
175
|
+
const payload = await response.text();
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(payload.replace(/^\uFEFF/, ''));
|
|
178
|
+
} catch {
|
|
179
|
+
const preview = payload.trim().slice(0, 200);
|
|
180
|
+
throw new Error(`BAS destination discovery returned unparsable JSON${preview ? `: ${preview}` : ''}`);
|
|
181
|
+
}
|
|
151
182
|
} finally {
|
|
152
183
|
await dispatcher?.close();
|
|
153
184
|
}
|
|
@@ -186,7 +217,12 @@ function requestProbe(target, proxyUrl, timeoutMs) {
|
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
export async function probeADT(destination, options = {}) {
|
|
189
|
-
|
|
220
|
+
let url;
|
|
221
|
+
try {
|
|
222
|
+
url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return { status: 'invalid-name', available: false, warning: error.message };
|
|
225
|
+
}
|
|
190
226
|
if (options.skipProbe) return { status: 'skipped', available: true, url };
|
|
191
227
|
try {
|
|
192
228
|
const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl ?? DEFAULT_PROXY, options.timeoutMs || 5000);
|
|
@@ -204,7 +240,13 @@ export async function discoverDestinations(options = {}) {
|
|
|
204
240
|
const list = listFromBody(body);
|
|
205
241
|
if (!list) throw new Error(`Unrecognized BAS destination response shape; top-level keys: ${Object.keys(body || {}).map(String).join(', ') || '<none>'}`);
|
|
206
242
|
const allow = text(brandedEnvValue(env, 'DESTINATION')).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
|
207
|
-
const normalized = list
|
|
243
|
+
const normalized = list
|
|
244
|
+
.map(item => {
|
|
245
|
+
try { return normalizeDestination(item); }
|
|
246
|
+
catch (error) { return { name: '', probe: { status: 'invalid-name', available: false, warning: error.message } }; }
|
|
247
|
+
})
|
|
248
|
+
.filter(item => item.name)
|
|
249
|
+
.filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
|
|
208
250
|
const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
|
|
209
251
|
const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
|
|
210
252
|
const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
|
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { basename, dirname, join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { harnessById, harnessRoot } from './harnesses.mjs';
|
|
6
|
+
import { decodeJsonFileBuffer } from './mcp-config.mjs';
|
|
6
7
|
|
|
7
8
|
const MANAGED_ENV = 'SAP_AI_DEV_TOOLKIT_MANAGED';
|
|
8
9
|
|
|
@@ -24,13 +25,15 @@ function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = p
|
|
|
24
25
|
async function readJsonConfig(path) {
|
|
25
26
|
let raw;
|
|
26
27
|
try {
|
|
27
|
-
raw = await readFile(path
|
|
28
|
+
raw = await readFile(path);
|
|
28
29
|
} catch (error) {
|
|
29
30
|
if (error.code === 'ENOENT') return {};
|
|
30
31
|
throw error;
|
|
31
32
|
}
|
|
32
33
|
try {
|
|
33
|
-
|
|
34
|
+
// decodeJsonFileBuffer tolerates the UTF-8 BOM / UTF-16 encodings that
|
|
35
|
+
// Windows tools commonly write (see mcp-config.mjs).
|
|
36
|
+
const parsed = JSON.parse(decodeJsonFileBuffer(raw));
|
|
34
37
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('top-level value is not an object');
|
|
35
38
|
return parsed;
|
|
36
39
|
} catch (error) {
|
package/src/mcp-config.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { access, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
5
|
import { homedir, platform } from 'node:os';
|
|
5
6
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -79,6 +80,26 @@ async function exists(path) {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Windows tools (PowerShell `>` redirection and Set-Content, Notepad "UTF-8
|
|
84
|
+
// with BOM", several editors) write mcp.json with a byte-order mark or even
|
|
85
|
+
// UTF-16. JSON.parse rejects a leading BOM (rendering invisibly as
|
|
86
|
+
// "Unexpected token ''"), so decode defensively before parsing.
|
|
87
|
+
export function decodeJsonFileBuffer(buffer) {
|
|
88
|
+
if (!Buffer.isBuffer(buffer)) return String(buffer ?? '').replace(/^/, '');
|
|
89
|
+
if (buffer.length >= 2) {
|
|
90
|
+
if (buffer[0] === 0xFF && buffer[1] === 0xFE) return buffer.subarray(2).toString('utf16le');
|
|
91
|
+
if (buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
|
92
|
+
let body = buffer.subarray(2);
|
|
93
|
+
if (body.length % 2) body = body.subarray(0, body.length - 1);
|
|
94
|
+
return Buffer.from(body).swap16().toString('utf16le');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const utf8 = (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF)
|
|
98
|
+
? buffer.subarray(3).toString('utf8')
|
|
99
|
+
: buffer.toString('utf8');
|
|
100
|
+
return utf8.replace(/^\uFEFF/, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
export async function resolveMcpServerCommand(env = process.env) {
|
|
83
104
|
const executableNames = process.platform === 'win32'
|
|
84
105
|
? ['sap-ai-dev.cmd', 'sap-ai-dev.exe', 'sap-ai-dev']
|
|
@@ -336,36 +357,186 @@ export function collectCloudFoundryKeyReferencesFromAllEntries(config) {
|
|
|
336
357
|
return collectCloudFoundryKeyReferences(config, false);
|
|
337
358
|
}
|
|
338
359
|
|
|
339
|
-
|
|
340
|
-
|
|
360
|
+
function freshConfig() {
|
|
361
|
+
return { servers: {}, inputs: [] };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// VS Code parses mcp.json as JSONC: // and /* */ comments plus trailing
|
|
365
|
+
// commas are accepted there but rejected by strict JSON.parse. Strip them in
|
|
366
|
+
// one string-aware pass (string literals are copied verbatim, so "//", "/*",
|
|
367
|
+
// and "," inside values survive) so self-healing can recover hand-written
|
|
368
|
+
// content instead of resetting the file.
|
|
369
|
+
export function loosenJsonText(text) {
|
|
370
|
+
let out = '';
|
|
371
|
+
let i = 0;
|
|
372
|
+
const n = text.length;
|
|
373
|
+
while (i < n) {
|
|
374
|
+
const ch = text[i];
|
|
375
|
+
if (ch === '"') {
|
|
376
|
+
let j = i + 1;
|
|
377
|
+
while (j < n && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
378
|
+
out += text.slice(i, Math.min(j + 1, n));
|
|
379
|
+
i = Math.min(j + 1, n);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
383
|
+
i += 2;
|
|
384
|
+
while (i < n && text[i] !== '\n' && text[i] !== '\r') i += 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
388
|
+
i += 2;
|
|
389
|
+
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
|
|
390
|
+
i = Math.min(i + 2, n);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ch === ',') {
|
|
394
|
+
// A trailing comma is dropped only when nothing but whitespace and
|
|
395
|
+
// comments separate it from a closing bracket.
|
|
396
|
+
let k = i + 1;
|
|
397
|
+
while (k < n) {
|
|
398
|
+
const lookahead = text[k];
|
|
399
|
+
if (lookahead === ' ' || lookahead === '\t' || lookahead === '\n' || lookahead === '\r') { k += 1; continue; }
|
|
400
|
+
if (lookahead === '/' && text[k + 1] === '/') { k += 2; while (k < n && text[k] !== '\n' && text[k] !== '\r') k += 1; continue; }
|
|
401
|
+
if (lookahead === '/' && text[k + 1] === '*') { k += 2; while (k < n && !(text[k] === '*' && text[k + 1] === '/')) k += 1; k = Math.min(k + 2, n); continue; }
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (text[k] === '}' || text[k] === ']') { i += 1; continue; }
|
|
405
|
+
}
|
|
406
|
+
out += ch;
|
|
407
|
+
i += 1;
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function backupPathCandidates(path, kind, now = new Date()) {
|
|
413
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
|
|
414
|
+
return ['', '-2', '-3', '-4', '-5'].map(suffix => `${path}.${kind}-${stamp}${suffix}.bak`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Backups use exclusive creation (wx) so a repeated setup can never clobber
|
|
418
|
+
// an earlier backup; identical timestamps walk a numeric suffix instead.
|
|
419
|
+
// Failing to write the backup is fatal on purpose: healing must never risk
|
|
420
|
+
// losing the only copy of the original file.
|
|
421
|
+
export async function writeConfigBackup(path, buffer, { kind, now } = {}) {
|
|
422
|
+
let lastError;
|
|
423
|
+
for (const candidate of backupPathCandidates(path, kind, now)) {
|
|
424
|
+
try {
|
|
425
|
+
await writeFile(candidate, buffer, { flag: 'wx', mode: 0o600 });
|
|
426
|
+
return candidate;
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (error?.code !== 'EEXIST') {
|
|
429
|
+
throw new Error(`Could not back up ${path} before healing (${error.message}); the original file was left untouched.`);
|
|
430
|
+
}
|
|
431
|
+
lastError = error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`Could not back up ${path}: no unique backup name was available (${lastError?.message}).`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function shapeFailures(config) {
|
|
438
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
439
|
+
return [{ field: null, message: 'must contain a top-level JSON object' }];
|
|
440
|
+
}
|
|
441
|
+
const failures = [];
|
|
442
|
+
if (config.servers !== undefined && (!config.servers || typeof config.servers !== 'object' || Array.isArray(config.servers))) {
|
|
443
|
+
failures.push({ field: 'servers', message: 'has a non-object servers value' });
|
|
444
|
+
}
|
|
445
|
+
if (config.inputs !== undefined && config.inputs !== null && !Array.isArray(config.inputs)) {
|
|
446
|
+
failures.push({ field: 'inputs', message: 'has a non-array inputs value' });
|
|
447
|
+
}
|
|
448
|
+
return failures;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Healing ladder (install/setup only; strict readers such as --doctor keep
|
|
452
|
+
// rejecting): BOM/UTF-16 decode -> torn-write re-read -> JSONC recovery ->
|
|
453
|
+
// field-level repair -> full reset. Every lossy step backs up the original
|
|
454
|
+
// first; a backup failure aborts rather than risking data loss.
|
|
455
|
+
async function readConfig(path, options = {}) {
|
|
456
|
+
const { recoverInvalid = false, warnings, retryDelayMs = 250, readFileImpl = readFile } = options;
|
|
457
|
+
let buffer;
|
|
341
458
|
try {
|
|
342
|
-
|
|
459
|
+
buffer = await readFileImpl(path);
|
|
343
460
|
} catch (error) {
|
|
344
|
-
if (error?.code === 'ENOENT') return
|
|
461
|
+
if (error?.code === 'ENOENT') return freshConfig();
|
|
345
462
|
throw new Error(`MCP config ${path} could not be read: ${error.message}`);
|
|
346
463
|
}
|
|
347
|
-
let
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
464
|
+
let text = decodeJsonFileBuffer(buffer);
|
|
465
|
+
if (!text.trim()) return freshConfig();
|
|
466
|
+
const tryParse = candidate => {
|
|
467
|
+
try { return { config: JSON.parse(candidate) }; } catch (error) { return { error }; }
|
|
468
|
+
};
|
|
469
|
+
let parsed = tryParse(text);
|
|
470
|
+
if (parsed.error && retryDelayMs > 0) {
|
|
471
|
+
// A torn read — setup racing an editor's non-atomic save — looks exactly
|
|
472
|
+
// like corruption; re-read once before healing so a transient state is
|
|
473
|
+
// never backed up and replaced.
|
|
474
|
+
await sleep(retryDelayMs);
|
|
475
|
+
const second = await readFileImpl(path).catch(() => null);
|
|
476
|
+
if (second && !second.equals(buffer)) {
|
|
477
|
+
buffer = second;
|
|
478
|
+
text = decodeJsonFileBuffer(second);
|
|
479
|
+
if (!text.trim()) return freshConfig();
|
|
480
|
+
parsed = tryParse(text);
|
|
481
|
+
}
|
|
352
482
|
}
|
|
353
|
-
|
|
354
|
-
|
|
483
|
+
let loosened = false;
|
|
484
|
+
if (parsed.error && recoverInvalid) {
|
|
485
|
+
const recovered = tryParse(loosenJsonText(text));
|
|
486
|
+
if (!recovered.error) {
|
|
487
|
+
parsed = recovered;
|
|
488
|
+
loosened = true;
|
|
489
|
+
}
|
|
355
490
|
}
|
|
356
|
-
|
|
357
|
-
|
|
491
|
+
const failures = parsed.error ? [{ field: null, message: `contains invalid JSON: ${parsed.error.message}` }] : shapeFailures(parsed.config);
|
|
492
|
+
if (failures.some(failure => failure.field === null)) {
|
|
493
|
+
const detail = failures[0].message;
|
|
494
|
+
if (!recoverInvalid) throw new Error(`MCP config ${path} ${detail}`);
|
|
495
|
+
return recoverConfig(path, buffer, warnings, {
|
|
496
|
+
kind: 'invalid',
|
|
497
|
+
notes: [`was not usable as JSON (${detail.replace(/^contains invalid JSON: /, '')}) and was replaced with a fresh config`],
|
|
498
|
+
config: freshConfig()
|
|
499
|
+
});
|
|
358
500
|
}
|
|
359
|
-
|
|
501
|
+
const blocking = failures.find(failure => failure.field !== 'inputs');
|
|
502
|
+
if (!recoverInvalid) {
|
|
503
|
+
if (blocking) throw new Error(`MCP config ${path} ${blocking.message}`);
|
|
504
|
+
return { ...parsed.config, servers: parsed.config.servers || {} };
|
|
505
|
+
}
|
|
506
|
+
const notes = [];
|
|
507
|
+
if (loosened) notes.push('contained comments or trailing commas (accepted by VS Code, not strict JSON)');
|
|
508
|
+
const healed = { ...parsed.config };
|
|
509
|
+
for (const failure of failures) {
|
|
510
|
+
if (failure.field === 'servers') { healed.servers = {}; notes.push('had a non-object "servers" value, which was reset'); }
|
|
511
|
+
if (failure.field === 'inputs') { delete healed.inputs; notes.push('had a non-array "inputs" value, which was removed'); }
|
|
512
|
+
}
|
|
513
|
+
if (!notes.length) return { ...parsed.config, servers: parsed.config.servers || {} };
|
|
514
|
+
return recoverConfig(path, buffer, warnings, {
|
|
515
|
+
kind: loosened ? 'healed' : 'repaired',
|
|
516
|
+
notes,
|
|
517
|
+
config: { ...healed, servers: healed.servers || {} }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function recoverConfig(path, buffer, warnings, { kind, notes, config }) {
|
|
522
|
+
// Back up beside the real file (following symlinks) so a linked mcp.json
|
|
523
|
+
// keeps its link and the backup sits next to the actual target.
|
|
524
|
+
const backupBase = await realpath(path).catch(() => path);
|
|
525
|
+
const backup = await writeConfigBackup(backupBase, buffer, { kind });
|
|
526
|
+
warnings?.push(`The existing MCP config ${notes.join(' and ')}; the original was backed up to ${backup}.`);
|
|
527
|
+
return config;
|
|
360
528
|
}
|
|
361
529
|
|
|
362
530
|
async function writeConfig(path, config) {
|
|
363
|
-
|
|
531
|
+
// Follow symlinks so a linked mcp.json (dotfiles managers) keeps its link
|
|
532
|
+
// while the real target is replaced atomically.
|
|
533
|
+
const target = await realpath(path).catch(() => path);
|
|
534
|
+
const directory = dirname(target);
|
|
364
535
|
await mkdir(directory, { recursive: true });
|
|
365
|
-
const temporary = join(directory, `.${basename(
|
|
536
|
+
const temporary = join(directory, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
|
|
366
537
|
try {
|
|
367
538
|
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
368
|
-
await rename(temporary,
|
|
539
|
+
await rename(temporary, target);
|
|
369
540
|
} catch (error) {
|
|
370
541
|
await rm(temporary, { force: true }).catch(() => {});
|
|
371
542
|
throw new Error(`MCP config ${path} could not be written: ${error.message}`);
|
|
@@ -380,7 +551,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
380
551
|
}
|
|
381
552
|
const env = options.env || process.env;
|
|
382
553
|
const path = options.path || await resolveMcpConfigPath(env);
|
|
383
|
-
const
|
|
554
|
+
const installWarnings = [];
|
|
555
|
+
// recoverInvalid: BOM/encoding damage, VS Code JSONC comments, torn writes
|
|
556
|
+
// racing an editor save, and corrupt configs are healed (with a backup)
|
|
557
|
+
// instead of failing the whole setup (self-healing).
|
|
558
|
+
const config = await readConfig(path, { recoverInvalid: true, warnings: installWarnings });
|
|
384
559
|
const selfVersion = await runningPackageVersion();
|
|
385
560
|
const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
|
|
386
561
|
const generated = {
|
|
@@ -434,7 +609,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
|
|
|
434
609
|
config.inputs = [...existingInputs, ...generatedInputs.values()];
|
|
435
610
|
}
|
|
436
611
|
await writeConfig(path, config);
|
|
437
|
-
return { path, servers: generated };
|
|
612
|
+
return { path, servers: generated, warnings: installWarnings };
|
|
438
613
|
}
|
|
439
614
|
|
|
440
615
|
export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
|
|
@@ -499,6 +674,6 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
|
|
|
499
674
|
return { path: configPath, changed: true, repaired, managedEntries };
|
|
500
675
|
}
|
|
501
676
|
|
|
502
|
-
export async function readMcpConfig(path) {
|
|
503
|
-
return readConfig(path);
|
|
677
|
+
export async function readMcpConfig(path, options = {}) {
|
|
678
|
+
return readConfig(path, options);
|
|
504
679
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -112,6 +112,167 @@ async function promptSapClients(destination, { input = stdin, output = stdout }
|
|
|
112
112
|
return parseSapClientList(answer, fallback);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
function validateAdtUrlCandidate(candidate) {
|
|
116
|
+
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(candidate);
|
|
119
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
120
|
+
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
121
|
+
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch { return 'Enter a valid URL.'; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function localAuthenticationLabel(authMode) {
|
|
128
|
+
return ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
132
|
+
const answer = await textPrompt({
|
|
133
|
+
message,
|
|
134
|
+
placeholder: 'y',
|
|
135
|
+
required: false,
|
|
136
|
+
validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
|
|
137
|
+
}, { input, output });
|
|
138
|
+
return !/^n(?:o)?$/i.test(String(answer || '').trim());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false } = {}) {
|
|
142
|
+
const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
|
|
143
|
+
const localDestination = { ...destination, name, serverName: name, client };
|
|
144
|
+
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
145
|
+
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
146
|
+
const credentialInputs = [
|
|
147
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
148
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
149
|
+
];
|
|
150
|
+
if (authMode === 'windows-sso') {
|
|
151
|
+
localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
152
|
+
localDestination.inputs = [];
|
|
153
|
+
} else if (authMode === 'windows-credential-ui') {
|
|
154
|
+
localDestination.childEnv = {
|
|
155
|
+
SAP_AUTH_MODE: 'windows-sso',
|
|
156
|
+
SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
|
|
157
|
+
};
|
|
158
|
+
localDestination.inputs = [];
|
|
159
|
+
} else if (authMode === 'windows-sso-basic-fallback') {
|
|
160
|
+
localDestination.childEnv = {
|
|
161
|
+
SAP_AUTH_MODE: 'windows-sso',
|
|
162
|
+
SAP_AUTH_FALLBACK_MODE: 'basic',
|
|
163
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
164
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
165
|
+
};
|
|
166
|
+
localDestination.inputs = credentialInputs;
|
|
167
|
+
} else if (authMode === 'browser-saml') {
|
|
168
|
+
localDestination.childEnv = {
|
|
169
|
+
SAP_AUTH_MODE: 'browser-saml',
|
|
170
|
+
SAP_BROWSER_AUTH: 'true',
|
|
171
|
+
SAP_SAML_AUTH: 'true'
|
|
172
|
+
};
|
|
173
|
+
localDestination.inputs = [];
|
|
174
|
+
} else if (authMode === 'saml-password') {
|
|
175
|
+
localDestination.childEnv = {
|
|
176
|
+
SAP_AUTH_MODE: 'saml-password',
|
|
177
|
+
SAP_SAML_AUTH: 'true',
|
|
178
|
+
SAP_SAML_USER: `\${input:${userInputId}}`,
|
|
179
|
+
SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
|
|
180
|
+
};
|
|
181
|
+
localDestination.inputs = credentialInputs;
|
|
182
|
+
} else {
|
|
183
|
+
localDestination.childEnv = {
|
|
184
|
+
SAP_AUTH_MODE: 'basic',
|
|
185
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
186
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
187
|
+
};
|
|
188
|
+
localDestination.inputs = credentialInputs;
|
|
189
|
+
}
|
|
190
|
+
return localDestination;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = stdin, output = stdout } = {}) {
|
|
194
|
+
const parsedAdtUrl = new URL(destination.url);
|
|
195
|
+
if (parsedAdtUrl.protocol !== 'https:' || !isIP(parsedAdtUrl.hostname)) return;
|
|
196
|
+
print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
|
|
197
|
+
let discoveredNames = [];
|
|
198
|
+
try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
|
|
199
|
+
catch { discoveredNames = []; }
|
|
200
|
+
if (!discoveredNames.length) {
|
|
201
|
+
print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const preview = discoveredNames.slice(0, 5).join(', ');
|
|
205
|
+
const useDiscovered = await confirmPrompt(`Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`, { input, output });
|
|
206
|
+
if (useDiscovered) {
|
|
207
|
+
destination.tlsServerNames = discoveredNames;
|
|
208
|
+
destination.tlsServerName = discoveredNames[0];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Manual MCP entry wizard: offered whenever discovery finds nothing to select
|
|
213
|
+
// (no BAS destinations, no Cloud Foundry imports, no SAP GUI entries). It
|
|
214
|
+
// collects the minimal ADT configuration directly from the user and produces
|
|
215
|
+
// the same local destination shape as the SAP GUI flow, so installation,
|
|
216
|
+
// credential inputs, and TLS handling stay identical.
|
|
217
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
|
|
218
|
+
print(output, '');
|
|
219
|
+
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
220
|
+
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));
|
|
221
|
+
print(output, colorText(' Setup writes one isolated MCP server per system/client pair, with login prompts and TLS self-healing like every other entry.', 'cyan', output));
|
|
222
|
+
const expanded = [];
|
|
223
|
+
const usedNames = new Set();
|
|
224
|
+
let addAnother = true;
|
|
225
|
+
while (addAnother) {
|
|
226
|
+
const name = (await textPrompt({
|
|
227
|
+
message: 'System name',
|
|
228
|
+
placeholder: 'S4H sandbox',
|
|
229
|
+
required: true,
|
|
230
|
+
validate: value => {
|
|
231
|
+
const trimmed = String(value || '').trim();
|
|
232
|
+
if (!trimmed) return 'Enter a name for the system.';
|
|
233
|
+
if (usedNames.has(trimmed.toLowerCase())) return 'A system with this name was already added; choose another name.';
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}, { input, output })).trim();
|
|
237
|
+
usedNames.add(name.toLowerCase());
|
|
238
|
+
const url = await textPrompt({
|
|
239
|
+
message: `ADT URL for ${name}`,
|
|
240
|
+
placeholder: 'https://host:44300',
|
|
241
|
+
required: true,
|
|
242
|
+
validate: validateAdtUrlCandidate
|
|
243
|
+
}, { input, output });
|
|
244
|
+
let parsedUrl;
|
|
245
|
+
try { parsedUrl = new URL(url); }
|
|
246
|
+
catch { parsedUrl = null; }
|
|
247
|
+
const destination = {
|
|
248
|
+
source: 'sap-gui-local',
|
|
249
|
+
name,
|
|
250
|
+
serverName: name,
|
|
251
|
+
url,
|
|
252
|
+
host: parsedUrl?.hostname || '',
|
|
253
|
+
client: '001',
|
|
254
|
+
authentication: 'Basic',
|
|
255
|
+
authMode: 'basic',
|
|
256
|
+
proxyType: 'Internet',
|
|
257
|
+
// Marks wizard-configured systems so the discovery confirmation prompts
|
|
258
|
+
// (clients, ADT URL, login) skip them when they flow through the
|
|
259
|
+
// destination picker.
|
|
260
|
+
configuredManually: true
|
|
261
|
+
};
|
|
262
|
+
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
263
|
+
const clients = await promptSapClients(destination, { input, output });
|
|
264
|
+
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
265
|
+
destination.authentication = localAuthenticationLabel(authMode);
|
|
266
|
+
destination.authMode = authMode;
|
|
267
|
+
for (const client of clients) {
|
|
268
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
|
|
269
|
+
}
|
|
270
|
+
print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
|
|
271
|
+
addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
|
|
272
|
+
}
|
|
273
|
+
return expanded;
|
|
274
|
+
}
|
|
275
|
+
|
|
115
276
|
function localSystemDiscriminator(destination) {
|
|
116
277
|
return [destination.systemId, destination.host, destination.instance].map(value => String(value || '').trim()).filter(Boolean).join(' ');
|
|
117
278
|
}
|
|
@@ -216,6 +377,90 @@ function printConnectionInstructions(output, result) {
|
|
|
216
377
|
}
|
|
217
378
|
|
|
218
379
|
|
|
380
|
+
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
381
|
+
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
382
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
|
|
383
|
+
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
384
|
+
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
385
|
+
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
386
|
+
return leftRank - rightRank || String(left.name).localeCompare(String(right.name));
|
|
387
|
+
});
|
|
388
|
+
const choices = orderedDestinations.map(destination => {
|
|
389
|
+
const probe = destination.probe?.status || 'unknown';
|
|
390
|
+
const source = destination.source === 'cloud-foundry'
|
|
391
|
+
? `CF ${destination.cf.destinationInstanceName}`
|
|
392
|
+
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
393
|
+
const disabled = destination.disabledReason;
|
|
394
|
+
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
395
|
+
return {
|
|
396
|
+
value: destination,
|
|
397
|
+
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
398
|
+
...(disabled ? { disabled } : {}),
|
|
399
|
+
checked: false
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
print(output, '');
|
|
403
|
+
print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
|
|
404
|
+
print(output, '');
|
|
405
|
+
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
406
|
+
print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
|
|
407
|
+
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
408
|
+
print(output, '');
|
|
409
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
|
|
410
|
+
value: destination,
|
|
411
|
+
name: `${destination.name} (manual, client ${destination.client})`
|
|
412
|
+
}));
|
|
413
|
+
let selected = await checkboxPrompt({
|
|
414
|
+
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
415
|
+
choices,
|
|
416
|
+
required: false,
|
|
417
|
+
shortcuts: { all: 'a' },
|
|
418
|
+
actions: { m: addManually }
|
|
419
|
+
}, { input, output });
|
|
420
|
+
|
|
421
|
+
if (!isBas && selected.length) {
|
|
422
|
+
print(output, '');
|
|
423
|
+
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
424
|
+
print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
|
|
425
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication (Negotiate/SPNEGO). It uses the current Windows logon session, including smart-card-backed Windows logon; the toolkit cannot prompt for or accept manual bearer/SAML tokens. Username/password remains the default.');
|
|
426
|
+
const selectedNameCounts = selected.reduce((counts, destination) => {
|
|
427
|
+
const key = String(destination.name).toLowerCase();
|
|
428
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
429
|
+
return counts;
|
|
430
|
+
}, new Map());
|
|
431
|
+
const expanded = [];
|
|
432
|
+
for (const destination of selected) {
|
|
433
|
+
// Wizard-configured systems are already complete; only discovered SAP
|
|
434
|
+
// GUI entries need their clients, ADT URL, and login confirmed here.
|
|
435
|
+
if (destination.configuredManually) {
|
|
436
|
+
expanded.push(destination);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
|
|
440
|
+
const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
|
|
441
|
+
const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
|
|
442
|
+
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
443
|
+
const fallback = defaultAdtUrl(destination);
|
|
444
|
+
const url = await textPrompt({
|
|
445
|
+
message: `ADT URL for ${promptLabel}`,
|
|
446
|
+
placeholder: fallback || 'https://host:44300',
|
|
447
|
+
validate: value => validateAdtUrlCandidate(value || fallback),
|
|
448
|
+
required: false
|
|
449
|
+
}, { input, output });
|
|
450
|
+
destination.url = url || fallback;
|
|
451
|
+
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
452
|
+
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
453
|
+
destination.authentication = localAuthenticationLabel(authMode);
|
|
454
|
+
destination.authMode = authMode;
|
|
455
|
+
for (const client of clients) {
|
|
456
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName }));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
selected = expanded;
|
|
460
|
+
}
|
|
461
|
+
return selected;
|
|
462
|
+
}
|
|
463
|
+
|
|
219
464
|
export async function runSetup({
|
|
220
465
|
env = process.env,
|
|
221
466
|
input = stdin,
|
|
@@ -237,6 +482,7 @@ export async function runSetup({
|
|
|
237
482
|
return { skipped: true, reason: 'non-tty' };
|
|
238
483
|
}
|
|
239
484
|
print(output, '');
|
|
485
|
+
print(output, colorText('💡 Tip: you can skip auto-discovery and add a system manually — press m in the destination picker, or take the wizard offer when nothing is found.', 'magenta', output));
|
|
240
486
|
if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
241
487
|
|
|
242
488
|
let destinations = [];
|
|
@@ -349,160 +595,32 @@ export async function runSetup({
|
|
|
349
595
|
return result.warnings || [];
|
|
350
596
|
};
|
|
351
597
|
const selectable = destinations.filter(destination => !destination.disabledReason);
|
|
352
|
-
|
|
353
|
-
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
354
|
-
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
598
|
+
const exitWithoutDestinations = async () => {
|
|
355
599
|
await reportWarnings(warnings);
|
|
356
600
|
const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
|
|
357
601
|
await reportWarnings(cleanupWarnings);
|
|
358
602
|
return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const choices = orderedDestinations.map(destination => {
|
|
367
|
-
const probe = destination.probe?.status || 'unknown';
|
|
368
|
-
const source = destination.source === 'cloud-foundry'
|
|
369
|
-
? `CF ${destination.cf.destinationInstanceName}`
|
|
370
|
-
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
371
|
-
const disabled = destination.disabledReason;
|
|
372
|
-
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
373
|
-
return {
|
|
374
|
-
value: destination,
|
|
375
|
-
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
376
|
-
...(disabled ? { disabled } : {}),
|
|
377
|
-
checked: false
|
|
378
|
-
};
|
|
379
|
-
});
|
|
380
|
-
print(output, '');
|
|
381
|
-
print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
|
|
382
|
-
print(output, '');
|
|
383
|
-
print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
|
|
384
|
-
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
385
|
-
print(output, '');
|
|
386
|
-
let selected = await checkboxPrompt({
|
|
387
|
-
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
388
|
-
choices,
|
|
389
|
-
required: false,
|
|
390
|
-
shortcuts: { all: 'a' }
|
|
391
|
-
}, { input, output });
|
|
392
|
-
|
|
393
|
-
if (!isBas && selected.length) {
|
|
603
|
+
};
|
|
604
|
+
let selected;
|
|
605
|
+
if (!selectable.length) {
|
|
606
|
+
// Nothing was detected — no BAS/CF destinations, or no SAP GUI entries.
|
|
607
|
+
// Offer the manual MCP entry wizard before giving up.
|
|
608
|
+
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
609
|
+
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or configure your system manually below. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
394
610
|
print(output, '');
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
},
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
409
|
-
const fallback = defaultAdtUrl(destination);
|
|
410
|
-
const url = await textPrompt({
|
|
411
|
-
message: `ADT URL for ${promptLabel}`,
|
|
412
|
-
placeholder: fallback || 'https://host:44300',
|
|
413
|
-
validate: value => {
|
|
414
|
-
const candidate = value || fallback;
|
|
415
|
-
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
416
|
-
try {
|
|
417
|
-
const parsed = new URL(candidate);
|
|
418
|
-
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
419
|
-
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
420
|
-
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
421
|
-
return true;
|
|
422
|
-
}
|
|
423
|
-
catch { return 'Enter a valid URL.'; }
|
|
424
|
-
},
|
|
425
|
-
required: false
|
|
426
|
-
}, { input, output });
|
|
427
|
-
destination.url = url || fallback;
|
|
428
|
-
const parsedAdtUrl = new URL(destination.url);
|
|
429
|
-
if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
|
|
430
|
-
print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
|
|
431
|
-
let discoveredNames = [];
|
|
432
|
-
try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
|
|
433
|
-
catch { discoveredNames = []; }
|
|
434
|
-
if (discoveredNames.length) {
|
|
435
|
-
const preview = discoveredNames.slice(0, 5).join(', ');
|
|
436
|
-
const answer = await textPrompt({
|
|
437
|
-
message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
|
|
438
|
-
placeholder: 'y',
|
|
439
|
-
required: false,
|
|
440
|
-
validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
|
|
441
|
-
}, { input, output });
|
|
442
|
-
if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
|
|
443
|
-
destination.tlsServerNames = discoveredNames;
|
|
444
|
-
destination.tlsServerName = discoveredNames[0];
|
|
445
|
-
}
|
|
446
|
-
} else {
|
|
447
|
-
print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
451
|
-
destination.authentication = ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
|
|
452
|
-
destination.authMode = authMode;
|
|
453
|
-
for (const client of clients) {
|
|
454
|
-
const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
|
|
455
|
-
const localDestination = { ...destination, name, serverName: name, client };
|
|
456
|
-
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
457
|
-
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
458
|
-
const credentialInputs = [
|
|
459
|
-
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
460
|
-
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
461
|
-
];
|
|
462
|
-
if (authMode === 'windows-sso') {
|
|
463
|
-
localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
464
|
-
localDestination.inputs = [];
|
|
465
|
-
} else if (authMode === 'windows-credential-ui') {
|
|
466
|
-
localDestination.childEnv = {
|
|
467
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
468
|
-
SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
|
|
469
|
-
};
|
|
470
|
-
localDestination.inputs = [];
|
|
471
|
-
} else if (authMode === 'windows-sso-basic-fallback') {
|
|
472
|
-
localDestination.childEnv = {
|
|
473
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
474
|
-
SAP_AUTH_FALLBACK_MODE: 'basic',
|
|
475
|
-
SAP_USER: `\${input:${userInputId}}`,
|
|
476
|
-
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
477
|
-
};
|
|
478
|
-
localDestination.inputs = credentialInputs;
|
|
479
|
-
} else if (authMode === 'browser-saml') {
|
|
480
|
-
localDestination.childEnv = {
|
|
481
|
-
SAP_AUTH_MODE: 'browser-saml',
|
|
482
|
-
SAP_BROWSER_AUTH: 'true',
|
|
483
|
-
SAP_SAML_AUTH: 'true'
|
|
484
|
-
};
|
|
485
|
-
localDestination.inputs = [];
|
|
486
|
-
} else if (authMode === 'saml-password') {
|
|
487
|
-
localDestination.childEnv = {
|
|
488
|
-
SAP_AUTH_MODE: 'saml-password',
|
|
489
|
-
SAP_SAML_AUTH: 'true',
|
|
490
|
-
SAP_SAML_USER: `\${input:${userInputId}}`,
|
|
491
|
-
SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
|
|
492
|
-
};
|
|
493
|
-
localDestination.inputs = credentialInputs;
|
|
494
|
-
} else {
|
|
495
|
-
localDestination.childEnv = {
|
|
496
|
-
SAP_AUTH_MODE: 'basic',
|
|
497
|
-
SAP_USER: `\${input:${userInputId}}`,
|
|
498
|
-
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
499
|
-
};
|
|
500
|
-
localDestination.inputs = credentialInputs;
|
|
501
|
-
}
|
|
502
|
-
expanded.push(localDestination);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
selected = expanded;
|
|
611
|
+
const choice = await selectPrompt({
|
|
612
|
+
message: colorText('💡 No systems were detected. Configure an SAP system manually?', 'magenta', output),
|
|
613
|
+
defaultValue: 'manual',
|
|
614
|
+
choices: [
|
|
615
|
+
{ name: '✍️ Start manual entry wizard (system name, ADT URL, client, login)', value: 'manual', shortcut: 'm' },
|
|
616
|
+
{ name: 'Exit setup without changes', value: 'exit', shortcut: 'e' }
|
|
617
|
+
]
|
|
618
|
+
}, { input, output });
|
|
619
|
+
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
620
|
+
selected = await runManualEntryWizard({ env, input, output });
|
|
621
|
+
if (!selected.length) return await exitWithoutDestinations();
|
|
622
|
+
} else {
|
|
623
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
|
|
506
624
|
}
|
|
507
625
|
|
|
508
626
|
let sapDevelopmentServers = [];
|
|
@@ -531,6 +649,7 @@ export async function runSetup({
|
|
|
531
649
|
const configuredCount = selected.length + sapDevelopmentServers.length;
|
|
532
650
|
print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
|
|
533
651
|
printConnectionInstructions(output, result);
|
|
652
|
+
warnings.push(...(Array.isArray(result?.warnings) ? result.warnings.filter(message => typeof message === 'string') : []));
|
|
534
653
|
let cleanupWarnings = [];
|
|
535
654
|
const finalPath = result?.path || configPath;
|
|
536
655
|
if (isBas) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -213,8 +213,11 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
|
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
export async function checkboxPrompt({ message, choices, required = false, shortcuts = { all: 'a' }, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
|
-
if (!Array.isArray(
|
|
216
|
+
export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
|
+
if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
|
|
218
|
+
// Action shortcuts (for example 'm' → manual entry wizard) may append new
|
|
219
|
+
// choices while the picker is open, so the list itself stays mutable.
|
|
220
|
+
let choices = initialChoices;
|
|
218
221
|
const selectable = enabledChoices(choices);
|
|
219
222
|
const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
|
|
220
223
|
let cursor = firstEnabledIndex(choices);
|
|
@@ -261,6 +264,42 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
261
264
|
};
|
|
262
265
|
|
|
263
266
|
return new Promise((resolve, reject) => {
|
|
267
|
+
const actionShortcuts = new Map(Object.entries(actions).filter(([shortcut, action]) => shortcut && typeof action === 'function'));
|
|
268
|
+
const actionShortcutFor = (chunk, key = {}) => {
|
|
269
|
+
if (!actionShortcuts.size) return undefined;
|
|
270
|
+
const typed = String(chunk ?? '').toLowerCase();
|
|
271
|
+
if (typed.length === 1 && actionShortcuts.has(typed)) return actionShortcuts.get(typed);
|
|
272
|
+
if (key.name && actionShortcuts.has(key.name)) return actionShortcuts.get(key.name);
|
|
273
|
+
return undefined;
|
|
274
|
+
};
|
|
275
|
+
// Running an action suspends the picker (listener detached, raw mode off),
|
|
276
|
+
// lets the action drive its own prompts on the same streams, and appends
|
|
277
|
+
// the produced items as pre-selected choices before redrawing. An aborted
|
|
278
|
+
// action (Ctrl+C in a sub-prompt) returns to the picker unchanged instead
|
|
279
|
+
// of tearing down the whole selection.
|
|
280
|
+
const runAction = async action => {
|
|
281
|
+
input.off('keypress', onKeypress);
|
|
282
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(false);
|
|
283
|
+
clearRendered();
|
|
284
|
+
let appended = [];
|
|
285
|
+
try { appended = await action() || []; }
|
|
286
|
+
catch { appended = []; }
|
|
287
|
+
const added = (Array.isArray(appended) ? appended : []).filter(item => item && item.value !== undefined);
|
|
288
|
+
if (added.length) {
|
|
289
|
+
for (const item of added) {
|
|
290
|
+
choices.push({ checked: true, ...item });
|
|
291
|
+
selected.add(choices.length - 1);
|
|
292
|
+
}
|
|
293
|
+
cursor = choices.length - added.length;
|
|
294
|
+
}
|
|
295
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(previousRawMode);
|
|
296
|
+
// Sub-prompts pause the stream on cleanup; resume so keypresses reach
|
|
297
|
+
// the re-attached picker listener.
|
|
298
|
+
if (typeof input.resume === 'function') input.resume();
|
|
299
|
+
input.on('keypress', onKeypress);
|
|
300
|
+
render();
|
|
301
|
+
};
|
|
302
|
+
|
|
264
303
|
const cleanup = () => {
|
|
265
304
|
input.off('keypress', onKeypress);
|
|
266
305
|
if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
|
|
@@ -289,9 +328,12 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
289
328
|
if (selected.has(cursor)) selected.delete(cursor);
|
|
290
329
|
else selected.add(cursor);
|
|
291
330
|
} else if (shortcuts?.all && key.name === shortcuts.all) {
|
|
292
|
-
const allSelected =
|
|
331
|
+
const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
|
|
293
332
|
selected.clear();
|
|
294
|
-
if (!allSelected) for (const choice of
|
|
333
|
+
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
334
|
+
} else if (actionShortcutFor(_chunk, key)) {
|
|
335
|
+
void runAction(actionShortcutFor(_chunk, key));
|
|
336
|
+
return;
|
|
295
337
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
296
338
|
const values = [...selected].sort((a, b) => a - b).map(index => choices[index].value);
|
|
297
339
|
const validation = typeof validate === 'function' ? validate(values) : true;
|