sap-ai-dev-toolkit 0.3.3 → 0.3.7

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 (36) hide show
  1. package/.github/agents/abap-developer.agent.md +3 -1
  2. package/.github/agents/abap-runtime-debugger.agent.md +3 -1
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +22 -0
  4. package/.github/agents/rap-service-developer.agent.md +3 -1
  5. package/.github/agents/sap-solution-architect.agent.md +6 -3
  6. package/.github/skills/hana-cloud-inspection/SKILL.md +35 -0
  7. package/.github/skills/hana-cloud-native-development/SKILL.md +26 -0
  8. package/.github/skills/hana-cloud-validation/SKILL.md +26 -0
  9. package/README.md +50 -22
  10. package/package.json +9 -4
  11. package/scripts/postinstall.mjs +3 -3
  12. package/src/bas-destination-relay.mjs +24 -13
  13. package/src/credential-overrides.mjs +75 -0
  14. package/src/credentials-store.mjs +14 -5
  15. package/src/hana-config.mjs +170 -0
  16. package/src/hana-database.mjs +150 -0
  17. package/src/hana-inspector.mjs +114 -0
  18. package/src/hana-tools.mjs +277 -0
  19. package/src/launcher.mjs +10 -32
  20. package/src/mcp-config.mjs +10 -1
  21. package/src/mcp-proxy.mjs +61 -0
  22. package/src/setup.mjs +87 -61
  23. package/test/bas-destination-relay.test.mjs +50 -0
  24. package/test/cf-runtime.test.mjs +27 -5
  25. package/test/copilot-content.test.mjs +63 -0
  26. package/test/credentials-store.test.mjs +31 -0
  27. package/test/hana-config.test.mjs +168 -0
  28. package/test/hana-inspector-stdio.test.mjs +44 -0
  29. package/test/hana-tools.test.mjs +206 -0
  30. package/test/launcher.test.mjs +52 -0
  31. package/test/live-s4h.test.mjs +202 -0
  32. package/test/mcp-config-cf.test.mjs +40 -0
  33. package/test/mcp-proxy.test.mjs +17 -31
  34. package/test/setup-cf.test.mjs +131 -4
  35. package/test/setup.test.mjs +10 -6
  36. package/tools.md +58 -50
@@ -0,0 +1,202 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { once } from 'node:events';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
+ const LIVE = /^(1|true|yes)$/iu.test(process.env.SAP_AI_DEV_LIVE_S4H || '');
12
+ const DESTINATION = process.env.SAP_AI_DEV_LIVE_DESTINATION || 'S4H';
13
+ const SLUG = process.env.SAP_AI_DEV_LIVE_DESTINATION_SLUG || DESTINATION.toLowerCase().replace(/[^a-z0-9_-]+/gu, '-');
14
+ const REQUEST_TIMEOUT_MS = Number(process.env.SAP_AI_DEV_LIVE_TIMEOUT_MS || 45_000);
15
+
16
+ async function readConfiguredServer() {
17
+ if (/^(0|false|no)$/iu.test(process.env.SAP_AI_DEV_LIVE_USE_MCP_CONFIG || '')) return null;
18
+ const candidates = [
19
+ process.env.SAP_AI_DEV_MCP_CONFIG,
20
+ process.env.BAS_VSP_MCP_CONFIG,
21
+ join(homedir(), '.vscode', 'data', 'User', 'mcp.json'),
22
+ join(homedir(), '.vscode-server', 'data', 'User', 'mcp.json'),
23
+ join(homedir(), '.code-server', 'data', 'User', 'mcp.json')
24
+ ].filter(Boolean);
25
+ for (const path of candidates) {
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(await readFile(path, 'utf8'));
29
+ } catch {
30
+ continue;
31
+ }
32
+ const server = parsed?.servers?.[DESTINATION];
33
+ if (server?.command) return { path, server };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ class StdioMcpClient {
39
+ constructor(command, args, env) {
40
+ this.child = spawn(command, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
41
+ this.nextId = 1;
42
+ this.pending = new Map();
43
+ this.stdout = '';
44
+ this.stderr = '';
45
+ this.notifications = [];
46
+ this.child.stdout.setEncoding('utf8');
47
+ this.child.stderr.setEncoding('utf8');
48
+ this.child.stdout.on('data', chunk => this.#onStdout(chunk));
49
+ this.child.stderr.on('data', chunk => { this.stderr += chunk; });
50
+ this.child.on('exit', (code, signal) => {
51
+ const error = new Error(`MCP process exited (${code ?? signal})\n${this.stderr.slice(-4000)}`);
52
+ for (const pending of this.pending.values()) pending.reject(error);
53
+ this.pending.clear();
54
+ });
55
+ }
56
+
57
+ #onStdout(chunk) {
58
+ this.stdout += chunk;
59
+ let newline;
60
+ while ((newline = this.stdout.indexOf('\n')) >= 0) {
61
+ const line = this.stdout.slice(0, newline).trim();
62
+ this.stdout = this.stdout.slice(newline + 1);
63
+ if (!line) continue;
64
+ let message;
65
+ try {
66
+ message = JSON.parse(line);
67
+ } catch (error) {
68
+ for (const pending of this.pending.values()) pending.reject(new Error(`Invalid JSON-RPC line: ${line}`));
69
+ this.pending.clear();
70
+ return;
71
+ }
72
+ if (message.id === undefined || message.id === null) {
73
+ this.notifications.push(message);
74
+ continue;
75
+ }
76
+ const pending = this.pending.get(message.id);
77
+ if (!pending) continue;
78
+ this.pending.delete(message.id);
79
+ clearTimeout(pending.timeout);
80
+ if (message.error) pending.reject(Object.assign(new Error(message.error.message), { response: message }));
81
+ else pending.resolve(message.result);
82
+ }
83
+ }
84
+
85
+ request(method, params) {
86
+ const id = this.nextId++;
87
+ const payload = { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) };
88
+ return new Promise((resolve, reject) => {
89
+ const timeout = setTimeout(() => {
90
+ this.pending.delete(id);
91
+ reject(new Error(`Timed out waiting for ${method}\n${this.stderr.slice(-4000)}`));
92
+ }, REQUEST_TIMEOUT_MS);
93
+ this.pending.set(id, { resolve, reject, timeout });
94
+ this.child.stdin.write(`${JSON.stringify(payload)}\n`);
95
+ });
96
+ }
97
+
98
+ notify(method, params) {
99
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) })}\n`);
100
+ }
101
+
102
+ async close() {
103
+ this.child.stdin.end();
104
+ this.child.kill('SIGTERM');
105
+ await Promise.race([
106
+ once(this.child, 'exit').catch(() => {}),
107
+ new Promise(resolve => setTimeout(resolve, 1500))
108
+ ]);
109
+ if (!this.child.killed) this.child.kill('SIGKILL');
110
+ }
111
+ }
112
+
113
+ function textPayload(result) {
114
+ return (result?.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
115
+ }
116
+
117
+ async function withLiveClient(t) {
118
+ const configured = await readConfiguredServer();
119
+ const server = configured?.server;
120
+ const command = process.env.SAP_AI_DEV_LIVE_COMMAND || server?.command || process.execPath;
121
+ const args = process.env.SAP_AI_DEV_LIVE_COMMAND
122
+ ? (process.env.SAP_AI_DEV_LIVE_ARGS ? JSON.parse(process.env.SAP_AI_DEV_LIVE_ARGS) : [])
123
+ : (server?.command ? (server.args || []) : [launcher]);
124
+ const client = new StdioMcpClient(command, args, {
125
+ ...process.env,
126
+ ...(server?.env || {}),
127
+ SAP_AI_DEV_TOOLKIT_DESTINATION: server?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION || DESTINATION,
128
+ SAP_AI_DEV_TOOLKIT_MODE: process.env.SAP_AI_DEV_TOOLKIT_MODE || server?.env?.SAP_AI_DEV_TOOLKIT_MODE || 'expert',
129
+ SAP_ALLOW_TRANSPORTABLE_EDITS: process.env.SAP_ALLOW_TRANSPORTABLE_EDITS || server?.env?.SAP_ALLOW_TRANSPORTABLE_EDITS || 'true'
130
+ });
131
+ t.after(() => client.close());
132
+ const initialized = await client.request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'sap-ai-dev-live-test', version: '1.0.0' } });
133
+ client.notify('notifications/initialized');
134
+ assert.equal(initialized.serverInfo.name, DESTINATION);
135
+ return client;
136
+ }
137
+
138
+ test('live S4H MCP exposes a curated ABAP lifecycle surface under 60 tools', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
139
+ const client = await withLiveClient(t);
140
+ const listed = await client.request('tools/list', {});
141
+ const tools = listed.tools || [];
142
+ const names = new Set(tools.map(tool => tool.name));
143
+ const prefix = `${SLUG}__`;
144
+
145
+ assert.ok(tools.length > 25, `expected a useful ABAP lifecycle surface, got ${tools.length}`);
146
+ assert.ok(tools.length < 60, `curated destination tool count must stay below 60, got ${tools.length}`);
147
+ assert.ok(tools.every(tool => tool.name.startsWith(prefix)), 'every tool is destination-prefixed for the selected S4H server');
148
+
149
+ for (const required of [
150
+ 'LintABAP', 'GetSystemInfo', 'GetConnectionInfo', 'GetFeatures',
151
+ 'SearchObject', 'GrepObjects', 'GrepPackages', 'GetSource', 'WriteSource', 'EditSource', 'CompareSource',
152
+ 'SyntaxCheck', 'PrettyPrint', 'Activate', 'RunUnitTests', 'RunATCCheck', 'GetInactiveObjects',
153
+ 'GetClass', 'GetProgram', 'GetPackage', 'GetTable', 'GetTableContents', 'RunQuery',
154
+ 'GetTransport', 'GetTransportInfo', 'ListTransports', 'CreateTransport',
155
+ 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet', 'CheckTransportReadiness', 'PlanABAPCloudMigration'
156
+ ]) {
157
+ assert.ok(names.has(`${prefix}${required}`), `expected curated lifecycle tool ${required}`);
158
+ }
159
+
160
+ for (const hidden of [
161
+ 'SAP', 'ReleaseTransport', 'DeleteTransport', 'DeleteObject', 'ExecuteABAP', 'CallRFC',
162
+ 'ListSQLTraces', 'AnalyzeABAPCode', 'CreateObject', 'CreateClassWithTests', 'WriteClass', 'UpdateSource'
163
+ ]) {
164
+ assert.equal(names.has(`${prefix}${hidden}`), false, `broad/destructive tool ${hidden} must remain hidden`);
165
+ }
166
+ });
167
+
168
+ test('live S4H MCP handles safe local and read-only SAP calls', { skip: LIVE ? false : 'set SAP_AI_DEV_LIVE_S4H=1 to run against a live BAS/S4H destination' }, async t => {
169
+ const client = await withLiveClient(t);
170
+ const prefix = `${SLUG}__`;
171
+ await client.request('tools/list', {});
172
+
173
+ const lint = await client.request('tools/call', {
174
+ name: `${prefix}LintABAP`,
175
+ arguments: { files: [{ filename: 'zlive_probe.prog.abap', source: "REPORT zlive_probe.\nWRITE / 'ok'.\n" }] }
176
+ });
177
+ const lintJson = JSON.parse(textPayload(lint));
178
+ assert.equal(lintJson.filesChecked, 1);
179
+
180
+ for (const [tool, args] of [
181
+ ['GetSystemInfo', {}],
182
+ ['GetConnectionInfo', {}],
183
+ ['GetFeatures', {}],
184
+ ['GetInstalledComponents', {}]
185
+ ]) {
186
+ const result = await client.request('tools/call', { name: `${prefix}${tool}`, arguments: args });
187
+ assert.notEqual(result.isError, true, `${tool} returned an MCP tool error: ${textPayload(result)}`);
188
+ }
189
+
190
+ const query = await client.request('tools/call', {
191
+ name: `${prefix}RunQuery`,
192
+ arguments: { sql_query: 'SELECT * FROM T000', max_rows: 1 }
193
+ });
194
+ assert.notEqual(query.isError, true, `RunQuery returned an MCP tool error: ${textPayload(query)}`);
195
+ assert.match(textPayload(query), /T000|MANDT|client|rows|\[/iu);
196
+
197
+ const appLog = await client.request('tools/call', {
198
+ name: `${prefix}GetApplicationLog`,
199
+ arguments: { max_results: 1 }
200
+ });
201
+ assert.notEqual(appLog.isError, true, `GetApplicationLog returned an MCP tool error: ${textPayload(appLog)}`);
202
+ });
@@ -100,6 +100,17 @@ test('builds SAP development companion MCP entries', () => {
100
100
  assert.throws(() => buildSapDevelopmentMcpEntries(['missing-tool']), /Unknown SAP development MCP server id/);
101
101
  });
102
102
 
103
+ test('configures the HANA inspector without persisting credentials', () => {
104
+ const entry = buildSapDevelopmentMcpEntries(['hana-cloud-inspector'])['hana-cloud-inspector'];
105
+ assert.equal(entry.command, 'npx');
106
+ assert.deepEqual(entry.args, ['--yes', '--ignore-scripts', '--package=sap-ai-dev-toolkit', 'sap-ai-hana']);
107
+ assert.equal(entry.BAS_EXT_KIND, 'sap-development-companion');
108
+ assert.equal(entry.displayName, 'HANA Cloud inspector');
109
+ assert.equal(entry.env, undefined);
110
+ assert.doesNotMatch(JSON.stringify(entry), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
111
+ assert.doesNotMatch(JSON.stringify(entry), /"(?:password|pwd)"\s*:/i);
112
+ });
113
+
103
114
  test('installs and removes SAP development companion entries', async t => {
104
115
  const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-tools-config-'));
105
116
  const path = join(directory, 'mcp.json');
@@ -120,6 +131,35 @@ test('installs and removes SAP development companion entries', async t => {
120
131
  assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
121
132
  });
122
133
 
134
+ test('installs and removes the HANA inspector without changing unrelated MCP servers', async t => {
135
+ const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-hana-config-'));
136
+ const path = join(directory, 'mcp.json');
137
+ const initial = {
138
+ custom: true,
139
+ servers: { userServer: { type: 'stdio', command: 'custom-server' } }
140
+ };
141
+ await writeFile(path, JSON.stringify(initial));
142
+ t.after(() => rm(directory, { recursive: true, force: true }));
143
+
144
+ const installed = await installMcpConfig([], {
145
+ env: { H2O_URL: 'http://h2o.example' },
146
+ path,
147
+ sapDevelopmentServers: ['hana-cloud-inspector']
148
+ });
149
+ assert.deepEqual(Object.keys(installed.servers), ['hana-cloud-inspector']);
150
+ const configured = await readMcpConfig(path);
151
+ assert.deepEqual(Object.keys(configured.servers).sort(), ['hana-cloud-inspector', 'userServer']);
152
+ assert.equal(configured.custom, true);
153
+ assert.equal(configured.servers['hana-cloud-inspector'].env, undefined);
154
+ assert.doesNotMatch(JSON.stringify(configured), /"(?:HANA_RO_PASSWORD|VCAP_SERVICES)"\s*:/);
155
+ assert.doesNotMatch(JSON.stringify(configured), /"(?:password|pwd)"\s*:/i);
156
+
157
+ await installMcpConfig([], { env: { H2O_URL: 'http://h2o.example' }, path });
158
+ const removed = await readMcpConfig(path);
159
+ assert.deepEqual(removed.servers, initial.servers);
160
+ assert.equal(removed.custom, true);
161
+ });
162
+
123
163
  test('installs and removes CF entries without deleting unrelated MCP servers', async t => {
124
164
  const directory = await mkdtemp(join(tmpdir(), 'bas-mcp-cf-config-'));
125
165
  const path = join(directory, 'mcp.json');
@@ -5,24 +5,13 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { once } from 'node:events';
8
- import { childArguments, MCPProxy } from '../src/mcp-proxy.mjs';
8
+ import { childArguments, MCPProxy, PUBLIC_VSP_TOOLS } from '../src/mcp-proxy.mjs';
9
9
  import { PassThrough } from 'node:stream';
10
10
 
11
11
  const fixture = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
12
- const REQUESTED_TOOLS = [
13
- 'AnalyzeABAPCode', 'AnalyzeCallGraph', 'CodeCompletion', 'GetAbapHelp',
14
- 'GetCallGraph', 'GetCalleesOf', 'GetCallersOf', 'GetCodeCoverage',
15
- 'GetConnectionInfo', 'GetObjectStructure', 'GetTypeHierarchy', 'GetTypeInfo',
16
- 'GrepObject', 'GrepPackage', 'CallRFC', 'DebuggerAttach', 'DebuggerDetach',
17
- 'DebuggerGetStack', 'DebuggerGetVariables', 'DebuggerListen', 'DebuggerStep',
18
- 'DeleteBreakpoint', 'GetBreakpoints', 'GetDump', 'GetSQLTraceState', 'GetTrace',
19
- 'ListDumps', 'SetBreakpoint', 'CloneObject', 'CreateAndActivateProgram',
20
- 'CreateClassWithTests', 'CreateObject', 'CreateTestInclude', 'DeleteObject',
21
- 'ExecuteABAP', 'GetClass', 'GetClassComponents', 'GetClassInclude', 'GetFunction',
22
- 'GetInclude', 'GetInterface', 'GetProgram', 'GetStructure', 'GetTransaction',
23
- 'LockObject', 'MoveObject', 'RecoverFailedCreate', 'RenameObject', 'SaveToFile',
24
- 'UnlockObject', 'UpdateClassInclude', 'UpdateSource', 'WriteClass', 'WriteProgram',
25
- 'CreateTransport', 'ListDependencies', 'PublishServiceBinding', 'UnpublishServiceBinding'
12
+ const HIDDEN_VSP_TOOLS = [
13
+ 'AnalyzeABAPCode', 'DebuggerListen', 'DeleteBreakpoint', 'DeleteObject', 'ExecuteABAP', 'ReleaseTransport',
14
+ 'DeleteTransport', 'SAP', 'ListSQLTraces', 'PublishServiceBinding', 'UnpublishServiceBinding'
26
15
  ];
27
16
 
28
17
 
@@ -166,22 +155,23 @@ test('merges paged tools and routes calls to the selected child', async t => {
166
155
  assert.deepEqual(lintTool.inputSchema.required, ['files']);
167
156
  assert.deepEqual(lintTool.inputSchema.properties.files.items.required, ['filename', 'source']);
168
157
 
158
+ const localWorkflowTools = [
159
+ 'LintABAP', 'GetApplicationLog', 'PrepareABAPChangeSet', 'ApplyABAPChangeSet',
160
+ 'CheckTransportReadiness', 'PlanABAPCloudMigration', 'GenerateRAPRegressionSuite', 'RunRAPRegressionSuite'
161
+ ];
169
162
  for (const destination of ['alpha', 'beta']) {
170
- for (const toolName of REQUESTED_TOOLS) {
171
- assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes ${toolName}`);
163
+ for (const toolName of PUBLIC_VSP_TOOLS) {
164
+ assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes curated ${toolName}`);
172
165
  }
173
- for (const coreTool of [
174
- 'GetSource', 'RunQuery', 'GetSystemInfo', 'GetInstalledComponents',
175
- 'GetFeatures', 'GetAPIReleaseState', 'PrettyPrint', 'ListTransports',
176
- 'GetTransport', 'GetUserTransports', 'GetTransportInfo',
177
- 'GetApplicationLog', 'ActivateMultiple'
178
- ]) {
179
- assert.ok(names.has(`${destination}__${coreTool}`), `${destination} exposes ${coreTool}`);
166
+ for (const toolName of localWorkflowTools) {
167
+ assert.ok(names.has(`${destination}__${toolName}`), `${destination} exposes local ${toolName}`);
180
168
  }
181
- for (const formerlyExcludedTool of ['ListSQLTraces', 'ReleaseTransport', 'DeleteTransport', 'SAP']) {
182
- assert.ok(names.has(`${destination}__${formerlyExcludedTool}`), `${destination} exposes ${formerlyExcludedTool}`);
169
+ for (const hiddenTool of HIDDEN_VSP_TOOLS) {
170
+ assert.equal(names.has(`${destination}__${hiddenTool}`), false, `${destination} hides ${hiddenTool}`);
183
171
  }
184
172
  }
173
+ assert.equal(listed.result.tools.filter(tool => tool.name.startsWith('alpha__')).length, PUBLIC_VSP_TOOLS.size + localWorkflowTools.length);
174
+ assert.equal(PUBLIC_VSP_TOOLS.size + localWorkflowTools.length, 59);
185
175
 
186
176
  const requiredArguments = {
187
177
  GetFeatures: [],
@@ -234,11 +224,7 @@ test('merges paged tools and routes calls to the selected child', async t => {
234
224
  params: { type: 'delete' }
235
225
  }],
236
226
  ['ActivateMultiple', { objects: ['PROG ZDEMO'] }],
237
- ['CreateTransport', {}],
238
- ['ReleaseTransport', {}],
239
- ['DeleteTransport', {}],
240
- ['ListSQLTraces', {}],
241
- ['SAP', { action: 'analyze', params: { type: 'application_log' } }]
227
+ ['CreateTransport', {}]
242
228
  ];
243
229
  for (const [index, [name, arguments_]] of additionalCalls.entries()) {
244
230
  const response = await proxy.handle({
@@ -6,6 +6,8 @@ import { join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { installMcpConfig } from '../src/mcp-config.mjs';
8
8
  import { destinationTable } from '../scripts/postinstall.mjs';
9
+ import { readCredentials, resolveCredentialsPath, storeDestinationCredentials } from '../src/credentials-store.mjs';
10
+ import { credentialKeyForDestination } from '../src/credential-overrides.mjs';
9
11
  import { spawnWithPty } from './pty.mjs';
10
12
 
11
13
  const setupModule = fileURLToPath(new URL('../src/setup.mjs', import.meta.url));
@@ -28,6 +30,22 @@ function cloudFoundryDestination({ name, serverName, instanceGuid, instanceName,
28
30
  };
29
31
  }
30
32
 
33
+ function cloudFoundryOnPremiseDestination() {
34
+ const destination = cloudFoundryDestination({
35
+ name: 'shared-name',
36
+ serverName: 'cf:space-one:cf-destination:shared-name',
37
+ instanceGuid: 'cf-destination',
38
+ instanceName: 'destination-service',
39
+ keyName: 'destination-key'
40
+ });
41
+ destination.authentication = 'BasicAuthentication';
42
+ destination.proxyType = 'OnPremise';
43
+ destination.cf.connectivityInstanceGuid = 'cf-connectivity';
44
+ destination.cf.connectivityInstanceName = 'connectivity-service';
45
+ destination.cf.connectivityKeyName = 'connectivity-key';
46
+ return destination;
47
+ }
48
+
31
49
  async function makeFixture(t, { version = 'cf version 8.18.0', space = 'space-one', deleteMode = 'success' } = {}) {
32
50
  const directory = await mkdtemp(join(tmpdir(), 'bas-cf-setup-'));
33
51
  const bin = join(directory, 'bin');
@@ -63,6 +81,7 @@ await runSetup({
63
81
  await writeFile(process.env.CF_DISCOVERY_LOG, JSON.stringify({ spaceGuid, managedKeys }));
64
82
  return JSON.parse(process.env.TEST_CF_RESULT);
65
83
  },
84
+ verifyOnPremCredentialRoute: async () => JSON.parse(process.env.TEST_ONPREM_CSRF_RESULT),
66
85
  ...(process.env.TEST_INSTALL_FAIL === 'true' ? {
67
86
  install: async () => { throw new Error('simulated config write failure'); }
68
87
  } : {})
@@ -81,13 +100,14 @@ await runSetup({
81
100
  TEST_CF_DELETE_MODE: deleteMode,
82
101
  TEST_INSTALL_FAIL: 'false',
83
102
  TEST_BAS_DESTINATIONS: JSON.stringify([]),
84
- TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] })
103
+ TEST_CF_RESULT: JSON.stringify({ destinations: [], createdKeys: [], warnings: [] }),
104
+ TEST_ONPREM_CSRF_RESULT: JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 1 })
85
105
  };
86
106
  t.after(() => rm(directory, { recursive: true, force: true }));
87
107
  return { directory, configPath, callLog, discoveryLog, harness, env };
88
108
  }
89
109
 
90
- function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}) {
110
+ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r', promptAnswers = [] } = {}) {
91
111
  return new Promise((resolve, reject) => {
92
112
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(fixture.harness)}`;
93
113
  const child = spawnWithPty(command, { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -95,6 +115,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
95
115
  let stderr = '';
96
116
  let importSent = false;
97
117
  let selectionSent = false;
118
+ let promptAnswerIndex = 0;
98
119
  const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
99
120
  child.stdout.on('data', chunk => {
100
121
  stdout += chunk.toString();
@@ -104,7 +125,16 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
104
125
  }
105
126
  if (!selectionSent && stdout.includes('Select destinations')) {
106
127
  selectionSent = true;
107
- child.stdin.end(selection);
128
+ if (promptAnswers.length) child.stdin.write(selection);
129
+ else child.stdin.end(selection);
130
+ }
131
+ if (selectionSent && promptAnswerIndex < promptAnswers.length) {
132
+ const answer = promptAnswers[promptAnswerIndex];
133
+ if (stdout.includes(answer.when)) {
134
+ promptAnswerIndex++;
135
+ child.stdin.write(answer.value);
136
+ if (promptAnswerIndex === promptAnswers.length) child.stdin.end();
137
+ }
108
138
  }
109
139
  });
110
140
  child.stderr.on('data', chunk => { stderr += chunk.toString(); });
@@ -114,7 +144,7 @@ function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}
114
144
  });
115
145
  child.on('exit', (code, signal) => {
116
146
  clearTimeout(timeout);
117
- resolve({ code, signal, stdout, stderr, importSent, selectionSent });
147
+ resolve({ code, signal, stdout, stderr, importSent, selectionSent, promptAnswersSent: promptAnswerIndex });
118
148
  });
119
149
  });
120
150
  }
@@ -145,6 +175,7 @@ test('imports selected CF destinations, passes managed key references, and remov
145
175
  assert.equal(result.selectionSent, true, result.stdout);
146
176
  assert.match(result.stdout, /CF new-destination-service/);
147
177
  assert.match(result.stdout, /Configured 1 MCP server/);
178
+ assert.match(result.stdout, /GitHub Copilot Chat.*Chat tools picker.*enable each selected destination server/s);
148
179
  assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /SECRET|clientSecret|Password/);
149
180
 
150
181
  const snapshot = JSON.parse(await readFile(fixture.discoveryLog, 'utf8'));
@@ -163,6 +194,102 @@ test('imports selected CF destinations, passes managed key references, and remov
163
194
  assert.equal(calls.some(args => args.at(-1) === 'new-key'), false);
164
195
  });
165
196
 
197
+ test('prompts for BAS and CF OnPremise overrides separately and keeps their routes distinct', async t => {
198
+ const fixture = await makeFixture(t);
199
+ const cfDestination = cloudFoundryOnPremiseDestination();
200
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([
201
+ {
202
+ name: 'internet-system', url: 'http://internet-system.dest', backendUrl: 'https://internet.example:44300',
203
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'Internet',
204
+ probe: { status: 'available', available: true }
205
+ },
206
+ {
207
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
208
+ client: '101', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
209
+ probe: { status: 'available', available: true }
210
+ },
211
+ {
212
+ name: 'principal-system', url: 'http://principal-system.dest',
213
+ client: '102', authentication: 'PrincipalPropagation', proxyType: 'OnPremise',
214
+ probe: { status: 'available', available: true }
215
+ }
216
+ ]);
217
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [cfDestination], createdKeys: [], warnings: [] });
218
+
219
+ const result = await runSetupInPty(fixture, {
220
+ selection: 'a\r',
221
+ promptAnswers: [
222
+ { when: 'BAS Internet — internet-system — override SAP credentials?', value: 'y\r' },
223
+ { when: 'BAS Internet — internet-system — SAP user', value: 'INTERNET_USER\r' },
224
+ { when: 'BAS Internet — internet-system — SAP password', value: 'internet-password-secret\r' },
225
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
226
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'BAS_CC_USER\r' },
227
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'bas-cc-password-secret\r' },
228
+ { when: 'Cloud Foundry OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
229
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP user', value: 'CF_CC_USER\r' },
230
+ { when: 'Cloud Foundry OnPremise — shared-name — SAP password', value: 'cf-cc-password-secret\r' }
231
+ ]
232
+ });
233
+ assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`);
234
+ assert.equal(result.promptAnswersSent, 9, result.stdout);
235
+ assert.doesNotMatch(result.stdout, /PrincipalPropagation — principal-system — override SAP credentials/);
236
+ assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /internet-password-secret|bas-cc-password-secret|cf-cc-password-secret/);
237
+
238
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
239
+ const stored = (await readCredentials(credentialsPath)).destinations;
240
+ assert.equal(stored['internet-system'].mode, 'direct');
241
+ assert.equal(stored['internet-system'].host, 'https://internet.example:44300');
242
+ assert.equal(stored['shared-name'].mode, 'bas-tunnel');
243
+ assert.equal(stored['shared-name'].host, undefined, 'BAS OnPremise must not store or use the backend URL');
244
+ const cfKey = credentialKeyForDestination(cfDestination);
245
+ assert.equal(stored[cfKey].mode, 'cf-connectivity');
246
+ assert.equal(stored[cfKey].host, undefined);
247
+ assert.deepEqual(
248
+ [stored['shared-name'].user, stored[cfKey].user],
249
+ ['BAS_CC_USER', 'CF_CC_USER'],
250
+ 'same destination names from BAS and CF must remain distinct'
251
+ );
252
+
253
+ const mcpConfig = JSON.parse(await readFile(fixture.configPath, 'utf8'));
254
+ assert.equal(JSON.stringify(mcpConfig).includes('bas-cc-password-secret'), false);
255
+ assert.equal(JSON.stringify(mcpConfig).includes('cf-cc-password-secret'), false);
256
+ });
257
+
258
+ test('No removes a stored BAS OnPremise override and failed CSRF preflight refuses a new one', async t => {
259
+ const fixture = await makeFixture(t);
260
+ fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
261
+ name: 'shared-name', url: 'http://shared-name.dest', backendUrl: 'https://internal.example:44300',
262
+ client: '100', authentication: 'BasicAuthentication', proxyType: 'OnPremise',
263
+ probe: { status: 'available', available: true }
264
+ }]);
265
+ fixture.env.TEST_CF_RESULT = JSON.stringify({ destinations: [], createdKeys: [], warnings: [] });
266
+ const credentialsPath = await resolveCredentialsPath(fixture.env, fixture.configPath);
267
+ await storeDestinationCredentials(credentialsPath, 'shared-name', {
268
+ user: 'OLD_USER', password: 'old-password-secret', mode: 'bas-tunnel'
269
+ });
270
+
271
+ const declined = await runSetupInPty(fixture, {
272
+ selection: ' \r',
273
+ promptAnswers: [{ when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'n\r' }]
274
+ });
275
+ assert.equal(declined.code, 0, `${declined.stdout}\n${declined.stderr}`);
276
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
277
+
278
+ fixture.env.TEST_ONPREM_CSRF_RESULT = JSON.stringify({ httpStatus: 200, tokenReceived: true, cookieCount: 0 });
279
+ const rejected = await runSetupInPty(fixture, {
280
+ selection: ' \r',
281
+ promptAnswers: [
282
+ { when: 'BAS OnPremise — shared-name — override SAP credentials?', value: 'y\r' },
283
+ { when: 'BAS OnPremise — shared-name — SAP user', value: 'NEW_USER\r' },
284
+ { when: 'BAS OnPremise — shared-name — SAP password', value: 'new-password-secret\r' }
285
+ ]
286
+ });
287
+ assert.equal(rejected.code, 0, `${rejected.stdout}\n${rejected.stderr}`);
288
+ assert.match(rejected.stdout, /did not return both a CSRF token and session cookie/);
289
+ assert.equal((await readCredentials(credentialsPath)).destinations['shared-name'], undefined);
290
+ assert.doesNotMatch(`${rejected.stdout}\n${rejected.stderr}`, /new-password-secret/);
291
+ });
292
+
166
293
  test('declining CF import keeps BAS setup and does not create CF keys', async t => {
167
294
  const fixture = await makeFixture(t);
168
295
  fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
@@ -70,7 +70,7 @@ function runPostinstallInPty(env, keys, assetsAnswer = '\r') {
70
70
  selectionSent = true;
71
71
  child.stdin.write(keys);
72
72
  }
73
- if (!assetsAnswerSent && stdout.includes('Install the bundled agents and eleven skills')) {
73
+ if (!assetsAnswerSent && stdout.includes('Install the bundled agents and all skills')) {
74
74
  assetsAnswerSent = true;
75
75
  child.stdin.write(assetsAnswer);
76
76
  }
@@ -90,6 +90,7 @@ function runPostinstallInPty(env, keys, assetsAnswer = '\r') {
90
90
  async function assertUserCopilotAssets(home) {
91
91
  const copilotRoot = join(home, '.copilot');
92
92
  assert.equal((await stat(join(copilotRoot, 'agents', 'abap-developer.agent.md'))).isFile(), true);
93
+ assert.equal((await stat(join(copilotRoot, 'agents', 'hana-cloud-hdi-specialist.agent.md'))).isFile(), true);
93
94
  const skillNames = (await readdir(join(copilotRoot, 'skills'))).sort();
94
95
  assert.deepEqual(skillNames, [
95
96
  'abap-debugging',
@@ -98,6 +99,9 @@ async function assertUserCopilotAssets(home) {
98
99
  'abap-testing-quality',
99
100
  'cds-development',
100
101
  'clean-core-extensibility',
102
+ 'hana-cloud-inspection',
103
+ 'hana-cloud-native-development',
104
+ 'hana-cloud-validation',
101
105
  'rap-development',
102
106
  'rap-service-delivery',
103
107
  'sap-sdlc-orchestration',
@@ -279,13 +283,13 @@ test('global postinstall completes BAS selection before optional Copilot assets'
279
283
  const declineLogs = `${declined.stdout}\n${declined.stderr}`;
280
284
  assert.match(declineLogs, /Configured 0 MCP servers/);
281
285
  assert.match(declineLogs, /\[Y\/n\]/);
282
- assert.match(declineLogs, /Press Enter to install the bundled agents and eleven skills in the path shown below; type n then press Enter to skip/);
286
+ assert.match(declineLogs, /Press Enter to install the bundled agents and all skills in the path shown below; type n then press Enter to skip/);
283
287
  assert.match(declineLogs, /🤖 sap-ai-dev-toolkit/);
284
288
  assert.match(declineLogs, /\u001b\[1;35m/);
285
289
  assert.match(declineLogs, /Optional Copilot setup is waiting for your choice/);
286
290
  assert.match(declineLogs, /Copilot agent and skills were skipped\. Your files were not changed/);
287
291
  assert.ok(declineLogs.indexOf('Optional Copilot setup is waiting for your choice') > declineLogs.indexOf('Configured 0 MCP servers'), declineLogs);
288
- assert.ok(declineLogs.indexOf('Install the bundled agents and eleven skills') > declineLogs.indexOf('Optional Copilot setup is waiting for your choice'), declineLogs);
292
+ assert.ok(declineLogs.indexOf('Install the bundled agents and all skills') > declineLogs.indexOf('Optional Copilot setup is waiting for your choice'), declineLogs);
289
293
  assert.ok(declineLogs.indexOf('No MCP server entries are configured for this add-on.') > declineLogs.indexOf('Copilot agent and skills were skipped'), declineLogs);
290
294
  assert.ok(declineLogs.includes(`MCP config file: ${config}`), declineLogs);
291
295
  const configAfterDecline = JSON.parse(await readFile(config, 'utf8'));
@@ -297,9 +301,9 @@ test('global postinstall completes BAS selection before optional Copilot assets'
297
301
  assert.equal(accepted.selectionSent, true, accepted.stdout);
298
302
  assert.equal(accepted.assetsAnswerSent, true, accepted.stdout);
299
303
  const acceptLogs = `${accepted.stdout}\n${accepted.stderr}`;
300
- assert.ok(acceptLogs.indexOf('Install the bundled agents and eleven skills') > acceptLogs.indexOf('Configured 1 MCP server'), acceptLogs);
301
- assert.match(acceptLogs, /Installed 15 Copilot files/);
302
- assert.ok(acceptLogs.indexOf('Installation configuration summary') > acceptLogs.indexOf('Installed 15 Copilot files'), acceptLogs);
304
+ assert.ok(acceptLogs.indexOf('Install the bundled agents and all skills') > acceptLogs.indexOf('Configured 1 MCP server'), acceptLogs);
305
+ assert.match(acceptLogs, /Installed 19 Copilot files/);
306
+ assert.ok(acceptLogs.indexOf('Installation configuration summary') > acceptLogs.indexOf('Installed 19 Copilot files'), acceptLogs);
303
307
  assert.ok(acceptLogs.includes(`MCP config file: ${config}`), acceptLogs);
304
308
  assert.ok(acceptLogs.includes('Destination: BAS · alpha-system · client 100 · Basic'), acceptLogs);
305
309
  assert.ok(acceptLogs.includes('Launch: stdio · sap-ai-dev'), acceptLogs);