sap-ai-dev-toolkit 0.2.7 → 0.3.1

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.
@@ -3,9 +3,10 @@ import assert from 'node:assert/strict';
3
3
  import { createServer } from 'node:http';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
6
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
+ import { spawnWithPty } from './pty.mjs';
9
10
 
10
11
  const launcher = fileURLToPath(new URL('../src/launcher.mjs', import.meta.url));
11
12
  const fakeVsp = fileURLToPath(new URL('./fixtures/fake-vsp.mjs', import.meta.url));
@@ -24,10 +25,11 @@ function runLauncher(args, env) {
24
25
  function runLauncherTty(env, input, args = ['--setup']) {
25
26
  return new Promise((resolve, reject) => {
26
27
  const command = `${process.execPath} ${launcher} ${args.join(' ')}`;
27
- const child = spawn('script', ['-qec', command, '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
28
+ const child = spawnWithPty(command, { env, stdio: ['pipe', 'pipe', 'pipe'] });
28
29
  let stdout = '';
29
30
  let stderr = '';
30
31
  let inputSent = false;
32
+ const timeout = setTimeout(() => child.kill('SIGKILL'), 15000);
31
33
  child.stdout.on('data', chunk => {
32
34
  stdout += chunk.toString();
33
35
  if (!inputSent && stdout.includes('Select destinations')) {
@@ -36,8 +38,8 @@ function runLauncherTty(env, input, args = ['--setup']) {
36
38
  }
37
39
  });
38
40
  child.stderr.on('data', chunk => { stderr += chunk.toString(); });
39
- child.on('error', reject);
40
- child.on('exit', (code, signal) => resolve({ code, signal, stdout, stderr }));
41
+ child.on('error', error => { clearTimeout(timeout); reject(error); });
42
+ child.on('exit', (code, signal) => { clearTimeout(timeout); resolve({ code, signal, stdout, stderr, inputSent }); });
41
43
  });
42
44
  }
43
45
 
@@ -59,6 +61,27 @@ test('help exits before BAS destination discovery', async t => {
59
61
  assert.equal(requests, 0);
60
62
  });
61
63
 
64
+ test('doctor reports missing BAS configuration as redacted JSON', async () => {
65
+ const result = await runLauncher(['--doctor', '--json'], {
66
+ ...process.env,
67
+ H2O_URL: '',
68
+ SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: '',
69
+ BAS_VSP_DESTINATION_SOURCE: ''
70
+ });
71
+ assert.equal(result.code, 1);
72
+ assert.equal(result.stderr, '');
73
+ assert.deepEqual(JSON.parse(result.stdout), {
74
+ ok: false,
75
+ destinations: 0,
76
+ checks: [{
77
+ name: '-',
78
+ stage: 'destination discovery',
79
+ status: 'failed',
80
+ detail: 'H2O_URL is required for BAS destination discovery'
81
+ }]
82
+ });
83
+ });
84
+
62
85
  test('list-destinations JSON is redacted and probes through the BAS proxy', async () => {
63
86
  const requests = [];
64
87
  const server = createServer((request, response) => {
@@ -98,9 +121,9 @@ test('runtime starts destinations even when their ADT probes fail', async () =>
98
121
  try {
99
122
  const result = await runLauncher([], {
100
123
  ...process.env,
101
- BAS_VSP_BINARY: fakeVsp,
124
+ SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
102
125
  H2O_URL: 'http://bas.example',
103
- BAS_VSP_DESTINATION: 'launch-system',
126
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'launch-system',
104
127
  HTTP_PROXY: proxy,
105
128
  http_proxy: proxy,
106
129
  NO_PROXY: '',
@@ -117,8 +140,21 @@ test('runtime starts destinations even when their ADT probes fail', async () =>
117
140
  });
118
141
  test('setup subprocess writes one isolated MCP entry per selected destination', async () => {
119
142
  const directory = await mkdtemp(join(tmpdir(), 'bas-launcher-setup-'));
143
+ const bin = join(directory, 'bin');
144
+ await mkdir(bin);
145
+ const cfCli = join(bin, 'cf');
146
+ await writeFile(cfCli, '#!/bin/sh\nexit 1\n');
147
+ await chmod(cfCli, 0o755);
120
148
  const config = join(directory, 'mcp.json');
121
- await writeFile(config, JSON.stringify({ inputs: [], servers: { unrelated: { type: 'stdio', command: 'other' } } }));
149
+ await writeFile(config, JSON.stringify({ inputs: [], servers: {
150
+ unrelated: { type: 'stdio', command: 'other' },
151
+ 'alpha-system': {
152
+ type: 'stdio',
153
+ command: 'bas-vsp-mcp',
154
+ env: { H2O_URL: 'http://old-h2o.example', BAS_VSP_DESTINATION: 'alpha-system' },
155
+ BAS_EXT: 'true'
156
+ }
157
+ } }));
122
158
  const server = createServer((request, response) => {
123
159
  if (request.url === '/api/listDestinations') {
124
160
  response.setHeader('content-type', 'application/json');
@@ -132,13 +168,14 @@ test('setup subprocess writes one isolated MCP entry per selected destination',
132
168
  response.end();
133
169
  });
134
170
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
135
- const env = { ...process.env, H2O_URL: `http://127.0.0.1:${server.address().port}`, BAS_VSP_SKIP_PROBE: 'true', BAS_VSP_MCP_CONFIG: config };
171
+ const env = { ...process.env, PATH: `${bin}:${process.env.PATH || ''}`, H2O_URL: `http://127.0.0.1:${server.address().port}`, SAP_AI_DEV_TOOLKIT_SKIP_PROBE: 'true', SAP_AI_DEV_TOOLKIT_MCP_CONFIG: config };
136
172
  try {
137
173
  const first = await runLauncherTty(env, 'a\r', ['--setup', '--npx']);
138
174
  assert.equal(first.code, 0, `${first.stdout}\n${first.stderr}`);
139
175
  let current = JSON.parse(await readFile(config, 'utf8'));
140
176
  let generated = Object.entries(current.servers).filter(([, entry]) => entry.BAS_EXT === 'true');
141
- assert.deepEqual(generated.map(([, entry]) => entry.env.BAS_VSP_DESTINATION), ['alpha-system', 'beta-system'], `${first.stdout}\n${first.stderr}`);
177
+ assert.deepEqual(generated.map(([, entry]) => entry.env.SAP_AI_DEV_TOOLKIT_DESTINATION), ['alpha-system', 'beta-system'], `${first.stdout}\n${first.stderr}`);
178
+ assert.equal(current.servers['alpha-system'].env.BAS_VSP_DESTINATION, undefined);
142
179
  assert.equal(generated.every(([, entry]) => entry.env.H2O_URL === env.H2O_URL), true);
143
180
  const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
144
181
  assert.deepEqual(generated.map(([, entry]) => [entry.command, entry.args]), [
@@ -45,15 +45,15 @@ test('writes source-qualified CF entries beside same-named BAS destinations', ()
45
45
  assert.deepEqual(entries['cf:space-one:instance-one:shared'].env, {
46
46
  H2O_URL: 'http://h2o.example',
47
47
  SAP_ALLOW_TRANSPORTABLE_EDITS: 'true',
48
- BAS_VSP_DESTINATION_SOURCE: 'cloud-foundry',
49
- BAS_VSP_DESTINATION: 'shared',
48
+ SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'cloud-foundry',
49
+ SAP_AI_DEV_TOOLKIT_DESTINATION: 'shared',
50
50
  BAS_CF_SPACE_GUID: 'space-one',
51
51
  BAS_CF_DESTINATION_INSTANCE_GUID: 'instance-one',
52
52
  BAS_CF_DESTINATION_INSTANCE: 'destination-instance-one',
53
53
  BAS_CF_DESTINATION_KEY: 'key-one',
54
54
  BAS_CF_DESTINATION_NAME: 'shared'
55
55
  });
56
- assert.equal(entries.shared.env.BAS_VSP_DESTINATION, 'shared');
56
+ assert.equal(entries.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
57
57
  assert.equal(JSON.stringify(entries).includes('shared.dest'), false);
58
58
  assert.equal(JSON.stringify(entries).includes('Password'), false);
59
59
 
@@ -75,14 +75,14 @@ test('extracts key references only from package-managed Cloud Foundry entries',
75
75
  duplicate: managedNpx,
76
76
  unmanaged: { ...cloudFoundry, BAS_EXT: undefined },
77
77
  otherPackage: { ...cloudFoundry, command: 'other' },
78
- otherSource: { ...cloudFoundry, env: { ...cloudFoundry.env, BAS_VSP_DESTINATION_SOURCE: 'bas' } }
78
+ otherSource: { ...cloudFoundry, env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'bas' } }
79
79
  } };
80
80
  assert.deepEqual(collectManagedCloudFoundryKeyReferences(config), [
81
81
  { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'destination-key' },
82
82
  { kind: 'connectivity', spaceGuid: 'space-one', instanceGuid: 'connectivity-guid', instanceName: 'connectivity-service', keyName: 'connectivity-key' }
83
83
  ]);
84
84
  const userConfig = { servers: {
85
- customServer: { command: 'custom-launcher', env: { ...cloudFoundry.env, BAS_VSP_DESTINATION_SOURCE: undefined } },
85
+ customServer: { command: 'custom-launcher', env: { ...cloudFoundry.env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined } },
86
86
  noEnvironment: { command: 'custom-launcher' }
87
87
  } };
88
88
  assert.deepEqual(collectCloudFoundryKeyReferencesFromAllEntries(userConfig), [
@@ -147,3 +147,47 @@ test('installs and removes CF entries without deleting unrelated MCP servers', a
147
147
  const cleared = JSON.parse(await readFile(path, 'utf8'));
148
148
  assert.deepEqual(Object.keys(cleared.servers), ['userServer']);
149
149
  });
150
+
151
+ test('wizard replaces legacy launcher entries with the branded command and environment', async t => {
152
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-toolkit-migration-'));
153
+ const path = join(directory, 'mcp.json');
154
+ t.after(() => rm(directory, { recursive: true, force: true }));
155
+ await writeFile(path, JSON.stringify({
156
+ servers: {
157
+ shared: {
158
+ type: 'stdio',
159
+ command: 'bas-vsp-mcp',
160
+ env: { H2O_URL: 'http://h2o.example', BAS_VSP_DESTINATION: 'shared' },
161
+ BAS_EXT: 'true'
162
+ },
163
+ userServer: { type: 'stdio', command: 'custom-server' }
164
+ }
165
+ }));
166
+
167
+ await installMcpConfig([bas], { env: { H2O_URL: 'http://h2o.example', BAS_VSP_MCP_CONFIG: path } });
168
+ const migrated = await readMcpConfig(path);
169
+ assert.deepEqual(Object.keys(migrated.servers).sort(), ['shared', 'userServer']);
170
+ assert.equal(migrated.servers.shared.command, 'sap-ai-dev');
171
+ assert.equal(migrated.servers.shared.env.SAP_AI_DEV_TOOLKIT_DESTINATION, 'shared');
172
+ assert.equal(migrated.servers.shared.env.BAS_VSP_DESTINATION, undefined);
173
+ assert.equal(migrated.servers.userServer.command, 'custom-server');
174
+ });
175
+
176
+ test('recognizes old npx Cloud Foundry entries so setup can reuse their service keys', () => {
177
+ const current = buildMcpEntries([cfDestination('space-one', 'instance-one', 'old-key')], { H2O_URL: 'http://h2o.example' })['cf:space-one:instance-one:shared'];
178
+ const legacy = {
179
+ ...current,
180
+ command: 'npx',
181
+ args: ['--yes', '--package=bas-mcp-addon@0.1.0', 'bas-vsp-mcp'],
182
+ env: {
183
+ ...current.env,
184
+ BAS_VSP_DESTINATION_SOURCE: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE,
185
+ BAS_VSP_DESTINATION: current.env.SAP_AI_DEV_TOOLKIT_DESTINATION
186
+ }
187
+ };
188
+ delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE;
189
+ delete legacy.env.SAP_AI_DEV_TOOLKIT_DESTINATION;
190
+ assert.deepEqual(collectManagedCloudFoundryKeyReferences({ servers: { legacy } }), [
191
+ { kind: 'destination', spaceGuid: 'space-one', instanceGuid: 'instance-one', instanceName: 'destination-instance-one', keyName: 'old-key' }
192
+ ]);
193
+ });
@@ -30,7 +30,7 @@ async function fixtureProxy(logger = () => {}) {
30
30
  const directory = await mkdtemp(join(tmpdir(), 'bas-vsp-test-'));
31
31
  const log = join(directory, 'children.log');
32
32
  const destinations = ['alpha', 'beta'].map(name => ({ name, url: `http://${name}.dest`, client: '001' }));
33
- const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, BAS_VSP_DESTINATION: 'alpha', BAS_VSP_MODE: 'expert', FAKE_LOG: log, SAP_ALLOW_TRANSPORTABLE_EDITS: 'true', SAP_USER: 'must-not-pass', SAP_PASSWORD: 'must-not-pass', Authorization: 'Bearer must-not-pass', Cookie: 'secret', SAP_READ_ONLY: 'true' }, log: logger });
33
+ const proxy = new MCPProxy({ binary: fixture, destinations, env: { ...process.env, SAP_AI_DEV_TOOLKIT_DESTINATION: 'alpha', SAP_AI_DEV_TOOLKIT_MODE: 'expert', FAKE_LOG: log, SAP_ALLOW_TRANSPORTABLE_EDITS: 'true', SAP_USER: 'must-not-pass', SAP_PASSWORD: 'must-not-pass', Authorization: 'Bearer must-not-pass', Cookie: 'secret', SAP_READ_ONLY: 'true' }, log: logger });
34
34
  proxy.start();
35
35
  return { directory, log, proxy };
36
36
  }
package/test/pty.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function spawnWithPty(command, options) {
4
+ if (process.platform === 'darwin') {
5
+ return spawn('python3', ['-c', 'import os,pty,sys; status=pty.spawn(["/bin/sh","-c",sys.argv[1]]); code=os.waitstatus_to_exitcode(status); sys.exit(code if code >= 0 else 128-code)', command], options);
6
+ }
7
+ return spawn('script', ['-qec', command, '/dev/null'], options);
8
+ }
@@ -1,12 +1,12 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
- import { spawn } from 'node:child_process';
5
4
  import { tmpdir } from 'node:os';
6
5
  import { join } from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import { installMcpConfig } from '../src/mcp-config.mjs';
9
8
  import { destinationTable } from '../scripts/postinstall.mjs';
9
+ import { spawnWithPty } from './pty.mjs';
10
10
 
11
11
  const setupModule = fileURLToPath(new URL('../src/setup.mjs', import.meta.url));
12
12
 
@@ -73,7 +73,7 @@ await runSetup({
73
73
  HOME: directory,
74
74
  PATH: `${bin}:${process.env.PATH || ''}`,
75
75
  H2O_URL: 'http://h2o.example',
76
- BAS_VSP_MCP_CONFIG: configPath,
76
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: configPath,
77
77
  CF_CALL_LOG: callLog,
78
78
  CF_DISCOVERY_LOG: discoveryLog,
79
79
  TEST_CF_VERSION: version,
@@ -90,7 +90,7 @@ await runSetup({
90
90
  function runSetupInPty(fixture, { importAnswer = 'y\r', selection = ' \r' } = {}) {
91
91
  return new Promise((resolve, reject) => {
92
92
  const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(fixture.harness)}`;
93
- const child = spawn('script', ['-qec', command, '/dev/null'], { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
93
+ const child = spawnWithPty(command, { env: fixture.env, stdio: ['pipe', 'pipe', 'pipe'] });
94
94
  let stdout = '';
95
95
  let stderr = '';
96
96
  let importSent = false;
@@ -323,7 +323,7 @@ test('preserves old CF keys referenced by an unrelated remaining MCP entry', asy
323
323
  const config = JSON.parse(await readFile(fixture.configPath, 'utf8'));
324
324
  config.servers.customServer = {
325
325
  command: 'custom-launcher',
326
- env: { ...config.servers[previous.serverName].env, BAS_VSP_DESTINATION_SOURCE: undefined }
326
+ env: { ...config.servers[previous.serverName].env, SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: undefined }
327
327
  };
328
328
  await writeFile(fixture.configPath, JSON.stringify(config));
329
329
  fixture.env.TEST_BAS_DESTINATIONS = JSON.stringify([{
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { stripVTControlCharacters } from 'node:util';
@@ -10,6 +10,7 @@ import { join } from 'node:path';
10
10
  import { pathToFileURL } from 'node:url';
11
11
  import { installMcpConfig } from '../src/mcp-config.mjs';
12
12
  import { runSetup } from '../src/setup.mjs';
13
+ import { spawnWithPty } from './pty.mjs';
13
14
 
14
15
  const destinations = [
15
16
  { name: 'alpha-system', client: '100', authentication: 'Basic', probe: { status: 'available', available: true } },
@@ -30,7 +31,7 @@ function outputStream() {
30
31
  function runSetupVisibilityInPty(fixture, env, keys = '\r') {
31
32
  return new Promise((resolve, reject) => {
32
33
  const command = `stty cols 48 rows 12; ${JSON.stringify(process.execPath)} ${JSON.stringify(fixture)}`;
33
- const child = spawn('script', ['-qec', command, '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
34
+ const child = spawnWithPty(command, { env, stdio: ['pipe', 'pipe', 'pipe'] });
34
35
  let stdout = '';
35
36
  let stderr = '';
36
37
  let selectionSent = false;
@@ -57,7 +58,7 @@ function runSetupVisibilityInPty(fixture, env, keys = '\r') {
57
58
  function runPostinstallInPty(env, keys, assetsAnswer = '\r') {
58
59
  return new Promise((resolve, reject) => {
59
60
  const command = `${JSON.stringify(process.execPath)} scripts/postinstall.mjs </dev/null | cat`;
60
- const child = spawn('script', ['-qec', command, '/dev/null'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
61
+ const child = spawnWithPty(command, { env, stdio: ['pipe', 'pipe', 'pipe'] });
61
62
  let stdout = '';
62
63
  let stderr = '';
63
64
  let selectionSent = false;
@@ -128,7 +129,7 @@ test('reconciles generated entries while preserving unrelated MCP config', async
128
129
  assert.deepEqual(Object.keys(config.servers).sort(), ['alpha-system', 'beta-system', 'unrelated']);
129
130
  assert.deepEqual(generated.map(([name]) => name), ['alpha-system', 'beta-system']);
130
131
  assert.equal(generated.length, 2);
131
- assert.deepEqual(generated.map(([, entry]) => entry.env.BAS_VSP_DESTINATION), ['alpha-system', 'beta-system']);
132
+ assert.deepEqual(generated.map(([, entry]) => entry.env.SAP_AI_DEV_TOOLKIT_DESTINATION), ['alpha-system', 'beta-system']);
132
133
  assert.deepEqual(generated.map(([, entry]) => entry.env.H2O_URL), ['http://new-h2o', 'http://new-h2o']);
133
134
  assert.deepEqual(generated.map(([, entry]) => entry.command), ['sap-ai-dev', 'sap-ai-dev']);
134
135
  assert.deepEqual(generated.map(([, entry]) => entry.env.SAP_ALLOW_TRANSPORTABLE_EDITS), ['true', 'true']);
@@ -187,13 +188,13 @@ const destinations = [
187
188
  await runSetup({
188
189
  env: { ...process.env, H2O_URL: 'http://h2o.example', PATH: ${JSON.stringify(`${bin}:${process.env.PATH || ''}`)} },
189
190
  discover: async () => destinations,
190
- install: async selected => ({ path: process.env.BAS_VSP_MCP_CONFIG, servers: Object.fromEntries(selected.map(destination => [destination.name, { env: { BAS_VSP_DESTINATION: destination.name } }])) })
191
+ install: async selected => ({ path: process.env.SAP_AI_DEV_TOOLKIT_MCP_CONFIG, servers: Object.fromEntries(selected.map(destination => [destination.name, { env: { SAP_AI_DEV_TOOLKIT_DESTINATION: destination.name } }])) })
191
192
  });
192
193
  `);
193
194
  try {
194
195
  const env = {
195
196
  ...process.env,
196
- BAS_VSP_MCP_CONFIG: join(directory, 'mcp.json'),
197
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: join(directory, 'mcp.json'),
197
198
  FORCE_COLOR: '1',
198
199
  PATH: `${bin}:${process.env.PATH || ''}`
199
200
  };
@@ -232,6 +233,11 @@ test('non-TTY setup skips without writing config', async () => {
232
233
  test('global postinstall completes BAS selection before optional Copilot assets', async t => {
233
234
  const directory = await mkdtemp(join(tmpdir(), 'bas-postinstall-wizard-'));
234
235
  const config = join(directory, 'mcp.json');
236
+ const bin = join(directory, 'bin');
237
+ await mkdir(bin);
238
+ const cfCli = join(bin, 'cf');
239
+ await writeFile(cfCli, '#!/bin/sh\nexit 1\n');
240
+ await chmod(cfCli, 0o755);
235
241
  const server = createServer((request, response) => {
236
242
  if (request.url === '/api/listDestinations') {
237
243
  response.writeHead(200, { 'content-type': 'application/json' });
@@ -249,9 +255,10 @@ test('global postinstall completes BAS selection before optional Copilot assets'
249
255
  const env = {
250
256
  ...process.env,
251
257
  HOME: directory,
258
+ PATH: `${bin}:${process.env.PATH || ''}`,
252
259
  BAS_VSP_BINARY: '/bin/true',
253
- BAS_VSP_MCP_CONFIG: config,
254
- BAS_VSP_SKIP_PROBE: 'true',
260
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: config,
261
+ SAP_AI_DEV_TOOLKIT_SKIP_PROBE: 'true',
255
262
  H2O_URL: `http://127.0.0.1:${server.address().port}`,
256
263
  HTTP_PROXY: '',
257
264
  http_proxy: '',
@@ -296,11 +303,11 @@ test('global postinstall completes BAS selection before optional Copilot assets'
296
303
  assert.ok(acceptLogs.includes(`MCP config file: ${config}`), acceptLogs);
297
304
  assert.ok(acceptLogs.includes('Destination: BAS · alpha-system · client 100 · Basic'), acceptLogs);
298
305
  assert.ok(acceptLogs.includes('Launch: stdio · sap-ai-dev'), acceptLogs);
299
- assert.ok(acceptLogs.includes('Environment keys: BAS_VSP_DESTINATION, H2O_URL, SAP_ALLOW_TRANSPORTABLE_EDITS'), acceptLogs);
306
+ assert.ok(acceptLogs.includes('Environment keys: H2O_URL, SAP_AI_DEV_TOOLKIT_DESTINATION, SAP_ALLOW_TRANSPORTABLE_EDITS'), acceptLogs);
300
307
  const configAfterAccept = JSON.parse(await readFile(config, 'utf8'));
301
308
  assert.deepEqual(Object.values(configAfterAccept.servers)
302
309
  .filter(entry => entry.BAS_EXT === 'true')
303
- .map(entry => entry.env.BAS_VSP_DESTINATION), ['alpha-system']);
310
+ .map(entry => entry.env.SAP_AI_DEV_TOOLKIT_DESTINATION), ['alpha-system']);
304
311
  await assertUserCopilotAssets(directory);
305
312
  });
306
313
 
@@ -311,7 +318,7 @@ test('non-TTY postinstall skips setup and leaves MCP config untouched', async ()
311
318
  const env = {
312
319
  ...process.env,
313
320
  HOME: directory,
314
- BAS_VSP_BINARY: '/bin/true',
321
+ SAP_AI_DEV_TOOLKIT_BINARY: '/bin/true',
315
322
  H2O_URL: 'http://bas.example'
316
323
  };
317
324
  delete env.npm_config_ignore_scripts;
@@ -319,7 +326,7 @@ test('non-TTY postinstall skips setup and leaves MCP config untouched', async ()
319
326
  try {
320
327
  for (const original of [undefined, JSON.stringify({ servers: { unrelated: { command: 'other' } } })]) {
321
328
  const config = join(directory, original === undefined ? 'new.json' : 'existing.json');
322
- env.BAS_VSP_MCP_CONFIG = config;
329
+ env.SAP_AI_DEV_TOOLKIT_MCP_CONFIG = config;
323
330
  if (original !== undefined) await writeFile(config, original);
324
331
  const result = await new Promise((resolve, reject) => {
325
332
  const child = spawn(process.execPath, ['scripts/postinstall.mjs'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -349,8 +356,8 @@ test('postinstall skips optional Copilot assets without an interactive terminal'
349
356
  const env = {
350
357
  ...process.env,
351
358
  HOME: join(directory, 'home'),
352
- BAS_VSP_BINARY: '/bin/true',
353
- BAS_VSP_MCP_CONFIG: config
359
+ SAP_AI_DEV_TOOLKIT_BINARY: '/bin/true',
360
+ SAP_AI_DEV_TOOLKIT_MCP_CONFIG: config
354
361
  };
355
362
  delete env.H2O_URL;
356
363
  delete env.npm_config_ignore_scripts;
@@ -374,3 +381,33 @@ test('postinstall skips optional Copilot assets without an interactive terminal'
374
381
  await rm(directory, { recursive: true, force: true });
375
382
  }
376
383
  });
384
+
385
+ test('postinstall runs when invoked through a symlinked install path', async () => {
386
+ const directory = await mkdtemp(join(tmpdir(), 'sap-ai-postinstall-symlink-'));
387
+ const alias = join(directory, 'postinstall.mjs');
388
+ await symlink(join(process.cwd(), 'scripts', 'postinstall.mjs'), alias);
389
+ const env = {
390
+ ...process.env,
391
+ HOME: directory,
392
+ SAP_AI_DEV_TOOLKIT_BINARY: '/bin/true'
393
+ };
394
+ delete env.H2O_URL;
395
+ delete env.npm_config_ignore_scripts;
396
+ delete env.NPM_CONFIG_IGNORE_SCRIPTS;
397
+ try {
398
+ const result = await new Promise((resolve, reject) => {
399
+ const child = spawn(process.execPath, [alias], { env, stdio: ['ignore', 'pipe', 'pipe'] });
400
+ let stdout = '';
401
+ let stderr = '';
402
+ child.stdout.on('data', chunk => { stdout += chunk; });
403
+ child.stderr.on('data', chunk => { stderr += chunk; });
404
+ child.on('error', reject);
405
+ child.on('exit', (code, signal) => resolve({ code, signal, stdout, stderr }));
406
+ });
407
+ const logs = `${result.stdout}\n${result.stderr}`;
408
+ assert.equal(result.code, 0, logs);
409
+ assert.match(logs, /BAS destination setup was skipped because H2O_URL is not set/);
410
+ } finally {
411
+ await rm(directory, { recursive: true, force: true });
412
+ }
413
+ });
package/tools.md CHANGED
@@ -65,4 +65,15 @@ The proxy forwards these tools when the installed VSP mode registers them. They
65
65
  - `CreateTransport`
66
66
  - `ListDependencies`
67
67
  - `PublishServiceBinding`
68
- - `UnpublishServiceBinding`
68
+ - `UnpublishServiceBinding`
69
+
70
+ ## Destination-scoped workflow tools
71
+
72
+ The proxy adds local multi-step tools to each destination when their required VSP capabilities are available:
73
+
74
+ - `PrepareABAPChangeSet` and `ApplyABAPChangeSet` — review full-source diffs and recheck source before explicit writes.
75
+ - `CheckTransportReadiness` — collect a whitelisted bundle of transport, dependency, inactive-object, ABAP Unit, and ATC results.
76
+ - `PlanABAPCloudMigration` — batch SAP API release-state checks and prioritize recognized unreleased results.
77
+ - `GenerateRAPRegressionSuite` and `RunRAPRegressionSuite` — create and run reusable OData GET-only checks under the selected service root.
78
+
79
+ `sap-ai-dev-toolkit --doctor` checks destination probing, VSP startup, `GetSystemInfo`, 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.