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 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, and Pi Coding Agent">
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
- The default installation is a single npx command — no global install, nothing left behind except the generated MCP configuration:
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, and Pi Coding Agent. 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.
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 seven, and confirming with none selected skips the step without changing files. When MCP setup succeeds, the generated managed MCP servers are also mirrored into selected harnesses that have a stable user-level JSON MCP configuration (Claude Code, Cursor, Gemini CLI, and Pi Coding Agent). 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`) to choose non-interactively.
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 the destination or local SAP GUI setup step, 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.
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 install skips the picker and installs for GitHub Copilot only; override with `SAP_AI_DEV_TOOLKIT_HARNESSES` (comma-separated harness ids). `npm install --ignore-scripts` skips the postinstall wizard entirely.
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
 
@@ -1,5 +1,5 @@
1
- 87b75dce6f9517696d1326526ae06bf1c216695283db7b2ee34f47335a861892 vsp-linux-x64
2
- c118ccd7043fc2eef9ad34d62ad78fb64c3ef3b4114d65b337b8d855b7b40f0d vsp-linux-arm64
3
- c66c6b75b83d3d581f859fa0a21c5a6a6ca0c168019353e3cd34ac8ed4632e7e vsp-darwin-x64
4
- 6b7833dfd6de0b2d1f6dab921e6ba7dd795217c733552413b4382925590f4388 vsp-darwin-arm64
5
- 8d1f0bf29a83d3b301c5bb9a34811c8c2a84d63285b93a7a393d0c942bf1d2f6 vsp-win32-x64.exe
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
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.9904",
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.30.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..3473536 100755
623
+ index 0b4d9f1..f0b5b51 100755
624
624
  --- a/pkg/adt/http.go
625
625
  +++ b/pkg/adt/http.go
626
- @@ -499,6 +499,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
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 +514,29 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
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
- @@ -537,6 +561,9 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
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 +583,11 @@ func (t *Transport) fetchCSRFTokenWithReauth(ctx context.Context, allowReauth bo
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
@@ -1,203 +1,25 @@
1
- import { open, readFile } from 'node:fs/promises';
2
- import { closeSync, openSync, realpathSync } from 'node:fs';
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 { installUserAssetsForHarnesses } from '../src/install-user-assets.mjs';
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
- let harnessIds = [];
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]))) {