sap-ai-dev-toolkit 0.8.5 → 0.9.0

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.
@@ -1,5 +1,5 @@
1
- 8ab555658815db7de670b956dafe0f12d6a249e4f1e1cbbb058deb658a89a37d vsp-linux-x64
2
- 7011b716e295ce2cecc40b9a56e57d9a1ec24c2b9d6aa25ab46e5cde0ec23998 vsp-linux-arm64
3
- 5c240a6b53e15ebaae817612aed1c5b95624a99adfc38e6abb9afa377de30d1a vsp-darwin-x64
4
- 28032c4d331253808ec9318e6db570420c6c9308935964de665e34374465d89f vsp-darwin-arm64
5
- 5bb45857489bbe5d57587d9abda0b768d3a1b352d89a2a6bf13b23af8d2fd45e 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.8.5",
3
+ "version": "0.9.0",
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",
@@ -22,8 +22,8 @@
22
22
  "scripts": {
23
23
  "postinstall": "node scripts/postinstall.mjs",
24
24
  "install:local": "node scripts/install-local.mjs",
25
- "test": "node --test test/*.test.mjs",
26
- "test:live:s4h": "SAP_AI_DEV_LIVE_S4H=1 node --test test/live-s4h.test.mjs",
25
+ "test": "node --test --test-force-exit test/*.test.mjs",
26
+ "test:live:s4h": "node scripts/run-live-s4h.mjs",
27
27
  "test:visibility": "node --test test/setup.test.mjs test/terminal-ui.test.mjs",
28
28
  "test:mutation": "node scripts/mutation-terminal-ui.mjs",
29
29
  "publish:npm": "node scripts/publish-npm.mjs",
@@ -55,5 +55,8 @@
55
55
  ],
56
56
  "publishConfig": {
57
57
  "access": "public"
58
+ },
59
+ "devDependencies": {
60
+ "@lydell/node-pty": "^1.2.0-beta.15"
58
61
  }
59
62
  }
@@ -1,21 +1,45 @@
1
1
  diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go
2
+ index 5007935..b2bc6be 100755
2
3
  --- a/cmd/vsp/main.go
3
4
  +++ b/cmd/vsp/main.go
4
- @@ -102,1 +102,2 @@
5
+ @@ -100,6 +100,7 @@ func init() {
6
+ rootCmd.Flags().StringVar(&cfg.BaseURL, "service", "", "SAP system URL (alias for --url)")
7
+
5
8
  // Authentication flags
6
9
  + rootCmd.Flags().BoolVar(&cfg.ProxyAuth, "proxy-auth", false, "Use BAS destination proxy authentication; do not send local credentials")
7
- @@ -172,1 +172,2 @@
10
+ rootCmd.Flags().StringVarP(&cfg.Username, "user", "u", "", "SAP username")
11
+ rootCmd.Flags().StringVarP(&cfg.Password, "password", "p", "", "SAP password")
12
+ rootCmd.Flags().StringVar(&cfg.Password, "pass", "", "SAP password (alias for --password)")
13
+ @@ -170,6 +171,7 @@ func init() {
14
+ rootCmd.PersistentFlags().BoolVarP(&cfg.Verbose, "verbose", "v", false, "Enable verbose output to stderr")
15
+
8
16
  // Bind flags to viper for environment variable support
9
17
  + viper.BindPFlag("proxy-auth", rootCmd.Flags().Lookup("proxy-auth"))
10
- @@ -235,1 +235,4 @@
18
+ viper.BindPFlag("url", rootCmd.Flags().Lookup("url"))
19
+ viper.BindPFlag("user", rootCmd.Flags().Lookup("user"))
20
+ viper.BindPFlag("password", rootCmd.Flags().Lookup("password"))
21
+ @@ -233,6 +235,9 @@ func runServer(cmd *cobra.Command, args []string) error {
22
+ }
23
+
11
24
  // Browser-based SSO authentication (must run before processCookieAuth)
12
25
  + if err := validateProxyAuth(cmd); err != nil {
13
26
  + return err
14
27
  + }
15
- @@ -348,1 +348,2 @@
28
+ if err := processBrowserAuth(cmd); err != nil {
29
+ return err
30
+ }
31
+ @@ -346,6 +351,7 @@ func runServer(cmd *cobra.Command, args []string) error {
32
+ }
33
+
16
34
  func resolveConfig(cmd *cobra.Command) {
17
35
  + cfg.ProxyAuth = cfg.ProxyAuth || viper.GetBool("PROXY_AUTH")
18
- @@ -533,1 +533,19 @@
36
+ // Check if cookie auth is explicitly requested via CLI flags OR env vars
37
+ // If so, we should NOT load user/password from env/.env to avoid conflicts
38
+ // Cookie auth takes precedence over basic auth since it's more explicit
39
+ @@ -530,6 +536,24 @@ func validateConfig() error {
40
+ return nil
41
+ }
42
+
19
43
  +func validateProxyAuth(cmd *cobra.Command) error {
20
44
  + if !cfg.ProxyAuth {
21
45
  + return nil
@@ -35,146 +59,68 @@ diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go
35
59
  + return nil
36
60
  +}
37
61
  func processBrowserAuth(cmd *cobra.Command) error {
38
- @@ -601,1 +601,4 @@
62
+ browserAuth, _ := cmd.Flags().GetBool("browser-auth")
63
+ if !browserAuth && !viper.GetBool("BROWSER_AUTH") {
64
+ @@ -599,6 +623,9 @@ func ssoRequested(cmd *cobra.Command) bool {
65
+ // days will outlive any session it starts with, and the point of the exercise
66
+ // is that nobody has to notice when it does.
39
67
  func processSSOAuth(cmd *cobra.Command) error {
40
68
  + if cfg.ProxyAuth {
41
69
  + return nil
42
70
  + }
43
- @@ -659,1 +659,4 @@
71
+ if !ssoRequested(cmd) {
72
+ return nil
73
+ }
74
+ @@ -657,6 +684,9 @@ func processSSOAuth(cmd *cobra.Command) error {
75
+ }
76
+
44
77
  func processSAMLAuth(cmd *cobra.Command) error {
45
78
  + if cfg.ProxyAuth {
46
79
  + return nil
47
80
  + }
48
- @@ -754,1 +754,4 @@
81
+ samlAuth, _ := cmd.Flags().GetBool("saml-auth")
82
+ if !samlAuth && !viper.GetBool("SAML_AUTH") {
83
+ return nil
84
+ @@ -752,6 +782,9 @@ func processSAMLAuth(cmd *cobra.Command) error {
85
+ }
86
+
49
87
  func processCookieAuth(cmd *cobra.Command) error {
50
88
  + if cfg.ProxyAuth {
51
89
  + return nil
52
90
  + }
91
+ cookieFile, _ := cmd.Flags().GetString("cookie-file")
92
+ cookieString, _ := cmd.Flags().GetString("cookie-string")
93
+
53
94
  diff --git a/internal/mcp/server.go b/internal/mcp/server.go
95
+ index 9337857..11fc940 100644
54
96
  --- a/internal/mcp/server.go
55
97
  +++ b/internal/mcp/server.go
56
- @@ -71,2 +71,5 @@
98
+ @@ -69,6 +69,9 @@ type Config struct {
99
+ InsecureSkipVerify bool
100
+
57
101
  // Cookie authentication (alternative to basic auth)
58
102
  + // ProxyAuth delegates identity to a BAS destination proxy. Local credentials are rejected.
59
103
  + ProxyAuth bool
60
104
  +
61
105
  Cookies map[string]string
62
- @@ -154,1 +154,4 @@
106
+
107
+ // Build names the binary, as "v2.52.0 (commit abc1234, built ...)".
108
+ @@ -151,6 +154,9 @@ func NewServer(cfg *Config) *Server {
109
+ if cfg.InsecureSkipVerify {
110
+ opts = append(opts, adt.WithInsecureSkipVerify())
111
+ }
63
112
  + if cfg.ProxyAuth {
64
113
  + opts = append(opts, adt.WithProxyContextIDGuard())
65
114
  + }
66
115
  if len(cfg.Cookies) > 0 {
67
- diff --git a/pkg/adt/client.go b/pkg/adt/client.go
68
- --- a/pkg/adt/client.go
69
- +++ b/pkg/adt/client.go
70
- @@ -1586 +1586 @@
71
- - Accept: "application/xml",
72
- + Accept: "application/atom+xml;type=feed",
73
- @@ -1592,27 +1592,27 @@
74
- - type componentXML struct {
75
- - Name string `xml:"name,attr"`
76
- - Release string `xml:"release,attr"`
77
- - SupportPack string `xml:"supportPack,attr"`
78
- - Description string `xml:"description,attr"`
79
- - }
80
- - type componentsXML struct {
81
- - XMLName xml.Name `xml:"components"`
82
- - Components []componentXML `xml:"component"`
83
- - }
84
- -
85
- - var comps componentsXML
86
- - if err := xml.Unmarshal(resp.Body, &comps); err != nil {
87
- - return nil, fmt.Errorf("parsing components: %w", err)
88
- - }
89
- -
90
- - result := make([]InstalledComponent, len(comps.Components))
91
- - for i, c := range comps.Components {
92
- - result[i] = InstalledComponent{
93
- - Name: c.Name,
94
- - Release: c.Release,
95
- - SupportPack: c.SupportPack,
96
- - Description: c.Description,
97
- - }
98
- - }
99
- -
100
- - return result, nil
101
- + type componentEntry struct {
102
- + ID string `xml:"id"`
103
- + Title string `xml:"title"`
104
- + }
105
- + type componentsFeed struct {
106
- + Entries []componentEntry `xml:"entry"`
107
- + }
108
- + var feed componentsFeed
109
- + if err := xml.Unmarshal(resp.Body, &feed); err != nil {
110
- + return nil, fmt.Errorf("parsing components: %w", err)
111
- + }
112
- +
113
- + result := make([]InstalledComponent, 0, len(feed.Entries))
114
- + for i, entry := range feed.Entries {
115
- + fields := strings.SplitN(entry.Title, ";", 4)
116
- + name := strings.TrimSpace(entry.ID)
117
- + if name == "" || len(fields) != 4 {
118
- + return nil, fmt.Errorf("parsing components: malformed feed entry %d", i+1)
119
- + }
120
- + result = append(result, InstalledComponent{
121
- + Name: name,
122
- + Release: strings.TrimSpace(fields[0]),
123
- + SupportPack: strings.TrimSpace(fields[2]),
124
- + Description: strings.TrimSpace(fields[3]),
125
- + })
126
- + }
127
- + return result, nil
128
- diff --git a/pkg/adt/client_components_test.go b/pkg/adt/client_components_test.go
129
- new file mode 100644
130
- --- /dev/null
131
- +++ b/pkg/adt/client_components_test.go
132
- @@ -0,0 +1,40 @@
133
- +package adt
134
- +
135
- +import (
136
- + "context"
137
- + "io"
138
- + "net/http"
139
- + "net/http/httptest"
140
- + "testing"
141
- +)
142
- +
143
- +func TestGetInstalledComponentsUsesADTFeed(t *testing.T) {
144
- + const accept = "application/atom+xml;type=feed"
145
- + const response = `<atom:feed xmlns:atom="http://www.w3.org/2005/Atom"><atom:entry><atom:id>SAP_BASIS</atom:id><atom:title>758;SAPK-75802INSAPBASIS;0002;SAP Basis &gt; 2022; patch</atom:title></atom:entry></atom:feed>`
146
- + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147
- + if r.URL.Path != "/sap/bc/adt/system/components" {
148
- + t.Errorf("unexpected request path: %s", r.URL.Path)
149
- + w.WriteHeader(http.StatusNotFound)
150
- + return
151
- + }
152
- + if got := r.Header.Get("Accept"); got != accept {
153
- + t.Errorf("Accept = %q, want %q", got, accept)
154
- + w.WriteHeader(http.StatusNotAcceptable)
155
- + return
156
- + }
157
- + w.Header().Set("Content-Type", accept)
158
- + _, _ = io.WriteString(w, response)
159
- + }))
160
- + defer server.Close()
161
- +
162
- + components, err := NewClient(server.URL, "user", "pass").GetInstalledComponents(context.Background())
163
- + if err != nil {
164
- + t.Fatalf("GetInstalledComponents returned an error: %v", err)
165
- + }
166
- + if len(components) != 1 {
167
- + t.Fatalf("got %d components, want 1", len(components))
168
- + }
169
- + if got := components[0]; got.Name != "SAP_BASIS" || got.Release != "758" || got.SupportPack != "0002" || got.Description != "SAP Basis > 2022; patch" {
170
- + t.Errorf("got component %#v", got)
171
- + }
172
- +}
116
+ opts = append(opts, adt.WithCookies(cfg.Cookies))
117
+ }
173
118
  diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
119
+ index 7740e56..b4c2faf 100755
174
120
  --- a/internal/mcp/server_test.go
175
121
  +++ b/internal/mcp/server_test.go
176
122
  @@ -159,3 +159,112 @@ func TestDebuggerGetVariablesSchemaIncludesItems(t *testing.T) {
177
- t.Fatalf("expected variableIDs.items.type to be 'string', got %v", items["type"])
123
+ t.Fatalf("expected variable_ids.items.type to be 'string', got %v", items["type"])
178
124
  }
179
125
  }
180
126
  +
@@ -287,6 +233,7 @@ diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
287
233
  + }
288
234
  +}
289
235
  diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go
236
+ index dd433d6..5553b7f 100644
290
237
  --- a/internal/mcp/tools_register.go
291
238
  +++ b/internal/mcp/tools_register.go
292
239
  @@ -837,6 +837,19 @@ func (s *Server) registerDevTools(shouldRegister func(string) bool) {
@@ -309,7 +256,117 @@ diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go
309
256
  ),
310
257
  ), s.handleActivateMultiple)
311
258
  }
259
+ diff --git a/pkg/adt/client.go b/pkg/adt/client.go
260
+ index 979e908..c7e9501 100755
261
+ --- a/pkg/adt/client.go
262
+ +++ b/pkg/adt/client.go
263
+ @@ -1583,38 +1583,38 @@ type InstalledComponent struct {
264
+ func (c *Client) GetInstalledComponents(ctx context.Context) ([]InstalledComponent, error) {
265
+ resp, err := c.transport.Request(ctx, "/sap/bc/adt/system/components", &RequestOptions{
266
+ Method: http.MethodGet,
267
+ - Accept: "application/xml",
268
+ + Accept: "application/atom+xml;type=feed",
269
+ })
270
+ if err != nil {
271
+ return nil, fmt.Errorf("getting installed components: %w", err)
272
+ }
273
+
274
+ - type componentXML struct {
275
+ - Name string `xml:"name,attr"`
276
+ - Release string `xml:"release,attr"`
277
+ - SupportPack string `xml:"supportPack,attr"`
278
+ - Description string `xml:"description,attr"`
279
+ + type componentEntry struct {
280
+ + ID string `xml:"id"`
281
+ + Title string `xml:"title"`
282
+ }
283
+ - type componentsXML struct {
284
+ - XMLName xml.Name `xml:"components"`
285
+ - Components []componentXML `xml:"component"`
286
+ + type componentsFeed struct {
287
+ + Entries []componentEntry `xml:"entry"`
288
+ }
289
+ -
290
+ - var comps componentsXML
291
+ - if err := xml.Unmarshal(resp.Body, &comps); err != nil {
292
+ + var feed componentsFeed
293
+ + if err := xml.Unmarshal(resp.Body, &feed); err != nil {
294
+ return nil, fmt.Errorf("parsing components: %w", err)
295
+ }
296
+
297
+ - result := make([]InstalledComponent, len(comps.Components))
298
+ - for i, c := range comps.Components {
299
+ - result[i] = InstalledComponent{
300
+ - Name: c.Name,
301
+ - Release: c.Release,
302
+ - SupportPack: c.SupportPack,
303
+ - Description: c.Description,
304
+ + result := make([]InstalledComponent, 0, len(feed.Entries))
305
+ + for i, entry := range feed.Entries {
306
+ + fields := strings.SplitN(entry.Title, ";", 4)
307
+ + name := strings.TrimSpace(entry.ID)
308
+ + if name == "" || len(fields) != 4 {
309
+ + return nil, fmt.Errorf("parsing components: malformed feed entry %d", i+1)
310
+ }
311
+ + result = append(result, InstalledComponent{
312
+ + Name: name,
313
+ + Release: strings.TrimSpace(fields[0]),
314
+ + SupportPack: strings.TrimSpace(fields[2]),
315
+ + Description: strings.TrimSpace(fields[3]),
316
+ + })
317
+ }
318
+ -
319
+ return result, nil
320
+ }
321
+
322
+ diff --git a/pkg/adt/client_components_test.go b/pkg/adt/client_components_test.go
323
+ new file mode 100644
324
+ index 0000000..1ed3c2b
325
+ --- /dev/null
326
+ +++ b/pkg/adt/client_components_test.go
327
+ @@ -0,0 +1,40 @@
328
+ +package adt
329
+ +
330
+ +import (
331
+ + "context"
332
+ + "io"
333
+ + "net/http"
334
+ + "net/http/httptest"
335
+ + "testing"
336
+ +)
337
+ +
338
+ +func TestGetInstalledComponentsUsesADTFeed(t *testing.T) {
339
+ + const accept = "application/atom+xml;type=feed"
340
+ + const response = `<atom:feed xmlns:atom="http://www.w3.org/2005/Atom"><atom:entry><atom:id>SAP_BASIS</atom:id><atom:title>758;SAPK-75802INSAPBASIS;0002;SAP Basis &gt; 2022; patch</atom:title></atom:entry></atom:feed>`
341
+ + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342
+ + if r.URL.Path != "/sap/bc/adt/system/components" {
343
+ + t.Errorf("unexpected request path: %s", r.URL.Path)
344
+ + w.WriteHeader(http.StatusNotFound)
345
+ + return
346
+ + }
347
+ + if got := r.Header.Get("Accept"); got != accept {
348
+ + t.Errorf("Accept = %q, want %q", got, accept)
349
+ + w.WriteHeader(http.StatusNotAcceptable)
350
+ + return
351
+ + }
352
+ + w.Header().Set("Content-Type", accept)
353
+ + _, _ = io.WriteString(w, response)
354
+ + }))
355
+ + defer server.Close()
356
+ +
357
+ + components, err := NewClient(server.URL, "user", "pass").GetInstalledComponents(context.Background())
358
+ + if err != nil {
359
+ + t.Fatalf("GetInstalledComponents returned an error: %v", err)
360
+ + }
361
+ + if len(components) != 1 {
362
+ + t.Fatalf("got %d components, want 1", len(components))
363
+ + }
364
+ + if got := components[0]; got.Name != "SAP_BASIS" || got.Release != "758" || got.SupportPack != "0002" || got.Description != "SAP Basis > 2022; patch" {
365
+ + t.Errorf("got component %#v", got)
366
+ + }
367
+ +}
312
368
  diff --git a/pkg/adt/http.go b/pkg/adt/http.go
369
+ index 0b4d9f1..3473536 100755
313
370
  --- a/pkg/adt/http.go
314
371
  +++ b/pkg/adt/http.go
315
372
  @@ -499,6 +499,7 @@ func (t *Transport) fetchCSRFTokenFor(ctx context.Context, stateful bool) error
@@ -375,9 +432,10 @@ diff --git a/pkg/adt/http.go b/pkg/adt/http.go
375
432
  }
376
433
  diff --git a/pkg/adt/http_csrf_discovery_test.go b/pkg/adt/http_csrf_discovery_test.go
377
434
  new file mode 100644
435
+ index 0000000..80741da
378
436
  --- /dev/null
379
437
  +++ b/pkg/adt/http_csrf_discovery_test.go
380
- @@ -0,0 +1,55 @@
438
+ @@ -0,0 +1,56 @@
381
439
  +package adt
382
440
  +
383
441
  +import (
@@ -433,3 +491,4 @@ new file mode 100644
433
491
  + t.Fatalf("expected both endpoint failures, got %v", err)
434
492
  + }
435
493
  +}
494
+ +
@@ -10,7 +10,8 @@ const exec = promisify(execFile);
10
10
  export const GO_VERSION = brandedEnvValue(process.env, 'GO_VERSION') || 'go1.27.1';
11
11
  const GO_RELEASES = {
12
12
  'linux:x64': { archive: `${GO_VERSION}.linux-amd64.tar.gz`, sha256: '63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445' },
13
- 'linux:arm64': { archive: `${GO_VERSION}.linux-arm64.tar.gz`, sha256: '3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec' }
13
+ 'linux:arm64': { archive: `${GO_VERSION}.linux-arm64.tar.gz`, sha256: '3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec' },
14
+ 'win32:x64': { archive: `${GO_VERSION}.windows-amd64.zip`, sha256: 'a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d' }
14
15
  };
15
16
 
16
17
  function localGoPath() {
@@ -28,7 +29,7 @@ function candidateGo() {
28
29
 
29
30
  export async function installGo() {
30
31
  const release = GO_RELEASES[`${process.platform}:${process.arch}`];
31
- if (!release) throw new Error(`Go is missing and automatic installation is supported only for Linux x64/arm64. Install ${GO_VERSION} manually and ensure go is on PATH.`);
32
+ if (!release) throw new Error(`Go is missing and automatic installation is supported only for Linux x64/arm64 and Windows x64. Install ${GO_VERSION} manually and ensure go is on PATH.`);
32
33
  const response = await fetch(`https://go.dev/dl/${release.archive}`);
33
34
  if (!response.ok) throw new Error(`Go download failed with HTTP ${response.status}; install ${GO_VERSION} manually and ensure go is on PATH.`);
34
35
  const bytes = Buffer.from(await response.arrayBuffer());
@@ -39,13 +40,20 @@ export async function installGo() {
39
40
  await mkdir(root, { recursive: true, mode: 0o755 });
40
41
  const archive = join(tmpdir(), `${release.archive}.${process.pid}`);
41
42
  await writeFile(archive, bytes, { mode: 0o600 });
42
- await exec('tar', ['-xzf', archive, '-C', root, '--strip-components=1'], { stdio: 'inherit' });
43
+ // bsdtar on Windows extracts zip archives the same way as gzip tarballs.
44
+ // Prefer the OS-supplied C:\Windows\System32\tar.exe: a GNU tar earlier on
45
+ // PATH (e.g. Git for Windows) misreads the drive-letter -C path as a
46
+ // remote host unless --force-local is passed.
47
+ const tarCommand = process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\WINDOWS', 'System32', 'tar.exe') : 'tar';
48
+ await exec(tarCommand, ['-xzf', archive, '-C', root, '--strip-components=1'], { stdio: 'inherit' });
43
49
  await rm(archive, { force: true });
44
- const goPath = join(root, 'bin', 'go');
50
+ const goPath = join(root, 'bin', process.platform === 'win32' ? 'go.exe' : 'go');
45
51
  await chmod(goPath, 0o755);
46
- const userBin = join(homedir(), '.local', 'bin');
47
- await mkdir(userBin, { recursive: true, mode: 0o755 });
48
- try { await symlink(goPath, join(userBin, process.platform === 'win32' ? 'go.exe' : 'go')); } catch {}
52
+ if (process.platform !== 'win32') {
53
+ const userBin = join(homedir(), '.local', 'bin');
54
+ await mkdir(userBin, { recursive: true, mode: 0o755 });
55
+ try { await symlink(goPath, join(userBin, 'go')); } catch {}
56
+ }
49
57
  return goPath;
50
58
  }
51
59
 
@@ -61,7 +61,11 @@ async function resolveGlobalBinaryPath() {
61
61
  async function verify() {
62
62
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
63
63
  const prefix = await resolveGlobalBinaryPath();
64
- const expected = resolvePath(prefix, 'lib', 'node_modules', pkg.name);
64
+ // npm's global layout differs per platform: Unix nests modules under
65
+ // <prefix>/lib/node_modules, Windows puts them directly under <prefix>.
66
+ const expected = process.platform === 'win32'
67
+ ? resolvePath(prefix, 'node_modules', pkg.name)
68
+ : resolvePath(prefix, 'lib', 'node_modules', pkg.name);
65
69
  const serverCommand = process.platform === 'win32'
66
70
  ? join(prefix, 'sap-ai-dev.cmd')
67
71
  : join(prefix, 'bin', 'sap-ai-dev');
@@ -20,9 +20,16 @@ const runtimeEnv = withBrandedEnvironment(process.env);
20
20
 
21
21
  async function announce(message, tone = 'info') {
22
22
  const content = message.replace(/^sap-ai-dev-toolkit:\s*/, '');
23
+ // Windows cannot open CONOUT$ as a writable console stream from a
24
+ // piped npm script (libuv normalizes the device name, and the resulting
25
+ // handle drops writes), so output goes through stderr unconditionally.
26
+ if (process.platform === 'win32') {
27
+ console.error(formatStatus(content, tone, process.stderr));
28
+ return;
29
+ }
23
30
  let terminal;
24
31
  try {
25
- terminal = await open(process.platform === 'win32' ? 'CONOUT$' : '/dev/tty', 'w');
32
+ terminal = await open('/dev/tty', 'w');
26
33
  await terminal.write(`${formatStatus(content, tone, true)}\n`);
27
34
  } catch {
28
35
  console.error(formatStatus(content, tone, process.stderr));
@@ -32,12 +39,17 @@ async function announce(message, tone = 'info') {
32
39
  }
33
40
 
34
41
  function openControllingTerminal() {
35
- const inputPath = process.platform === 'win32' ? 'CONIN$' : '/dev/tty';
36
- const outputPath = process.platform === 'win32' ? 'CONOUT$' : '/dev/tty';
37
- const inputFd = openSync(inputPath, 'r');
42
+ if (process.platform === 'win32') {
43
+ // fs cannot open CONIN$/CONOUT$ (libuv path normalization rejects the
44
+ // device names), so there is no controlling-terminal recovery on
45
+ // Windows: npm-script installs run non-interactively and the printed
46
+ // guidance points at `sap-ai-dev --setup` in a real terminal.
47
+ throw new Error('controlling terminal recovery is unavailable on Windows');
48
+ }
49
+ const inputFd = openSync('/dev/tty', 'r');
38
50
  let outputFd;
39
51
  try {
40
- outputFd = openSync(outputPath, 'w');
52
+ outputFd = openSync('/dev/tty', 'w');
41
53
  return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
42
54
  } catch (error) {
43
55
  closeSync(inputFd);
@@ -0,0 +1,10 @@
1
+ // Cross-platform runner for the live S4H suite: Windows cmd cannot parse the
2
+ // Unix `VAR=1 command` prefix used in npm scripts, so the flag is set here
3
+ // and the test runner is spawned directly.
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const result = spawnSync(process.execPath, ['--test', '--test-force-exit', 'test/live-s4h.test.mjs'], {
7
+ stdio: 'inherit',
8
+ env: { ...process.env, SAP_AI_DEV_LIVE_S4H: '1' }
9
+ });
10
+ process.exitCode = result.status ?? 1;
package/src/harnesses.mjs CHANGED
@@ -5,7 +5,9 @@ export const HARNESS_ENV_VAR = 'SAP_AI_DEV_TOOLKIT_HARNESSES';
5
5
  export const DEFAULT_HARNESS_IDS = ['github-copilot', 'claude-code'];
6
6
  export const NON_INTERACTIVE_HARNESS_IDS = ['github-copilot'];
7
7
 
8
- const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n/;
8
+ // \r? keeps frontmatter parsing working on Windows checkouts where git's
9
+ // autocrlf converted the bundled agents and skills to CRLF line endings.
10
+ const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
9
11
 
10
12
  function splitFrontmatter(source) {
11
13
  const match = FRONTMATTER_PATTERN.exec(String(source));
package/src/launcher.mjs CHANGED
@@ -105,7 +105,10 @@ async function runDoctor(destinations) {
105
105
  const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
106
106
  checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} VSP tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
107
107
  const systemInfo = tools.find(tool => tool.name === 'get_system_info' || tool.name === `${toolPrefix}get_system_info`);
108
- if (!systemInfo) {
108
+ const needsAdtUrl = destination.probe?.status === 'needs-adt-url';
109
+ if (needsAdtUrl) {
110
+ checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'No ADT URL is configured for this SAP GUI system; run sap-ai-dev --setup to add one.'));
111
+ } else if (!systemInfo) {
109
112
  checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
110
113
  } else {
111
114
  const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
@@ -316,10 +319,18 @@ async function main() {
316
319
  return;
317
320
  }
318
321
  const binary = await binaryOrError();
319
- const child = spawn(binary, process.argv.slice(2), { env: runtimeEnv, stdio: 'inherit' });
322
+ // Windows cannot exec a .js/.mjs file directly; route JavaScript entries
323
+ // (test fixtures) through the current Node binary like MCPProxy does.
324
+ const spawnTarget = /\.(?:mjs|cjs|js)$/i.test(binary)
325
+ ? { command: process.execPath, prefixArgs: [binary] }
326
+ : { command: binary, prefixArgs: [] };
327
+ const child = spawn(spawnTarget.command, [...spawnTarget.prefixArgs, ...process.argv.slice(2)], { env: runtimeEnv, stdio: 'inherit' });
320
328
  process.once('SIGINT', () => child.kill('SIGINT'));
321
329
  process.once('SIGTERM', () => child.kill('SIGTERM'));
322
- const [code, signal] = await new Promise(resolve => child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal])));
330
+ const [code, signal] = await new Promise((resolve, reject) => {
331
+ child.once('error', reject);
332
+ child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
333
+ });
323
334
  if (signal) process.kill(process.pid, signal);
324
335
  process.exitCode = code ?? 1;
325
336
  return;
@@ -300,6 +300,10 @@ function registryPathCandidates(value) {
300
300
 
301
301
  async function windowsRegistrySapGuiCandidates(env = process.env) {
302
302
  if (platform() !== 'win32') return [];
303
+ // Escape hatch for locked-down machines and for tests that must not see
304
+ // the real registry's landscape paths.
305
+ const disabled = String(env.SAP_AI_DEV_TOOLKIT_DISABLE_SAP_GUI_REGISTRY || '').toLowerCase() === 'true';
306
+ if (disabled) return [];
303
307
  const candidates = [];
304
308
  for (const root of SAP_GUI_REGISTRY_ROOTS) {
305
309
  try {
package/src/mcp-proxy.mjs CHANGED
@@ -198,6 +198,14 @@ export function childArguments(destination, env = process.env) {
198
198
  return args;
199
199
  }
200
200
 
201
+ // Windows cannot exec a .js/.mjs file directly (no shebang support), so test
202
+ // fixtures written in JavaScript are launched through the current Node binary.
203
+ // The production VSP binary is a native executable and is unaffected.
204
+ function spawnCommand(binary) {
205
+ if (/\.(?:mjs|cjs|js)$/i.test(binary)) return [process.execPath, binary];
206
+ return [binary];
207
+ }
208
+
201
209
  class Child {
202
210
  constructor(binary, destination, options) {
203
211
  this.destination = destination;
@@ -208,7 +216,8 @@ class Child {
208
216
  this.exited = false;
209
217
  this.closing = false;
210
218
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
211
- this.process = (options.spawn || nodeSpawn)(binary, options.args || childArguments(destination, options.env), {
219
+ const [command, ...commandPrefixArgs] = spawnCommand(binary);
220
+ this.process = (options.spawn || nodeSpawn)(command, [...commandPrefixArgs, ...(options.args || childArguments(destination, options.env))], {
212
221
  env: childEnvironment(destination, options.env),
213
222
  stdio: ['pipe', 'pipe', 'pipe']
214
223
  });
@@ -346,8 +355,22 @@ class Child {
346
355
  if (this.exited) return;
347
356
  this.closing = true;
348
357
  this.notify('notifications/cancelled', { reason: 'proxy shutdown' });
349
- this.process.kill('SIGTERM');
358
+ // Give well-behaved MCP children a graceful exit first: stdin EOF lets
359
+ // them finish and log their own shutdown. Windows cannot trap SIGTERM
360
+ // (Node maps it to TerminateProcess), so EOF is the only graceful path —
361
+ // wait briefly for it before escalating.
362
+ this.process.stdin?.end();
350
363
  let timeout;
364
+ try {
365
+ await Promise.race([
366
+ once(this.process, 'exit').catch(() => {}),
367
+ new Promise(resolve => { timeout = setTimeout(resolve, 750); })
368
+ ]);
369
+ } finally {
370
+ clearTimeout(timeout);
371
+ }
372
+ if (this.exited) return;
373
+ this.process.kill('SIGTERM');
351
374
  try {
352
375
  await Promise.race([
353
376
  once(this.process, 'exit').catch(() => {}),
@@ -21,37 +21,9 @@ function validateTarget(value) {
21
21
  return url;
22
22
  }
23
23
 
24
- function authChallengeValues(headers) {
25
- const value = headers?.get?.('www-authenticate');
26
- if (!value) return [];
27
- const parts = [];
28
- let start = 0;
29
- let quoted = false;
30
- let escaped = false;
31
- for (let index = 0; index < value.length; index += 1) {
32
- const char = value[index];
33
- if (escaped) {
34
- escaped = false;
35
- } else if (char === '\\' && quoted) {
36
- escaped = true;
37
- } else if (char === '"') {
38
- quoted = !quoted;
39
- } else if (char === ',' && !quoted) {
40
- parts.push(value.slice(start, index).trim());
41
- start = index + 1;
42
- }
43
- }
44
- parts.push(value.slice(start).trim());
45
- return parts.filter(Boolean);
46
- }
47
-
48
- function hasAuthChallenge(headers, scheme) {
49
- const pattern = new RegExp(`^${scheme}(?:\\s|$)`, 'i');
50
- return authChallengeValues(headers).some(part => pattern.test(part));
51
- }
52
-
53
24
  function hasNegotiateChallenge(headers) {
54
- return hasAuthChallenge(headers, 'Negotiate');
25
+ const value = headers.get?.('www-authenticate') || '';
26
+ return /(?:^|,)\s*Negotiate\b/i.test(value);
55
27
  }
56
28
 
57
29
  function headerObject(headers) {
@@ -170,8 +142,8 @@ async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }
170
142
  }
171
143
 
172
144
  function basicFallbackCredentials(env) {
173
- const mode = String(env.SAP_AUTH_FALLBACK_MODE || env.SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE || '').trim().toLowerCase();
174
- const enabled = mode === 'basic' || String(env.SAP_AI_DEV_TOOLKIT_SSO_BASIC_FALLBACK || '').toLowerCase() === 'true';
145
+ const mode = String(env.SAP_AUTH_FALLBACK_MODE || env.SAP_AI_DEV_TOOLKIT_AUTH_FALLBACK_MODE || '').toLowerCase();
146
+ const enabled = mode === 'basic' || env.SAP_AI_DEV_TOOLKIT_SSO_BASIC_FALLBACK === 'true';
175
147
  const user = String(env.SAP_USER || env.SAP_USERNAME || '').trim();
176
148
  const password = String(env.SAP_PASSWORD || env.SAP_PASS || '');
177
149
  if (!enabled || !user || !password) return null;
@@ -179,7 +151,8 @@ function basicFallbackCredentials(env) {
179
151
  }
180
152
 
181
153
  function hasBasicChallenge(headers) {
182
- return hasAuthChallenge(headers, 'Basic');
154
+ const value = headers.get?.('www-authenticate') || '';
155
+ return /(?:^|,)\s*Basic\b/i.test(value);
183
156
  }
184
157
 
185
158
  async function retryWithBasicFallback({ req, target, headers, body, fetchImpl, env }) {
@@ -243,9 +216,7 @@ export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.e
243
216
  if (cookies) retryHeaders.Cookie = cookies;
244
217
  response = await forward(req, target, retryHeaders, body, fetchImpl);
245
218
  } catch (error) {
246
- response = hasBasicChallenge(response.headers)
247
- ? await retryWithBasicFallback({ req, target, headers: { ...headers, ...(cookies ? { Cookie: cookies } : {}) }, body, fetchImpl, env })
248
- : null;
219
+ response = await retryWithBasicFallback({ req, target, headers: { ...headers, ...(cookies ? { Cookie: cookies } : {}) }, body, fetchImpl, env });
249
220
  if (!response) throw error;
250
221
  }
251
222
  } else if (response.status === 401 && hasBasicChallenge(response.headers) && basicFallbackCredentials(env)) {