sap-ai-dev-toolkit 0.9.97 → 0.9.9903
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/.github/agents/abap-developer.agent.md +2 -2
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/hana-cloud-hdi-specialist.agent.md +3 -2
- package/.github/agents/rap-service-developer.agent.md +4 -4
- package/.github/agents/sap-solution-architect.agent.md +3 -3
- package/.github/skills/abap-debugging/SKILL.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-runtime-analysis/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +2 -2
- package/.github/skills/cds-development/SKILL.md +1 -1
- package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
- package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
- package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +2 -2
- package/.github/skills/rap-service-delivery/SKILL.md +2 -2
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/.github/skills/sap-transport-release/SKILL.md +1 -1
- package/README.md +62 -73
- 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/inventory.md +25 -15
- package/package.json +1 -1
- package/patches/vsp-bas-proxy-auth.patch +342 -0
- package/scripts/build-vsp.mjs +2 -2
- package/scripts/postinstall.mjs +22 -11
- package/scripts/publish-npm.mjs +4 -4
- package/src/binary.mjs +14 -14
- package/src/launcher.mjs +19 -19
- package/src/local-sap-gui.mjs +46 -21
- package/src/mcp-config.mjs +35 -14
- package/src/mcp-proxy.mjs +26 -16
- package/src/setup.mjs +4 -1
- package/src/sso-browsers.mjs +1 -1
- package/src/terminal-ui.mjs +5 -3
- package/tools.md +15 -9
|
@@ -435,6 +435,190 @@ index 0000000..1ed3c2b
|
|
|
435
435
|
+ t.Errorf("got component %#v", got)
|
|
436
436
|
+ }
|
|
437
437
|
+}
|
|
438
|
+
diff --git a/pkg/adt/crud.go b/pkg/adt/crud.go
|
|
439
|
+
index f412fb1..15ac38a 100755
|
|
440
|
+
--- a/pkg/adt/crud.go
|
|
441
|
+
+++ b/pkg/adt/crud.go
|
|
442
|
+
@@ -1250,7 +1250,12 @@ func (c *Client) publishUnpublishServiceBinding(ctx context.Context, action, ser
|
|
443
|
+
<adtcore:objectReference adtcore:name="%s"/>
|
|
444
|
+
</adtcore:objectReferences>`, serviceName)
|
|
445
|
+
|
|
446
|
+
- path := fmt.Sprintf("/sap/bc/adt/businessservices/odatav2/%s", action)
|
|
447
|
+
+ // Publish jobs live under the binding's protocol; odatav2 alone cannot serve a V4 binding.
|
|
448
|
+
+ protocol := "odatav2"
|
|
449
|
+
+ if binding, err := c.GetSRVB(ctx, serviceName); err == nil && strings.EqualFold(binding.BindingVersion, "V4") {
|
|
450
|
+
+ protocol = "odatav4"
|
|
451
|
+
+ }
|
|
452
|
+
+ path := fmt.Sprintf("/sap/bc/adt/businessservices/%s/%s", protocol, action)
|
|
453
|
+
|
|
454
|
+
resp, err := c.transport.Request(ctx, path, &RequestOptions{
|
|
455
|
+
Method: http.MethodPost,
|
|
456
|
+
diff --git a/pkg/adt/features.go b/pkg/adt/features.go
|
|
457
|
+
index 13aa4b0..8fb7376 100755
|
|
458
|
+
--- a/pkg/adt/features.go
|
|
459
|
+
+++ b/pkg/adt/features.go
|
|
460
|
+
@@ -3,6 +3,7 @@ package adt
|
|
461
|
+
|
|
462
|
+
import (
|
|
463
|
+
"context"
|
|
464
|
+
+ "errors"
|
|
465
|
+
"fmt"
|
|
466
|
+
"io"
|
|
467
|
+
"net/http"
|
|
468
|
+
@@ -267,26 +268,11 @@ func (p *FeatureProber) probeAbapGit(ctx context.Context) (bool, string, error)
|
|
469
|
+
return false, "abapGit not found", nil
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
-// probeRAP checks if RAP development tools are available
|
|
473
|
+
+// probeRAP checks if RAP development tools are available.
|
|
474
|
+
+// DDLS predates RAP, so the behavior-definition collection is the signal.
|
|
475
|
+
func (p *FeatureProber) probeRAP(ctx context.Context) (bool, string, error) {
|
|
476
|
+
- // Check if DDLS endpoint exists
|
|
477
|
+
- resp, err := p.client.transport.Request(ctx, "/sap/bc/adt/ddic/ddl/sources", &RequestOptions{
|
|
478
|
+
- Method: http.MethodOptions,
|
|
479
|
+
- })
|
|
480
|
+
- if err != nil {
|
|
481
|
+
- // Check if it's a 404 vs connection error
|
|
482
|
+
- if strings.Contains(err.Error(), "404") {
|
|
483
|
+
- return false, "DDLS endpoint not available", nil
|
|
484
|
+
- }
|
|
485
|
+
- return false, "", err
|
|
486
|
+
- }
|
|
487
|
+
-
|
|
488
|
+
- // OPTIONS returning 200 or 405 means endpoint exists
|
|
489
|
+
- if resp.StatusCode == 200 || resp.StatusCode == 405 {
|
|
490
|
+
- return true, "RAP endpoints available", nil
|
|
491
|
+
- }
|
|
492
|
+
-
|
|
493
|
+
- return false, "RAP endpoints not responding", nil
|
|
494
|
+
+ return p.probeADTResource(ctx, "/sap/bc/adt/bo/behaviordefinitions",
|
|
495
|
+
+ "RAP endpoints available", "RAP behavior definition endpoint not available")
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// probeAMDP checks if AMDP debugging is available
|
|
499
|
+
@@ -324,44 +310,35 @@ func (p *FeatureProber) probeAMDP(ctx context.Context) (bool, string, error) {
|
|
500
|
+
return false, "AMDP debugger not responding", nil
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
-// probeUI5 checks if UI5/Fiori BSP management is available
|
|
504
|
+
+// probeUI5 checks if the UI5/Fiori BSP repository is available
|
|
505
|
+
func (p *FeatureProber) probeUI5(ctx context.Context) (bool, string, error) {
|
|
506
|
+
- // Check if UI5 repository endpoint exists
|
|
507
|
+
- resp, err := p.client.transport.Request(ctx, "/sap/bc/adt/filestore/ui5-bsp", &RequestOptions{
|
|
508
|
+
- Method: http.MethodOptions,
|
|
509
|
+
- })
|
|
510
|
+
- if err != nil {
|
|
511
|
+
- if strings.Contains(err.Error(), "404") {
|
|
512
|
+
- return false, "UI5 BSP endpoint not available", nil
|
|
513
|
+
- }
|
|
514
|
+
- return false, "", err
|
|
515
|
+
- }
|
|
516
|
+
-
|
|
517
|
+
- if resp.StatusCode == 200 || resp.StatusCode == 405 {
|
|
518
|
+
- return true, "UI5 BSP repository available", nil
|
|
519
|
+
- }
|
|
520
|
+
-
|
|
521
|
+
- return false, "UI5 BSP not responding", nil
|
|
522
|
+
+ return p.probeADTResource(ctx, "/sap/bc/adt/filestore/ui5-bsp/objects",
|
|
523
|
+
+ "UI5 BSP repository available (read-only through ADT)", "UI5 BSP endpoint not available")
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// probeTransport checks if CTS transport management is available
|
|
527
|
+
func (p *FeatureProber) probeTransport(ctx context.Context) (bool, string, error) {
|
|
528
|
+
- // Check if transport endpoint exists
|
|
529
|
+
- resp, err := p.client.transport.Request(ctx, "/sap/bc/adt/cts/transports", &RequestOptions{
|
|
530
|
+
- Method: http.MethodOptions,
|
|
531
|
+
- })
|
|
532
|
+
- if err != nil {
|
|
533
|
+
- if strings.Contains(err.Error(), "404") {
|
|
534
|
+
- return false, "CTS endpoint not available", nil
|
|
535
|
+
- }
|
|
536
|
+
- return false, "", err
|
|
537
|
+
- }
|
|
538
|
+
+ return p.probeADTResource(ctx, "/sap/bc/adt/cts/transportrequests",
|
|
539
|
+
+ "CTS transport management available", "CTS endpoint not available")
|
|
540
|
+
+}
|
|
541
|
+
|
|
542
|
+
- if resp.StatusCode == 200 || resp.StatusCode == 405 {
|
|
543
|
+
- return true, "CTS transport management available", nil
|
|
544
|
+
+// probeADTResource reports whether the ADT resource at path is registered.
|
|
545
|
+
+// ADT answers 404 only for an unknown path; a registered resource refuses OPTIONS with 400 or 405.
|
|
546
|
+
+func (p *FeatureProber) probeADTResource(ctx context.Context, path, available, missing string) (bool, string, error) {
|
|
547
|
+
+ _, err := p.client.transport.Request(ctx, path, &RequestOptions{Method: http.MethodOptions})
|
|
548
|
+
+ if err == nil {
|
|
549
|
+
+ return true, available, nil
|
|
550
|
+
}
|
|
551
|
+
-
|
|
552
|
+
- return false, "CTS not responding", nil
|
|
553
|
+
+ var apiErr *APIError
|
|
554
|
+
+ if errors.As(err, &apiErr) {
|
|
555
|
+
+ switch apiErr.StatusCode {
|
|
556
|
+
+ case http.StatusBadRequest, http.StatusMethodNotAllowed:
|
|
557
|
+
+ return true, available, nil
|
|
558
|
+
+ case http.StatusNotFound:
|
|
559
|
+
+ return false, missing, nil
|
|
560
|
+
+ }
|
|
561
|
+
+ }
|
|
562
|
+
+ return false, "", err
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// FeatureSummary returns a human-readable summary of all features
|
|
566
|
+
diff --git a/pkg/adt/features_test.go b/pkg/adt/features_test.go
|
|
567
|
+
new file mode 100644
|
|
568
|
+
index 0000000..5e880bd
|
|
569
|
+
--- /dev/null
|
|
570
|
+
+++ b/pkg/adt/features_test.go
|
|
571
|
+
@@ -0,0 +1,50 @@
|
|
572
|
+
+package adt
|
|
573
|
+
+
|
|
574
|
+
+import (
|
|
575
|
+
+ "context"
|
|
576
|
+
+ "io"
|
|
577
|
+
+ "net/http"
|
|
578
|
+
+ "net/http/httptest"
|
|
579
|
+
+ "strings"
|
|
580
|
+
+ "testing"
|
|
581
|
+
+)
|
|
582
|
+
+
|
|
583
|
+
+// Measured on S/4HANA 2023 (7.58): OPTIONS on a registered ADT resource answers
|
|
584
|
+
+// 400 "HTTP method OPTIONS not supported"; an unknown path answers 404.
|
|
585
|
+
+func TestFeatureProbesReadADTResourceAnswers(t *testing.T) {
|
|
586
|
+
+ tests := []struct {
|
|
587
|
+
+ name string
|
|
588
|
+
+ feature FeatureID
|
|
589
|
+
+ path string
|
|
590
|
+
+ status int
|
|
591
|
+
+ available bool
|
|
592
|
+
+ message string
|
|
593
|
+
+ }{
|
|
594
|
+
+ {"RAP registered", FeatureRAP, "/sap/bc/adt/bo/behaviordefinitions", http.StatusBadRequest, true, "RAP endpoints available"},
|
|
595
|
+
+ {"RAP absent", FeatureRAP, "/sap/bc/adt/bo/behaviordefinitions", http.StatusNotFound, false, "RAP behavior definition endpoint not available"},
|
|
596
|
+
+ {"UI5 registered", FeatureUI5, "/sap/bc/adt/filestore/ui5-bsp/objects", http.StatusMethodNotAllowed, true, "UI5 BSP repository available (read-only through ADT)"},
|
|
597
|
+
+ {"UI5 absent", FeatureUI5, "/sap/bc/adt/filestore/ui5-bsp/objects", http.StatusNotFound, false, "UI5 BSP endpoint not available"},
|
|
598
|
+
+ {"CTS registered", FeatureTransport, "/sap/bc/adt/cts/transportrequests", http.StatusBadRequest, true, "CTS transport management available"},
|
|
599
|
+
+ {"CTS server error", FeatureTransport, "/sap/bc/adt/cts/transportrequests", http.StatusInternalServerError, false, "probe failed: ADT API error: status 500"},
|
|
600
|
+
+ }
|
|
601
|
+
+ for _, tt := range tests {
|
|
602
|
+
+ t.Run(tt.name, func(t *testing.T) {
|
|
603
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
604
|
+
+ if r.Method != http.MethodOptions || r.URL.Path != tt.path {
|
|
605
|
+
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
606
|
+
+ http.NotFound(w, r)
|
|
607
|
+
+ return
|
|
608
|
+
+ }
|
|
609
|
+
+ w.WriteHeader(tt.status)
|
|
610
|
+
+ _, _ = io.WriteString(w, "HTTP method OPTIONS not supported")
|
|
611
|
+
+ }))
|
|
612
|
+
+ defer server.Close()
|
|
613
|
+
+
|
|
614
|
+
+ prober := NewFeatureProber(NewClient(server.URL, "user", "pass"), DefaultFeatureConfig(), false)
|
|
615
|
+
+ got := prober.Probe(context.Background(), tt.feature)
|
|
616
|
+
+ if got.Available != tt.available || !strings.HasPrefix(got.Message, tt.message) {
|
|
617
|
+
+ t.Errorf("Probe(%s) = available %v, message %q; want %v, %q", tt.feature, got.Available, got.Message, tt.available, tt.message)
|
|
618
|
+
+ }
|
|
619
|
+
+ })
|
|
620
|
+
+ }
|
|
621
|
+
+}
|
|
438
622
|
diff --git a/pkg/adt/http.go b/pkg/adt/http.go
|
|
439
623
|
index 0b4d9f1..3473536 100755
|
|
440
624
|
--- a/pkg/adt/http.go
|
|
@@ -562,6 +746,65 @@ index 0000000..80741da
|
|
|
562
746
|
+ }
|
|
563
747
|
+}
|
|
564
748
|
+
|
|
749
|
+
diff --git a/pkg/adt/servicebinding_publish_test.go b/pkg/adt/servicebinding_publish_test.go
|
|
750
|
+
new file mode 100644
|
|
751
|
+
index 0000000..93c19f9
|
|
752
|
+
--- /dev/null
|
|
753
|
+
+++ b/pkg/adt/servicebinding_publish_test.go
|
|
754
|
+
@@ -0,0 +1,53 @@
|
|
755
|
+
+package adt
|
|
756
|
+
+
|
|
757
|
+
+import (
|
|
758
|
+
+ "context"
|
|
759
|
+
+ "fmt"
|
|
760
|
+
+ "io"
|
|
761
|
+
+ "net/http"
|
|
762
|
+
+ "net/http/httptest"
|
|
763
|
+
+ "strings"
|
|
764
|
+
+ "testing"
|
|
765
|
+
+)
|
|
766
|
+
+
|
|
767
|
+
+func TestPublishServiceBindingUsesTheBindingProtocol(t *testing.T) {
|
|
768
|
+
+ tests := []struct {
|
|
769
|
+
+ name string
|
|
770
|
+
+ status int
|
|
771
|
+
+ version string
|
|
772
|
+
+ want string
|
|
773
|
+
+ }{
|
|
774
|
+
+ {"V4 binding", http.StatusOK, "V4", "/sap/bc/adt/businessservices/odatav4/publishjobs"},
|
|
775
|
+
+ {"V2 binding", http.StatusOK, "V2", "/sap/bc/adt/businessservices/odatav2/publishjobs"},
|
|
776
|
+
+ {"unreadable binding", http.StatusNotFound, "", "/sap/bc/adt/businessservices/odatav2/publishjobs"},
|
|
777
|
+
+ }
|
|
778
|
+
+ for _, tt := range tests {
|
|
779
|
+
+ t.Run(tt.name, func(t *testing.T) {
|
|
780
|
+
+ var published string
|
|
781
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
782
|
+
+ switch {
|
|
783
|
+
+ case strings.Contains(r.URL.Path, "/discovery"):
|
|
784
|
+
+ w.Header().Set("X-CSRF-Token", "TOKEN")
|
|
785
|
+
+ case r.Method == http.MethodGet && r.URL.Path == "/sap/bc/adt/businessservices/bindings/ZUI_DEMO":
|
|
786
|
+
+ w.WriteHeader(tt.status)
|
|
787
|
+
+ fmt.Fprintf(w, `<srvb:serviceBinding xmlns:srvb="http://www.sap.com/adt/ddic/ServiceBindings" xmlns:adtcore="http://www.sap.com/adt/core" adtcore:name="ZUI_DEMO"><srvb:binding srvb:type="ODATA" srvb:version="%s" srvb:category="0"/></srvb:serviceBinding>`, tt.version)
|
|
788
|
+
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/publishjobs"):
|
|
789
|
+
+ published = r.URL.Path
|
|
790
|
+
+ _, _ = io.WriteString(w, `<asx:abap xmlns:asx="http://www.sap.com/abapxml"><asx:values><DATA><SEVERITY>OK</SEVERITY><SHORT_TEXT>Published</SHORT_TEXT></DATA></asx:values></asx:abap>`)
|
|
791
|
+
+ default:
|
|
792
|
+
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
793
|
+
+ http.NotFound(w, r)
|
|
794
|
+
+ }
|
|
795
|
+
+ }))
|
|
796
|
+
+ defer server.Close()
|
|
797
|
+
+
|
|
798
|
+
+ result, err := NewClient(server.URL, "user", "pass").PublishServiceBinding(context.Background(), "ZUI_DEMO", "0001")
|
|
799
|
+
+ if err != nil {
|
|
800
|
+
+ t.Fatalf("PublishServiceBinding: %v", err)
|
|
801
|
+
+ }
|
|
802
|
+
+ if published != tt.want || result.Severity != "OK" {
|
|
803
|
+
+ t.Errorf("published to %q with severity %q; want %q with OK", published, result.Severity, tt.want)
|
|
804
|
+
+ }
|
|
805
|
+
+ })
|
|
806
|
+
+ }
|
|
807
|
+
+}
|
|
565
808
|
diff --git a/pkg/adt/sso.go b/pkg/adt/sso.go
|
|
566
809
|
index d67c4e8..a03f740 100644
|
|
567
810
|
--- a/pkg/adt/sso.go
|
|
@@ -791,3 +1034,102 @@ index 8e5bed4..544b873 100644
|
|
|
791
1034
|
Headless: !headed,
|
|
792
1035
|
Timeout: p.timeout(headed),
|
|
793
1036
|
Insecure: p.cfg.Insecure,
|
|
1037
|
+
diff --git a/pkg/adt/ui5.go b/pkg/adt/ui5.go
|
|
1038
|
+
index 6d57bb3..425f1b1 100755
|
|
1039
|
+
--- a/pkg/adt/ui5.go
|
|
1040
|
+
+++ b/pkg/adt/ui5.go
|
|
1041
|
+
@@ -6,6 +6,7 @@ import (
|
|
1042
|
+
"fmt"
|
|
1043
|
+
"net/http"
|
|
1044
|
+
"net/url"
|
|
1045
|
+
+ "regexp"
|
|
1046
|
+
"strings"
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
@@ -117,9 +118,19 @@ func (c *Client) UI5ListApps(ctx context.Context, query string, maxResults int)
|
|
1050
|
+
return nil, fmt.Errorf("parsing UI5 apps list: %w", err)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
- // Convert Atom entries to UI5App
|
|
1054
|
+
- apps := make([]UI5App, 0, len(feed.Entries))
|
|
1055
|
+
+ // The ADT filestore ignores name and maxResults (S/4HANA 2023 returned all 2797 apps), so apply both here.
|
|
1056
|
+
+ var match *regexp.Regexp
|
|
1057
|
+
+ if query != "" {
|
|
1058
|
+
+ match = regexp.MustCompile("(?i)^" + strings.ReplaceAll(regexp.QuoteMeta(query), `\*`, ".*") + "$")
|
|
1059
|
+
+ }
|
|
1060
|
+
+ apps := make([]UI5App, 0, min(len(feed.Entries), maxResults))
|
|
1061
|
+
for _, entry := range feed.Entries {
|
|
1062
|
+
+ if len(apps) == maxResults {
|
|
1063
|
+
+ break
|
|
1064
|
+
+ }
|
|
1065
|
+
+ if match != nil && !match.MatchString(entry.Title) {
|
|
1066
|
+
+ continue
|
|
1067
|
+
+ }
|
|
1068
|
+
apps = append(apps, UI5App{
|
|
1069
|
+
Name: entry.Title,
|
|
1070
|
+
Description: entry.Summary,
|
|
1071
|
+
diff --git a/pkg/adt/ui5_list_filter_test.go b/pkg/adt/ui5_list_filter_test.go
|
|
1072
|
+
new file mode 100644
|
|
1073
|
+
index 0000000..1385d6d
|
|
1074
|
+
--- /dev/null
|
|
1075
|
+
+++ b/pkg/adt/ui5_list_filter_test.go
|
|
1076
|
+
@@ -0,0 +1,59 @@
|
|
1077
|
+
+package adt
|
|
1078
|
+
+
|
|
1079
|
+
+import (
|
|
1080
|
+
+ "context"
|
|
1081
|
+
+ "io"
|
|
1082
|
+
+ "net/http"
|
|
1083
|
+
+ "net/http/httptest"
|
|
1084
|
+
+ "testing"
|
|
1085
|
+
+)
|
|
1086
|
+
+
|
|
1087
|
+
+// SAP's ADT filestore answers with every app whatever name and maxResults say.
|
|
1088
|
+
+func TestUI5ListAppsFiltersWhatTheFilestoreIgnores(t *testing.T) {
|
|
1089
|
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
1090
|
+
+ if r.URL.Path != "/sap/bc/adt/filestore/ui5-bsp/objects" {
|
|
1091
|
+
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
|
1092
|
+
+ http.NotFound(w, r)
|
|
1093
|
+
+ return
|
|
1094
|
+
+ }
|
|
1095
|
+
+ w.Header().Set("Content-Type", "application/atom+xml")
|
|
1096
|
+
+ _, _ = io.WriteString(w, `<atom:feed xmlns:atom="http://www.w3.org/2005/Atom">`+
|
|
1097
|
+
+ `<atom:entry><atom:id>ZAPP_ONE</atom:id><atom:title>ZAPP_ONE</atom:title><atom:summary>One</atom:summary></atom:entry>`+
|
|
1098
|
+
+ `<atom:entry><atom:id>/UI2/LAUNCHPAD</atom:id><atom:title>/UI2/LAUNCHPAD</atom:title></atom:entry>`+
|
|
1099
|
+
+ `<atom:entry><atom:id>ZAPP_TWO</atom:id><atom:title>ZAPP_TWO</atom:title></atom:entry>`+
|
|
1100
|
+
+ `</atom:feed>`)
|
|
1101
|
+
+ }))
|
|
1102
|
+
+ defer server.Close()
|
|
1103
|
+
+ client := NewClient(server.URL, "user", "pass")
|
|
1104
|
+
+
|
|
1105
|
+
+ tests := []struct {
|
|
1106
|
+
+ query string
|
|
1107
|
+
+ maxResults int
|
|
1108
|
+
+ want []string
|
|
1109
|
+
+ }{
|
|
1110
|
+
+ {"z*", 1, []string{"ZAPP_ONE"}},
|
|
1111
|
+
+ {"Z*", 5, []string{"ZAPP_ONE", "ZAPP_TWO"}},
|
|
1112
|
+
+ {"*LAUNCH*", 5, []string{"/UI2/LAUNCHPAD"}},
|
|
1113
|
+
+ {"", 0, []string{"ZAPP_ONE", "/UI2/LAUNCHPAD", "ZAPP_TWO"}},
|
|
1114
|
+
+ }
|
|
1115
|
+
+ for _, tt := range tests {
|
|
1116
|
+
+ apps, err := client.UI5ListApps(context.Background(), tt.query, tt.maxResults)
|
|
1117
|
+
+ if err != nil {
|
|
1118
|
+
+ t.Fatalf("UI5ListApps(%q, %d): %v", tt.query, tt.maxResults, err)
|
|
1119
|
+
+ }
|
|
1120
|
+
+ var got []string
|
|
1121
|
+
+ for _, app := range apps {
|
|
1122
|
+
+ got = append(got, app.Name)
|
|
1123
|
+
+ }
|
|
1124
|
+
+ if len(got) != len(tt.want) {
|
|
1125
|
+
+ t.Errorf("UI5ListApps(%q, %d) = %v, want %v", tt.query, tt.maxResults, got, tt.want)
|
|
1126
|
+
+ continue
|
|
1127
|
+
+ }
|
|
1128
|
+
+ for i := range got {
|
|
1129
|
+
+ if got[i] != tt.want[i] {
|
|
1130
|
+
+ t.Errorf("UI5ListApps(%q, %d) = %v, want %v", tt.query, tt.maxResults, got, tt.want)
|
|
1131
|
+
+ break
|
|
1132
|
+
+ }
|
|
1133
|
+
+ }
|
|
1134
|
+
+ }
|
|
1135
|
+
+}
|
package/scripts/build-vsp.mjs
CHANGED
|
@@ -47,7 +47,7 @@ async function main() {
|
|
|
47
47
|
checksums.push(`${createHash('sha256').update(bytes).digest('hex')} ${output.slice(dist.length + 1)}`);
|
|
48
48
|
}
|
|
49
49
|
await writeFile(join(dist, 'checksums.txt'), `${checksums.join('\n')}\n`);
|
|
50
|
-
console.error(`Built ${targets.length}
|
|
50
|
+
console.error(`Built ${targets.length} runtime executables at ${dist}`);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
main().catch(error => { console.error(`build
|
|
53
|
+
main().catch(error => { console.error(`runtime build: ${error.message}`); process.exitCode = 1; });
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { open, readFile } from 'node:fs/promises';
|
|
2
2
|
import { closeSync, openSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { installBinary } from '../src/binary.mjs';
|
|
@@ -41,11 +42,21 @@ async function announce(message, tone = 'info') {
|
|
|
41
42
|
|
|
42
43
|
function openControllingTerminal() {
|
|
43
44
|
if (process.platform === 'win32') {
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
throw new Error('
|
|
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
|
+
}
|
|
49
60
|
}
|
|
50
61
|
const inputFd = openSync('/dev/tty', 'r');
|
|
51
62
|
let outputFd;
|
|
@@ -299,8 +310,8 @@ async function main() {
|
|
|
299
310
|
if (process.env.npm_config_ignore_scripts === 'true') return;
|
|
300
311
|
|
|
301
312
|
// Go is intentionally NOT provisioned here: the installed package never
|
|
302
|
-
// runs `go` (only the repository-only build
|
|
303
|
-
// download at install time was pure cost. The pinned, checksum-verified
|
|
313
|
+
// runs `go` (only the repository-only build script does), so a toolchain
|
|
314
|
+
// download at install time was pure cost. The pinned, checksum-verified runtime
|
|
304
315
|
// binary ships with the package; the download below is the fallback.
|
|
305
316
|
let binary = brandedEnvValue(runtimeEnv, 'BINARY');
|
|
306
317
|
try {
|
|
@@ -309,19 +320,19 @@ async function main() {
|
|
|
309
320
|
} else {
|
|
310
321
|
// The bundled binary resolves in milliseconds; the animation only
|
|
311
322
|
// appears when provisioning actually takes time (download fallback).
|
|
312
|
-
const stopBinaryProgress = startProgress('Preparing the pinned
|
|
323
|
+
const stopBinaryProgress = startProgress('Preparing the pinned runtime for this platform', { output: process.stderr, env: runtimeEnv });
|
|
313
324
|
try {
|
|
314
325
|
binary = await installBinary(pkg, { env: runtimeEnv });
|
|
315
326
|
stopBinaryProgress();
|
|
316
|
-
await announce('Pinned
|
|
327
|
+
await announce('Pinned runtime installed and ready.', 'success');
|
|
317
328
|
} catch (error) {
|
|
318
329
|
stopBinaryProgress();
|
|
319
|
-
throw new Error(`
|
|
330
|
+
throw new Error(`Runtime provisioning failed: ${error.message}`);
|
|
320
331
|
}
|
|
321
332
|
}
|
|
322
333
|
} catch (error) {
|
|
323
334
|
await announce(error.message, 'error');
|
|
324
|
-
await announce('Set SAP_AI_DEV_TOOLKIT_BINARY only when supplying a trusted prebuilt
|
|
335
|
+
await announce('Set SAP_AI_DEV_TOOLKIT_BINARY only when supplying a trusted prebuilt runtime executable.', 'info');
|
|
325
336
|
process.exitCode = 1;
|
|
326
337
|
return;
|
|
327
338
|
}
|
package/scripts/publish-npm.mjs
CHANGED
|
@@ -60,7 +60,7 @@ function runNpm(npmArgs, env) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// dist/ is gitignored, so a publish from a clean checkout silently ships no
|
|
63
|
-
//
|
|
63
|
+
// runtime executables and every `npm install` of it fails (the 0.7.0 incident):
|
|
64
64
|
// postinstall only downloads from a GitHub release as a fallback. The
|
|
65
65
|
// prepublishOnly hook in package.json builds the binaries and re-invokes this
|
|
66
66
|
// script with --verify-only, so an incomplete dist/ can never be published —
|
|
@@ -84,12 +84,12 @@ async function main() {
|
|
|
84
84
|
const patch = args.has('--patch');
|
|
85
85
|
if (dryRun && patch) throw new Error('--dry-run cannot be combined with --patch; it never changes the package version');
|
|
86
86
|
|
|
87
|
-
// Second half of the prepublishOnly hook (after build
|
|
87
|
+
// Second half of the prepublishOnly hook (after the build script ran): refuse
|
|
88
88
|
// to let an incomplete dist/ reach the registry.
|
|
89
89
|
if (args.has('--verify-only')) {
|
|
90
90
|
const missing = await missingDistBinaries();
|
|
91
|
-
if (missing.length > 0) throw new Error(`dist/checksums.txt is missing ${missing.join(', ')}; run
|
|
92
|
-
console.error('dist/checksums.txt lists every pinned
|
|
91
|
+
if (missing.length > 0) throw new Error(`dist/checksums.txt is missing ${missing.join(', ')}; run the package build script first`);
|
|
92
|
+
console.error('dist/checksums.txt lists every pinned runtime executable.');
|
|
93
93
|
return;
|
|
94
94
|
}
|
|
95
95
|
|
package/src/binary.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
7
|
|
|
8
8
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
-
// Single source of truth for the upstream
|
|
9
|
+
// Single source of truth for the pinned upstream commit; scripts/build-vsp.mjs
|
|
10
10
|
// imports this constant so the built artifact and the runtime cannot drift.
|
|
11
11
|
export const UPSTREAM_COMMIT = '9886d2727f47506368b0a3c2f1c1766f1200f747';
|
|
12
12
|
|
|
@@ -20,11 +20,11 @@ const platformMap = {
|
|
|
20
20
|
|
|
21
21
|
export function binaryTarget(platform = process.platform, arch = process.arch) {
|
|
22
22
|
const target = platformMap[`${platform}:${arch}`];
|
|
23
|
-
if (!target) throw new Error(`Unsupported platform ${platform}/${arch}. Set SAP_AI_DEV_TOOLKIT_BINARY or
|
|
23
|
+
if (!target) throw new Error(`Unsupported platform ${platform}/${arch}. Set SAP_AI_DEV_TOOLKIT_BINARY or run the package build script.`);
|
|
24
24
|
return { os: target[0], arch: target[1], extension: target[2], asset: `vsp-${target[0]}-${target[1]}${target[2]}` };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Every
|
|
27
|
+
// Every runtime asset the npm tarball must bundle; publish:npm refuses to ship a
|
|
28
28
|
// package whose dist/checksums.txt does not list all of them.
|
|
29
29
|
export function packagedBinaryAssets() {
|
|
30
30
|
return Object.keys(platformMap).map(key => binaryTarget(...key.split(':')).asset);
|
|
@@ -76,7 +76,7 @@ async function verifyAgainstPackagedChecksums(path, target) {
|
|
|
76
76
|
|
|
77
77
|
export function packageRepositoryUrl(pkg) {
|
|
78
78
|
const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
|
|
79
|
-
if (!raw) throw new Error('package.json repository.url is required to resolve
|
|
79
|
+
if (!raw) throw new Error('package.json repository.url is required to resolve runtime release assets');
|
|
80
80
|
return raw.replace(/^git\+/, '').replace(/\.git$/, '').replace(/\/$/, '');
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -114,13 +114,13 @@ export async function findBinary(pkg, options = {}) {
|
|
|
114
114
|
await stat(bundled);
|
|
115
115
|
const verified = await verifyAgainstPackagedChecksums(bundled, target);
|
|
116
116
|
if (verified === false) {
|
|
117
|
-
log(`bundled
|
|
117
|
+
log(`bundled runtime executable ${bundled} failed checksum verification; ignoring it`);
|
|
118
118
|
} else {
|
|
119
|
-
if (verified === null) log(`bundled
|
|
119
|
+
if (verified === null) log(`bundled runtime executable ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
120
120
|
return bundled;
|
|
121
121
|
}
|
|
122
122
|
} catch (error) {
|
|
123
|
-
log(`bundled
|
|
123
|
+
log(`bundled runtime executable not found at ${bundled} (${error.code || error.message})`);
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
@@ -128,9 +128,9 @@ export async function findBinary(pkg, options = {}) {
|
|
|
128
128
|
await stat(path);
|
|
129
129
|
const verified = await verifyAgainstPackagedChecksums(path, target);
|
|
130
130
|
if (verified === false) {
|
|
131
|
-
log(`cached
|
|
131
|
+
log(`cached runtime executable ${target.asset} failed checksum verification; ignoring it`);
|
|
132
132
|
} else {
|
|
133
|
-
if (verified === null) log(`cached
|
|
133
|
+
if (verified === null) log(`cached runtime executable ${target.asset} has no packaged checksum entry; using it unverified`);
|
|
134
134
|
return path;
|
|
135
135
|
}
|
|
136
136
|
} catch {}
|
|
@@ -141,7 +141,7 @@ export async function findBinary(pkg, options = {}) {
|
|
|
141
141
|
await stat(legacyPath);
|
|
142
142
|
const verified = await verifyAgainstPackagedChecksums(legacyPath, target);
|
|
143
143
|
if (verified === true) return legacyPath;
|
|
144
|
-
log(`legacy cached
|
|
144
|
+
log(`legacy cached runtime executable ${legacyPath} has no packaged checksum match; ignoring it`);
|
|
145
145
|
} catch {}
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
@@ -155,7 +155,7 @@ export async function installBinary(pkg, options = {}) {
|
|
|
155
155
|
await stat(bundled);
|
|
156
156
|
const target0 = binaryTarget(options.platform, options.arch);
|
|
157
157
|
if (await verifyAgainstPackagedChecksums(bundled, target0) !== false) return bundled;
|
|
158
|
-
throw new Error(`bundled
|
|
158
|
+
throw new Error(`bundled runtime executable ${target0.asset} failed checksum verification`);
|
|
159
159
|
} catch (error) {
|
|
160
160
|
if (String(error?.message || '').includes('checksum verification')) throw error;
|
|
161
161
|
}
|
|
@@ -169,7 +169,7 @@ export async function downloadBinary(pkg, options = {}) {
|
|
|
169
169
|
const base = releaseBaseUrl(pkg, pkg.version);
|
|
170
170
|
const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
|
|
171
171
|
const response = await fetch(assetUrl);
|
|
172
|
-
if (!response.ok) throw new Error(`
|
|
172
|
+
if (!response.ok) throw new Error(`Runtime binary download failed (${response.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
173
173
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
174
174
|
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
175
175
|
// Prefer the packaged checksum manifest (immutable per published version)
|
|
@@ -180,13 +180,13 @@ export async function downloadBinary(pkg, options = {}) {
|
|
|
180
180
|
if (!expected) {
|
|
181
181
|
const checksumUrl = `${base}/checksums.txt`;
|
|
182
182
|
const checksumResponse = await fetch(checksumUrl);
|
|
183
|
-
if (!checksumResponse.ok) throw new Error(`
|
|
183
|
+
if (!checksumResponse.ok) throw new Error(`Runtime checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
184
184
|
const checksumText = await checksumResponse.text();
|
|
185
185
|
expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0]?.toLowerCase();
|
|
186
186
|
expectedSource = 'release checksums.txt';
|
|
187
187
|
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
188
188
|
}
|
|
189
|
-
if (actual !== expected) throw new Error(`
|
|
189
|
+
if (actual !== expected) throw new Error(`Runtime checksum mismatch for ${target.asset} (verified against ${expectedSource})`);
|
|
190
190
|
const dir = cacheDirectory(env);
|
|
191
191
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
192
192
|
await chmod(dir, 0o700);
|