sap-ai-dev-toolkit 0.2.5
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 +25 -0
- package/.github/agents/abap-runtime-debugger.agent.md +27 -0
- package/.github/agents/rap-service-developer.agent.md +27 -0
- package/.github/agents/sap-solution-architect.agent.md +31 -0
- package/.github/skills/abap-debugging/SKILL.md +15 -0
- package/.github/skills/abap-development/SKILL.md +16 -0
- package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
- package/.github/skills/abap-testing-quality/SKILL.md +19 -0
- package/.github/skills/cds-development/SKILL.md +16 -0
- package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
- package/.github/skills/rap-development/SKILL.md +16 -0
- package/.github/skills/rap-service-delivery/SKILL.md +17 -0
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
- package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
- package/.github/skills/sap-transport-release/SKILL.md +14 -0
- package/LICENSE +22 -0
- package/LICENSE-APACHE-2.0.txt +201 -0
- package/NOTICE +33 -0
- package/README.md +723 -0
- package/dist/checksums.txt +5 -0
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +52 -0
- package/patches/vsp-bas-proxy-auth.patch +310 -0
- package/scripts/build-vsp.mjs +52 -0
- package/scripts/ensure-go.mjs +59 -0
- package/scripts/install-user-copilot-assets.mjs +110 -0
- package/scripts/mutation-terminal-ui.mjs +69 -0
- package/scripts/postinstall.mjs +269 -0
- package/scripts/publish-npm.mjs +97 -0
- package/src/abaplint.mjs +105 -0
- package/src/bas-discovery.mjs +210 -0
- package/src/binary.mjs +99 -0
- package/src/cf-connectivity.mjs +317 -0
- package/src/cf-destination.mjs +793 -0
- package/src/launcher.mjs +144 -0
- package/src/mcp-config.mjs +253 -0
- package/src/mcp-proxy.mjs +388 -0
- package/src/setup.mjs +275 -0
- package/src/terminal-ui.mjs +67 -0
- package/test/cf-connectivity.test.mjs +287 -0
- package/test/cf-destination.test.mjs +307 -0
- package/test/cf-runtime.test.mjs +505 -0
- package/test/copilot-assets.test.mjs +37 -0
- package/test/copilot-content.test.mjs +145 -0
- package/test/discovery.test.mjs +199 -0
- package/test/fixtures/fake-vsp.mjs +244 -0
- package/test/launcher.test.mjs +164 -0
- package/test/mcp-config-cf.test.mjs +149 -0
- package/test/mcp-proxy.test.mjs +337 -0
- package/test/setup-cf.test.mjs +362 -0
- package/test/setup.test.mjs +376 -0
- package/test/terminal-ui.test.mjs +47 -0
- package/tools.md +68 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
78c0f6b7adf151a4c83ee3a757752aa409225fc75af64dfe728aed68dc5cf598 vsp-linux-x64
|
|
2
|
+
4ceaf3ef3519431aa5ebe7d7c14569cf7c7035956dbfc9342c8fa9b6343a5380 vsp-linux-arm64
|
|
3
|
+
f8d075f1ef955f8bb4be5b1d9ec3ffc86e84349b8a141265cf39e74c9fd83246 vsp-darwin-x64
|
|
4
|
+
25e19e0d7317fe5adcb3c670b5fcf1da1caafa3c1bf8a6783c4ff00b312b5194 vsp-darwin-arm64
|
|
5
|
+
c67c921ae884abeacaa39782c5524f58ec58bdbccc0d7097ee8a2499c04331ac vsp-win32-x64.exe
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sap-ai-dev-toolkit",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, Fiori, UI5, and MCP",
|
|
5
|
+
"author": "Gurkan Yilmaz",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@abaplint/core": "2.119.0",
|
|
12
|
+
"@inquirer/checkbox": "4.2.0",
|
|
13
|
+
"undici": "^6.21.3"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"sap-ai-dev": "src/launcher.mjs"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"test:visibility": "node --test test/setup.test.mjs test/terminal-ui.test.mjs",
|
|
22
|
+
"test:mutation": "node scripts/mutation-terminal-ui.mjs",
|
|
23
|
+
"publish:npm": "node scripts/publish-npm.mjs",
|
|
24
|
+
"ensure:go": "node scripts/ensure-go.mjs",
|
|
25
|
+
"build:vsp": "node scripts/build-vsp.mjs"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/grknylmz/sap-ai-dev-toolkit.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/grknylmz/sap-ai-dev-toolkit#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/grknylmz/sap-ai-dev-toolkit/issues"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"scripts/",
|
|
39
|
+
"patches/",
|
|
40
|
+
"dist/",
|
|
41
|
+
"test/",
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"NOTICE",
|
|
44
|
+
"LICENSE-APACHE-2.0.txt",
|
|
45
|
+
"tools.md",
|
|
46
|
+
".github/agents/",
|
|
47
|
+
".github/skills/"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
diff --git a/cmd/vsp/main.go b/cmd/vsp/main.go
|
|
2
|
+
--- a/cmd/vsp/main.go
|
|
3
|
+
+++ b/cmd/vsp/main.go
|
|
4
|
+
@@ -102,1 +102,2 @@
|
|
5
|
+
// Authentication flags
|
|
6
|
+
+ rootCmd.Flags().BoolVar(&cfg.ProxyAuth, "proxy-auth", false, "Use BAS destination proxy authentication; do not send local credentials")
|
|
7
|
+
@@ -172,1 +172,2 @@
|
|
8
|
+
// Bind flags to viper for environment variable support
|
|
9
|
+
+ viper.BindPFlag("proxy-auth", rootCmd.Flags().Lookup("proxy-auth"))
|
|
10
|
+
@@ -235,1 +235,4 @@
|
|
11
|
+
// Browser-based SSO authentication (must run before processCookieAuth)
|
|
12
|
+
+ if err := validateProxyAuth(cmd); err != nil {
|
|
13
|
+
+ return err
|
|
14
|
+
+ }
|
|
15
|
+
@@ -348,1 +348,2 @@
|
|
16
|
+
func resolveConfig(cmd *cobra.Command) {
|
|
17
|
+
+ cfg.ProxyAuth = cfg.ProxyAuth || viper.GetBool("PROXY_AUTH")
|
|
18
|
+
@@ -533,1 +533,19 @@
|
|
19
|
+
+func validateProxyAuth(cmd *cobra.Command) error {
|
|
20
|
+
+ if !cfg.ProxyAuth {
|
|
21
|
+
+ return nil
|
|
22
|
+
+ }
|
|
23
|
+
+ for _, flag := range []string{"user", "password", "pass", "cookie-file", "cookie-string", "browser-auth", "sso", "saml-auth", "saml-user", "saml-password", "credential-cmd"} {
|
|
24
|
+
+ if cmd.Flags().Changed(flag) {
|
|
25
|
+
+ return fmt.Errorf("--proxy-auth cannot be combined with local authentication option --%s", flag)
|
|
26
|
+
+ }
|
|
27
|
+
+ }
|
|
28
|
+
+ for _, key := range []string{"USER", "USERNAME", "PASSWORD", "PASS", "COOKIE_FILE", "COOKIE_STRING", "BROWSER_AUTH", "SSO", "SAML_AUTH", "SAML_USER", "SAML_PASSWORD", "CREDENTIAL_CMD"} {
|
|
29
|
+
+ if os.Getenv("SAP_"+key) != "" {
|
|
30
|
+
+ return fmt.Errorf("--proxy-auth cannot be combined with SAP_%s; BAS supplies destination authentication", key)
|
|
31
|
+
+ }
|
|
32
|
+
+ }
|
|
33
|
+
+ cfg.Username, cfg.Password = "", ""
|
|
34
|
+
+ cfg.Cookies = nil
|
|
35
|
+
+ return nil
|
|
36
|
+
+}
|
|
37
|
+
func processBrowserAuth(cmd *cobra.Command) error {
|
|
38
|
+
@@ -601,1 +601,4 @@
|
|
39
|
+
func processSSOAuth(cmd *cobra.Command) error {
|
|
40
|
+
+ if cfg.ProxyAuth {
|
|
41
|
+
+ return nil
|
|
42
|
+
+ }
|
|
43
|
+
@@ -659,1 +659,4 @@
|
|
44
|
+
func processSAMLAuth(cmd *cobra.Command) error {
|
|
45
|
+
+ if cfg.ProxyAuth {
|
|
46
|
+
+ return nil
|
|
47
|
+
+ }
|
|
48
|
+
@@ -754,1 +754,4 @@
|
|
49
|
+
func processCookieAuth(cmd *cobra.Command) error {
|
|
50
|
+
+ if cfg.ProxyAuth {
|
|
51
|
+
+ return nil
|
|
52
|
+
+ }
|
|
53
|
+
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
|
|
54
|
+
--- a/internal/mcp/server.go
|
|
55
|
+
+++ b/internal/mcp/server.go
|
|
56
|
+
@@ -71,2 +71,5 @@
|
|
57
|
+
// Cookie authentication (alternative to basic auth)
|
|
58
|
+
+ // ProxyAuth delegates identity to a BAS destination proxy. Local credentials are rejected.
|
|
59
|
+
+ ProxyAuth bool
|
|
60
|
+
+
|
|
61
|
+
Cookies map[string]string
|
|
62
|
+
@@ -154,1 +154,4 @@
|
|
63
|
+
+ if cfg.ProxyAuth {
|
|
64
|
+
+ opts = append(opts, adt.WithProxyContextIDGuard())
|
|
65
|
+
+ }
|
|
66
|
+
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 > 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
|
+
+}
|
|
173
|
+
diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go
|
|
174
|
+
--- a/internal/mcp/server_test.go
|
|
175
|
+
+++ b/internal/mcp/server_test.go
|
|
176
|
+
@@ -159,3 +159,112 @@ func TestDebuggerGetVariablesSchemaIncludesItems(t *testing.T) {
|
|
177
|
+
t.Fatalf("expected variableIDs.items.type to be 'string', got %v", items["type"])
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
+
|
|
181
|
+
+func TestActivateMultipleObjectsSchemaIncludesItems(t *testing.T) {
|
|
182
|
+
+ cfg := &Config{
|
|
183
|
+
+ BaseURL: "https://sap.example.com:44300",
|
|
184
|
+
+ Username: "testuser",
|
|
185
|
+
+ Password: "testpass",
|
|
186
|
+
+ Client: "001",
|
|
187
|
+
+ Language: "EN",
|
|
188
|
+
+ Mode: "expert",
|
|
189
|
+
+ }
|
|
190
|
+
+
|
|
191
|
+
+ server := NewServer(cfg)
|
|
192
|
+
+ if server == nil || server.mcpServer == nil {
|
|
193
|
+
+ t.Fatal("server or MCP server is nil")
|
|
194
|
+
+ }
|
|
195
|
+
+
|
|
196
|
+
+ rawResponse := server.mcpServer.HandleMessage(context.Background(), []byte(`{
|
|
197
|
+
+ "jsonrpc": "2.0",
|
|
198
|
+
+ "id": 1,
|
|
199
|
+
+ "method": "tools/list",
|
|
200
|
+
+ "params": {}
|
|
201
|
+
+ }`))
|
|
202
|
+
+ response, ok := rawResponse.(mcp.JSONRPCResponse)
|
|
203
|
+
+ if !ok {
|
|
204
|
+
+ t.Fatalf("expected JSONRPCResponse, got %T", rawResponse)
|
|
205
|
+
+ }
|
|
206
|
+
+
|
|
207
|
+
+ var tools []mcp.Tool
|
|
208
|
+
+ switch result := response.Result.(type) {
|
|
209
|
+
+ case mcp.ListToolsResult:
|
|
210
|
+
+ tools = result.Tools
|
|
211
|
+
+ case *mcp.ListToolsResult:
|
|
212
|
+
+ tools = result.Tools
|
|
213
|
+
+ default:
|
|
214
|
+
+ t.Fatalf("expected ListToolsResult, got %T", response.Result)
|
|
215
|
+
+ }
|
|
216
|
+
+
|
|
217
|
+
+ var activateMultipleTool *mcp.Tool
|
|
218
|
+
+ for i := range tools {
|
|
219
|
+
+ if tools[i].Name == "ActivateMultiple" {
|
|
220
|
+
+ activateMultipleTool = &tools[i]
|
|
221
|
+
+ break
|
|
222
|
+
+ }
|
|
223
|
+
+ }
|
|
224
|
+
+ if activateMultipleTool == nil {
|
|
225
|
+
+ t.Fatal("ActivateMultiple tool not found")
|
|
226
|
+
+ }
|
|
227
|
+
+
|
|
228
|
+
+ objectsRaw, ok := activateMultipleTool.InputSchema.Properties["objects"]
|
|
229
|
+
+ if !ok {
|
|
230
|
+
+ t.Fatal("objects property not found in ActivateMultiple schema")
|
|
231
|
+
+ }
|
|
232
|
+
+ objects, ok := objectsRaw.(map[string]interface{})
|
|
233
|
+
+ if !ok {
|
|
234
|
+
+ t.Fatalf("expected objects schema to be map[string]interface{}, got %T", objectsRaw)
|
|
235
|
+
+ }
|
|
236
|
+
+ if objects["type"] != "array" {
|
|
237
|
+
+ t.Fatalf("expected objects type to be 'array', got %v", objects["type"])
|
|
238
|
+
+ }
|
|
239
|
+
+
|
|
240
|
+
+ itemsRaw, ok := objects["items"]
|
|
241
|
+
+ if !ok {
|
|
242
|
+
+ t.Fatal("objects array schema is missing items")
|
|
243
|
+
+ }
|
|
244
|
+
+ items, ok := itemsRaw.(map[string]interface{})
|
|
245
|
+
+ if !ok {
|
|
246
|
+
+ t.Fatalf("expected objects.items to be map[string]interface{}, got %T", itemsRaw)
|
|
247
|
+
+ }
|
|
248
|
+
+ anyOf, ok := items["anyOf"].([]interface{})
|
|
249
|
+
+ if !ok || len(anyOf) != 2 {
|
|
250
|
+
+ t.Fatalf("expected objects.items.anyOf to contain string and object variants, got %v", items["anyOf"])
|
|
251
|
+
+ }
|
|
252
|
+
+ hasObject, hasString := false, false
|
|
253
|
+
+ for _, variant := range anyOf {
|
|
254
|
+
+ schema, ok := variant.(map[string]interface{})
|
|
255
|
+
+ if !ok {
|
|
256
|
+
+ t.Fatalf("expected item variant to be a schema object, got %T", variant)
|
|
257
|
+
+ }
|
|
258
|
+
+ switch schema["type"] {
|
|
259
|
+
+ case "object":
|
|
260
|
+
+ hasObject = true
|
|
261
|
+
+ properties, ok := schema["properties"].(map[string]interface{})
|
|
262
|
+
+ if !ok {
|
|
263
|
+
+ t.Fatal("object item schema is missing properties")
|
|
264
|
+
+ }
|
|
265
|
+
+ for _, propertyName := range []string{"url", "name"} {
|
|
266
|
+
+ propertyRaw, ok := properties[propertyName]
|
|
267
|
+
+ if !ok {
|
|
268
|
+
+ t.Fatalf("object item schema is missing %q", propertyName)
|
|
269
|
+
+ }
|
|
270
|
+
+ property, ok := propertyRaw.(map[string]interface{})
|
|
271
|
+
+ if !ok || property["type"] != "string" {
|
|
272
|
+
+ t.Fatalf("object item %q must have type string", propertyName)
|
|
273
|
+
+ }
|
|
274
|
+
+ }
|
|
275
|
+
+ required, ok := schema["required"].([]string)
|
|
276
|
+
+ if !ok || len(required) != 2 || required[0] != "url" || required[1] != "name" {
|
|
277
|
+
+ t.Fatalf("object item schema must require url and name, got %v", schema["required"])
|
|
278
|
+
+ }
|
|
279
|
+
+ case "string":
|
|
280
|
+
+ hasString = true
|
|
281
|
+
+ default:
|
|
282
|
+
+ t.Fatalf("unexpected ActivateMultiple item type %v", schema["type"])
|
|
283
|
+
+ }
|
|
284
|
+
+ }
|
|
285
|
+
+ if !hasObject || !hasString {
|
|
286
|
+
+ t.Fatalf("expected object and string item variants, got object=%t string=%t", hasObject, hasString)
|
|
287
|
+
+ }
|
|
288
|
+
+}
|
|
289
|
+
diff --git a/internal/mcp/tools_register.go b/internal/mcp/tools_register.go
|
|
290
|
+
--- a/internal/mcp/tools_register.go
|
|
291
|
+
+++ b/internal/mcp/tools_register.go
|
|
292
|
+
@@ -837,6 +837,19 @@ func (s *Server) registerDevTools(shouldRegister func(string) bool) {
|
|
293
|
+
mcp.Description(`Objects to activate. Each item can be:
|
|
294
|
+
- {"url": "/sap/bc/adt/programs/programs/zprog", "name": "ZPROG"}
|
|
295
|
+
- "TYPE NAME" shorthand, e.g. "PROG ZPROG", "INCL ZPROG_TOP", "CLAS ZCL_X"`),
|
|
296
|
+
+ mcp.Items(map[string]any{
|
|
297
|
+
+ "anyOf": []any{
|
|
298
|
+
+ map[string]any{
|
|
299
|
+
+ "type": "object",
|
|
300
|
+
+ "properties": map[string]any{
|
|
301
|
+
+ "url": map[string]any{"type": "string"},
|
|
302
|
+
+ "name": map[string]any{"type": "string"},
|
|
303
|
+
+ },
|
|
304
|
+
+ "required": []string{"url", "name"},
|
|
305
|
+
+ },
|
|
306
|
+
+ map[string]any{"type": "string"},
|
|
307
|
+
+ },
|
|
308
|
+
+ }),
|
|
309
|
+
),
|
|
310
|
+
), s.handleActivateMultiple)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { ensureGo } from './ensure-go.mjs';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
const source = join(root, '.build', 'vibing-steampunk');
|
|
12
|
+
const dist = join(root, 'dist');
|
|
13
|
+
const commit = '9886d2727f47506368b0a3c2f1c1766f1200f747';
|
|
14
|
+
const upstream = 'https://github.com/oisee/vibing-steampunk.git';
|
|
15
|
+
const targets = [
|
|
16
|
+
['linux', 'amd64', 'linux', 'x64', ''],
|
|
17
|
+
['linux', 'arm64', 'linux', 'arm64', ''],
|
|
18
|
+
['darwin', 'amd64', 'darwin', 'x64', ''],
|
|
19
|
+
['darwin', 'arm64', 'darwin', 'arm64', ''],
|
|
20
|
+
['windows', 'amd64', 'win32', 'x64', '.exe']
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
async function run(command, args, options = {}) {
|
|
24
|
+
return exec(command, args, { cwd: root, stdio: 'inherit', ...options });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
29
|
+
if (!pkg.repository?.url) throw new Error('package.json repository.url is required before publishing binaries');
|
|
30
|
+
const goPath = await ensureGo();
|
|
31
|
+
await rm(join(root, '.build'), { recursive: true, force: true });
|
|
32
|
+
await mkdir(dirname(source), { recursive: true });
|
|
33
|
+
await run('git', ['clone', '--filter=blob:none', upstream, source]);
|
|
34
|
+
await run('git', ['-C', source, 'checkout', '--detach', commit]);
|
|
35
|
+
await run('patch', ['-p1', '-i', join(root, 'patches', 'vsp-bas-proxy-auth.patch')], { cwd: source });
|
|
36
|
+
await mkdir(dist, { recursive: true });
|
|
37
|
+
const checksums = [];
|
|
38
|
+
for (const [goos, goarch, os, arch, extension] of targets) {
|
|
39
|
+
const output = join(dist, `vsp-${os}-${arch}${extension}`);
|
|
40
|
+
await exec(goPath, ['build', '-trimpath', '-ldflags', `-s -w -X main.Commit=${commit}`, '-o', output, './cmd/vsp'], {
|
|
41
|
+
cwd: source,
|
|
42
|
+
env: { ...process.env, CGO_ENABLED: '0', GOOS: goos, GOARCH: goarch },
|
|
43
|
+
stdio: 'inherit'
|
|
44
|
+
});
|
|
45
|
+
const bytes = await (await import('node:fs/promises')).readFile(output);
|
|
46
|
+
checksums.push(`${createHash('sha256').update(bytes).digest('hex')} ${output.slice(dist.length + 1)}`);
|
|
47
|
+
}
|
|
48
|
+
await writeFile(join(dist, 'checksums.txt'), `${checksums.join('\n')}\n`);
|
|
49
|
+
console.error(`Built ${targets.length} patched VSP binaries at ${dist}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
main().catch(error => { console.error(`build-vsp: ${error.message}`); process.exitCode = 1; });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
3
|
+
import { chmod, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export const GO_VERSION = process.env.BAS_VSP_GO_VERSION || 'go1.27.1';
|
|
10
|
+
const GO_RELEASES = {
|
|
11
|
+
'linux:x64': { archive: `${GO_VERSION}.linux-amd64.tar.gz`, sha256: '63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445' },
|
|
12
|
+
'linux:arm64': { archive: `${GO_VERSION}.linux-arm64.tar.gz`, sha256: '3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec' }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function localGoPath() {
|
|
16
|
+
return join(process.env.BAS_VSP_GO_ROOT || join(homedir(), '.local', 'go'), GO_VERSION, 'bin', process.platform === 'win32' ? 'go.exe' : 'go');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function candidateGo() {
|
|
20
|
+
if (process.env.GO_BINARY) return process.env.GO_BINARY;
|
|
21
|
+
const result = spawnSync('go', ['version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
22
|
+
if (result.status === 0) return 'go';
|
|
23
|
+
const local = localGoPath();
|
|
24
|
+
const localResult = spawnSync(local, ['version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
25
|
+
return localResult.status === 0 ? local : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function installGo() {
|
|
29
|
+
const release = GO_RELEASES[`${process.platform}:${process.arch}`];
|
|
30
|
+
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.`);
|
|
31
|
+
const response = await fetch(`https://go.dev/dl/${release.archive}`);
|
|
32
|
+
if (!response.ok) throw new Error(`Go download failed with HTTP ${response.status}; install ${GO_VERSION} manually and ensure go is on PATH.`);
|
|
33
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
34
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
35
|
+
const expected = process.env.BAS_VSP_GO_SHA256 || release.sha256;
|
|
36
|
+
if (actual !== expected) throw new Error(`Go checksum mismatch for ${release.archive}`);
|
|
37
|
+
const root = join(process.env.BAS_VSP_GO_ROOT || join(homedir(), '.local', 'go'), GO_VERSION);
|
|
38
|
+
await mkdir(root, { recursive: true, mode: 0o755 });
|
|
39
|
+
const archive = join(tmpdir(), `${release.archive}.${process.pid}`);
|
|
40
|
+
await writeFile(archive, bytes, { mode: 0o600 });
|
|
41
|
+
await exec('tar', ['-xzf', archive, '-C', root, '--strip-components=1'], { stdio: 'inherit' });
|
|
42
|
+
await rm(archive, { force: true });
|
|
43
|
+
const goPath = join(root, 'bin', 'go');
|
|
44
|
+
await chmod(goPath, 0o755);
|
|
45
|
+
const userBin = join(homedir(), '.local', 'bin');
|
|
46
|
+
await mkdir(userBin, { recursive: true, mode: 0o755 });
|
|
47
|
+
try { await symlink(goPath, join(userBin, process.platform === 'win32' ? 'go.exe' : 'go')); } catch {}
|
|
48
|
+
return goPath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function ensureGo() {
|
|
52
|
+
const existing = candidateGo();
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
return installGo();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (process.argv[1]?.endsWith('ensure-go.mjs')) {
|
|
58
|
+
ensureGo().then(path => console.error(`Go available at ${path}`)).catch(error => { console.error(`ensure-go: ${error.message}`); process.exitCode = 1; });
|
|
59
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
const manifestName = '.sap-ai-dev-toolkit-assets.json';
|
|
9
|
+
|
|
10
|
+
function digest(content) {
|
|
11
|
+
return createHash('sha256').update(content).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function collectFiles(sourceRoot, targetPrefix, relativePath = '') {
|
|
15
|
+
const files = [];
|
|
16
|
+
const entries = await readdir(join(sourceRoot, relativePath), { withFileTypes: true });
|
|
17
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const childPath = join(relativePath, entry.name);
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
files.push(...await collectFiles(sourceRoot, targetPrefix, childPath));
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
files.push({ source: join(sourceRoot, childPath), target: join(targetPrefix, childPath) });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readManagedFiles(path) {
|
|
30
|
+
try {
|
|
31
|
+
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
32
|
+
if (manifest === null || typeof manifest !== 'object' || manifest.version !== 1 || manifest.files === null || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) return {};
|
|
33
|
+
return manifest.files;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function replaceFile(path, content) {
|
|
41
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(temporary, content, { flag: 'wx', mode: 0o644 });
|
|
44
|
+
await rename(temporary, path);
|
|
45
|
+
} finally {
|
|
46
|
+
await rm(temporary, { force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function installUserCopilotAssets({ home = process.env.HOME || homedir(), root = packageRoot } = {}) {
|
|
51
|
+
const sourceRoot = join(root, '.github');
|
|
52
|
+
const files = [
|
|
53
|
+
...await collectFiles(join(sourceRoot, 'agents'), 'agents'),
|
|
54
|
+
...await collectFiles(join(sourceRoot, 'skills'), 'skills')
|
|
55
|
+
];
|
|
56
|
+
if (!files.some(file => file.target === join('agents', 'abap-developer.agent.md'))) {
|
|
57
|
+
throw new Error('Packaged ABAP Developer agent profile is missing');
|
|
58
|
+
}
|
|
59
|
+
if (!files.some(file => file.target.startsWith(`skills${sep}`) && file.target.endsWith('SKILL.md'))) {
|
|
60
|
+
throw new Error('Packaged Agent Skills are missing');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const copilotRoot = join(home, '.copilot');
|
|
64
|
+
const manifestPath = join(copilotRoot, manifestName);
|
|
65
|
+
await mkdir(copilotRoot, { recursive: true, mode: 0o755 });
|
|
66
|
+
const previousFiles = await readManagedFiles(manifestPath);
|
|
67
|
+
const managedFiles = { ...previousFiles };
|
|
68
|
+
const conflicts = [];
|
|
69
|
+
let installed = 0;
|
|
70
|
+
let updated = 0;
|
|
71
|
+
let unchanged = 0;
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
const destination = join(copilotRoot, file.target);
|
|
75
|
+
const content = await readFile(file.source);
|
|
76
|
+
const contentHash = digest(content);
|
|
77
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
|
|
78
|
+
|
|
79
|
+
let existing;
|
|
80
|
+
try {
|
|
81
|
+
existing = await readFile(destination);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error.code !== 'ENOENT') throw error;
|
|
84
|
+
try {
|
|
85
|
+
await writeFile(destination, content, { flag: 'wx', mode: 0o644 });
|
|
86
|
+
managedFiles[file.target] = contentHash;
|
|
87
|
+
installed += 1;
|
|
88
|
+
continue;
|
|
89
|
+
} catch (writeError) {
|
|
90
|
+
if (writeError.code !== 'EEXIST') throw writeError;
|
|
91
|
+
existing = await readFile(destination);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const existingHash = digest(existing);
|
|
96
|
+
if (existingHash === contentHash) {
|
|
97
|
+
managedFiles[file.target] = contentHash;
|
|
98
|
+
unchanged += 1;
|
|
99
|
+
} else if (previousFiles[file.target] === existingHash) {
|
|
100
|
+
await replaceFile(destination, content);
|
|
101
|
+
managedFiles[file.target] = contentHash;
|
|
102
|
+
updated += 1;
|
|
103
|
+
} else {
|
|
104
|
+
conflicts.push(file.target);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
|
|
109
|
+
return { root: copilotRoot, installed, updated, unchanged, conflicts };
|
|
110
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdtemp, cp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const mutants = [
|
|
7
|
+
{
|
|
8
|
+
name: 'wide progress emoji returns',
|
|
9
|
+
mutate: source => source.replace("progress: { icon: '…', color: 'cyan' }", "progress: { icon: '⏳', color: 'cyan' }")
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'animated spinner frames return',
|
|
13
|
+
mutate: source => source.replace("spinner: { interval: 80, frames: [''] }", "spinner: { interval: 80, frames: ['⠇'] }")
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'prompt output no longer exposes terminal columns',
|
|
17
|
+
mutate: source => source.replace("for (const property of ['isTTY', 'columns', 'rows'])", "for (const property of ['isTTY', 'rows'])")
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'prompt output no longer exposes terminal rows',
|
|
21
|
+
mutate: source => source.replace("for (const property of ['isTTY', 'columns', 'rows'])", "for (const property of ['isTTY', 'columns'])")
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function run(command, args, options) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
|
+
let stdout = '';
|
|
29
|
+
let stderr = '';
|
|
30
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
31
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
32
|
+
child.on('error', reject);
|
|
33
|
+
child.on('exit', code => resolve({ code, stdout, stderr }));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const root = process.cwd();
|
|
38
|
+
let survived = 0;
|
|
39
|
+
|
|
40
|
+
for (const mutant of mutants) {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), 'bas-mutation-'));
|
|
42
|
+
try {
|
|
43
|
+
await cp(join(root, 'src'), join(directory, 'src'), { recursive: true });
|
|
44
|
+
await cp(join(root, 'test'), join(directory, 'test'), { recursive: true });
|
|
45
|
+
const path = join(directory, 'src', 'terminal-ui.mjs');
|
|
46
|
+
const original = await readFile(path, 'utf8');
|
|
47
|
+
const mutated = mutant.mutate(original);
|
|
48
|
+
if (mutated === original) throw new Error(`Mutant did not change source: ${mutant.name}`);
|
|
49
|
+
await writeFile(path, mutated);
|
|
50
|
+
|
|
51
|
+
const result = await run(process.execPath, ['--test', join(directory, 'test', 'terminal-ui.test.mjs')], { cwd: directory });
|
|
52
|
+
if (result.code === 0) {
|
|
53
|
+
survived += 1;
|
|
54
|
+
console.error(`SURVIVED: ${mutant.name}`);
|
|
55
|
+
console.error(result.stdout);
|
|
56
|
+
console.error(result.stderr);
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`KILLED: ${mutant.name}`);
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
await rm(directory, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (survived > 0) {
|
|
66
|
+
console.error(`${survived} mutation(s) survived`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log(`All ${mutants.length} terminal UI mutants killed.`);
|