sap-ai-dev-toolkit 0.9.9922 → 0.9.9923
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/sap-btp-infrastructure-admin.agent.md +23 -0
- package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
- package/.github/agents/sap-datasphere-developer.agent.md +22 -0
- package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
- package/.github/agents/sap-process-automation-developer.agent.md +50 -0
- package/.github/skills/btp-platform-administration/SKILL.md +53 -0
- package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
- package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
- package/.github/skills/datasphere-administration/SKILL.md +40 -0
- package/.github/skills/datasphere-development/SKILL.md +55 -0
- package/.github/skills/process-automation-development/SKILL.md +113 -0
- package/README.md +152 -8
- package/inventory.md +60 -0
- package/package.json +8 -2
- package/src/btp-cli.mjs +199 -0
- package/src/btp-server.mjs +125 -0
- package/src/btp-tools.mjs +776 -0
- package/src/cloud-alm-client.mjs +44 -0
- package/src/cloud-alm-config.mjs +92 -0
- package/src/cloud-alm-credentials.mjs +29 -0
- package/src/cloud-alm-data.mjs +129 -0
- package/src/cloud-alm-server.mjs +115 -0
- package/src/cloud-alm-tools.mjs +682 -0
- package/src/datasphere-auth.mjs +279 -0
- package/src/datasphere-cli.mjs +220 -0
- package/src/datasphere-command.mjs +236 -0
- package/src/datasphere-config.mjs +154 -0
- package/src/datasphere-credentials.mjs +37 -0
- package/src/datasphere-csn.mjs +73 -0
- package/src/datasphere-discovery.mjs +151 -0
- package/src/datasphere-server.mjs +249 -0
- package/src/datasphere-tools.mjs +809 -0
- package/src/hana-database.mjs +3 -3
- package/src/hana-inspector.mjs +2 -2
- package/src/harnesses.mjs +31 -1
- package/src/install-user-assets.mjs +1 -1
- package/src/integration-common.mjs +7 -7
- package/src/integration-config.mjs +5 -0
- package/src/integration-credentials.mjs +25 -16
- package/src/integration-http.mjs +24 -7
- package/src/launcher.mjs +56 -4
- package/src/mcp-config.mjs +37 -1
- package/src/process-automation-automation-tools.mjs +125 -0
- package/src/process-automation-client.mjs +36 -0
- package/src/process-automation-common.mjs +69 -0
- package/src/process-automation-config.mjs +142 -0
- package/src/process-automation-credentials.mjs +37 -0
- package/src/process-automation-inbox-tools.mjs +246 -0
- package/src/process-automation-server.mjs +139 -0
- package/src/process-automation-tools.mjs +727 -0
- package/src/process-automation-visibility-tools.mjs +182 -0
- package/src/setup.mjs +623 -11
- package/tools.md +138 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Direct SAP CLI access with a tenant URL and a short-lived browser passcode.
|
|
2
|
+
// Only the tenant URL and discovery catalog are remembered; authentication is temporary.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { datasphereCliEnvironment } from './datasphere-cli.mjs';
|
|
10
|
+
import { loadDatasphereCommands } from './datasphere-discovery.mjs';
|
|
11
|
+
import { readStoredDatasphereCredentials } from './datasphere-credentials.mjs';
|
|
12
|
+
import { userConfigFilePath, writeUserOnlyJson } from './integration-credentials.mjs';
|
|
13
|
+
import { openInBrowser } from './datasphere-auth.mjs';
|
|
14
|
+
import { textPrompt } from './terminal-ui.mjs';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const CAPTURE_LIMIT = 1024 * 1024;
|
|
18
|
+
const MANAGED_AUTH = /^(?:--(?:options-file|secrets-file|client-id|client-secret|access-token|refresh-token|authorization-url|token-url|authorization-flow|code|login-id)(?:=|$)|-[OscCatAr](?:[^-]|$))/;
|
|
19
|
+
|
|
20
|
+
export function datasphereCommandUsage() {
|
|
21
|
+
return [
|
|
22
|
+
'Usage: sap-ai-dev --datasphere [--host <tenant URL>] <command> [options]',
|
|
23
|
+
'',
|
|
24
|
+
'Run the bundled SAP Datasphere CLI using your user and a browser temporary code.',
|
|
25
|
+
'The tenant URL and command catalog are remembered. Each command opens the SAP code page.',
|
|
26
|
+
'The first run needs two fresh codes: one for the catalog, then one for the command.',
|
|
27
|
+
'',
|
|
28
|
+
' sap-ai-dev --datasphere objects views list --space MY_SPACE',
|
|
29
|
+
' sap-ai-dev --datasphere --host https://<tenant>.eu20.hcs.cloud.sap objects views list --space MY_SPACE',
|
|
30
|
+
' sap-ai-dev --datasphere --version',
|
|
31
|
+
'',
|
|
32
|
+
' --passcode-stdin Read a temporary code from standard input instead of opening the browser',
|
|
33
|
+
'',
|
|
34
|
+
'Native SAP command options and confirmations apply. Temporary code files are removed after the command.',
|
|
35
|
+
'For persistent OAuth-based MCP access, use sap-ai-dev --setup-datasphere.',
|
|
36
|
+
''
|
|
37
|
+
].join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tenantUrl(value) {
|
|
41
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error('Datasphere tenant URL is invalid');
|
|
42
|
+
value = value.trim();
|
|
43
|
+
let url;
|
|
44
|
+
try { url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `https://${value}`); }
|
|
45
|
+
catch { throw new Error('Datasphere tenant URL is invalid'); }
|
|
46
|
+
if (url.username || url.password) throw new Error('Datasphere tenant URL must not contain credentials');
|
|
47
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
48
|
+
throw new Error('Datasphere tenant URL must use https');
|
|
49
|
+
}
|
|
50
|
+
return url.origin;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArguments(argv) {
|
|
54
|
+
const command = [];
|
|
55
|
+
let host;
|
|
56
|
+
let passcode;
|
|
57
|
+
let fromStdin = false;
|
|
58
|
+
for (let index = 0; index < argv.length; index++) {
|
|
59
|
+
const arg = argv[index];
|
|
60
|
+
if (arg === '--passcode-stdin') { fromStdin = true; continue; }
|
|
61
|
+
const option = /^(--host|-H|--passcode|-p)(?:=(.*))?$/.exec(arg);
|
|
62
|
+
const attached = /^(-H|-p)([^=].+)$/.exec(arg);
|
|
63
|
+
if (option || attached) {
|
|
64
|
+
const name = (option || attached)[1];
|
|
65
|
+
const value = attached?.[2] ?? option[2] ?? argv[++index];
|
|
66
|
+
if (!value || (name === '--host' || name === '-H') && value.startsWith('-')) throw new Error(`Missing value for ${name}`);
|
|
67
|
+
if (name === '--host' || name === '-H') host = value;
|
|
68
|
+
else passcode = value;
|
|
69
|
+
} else {
|
|
70
|
+
if (MANAGED_AUTH.test(arg)) throw new Error('This command uses browser-code authentication; configure OAuth with sap-ai-dev --setup-datasphere.');
|
|
71
|
+
command.push(arg);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (passcode && fromStdin) throw new Error('Use either --passcode or --passcode-stdin');
|
|
75
|
+
return { command, host, passcode, fromStdin };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function rememberedTenant(env) {
|
|
79
|
+
const path = userConfigFilePath(env, 'datasphere-cli.json');
|
|
80
|
+
try { return JSON.parse(await readFile(path, 'utf8'))?.url; }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') return readStoredDatasphereCredentials(env)?.url;
|
|
83
|
+
throw new Error('Stored Datasphere CLI tenant could not be read; supply --host <tenant URL> to replace it.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readPasscode(input) {
|
|
88
|
+
let value = '';
|
|
89
|
+
for await (const chunk of input) {
|
|
90
|
+
value += chunk.toString();
|
|
91
|
+
if (value.length > 4096) throw new Error('Temporary authentication code is too long');
|
|
92
|
+
}
|
|
93
|
+
return value.trim();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Copy only SAP's discovery files; OAuth secrets and authentication files never persist.
|
|
97
|
+
async function copyDiscoveryCache(from, to) {
|
|
98
|
+
const relative = join('.@sap', 'datasphere-cli', '.cache');
|
|
99
|
+
let names;
|
|
100
|
+
try { names = await readdir(join(from, relative)); }
|
|
101
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
102
|
+
const files = names.filter(name => /^discovery-.*\.json$/.test(name));
|
|
103
|
+
if (!files.length) return;
|
|
104
|
+
await mkdir(join(to, relative), { recursive: true, mode: 0o700 });
|
|
105
|
+
await Promise.all(files.map(name => copyFile(join(from, relative, name), join(to, relative, name))));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run a native SAP command. Prompt text goes to stderr, leaving stdout for the command result. */
|
|
109
|
+
export async function runDatasphereCommand(argv, {
|
|
110
|
+
env = process.env,
|
|
111
|
+
input = process.stdin,
|
|
112
|
+
output = process.stdout,
|
|
113
|
+
errorOutput = process.stderr,
|
|
114
|
+
cwd = process.cwd(),
|
|
115
|
+
spawnImpl = spawn,
|
|
116
|
+
openUrl = openInBrowser,
|
|
117
|
+
prompt = textPrompt
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (!argv.length || argv.length === 1 && ['--help', '-h'].includes(argv[0])) {
|
|
120
|
+
output.write(datasphereCommandUsage());
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const parsed = parseArguments(argv);
|
|
124
|
+
const versionOnly = parsed.command.length === 1 && ['--version', '-v'].includes(parsed.command[0]);
|
|
125
|
+
let host;
|
|
126
|
+
if (!versionOnly) {
|
|
127
|
+
host = parsed.host || env.DATASPHERE_URL || await rememberedTenant(env);
|
|
128
|
+
if (!host) {
|
|
129
|
+
if (!input.isTTY) throw new Error('Supply --host <tenant URL> on the first Datasphere command.');
|
|
130
|
+
host = await prompt({ message: 'Datasphere tenant URL', validate: value => { try { tenantUrl(value); return true; } catch (error) { return error.message; } } }, { input, output: errorOutput });
|
|
131
|
+
}
|
|
132
|
+
host = tenantUrl(host);
|
|
133
|
+
if (!parsed.passcode && !parsed.fromStdin && !input.isTTY) throw new Error('Browser sign-in needs a terminal; use --passcode-stdin to supply a temporary code through standard input.');
|
|
134
|
+
await writeUserOnlyJson(userConfigFilePath(env, 'datasphere-cli.json'), { url: host });
|
|
135
|
+
}
|
|
136
|
+
const entry = require.resolve('@sap/datasphere-cli/terminal.js');
|
|
137
|
+
const version = JSON.parse(await readFile(require.resolve('@sap/datasphere-cli/package.json'), 'utf8')).version;
|
|
138
|
+
const cacheHome = host ? userConfigFilePath(env, join('datasphere-cli-cache', `${createHash('sha256').update(host).digest('hex').slice(0, 24)}-${version}`)) : null;
|
|
139
|
+
const dir = await mkdtemp(join(tmpdir(), 'sap-ai-datasphere-command-'));
|
|
140
|
+
const childEnv = datasphereCliEnvironment(env, dir);
|
|
141
|
+
const optionsPath = join(dir, 'options.json');
|
|
142
|
+
let passcode = parsed.passcode || '';
|
|
143
|
+
let activeChild;
|
|
144
|
+
let interrupted;
|
|
145
|
+
const interrupt = signal => {
|
|
146
|
+
interrupted = signal;
|
|
147
|
+
if (activeChild) activeChild.kill(signal);
|
|
148
|
+
else if (parsed.fromStdin) input.destroy(new Error('Datasphere command interrupted'));
|
|
149
|
+
};
|
|
150
|
+
const onInterrupt = () => interrupt('SIGINT');
|
|
151
|
+
const onTerminate = () => interrupt('SIGTERM');
|
|
152
|
+
const exitCode = (code, signal) => code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
|
|
153
|
+
const run = (args, capture = false) => new Promise((resolve, reject) => {
|
|
154
|
+
if (interrupted) { resolve({ code: exitCode(null, interrupted), stdout: '', stderr: '' }); return; }
|
|
155
|
+
let stdout = '';
|
|
156
|
+
let stderr = '';
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
let oversized = false;
|
|
159
|
+
const child = spawnImpl(process.execPath, [entry, ...args], {
|
|
160
|
+
cwd, env: childEnv, stdio: capture ? ['ignore', 'pipe', 'pipe'] : [input, output, errorOutput],
|
|
161
|
+
...(capture ? { timeout: 180_000, windowsHide: true } : {})
|
|
162
|
+
});
|
|
163
|
+
activeChild = child;
|
|
164
|
+
if (capture) for (const stream of ['stdout', 'stderr']) {
|
|
165
|
+
child[stream].setEncoding('utf8');
|
|
166
|
+
child[stream].on('data', chunk => {
|
|
167
|
+
bytes += Buffer.byteLength(chunk);
|
|
168
|
+
if (bytes > CAPTURE_LIMIT) { oversized = true; child.kill(); return; }
|
|
169
|
+
if (stream === 'stdout') stdout += chunk;
|
|
170
|
+
else stderr += chunk;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
child.once('error', error => reject(new Error(`Could not start the SAP Datasphere CLI (${error.code || 'process error'})`)));
|
|
174
|
+
child.once('close', (code, signal) => {
|
|
175
|
+
activeChild = null;
|
|
176
|
+
if (oversized) reject(new Error('SAP Datasphere CLI initialization output exceeded its size limit'));
|
|
177
|
+
else resolve({ code: exitCode(code, signal), stdout, stderr });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
process.once('SIGINT', onInterrupt);
|
|
181
|
+
process.once('SIGTERM', onTerminate);
|
|
182
|
+
try {
|
|
183
|
+
if (versionOnly) return (await run(parsed.command)).code;
|
|
184
|
+
await copyDiscoveryCache(cacheHome, dir);
|
|
185
|
+
const cached = await loadDatasphereCommands({ cli: { init: async () => {}, stateDir: () => dir } }).then(() => true, () => false);
|
|
186
|
+
const initializeOnly = parsed.command.slice(0, 3).join(' ') === 'config cache init';
|
|
187
|
+
if (!cached && !initializeOnly && (parsed.fromStdin || parsed.passcode && !input.isTTY)) {
|
|
188
|
+
throw new Error('The CLI catalog needs one-time initialization. Run sap-ai-dev --datasphere config cache init --host <tenant URL> first, using a fresh browser code.');
|
|
189
|
+
}
|
|
190
|
+
let codeUrl;
|
|
191
|
+
const browserCode = async () => {
|
|
192
|
+
if (!codeUrl) {
|
|
193
|
+
const result = await run(['config', 'passcode-url', '--host', host], true);
|
|
194
|
+
if (result.code) throw new Error('Could not resolve the SAP browser login URL. Check the tenant URL.');
|
|
195
|
+
codeUrl = result.stdout.split(/\r?\n/).map(line => line.trim()).find(line => /^https:\/\//.test(line));
|
|
196
|
+
if (!codeUrl) throw new Error('SAP Datasphere CLI returned no browser login URL.');
|
|
197
|
+
}
|
|
198
|
+
errorOutput.write(`Sign in at ${codeUrl}, then copy the temporary authentication code.\n`);
|
|
199
|
+
try { await openUrl(codeUrl); } catch { /* The printed URL also works on remote terminals. */ }
|
|
200
|
+
return prompt({ message: 'Temporary authentication code', secret: true }, { input, output: errorOutput });
|
|
201
|
+
};
|
|
202
|
+
const writePasscode = async () => {
|
|
203
|
+
if (!passcode || /\s/.test(passcode) || passcode.length > 4096) throw new Error('Supply one non-empty temporary authentication code.');
|
|
204
|
+
await writeFile(optionsPath, JSON.stringify({ host, passcode }), { mode: 0o600 });
|
|
205
|
+
};
|
|
206
|
+
if (parsed.fromStdin) passcode = await readPasscode(input);
|
|
207
|
+
else if (!passcode) passcode = await browserCode();
|
|
208
|
+
await writePasscode();
|
|
209
|
+
if (!cached && !initializeOnly) {
|
|
210
|
+
const initialized = await run(['config', 'cache', 'init', '--host', host, '--options-file', optionsPath], true);
|
|
211
|
+
if (interrupted) return exitCode(null, interrupted);
|
|
212
|
+
if (initialized.code) {
|
|
213
|
+
const detail = (initialized.stderr || initialized.stdout).split(passcode).join('[redacted]').trim().slice(0, 1200);
|
|
214
|
+
throw new Error(`Datasphere CLI initialization failed${detail ? `: ${detail}` : ''}`);
|
|
215
|
+
}
|
|
216
|
+
await copyDiscoveryCache(dir, cacheHome);
|
|
217
|
+
errorOutput.write('Catalog initialized. A fresh browser code is needed for the requested command.\n');
|
|
218
|
+
passcode = await browserCode();
|
|
219
|
+
await writePasscode();
|
|
220
|
+
}
|
|
221
|
+
const result = await run([...(parsed.command.length ? parsed.command : ['--help']), '--host', host, '--options-file', optionsPath]);
|
|
222
|
+
if (result.code === 0) {
|
|
223
|
+
if (parsed.command.slice(0, 3).join(' ') === 'config cache clean') await rm(cacheHome, { recursive: true, force: true });
|
|
224
|
+
else await copyDiscoveryCache(dir, cacheHome);
|
|
225
|
+
}
|
|
226
|
+
return result.code;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (interrupted) return exitCode(null, interrupted);
|
|
229
|
+
throw error;
|
|
230
|
+
} finally {
|
|
231
|
+
passcode = '';
|
|
232
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
233
|
+
process.removeListener('SIGTERM', onTerminate);
|
|
234
|
+
await rm(dir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SAP Datasphere tenant/OAuth client configuration and optional Open SQL credentials.
|
|
2
|
+
// Pure: no I/O, no network. Two auth modes: 'technical' (client credentials,
|
|
3
|
+
// Technical User purpose) and 'interactive' (authorization code + refresh token,
|
|
4
|
+
// Interactive Usage purpose).
|
|
5
|
+
import { finalizeClientCredentials } from './integration-config.mjs';
|
|
6
|
+
|
|
7
|
+
const BASE_KEYS = ['DATASPHERE_URL', 'DATASPHERE_TOKEN_URL', 'DATASPHERE_CLIENT_ID', 'DATASPHERE_CLIENT_SECRET'];
|
|
8
|
+
const SQL_KEYS = ['DATASPHERE_SQL_HOST', 'DATASPHERE_SQL_USER', 'DATASPHERE_SQL_PASSWORD'];
|
|
9
|
+
const ALL_SQL_KEYS = [...SQL_KEYS, 'DATASPHERE_SQL_PORT', 'DATASPHERE_SQL_SCHEMA'];
|
|
10
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
11
|
+
|
|
12
|
+
function fail(message) {
|
|
13
|
+
throw new Error(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function text(value) {
|
|
17
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Users paste tenant page URLs with paths and #/hash routes; only the origin matters. */
|
|
21
|
+
function tenantOrigin(value) {
|
|
22
|
+
try {
|
|
23
|
+
return new URL(value).origin;
|
|
24
|
+
} catch {
|
|
25
|
+
return value; // finalize reports the invalid URL with the env-key prefix.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateHttpsUrl(value, prefix, label) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(value);
|
|
33
|
+
} catch {
|
|
34
|
+
fail(`${prefix}_${label} is not a valid URL`);
|
|
35
|
+
}
|
|
36
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
37
|
+
fail(`${prefix}_${label} must not contain credentials, a query, or a fragment`);
|
|
38
|
+
}
|
|
39
|
+
const loopback = LOOPBACK_HOSTS.has(parsed.hostname);
|
|
40
|
+
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
|
|
41
|
+
fail(`${prefix}_${label} must use https`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseSql(raw) {
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
const sql = {
|
|
49
|
+
host: text(raw.host),
|
|
50
|
+
port: raw.port === undefined || raw.port === '' || raw.port === null ? 443 : Number(raw.port),
|
|
51
|
+
user: text(raw.user),
|
|
52
|
+
password: typeof raw.password === 'string' ? raw.password.trim() : '',
|
|
53
|
+
schema: text(raw.schema)
|
|
54
|
+
};
|
|
55
|
+
if (!sql.host || !sql.user || !sql.password) {
|
|
56
|
+
fail('DATASPHERE_SQL configuration is incomplete: DATASPHERE_SQL_HOST, DATASPHERE_SQL_USER, DATASPHERE_SQL_PASSWORD');
|
|
57
|
+
}
|
|
58
|
+
if (!Number.isInteger(sql.port) || sql.port < 1 || sql.port > 65535) {
|
|
59
|
+
fail('DATASPHERE_SQL_PORT must be an integer between 1 and 65535');
|
|
60
|
+
}
|
|
61
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(sql.host)) {
|
|
62
|
+
fail('DATASPHERE_SQL_HOST must be a host name, not a URL');
|
|
63
|
+
}
|
|
64
|
+
if (!sql.schema) sql.schema = sql.user;
|
|
65
|
+
return Object.freeze({ ...sql, password: sql.password });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate raw Datasphere credentials into a frozen config.
|
|
70
|
+
* `raw = { auth, url, tokenUrl, clientId, clientSecret, authorizationUrl?, refreshToken?, sql? }`.
|
|
71
|
+
*/
|
|
72
|
+
export function parseDatasphereCredentials(raw, source) {
|
|
73
|
+
const auth = text(raw?.auth) || 'technical';
|
|
74
|
+
if (auth !== 'technical' && auth !== 'interactive') {
|
|
75
|
+
fail('DATASPHERE_AUTH must be technical or interactive');
|
|
76
|
+
}
|
|
77
|
+
const base = finalizeClientCredentials('DATASPHERE', source, {
|
|
78
|
+
url: tenantOrigin(text(raw?.url)),
|
|
79
|
+
tokenUrl: text(raw?.tokenUrl),
|
|
80
|
+
clientId: text(raw?.clientId),
|
|
81
|
+
clientSecret: text(raw?.clientSecret)
|
|
82
|
+
});
|
|
83
|
+
let authorizationUrl = null;
|
|
84
|
+
let refreshToken = null;
|
|
85
|
+
if (auth === 'interactive') {
|
|
86
|
+
authorizationUrl = validateHttpsUrl(text(raw?.authorizationUrl), 'DATASPHERE', 'AUTHORIZATION_URL').href;
|
|
87
|
+
refreshToken = text(raw?.refreshToken);
|
|
88
|
+
if (!refreshToken) {
|
|
89
|
+
fail('DATASPHERE_REFRESH_TOKEN is required for interactive sign-in; run sap-ai-dev --setup-datasphere');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
source,
|
|
94
|
+
auth,
|
|
95
|
+
url: base.url,
|
|
96
|
+
tokenUrl: base.tokenUrl,
|
|
97
|
+
clientId: base.clientId,
|
|
98
|
+
clientSecret: base.clientSecret,
|
|
99
|
+
authorizationUrl,
|
|
100
|
+
refreshToken,
|
|
101
|
+
sql: parseSql(raw?.sql)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sqlFromEnv(env) {
|
|
106
|
+
if (!ALL_SQL_KEYS.some(key => env[key] !== undefined && env[key] !== '')) return undefined;
|
|
107
|
+
const missing = SQL_KEYS.filter(key => !text(env[key]));
|
|
108
|
+
if (missing.length) fail(`Explicit DATASPHERE_SQL configuration is incomplete: ${missing.join(', ')}`);
|
|
109
|
+
return parseSql({
|
|
110
|
+
host: env.DATASPHERE_SQL_HOST,
|
|
111
|
+
port: env.DATASPHERE_SQL_PORT,
|
|
112
|
+
user: env.DATASPHERE_SQL_USER,
|
|
113
|
+
password: env.DATASPHERE_SQL_PASSWORD,
|
|
114
|
+
schema: env.DATASPHERE_SQL_SCHEMA
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve Datasphere credentials. Base group: environment, then the record stored by
|
|
120
|
+
* `sap-ai-dev --setup-datasphere`. The Open SQL group is resolved independently.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveDatasphereConfig(env = process.env, { stored } = {}) {
|
|
123
|
+
let config;
|
|
124
|
+
if (BASE_KEYS.some(key => env[key] !== undefined && env[key] !== '')) {
|
|
125
|
+
const missing = BASE_KEYS.filter(key => !text(env[key]));
|
|
126
|
+
if (missing.length) fail(`Explicit DATASPHERE configuration is incomplete: ${missing.join(', ')}`);
|
|
127
|
+
config = parseDatasphereCredentials({
|
|
128
|
+
auth: env.DATASPHERE_AUTH,
|
|
129
|
+
url: env.DATASPHERE_URL,
|
|
130
|
+
tokenUrl: env.DATASPHERE_TOKEN_URL,
|
|
131
|
+
clientId: env.DATASPHERE_CLIENT_ID,
|
|
132
|
+
clientSecret: env.DATASPHERE_CLIENT_SECRET,
|
|
133
|
+
authorizationUrl: env.DATASPHERE_AUTHORIZATION_URL,
|
|
134
|
+
refreshToken: env.DATASPHERE_REFRESH_TOKEN,
|
|
135
|
+
sql: sqlFromEnv(env)
|
|
136
|
+
}, 'environment');
|
|
137
|
+
} else if (stored) {
|
|
138
|
+
config = parseDatasphereCredentials({
|
|
139
|
+
auth: stored.auth,
|
|
140
|
+
url: stored.url,
|
|
141
|
+
tokenUrl: stored.tokenurl,
|
|
142
|
+
clientId: stored.clientid,
|
|
143
|
+
clientSecret: stored.clientsecret,
|
|
144
|
+
authorizationUrl: stored.authorizationurl,
|
|
145
|
+
refreshToken: stored.refreshtoken,
|
|
146
|
+
sql: stored.sql
|
|
147
|
+
}, 'stored-key');
|
|
148
|
+
const sql = sqlFromEnv(env);
|
|
149
|
+
if (sql) config = Object.freeze({ ...config, sql });
|
|
150
|
+
} else {
|
|
151
|
+
fail('Configure Datasphere (DATASPHERE_URL/DATASPHERE_TOKEN_URL/DATASPHERE_CLIENT_ID/DATASPHERE_CLIENT_SECRET) in the MCP host environment, or run sap-ai-dev --setup-datasphere.');
|
|
152
|
+
}
|
|
153
|
+
return config;
|
|
154
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// User-only store for Datasphere OAuth client credentials (never mcp.json):
|
|
2
|
+
// ~/.config/sap-ai-dev-toolkit/datasphere.json, mode 0600, atomic writes.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { userConfigFilePath, writeUserOnlyJson } from './integration-credentials.mjs';
|
|
5
|
+
|
|
6
|
+
export function datasphereCredentialsPath(env = process.env) {
|
|
7
|
+
return userConfigFilePath(env, 'datasphere.json');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Stored record ({ auth, url, tokenurl, clientid, clientsecret, authorizationurl?, refreshtoken?, sql? }); undefined when absent. */
|
|
11
|
+
export function readStoredDatasphereCredentials(env = process.env) {
|
|
12
|
+
const path = datasphereCredentialsPath(env);
|
|
13
|
+
let text;
|
|
14
|
+
try {
|
|
15
|
+
text = readFileSync(path, 'utf8');
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
18
|
+
throw new Error(`Stored Datasphere credentials at ${path} could not be read`);
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(text);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`Stored Datasphere credentials at ${path} are not valid JSON; rerun sap-ai-dev --setup-datasphere`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function storeDatasphereCredentials(record, env = process.env) {
|
|
28
|
+
return writeUserOnlyJson(datasphereCredentialsPath(env), record);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Persist a rotated refresh token without touching the rest of the record. */
|
|
32
|
+
export async function updateStoredRefreshToken(refreshToken, env = process.env) {
|
|
33
|
+
const path = datasphereCredentialsPath(env);
|
|
34
|
+
const stored = readStoredDatasphereCredentials(env);
|
|
35
|
+
if (!stored) throw new Error(`Stored Datasphere credentials at ${path} could not be read`);
|
|
36
|
+
return writeUserOnlyJson(path, { ...stored, refreshtoken: refreshToken });
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Pure CSN builder for Datasphere local tables from BW/Data Dictionary field
|
|
2
|
+
// lists (shape from the CLI guide "Create Objects" example). No I/O.
|
|
3
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
4
|
+
|
|
5
|
+
const STRING_TYPES = new Set(['CHAR', 'NUMC', 'CUKY', 'UNIT', 'LANG', 'CLNT', 'ACCP', 'SSTRING']);
|
|
6
|
+
const DECIMAL_TYPES = new Set(['DEC', 'CURR', 'QUAN']);
|
|
7
|
+
const INTEGER_TYPES = new Set(['INT1', 'INT2', 'INT4']);
|
|
8
|
+
|
|
9
|
+
function columnName(name) {
|
|
10
|
+
return String(name)
|
|
11
|
+
.replace(/^\/(?:BIC|BI0)\//, '')
|
|
12
|
+
.replace(/[^A-Za-z0-9_]/g, '_');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function positiveLength(field) {
|
|
16
|
+
const length = Number(field.length);
|
|
17
|
+
if (!Number.isInteger(length) || length < 1) {
|
|
18
|
+
throw new IntegrationError(`${field.name} needs length`);
|
|
19
|
+
}
|
|
20
|
+
return length;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function elementType(field) {
|
|
24
|
+
const type = String(field.ddic_type || '').toUpperCase();
|
|
25
|
+
if (STRING_TYPES.has(type)) return { type: 'cds.String', length: positiveLength(field) };
|
|
26
|
+
if (type === 'STRING') return { type: 'cds.LargeString' };
|
|
27
|
+
if (type === 'DATS') return { type: 'cds.Date' };
|
|
28
|
+
if (type === 'TIMS') return { type: 'cds.Time' };
|
|
29
|
+
if (DECIMAL_TYPES.has(type)) {
|
|
30
|
+
const decimals = Number(field.decimals);
|
|
31
|
+
return { type: 'cds.Decimal', precision: positiveLength(field), scale: Number.isInteger(decimals) && decimals >= 0 ? decimals : 0 };
|
|
32
|
+
}
|
|
33
|
+
if (INTEGER_TYPES.has(type)) return { type: 'cds.Integer' };
|
|
34
|
+
if (type === 'INT8') return { type: 'cds.Integer64' };
|
|
35
|
+
if (type === 'FLTP') return { type: 'cds.Double' };
|
|
36
|
+
if (type === 'RAW') return { type: 'cds.Binary', length: positiveLength(field) };
|
|
37
|
+
throw new IntegrationError(`Unsupported DDIC type ${field.ddic_type} for ${field.name}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a Datasphere local-table CSN definition:
|
|
42
|
+
* buildLocalTableCsn({ technicalName, businessName, fields }) where each field is
|
|
43
|
+
* { name, label?, ddic_type, length?, decimals?, key? }.
|
|
44
|
+
*/
|
|
45
|
+
export function buildLocalTableCsn({ technicalName, businessName, fields }) {
|
|
46
|
+
if (!technicalName || typeof technicalName !== 'string') throw new IntegrationError('technical_name is required');
|
|
47
|
+
const elements = {};
|
|
48
|
+
const byColumn = new Map();
|
|
49
|
+
for (const field of Array.isArray(fields) ? fields : []) {
|
|
50
|
+
const column = columnName(field.name);
|
|
51
|
+
const previous = byColumn.get(column);
|
|
52
|
+
if (previous) {
|
|
53
|
+
throw new IntegrationError(`Columns ${previous} and ${field.name} both map to ${column}`);
|
|
54
|
+
}
|
|
55
|
+
byColumn.set(column, field.name);
|
|
56
|
+
elements[column] = {
|
|
57
|
+
'@EndUserText.label': String(field.label ?? field.name),
|
|
58
|
+
...elementType(field),
|
|
59
|
+
...(field.key ? { key: true, notNull: true } : {})
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
definitions: {
|
|
64
|
+
[technicalName]: {
|
|
65
|
+
kind: 'entity',
|
|
66
|
+
'@EndUserText.label': String(businessName ?? technicalName),
|
|
67
|
+
'@ObjectModel.modelingPattern': { '#': 'DATA_STRUCTURE' },
|
|
68
|
+
'@ObjectModel.supportedCapabilities': [{ '#': 'DATA_STRUCTURE' }],
|
|
69
|
+
elements
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Datasphere command discovery: the tenant OpenAPI discovery document drives the
|
|
2
|
+
// generic CLI tools. Primary source is the REST tenant (same document the CLI
|
|
3
|
+
// itself uses); fallback is the CLI's local discovery cache after `config cache init`.
|
|
4
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { IntegrationError } from './integration-http.mjs';
|
|
7
|
+
|
|
8
|
+
const DISCOVERY_PATH = '/dwaas-core/api/v1/discovery';
|
|
9
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
10
|
+
const TECHNICAL_USER_PREFIXES = ['configuration certificates', 'marketplace', 'objects', 'spaces connections', 'tasks'];
|
|
11
|
+
const DESCRIPTION_CAP = 300;
|
|
12
|
+
|
|
13
|
+
const kebab = name => String(name)
|
|
14
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
15
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
16
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
17
|
+
.replace(/^-+|-+$/g, '')
|
|
18
|
+
.toLowerCase();
|
|
19
|
+
|
|
20
|
+
function resolveRef(document, ref) {
|
|
21
|
+
if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) return null;
|
|
22
|
+
let node = document;
|
|
23
|
+
for (const segment of ref.slice(2).split('/')) {
|
|
24
|
+
node = node?.[segment];
|
|
25
|
+
if (node === undefined) return null;
|
|
26
|
+
}
|
|
27
|
+
return node || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bodyPropertyNames(document, requestBody) {
|
|
31
|
+
const body = requestBody?.$ref ? (resolveRef(document, requestBody.$ref) || requestBody) : requestBody;
|
|
32
|
+
const schema = body?.content?.['application/json']?.schema;
|
|
33
|
+
const resolved = schema?.$ref ? (resolveRef(document, schema.$ref) || schema) : schema;
|
|
34
|
+
const properties = resolved?.properties;
|
|
35
|
+
if (!properties || typeof properties !== 'object') return [];
|
|
36
|
+
return Object.keys(properties);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function catalogEntry(document, op, method, command) {
|
|
40
|
+
const parameters = [];
|
|
41
|
+
for (const parameter of Array.isArray(op.parameters) ? op.parameters : []) {
|
|
42
|
+
const resolved = parameter?.$ref ? (resolveRef(document, parameter.$ref) || parameter) : parameter;
|
|
43
|
+
if (!resolved?.name || !['path', 'query', 'header'].includes(resolved.in)) continue;
|
|
44
|
+
parameters.push({
|
|
45
|
+
name: resolved.name,
|
|
46
|
+
in: resolved.in,
|
|
47
|
+
required: Boolean(resolved.required),
|
|
48
|
+
option: `--${kebab(resolved.name)}`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const fileBodyOnly = Boolean(op['x-requestbody-fileonly']);
|
|
52
|
+
const hasBody = Boolean(op.requestBody) || fileBodyOnly;
|
|
53
|
+
if (hasBody) {
|
|
54
|
+
for (const name of bodyPropertyNames(document, op.requestBody)) {
|
|
55
|
+
parameters.push({ name, in: 'body', required: false, option: `--${kebab(name)}` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
command,
|
|
60
|
+
method,
|
|
61
|
+
summary: typeof op.summary === 'string' ? op.summary : '',
|
|
62
|
+
description: String(op.description || op.summary || '').replace(/\s+/g, ' ').trim().slice(0, DESCRIPTION_CAP),
|
|
63
|
+
deprecated: Boolean(op.deprecated),
|
|
64
|
+
confirm_required: Boolean(op['x-user-to-confirm']),
|
|
65
|
+
file_body_only: fileBodyOnly,
|
|
66
|
+
has_body: hasBody,
|
|
67
|
+
access: method === 'get' ? 'read' : method === 'delete' ? 'delete' : 'write',
|
|
68
|
+
technical_user_supported: TECHNICAL_USER_PREFIXES.some(prefix => command.startsWith(prefix)),
|
|
69
|
+
parameters
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function catalogFromDocument(document) {
|
|
74
|
+
const entries = [];
|
|
75
|
+
for (const item of Object.values(document?.paths || {})) {
|
|
76
|
+
if (!item || typeof item !== 'object') continue;
|
|
77
|
+
const sharedParameters = Array.isArray(item.parameters) ? item.parameters : [];
|
|
78
|
+
for (const method of METHODS) {
|
|
79
|
+
const op = item[method];
|
|
80
|
+
if (!op || typeof op !== 'object') continue;
|
|
81
|
+
const withShared = sharedParameters.length ? { ...op, parameters: [...sharedParameters, ...(op.parameters || [])] } : op;
|
|
82
|
+
const ids = Array.isArray(op.operationId) ? op.operationId : [op.operationId];
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
if (typeof id === 'string') {
|
|
85
|
+
entries.push(catalogEntry(document, withShared, method, id.split(' ').filter(Boolean).join(' ')));
|
|
86
|
+
} else if (id && typeof id === 'object' && typeof id.operationId === 'string') {
|
|
87
|
+
const merged = { ...withShared, ...id };
|
|
88
|
+
entries.push(catalogEntry(document, merged, method, id.operationId.split(' ').filter(Boolean).join(' ')));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const seen = new Map();
|
|
94
|
+
for (const entry of entries.filter(entry => entry.command)) seen.set(`${entry.command} ${entry.method}`, entry);
|
|
95
|
+
return [...seen.values()].sort((a, b) => a.command.localeCompare(b.command));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function newestCacheFile(stateDir) {
|
|
99
|
+
const root = join(stateDir, '.@sap');
|
|
100
|
+
let entries;
|
|
101
|
+
try {
|
|
102
|
+
entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
let newest = null;
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (typeof entry.isDirectory === 'function' ? entry.isDirectory() : true) continue;
|
|
109
|
+
const base = String(entry.name).split(/[\\/]/).pop();
|
|
110
|
+
if (!base.startsWith('discovery-') || !base.endsWith('.json') || base === 'discovery-metadata.json') continue;
|
|
111
|
+
const path = join(entry.parentPath || entry.path, entry.name);
|
|
112
|
+
const mtime = (await stat(path).catch(() => null))?.mtimeMs;
|
|
113
|
+
if (mtime !== undefined && (!newest || mtime > newest.mtime)) newest = { path, mtime };
|
|
114
|
+
}
|
|
115
|
+
return newest?.path || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Load the Datasphere CLI command catalog from the tenant discovery document
|
|
120
|
+
* (REST), falling back to the CLI's cached discovery JSON.
|
|
121
|
+
*/
|
|
122
|
+
export async function loadDatasphereCommands({ rest, cli } = {}) {
|
|
123
|
+
const failures = [];
|
|
124
|
+
if (rest) {
|
|
125
|
+
try {
|
|
126
|
+
const document = await rest.request('GET', DISCOVERY_PATH, { accept: 'application/json' });
|
|
127
|
+
const catalog = catalogFromDocument(document);
|
|
128
|
+
if (catalog.length) return catalog;
|
|
129
|
+
failures.push('discovery document contained no commands');
|
|
130
|
+
} catch (error) {
|
|
131
|
+
failures.push(error?.message || String(error));
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
failures.push('no REST client');
|
|
135
|
+
}
|
|
136
|
+
if (cli) {
|
|
137
|
+
try {
|
|
138
|
+
await cli.init();
|
|
139
|
+
const path = await newestCacheFile(cli.stateDir());
|
|
140
|
+
if (!path) throw new Error('no cached discovery document');
|
|
141
|
+
const catalog = catalogFromDocument(JSON.parse(await readFile(path, 'utf8')));
|
|
142
|
+
if (catalog.length) return catalog;
|
|
143
|
+
failures.push('cached discovery document contained no commands');
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failures.push(error?.message || String(error));
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
failures.push('no CLI runtime');
|
|
149
|
+
}
|
|
150
|
+
throw new IntegrationError(`Datasphere command discovery is unavailable: ${failures.join('; ')}`);
|
|
151
|
+
}
|