sap-ai-dev-toolkit 0.9.9904 → 0.9.9908
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 +12 -6
- package/dist/checksums.txt +5 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +2 -2
- package/patches/vsp-bas-proxy-auth.patch +162 -5
- package/scripts/postinstall.mjs +5 -193
- package/src/abaplint.mjs +229 -24
- package/src/harness-mcp-config.mjs +9 -1
- package/src/harness-setup.mjs +202 -0
- package/src/harnesses.mjs +21 -4
- package/src/launcher.mjs +6 -3
- package/src/mcp-proxy.mjs +7 -1
- package/src/setup.mjs +54 -10
- package/src/sso-browsers.mjs +154 -4
- package/tools.md +3 -2
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<img src="https://img.shields.io/badge/SAP-Business%20Application%20Studio-0A6ED1?style=flat-square&logo=sap&logoColor=white" alt="SAP Business Application Studio">
|
|
18
|
-
<img src="https://img.shields.io/badge/AI%20agents-Copilot%20%7C%20Claude%20%7C%20Codex%20%7C%20Cursor%20%7C%20Gemini%20%7C%20opencode%20%7C%20Pi-000000?style=flat-square" alt="Works with GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Gemini CLI, opencode,
|
|
18
|
+
<img src="https://img.shields.io/badge/AI%20agents-Copilot%20%7C%20Claude%20%7C%20Codex%20%7C%20Cursor%20%7C%20Gemini%20%7C%20opencode%20%7C%20Pi%20%7C%20OMP%20%7C%20Cline%20%7C%20Roo%20%7C%20Windsurf-000000?style=flat-square" alt="Works with GitHub Copilot, Claude Code, OpenAI Codex, Cursor, Gemini CLI, opencode, Pi Coding Agent, Oh My Pi, Cline, Roo Code, and Windsurf">
|
|
19
19
|
<img src="https://img.shields.io/badge/MCP-enabled-7B61FF?style=flat-square" alt="MCP enabled">
|
|
20
20
|
<img src="https://img.shields.io/badge/SAP%20ADT-connected-0A6ED1?style=flat-square&logo=sap&logoColor=white" alt="SAP ADT connected">
|
|
21
21
|
<img src="https://img.shields.io/badge/ABAP-CDS%20%7C%20RAP-EA4AAA?style=flat-square" alt="ABAP CDS RAP">
|
|
@@ -41,12 +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
|
-
|
|
44
|
+
Run the setup wizard without a global install:
|
|
45
45
|
|
|
46
46
|
```sh
|
|
47
47
|
npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
Interactive setup configures the selected destinations, then asks which AI harnesses should receive the bundled agents and skills. This prompt is part of `--setup`, so it still appears when `--ignore-scripts` skips npm lifecycle scripts. Without an interactive terminal, assets install for GitHub Copilot by default.
|
|
51
|
+
|
|
50
52
|
Prefer a persistent global install instead?
|
|
51
53
|
|
|
52
54
|
```sh
|
|
@@ -87,7 +89,7 @@ Use the attached destination-prefixed tools directly in chat. Do not launch `sap
|
|
|
87
89
|
|
|
88
90
|
**Included skills:** `abap-development` · `abap-testing-quality` · `cds-development` · `rap-development` · `abap-debugging` · `abap-runtime-analysis` · `rap-service-delivery` · `sap-standard-api-analysis` · `clean-core-extensibility` · `sap-sdlc-orchestration` · `sap-transport-release` · `hana-cloud-inspection` · `hana-cloud-native-development` · `hana-cloud-validation`
|
|
89
91
|
|
|
90
|
-
Agents and skills install for the harnesses you pick during global installation: a multi-select offers GitHub Copilot and Claude Code pre-checked, plus OpenAI Codex, Cursor, Gemini CLI, opencode,
|
|
92
|
+
Agents and skills install for the harnesses you pick during global installation: a multi-select offers GitHub Copilot and Claude Code pre-checked, plus OpenAI Codex, Cursor, Gemini CLI, opencode, Pi Coding Agent, Oh My Pi (OMP), Cline, Roo Code, and Windsurf / Devin Desktop. In non-interactive installs they are installed for GitHub Copilot only; run `sap-ai-dev --setup` afterwards to configure destinations or local SAP GUI systems. Repository-scoped installation instructions appear below.
|
|
91
93
|
|
|
92
94
|
## ✨ Your SAP development cockpit, inside your AI assistant
|
|
93
95
|
|
|
@@ -332,7 +334,7 @@ sap-ai-dev --setup
|
|
|
332
334
|
5. Choose Windows / smart card SSO or username/password when the prompt is available.
|
|
333
335
|
6. Start each generated MCP server from VS Code's **MCP: List Servers** command.
|
|
334
336
|
|
|
335
|
-
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all
|
|
337
|
+
The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all eleven, and confirming with none selected skips the step without changing files. When MCP setup succeeds, generated managed MCP servers are also mirrored into selected harnesses with documented user-level JSON configuration (Claude Code, Cursor, Gemini CLI, Pi Coding Agent, OMP, and Windsurf / Devin Desktop). GitHub Copilot continues to use the VS Code/BAS MCP configuration written during setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`, `omp`, `cline`, `roo-code`, `windsurf`) to choose non-interactively.
|
|
336
338
|
|
|
337
339
|
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.
|
|
338
340
|
|
|
@@ -405,7 +407,7 @@ The package includes five user-invocable custom agents (**SAP Solution Architect
|
|
|
405
407
|
|
|
406
408
|
### 📥 Install agents and skills for your user
|
|
407
409
|
|
|
408
|
-
During npm postinstall, after
|
|
410
|
+
During npm postinstall, or after interactive `sap-ai-dev --setup` in BAS or on a local PC, the installer prints a 🤖 notice and a checkbox picker asking which AI coding harnesses should receive the bundled agents and skills. **Space** selects or deselects, **a** toggles all, **Enter** confirms. **GitHub Copilot** and **Claude Code** are pre-checked, so pressing **Enter** without changes installs both; confirming with none selected skips the step and leaves your files unchanged.
|
|
409
411
|
|
|
410
412
|
| Harness | Skills land in | Agents land in | MCP auto-wiring |
|
|
411
413
|
| --- | --- | --- | --- |
|
|
@@ -416,12 +418,16 @@ During npm postinstall, after the destination or local SAP GUI setup step, the i
|
|
|
416
418
|
| Gemini CLI | `$HOME/.gemini/skills/` | — | `$HOME/.gemini/settings.json` |
|
|
417
419
|
| opencode | `$XDG_CONFIG_HOME/opencode/skills/` (default `$HOME/.config/opencode/skills/`) | `$XDG_CONFIG_HOME/opencode/agents/<id>.md` (as subagents) | Not changed automatically |
|
|
418
420
|
| Pi Coding Agent | `$PI_CODING_AGENT_DIR/skills/` (default `$HOME/.pi/agent/skills/`) | `$PI_CODING_AGENT_DIR/prompts/<id>.md` (as `/` prompt commands) | `$PI_CODING_AGENT_DIR/mcp.json` (default `$HOME/.pi/agent/mcp.json`) |
|
|
421
|
+
| Oh My Pi (OMP) | `$HOME/.omp/agent/skills/` (overridden by `PI_CONFIG_DIR` or `PI_CODING_AGENT_DIR`) | — | `$HOME/.omp/agent/mcp.json` (same overrides) |
|
|
422
|
+
| Cline | `$HOME/.cline/skills/` | — | Not changed automatically |
|
|
423
|
+
| Roo Code | `$HOME/.roo/skills/` | — | Not changed automatically |
|
|
424
|
+
| Windsurf / Devin Desktop | `$XDG_CONFIG_HOME/devin/skills/` (default `$HOME/.config/devin/skills/`); Windows: `$HOME/.codeium/windsurf/skills/` | — | `$XDG_CONFIG_HOME/devin/mcp_config.json` (default `$HOME/.config/devin/mcp_config.json`); Windows: `%APPDATA%\devin\mcp_config.json` |
|
|
419
425
|
|
|
420
426
|
All 14 skills use the portable `SKILL.md` format and are installed verbatim. The 5 agents use Copilot's `.agent.md` format, so they are installed as native custom agents for harnesses with a custom-agent mechanism (GitHub Copilot, Claude Code, opencode), with frontmatter adapted where needed. Pi receives equivalent prompt templates (`/abap-developer`, `/sap-solution-architect`, and so on) because Pi exposes reusable personas through prompts rather than a separate custom-agent file type. Selecting several harnesses duplicates the small markdown payloads on disk.
|
|
421
427
|
|
|
422
428
|
MCP auto-wiring preserves unrelated servers in each harness config and replaces only entries previously managed by `sap-ai-dev-toolkit`. Generated entries carry `SAP_AI_DEV_TOOLKIT_MANAGED=true` in their server environment so future reinstalls can reconcile them without touching user-owned MCP servers.
|
|
423
429
|
|
|
424
|
-
These user-level customizations are available across workspaces opened by the same OS user (or the same BAS user in a dev space). The harness may need a window reload to discover new files. A non-interactive
|
|
430
|
+
These user-level customizations are available across workspaces opened by the same OS user (or the same BAS user in a dev space). The harness may need a window reload to discover new files. A non-interactive postinstall or setup run installs for GitHub Copilot only; override with `SAP_AI_DEV_TOOLKIT_HARNESSES` (comma-separated harness ids). `npm install --ignore-scripts` skips postinstall, but running `sap-ai-dev --setup` still offers harness installation after destination setup.
|
|
425
431
|
|
|
426
432
|
On reinstall or package upgrade, unchanged add-on-managed files are updated per harness. Existing customizations and files edited since the previous install are preserved; postinstall reports paths that need manual review instead of overwriting them.
|
|
427
433
|
|
package/dist/checksums.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
f10b759b5e320c6fec4c176ef9bce49f783d796eeb8a2476dd5898c94cb14afd vsp-linux-x64
|
|
2
|
+
a4c47e4c15538cc16b540d19f55771e4c1340389efeca815e1881eafd89e45c5 vsp-linux-arm64
|
|
3
|
+
20a197921c88f539ea1a7f5236509dbaa9b9f906a6333e6fb1dce9f8b5eec85c vsp-darwin-x64
|
|
4
|
+
24a8810a17d31a434b92957ea41e6347b24e2a6a35e47b3b2fc13a16082a44c3 vsp-darwin-arm64
|
|
5
|
+
b318fdf5194a7f15ebcf7ee912fb2493df21aba337540a2402f81786300fd0ea vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
CHANGED
|
Binary file
|
package/dist/vsp-darwin-x64
CHANGED
|
Binary file
|
package/dist/vsp-linux-arm64
CHANGED
|
Binary file
|
package/dist/vsp-linux-x64
CHANGED
|
Binary file
|
package/dist/vsp-win32-x64.exe
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sap-ai-dev-toolkit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9908",
|
|
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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@abaplint/core": "2.119.0",
|
|
12
|
-
"@modelcontextprotocol/sdk": "1.
|
|
12
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
13
13
|
"@sap/hana-client": "2.30.27",
|
|
14
14
|
"terminal-kit": "^3.1.4",
|
|
15
15
|
"undici": "^6.21.3",
|
|
@@ -620,10 +620,30 @@ index 0000000..5e880bd
|
|
|
620
620
|
+ }
|
|
621
621
|
+}
|
|
622
622
|
diff --git a/pkg/adt/http.go b/pkg/adt/http.go
|
|
623
|
-
index 0b4d9f1..
|
|
623
|
+
index 0b4d9f1..f0b5b51 100755
|
|
624
624
|
--- a/pkg/adt/http.go
|
|
625
625
|
+++ b/pkg/adt/http.go
|
|
626
|
-
@@ -
|
|
626
|
+
@@ -313,7 +313,7 @@ func (t *Transport) request(ctx context.Context, path string, opts *RequestOptio
|
|
627
|
+
// The same expiry reaches a plain read as a successful-looking response that
|
|
628
|
+
// was in fact served by the identity provider. Nothing downstream would
|
|
629
|
+
// recognise the logon page it carries, so catch it here by origin.
|
|
630
|
+
- if resp.StatusCode < 400 && t.canReauth() && t.redirectedAwayFromSAP(resp) {
|
|
631
|
+
+ if t.canReauth() && ((resp.StatusCode < 400 && t.redirectedAwayFromSAP(resp)) || isSSOLoginResponse(resp, body)) {
|
|
632
|
+
t.setCSRFToken("")
|
|
633
|
+
t.setSessionID("")
|
|
634
|
+
if err := t.callReauthFunc(ctx); err != nil {
|
|
635
|
+
@@ -448,6 +448,10 @@ func (t *Transport) retryRequest(ctx context.Context, path string, opts *Request
|
|
636
|
+
}
|
|
637
|
+
traceHTTPResponse(resp, body)
|
|
638
|
+
|
|
639
|
+
+ if t.canReauth() && (t.redirectedAwayFromSAP(resp) || isSSOLoginResponse(resp, body)) {
|
|
640
|
+
+ return nil, fmt.Errorf("SSO session is still unauthenticated after re-authentication on %s", path)
|
|
641
|
+
+ }
|
|
642
|
+
+
|
|
643
|
+
if resp.StatusCode >= 400 {
|
|
644
|
+
return nil, &APIError{
|
|
645
|
+
StatusCode: resp.StatusCode,
|
|
646
|
+
@@ -499,6 +503,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
|
|
627
647
|
// allowReauth exists to break a cycle: re-authenticating ends with a token
|
|
628
648
|
// fetch of its own, and that fetch must not start another re-authentication.
|
|
629
649
|
func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bool, stateful bool) error {
|
|
@@ -631,7 +651,7 @@ index 0b4d9f1..3473536 100755
|
|
|
631
651
|
token, status, redirected, err := t.probeCSRFToken(ctx, http.MethodHead, stateful)
|
|
632
652
|
if err != nil {
|
|
633
653
|
return err
|
|
634
|
-
@@ -513,6 +
|
|
654
|
+
@@ -513,6 +518,29 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
635
655
|
if err != nil {
|
|
636
656
|
return err
|
|
637
657
|
}
|
|
@@ -661,7 +681,16 @@ index 0b4d9f1..3473536 100755
|
|
|
661
681
|
if !isCSRFToken(token) {
|
|
662
682
|
// An expired SSO session rarely announces itself as a 401. ICF sends
|
|
663
683
|
// the request on to the identity provider, the redirect chain is
|
|
664
|
-
@@ -
|
|
684
|
+
@@ -520,7 +548,7 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
685
|
+
// 200 — with no CSRF token in it, because it is not ADT answering.
|
|
686
|
+
// A live ADT session always yields a token, so its absence here is
|
|
687
|
+
// the signal, and a hop to a foreign host is the confirmation.
|
|
688
|
+
- if allowReauth && t.canReauth() && getStatus != http.StatusForbidden {
|
|
689
|
+
+ if allowReauth && t.canReauth() && (getStatus != http.StatusForbidden || redirected || getRedirected) {
|
|
690
|
+
reason := fmt.Sprintf("no CSRF token (HEAD %d, GET %d)", status, getStatus)
|
|
691
|
+
if redirected || getRedirected {
|
|
692
|
+
reason = "the identity provider answered instead of SAP"
|
|
693
|
+
@@ -537,6 +565,9 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
665
694
|
}
|
|
666
695
|
return t.fetchCSRFTokenWithReauth(ctx, false, stateful)
|
|
667
696
|
}
|
|
@@ -671,7 +700,7 @@ index 0b4d9f1..3473536 100755
|
|
|
671
700
|
switch getStatus {
|
|
672
701
|
case http.StatusUnauthorized:
|
|
673
702
|
return fmt.Errorf("authentication failed (401): check username/password")
|
|
674
|
-
@@ -556,7 +
|
|
703
|
+
@@ -556,7 +587,11 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
|
|
675
704
|
// returns the token (empty when the server did not supply one), the status, and
|
|
676
705
|
// whether the answer came from somewhere other than the SAP host.
|
|
677
706
|
func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful bool) (token string, status int, redirected bool, err error) {
|
|
@@ -684,6 +713,42 @@ index 0b4d9f1..3473536 100755
|
|
|
684
713
|
if err != nil {
|
|
685
714
|
return "", 0, false, fmt.Errorf("building URL: %w", err)
|
|
686
715
|
}
|
|
716
|
+
@@ -596,12 +631,34 @@ func (t *Transport) probeCSRFToken(ctx context.Context, method string, stateful
|
|
717
|
+
return "", 0, false, fmt.Errorf("executing request: %w", err)
|
|
718
|
+
}
|
|
719
|
+
defer resp.Body.Close()
|
|
720
|
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
|
721
|
+
+ if err != nil {
|
|
722
|
+
+ return "", 0, false, fmt.Errorf("reading CSRF response: %w", err)
|
|
723
|
+
+ }
|
|
724
|
+
// Drain the body so the connection can be reused.
|
|
725
|
+
_, _ = io.Copy(io.Discard, resp.Body)
|
|
726
|
+
traceHTTPResponse(resp, nil)
|
|
727
|
+
t.adoptServerCookies(resp)
|
|
728
|
+
|
|
729
|
+
- return resp.Header.Get("X-CSRF-Token"), resp.StatusCode, t.redirectedAwayFromSAP(resp), nil
|
|
730
|
+
+ return resp.Header.Get("X-CSRF-Token"), resp.StatusCode, t.redirectedAwayFromSAP(resp) || isSSOLoginResponse(resp, body), nil
|
|
731
|
+
+}
|
|
732
|
+
+
|
|
733
|
+
+func isSSOLoginResponse(resp *http.Response, body []byte) bool {
|
|
734
|
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
|
|
735
|
+
+ return false
|
|
736
|
+
+ }
|
|
737
|
+
+ page := strings.ToLower(strings.TrimSpace(string(body)))
|
|
738
|
+
+ html := strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/html") ||
|
|
739
|
+
+ strings.HasPrefix(page, "<!doctype html") || strings.HasPrefix(page, "<html")
|
|
740
|
+
+ if !html || !strings.Contains(page, "<form") {
|
|
741
|
+
+ return false
|
|
742
|
+
+ }
|
|
743
|
+
+ for _, marker := range []string{"samlrequest", "samlresponse", "sap-system-login", "sap-password", `type="password"`, `type='password'`} {
|
|
744
|
+
+ if strings.Contains(page, marker) {
|
|
745
|
+
+ return true
|
|
746
|
+
+ }
|
|
747
|
+
+ }
|
|
748
|
+
+ return false
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// redirectedAwayFromSAP reports whether a response was ultimately served by
|
|
687
752
|
diff --git a/pkg/adt/http_csrf_discovery_test.go b/pkg/adt/http_csrf_discovery_test.go
|
|
688
753
|
new file mode 100644
|
|
689
754
|
index 0000000..80741da
|
|
@@ -746,6 +811,98 @@ index 0000000..80741da
|
|
|
746
811
|
+ }
|
|
747
812
|
+}
|
|
748
813
|
+
|
|
814
|
+
diff --git a/pkg/adt/http_sso_same_host_test.go b/pkg/adt/http_sso_same_host_test.go
|
|
815
|
+
new file mode 100644
|
|
816
|
+
index 0000000..0c39b96
|
|
817
|
+
--- /dev/null
|
|
818
|
+
+++ b/pkg/adt/http_sso_same_host_test.go
|
|
819
|
+
@@ -0,0 +1,86 @@
|
|
820
|
+
+package adt
|
|
821
|
+
+
|
|
822
|
+
+import (
|
|
823
|
+
+ "context"
|
|
824
|
+
+ "io"
|
|
825
|
+
+ "net/http"
|
|
826
|
+
+ "net/http/httptest"
|
|
827
|
+
+ "strings"
|
|
828
|
+
+ "testing"
|
|
829
|
+
+)
|
|
830
|
+
+
|
|
831
|
+
+func TestSameHostLoginPageRefreshesSSO(t *testing.T) {
|
|
832
|
+
+ for _, method := range []string{http.MethodGet, http.MethodPost} {
|
|
833
|
+
+ for _, status := range []int{http.StatusOK, http.StatusUnauthorized, http.StatusForbidden} {
|
|
834
|
+
+ t.Run(method+"/"+http.StatusText(status), func(t *testing.T) {
|
|
835
|
+
+ refreshes := 0
|
|
836
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
837
|
+
+ cookie, err := r.Cookie("SAP_SESSIONID")
|
|
838
|
+
+ if err != nil || cookie.Value != "fresh" {
|
|
839
|
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
840
|
+
+ w.WriteHeader(status)
|
|
841
|
+
+ _, _ = io.WriteString(w, `<html><body><form action="/sap/saml2/sp/acs"><input name="SAMLRequest" value="fixture"></form></body></html>`)
|
|
842
|
+
+ return
|
|
843
|
+
+ }
|
|
844
|
+
+ w.Header().Set("X-CSRF-Token", "fresh-token")
|
|
845
|
+
+ w.Header().Set("Content-Type", "application/xml")
|
|
846
|
+
+ _, _ = io.WriteString(w, "<source>authenticated</source>")
|
|
847
|
+
+ }))
|
|
848
|
+
+ defer server.Close()
|
|
849
|
+
+ transport := NewTransportWithClient(&Config{
|
|
850
|
+
+ BaseURL: server.URL,
|
|
851
|
+
+ Cookies: map[string]string{"SAP_SESSIONID": "expired"},
|
|
852
|
+
+ ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
853
|
+
+ refreshes++
|
|
854
|
+
+ return map[string]string{"SAP_SESSIONID": "fresh"}, nil
|
|
855
|
+
+ },
|
|
856
|
+
+ }, server.Client())
|
|
857
|
+
+ response, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: method})
|
|
858
|
+
+ if err != nil {
|
|
859
|
+
+ t.Fatalf("request failed: %v", err)
|
|
860
|
+
+ }
|
|
861
|
+
+ if refreshes != 1 || string(response.Body) != "<source>authenticated</source>" {
|
|
862
|
+
+ t.Fatalf("refreshes=%d body=%q, want one SSO refresh and authenticated source", refreshes, response.Body)
|
|
863
|
+
+ }
|
|
864
|
+
+ })
|
|
865
|
+
+ }
|
|
866
|
+
+ }
|
|
867
|
+
+}
|
|
868
|
+
+
|
|
869
|
+
+func TestOrdinaryForbiddenDoesNotRefreshSSO(t *testing.T) {
|
|
870
|
+
+ refreshes := 0
|
|
871
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
872
|
+
+ w.WriteHeader(http.StatusForbidden)
|
|
873
|
+
+ _, _ = io.WriteString(w, "Missing development authorization")
|
|
874
|
+
+ }))
|
|
875
|
+
+ defer server.Close()
|
|
876
|
+
+ transport := NewTransportWithClient(&Config{BaseURL: server.URL, ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
877
|
+
+ refreshes++
|
|
878
|
+
+ return nil, nil
|
|
879
|
+
+ }}, server.Client())
|
|
880
|
+
+ _, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: http.MethodGet})
|
|
881
|
+
+ if err == nil || refreshes != 0 {
|
|
882
|
+
+ t.Fatalf("err=%v refreshes=%d, want authorization error without SSO refresh", err, refreshes)
|
|
883
|
+
+ }
|
|
884
|
+
+}
|
|
885
|
+
+
|
|
886
|
+
+func TestSSOLoginPageAfterRefreshFailsWithoutLoop(t *testing.T) {
|
|
887
|
+
+ refreshes := 0
|
|
888
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
889
|
+
+ if r.URL.Path == "/sap/bc/adt/source" {
|
|
890
|
+
+ w.Header().Set("Content-Type", "text/html")
|
|
891
|
+
+ _, _ = io.WriteString(w, `<html><form><input type="password"></form></html>`)
|
|
892
|
+
+ return
|
|
893
|
+
+ }
|
|
894
|
+
+ w.Header().Set("X-CSRF-Token", "fresh-token")
|
|
895
|
+
+ }))
|
|
896
|
+
+ defer server.Close()
|
|
897
|
+
+ transport := NewTransportWithClient(&Config{BaseURL: server.URL, ReauthFunc: func(context.Context) (map[string]string, error) {
|
|
898
|
+
+ refreshes++
|
|
899
|
+
+ return map[string]string{"SAP_SESSIONID": "fresh"}, nil
|
|
900
|
+
+ }}, server.Client())
|
|
901
|
+
+ _, err := transport.Request(context.Background(), "/sap/bc/adt/source", &RequestOptions{Method: http.MethodGet})
|
|
902
|
+
+ if err == nil || !strings.Contains(err.Error(), "still unauthenticated") || refreshes != 1 {
|
|
903
|
+
+ t.Fatalf("err=%v refreshes=%d, want explicit failure after one refresh", err, refreshes)
|
|
904
|
+
+ }
|
|
905
|
+
+}
|
|
749
906
|
diff --git a/pkg/adt/servicebinding_publish_test.go b/pkg/adt/servicebinding_publish_test.go
|
|
750
907
|
new file mode 100644
|
|
751
908
|
index 0000000..93c19f9
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,203 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
4
3
|
import { dirname, join, resolve } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { installBinary } from '../src/binary.mjs';
|
|
7
6
|
import { detectAdtUrl } from '../src/local-sap-gui.mjs';
|
|
8
7
|
import { runSetup } from '../src/setup.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import { installMcpServersForHarnesses } from '../src/harness-mcp-config.mjs';
|
|
11
|
-
import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from '../src/harnesses.mjs';
|
|
12
|
-
import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { checkboxPrompt, colorText, formatStatus, startProgress } from '../src/terminal-ui.mjs';
|
|
8
|
+
import { colorText, startProgress } from '../src/terminal-ui.mjs';
|
|
15
9
|
import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
|
|
16
10
|
import { generatedServerName } from '../src/mcp-config.mjs';
|
|
17
11
|
import { redactText } from '../src/redact.mjs';
|
|
12
|
+
import { announce, runHarnessSetup, withInstallTerminal } from '../src/harness-setup.mjs';
|
|
18
13
|
|
|
19
14
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
20
15
|
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
21
16
|
const runtimeEnv = withBrandedEnvironment(process.env);
|
|
22
17
|
|
|
23
|
-
async function announce(message, tone = 'info') {
|
|
24
|
-
const content = message.replace(/^sap-ai-dev-toolkit:\s*/, '');
|
|
25
|
-
// Windows cannot open CONOUT$ as a writable console stream from a
|
|
26
|
-
// piped npm script (libuv normalizes the device name, and the resulting
|
|
27
|
-
// handle drops writes), so output goes through stderr unconditionally.
|
|
28
|
-
if (process.platform === 'win32') {
|
|
29
|
-
console.error(formatStatus(content, tone, process.stderr));
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
let terminal;
|
|
33
|
-
try {
|
|
34
|
-
terminal = await open('/dev/tty', 'w');
|
|
35
|
-
await terminal.write(`${formatStatus(content, tone, true)}\n`);
|
|
36
|
-
} catch {
|
|
37
|
-
console.error(formatStatus(content, tone, process.stderr));
|
|
38
|
-
} finally {
|
|
39
|
-
await terminal?.close().catch(() => {});
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function openControllingTerminal() {
|
|
44
|
-
if (process.platform === 'win32') {
|
|
45
|
-
// npm can pipe lifecycle script streams even when the install was
|
|
46
|
-
// launched from an interactive Windows console. Verify console access,
|
|
47
|
-
// then attach Node TTY streams to its standard console devices.
|
|
48
|
-
const probe = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OpenStandardInput(); [Console]::OpenStandardOutput()'], { stdio: 'ignore', windowsHide: true });
|
|
49
|
-
if (probe.error || probe.status !== 0) throw new Error('interactive console is unavailable');
|
|
50
|
-
const inputFd = openSync('CONIN$', 'r');
|
|
51
|
-
let outputFd;
|
|
52
|
-
try {
|
|
53
|
-
outputFd = openSync('CONOUT$', 'w');
|
|
54
|
-
return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
|
|
55
|
-
} catch (error) {
|
|
56
|
-
closeSync(inputFd);
|
|
57
|
-
if (outputFd !== undefined) closeSync(outputFd);
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const inputFd = openSync('/dev/tty', 'r');
|
|
62
|
-
let outputFd;
|
|
63
|
-
try {
|
|
64
|
-
outputFd = openSync('/dev/tty', 'w');
|
|
65
|
-
return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
|
|
66
|
-
} catch (error) {
|
|
67
|
-
closeSync(inputFd);
|
|
68
|
-
if (outputFd !== undefined) closeSync(outputFd);
|
|
69
|
-
throw error;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function withInstallTerminal(action) {
|
|
74
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
75
|
-
return action({ input: process.stdin, output: process.stdout });
|
|
76
|
-
}
|
|
77
|
-
let terminal;
|
|
78
|
-
try {
|
|
79
|
-
terminal = openControllingTerminal();
|
|
80
|
-
} catch {
|
|
81
|
-
return action(null);
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
return await action(terminal);
|
|
85
|
-
} finally {
|
|
86
|
-
terminal.input.destroy();
|
|
87
|
-
terminal.output.destroy();
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
18
|
async function runInstallSetup(binary) {
|
|
92
19
|
const detect = binary ? target => detectAdtUrl(binary, { ...target, env: runtimeEnv }) : undefined;
|
|
93
20
|
return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}), detectAdtUrl: detect }));
|
|
94
21
|
}
|
|
95
22
|
|
|
96
|
-
function harnessSelectionLabels(ids) {
|
|
97
|
-
return HARNESSES.filter(harness => ids.includes(harness.id)).map(harness => harness.label).join(', ');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async function resolveHarnessOverride() {
|
|
101
|
-
try {
|
|
102
|
-
return envHarnessSelection(runtimeEnv);
|
|
103
|
-
} catch (error) {
|
|
104
|
-
await announce(`${error.message} Ignoring ${HARNESS_ENV_VAR}.`, 'warning');
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function installSelectedHarnessAssets(ids) {
|
|
110
|
-
const { results, failures } = await installUserAssetsForHarnesses(ids, { root });
|
|
111
|
-
for (const failure of failures) {
|
|
112
|
-
await announce(`${failure.label}: bundled agent and skill installation failed: ${failure.error.message}`, 'error');
|
|
113
|
-
}
|
|
114
|
-
for (const assets of results) {
|
|
115
|
-
const installed = assets.installed + assets.updated;
|
|
116
|
-
const removed = assets.removed || 0;
|
|
117
|
-
const cleanup = removed > 0 ? `; ${removed} duplicate or legacy file${removed === 1 ? '' : 's'} removed` : '';
|
|
118
|
-
await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current${cleanup}.`, 'success');
|
|
119
|
-
if (assets.conflicts.length > 0) {
|
|
120
|
-
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
121
|
-
await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
const total = results.reduce((sum, assets) => sum + assets.installed + assets.updated, 0);
|
|
125
|
-
if (results.length > 0) {
|
|
126
|
-
await announce(`Installed ${total} files across ${results.length} harness${results.length === 1 ? '' : 'es'}.`, 'success');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function runHarnessAssetInstall() {
|
|
131
|
-
const home = process.env.HOME || homedir();
|
|
132
|
-
const override = await resolveHarnessOverride();
|
|
133
|
-
return withInstallTerminal(async terminal => {
|
|
134
|
-
let selected = override;
|
|
135
|
-
if (!terminal) {
|
|
136
|
-
if (!selected) selected = NON_INTERACTIVE_HARNESS_IDS;
|
|
137
|
-
await announce(`Installing bundled agents and skills for ${harnessSelectionLabels(selected)} by default because no interactive terminal is available.`, 'progress');
|
|
138
|
-
await installSelectedHarnessAssets(selected);
|
|
139
|
-
return selected;
|
|
140
|
-
}
|
|
141
|
-
if (!selected) selected = DEFAULT_HARNESS_IDS;
|
|
142
|
-
await announce([
|
|
143
|
-
'The bundled agents and skills can be installed for several AI coding harnesses.',
|
|
144
|
-
'',
|
|
145
|
-
'Space = select or deselect · a = toggle all · Enter = confirm. Pressing Enter without changes installs the pre-checked harnesses; confirming with none selected skips this step.',
|
|
146
|
-
'',
|
|
147
|
-
`${colorText('📁 Target folders:', 'cyan', true)}`,
|
|
148
|
-
...HARNESSES.map(harness => ` ${harnessRoot(harness, { home, env: runtimeEnv })} — ${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills only'})`),
|
|
149
|
-
''
|
|
150
|
-
].join('\n'), 'copilot');
|
|
151
|
-
let answer;
|
|
152
|
-
try {
|
|
153
|
-
answer = await checkboxPrompt({
|
|
154
|
-
message: colorText('🤖 Install bundled agents and skills for', 'magenta', terminal.output),
|
|
155
|
-
choices: HARNESSES.map(harness => ({
|
|
156
|
-
value: harness.id,
|
|
157
|
-
name: `${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills'})`,
|
|
158
|
-
checked: selected.includes(harness.id)
|
|
159
|
-
})),
|
|
160
|
-
required: false,
|
|
161
|
-
shortcuts: { all: 'a' }
|
|
162
|
-
}, { input: terminal.input, output: terminal.output });
|
|
163
|
-
} catch (error) {
|
|
164
|
-
if (error.message === 'Prompt interrupted') {
|
|
165
|
-
await announce('Bundled agents and skills were skipped because the prompt was interrupted. Your files were not changed.', 'info');
|
|
166
|
-
return [];
|
|
167
|
-
}
|
|
168
|
-
throw error;
|
|
169
|
-
}
|
|
170
|
-
if (answer.length === 0) {
|
|
171
|
-
await announce('Bundled agents and skills were skipped. Your files were not changed.', 'info');
|
|
172
|
-
return [];
|
|
173
|
-
}
|
|
174
|
-
await installSelectedHarnessAssets(answer);
|
|
175
|
-
return answer;
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function wireHarnessMcpServers(ids, servers) {
|
|
180
|
-
const entries = servers && Object.keys(servers).length ? servers : null;
|
|
181
|
-
if (!entries || !ids?.length) return;
|
|
182
|
-
const hasLocalSapGui = Object.values(entries).some(entry => entry?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local');
|
|
183
|
-
if (hasLocalSapGui) {
|
|
184
|
-
await announce('Local SAP GUI MCP entries use VS Code input prompts for SAP login, so automatic wiring into non-VS Code harness MCP files was skipped. Configure those harnesses manually if they support secure runtime prompts.', 'info');
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const { results, failures } = await installMcpServersForHarnesses(ids, entries, { env: runtimeEnv });
|
|
188
|
-
for (const result of results) {
|
|
189
|
-
if (!result.supported) {
|
|
190
|
-
await announce(`${result.label}: MCP auto-wiring skipped. ${result.skipped}`, 'info');
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
await announce(`${result.label}: wired ${result.servers} MCP server${result.servers === 1 ? '' : 's'} in ${result.path}.`, 'success');
|
|
194
|
-
}
|
|
195
|
-
for (const failure of failures) {
|
|
196
|
-
await announce(`${failure.label}: MCP auto-wiring failed: ${failure.error.message}`, 'error');
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
23
|
function probeCell(destination) {
|
|
202
24
|
const probe = destination.probe || {};
|
|
203
25
|
if (probe.status === 'skipped') return { text: 'SKIPPED (probe disabled)', color: 'yellow' };
|
|
@@ -347,17 +169,7 @@ async function main() {
|
|
|
347
169
|
await announce('Rerun sap-ai-dev --setup.', 'info');
|
|
348
170
|
}
|
|
349
171
|
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
harnessIds = await runHarnessAssetInstall();
|
|
353
|
-
} catch (error) {
|
|
354
|
-
await announce(`Bundled agent and skill installation failed: ${error.message}`, 'error');
|
|
355
|
-
}
|
|
356
|
-
try {
|
|
357
|
-
if (setupCompleted) await wireHarnessMcpServers(harnessIds, setupResult?.servers);
|
|
358
|
-
} catch (error) {
|
|
359
|
-
await announce(`Harness MCP auto-wiring failed: ${error.message}`, 'error');
|
|
360
|
-
}
|
|
172
|
+
await runHarnessSetup(setupResult?.servers, { env: runtimeEnv, root, wire: setupCompleted });
|
|
361
173
|
if (setupCompleted) await announceSetup(setupResult);
|
|
362
174
|
}
|
|
363
175
|
if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]))) {
|