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.
- package/.github/agents/abap-developer.agent.md +4 -3
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +2 -1
- package/.github/skills/rap-service-delivery/SKILL.md +4 -1
- package/.github/skills/sap-transport-release/SKILL.md +2 -2
- package/README.md +82 -34
- package/package.json +1 -1
- package/scripts/ensure-go.mjs +5 -4
- package/scripts/install-user-copilot-assets.mjs +6 -1
- package/scripts/postinstall.mjs +11 -11
- package/src/bas-discovery.mjs +3 -2
- package/src/binary.mjs +27 -17
- package/src/branding.mjs +21 -0
- package/src/cf-destination.mjs +1 -1
- package/src/demo-vsp.mjs +113 -0
- package/src/engineering-tools.mjs +641 -0
- package/src/launcher.mjs +118 -11
- package/src/mcp-config.mjs +19 -10
- package/src/mcp-proxy.mjs +18 -4
- package/src/setup.mjs +5 -6
- package/test/cf-destination.test.mjs +2 -2
- package/test/cf-runtime.test.mjs +12 -12
- package/test/copilot-assets.test.mjs +26 -0
- package/test/discovery.test.mjs +10 -1
- package/test/launcher.test.mjs +46 -9
- package/test/mcp-config-cf.test.mjs +49 -5
- package/test/mcp-proxy.test.mjs +1 -1
- package/test/pty.mjs +8 -0
- package/test/setup-cf.test.mjs +4 -4
- package/test/setup.test.mjs +51 -14
- package/tools.md +12 -1
package/test/launcher.test.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
124
|
+
SAP_AI_DEV_TOOLKIT_BINARY: fakeVsp,
|
|
102
125
|
H2O_URL: 'http://bas.example',
|
|
103
|
-
|
|
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: {
|
|
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}`,
|
|
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.
|
|
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
|
-
|
|
49
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
+
});
|
package/test/mcp-proxy.test.mjs
CHANGED
|
@@ -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,
|
|
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
|
+
}
|
package/test/setup-cf.test.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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,
|
|
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([{
|
package/test/setup.test.mjs
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
353
|
-
|
|
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.
|