sap-ai-dev-toolkit 0.6.5 → 0.6.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.
- package/.github/agents/abap-developer.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/agents/sap-solution-architect.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +1 -1
- package/.github/skills/abap-testing-quality/SKILL.md +1 -1
- package/.github/skills/rap-development/SKILL.md +1 -1
- package/.github/skills/rap-service-delivery/SKILL.md +1 -1
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +1 -1
- package/README.md +26 -20
- package/package.json +1 -2
- package/patches/vsp-bas-proxy-auth.patch +125 -0
- package/scripts/install-local.mjs +2 -1
- package/scripts/install-user-copilot-assets.mjs +4 -112
- package/scripts/postinstall.mjs +64 -28
- package/src/abaplint.mjs +2 -2
- package/src/harnesses.mjs +93 -0
- package/src/install-user-assets.mjs +149 -0
- package/src/launcher.mjs +3 -36
- package/src/mcp-proxy.mjs +5 -51
- package/dist/checksums.txt +0 -5
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/src/bas-destination-relay.mjs +0 -377
package/scripts/postinstall.mjs
CHANGED
|
@@ -4,11 +4,11 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { installBinary } from '../src/binary.mjs';
|
|
6
6
|
import { runSetup } from '../src/setup.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { installUserAssetsForHarnesses } from '../src/install-user-assets.mjs';
|
|
8
|
+
import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from '../src/harnesses.mjs';
|
|
9
9
|
import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
-
import { colorText, formatStatus } from '../src/terminal-ui.mjs';
|
|
11
|
+
import { checkboxPrompt, colorText, formatStatus } from '../src/terminal-ui.mjs';
|
|
12
12
|
import { brandedEnvValue, withBrandedEnvironment } from '../src/branding.mjs';
|
|
13
13
|
import { generatedServerName } from '../src/mcp-config.mjs';
|
|
14
14
|
import { redactText } from '../src/redact.mjs';
|
|
@@ -67,47 +67,83 @@ async function runInstallSetup() {
|
|
|
67
67
|
return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}) }));
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
function harnessSelectionLabels(ids) {
|
|
71
|
+
return HARNESSES.filter(harness => ids.includes(harness.id)).map(harness => harness.label).join(', ');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function resolveHarnessOverride() {
|
|
75
|
+
try {
|
|
76
|
+
return envHarnessSelection(runtimeEnv);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
await announce(`${error.message} Ignoring ${HARNESS_ENV_VAR}.`, 'warning');
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function installSelectedHarnessAssets(ids) {
|
|
84
|
+
const { results, failures } = await installUserAssetsForHarnesses(ids, { root });
|
|
85
|
+
for (const failure of failures) {
|
|
86
|
+
await announce(`${failure.label}: bundled agent and skill installation failed: ${failure.error.message}`, 'error');
|
|
87
|
+
}
|
|
88
|
+
for (const assets of results) {
|
|
74
89
|
const installed = assets.installed + assets.updated;
|
|
75
|
-
await announce(
|
|
90
|
+
await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current.`, 'success');
|
|
76
91
|
if (assets.conflicts.length > 0) {
|
|
77
92
|
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
78
|
-
await announce(
|
|
93
|
+
await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
79
94
|
}
|
|
80
|
-
}
|
|
95
|
+
}
|
|
96
|
+
const total = results.reduce((sum, assets) => sum + assets.installed + assets.updated, 0);
|
|
97
|
+
if (results.length > 0) {
|
|
98
|
+
await announce(`Installed ${total} files across ${results.length} harness${results.length === 1 ? '' : 'es'}.`, 'success');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runHarnessAssetInstall() {
|
|
103
|
+
const home = process.env.HOME || homedir();
|
|
104
|
+
const override = await resolveHarnessOverride();
|
|
81
105
|
await withInstallTerminal(async terminal => {
|
|
106
|
+
let selected = override;
|
|
82
107
|
if (!terminal) {
|
|
83
|
-
|
|
84
|
-
await
|
|
108
|
+
if (!selected) selected = NON_INTERACTIVE_HARNESS_IDS;
|
|
109
|
+
await announce(`Installing bundled agents and skills for ${harnessSelectionLabels(selected)} by default because no interactive terminal is available.`, 'progress');
|
|
110
|
+
await installSelectedHarnessAssets(selected);
|
|
85
111
|
return;
|
|
86
112
|
}
|
|
113
|
+
if (!selected) selected = DEFAULT_HARNESS_IDS;
|
|
87
114
|
await announce([
|
|
88
|
-
'
|
|
89
|
-
'',
|
|
90
|
-
'Type n then press Enter to skip installing or updating the bundled files.',
|
|
115
|
+
'The bundled agents and skills can be installed for several AI coding harnesses.',
|
|
91
116
|
'',
|
|
92
|
-
|
|
93
|
-
`${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this step.`,
|
|
117
|
+
'Space = select or deselect · a = toggle all · Enter = confirm. Pressing Enter without changes installs the pre-checked harnesses; confirming with none selected skips this step.',
|
|
94
118
|
'',
|
|
95
|
-
`${colorText('📁 Target
|
|
96
|
-
` ${
|
|
119
|
+
`${colorText('📁 Target folders:', 'cyan', true)}`,
|
|
120
|
+
...HARNESSES.map(harness => ` ${harnessRoot(harness, { home, env: runtimeEnv })} — ${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills only'})`),
|
|
97
121
|
''
|
|
98
122
|
].join('\n'), 'copilot');
|
|
99
|
-
const prompt = createInterface({ input: terminal.input, output: terminal.output });
|
|
100
123
|
let answer;
|
|
101
124
|
try {
|
|
102
|
-
answer = await
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
answer = await checkboxPrompt({
|
|
126
|
+
message: colorText('🤖 Install bundled agents and skills for', 'magenta', terminal.output),
|
|
127
|
+
choices: HARNESSES.map(harness => ({
|
|
128
|
+
value: harness.id,
|
|
129
|
+
name: `${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills'})`,
|
|
130
|
+
checked: selected.includes(harness.id)
|
|
131
|
+
})),
|
|
132
|
+
required: false,
|
|
133
|
+
shortcuts: { all: 'a' }
|
|
134
|
+
}, { input: terminal.input, output: terminal.output });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.message === 'Prompt interrupted') {
|
|
137
|
+
await announce('Bundled agents and skills were skipped because the prompt was interrupted. Your files were not changed.', 'info');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
105
141
|
}
|
|
106
|
-
if (
|
|
107
|
-
await announce('
|
|
142
|
+
if (answer.length === 0) {
|
|
143
|
+
await announce('Bundled agents and skills were skipped. Your files were not changed.', 'info');
|
|
108
144
|
return;
|
|
109
145
|
}
|
|
110
|
-
await
|
|
146
|
+
await installSelectedHarnessAssets(answer);
|
|
111
147
|
});
|
|
112
148
|
}
|
|
113
149
|
|
|
@@ -256,9 +292,9 @@ async function main() {
|
|
|
256
292
|
}
|
|
257
293
|
|
|
258
294
|
try {
|
|
259
|
-
await
|
|
295
|
+
await runHarnessAssetInstall();
|
|
260
296
|
} catch (error) {
|
|
261
|
-
await announce(`
|
|
297
|
+
await announce(`Bundled agent and skill installation failed: ${error.message}`, 'error');
|
|
262
298
|
}
|
|
263
299
|
if (setupCompleted) await announceSetup(setupResult);
|
|
264
300
|
}
|
package/src/abaplint.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { Config, MemoryFile, Registry } from '@abaplint/core';
|
|
|
2
2
|
|
|
3
3
|
export const ABAP_LINT_TOOL = {
|
|
4
4
|
name: 'LintABAP',
|
|
5
|
-
description: 'Run abaplint on caller-supplied
|
|
5
|
+
description: 'Run abaplint on caller-supplied source files in memory; does not read workspace files or call SAP.',
|
|
6
6
|
inputSchema: {
|
|
7
7
|
type: 'object',
|
|
8
8
|
properties: {
|
|
@@ -50,7 +50,7 @@ function validateArguments(arguments_) {
|
|
|
50
50
|
}
|
|
51
51
|
const basename = file.filename.split(/[\\/]/).at(-1);
|
|
52
52
|
if (!/^[^.]+\.[^.]+\.[^.]+$/.test(basename)) {
|
|
53
|
-
throw new Error('Each filename must have an
|
|
53
|
+
throw new Error('Each filename must have an object.type.extension basename');
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
if (arguments_.config !== undefined && !isObject(arguments_.config)) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HARNESS_ENV_VAR = 'SAP_AI_DEV_TOOLKIT_HARNESSES';
|
|
5
|
+
export const DEFAULT_HARNESS_IDS = ['github-copilot', 'claude-code'];
|
|
6
|
+
export const NON_INTERACTIVE_HARNESS_IDS = ['github-copilot'];
|
|
7
|
+
|
|
8
|
+
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n/;
|
|
9
|
+
|
|
10
|
+
function splitFrontmatter(source) {
|
|
11
|
+
const match = FRONTMATTER_PATTERN.exec(String(source));
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
const fields = match[1].split('\n').map(line => {
|
|
14
|
+
const separator = line.indexOf(':');
|
|
15
|
+
return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
|
|
16
|
+
});
|
|
17
|
+
if (fields.some(field => !field.key)) return null;
|
|
18
|
+
return { fields, body: String(source).slice(match[0].length) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requiredField(fields, key) {
|
|
22
|
+
const field = fields.find(entry => entry.key === key);
|
|
23
|
+
return field && field.value ? field.value : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderDocument(fields, body) {
|
|
27
|
+
return `---\n${fields.map(field => `${field.key}: ${field.value}`).join('\n')}\n---\n${body}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function agentMarkdownName(fileName) {
|
|
31
|
+
return String(fileName).replace(/\.agent\.md$/, '.md');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function claudeCodeAgentDocument(source) {
|
|
35
|
+
const frontmatter = splitFrontmatter(source);
|
|
36
|
+
if (!frontmatter) throw new Error('unsupported agent frontmatter');
|
|
37
|
+
const kept = frontmatter.fields
|
|
38
|
+
.filter(field => field.key === 'name' || field.key === 'description')
|
|
39
|
+
.filter(field => field.value);
|
|
40
|
+
if (!requiredField(frontmatter.fields, 'name') || !requiredField(frontmatter.fields, 'description')) {
|
|
41
|
+
throw new Error('unsupported agent frontmatter');
|
|
42
|
+
}
|
|
43
|
+
return renderDocument(kept, frontmatter.body);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function opencodeAgentDocument(source) {
|
|
47
|
+
const frontmatter = splitFrontmatter(source);
|
|
48
|
+
if (!frontmatter) throw new Error('unsupported agent frontmatter');
|
|
49
|
+
const description = requiredField(frontmatter.fields, 'description');
|
|
50
|
+
if (!description) throw new Error('unsupported agent frontmatter');
|
|
51
|
+
return renderDocument([{ key: 'description', value: description }, { key: 'mode', value: 'subagent' }], frontmatter.body);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const HARNESSES = [
|
|
55
|
+
{ id: 'github-copilot', label: 'GitHub Copilot', directory: '.copilot', supportsAgents: true, legacyManifest: true },
|
|
56
|
+
{ id: 'claude-code', label: 'Claude Code', directory: '.claude', supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: claudeCodeAgentDocument },
|
|
57
|
+
{ id: 'codex', label: 'OpenAI Codex', directory: '.agents', supportsAgents: false },
|
|
58
|
+
{ id: 'cursor', label: 'Cursor', directory: '.cursor', supportsAgents: false },
|
|
59
|
+
{ id: 'gemini-cli', label: 'Gemini CLI', directory: '.gemini', supportsAgents: false },
|
|
60
|
+
{ id: 'opencode', label: 'opencode', directory: 'opencode', configHome: true, supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: opencodeAgentDocument }
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export function harnessById(id) {
|
|
64
|
+
const harness = HARNESSES.find(entry => entry.id === id);
|
|
65
|
+
if (!harness) {
|
|
66
|
+
throw new Error(`Unknown harness id: ${id}. Valid harness ids: ${HARNESSES.map(entry => entry.id).join(', ')}.`);
|
|
67
|
+
}
|
|
68
|
+
return harness;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
|
|
72
|
+
if (harness.configHome) {
|
|
73
|
+
const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
74
|
+
return join(override || join(home, '.config'), harness.directory);
|
|
75
|
+
}
|
|
76
|
+
return join(home, harness.directory);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseHarnessSelection(raw) {
|
|
80
|
+
const tokens = [...new Set(String(raw).split(',').map(token => token.trim().toLowerCase()).filter(Boolean))];
|
|
81
|
+
const known = new Set(HARNESSES.map(entry => entry.id));
|
|
82
|
+
const unknown = tokens.filter(token => !known.has(token));
|
|
83
|
+
if (unknown.length > 0) {
|
|
84
|
+
throw new Error(`Unknown harness id${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. Valid harness ids: ${[...known].join(', ')}.`);
|
|
85
|
+
}
|
|
86
|
+
return HARNESSES.filter(entry => tokens.includes(entry.id)).map(entry => entry.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function envHarnessSelection(env = process.env) {
|
|
90
|
+
const raw = env?.[HARNESS_ENV_VAR];
|
|
91
|
+
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
92
|
+
return parseHarnessSelection(raw);
|
|
93
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { HARNESSES, harnessById, harnessRoot } from './harnesses.mjs';
|
|
7
|
+
|
|
8
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
export const MANIFEST_NAME = '.sap-ai-dev-toolkit-assets.json';
|
|
10
|
+
const LEGACY_MANIFEST_NAME = '.bas-mcp-addon-assets.json';
|
|
11
|
+
|
|
12
|
+
function digest(content) {
|
|
13
|
+
return createHash('sha256').update(content).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function collectFiles(sourceRoot, targetPrefix, relativePath = '') {
|
|
17
|
+
const files = [];
|
|
18
|
+
const entries = await readdir(join(sourceRoot, relativePath), { withFileTypes: true });
|
|
19
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
const childPath = join(relativePath, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
files.push(...await collectFiles(sourceRoot, targetPrefix, childPath));
|
|
24
|
+
} else if (entry.isFile()) {
|
|
25
|
+
files.push({ source: join(sourceRoot, childPath), target: join(targetPrefix, childPath) });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readManagedFiles(path) {
|
|
32
|
+
try {
|
|
33
|
+
const manifest = JSON.parse(await readFile(path, 'utf8'));
|
|
34
|
+
if (manifest === null || typeof manifest !== 'object' || manifest.version !== 1 || manifest.files === null || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) return {};
|
|
35
|
+
return manifest.files;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function replaceFile(path, content) {
|
|
43
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(temporary, content, { flag: 'wx', mode: 0o644 });
|
|
46
|
+
await rename(temporary, path);
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(temporary, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function collectSourceFiles(root) {
|
|
53
|
+
const sourceRoot = join(root, '.github');
|
|
54
|
+
const files = [
|
|
55
|
+
...await collectFiles(join(sourceRoot, 'agents'), 'agents'),
|
|
56
|
+
...await collectFiles(join(sourceRoot, 'skills'), 'skills')
|
|
57
|
+
];
|
|
58
|
+
if (!files.some(file => file.target === join('agents', 'abap-developer.agent.md'))) {
|
|
59
|
+
throw new Error('Packaged ABAP Developer agent profile is missing');
|
|
60
|
+
}
|
|
61
|
+
if (!files.some(file => file.target.startsWith(`skills${sep}`) && file.target.endsWith('SKILL.md'))) {
|
|
62
|
+
throw new Error('Packaged Agent Skills are missing');
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function planDestinations(harness, files) {
|
|
68
|
+
const destinations = [];
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
const raw = await readFile(file.source);
|
|
71
|
+
if (!file.target.startsWith(`agents${sep}`)) {
|
|
72
|
+
destinations.push({ target: file.target, content: raw });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!harness.supportsAgents) continue;
|
|
76
|
+
const fileName = harness.agentFileName ? harness.agentFileName(basename(file.target)) : basename(file.target);
|
|
77
|
+
const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
|
|
78
|
+
destinations.push({ target: join('agents', fileName), content });
|
|
79
|
+
}
|
|
80
|
+
return destinations;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function installUserAssets({ harness, home = process.env.HOME || homedir(), root = packageRoot, env = process.env } = {}) {
|
|
84
|
+
const destinations = await planDestinations(harness, await collectSourceFiles(root));
|
|
85
|
+
const targetRoot = harnessRoot(harness, { home, env });
|
|
86
|
+
const manifestPath = join(targetRoot, MANIFEST_NAME);
|
|
87
|
+
const legacyManifestPath = join(targetRoot, LEGACY_MANIFEST_NAME);
|
|
88
|
+
await mkdir(targetRoot, { recursive: true, mode: 0o755 });
|
|
89
|
+
const legacyFiles = harness.legacyManifest ? await readManagedFiles(legacyManifestPath) : {};
|
|
90
|
+
const currentFiles = await readManagedFiles(manifestPath);
|
|
91
|
+
const previousFiles = { ...legacyFiles, ...currentFiles };
|
|
92
|
+
const managedFiles = { ...previousFiles };
|
|
93
|
+
const conflicts = [];
|
|
94
|
+
let installed = 0;
|
|
95
|
+
let updated = 0;
|
|
96
|
+
let unchanged = 0;
|
|
97
|
+
|
|
98
|
+
for (const file of destinations) {
|
|
99
|
+
const destination = join(targetRoot, file.target);
|
|
100
|
+
const contentHash = digest(file.content);
|
|
101
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
|
|
102
|
+
|
|
103
|
+
let existing;
|
|
104
|
+
try {
|
|
105
|
+
existing = await readFile(destination);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error.code !== 'ENOENT') throw error;
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(destination, file.content, { flag: 'wx', mode: 0o644 });
|
|
110
|
+
managedFiles[file.target] = contentHash;
|
|
111
|
+
installed += 1;
|
|
112
|
+
continue;
|
|
113
|
+
} catch (writeError) {
|
|
114
|
+
if (writeError.code !== 'EEXIST') throw writeError;
|
|
115
|
+
existing = await readFile(destination);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const existingHash = digest(existing);
|
|
120
|
+
if (existingHash === contentHash) {
|
|
121
|
+
managedFiles[file.target] = contentHash;
|
|
122
|
+
unchanged += 1;
|
|
123
|
+
} else if (previousFiles[file.target] === existingHash) {
|
|
124
|
+
await replaceFile(destination, file.content);
|
|
125
|
+
managedFiles[file.target] = contentHash;
|
|
126
|
+
updated += 1;
|
|
127
|
+
} else {
|
|
128
|
+
conflicts.push(file.target);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
|
|
133
|
+
if (harness.legacyManifest) await rm(legacyManifestPath, { force: true });
|
|
134
|
+
return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, conflicts };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function installUserAssetsForHarnesses(ids, { home, root = packageRoot, env = process.env } = {}) {
|
|
138
|
+
const results = [];
|
|
139
|
+
const failures = [];
|
|
140
|
+
for (const id of ids) {
|
|
141
|
+
try {
|
|
142
|
+
results.push(await installUserAssets({ harness: harnessById(id), home, root, env }));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const entry = HARNESSES.find(harness => harness.id === id);
|
|
145
|
+
failures.push({ id, label: entry ? entry.label : id, error });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { results, failures };
|
|
149
|
+
}
|
package/src/launcher.mjs
CHANGED
|
@@ -89,16 +89,6 @@ async function runDoctor(destinations) {
|
|
|
89
89
|
const initialized = await proxy.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } });
|
|
90
90
|
if (initialized?.error) throw new Error(initialized.error.message || 'MCP initialization failed');
|
|
91
91
|
checks.push(doctorRow(destination.name, 'VSP MCP startup', 'passed', 'initialized'));
|
|
92
|
-
const relayedDestination = proxy.children[0]?.destination;
|
|
93
|
-
if (relayedDestination?.relay) {
|
|
94
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'passed', `enabled (${relayedDestination.url}); runtime session preflight pending`));
|
|
95
|
-
} else if (destination.source === 'cloud-foundry') {
|
|
96
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Cloud Foundry destinations connect through the connectivity proxy directly.'));
|
|
97
|
-
} else if (String(runtimeEnv.SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY || '').toLowerCase() === 'true') {
|
|
98
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Disabled through SAP_AI_DEV_TOOLKIT_DISABLE_BAS_RELAY.'));
|
|
99
|
-
} else {
|
|
100
|
-
checks.push(doctorRow(destination.name, 'BAS destination relay', 'skipped', 'Not applicable for this destination.'));
|
|
101
|
-
}
|
|
102
92
|
const listed = await proxy.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
103
93
|
if (listed?.error) throw new Error(listed.error.message || 'MCP tools/list failed');
|
|
104
94
|
const tools = listed.result?.tools || [];
|
|
@@ -114,32 +104,9 @@ async function runDoctor(destinations) {
|
|
|
114
104
|
if (!systemInfo) {
|
|
115
105
|
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', 'GetSystemInfo is not exposed by this VSP mode.'));
|
|
116
106
|
} else {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
let preflightError;
|
|
121
|
-
try {
|
|
122
|
-
session = await relayedDestination.relay.probeCsrfSession();
|
|
123
|
-
} catch (error) {
|
|
124
|
-
preflightError = redactText(error.message || error).slice(0, 250);
|
|
125
|
-
}
|
|
126
|
-
sessionReady = session?.sessionUsable === true;
|
|
127
|
-
const status = sessionReady ? 'passed' : 'failed';
|
|
128
|
-
const stats = relayedDestination.relay.stats || {};
|
|
129
|
-
const detail = preflightError
|
|
130
|
-
? `preflight request failed: ${preflightError}`
|
|
131
|
-
: `HTTP ${session?.httpStatus || 0}; token=${session?.tokenReceived ? 'received' : 'missing'}; cookies=${session?.cookieCount || 0}; usableSession=${sessionReady}; csrfFetches=${stats.csrfFetches || 0}; csrfRetries=${stats.csrfRetries || 0}; sessionFailures=${stats.csrfSessionFailures || 0}; tunnelFallbacks=${stats.tunnelFallbacks || 0}`;
|
|
132
|
-
checks.push(doctorRow(destination.name, 'BAS relay CSRF preflight', status, detail));
|
|
133
|
-
if (!sessionReady) {
|
|
134
|
-
const remediation = 'GetSystemInfo was not called because the relay could not establish a paired CSRF token/session. Confirm the configured destination authentication and BAS proxy route.';
|
|
135
|
-
checks.push(doctorRow(destination.name, 'SAP system check', 'skipped', preflightError ? `${remediation} Relay error: ${preflightError}` : remediation));
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (sessionReady) {
|
|
139
|
-
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
140
|
-
const failed = inspected?.error || inspected?.result?.isError;
|
|
141
|
-
checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
|
|
142
|
-
}
|
|
107
|
+
const inspected = await proxy.handle({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: systemInfo.name, arguments: {} } });
|
|
108
|
+
const failed = inspected?.error || inspected?.result?.isError;
|
|
109
|
+
checks.push(doctorRow(destination.name, 'SAP system check', failed ? 'failed' : 'passed', failed ? redactText(inspected?.error?.message || inspected?.result?.content?.[0]?.text || 'GetSystemInfo failed') : 'GetSystemInfo returned successfully'));
|
|
143
110
|
}
|
|
144
111
|
} catch (error) {
|
|
145
112
|
const detail = redactText(error.message || error).slice(0, 300);
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
|
4
4
|
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
|
-
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
8
7
|
import { diagnosticText, redactText } from './redact.mjs';
|
|
9
8
|
|
|
10
9
|
const JSONRPC = '2.0';
|
|
@@ -167,12 +166,6 @@ function closeDestinationRoute(destination) {
|
|
|
167
166
|
return closedRoutes.get(destination);
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
function useBasDestinationRelay(destination, env = process.env) {
|
|
171
|
-
if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
|
|
172
|
-
if (destination.source === 'cloud-foundry') return false;
|
|
173
|
-
if (!destination.authentication) return false;
|
|
174
|
-
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
175
|
-
}
|
|
176
169
|
|
|
177
170
|
function useProxyAuthentication(destination) {
|
|
178
171
|
return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
|
|
@@ -190,17 +183,6 @@ function childEnvironment(destination, env) {
|
|
|
190
183
|
'SAP_SAML_AUTH', 'SAP_SAML_USER', 'SAP_SAML_PASSWORD', 'SAP_CREDENTIAL_CMD'
|
|
191
184
|
]) childEnv[key] = '';
|
|
192
185
|
}
|
|
193
|
-
// The VSP child must reach the local BAS relay directly, never through the
|
|
194
|
-
// BAS .dest proxy. Without a loopback exception every child request would
|
|
195
|
-
// be double-proxied and the relay's session pairing could never apply.
|
|
196
|
-
if (destination.relay) {
|
|
197
|
-
const loopback = '127.0.0.1,localhost';
|
|
198
|
-
for (const key of ['NO_PROXY', 'no_proxy']) {
|
|
199
|
-
const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
|
|
200
|
-
for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
|
|
201
|
-
childEnv[key] = entries.join(',');
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
186
|
return childEnv;
|
|
205
187
|
}
|
|
206
188
|
|
|
@@ -245,14 +227,12 @@ class Child {
|
|
|
245
227
|
this.process.on('error', error => {
|
|
246
228
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
247
229
|
this.fail(error);
|
|
248
|
-
|
|
249
|
-
// them; they are closed by the proxy shutdown path instead.
|
|
250
|
-
if (!destination.relay) void closeDestinationRoute(destination);
|
|
230
|
+
void closeDestinationRoute(destination);
|
|
251
231
|
});
|
|
252
232
|
this.process.on('exit', (code, signal) => {
|
|
253
233
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
|
|
254
234
|
this.fail(new Error(`child exited (${code ?? signal})`));
|
|
255
|
-
|
|
235
|
+
void closeDestinationRoute(destination);
|
|
256
236
|
});
|
|
257
237
|
}
|
|
258
238
|
|
|
@@ -442,34 +422,10 @@ export class MCPProxy {
|
|
|
442
422
|
if (this.started) return this;
|
|
443
423
|
this.started = true;
|
|
444
424
|
this.starting = (async () => {
|
|
445
|
-
// Destinations start concurrently
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
const started = await Promise.all(this.destinations.map(originalDestination => (async () => {
|
|
449
|
-
let destination = originalDestination;
|
|
450
|
-
let relay;
|
|
425
|
+
// Destinations start concurrently and are assembled in destination order,
|
|
426
|
+
// keeping serverInfo's children[0] fallback and merged slug numbering deterministic.
|
|
427
|
+
const started = await Promise.all(this.destinations.map(destination => (async () => {
|
|
451
428
|
try {
|
|
452
|
-
if (useBasDestinationRelay(originalDestination, this.env)) {
|
|
453
|
-
relay = createBasDestinationRelay(originalDestination, { env: this.env, log: message => this.eventSink(message) });
|
|
454
|
-
const originalClose = originalDestination.close;
|
|
455
|
-
const relayUrl = await relay.ready;
|
|
456
|
-
destination = {
|
|
457
|
-
...originalDestination,
|
|
458
|
-
url: relayUrl,
|
|
459
|
-
relay: {
|
|
460
|
-
probeCsrfSession: relay.probeCsrfSession,
|
|
461
|
-
stats: relay.stats
|
|
462
|
-
},
|
|
463
|
-
async close() {
|
|
464
|
-
try {
|
|
465
|
-
await originalClose?.();
|
|
466
|
-
} finally {
|
|
467
|
-
await relay.close();
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
this.eventSink(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
472
|
-
}
|
|
473
429
|
this.eventSink(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
474
430
|
return {
|
|
475
431
|
destination,
|
|
@@ -477,8 +433,6 @@ export class MCPProxy {
|
|
|
477
433
|
};
|
|
478
434
|
} catch (error) {
|
|
479
435
|
this.eventSink(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`, 'error');
|
|
480
|
-
// A relay whose ready promise rejected still owns dispatchers.
|
|
481
|
-
if (relay && destination === originalDestination) await relay.close().catch(() => {});
|
|
482
436
|
void closeDestinationRoute(destination);
|
|
483
437
|
return null;
|
|
484
438
|
}
|
package/dist/checksums.txt
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
78c0f6b7adf151a4c83ee3a757752aa409225fc75af64dfe728aed68dc5cf598 vsp-linux-x64
|
|
2
|
-
4ceaf3ef3519431aa5ebe7d7c14569cf7c7035956dbfc9342c8fa9b6343a5380 vsp-linux-arm64
|
|
3
|
-
f8d075f1ef955f8bb4be5b1d9ec3ffc86e84349b8a141265cf39e74c9fd83246 vsp-darwin-x64
|
|
4
|
-
25e19e0d7317fe5adcb3c670b5fcf1da1caafa3c1bf8a6783c4ff00b312b5194 vsp-darwin-arm64
|
|
5
|
-
c67c921ae884abeacaa39782c5524f58ec58bdbccc0d7097ee8a2499c04331ac vsp-win32-x64.exe
|
package/dist/vsp-darwin-arm64
DELETED
|
Binary file
|
package/dist/vsp-darwin-x64
DELETED
|
Binary file
|
package/dist/vsp-linux-arm64
DELETED
|
Binary file
|
package/dist/vsp-linux-x64
DELETED
|
Binary file
|
package/dist/vsp-win32-x64.exe
DELETED
|
Binary file
|