sap-ai-dev-toolkit 0.9.1 → 0.9.3

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 CHANGED
@@ -41,6 +41,14 @@ 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
+
46
+ ```sh
47
+ npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
48
+ ```
49
+
50
+ Prefer a persistent global install instead?
51
+
44
52
  ```sh
45
53
  npm install --global sap-ai-dev-toolkit
46
54
  sap-ai-dev --setup
@@ -242,17 +250,33 @@ Once the destination server is enabled in your chat's tools picker, ask for outc
242
250
 
243
251
  ## 📦 Installation
244
252
 
245
- Install globally from SAP Business Application Studio or from a local workstation with SAP GUI installed, then run setup explicitly for your environment:
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:
246
260
 
247
261
  | Environment / device | What to run | What setup does |
248
262
  | --- | --- | --- |
249
- | **SAP Business Application Studio dev space** | `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. |
250
- | **Windows workstation with SAP GUI** | `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. |
251
- | **macOS workstation with SAP GUI** | `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. |
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. |
252
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. |
253
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. |
254
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. |
255
- | **No global install / temporary setup** | `npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx` | Runs the guided setup through npm and writes generated entries that launch the pinned package through `npx`. |
270
+
271
+ ### 🪟 Windows notes
272
+
273
+ Everything works natively on Windows — PowerShell, cmd, and Windows Terminal are all supported:
274
+
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.
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.
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.
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.
256
280
 
257
281
  If you previously installed the old package, remove it first because the legacy `bas-vsp-mcp` command is no longer shipped:
258
282
 
@@ -312,9 +336,9 @@ The same postinstall offers the bundled agents and skills for several AI coding
312
336
 
313
337
  Some current npm versions also require install hooks to be approved. If npm reports that `sap-ai-dev-toolkit`'s `postinstall` was blocked, allow it during a fresh install with `npm install --global --allow-scripts=sap-ai-dev-toolkit sap-ai-dev-toolkit`, or run `sap-ai-dev --setup` from an interactive terminal after installation.
314
338
 
315
- The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
339
+ The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. Long-running steps (BAS destination discovery, ADT probes, VSP runtime provisioning, `--doctor` checks) show an animated progress line so the terminal never looks stuck; it appears only on interactive terminals and is disabled automatically on CI — set `SAP_AI_DEV_TOOLKIT_DISABLE_SCAN_ANIMATION=true` to turn it off. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
316
340
 
317
- Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Use `--npx` to make generated destination entries start the pinned package through npm instead of relying on a global `sap-ai-dev` command; companion tools already use `npx` with their own npm packages.
341
+ Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Generated destination entries use the global `sap-ai-dev` command when it is on `PATH`; otherwise (for example after a pure npx setup) they automatically launch the pinned package version through `npx`, routed through `cmd /c npx` on Windows so every MCP host can spawn them. Pass `--npx` to force the npx launcher even when a global command exists; companion tools already use `npx` with their own npm packages.
318
342
 
319
343
  If a setup step is skipped or fails, rerun it from an interactive terminal:
320
344
 
@@ -325,10 +349,10 @@ sap-ai-dev --setup
325
349
  To configure without a global install, run the guided setup directly through npm:
326
350
 
327
351
  ```sh
328
- npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx
352
+ npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
329
353
  ```
330
354
 
331
- This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). It writes MCP entries that launch the selected servers through npx. The entries pin the package version used during setup, and `--ignore-scripts` avoids running the install-time wizard a second time. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
355
+ This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). 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**.
332
356
 
333
357
  For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
334
358
 
@@ -423,7 +447,7 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
423
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.
424
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.
425
449
 
426
- 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, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). 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; add `--npx` 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, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). 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.
427
451
 
428
452
  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.
429
453
 
@@ -833,6 +857,8 @@ Runtime diagnostics take the stderr lane. In the MCP server's Output view, check
833
857
 
834
858
  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.
835
859
 
860
+ **`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`.
861
+
836
862
  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`.
837
863
 
838
864
  ## 🧑‍💻 Development
@@ -857,7 +883,7 @@ NPM_PUBLISH_TOKEN=npm_...
857
883
  - 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`.
858
884
  - 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.
859
885
  - Preview the package without publishing, changing its version, or building with `npm run publish:npm -- --dry-run`.
860
- - 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, and publishes that version to npm.
886
+ - 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.
861
887
  - 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.
862
888
 
863
889
  The token is not printed or stored in the repository.
@@ -1,5 +1,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
1
+ 85333f1ac7cbc25bc10a938cea5712b43d1c7b241911f19c6794f897dbcb589c vsp-linux-x64
2
+ 68cd2b79e7cda145135f9f516b6ba76f33839bc9226bbf58ea015ff2442b4b2d vsp-linux-arm64
3
+ 62fd8cd8eba8d61e36813c098938a2479af68047fd2fa1c85782edb4d4b5261b vsp-darwin-x64
4
+ 71eb9e64e292e3ac83a2ab6a630367cfb0d79fe465d62ad1cfb05ef8c6c2b6e7 vsp-darwin-arm64
5
+ 952a6d5a90265fb6a6b3494d01a5d143fd7c471f7ce560bed200c399522b180f vsp-win32-x64.exe
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "bin": {
19
19
  "sap-ai-dev": "src/launcher.mjs",
20
+ "sap-ai-dev-toolkit": "src/launcher.mjs",
20
21
  "sap-ai-hana": "src/hana-inspector.mjs"
21
22
  },
22
23
  "scripts": {
@@ -9,7 +9,7 @@ import { installMcpServersForHarnesses } from '../src/harness-mcp-config.mjs';
9
9
  import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from '../src/harnesses.mjs';
10
10
  import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
11
11
  import { homedir } from 'node:os';
12
- import { checkboxPrompt, colorText, formatStatus } from '../src/terminal-ui.mjs';
12
+ import { checkboxPrompt, colorText, formatStatus, startProgress } from '../src/terminal-ui.mjs';
13
13
  import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
14
14
  import { generatedServerName } from '../src/mcp-config.mjs';
15
15
  import { redactText } from '../src/redact.mjs';
@@ -232,7 +232,7 @@ async function announceSetup(result) {
232
232
  return;
233
233
  }
234
234
  if (result?.reason === 'non-tty') {
235
- await announce('Destination selection was skipped because npm did not provide an interactive terminal.\nMCP config was not changed.\nRun sap-ai-dev --setup from an interactive BAS terminal, or run npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx.', 'warning');
235
+ await announce('Destination selection was skipped because npm did not provide an interactive terminal.\nMCP config was not changed.\nRun sap-ai-dev --setup from an interactive terminal, or run npx --yes --ignore-scripts sap-ai-dev-toolkit --setup from any terminal.', 'warning');
236
236
  return;
237
237
  }
238
238
  if (result?.reason === 'no-destinations') {
@@ -304,11 +304,15 @@ async function main() {
304
304
  if (brandedEnvValue(runtimeEnv, 'BINARY')) {
305
305
  await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
306
306
  } else {
307
- await announce('Preparing the pinned VSP runtime for this platform.', 'progress');
307
+ // The bundled binary resolves in milliseconds; the animation only
308
+ // appears when provisioning actually takes time (download fallback).
309
+ const stopBinaryProgress = startProgress('Preparing the pinned VSP runtime for this platform', { output: process.stderr, env: runtimeEnv });
308
310
  try {
309
311
  await installBinary(pkg, { env: runtimeEnv });
312
+ stopBinaryProgress();
310
313
  await announce('Pinned VSP binary installed and ready.', 'success');
311
314
  } catch (error) {
315
+ stopBinaryProgress();
312
316
  throw new Error(`VSP binary provisioning failed: ${error.message}`);
313
317
  }
314
318
  }
@@ -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, 'utf8');
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
- const parsed = JSON.parse(raw);
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/launcher.mjs CHANGED
@@ -7,13 +7,14 @@ import { discoverDestinations, remediation, slugifyDestination, statusRows } fro
7
7
  import { discoverSapGuiSystems } from './local-sap-gui.mjs';
8
8
  import { findBinary } from './binary.mjs';
9
9
  import { MCPProxy } from './mcp-proxy.mjs';
10
- import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
+ import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
11
11
  import { runSetup } from './setup.mjs';
12
12
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
13
13
  import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
14
14
  import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
15
15
  import { withBrandedEnvironment } from './branding.mjs';
16
16
  import { redactText } from './redact.mjs';
17
+ import { startProgress } from './terminal-ui.mjs';
17
18
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
18
19
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
19
20
  const runtimeEnv = withBrandedEnvironment(process.env);
@@ -53,7 +54,9 @@ function doctorRow(name, stage, status, detail) {
53
54
 
54
55
  async function runDoctor(destinations) {
55
56
  const checks = [];
57
+ const stopProgress = startProgress(destinations.length === 1 ? `Checking ${destinations[0]?.name || 'destination'}` : `Checking ${destinations.length} destinations`, { output: process.stderr, label: 'Doctor' });
56
58
  if (!destinations.length) {
59
+ stopProgress();
57
60
  return { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', 'No destinations were found. Check H2O_URL or the configured Cloud Foundry destination.')] };
58
61
  }
59
62
  let binary;
@@ -76,6 +79,7 @@ async function runDoctor(destinations) {
76
79
  checks.push(doctorRow('-', 'MCP config repair', 'failed', redactText(error.message || error).slice(0, 300)));
77
80
  }
78
81
  for (const destination of destinations) {
82
+ stopProgress.update(`Checking ${destination.name}: ADT probe, VSP startup, tools/list`);
79
83
  const probe = destination.probe;
80
84
  const probeStatus = !probe || probe.status === 'skipped' ? 'skipped' : (probe.available === true ? 'passed' : 'failed');
81
85
  checks.push(doctorRow(destination.name, 'ADT probe', probeStatus, probe?.status || 'No BAS ADT probe was supplied; GetSystemInfo will check the route.'));
@@ -126,6 +130,7 @@ async function runDoctor(destinations) {
126
130
  }
127
131
  }
128
132
  const required = checks.filter(check => check.status !== 'skipped');
133
+ stopProgress();
129
134
  return { ok: required.every(check => check.status === 'passed'), destinations: destinations.length, checks };
130
135
  }
131
136
 
@@ -253,11 +258,17 @@ async function main() {
253
258
  if (doctor) {
254
259
  let report;
255
260
  try {
256
- const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
257
- ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
258
- : (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
259
- ? [await configuredLocalSapGuiDestination(runtimeEnv)]
260
- : await discoverForCommand());
261
+ const stopDiscoveryProgress = startProgress('Discovering SAP destinations', { output: process.stderr, env: runtimeEnv, label: 'Doctor' });
262
+ let destinations;
263
+ try {
264
+ destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
265
+ ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
266
+ : (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
267
+ ? [await configuredLocalSapGuiDestination(runtimeEnv)]
268
+ : await discoverForCommand());
269
+ } finally {
270
+ stopDiscoveryProgress();
271
+ }
261
272
  report = await runDoctor(destinations);
262
273
  } catch (error) {
263
274
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -272,17 +283,26 @@ async function main() {
272
283
  includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
273
284
  };
274
285
  if (hasFlag('--npx')) {
286
+ // Windows has no npx.exe, so generated entries there launch through
287
+ // `cmd /c npx`; other platforms use npx directly.
288
+ const launcher = npxMcpLauncher();
275
289
  setupOptions.install = (selected, options) => installMcpConfig(selected, {
276
290
  ...options,
277
- command: 'npx',
278
- args: ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
291
+ command: launcher.command,
292
+ args: [...launcher.prefixArgs, '--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
279
293
  });
280
294
  }
281
295
  await runSetup(setupOptions);
282
296
  return;
283
297
  }
284
298
  if (check || list) {
285
- const destinations = await discoverForCommand();
299
+ const stopProgress = startProgress('Discovering SAP destinations and probing ADT endpoints', { output: process.stderr, env: runtimeEnv });
300
+ let destinations;
301
+ try {
302
+ destinations = await discoverForCommand();
303
+ } finally {
304
+ stopProgress();
305
+ }
286
306
  const statuses = statusRows(destinations);
287
307
  if (list && hasFlag('--json')) json(statuses);
288
308
  else for (const row of statuses) console.error(`${row.name}: client=${row.client} authentication=${row.authentication} probe=${row.probe}`);
@@ -319,11 +339,15 @@ async function main() {
319
339
  return;
320
340
  }
321
341
  const binary = await binaryOrError();
322
- // Windows cannot exec a .js/.mjs file directly; route JavaScript entries
342
+ // Windows cannot exec .js/.mjs files directly; route JavaScript entries
323
343
  // (test fixtures) through the current Node binary like MCPProxy does.
344
+ // Windows .cmd/.bat shims (e.g. a SAP_AI_DEV_TOOLKIT_BINARY override)
345
+ // must go through cmd /c; Node refuses to spawn batch files directly.
324
346
  const spawnTarget = /\.(?:mjs|cjs|js)$/i.test(binary)
325
347
  ? { command: process.execPath, prefixArgs: [binary] }
326
- : { command: binary, prefixArgs: [] };
348
+ : (/\.(?:cmd|bat)$/i.test(binary)
349
+ ? { command: 'cmd', prefixArgs: ['/c', binary] }
350
+ : { command: binary, prefixArgs: [] });
327
351
  const child = spawn(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], { env: runtimeEnv, stdio: 'inherit' });
328
352
  process.once('SIGINT', () => child.kill('SIGINT'));
329
353
  process.once('SIGTERM', () => child.kill('SIGTERM'));
@@ -336,7 +360,13 @@ async function main() {
336
360
  return;
337
361
  }
338
362
 
339
- const discovered = await discoverDestinations({ env: runtimeEnv });
363
+ const stopProgress = startProgress('Discovering BAS destinations and probing ADT endpoints', { output: process.stderr, env: runtimeEnv });
364
+ let discovered;
365
+ try {
366
+ discovered = await discoverDestinations({ env: runtimeEnv });
367
+ } finally {
368
+ stopProgress();
369
+ }
340
370
  for (const destination of discovered) logLine(probeDiagnostic(destination));
341
371
  const destinations = discovered;
342
372
  if (!destinations.length) {
@@ -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']
@@ -98,6 +119,44 @@ export async function resolveMcpServerCommand(env = process.env) {
98
119
  return 'sap-ai-dev';
99
120
  }
100
121
 
122
+ // Windows ships npx as npx.cmd, not npx.exe: MCP hosts spawn the configured
123
+ // command directly without shell PATH resolution, so npx-launched servers
124
+ // must route through `cmd /c npx` there (the form VS Code, Claude Code, and
125
+ // Cursor document for Windows). Other platforms run npx directly.
126
+ export function npxMcpLauncher(platform = process.platform) {
127
+ return platform === 'win32'
128
+ ? { command: 'cmd', prefixArgs: ['/c', 'npx'] }
129
+ : { command: 'npx', prefixArgs: [] };
130
+ }
131
+
132
+ // Normalizes the argument list of npx-launched entries, whether written as
133
+ // `npx ...` or the Windows `cmd /c npx ...` form; returns null when the entry
134
+ // is not npx-launched.
135
+ function npxLaunchArgs(entry) {
136
+ const args = Array.isArray(entry?.args) ? entry.args.map(String) : [];
137
+ if (entry?.command === 'npx') return args;
138
+ const commandName = typeof entry?.command === 'string'
139
+ ? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1)
140
+ : '';
141
+ if (/^cmd(?:\.exe)?$/i.test(commandName) && args[0]?.toLowerCase() === '/c' && /^npx(?:\.cmd)?$/i.test(args[1] || '')) {
142
+ return args.slice(2);
143
+ }
144
+ return null;
145
+ }
146
+
147
+ // Resolves how generated destination entries launch the toolkit: the global
148
+ // sap-ai-dev command when it is on PATH, otherwise the pinned package
149
+ // through npx (routed through cmd /c on Windows) so entries written by a
150
+ // pure `npx sap-ai-dev-toolkit --setup` run still start on every host.
151
+ async function resolveLauncherInstallCommand(env) {
152
+ const resolved = await resolveMcpServerCommand(env);
153
+ if (resolved !== 'sap-ai-dev') return { command: resolved, args: null };
154
+ const { command, prefixArgs } = npxMcpLauncher();
155
+ const selfVersion = await runningPackageVersion();
156
+ const packageSpec = selfVersion ? `sap-ai-dev-toolkit@${selfVersion}` : 'sap-ai-dev-toolkit';
157
+ return { command, args: [...prefixArgs, '--yes', '--ignore-scripts', `--package=${packageSpec}`, 'sap-ai-dev'] };
158
+ }
159
+
101
160
  export async function resolveMcpConfigPath(env = process.env) {
102
161
  const configuredPath = [env.SAP_AI_DEV_MCP_CONFIG, brandedEnvValue(env, 'MCP_CONFIG')]
103
162
  .find(value => typeof value === 'string' && value.length > 0);
@@ -138,16 +197,17 @@ export function generatedDestinationServerName(destination) {
138
197
  return generatedServerName(destination?.source === 'cloud-foundry' ? destination.serverName : destination?.name);
139
198
  }
140
199
 
141
- export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
200
+ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx', platform = process.platform } = {}) {
142
201
  const selected = new Set(serverIds);
143
202
  const entries = Object.create(null);
203
+ const launcher = packageManager === 'npx' ? npxMcpLauncher(platform) : { command: packageManager, prefixArgs: [] };
144
204
  for (const server of SAP_DEVELOPMENT_MCP_SERVERS) {
145
205
  if (!selected.has(server.id)) continue;
146
206
  const packageSpec = packageVersions[server.id] || packageVersions[server.packageName] || server.packageName;
147
207
  entries[server.id] = {
148
208
  type: 'stdio',
149
- command: packageManager,
150
- args: ['--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
209
+ command: launcher.command,
210
+ args: [...launcher.prefixArgs, '--yes', ...(server.ignoreScripts ? ['--ignore-scripts'] : []), `--package=${packageSpec}`, server.bin],
151
211
  BAS_EXT: 'true',
152
212
  BAS_EXT_KIND: COMPANION_SERVER_KIND,
153
213
  displayName: server.name,
@@ -232,16 +292,14 @@ function isPackageLauncher(entry) {
232
292
  const legacyCommand = legacyCommands.has(entry?.command) || legacyCommands.has(commandName);
233
293
  const currentPackages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/];
234
294
  const legacyPackages = [/^--package=bas-mcp-addon(?:@[^/]+)?$/];
235
- const hasPackage = patterns => Array.isArray(entry?.args)
236
- && entry.args.some(argument => typeof argument === 'string' && patterns.some(pattern => pattern.test(argument)));
237
- const currentNpxLauncher = entry?.command === 'npx'
238
- && Array.isArray(entry.args)
239
- && entry.args.some(argument => currentCommands.has(argument))
240
- && hasPackage(currentPackages);
241
- const legacyNpxLauncher = entry?.command === 'npx'
242
- && Array.isArray(entry.args)
243
- && entry.args.some(argument => legacyCommands.has(argument))
244
- && hasPackage(legacyPackages);
295
+ const hasPackage = (arguments_, patterns) => arguments_.some(argument => patterns.some(pattern => pattern.test(argument)));
296
+ const npxArgs = npxLaunchArgs(entry);
297
+ const currentNpxLauncher = npxArgs !== null
298
+ && npxArgs.some(argument => currentCommands.has(argument))
299
+ && hasPackage(npxArgs, currentPackages);
300
+ const legacyNpxLauncher = npxArgs !== null
301
+ && npxArgs.some(argument => legacyCommands.has(argument))
302
+ && hasPackage(npxArgs, legacyPackages);
245
303
  // Current launchers are treated as managed only when tagged by this add-on.
246
304
  // Some early bas-mcp-addon entries were not tagged with BAS_EXT, though, and
247
305
  // leaving them behind keeps advertising the old full VSP surface (~159 tools).
@@ -264,10 +322,10 @@ function isManagedBasDestinationEntry(entry) {
264
322
  ? entry.command.slice(Math.max(entry.command.lastIndexOf('/'), entry.command.lastIndexOf('\\')) + 1).replace(/\.(?:cmd|exe)$/i, '')
265
323
  : '';
266
324
  const currentCommand = currentCommands.has(entry?.command) || currentCommands.has(commandName);
267
- const currentNpxLauncher = entry?.command === 'npx'
268
- && Array.isArray(entry.args)
269
- && entry.args.some(argument => currentCommands.has(argument))
270
- && entry.args.some(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument));
325
+ const npxArgs = npxLaunchArgs(entry);
326
+ const currentNpxLauncher = npxArgs !== null
327
+ && npxArgs.some(argument => currentCommands.has(argument))
328
+ && npxArgs.some(argument => /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument));
271
329
  return entry?.BAS_EXT === 'true'
272
330
  && (currentCommand || currentNpxLauncher)
273
331
  && typeof destinationValue(entry?.env) === 'string'
@@ -299,36 +357,186 @@ export function collectCloudFoundryKeyReferencesFromAllEntries(config) {
299
357
  return collectCloudFoundryKeyReferences(config, false);
300
358
  }
301
359
 
302
- async function readConfig(path) {
303
- let raw;
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;
304
458
  try {
305
- raw = await readFile(path, 'utf8');
459
+ buffer = await readFileImpl(path);
306
460
  } catch (error) {
307
- if (error?.code === 'ENOENT') return { servers: {}, inputs: [] };
461
+ if (error?.code === 'ENOENT') return freshConfig();
308
462
  throw new Error(`MCP config ${path} could not be read: ${error.message}`);
309
463
  }
310
- let config;
311
- try {
312
- config = JSON.parse(raw);
313
- } catch (error) {
314
- throw new Error(`MCP config ${path} contains invalid JSON: ${error.message}`);
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
+ }
315
482
  }
316
- if (!config || typeof config !== 'object' || Array.isArray(config)) {
317
- throw new Error(`MCP config ${path} must contain a top-level JSON object`);
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
+ }
318
490
  }
319
- if (config.servers !== undefined && (!config.servers || typeof config.servers !== 'object' || Array.isArray(config.servers))) {
320
- throw new Error(`MCP config ${path} has a non-object servers value`);
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
+ });
500
+ }
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'); }
321
512
  }
322
- return { ...config, servers: config.servers || {} };
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;
323
528
  }
324
529
 
325
530
  async function writeConfig(path, config) {
326
- const directory = dirname(path);
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);
327
535
  await mkdir(directory, { recursive: true });
328
- const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
536
+ const temporary = join(directory, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
329
537
  try {
330
538
  await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
331
- await rename(temporary, path);
539
+ await rename(temporary, target);
332
540
  } catch (error) {
333
541
  await rm(temporary, { force: true }).catch(() => {});
334
542
  throw new Error(`MCP config ${path} could not be written: ${error.message}`);
@@ -343,7 +551,11 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
343
551
  }
344
552
  const env = options.env || process.env;
345
553
  const path = options.path || await resolveMcpConfigPath(env);
346
- const config = await readConfig(path);
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 });
347
559
  const selfVersion = await runningPackageVersion();
348
560
  const companionVersions = selfVersion ? { 'sap-ai-dev-toolkit': selfVersion, 'hana-cloud-inspector': selfVersion } : {};
349
561
  const generated = {
@@ -356,12 +568,18 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
356
568
  if (input?.id) generatedInputs.set(input.id, input);
357
569
  }
358
570
  }
359
- const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
571
+ let launcherCommand = options.command || null;
572
+ let launcherArgs = options.command ? options.args : null;
573
+ if (!launcherCommand && destinations.length) {
574
+ const resolved = await resolveLauncherInstallCommand(env);
575
+ launcherCommand = resolved.command;
576
+ launcherArgs = resolved.args;
577
+ }
360
578
  if (launcherCommand) {
361
579
  for (const entry of Object.values(generated)) {
362
580
  if (isCompanionServer(entry)) continue;
363
581
  entry.command = launcherCommand;
364
- if (options.command && options.args?.length) entry.args = [...options.args];
582
+ if (launcherArgs?.length) entry.args = [...launcherArgs];
365
583
  }
366
584
  }
367
585
  const servers = Object.create(null);
@@ -391,7 +609,7 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
391
609
  config.inputs = [...existingInputs, ...generatedInputs.values()];
392
610
  }
393
611
  await writeConfig(path, config);
394
- return { path, servers: generated };
612
+ return { path, servers: generated, warnings: installWarnings };
395
613
  }
396
614
 
397
615
  export async function repairManagedMcpConfig(discoveredDestinations, { env = process.env, path, discoveryComplete = false, packageVersion } = {}) {
@@ -406,7 +624,7 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
406
624
  const config = await readConfig(configPath);
407
625
  const managedEntries = Object.values(config.servers).filter(isManagedBasDestinationEntry).length;
408
626
  if (!basDestinations.size) return { path: configPath, changed: false, repaired: 0, managedEntries };
409
- const localCommand = await resolveMcpServerCommand(env);
627
+ const localLauncher = await resolveLauncherInstallCommand(env);
410
628
  let repaired = 0;
411
629
  for (const [serverName, entry] of Object.entries(config.servers)) {
412
630
  if (!isManagedBasDestinationEntry(entry)) continue;
@@ -430,14 +648,16 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
430
648
  delete nextEnvironment.BAS_VSP_DESTINATION;
431
649
  delete nextEnvironment.BAS_VSP_DESTINATION_SOURCE;
432
650
  const nextEntry = { ...entry, type: 'stdio', env: nextEnvironment, BAS_EXT: 'true' };
433
- if (entry.command === 'npx') {
651
+ if (npxLaunchArgs(entry) !== null) {
434
652
  if (packageVersion && Array.isArray(entry.args)) {
435
653
  nextEntry.args = entry.args.map(argument => typeof argument === 'string' && /^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/.test(argument)
436
654
  ? `--package=sap-ai-dev-toolkit@${packageVersion}`
437
655
  : argument);
438
656
  }
439
657
  } else {
440
- nextEntry.command = localCommand;
658
+ nextEntry.command = localLauncher.command;
659
+ if (localLauncher.args) nextEntry.args = [...localLauncher.args];
660
+ else delete nextEntry.args;
441
661
  }
442
662
  if (expectedName === serverName) {
443
663
  if (JSON.stringify(nextEntry) === JSON.stringify(entry)) continue;
@@ -454,6 +674,6 @@ export async function repairManagedMcpConfig(discoveredDestinations, { env = pro
454
674
  return { path: configPath, changed: true, repaired, managedEntries };
455
675
  }
456
676
 
457
- export async function readMcpConfig(path) {
458
- return readConfig(path);
677
+ export async function readMcpConfig(path, options = {}) {
678
+ return readConfig(path, options);
459
679
  }
package/src/mcp-proxy.mjs CHANGED
@@ -200,9 +200,12 @@ export function childArguments(destination, env = process.env) {
200
200
 
201
201
  // Windows cannot exec a .js/.mjs file directly (no shebang support), so test
202
202
  // fixtures written in JavaScript are launched through the current Node binary.
203
- // The production VSP binary is a native executable and is unaffected.
203
+ // The production VSP binary is a native executable and is unaffected; a
204
+ // Windows .cmd/.bat override needs cmd /c because Node refuses to spawn
205
+ // batch files directly (EINVAL since the CVE-2024-27980 hardening).
204
206
  function spawnCommand(binary) {
205
207
  if (/\.(?:mjs|cjs|js)$/i.test(binary)) return [process.execPath, binary];
208
+ if (/\.(?:cmd|bat)$/i.test(binary) && process.platform === 'win32') return ['cmd', '/c', binary];
206
209
  return [binary];
207
210
  }
208
211
 
package/src/setup.mjs CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
3
  import { platform } from 'node:os';
4
4
  import { isIP } from 'node:net';
5
- import { checkboxPrompt, colorText, formatStatus, selectPrompt, textPrompt } from './terminal-ui.mjs';
5
+ import { checkboxPrompt, colorText, formatStatus, selectPrompt, startProgress, textPrompt } from './terminal-ui.mjs';
6
6
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
7
7
  import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
8
8
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
@@ -237,15 +237,21 @@ export async function runSetup({
237
237
  return { skipped: true, reason: 'non-tty' };
238
238
  }
239
239
  print(output, '');
240
- print(output, formatStatus(isBas ? 'Contacting BAS to discover destinations; this may take a moment.' : 'Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
240
+ if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
241
241
 
242
242
  let destinations = [];
243
243
  if (isBas) {
244
244
  let basDestinations;
245
+ // BAS discovery probes every destination's ADT endpoint (up to a 5s
246
+ // timeout each); the animated progress line keeps the terminal visibly
247
+ // alive from the first moment instead of looking stuck.
248
+ const stopDiscoveryProgress = startProgress('Contacting BAS to discover destinations', { output, env, label: 'Setup' });
245
249
  try {
246
250
  basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
247
251
  } catch (error) {
248
252
  throw new Error(`BAS discovery failed: ${error.message}`);
253
+ } finally {
254
+ stopDiscoveryProgress();
249
255
  }
250
256
  destinations = basDestinations.map(safeBasDestination);
251
257
  } else {
@@ -525,6 +531,7 @@ export async function runSetup({
525
531
  const configuredCount = selected.length + sapDevelopmentServers.length;
526
532
  print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
527
533
  printConnectionInstructions(output, result);
534
+ warnings.push(...(Array.isArray(result?.warnings) ? result.warnings.filter(message => typeof message === 'string') : []));
528
535
  let cleanupWarnings = [];
529
536
  const finalPath = result?.path || configPath;
530
537
  if (isBas) {
@@ -401,3 +401,43 @@ export async function textPrompt({ message, secret = false, required = true, pla
401
401
  }
402
402
  });
403
403
  }
404
+
405
+ // Animated progress line for long-running steps (destination discovery,
406
+ // ADT probes, VSP binary provisioning). TTY outputs get spinner frames on an
407
+ // unref'd timer that starts after a short delay, so steps that finish quickly
408
+ // never flash anything. Non-TTY outputs (pipes, MCP hosts, --json callers,
409
+ // CI) stay completely silent so machine consumers never see progress noise.
410
+ // Returns stop(); on a TTY, stop() clears the line so it never pollutes
411
+ // scrollback. stop.update(message) retargets the label while running (for
412
+ // example per destination during --doctor).
413
+ export function startProgress(initialMessage, { output = process.stdout, env = process.env, label = 'sap-ai-dev-toolkit' } = {}) {
414
+ const noop = Object.assign(() => {}, { update: () => {} });
415
+ if (!output || typeof output.write !== 'function') return noop;
416
+ const animationDisabled = env.TERM === 'dumb' || env.CI || env.SAP_AI_DEV_TOOLKIT_DISABLE_SCAN_ANIMATION === 'true';
417
+ if (!output.isTTY || !Number.isFinite(output.columns) || animationDisabled) return noop;
418
+ let message = String(initialMessage);
419
+ let index = 0;
420
+ let active = false;
421
+ let interval;
422
+ const render = () => {
423
+ active = true;
424
+ index += 1;
425
+ const columns = Math.max(20, output.columns || 80);
426
+ const dots = `.${'.'.repeat(index % 3)}${' '.repeat(2 - index % 3)}`;
427
+ const line = `${label}: ${message} ${dots}`.slice(0, Math.max(1, columns - 1));
428
+ output.write(`\r\u001b[2K${line}`);
429
+ };
430
+ const delay = setTimeout(() => {
431
+ render();
432
+ interval = setInterval(render, 120);
433
+ if (typeof interval.unref === 'function') interval.unref();
434
+ }, 150);
435
+ if (typeof delay.unref === 'function') delay.unref();
436
+ const stop = () => {
437
+ clearTimeout(delay);
438
+ if (interval) clearInterval(interval);
439
+ if (active) output.write('\r\u001b[2K');
440
+ };
441
+ stop.update = next => { message = String(next); };
442
+ return stop;
443
+ }