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.
Files changed (57) hide show
  1. package/.github/agents/abap-developer.agent.md +25 -0
  2. package/.github/agents/abap-runtime-debugger.agent.md +27 -0
  3. package/.github/agents/rap-service-developer.agent.md +27 -0
  4. package/.github/agents/sap-solution-architect.agent.md +31 -0
  5. package/.github/skills/abap-debugging/SKILL.md +15 -0
  6. package/.github/skills/abap-development/SKILL.md +16 -0
  7. package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
  8. package/.github/skills/abap-testing-quality/SKILL.md +19 -0
  9. package/.github/skills/cds-development/SKILL.md +16 -0
  10. package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
  11. package/.github/skills/rap-development/SKILL.md +16 -0
  12. package/.github/skills/rap-service-delivery/SKILL.md +17 -0
  13. package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
  14. package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
  15. package/.github/skills/sap-transport-release/SKILL.md +14 -0
  16. package/LICENSE +22 -0
  17. package/LICENSE-APACHE-2.0.txt +201 -0
  18. package/NOTICE +33 -0
  19. package/README.md +723 -0
  20. package/dist/checksums.txt +5 -0
  21. package/dist/vsp-darwin-arm64 +0 -0
  22. package/dist/vsp-darwin-x64 +0 -0
  23. package/dist/vsp-linux-arm64 +0 -0
  24. package/dist/vsp-linux-x64 +0 -0
  25. package/dist/vsp-win32-x64.exe +0 -0
  26. package/package.json +52 -0
  27. package/patches/vsp-bas-proxy-auth.patch +310 -0
  28. package/scripts/build-vsp.mjs +52 -0
  29. package/scripts/ensure-go.mjs +59 -0
  30. package/scripts/install-user-copilot-assets.mjs +110 -0
  31. package/scripts/mutation-terminal-ui.mjs +69 -0
  32. package/scripts/postinstall.mjs +269 -0
  33. package/scripts/publish-npm.mjs +97 -0
  34. package/src/abaplint.mjs +105 -0
  35. package/src/bas-discovery.mjs +210 -0
  36. package/src/binary.mjs +99 -0
  37. package/src/cf-connectivity.mjs +317 -0
  38. package/src/cf-destination.mjs +793 -0
  39. package/src/launcher.mjs +144 -0
  40. package/src/mcp-config.mjs +253 -0
  41. package/src/mcp-proxy.mjs +388 -0
  42. package/src/setup.mjs +275 -0
  43. package/src/terminal-ui.mjs +67 -0
  44. package/test/cf-connectivity.test.mjs +287 -0
  45. package/test/cf-destination.test.mjs +307 -0
  46. package/test/cf-runtime.test.mjs +505 -0
  47. package/test/copilot-assets.test.mjs +37 -0
  48. package/test/copilot-content.test.mjs +145 -0
  49. package/test/discovery.test.mjs +199 -0
  50. package/test/fixtures/fake-vsp.mjs +244 -0
  51. package/test/launcher.test.mjs +164 -0
  52. package/test/mcp-config-cf.test.mjs +149 -0
  53. package/test/mcp-proxy.test.mjs +337 -0
  54. package/test/setup-cf.test.mjs +362 -0
  55. package/test/setup.test.mjs +376 -0
  56. package/test/terminal-ui.test.mjs +47 -0
  57. 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 &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
+ +}
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.`);