sap-ai-dev-toolkit 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.github/agents/abap-developer.agent.md +7 -7
  2. package/.github/agents/abap-runtime-debugger.agent.md +4 -4
  3. package/.github/agents/hana-cloud-hdi-specialist.agent.md +1 -1
  4. package/.github/agents/rap-service-developer.agent.md +5 -5
  5. package/.github/agents/sap-solution-architect.agent.md +2 -2
  6. package/.github/skills/abap-debugging/SKILL.md +1 -1
  7. package/.github/skills/abap-development/SKILL.md +3 -3
  8. package/.github/skills/abap-runtime-analysis/SKILL.md +2 -2
  9. package/.github/skills/abap-testing-quality/SKILL.md +3 -3
  10. package/.github/skills/cds-development/SKILL.md +3 -3
  11. package/.github/skills/clean-core-extensibility/SKILL.md +1 -1
  12. package/.github/skills/hana-cloud-inspection/SKILL.md +2 -2
  13. package/.github/skills/hana-cloud-native-development/SKILL.md +1 -1
  14. package/.github/skills/hana-cloud-validation/SKILL.md +1 -1
  15. package/.github/skills/rap-development/SKILL.md +3 -3
  16. package/.github/skills/rap-service-delivery/SKILL.md +2 -2
  17. package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
  18. package/.github/skills/sap-standard-api-analysis/SKILL.md +1 -1
  19. package/.github/skills/sap-transport-release/SKILL.md +2 -2
  20. package/README.md +31 -26
  21. package/inventory.md +5 -3
  22. package/package.json +1 -1
  23. package/src/bas-destination-relay.mjs +13 -41
  24. package/src/launcher.mjs +11 -17
  25. package/src/mcp-proxy.mjs +56 -29
  26. package/src/setup.mjs +2 -110
  27. package/src/terminal-ui.mjs +3 -1
  28. package/tools.md +10 -2
  29. package/src/credential-overrides.mjs +0 -75
  30. package/src/credentials-store.mjs +0 -113
package/src/setup.mjs CHANGED
@@ -1,12 +1,9 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
3
+ import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
4
4
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
5
5
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
6
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
- import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
8
- import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
9
- import { createBasDestinationRelay } from './bas-destination-relay.mjs';
10
7
 
11
8
  const SETUP_COMMAND = 'sap-ai-dev --setup';
12
9
 
@@ -26,89 +23,6 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
26
23
  }
27
24
  }
28
25
 
29
- async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
30
- const relay = createBasDestinationRelay({
31
- ...destination,
32
- credentials: { user, password, mode: 'bas-tunnel' }
33
- }, { env, log: () => {} });
34
- try {
35
- await relay.ready;
36
- return await relay.probeCsrfSession();
37
- } finally {
38
- await relay.close();
39
- }
40
- }
41
-
42
- async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
43
- const eligible = destinations.filter(canPromptForCredentials);
44
- const retained = new Set();
45
- if (!eligible.length) return retained;
46
-
47
- print(output, '');
48
- print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
49
- print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
50
- print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
51
- print(output, '');
52
-
53
- for (const destination of eligible) {
54
- const key = credentialKeyForDestination(destination);
55
- if (!key) continue;
56
- const mode = credentialModeForDestination(destination);
57
- const known = existing[key];
58
- const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
59
- const answer = await textPrompt({
60
- message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
61
- required: true,
62
- validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
63
- }, { input, output });
64
- if (/^n/i.test(answer.trim())) {
65
- await removeDestinationCredentials(credentialsPath, [key]);
66
- print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
67
- continue;
68
- }
69
-
70
- if (known?.user && known?.password) {
71
- print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
72
- }
73
- const userAnswer = await textPrompt({
74
- message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
75
- required: false,
76
- placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
77
- }, { input, output });
78
- const user = userAnswer.trim() || known?.user || '';
79
- const passwordAnswer = await textPrompt({
80
- message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
81
- secret: true,
82
- required: false
83
- }, { input, output });
84
- const password = passwordAnswer || known?.password || '';
85
- if (!user || !password) {
86
- await removeDestinationCredentials(credentialsPath, [key]);
87
- print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
88
- continue;
89
- }
90
-
91
- if (mode === 'bas-tunnel') {
92
- let route;
93
- try {
94
- route = await verifyOnPremCredentialRoute({ destination, user, password, env });
95
- } catch {
96
- route = null;
97
- }
98
- if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
99
- await removeDestinationCredentials(credentialsPath, [key]);
100
- print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
101
- continue;
102
- }
103
- }
104
-
105
- const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
106
- await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
107
- retained.add(key);
108
- print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
109
- }
110
- return retained;
111
- }
112
26
 
113
27
  function safeProbe(probe) {
114
28
  const result = {};
@@ -187,7 +101,6 @@ export async function runSetup({
187
101
  discoverCf = discoverCloudFoundryDestinations,
188
102
  confirmCfImport = confirmCloudFoundryImport,
189
103
  install = installMcpConfig,
190
- verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
191
104
  includeSapDevelopmentToolsPrompt = false
192
105
  } = {}) {
193
106
  if (!env.H2O_URL) {
@@ -209,7 +122,6 @@ export async function runSetup({
209
122
  }
210
123
  const destinations = basDestinations.map(safeBasDestination);
211
124
  const warnings = [];
212
- const credentialWarnings = [];
213
125
  let createdKeys = [];
214
126
  let configPath;
215
127
  let managedKeys = [];
@@ -352,25 +264,6 @@ export async function runSetup({
352
264
  }, { input, output });
353
265
  }
354
266
 
355
- // Ask for an explicit credential override choice for each selected supported destination.
356
- const credentialsPath = await resolveCredentialsPath(env, configPath);
357
- try {
358
- const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
359
- const retained = await promptForDestinationCredentials({
360
- destinations: selected,
361
- credentialsPath,
362
- existing: existingMap,
363
- input,
364
- output,
365
- env,
366
- verifyOnPremCredentialRoute
367
- });
368
- const stale = Object.keys(existingMap).filter(name => !retained.has(name));
369
- if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
370
- } catch (error) {
371
- if (/Prompt interrupted/.test(error.message)) throw error;
372
- credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
373
- }
374
267
  try {
375
268
  const result = await install(selected, { env, sapDevelopmentServers });
376
269
  const location = result?.path ? ` in ${result.path}` : '';
@@ -391,9 +284,8 @@ export async function runSetup({
391
284
  }
392
285
  }
393
286
  await reportWarnings(warnings);
394
- await reportWarnings(credentialWarnings);
395
287
  await reportWarnings(cleanupWarnings);
396
- return { selected, ...(result || {}), warnings: [...warnings, ...credentialWarnings, ...cleanupWarnings] };
288
+ return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
397
289
  } catch (error) {
398
290
  const cleanupWarnings = [];
399
291
  if (createdKeys.length) {
@@ -139,7 +139,9 @@ export async function checkboxPrompt({ message, choices, required = false, short
139
139
  for (let index = top; index < end; index += 1) {
140
140
  const choice = choices[index];
141
141
  const pointer = index === cursor ? '❯' : ' ';
142
- const marker = selected.has(index) ? '◉' : '◯';
142
+ const marker = selected.has(index)
143
+ ? colorText('✓', 'green', output)
144
+ : colorText('✗', 'red', output);
143
145
  const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
144
146
  const label = `${pointer}${marker} ${choice.name}${disabled}`;
145
147
  lines.push(truncateToColumns(label, columns));
package/tools.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The proxy no longer advertises every VSP tool. To keep the developer-lifecycle MCP surface manageable, each generated one-destination server exposes a cherry-picked set of VSP tools plus local workflow tools (currently 59 tools when all curated VSP capabilities are registered).
4
4
 
5
- The names below are the logical tool names. Publicly, every tool is exposed as `<destination-slug>_<tool>` in lowercase (for example `GetTableContents` on destination `DEMO_ABAP` is `demo-abap_get_table_contents`): BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
5
+ The names below are the logical tool names. Publicly they are exposed in lowercase snake_case without a prefix (`get_table_contents`, `run_query`) because each generated MCP server is scoped to one destination; only when a single server fronts multiple destinations does each name gain its destination slug (`demo-abap_get_table_contents`) to stay unambiguous. BAS/VS Code chat tool references bind only lowercase identifiers, so mixed-case names never reach the model.
6
6
 
7
7
  Hidden upstream VSP tools are not directly callable through the proxy. Local workflow tools may still call hidden or non-advertised upstream operations internally when they are required for a bounded workflow.
8
8
 
@@ -75,7 +75,15 @@ The proxy adds local multi-step tools to each destination when their required VS
75
75
  - `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
76
76
  - `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
77
77
 
78
- `sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev-toolkit --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
78
+ `sap-ai-dev --doctor` checks destination probing, VSP startup, the `get_system_info` probe, and MCP tool listing. `sap-ai-dev --demo` runs the sample tools and OData fixtures without contacting SAP; demo writes and transport creation remain in memory until that process exits.
79
+
80
+ ### Read-only mode
81
+
82
+ Set `SAP_AI_DEV_TOOLKIT_READ_ONLY=true` (or legacy `BAS_VSP_READ_ONLY`) on a destination server to run it strictly read-only: `WriteSource`, `EditSource`, `Activate`, `ActivateMultiple`, `CreateTransport`, `SetBreakpoint`, and the change-set workflow tools are removed from the surface, and the VSP child starts with `--transport-read-only` so transport writes are rejected upstream as well. Inspection, queries, linting, and checks remain available.
83
+
84
+ ### Request timeouts
85
+
86
+ Calls forwarded to a VSP child time out after `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` (default 600000 ms; `0` disables). A stalled request fails with a timeout error; the child keeps running so unrelated in-flight requests are unaffected.
79
87
 
80
88
  ## Separate optional HANA Cloud inspector
81
89
 
@@ -1,75 +0,0 @@
1
- import { readCredentials, resolveCredentialsPath } from './credentials-store.mjs';
2
-
3
- const CLOUD_FOUNDRY_KEY_PREFIX = 'cloud-foundry:';
4
-
5
- function isBasicAuthentication(value) {
6
- return /^basic(?:authentication)?$/i.test(String(value || '').replace(/\s+/g, ''));
7
- }
8
-
9
- export function credentialKeyForDestination(destination = {}) {
10
- if (destination.source === 'cloud-foundry') {
11
- const serverName = String(destination.serverName || '').trim();
12
- return serverName ? `${CLOUD_FOUNDRY_KEY_PREFIX}${serverName}` : '';
13
- }
14
- return String(destination.name || '').trim();
15
- }
16
-
17
- export function credentialModeForDestination(destination = {}) {
18
- if (!isBasicAuthentication(destination.authentication)) return null;
19
- const proxyType = String(destination.proxyType || '').trim().toLowerCase();
20
- if (destination.source === 'cloud-foundry') {
21
- return proxyType === 'onpremise' ? 'cf-connectivity' : null;
22
- }
23
- if (proxyType === 'internet') return 'direct';
24
- if (proxyType === 'onpremise') return 'bas-tunnel';
25
- return null;
26
- }
27
-
28
- export function canPromptForCredentials(destination = {}) {
29
- const mode = credentialModeForDestination(destination);
30
- return Boolean(mode && (mode !== 'direct' || destination.backendUrl));
31
- }
32
-
33
- export async function enrichWithStoredCredentials(destinations, env = process.env, log = message => console.error(message)) {
34
- const needingCredentials = destinations.filter(destination => {
35
- const mode = credentialModeForDestination(destination);
36
- if (!mode || !canPromptForCredentials(destination)) return false;
37
- if (mode === 'cf-connectivity') return Boolean(destination.serverName);
38
- return Boolean(destination.url && String(destination.url).includes('.dest'));
39
- });
40
- if (!needingCredentials.length) return destinations;
41
- let stored;
42
- try {
43
- const path = await resolveCredentialsPath(env);
44
- stored = await readCredentials(path);
45
- } catch (error) {
46
- log(`[sap-ai-dev] stored credentials could not be read: ${String(error.message).slice(0, 200)}`);
47
- return destinations;
48
- }
49
- return destinations.map(destination => {
50
- const mode = credentialModeForDestination(destination);
51
- const key = credentialKeyForDestination(destination);
52
- const entry = key ? stored.destinations?.[key] : undefined;
53
- if (!mode || !entry?.user || !entry?.password) return destination;
54
- if (mode === 'cf-connectivity') {
55
- if (entry.mode !== mode || destination.source !== 'cloud-foundry') return destination;
56
- return {
57
- ...destination,
58
- childEnv: {
59
- ...(destination.childEnv || {}),
60
- SAP_USER: entry.user,
61
- SAP_PASSWORD: entry.password,
62
- SAP_VERBOSE: 'false'
63
- }
64
- };
65
- }
66
- if (mode === 'bas-tunnel') {
67
- if (entry.mode !== mode) return destination;
68
- return { ...destination, credentials: { user: entry.user, password: entry.password, mode } };
69
- }
70
- if (!destination.backendUrl || !destination.url || !String(destination.url).includes('.dest')) return destination;
71
- if (entry.mode && entry.mode !== 'direct') return destination;
72
- const host = entry.host || destination.backendUrl;
73
- return { ...destination, credentials: { host, user: entry.user, password: entry.password, mode: 'direct' } };
74
- });
75
- }
@@ -1,113 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { basename, dirname, join } from 'node:path';
5
- import { brandedEnvValue } from './branding.mjs';
6
-
7
- // Optional SAP backend credential overrides live next to the MCP config
8
- // (never inside it), with 0600 permissions and no tokens or cookies. Entries
9
- // are keyed by a stable destination identity and may include a route mode.
10
- const CREDENTIALS_FILENAME = 'sap-ai-dev-toolkit-credentials.json';
11
- const CREDENTIAL_MODES = new Set(['direct', 'bas-tunnel', 'cf-connectivity']);
12
-
13
- async function exists(path) {
14
- try {
15
- await stat(path);
16
- return true;
17
- } catch (error) {
18
- if (error?.code === 'ENOENT') return false;
19
- throw error;
20
- }
21
- }
22
-
23
- export async function resolveCredentialsPath(env = process.env, mcpConfigPath) {
24
- const configured = brandedEnvValue(env, 'CREDENTIALS_FILE');
25
- if (configured) return configured;
26
- if (mcpConfigPath) return join(dirname(mcpConfigPath), CREDENTIALS_FILENAME);
27
- const home = env.HOME || homedir();
28
- const candidates = [
29
- join(home, '.vscode', 'data', 'User'),
30
- join(home, '.vscode-server', 'data', 'User'),
31
- join(home, '.code-server', 'data', 'User')
32
- ];
33
- for (const candidate of candidates) if (await exists(candidate)) return join(candidate, CREDENTIALS_FILENAME);
34
- return join(candidates[0], CREDENTIALS_FILENAME);
35
- }
36
-
37
- function sanitizeEntry(entry) {
38
- const result = {};
39
- if (typeof entry?.host === 'string' && entry.host) result.host = entry.host;
40
- if (typeof entry?.user === 'string' && entry.user) result.user = entry.user;
41
- if (typeof entry?.password === 'string' && entry.password) result.password = entry.password;
42
- if (CREDENTIAL_MODES.has(entry?.mode)) result.mode = entry.mode;
43
- if (typeof entry?.updatedAt === 'string') result.updatedAt = entry.updatedAt;
44
- return result;
45
- }
46
-
47
- export async function readCredentials(path) {
48
- let raw;
49
- try {
50
- raw = await readFile(path, 'utf8');
51
- } catch (error) {
52
- if (error?.code === 'ENOENT') return {};
53
- throw new Error(`Credentials file ${path} could not be read: ${error.message}`);
54
- }
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(raw);
58
- } catch (error) {
59
- throw new Error(`Credentials file ${path} contains invalid JSON: ${error.message}`);
60
- }
61
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
62
- throw new Error(`Credentials file ${path} must contain a top-level JSON object`);
63
- }
64
- const destinations = {};
65
- for (const [name, entry] of Object.entries(parsed.destinations || {})) {
66
- const clean = sanitizeEntry(entry);
67
- if (clean.user && clean.password) destinations[name] = clean;
68
- }
69
- return { destinations };
70
- }
71
-
72
- async function writeCredentials(path, destinations) {
73
- const directory = dirname(path);
74
- await mkdir(directory, { recursive: true });
75
- const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
76
- try {
77
- await writeFile(temporary, `${JSON.stringify({ version: 1, destinations }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
78
- await rename(temporary, path);
79
- await chmod(path, 0o600).catch(() => {});
80
- } catch (error) {
81
- await rm(temporary, { force: true }).catch(() => {});
82
- throw new Error(`Credentials file ${path} could not be written: ${error.message}`);
83
- }
84
- }
85
-
86
- export async function storeDestinationCredentials(path, name, { host, user, password, mode }) {
87
- if (mode != null && !CREDENTIAL_MODES.has(mode)) throw new Error('Credential override route mode is unsupported');
88
- const current = await readCredentials(path).catch(error => { throw error; });
89
- const destinations = { ...current.destinations };
90
- destinations[String(name)] = {
91
- host: String(host || ''),
92
- user: String(user || ''),
93
- password: String(password || ''),
94
- ...(mode ? { mode } : {}),
95
- updatedAt: new Date().toISOString()
96
- };
97
- await writeCredentials(path, destinations);
98
- return { path, name };
99
- }
100
-
101
- export async function removeDestinationCredentials(path, names) {
102
- const current = await readCredentials(path);
103
- const destinations = { ...current.destinations };
104
- const removed = [];
105
- for (const name of names) {
106
- if (Object.hasOwn(destinations, String(name))) {
107
- delete destinations[String(name)];
108
- removed.push(String(name));
109
- }
110
- }
111
- if (removed.length) await writeCredentials(path, destinations);
112
- return { path, removed };
113
- }