sap-ai-dev-toolkit 0.2.5
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 +25 -0
- package/.github/agents/abap-runtime-debugger.agent.md +27 -0
- package/.github/agents/rap-service-developer.agent.md +27 -0
- package/.github/agents/sap-solution-architect.agent.md +31 -0
- package/.github/skills/abap-debugging/SKILL.md +15 -0
- package/.github/skills/abap-development/SKILL.md +16 -0
- package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
- package/.github/skills/abap-testing-quality/SKILL.md +19 -0
- package/.github/skills/cds-development/SKILL.md +16 -0
- package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
- package/.github/skills/rap-development/SKILL.md +16 -0
- package/.github/skills/rap-service-delivery/SKILL.md +17 -0
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
- package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
- package/.github/skills/sap-transport-release/SKILL.md +14 -0
- package/LICENSE +22 -0
- package/LICENSE-APACHE-2.0.txt +201 -0
- package/NOTICE +33 -0
- package/README.md +723 -0
- package/dist/checksums.txt +5 -0
- 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/package.json +52 -0
- package/patches/vsp-bas-proxy-auth.patch +310 -0
- package/scripts/build-vsp.mjs +52 -0
- package/scripts/ensure-go.mjs +59 -0
- package/scripts/install-user-copilot-assets.mjs +110 -0
- package/scripts/mutation-terminal-ui.mjs +69 -0
- package/scripts/postinstall.mjs +269 -0
- package/scripts/publish-npm.mjs +97 -0
- package/src/abaplint.mjs +105 -0
- package/src/bas-discovery.mjs +210 -0
- package/src/binary.mjs +99 -0
- package/src/cf-connectivity.mjs +317 -0
- package/src/cf-destination.mjs +793 -0
- package/src/launcher.mjs +144 -0
- package/src/mcp-config.mjs +253 -0
- package/src/mcp-proxy.mjs +388 -0
- package/src/setup.mjs +275 -0
- package/src/terminal-ui.mjs +67 -0
- package/test/cf-connectivity.test.mjs +287 -0
- package/test/cf-destination.test.mjs +307 -0
- package/test/cf-runtime.test.mjs +505 -0
- package/test/copilot-assets.test.mjs +37 -0
- package/test/copilot-content.test.mjs +145 -0
- package/test/discovery.test.mjs +199 -0
- package/test/fixtures/fake-vsp.mjs +244 -0
- package/test/launcher.test.mjs +164 -0
- package/test/mcp-config-cf.test.mjs +149 -0
- package/test/mcp-proxy.test.mjs +337 -0
- package/test/setup-cf.test.mjs +362 -0
- package/test/setup.test.mjs +376 -0
- package/test/terminal-ui.test.mjs +47 -0
- package/tools.md +68 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { open, readFile } from 'node:fs/promises';
|
|
2
|
+
import { closeSync, openSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { ensureGo } from './ensure-go.mjs';
|
|
6
|
+
import { installBinary } from '../src/binary.mjs';
|
|
7
|
+
import { runSetup } from '../src/setup.mjs';
|
|
8
|
+
import { installUserCopilotAssets } from './install-user-copilot-assets.mjs';
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { colorText, formatStatus } from '../src/terminal-ui.mjs';
|
|
13
|
+
|
|
14
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
15
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
16
|
+
|
|
17
|
+
async function announce(message, tone = 'info') {
|
|
18
|
+
const content = message.replace(/^sap-ai-dev-toolkit:\s*/, '');
|
|
19
|
+
let terminal;
|
|
20
|
+
try {
|
|
21
|
+
terminal = await open(process.platform === 'win32' ? 'CONOUT$' : '/dev/tty', 'w');
|
|
22
|
+
await terminal.write(`${formatStatus(content, tone, true)}\n`);
|
|
23
|
+
} catch {
|
|
24
|
+
console.error(formatStatus(content, tone, process.stderr));
|
|
25
|
+
} finally {
|
|
26
|
+
await terminal?.close().catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function openControllingTerminal() {
|
|
31
|
+
const inputPath = process.platform === 'win32' ? 'CONIN$' : '/dev/tty';
|
|
32
|
+
const outputPath = process.platform === 'win32' ? 'CONOUT$' : '/dev/tty';
|
|
33
|
+
const inputFd = openSync(inputPath, 'r');
|
|
34
|
+
let outputFd;
|
|
35
|
+
try {
|
|
36
|
+
outputFd = openSync(outputPath, 'w');
|
|
37
|
+
return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
closeSync(inputFd);
|
|
40
|
+
if (outputFd !== undefined) closeSync(outputFd);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function withInstallTerminal(action) {
|
|
46
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
47
|
+
return action({ input: process.stdin, output: process.stdout });
|
|
48
|
+
}
|
|
49
|
+
let terminal;
|
|
50
|
+
try {
|
|
51
|
+
terminal = openControllingTerminal();
|
|
52
|
+
} catch {
|
|
53
|
+
return action(null);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await action(terminal);
|
|
57
|
+
} finally {
|
|
58
|
+
terminal.input.destroy();
|
|
59
|
+
terminal.output.destroy();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runInstallSetup() {
|
|
64
|
+
return withInstallTerminal(terminal => runSetup(terminal || undefined));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runCopilotAssetInstall() {
|
|
68
|
+
await withInstallTerminal(async terminal => {
|
|
69
|
+
if (!terminal) {
|
|
70
|
+
await announce('Optional Copilot installation skipped because no interactive terminal is available.', 'warning');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const copilotRoot = join(process.env.HOME || homedir(), '.copilot');
|
|
74
|
+
await announce([
|
|
75
|
+
'Optional Copilot setup is waiting for your choice.',
|
|
76
|
+
'',
|
|
77
|
+
'Press Enter to install the bundled agents and eleven skills in the path shown below; type n then press Enter to skip.',
|
|
78
|
+
'',
|
|
79
|
+
`${colorText('✅ Press Enter', 'green', true)} to install the bundled agents and eleven skills.`,
|
|
80
|
+
`${colorText('⏭️ Type n then Enter', 'yellow', true)} to skip this optional step.`,
|
|
81
|
+
'',
|
|
82
|
+
`${colorText('📁 Target folder:', 'cyan', true)}`,
|
|
83
|
+
` ${copilotRoot}`,
|
|
84
|
+
''
|
|
85
|
+
].join('\n'), 'copilot');
|
|
86
|
+
const prompt = createInterface({ input: terminal.input, output: terminal.output });
|
|
87
|
+
let answer;
|
|
88
|
+
try {
|
|
89
|
+
answer = await prompt.question(`${colorText('🤖 Install the bundled agents and eleven skills?', 'magenta', terminal.output)} ${colorText('[Y/n]', 'yellow', terminal.output)} `);
|
|
90
|
+
} finally {
|
|
91
|
+
prompt.close();
|
|
92
|
+
}
|
|
93
|
+
if (/^n(?:o)?$/i.test(answer.trim())) {
|
|
94
|
+
await announce('Copilot agent and skills were skipped. Your files were not changed.', 'info');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const assets = await installUserCopilotAssets({ root });
|
|
98
|
+
const installed = assets.installed + assets.updated;
|
|
99
|
+
await announce(`Installed ${installed} Copilot files in ${assets.root}; ${assets.unchanged} already current.`, 'success');
|
|
100
|
+
if (assets.conflicts.length > 0) {
|
|
101
|
+
const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
|
|
102
|
+
await announce(`Existing Copilot customizations were preserved; review these paths: ${paths}`, 'warning');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
function probeCell(destination) {
|
|
109
|
+
const probe = destination.probe || {};
|
|
110
|
+
if (probe.status === 'skipped') return { text: 'SKIPPED (probe disabled)', color: 'yellow' };
|
|
111
|
+
const error = destination.source === 'cloud-foundry' ? '' : String(probe.error || '')
|
|
112
|
+
.replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
113
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
114
|
+
.replace(/\s+/g, ' ')
|
|
115
|
+
.slice(0, 100);
|
|
116
|
+
const details = [
|
|
117
|
+
probe.httpStatus ? `HTTP ${probe.httpStatus}` : '',
|
|
118
|
+
error
|
|
119
|
+
].filter(Boolean).join('; ');
|
|
120
|
+
if (probe.available) {
|
|
121
|
+
return { text: `PASS ${probe.status || 'reachable'}${details ? ` (${details})` : ''}`, color: 'green' };
|
|
122
|
+
}
|
|
123
|
+
return { text: `FAIL ${probe.status || 'unknown'}${details ? ` (${details})` : ''}`, color: 'red' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function destinationTable(destinations, registeredNames) {
|
|
127
|
+
const headings = ['Destination', 'Source', 'Client', 'Authentication', 'ADT probe', 'MCP server'];
|
|
128
|
+
const rows = destinations.map(destination => {
|
|
129
|
+
const probe = probeCell(destination);
|
|
130
|
+
const registered = registeredNames.has(destination.serverName || destination.name);
|
|
131
|
+
const source = destination.source === 'cloud-foundry'
|
|
132
|
+
? `CF ${destination.cf?.destinationInstanceName || 'unknown instance'}`
|
|
133
|
+
: 'BAS';
|
|
134
|
+
return [
|
|
135
|
+
{ text: destination.name, output: destination.name },
|
|
136
|
+
{ text: source, output: source },
|
|
137
|
+
{ text: String(destination.client || '001'), output: String(destination.client || '001') },
|
|
138
|
+
{ text: String(destination.authentication || 'Unknown'), output: String(destination.authentication || 'Unknown') },
|
|
139
|
+
{ text: probe.text, output: colorText(probe.text, probe.color, true) },
|
|
140
|
+
{ text: registered ? 'REGISTERED' : 'NOT REGISTERED', output: colorText(registered ? 'REGISTERED' : 'NOT REGISTERED', registered ? 'green' : 'red', true) }
|
|
141
|
+
];
|
|
142
|
+
});
|
|
143
|
+
const widths = headings.map((heading, index) => Math.max(heading.length, ...rows.map(row => row[index].text.length)));
|
|
144
|
+
const border = `+${widths.map(width => '-'.repeat(width + 2)).join('+')}+`;
|
|
145
|
+
const formatRow = cells => `|${cells.map((cell, index) => ` ${cell.output}${' '.repeat(widths[index] - cell.text.length)} `).join('|')}|`;
|
|
146
|
+
const coloredBorder = colorText(border, 'cyan', true);
|
|
147
|
+
const coloredHeadings = formatRow(headings.map(text => ({ text, output: colorText(text, 'cyan', true) })));
|
|
148
|
+
return [coloredBorder, coloredHeadings, coloredBorder, ...rows.map(formatRow), coloredBorder].join('\n');
|
|
149
|
+
}
|
|
150
|
+
async function announceSetup(result) {
|
|
151
|
+
if (result?.reason === 'non-bas') {
|
|
152
|
+
await announce('BAS destination setup was skipped because H2O_URL is not set.\nMCP config was not changed.\nRun sap-ai-dev --setup from a BAS dev space when you are ready.', 'info');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (result?.reason === 'non-tty') {
|
|
156
|
+
await announce('Destination selection was skipped because npm did not provide an interactive terminal.\nMCP config was not changed.\nRun sap-ai-dev --setup from an interactive BAS terminal, or run npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx.', 'warning');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (result?.reason === 'no-destinations') {
|
|
160
|
+
const details = (result.warnings || []).map(warning => `• ${warning}`).join('\n');
|
|
161
|
+
await announce(`No selectable BAS or Cloud Foundry destinations were found; MCP config was not changed.\nRun sap-ai-dev --setup to retry.${details ? `\n${details}` : ''}`, 'warning');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const servers = Object.entries(result?.servers || {});
|
|
166
|
+
const path = result?.path || '(path unavailable)';
|
|
167
|
+
if (!servers.length) {
|
|
168
|
+
await announce([
|
|
169
|
+
'No MCP server entries are configured for this add-on.',
|
|
170
|
+
`MCP config file: ${path}`,
|
|
171
|
+
'No destinations were selected, so previously managed entries were removed. Other servers and settings were preserved.',
|
|
172
|
+
'Run sap-ai-dev --setup to choose destinations later.'
|
|
173
|
+
].join('\n'), 'info');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const selected = result.selected || [];
|
|
178
|
+
const destinationsByServer = new Map(selected.map(destination => [
|
|
179
|
+
destination.serverName || destination.name,
|
|
180
|
+
destination
|
|
181
|
+
]));
|
|
182
|
+
const registeredNames = new Set(servers.map(([name]) => name));
|
|
183
|
+
const entryDetails = servers.flatMap(([name, entry]) => {
|
|
184
|
+
const destination = destinationsByServer.get(name);
|
|
185
|
+
const source = entry.env?.BAS_VSP_DESTINATION_SOURCE === 'cloud-foundry' || destination?.source === 'cloud-foundry'
|
|
186
|
+
? 'Cloud Foundry'
|
|
187
|
+
: 'BAS';
|
|
188
|
+
const destinationName = entry.env?.BAS_VSP_DESTINATION || destination?.name || 'unknown';
|
|
189
|
+
const client = destination?.client || '001';
|
|
190
|
+
const authentication = destination?.authentication || 'unknown';
|
|
191
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
192
|
+
const command = [entry.command, ...args].filter(value => typeof value === 'string' && value.length > 0).join(' ');
|
|
193
|
+
const environmentKeys = Object.keys(entry.env || {}).sort().join(', ');
|
|
194
|
+
return [
|
|
195
|
+
`• ${colorText(name, 'cyan', true)}`,
|
|
196
|
+
` Destination: ${source} · ${destinationName} · client ${client} · ${authentication}`,
|
|
197
|
+
` Launch: ${entry.type || 'stdio'} · ${command || '(command unavailable)'}`,
|
|
198
|
+
` Environment keys: ${environmentKeys || '(none)'}`
|
|
199
|
+
];
|
|
200
|
+
});
|
|
201
|
+
const message = [
|
|
202
|
+
'Installation configuration summary',
|
|
203
|
+
`MCP config file: ${path}`,
|
|
204
|
+
`Configured MCP entries (${servers.length}):`,
|
|
205
|
+
...entryDetails,
|
|
206
|
+
'',
|
|
207
|
+
destinationTable(selected, registeredNames),
|
|
208
|
+
`Probe guide: ${colorText('PASS', 'green', true)} = ADT responded (2xx/401/403) · ${colorText('FAIL', 'red', true)} = probe failed · ${colorText('SKIPPED', 'yellow', true)} = probe disabled.`,
|
|
209
|
+
'Probe results are informational; only selected destinations are registered.',
|
|
210
|
+
'Unrelated MCP servers and settings were preserved. Authentication remains in BAS/Cloud Foundry; credentials were not copied into the MCP file.',
|
|
211
|
+
'Inspect: BAS/VS Code → “MCP: Open User Configuration”. Start: “MCP: List Servers” → select a generated server → “Start Server”.'
|
|
212
|
+
].join('\n');
|
|
213
|
+
await announce(message, 'success');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
if (process.env.npm_config_ignore_scripts === 'true') return;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (process.env.BAS_VSP_BINARY) {
|
|
221
|
+
await announce('Using the BAS_VSP_BINARY override.', 'info');
|
|
222
|
+
} else {
|
|
223
|
+
await announce('Checking for Go. The supported version installs automatically if needed; this may take a few minutes.', 'progress');
|
|
224
|
+
try {
|
|
225
|
+
await ensureGo();
|
|
226
|
+
await announce('Go is ready.', 'success');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new Error(`Go provisioning failed: ${error.message}`);
|
|
229
|
+
}
|
|
230
|
+
await announce('Preparing the pinned VSP runtime for this platform.', 'progress');
|
|
231
|
+
try {
|
|
232
|
+
await installBinary(pkg);
|
|
233
|
+
await announce('Pinned VSP binary installed and ready.', 'success');
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw new Error(`VSP binary provisioning failed: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
await announce(error.message, 'error');
|
|
240
|
+
await announce('Go is provisioned automatically. Set BAS_VSP_BINARY only when supplying a trusted prebuilt VSP executable.', 'info');
|
|
241
|
+
process.exitCode = 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let setupResult;
|
|
246
|
+
let setupCompleted = false;
|
|
247
|
+
try {
|
|
248
|
+
setupResult = await runInstallSetup();
|
|
249
|
+
setupCompleted = true;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
await announce(`BAS MCP setup failed: ${error.message}`, 'error');
|
|
252
|
+
await announce('Rerun sap-ai-dev --setup.', 'info');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await runCopilotAssetInstall();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
await announce(`Copilot agent and skill installation failed: ${error.message}`, 'error');
|
|
259
|
+
}
|
|
260
|
+
if (setupCompleted) await announceSetup(setupResult);
|
|
261
|
+
}
|
|
262
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
263
|
+
main().catch(error => {
|
|
264
|
+
console.error(formatStatus(`Postinstall failed: ${error.message}`, 'error', process.stderr));
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
const args = new Set(process.argv.slice(2));
|
|
9
|
+
const allowedArgs = new Set(['--patch', '--dry-run']);
|
|
10
|
+
|
|
11
|
+
function parseToken(value) {
|
|
12
|
+
const tokenValue = value.trim();
|
|
13
|
+
if (!tokenValue) return '';
|
|
14
|
+
if (tokenValue[0] === '"' || tokenValue[0] === "'") {
|
|
15
|
+
const quote = tokenValue[0];
|
|
16
|
+
const closingQuote = tokenValue.indexOf(quote, 1);
|
|
17
|
+
if (closingQuote < 0 || (tokenValue.slice(closingQuote + 1).trim() && !tokenValue.slice(closingQuote + 1).trim().startsWith('#'))) {
|
|
18
|
+
throw new Error('Malformed NPM_PUBLISH_TOKEN value in .env');
|
|
19
|
+
}
|
|
20
|
+
return tokenValue.slice(1, closingQuote);
|
|
21
|
+
}
|
|
22
|
+
return tokenValue.replace(/\s+#.*$/, '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function npmToken() {
|
|
26
|
+
if (process.env.NPM_PUBLISH_TOKEN?.trim()) return process.env.NPM_PUBLISH_TOKEN;
|
|
27
|
+
let envFile;
|
|
28
|
+
try {
|
|
29
|
+
envFile = await readFile(join(root, '.env'), 'utf8');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
32
|
+
throw new Error('Set NPM_PUBLISH_TOKEN in the project .env file or environment before publishing');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let token = '';
|
|
36
|
+
for (const line of envFile.split(/\r?\n/)) {
|
|
37
|
+
const assignment = line.match(/^\s*(?:export\s+)?NPM_PUBLISH_TOKEN\s*=\s*(.*?)\s*$/);
|
|
38
|
+
if (assignment) token = parseToken(assignment[1]);
|
|
39
|
+
}
|
|
40
|
+
if (!token) throw new Error('NPM_PUBLISH_TOKEN is missing or empty in the project .env file');
|
|
41
|
+
return token;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runNpm(npmArgs, env) {
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', npmArgs, {
|
|
47
|
+
cwd: root,
|
|
48
|
+
env,
|
|
49
|
+
stdio: 'inherit',
|
|
50
|
+
...(process.platform === 'win32' ? { shell: true } : {})
|
|
51
|
+
});
|
|
52
|
+
child.once('error', reject);
|
|
53
|
+
child.once('close', (code, signal) => {
|
|
54
|
+
if (code === 0) resolve();
|
|
55
|
+
else reject(new Error(signal ? `npm ${npmArgs[0]} terminated by ${signal}` : `npm ${npmArgs[0]} exited with status ${code}`));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
for (const argument of args) {
|
|
62
|
+
if (!allowedArgs.has(argument)) throw new Error(`Unknown option: ${argument}`);
|
|
63
|
+
}
|
|
64
|
+
const dryRun = args.has('--dry-run');
|
|
65
|
+
const patch = args.has('--patch');
|
|
66
|
+
if (dryRun && patch) throw new Error('--dry-run cannot be combined with --patch; it never changes the package version');
|
|
67
|
+
|
|
68
|
+
let authDirectory;
|
|
69
|
+
try {
|
|
70
|
+
let env = { ...process.env };
|
|
71
|
+
if (!dryRun) {
|
|
72
|
+
const token = await npmToken();
|
|
73
|
+
authDirectory = await mkdtemp(join(tmpdir(), 'sap-ai-dev-toolkit-npm-'));
|
|
74
|
+
const userConfig = join(authDirectory, '.npmrc');
|
|
75
|
+
await writeFile(userConfig, '//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}\n', { encoding: 'utf8', mode: 0o600 });
|
|
76
|
+
env = {
|
|
77
|
+
...env,
|
|
78
|
+
NPM_PUBLISH_TOKEN: token,
|
|
79
|
+
npm_config_userconfig: userConfig,
|
|
80
|
+
NPM_CONFIG_USERCONFIG: userConfig
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (patch) await runNpm(['version', 'patch', '--no-git-tag-version'], env);
|
|
85
|
+
const packageJson = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
86
|
+
const publishArgs = ['publish', '--access', 'public', ...(dryRun ? ['--dry-run'] : [])];
|
|
87
|
+
console.error(`${dryRun ? 'Dry-running' : 'Publishing'} ${packageJson.name}@${packageJson.version}${dryRun ? '' : ' to npm'}...`);
|
|
88
|
+
await runNpm(publishArgs, env);
|
|
89
|
+
} finally {
|
|
90
|
+
if (authDirectory) await rm(authDirectory, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main().catch(error => {
|
|
95
|
+
console.error(`publish:npm: ${error.message}`);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
package/src/abaplint.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Config, MemoryFile, Registry } from '@abaplint/core';
|
|
2
|
+
|
|
3
|
+
export const ABAP_LINT_TOOL = {
|
|
4
|
+
name: 'LintABAP',
|
|
5
|
+
description: 'Run abaplint on caller-supplied ABAPGit-serialized files in memory; does not read workspace files or call SAP.',
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: 'object',
|
|
8
|
+
properties: {
|
|
9
|
+
files: {
|
|
10
|
+
type: 'array',
|
|
11
|
+
minItems: 1,
|
|
12
|
+
items: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
filename: { type: 'string', minLength: 1 },
|
|
16
|
+
source: { type: 'string' }
|
|
17
|
+
},
|
|
18
|
+
required: ['filename', 'source'],
|
|
19
|
+
additionalProperties: false
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
config: { type: 'object' }
|
|
23
|
+
},
|
|
24
|
+
required: ['files'],
|
|
25
|
+
additionalProperties: false
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function isObject(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rejectUnknownFields(value, allowed, label) {
|
|
34
|
+
for (const field of Object.keys(value)) {
|
|
35
|
+
if (!allowed.has(field)) throw new Error(`Unknown ${label} field: ${field}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateArguments(arguments_) {
|
|
40
|
+
if (!isObject(arguments_)) throw new Error('Arguments must be an object');
|
|
41
|
+
rejectUnknownFields(arguments_, new Set(['files', 'config']), 'argument');
|
|
42
|
+
if (!Array.isArray(arguments_.files) || arguments_.files.length === 0) {
|
|
43
|
+
throw new Error('files must be a nonempty array');
|
|
44
|
+
}
|
|
45
|
+
for (const file of arguments_.files) {
|
|
46
|
+
if (!isObject(file)) throw new Error('Each file must be an object');
|
|
47
|
+
rejectUnknownFields(file, new Set(['filename', 'source']), 'file');
|
|
48
|
+
if (typeof file.filename !== 'string' || typeof file.source !== 'string') {
|
|
49
|
+
throw new Error('Each file must contain string filename and source fields');
|
|
50
|
+
}
|
|
51
|
+
const basename = file.filename.split(/[\\/]/).at(-1);
|
|
52
|
+
if (!/^[^.]+\.[^.]+\.[^.]+$/.test(basename)) {
|
|
53
|
+
throw new Error('Each filename must have an abapGit object.type.extension basename');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (arguments_.config !== undefined && !isObject(arguments_.config)) {
|
|
57
|
+
throw new Error('config must be an object');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function errorResult(error) {
|
|
62
|
+
return {
|
|
63
|
+
isError: true,
|
|
64
|
+
content: [{ type: 'text', text: JSON.stringify({ error: error.message || String(error) }) }]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runABAPLint(arguments_) {
|
|
69
|
+
try {
|
|
70
|
+
validateArguments(arguments_);
|
|
71
|
+
const config = arguments_.config === undefined
|
|
72
|
+
? Config.getDefault()
|
|
73
|
+
: new Config(JSON.stringify(arguments_.config));
|
|
74
|
+
const files = arguments_.files.map(({ filename, source }) => new MemoryFile(filename, source));
|
|
75
|
+
const registry = new Registry(config).addFiles(files);
|
|
76
|
+
await registry.parseAsync();
|
|
77
|
+
const issues = registry.findIssues();
|
|
78
|
+
const counts = { Error: 0, Warning: 0, Info: 0 };
|
|
79
|
+
const reportIssues = issues.map(issue => {
|
|
80
|
+
counts[issue.getSeverity()] += 1;
|
|
81
|
+
const start = issue.getStart();
|
|
82
|
+
const end = issue.getEnd();
|
|
83
|
+
return {
|
|
84
|
+
filename: issue.getFilename(),
|
|
85
|
+
rule: issue.getKey(),
|
|
86
|
+
severity: issue.getSeverity(),
|
|
87
|
+
message: issue.getMessage(),
|
|
88
|
+
start: { line: start.getRow(), column: start.getCol() },
|
|
89
|
+
end: { line: end.getRow(), column: end.getCol() }
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const report = {
|
|
93
|
+
status: reportIssues.length === 0 ? 'clean' : 'issues',
|
|
94
|
+
filesChecked: files.length,
|
|
95
|
+
issueCount: reportIssues.length,
|
|
96
|
+
errors: counts.Error,
|
|
97
|
+
warnings: counts.Warning,
|
|
98
|
+
infos: counts.Info,
|
|
99
|
+
issues: reportIssues
|
|
100
|
+
};
|
|
101
|
+
return { isError: false, content: [{ type: 'text', text: JSON.stringify(report) }] };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return errorResult(error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
4
|
+
|
|
5
|
+
const SECRET_KEY = /^(authorization|cookie|set-cookie|username|user|sap[_-](user(name)?|password|pass|token|saml[_-]?(user|password)|cookie[_-]?(file|string)|browser[_-]?auth|sso|credential[_-]?cmd))$|.*(password|passwd|secret|bearer|credential).*/i;
|
|
6
|
+
const DEFAULT_PROXY = 'http://127.0.0.1:8887';
|
|
7
|
+
|
|
8
|
+
function keyName(value) {
|
|
9
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function scalar(value) {
|
|
13
|
+
if (Array.isArray(value)) return value.length === 1 ? scalar(value[0]) : value;
|
|
14
|
+
if (value && typeof value === 'object' && 'value' in value) return scalar(value.value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function propertiesOf(item) {
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const [key, value] of Object.entries(item || {})) result[keyName(key)] = scalar(value);
|
|
21
|
+
const maps = Object.entries(item || {}).filter(([key]) => ['properties', 'propertymap', 'destinationproperties', 'configuration'].includes(keyName(key))).map(([, value]) => value);
|
|
22
|
+
for (const map of maps) {
|
|
23
|
+
if (Array.isArray(map)) {
|
|
24
|
+
for (const entry of map) {
|
|
25
|
+
const key = entry?.key ?? entry?.name ?? entry?.property;
|
|
26
|
+
if (key != null) result[keyName(key)] = scalar(entry?.value ?? entry?.val);
|
|
27
|
+
}
|
|
28
|
+
} else if (map && typeof map === 'object') {
|
|
29
|
+
for (const [key, value] of Object.entries(map)) result[keyName(key)] = scalar(value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
function text(value) {
|
|
37
|
+
return value == null ? '' : String(value).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listFromBody(body) {
|
|
41
|
+
if (Array.isArray(body)) return body;
|
|
42
|
+
if (!body || typeof body !== 'object') return null;
|
|
43
|
+
for (const [key, value] of Object.entries(body)) {
|
|
44
|
+
if (['destinations', 'data'].includes(keyName(key)) && Array.isArray(value)) return value;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeDestination(item) {
|
|
50
|
+
const values = propertiesOf(item);
|
|
51
|
+
const name = text(values.name || values.destinationname || values.destname);
|
|
52
|
+
const authentication = text(values.authentication || values.authtype || values.auth);
|
|
53
|
+
const client = text(values.sapclient || values.client) || '001';
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
authentication: authentication || 'Unknown',
|
|
57
|
+
client,
|
|
58
|
+
url: name ? destinationUrl(name) : null,
|
|
59
|
+
rawKeys: Object.keys(item || {}).map(String)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function destinationUrl(name) {
|
|
64
|
+
const value = text(name);
|
|
65
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid BAS destination name: ${value || '<empty>'}`);
|
|
66
|
+
return `http://${value}.dest`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function slugifyDestination(name, used = new Map()) {
|
|
70
|
+
const base = text(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'destination';
|
|
71
|
+
const count = (used.get(base) || 0) + 1;
|
|
72
|
+
used.set(base, count);
|
|
73
|
+
return count === 1 ? base : `${base}-${count}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function redact(value) {
|
|
77
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
78
|
+
if (!value || typeof value !== 'object') return value;
|
|
79
|
+
const result = {};
|
|
80
|
+
for (const [key, entry] of Object.entries(value)) result[key] = SECRET_KEY.test(key) ? '[redacted]' : redact(entry);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sanitizeChildEnv(input = process.env) {
|
|
85
|
+
const env = {};
|
|
86
|
+
for (const [key, value] of Object.entries(input)) {
|
|
87
|
+
if (!SECRET_KEY.test(key)) env[key] = value;
|
|
88
|
+
}
|
|
89
|
+
env.SAP_PROXY_CONTEXTID_GUARD = 'true';
|
|
90
|
+
const proxy = env.HTTP_PROXY || env.http_proxy || DEFAULT_PROXY;
|
|
91
|
+
if (!env.HTTP_PROXY && !env.http_proxy) env.HTTP_PROXY = proxy;
|
|
92
|
+
if (!env.HTTPS_PROXY && !env.https_proxy) env.HTTPS_PROXY = proxy;
|
|
93
|
+
const noProxyKey = env.NO_PROXY != null ? 'NO_PROXY' : (env.no_proxy != null ? 'no_proxy' : 'NO_PROXY');
|
|
94
|
+
const entries = String(env[noProxyKey] || '').split(',').map(value => value.trim()).filter(Boolean).filter(value => !value.toLowerCase().includes('.dest'));
|
|
95
|
+
env[noProxyKey] = entries.join(',');
|
|
96
|
+
return env;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function timeoutSignal(timeoutMs) {
|
|
100
|
+
return AbortSignal.timeout(timeoutMs);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function proxyBypasses(url, noProxy) {
|
|
104
|
+
const hostname = url.host.replace(/:\d*$/, '').toLowerCase();
|
|
105
|
+
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
|
106
|
+
return String(noProxy).split(/[,\s]/).some(entry => {
|
|
107
|
+
if (!entry) return false;
|
|
108
|
+
const match = entry.match(/^(.+):(\d+)$/);
|
|
109
|
+
const candidate = (match ? match[1] : entry).toLowerCase();
|
|
110
|
+
if (match && Number(match[2]) !== port) return false;
|
|
111
|
+
if (/^[.*]/.test(candidate)) return hostname.endsWith(candidate.replace(/^\*/, ''));
|
|
112
|
+
return hostname === candidate;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function proxyDispatcher(target, env) {
|
|
117
|
+
const url = new URL(target);
|
|
118
|
+
const httpProxy = env.http_proxy ?? env.HTTP_PROXY ?? '';
|
|
119
|
+
const httpsProxy = env.https_proxy ?? env.HTTPS_PROXY ?? '';
|
|
120
|
+
const proxy = url.protocol === 'https:' ? httpsProxy || httpProxy : httpProxy;
|
|
121
|
+
const noProxy = env.no_proxy ?? env.NO_PROXY ?? '';
|
|
122
|
+
if (!proxy || proxyBypasses(url, noProxy)) return undefined;
|
|
123
|
+
return new ProxyAgent({ uri: proxy, proxyTunnel: false });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function fetchDestinationList(h2oUrl, options = {}) {
|
|
127
|
+
if (!h2oUrl) throw new Error('H2O_URL is required for BAS destination discovery');
|
|
128
|
+
const url = `${String(h2oUrl).replace(/\/$/, '')}/api/listDestinations`;
|
|
129
|
+
const env = options.env || process.env;
|
|
130
|
+
const dispatcher = proxyDispatcher(url, env);
|
|
131
|
+
try {
|
|
132
|
+
const response = await (options.fetchImpl || undiciFetch)(url, {
|
|
133
|
+
signal: options.signal || timeoutSignal(options.timeoutMs || 10000),
|
|
134
|
+
headers: { accept: 'application/json' },
|
|
135
|
+
...(dispatcher ? { dispatcher } : {})
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) throw new Error(`BAS destination discovery failed with HTTP ${response.status}`);
|
|
138
|
+
return await response.json();
|
|
139
|
+
} finally {
|
|
140
|
+
await dispatcher?.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
function requestProbe(target, proxyUrl, timeoutMs) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const targetUrl = new URL(target);
|
|
148
|
+
const proxy = proxyUrl ? new URL(proxyUrl) : null;
|
|
149
|
+
const throughProxy = proxy && targetUrl.protocol === 'http:' && proxy.protocol === 'http:';
|
|
150
|
+
const transport = throughProxy ? httpRequest : (targetUrl.protocol === 'https:' ? httpsRequest : httpRequest);
|
|
151
|
+
const requestOptions = throughProxy ? {
|
|
152
|
+
protocol: proxy.protocol,
|
|
153
|
+
hostname: proxy.hostname,
|
|
154
|
+
port: proxy.port || 80,
|
|
155
|
+
method: 'GET',
|
|
156
|
+
path: targetUrl.href,
|
|
157
|
+
headers: { host: targetUrl.host, accept: 'application/xml,text/xml,*/*' }
|
|
158
|
+
} : {
|
|
159
|
+
protocol: targetUrl.protocol,
|
|
160
|
+
hostname: targetUrl.hostname,
|
|
161
|
+
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
|
|
162
|
+
method: 'GET',
|
|
163
|
+
path: `${targetUrl.pathname}${targetUrl.search}`,
|
|
164
|
+
headers: { host: targetUrl.host, accept: 'application/xml,text/xml,*/*' }
|
|
165
|
+
};
|
|
166
|
+
const req = transport(requestOptions, response => {
|
|
167
|
+
response.resume();
|
|
168
|
+
resolve({ status: response.statusCode || 0, throughProxy });
|
|
169
|
+
});
|
|
170
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('probe timeout')));
|
|
171
|
+
req.on('error', reject);
|
|
172
|
+
req.end();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function probeADT(destination, options = {}) {
|
|
177
|
+
const url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
|
|
178
|
+
if (options.skipProbe) return { status: 'skipped', available: true, url };
|
|
179
|
+
try {
|
|
180
|
+
const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl || DEFAULT_PROXY, options.timeoutMs || 5000);
|
|
181
|
+
if ([401, 403].includes(result.status) || (result.status >= 200 && result.status < 300)) return { status: result.status === 401 || result.status === 403 ? 'auth-required' : 'available', available: true, httpStatus: result.status, url };
|
|
182
|
+
if (result.status === 404) return { status: 'not-found', available: false, httpStatus: result.status, url };
|
|
183
|
+
return { status: 'unavailable', available: false, httpStatus: result.status, url };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { status: error?.message === 'probe timeout' ? 'timeout' : 'network-error', available: false, error: error?.message || 'network error', url };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function discoverDestinations(options = {}) {
|
|
190
|
+
const env = options.env || process.env;
|
|
191
|
+
const body = options.body ?? await fetchDestinationList(env.H2O_URL, options);
|
|
192
|
+
const list = listFromBody(body);
|
|
193
|
+
if (!list) throw new Error(`Unrecognized BAS destination response shape; top-level keys: ${Object.keys(body || {}).map(String).join(', ') || '<none>'}`);
|
|
194
|
+
const allow = text(env.BAS_VSP_DESTINATION).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
|
|
195
|
+
const normalized = list.map(normalizeDestination).filter(item => item.name).filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
|
|
196
|
+
const proxyUrl = env.HTTP_PROXY || env.http_proxy || DEFAULT_PROXY;
|
|
197
|
+
const skipProbe = String(env.BAS_VSP_SKIP_PROBE || '').toLowerCase() === 'true' || options.skipProbe;
|
|
198
|
+
const result = [];
|
|
199
|
+
for (const destination of normalized.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
200
|
+
const probe = await probeADT(destination, { ...options, proxyUrl, skipProbe });
|
|
201
|
+
result.push({ ...destination, probe });
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function statusRows(destinations) {
|
|
207
|
+
return destinations.map(destination => ({ name: destination.name, client: destination.client, authentication: destination.authentication, probe: destination.probe?.status || 'unknown' }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const remediation = 'No named BAS destinations were returned. Check H2O_URL, Cloud Connector /sap/bc/adt, and run curl "$H2O_URL/api/listDestinations".';
|