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,793 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { stdin, stdout } from 'node:process';
|
|
5
|
+
import checkbox from '@inquirer/checkbox';
|
|
6
|
+
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
7
|
+
import { sanitizeChildEnv } from './bas-discovery.mjs';
|
|
8
|
+
import { createCfConnectivityProxy } from './cf-connectivity.mjs';
|
|
9
|
+
import { colorText, promptOutput, quietSpinnerTheme } from './terminal-ui.mjs';
|
|
10
|
+
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
12
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
13
|
+
const execFile = promisify(nodeExecFile);
|
|
14
|
+
const KEY_PREFIX = 'sap-ai-dev-toolkit-';
|
|
15
|
+
|
|
16
|
+
function text(value) {
|
|
17
|
+
return value == null ? '' : String(value).trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function keyPart(value) {
|
|
21
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cliError(error) {
|
|
25
|
+
const failure = new Error('Cloud Foundry CLI command failed');
|
|
26
|
+
failure.stderr = String(error?.stderr || '');
|
|
27
|
+
return failure;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runCf(args, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
31
|
+
try {
|
|
32
|
+
const run = execFileImpl === nodeExecFile ? execFile : promisify(execFileImpl);
|
|
33
|
+
const result = await run('cf', args, {
|
|
34
|
+
env,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
37
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
38
|
+
windowsHide: true
|
|
39
|
+
});
|
|
40
|
+
const output = result?.stdout ?? result ?? '';
|
|
41
|
+
return typeof output === 'string' ? output : String(output);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw cliError(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function versionAtLeast818(value) {
|
|
48
|
+
const match = /\bcf\s+version\s+(\d+)\.(\d+)\.(\d+)/i.exec(value);
|
|
49
|
+
if (!match) return false;
|
|
50
|
+
const major = Number(match[1]);
|
|
51
|
+
const minor = Number(match[2]);
|
|
52
|
+
return major > 8 || (major === 8 && minor >= 18);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function targetedSpaceName(output) {
|
|
56
|
+
const match = /^\s*space:\s*(.*?)\s*$/im.exec(output);
|
|
57
|
+
const name = match?.[1]?.trim() || '';
|
|
58
|
+
return /^(?:<none>|\(none\)|none)$/i.test(name) ? '' : name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getCloudFoundryTarget({ env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
62
|
+
let version;
|
|
63
|
+
try {
|
|
64
|
+
version = await runCf(['version'], { env, execFileImpl });
|
|
65
|
+
} catch {
|
|
66
|
+
return { available: false, reason: 'Cloud Foundry CLI is unavailable; CF destinations were skipped.' };
|
|
67
|
+
}
|
|
68
|
+
if (!versionAtLeast818(version)) {
|
|
69
|
+
return { available: false, reason: 'Cloud Foundry CLI 8.18 or newer is required; CF destinations were skipped.' };
|
|
70
|
+
}
|
|
71
|
+
let target;
|
|
72
|
+
try {
|
|
73
|
+
target = await runCf(['target'], { env, execFileImpl });
|
|
74
|
+
} catch {
|
|
75
|
+
return { available: false, reason: 'Cloud Foundry CLI is not authenticated to a targeted space; CF destinations were skipped.' };
|
|
76
|
+
}
|
|
77
|
+
const spaceName = targetedSpaceName(target);
|
|
78
|
+
if (!spaceName) {
|
|
79
|
+
return { available: false, reason: 'Cloud Foundry CLI is not authenticated to a targeted space; CF destinations were skipped.' };
|
|
80
|
+
}
|
|
81
|
+
let spaceGuid;
|
|
82
|
+
try {
|
|
83
|
+
spaceGuid = text(await runCf(['space', spaceName, '--guid'], { env, execFileImpl }));
|
|
84
|
+
} catch {
|
|
85
|
+
return { available: false, reason: 'Cloud Foundry CLI is not authenticated to a targeted space; CF destinations were skipped.' };
|
|
86
|
+
}
|
|
87
|
+
if (!spaceGuid || /\s/.test(spaceGuid)) {
|
|
88
|
+
return { available: false, reason: 'Cloud Foundry CLI is not authenticated to a targeted space; CF destinations were skipped.' };
|
|
89
|
+
}
|
|
90
|
+
return { available: true, spaceGuid };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function missingServiceKey(error) {
|
|
94
|
+
const details = String(error?.stderr || '').toLowerCase();
|
|
95
|
+
return details.includes('service key') && /(not found|does not exist|doesn.t exist)/.test(details);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function deleteManagedCloudFoundryServiceKeys({ env = process.env, keys = [] } = {}) {
|
|
99
|
+
if (!keys.length) return { warnings: [] };
|
|
100
|
+
const target = await getCloudFoundryTarget({ env });
|
|
101
|
+
if (!target.available) return { warnings: [`CF service-key cleanup skipped: ${target.reason}`] };
|
|
102
|
+
const warnings = [];
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
if (key.spaceGuid !== target.spaceGuid) {
|
|
105
|
+
warnings.push(`Managed ${key.kind} service key ${key.keyName} for ${key.instanceName} requires targeting CF space ${key.spaceGuid}; it was left untouched.`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await runCf(['delete-service-key', '-f', '--wait', key.instanceName, key.keyName], { env });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (missingServiceKey(error)) continue;
|
|
112
|
+
warnings.push(`Could not delete managed ${key.kind} service key ${key.keyName} for service instance ${key.instanceName}; target CF space ${key.spaceGuid} and retry.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { warnings };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function cfJson(path, options) {
|
|
119
|
+
const value = await runCf(['curl', path], options);
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(value);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error('Cloud Foundry API returned an invalid response');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function apiPath(href) {
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(href, 'https://cf-api.invalid');
|
|
130
|
+
return `${url.pathname}${url.search}`;
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error('Cloud Foundry API pagination link was invalid');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function serviceInstances(spaceGuid, options) {
|
|
137
|
+
const instances = [];
|
|
138
|
+
let path = `/v3/service_instances?space_guids=${encodeURIComponent(spaceGuid)}&per_page=500`;
|
|
139
|
+
const visited = new Set();
|
|
140
|
+
while (path && !visited.has(path)) {
|
|
141
|
+
visited.add(path);
|
|
142
|
+
const page = await cfJson(path, options);
|
|
143
|
+
if (!Array.isArray(page?.resources)) throw new Error('Cloud Foundry service instance response was invalid');
|
|
144
|
+
instances.push(...page.resources);
|
|
145
|
+
path = page?.metadata?.pagination?.next?.href ? apiPath(page.metadata.pagination.next.href) : '';
|
|
146
|
+
}
|
|
147
|
+
return instances;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function serviceOffering(instance, options) {
|
|
151
|
+
const planGuid = instance?.relationships?.service_plan?.data?.guid;
|
|
152
|
+
if (!planGuid) return null;
|
|
153
|
+
const plan = await cfJson(`/v3/service_plans/${encodeURIComponent(planGuid)}`, options);
|
|
154
|
+
const offeringGuid = plan?.relationships?.service_offering?.data?.guid;
|
|
155
|
+
if (!offeringGuid) return null;
|
|
156
|
+
return cfJson(`/v3/service_offerings/${encodeURIComponent(offeringGuid)}`, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function listValue(payload, keys) {
|
|
160
|
+
if (Array.isArray(payload)) return payload;
|
|
161
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
162
|
+
const wanted = new Set(keys.map(keyPart));
|
|
163
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
164
|
+
if (wanted.has(keyPart(key)) && Array.isArray(value)) return value;
|
|
165
|
+
}
|
|
166
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
167
|
+
if (wanted.has(keyPart(key)) && value && typeof value === 'object') {
|
|
168
|
+
const found = listValue(value, keys);
|
|
169
|
+
if (found) return found;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function scalar(value) {
|
|
176
|
+
const wrapped = objectValue(value, ['value']);
|
|
177
|
+
if (wrapped !== undefined) return scalar(wrapped);
|
|
178
|
+
if (Array.isArray(value)) return value.length === 1 ? scalar(value[0]) : '';
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function objectValue(object, candidates) {
|
|
183
|
+
const wanted = new Set(candidates.map(keyPart));
|
|
184
|
+
const entry = Object.entries(object || {}).find(([key]) => wanted.has(keyPart(key)));
|
|
185
|
+
return entry?.[1];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function propertyValues(item) {
|
|
189
|
+
let configuration = item;
|
|
190
|
+
for (let index = 0; index < 3 && configuration && typeof configuration === 'object'; index += 1) {
|
|
191
|
+
const nested = Object.entries(configuration).find(([key]) => keyPart(key) === 'destinationconfiguration')?.[1];
|
|
192
|
+
if (!nested || typeof nested !== 'object') break;
|
|
193
|
+
configuration = nested;
|
|
194
|
+
}
|
|
195
|
+
const values = Object.create(null);
|
|
196
|
+
for (const source of [configuration, item]) {
|
|
197
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) continue;
|
|
198
|
+
for (const [key, value] of Object.entries(source)) {
|
|
199
|
+
const normalized = keyPart(key);
|
|
200
|
+
if (!normalized || ['properties', 'propertymap', 'destinationproperties', 'configuration', 'destinationconfiguration'].includes(normalized)) continue;
|
|
201
|
+
values[normalized] = scalar(value);
|
|
202
|
+
}
|
|
203
|
+
for (const [key, value] of Object.entries(source)) {
|
|
204
|
+
if (!['properties', 'propertymap', 'destinationproperties', 'configuration'].includes(keyPart(key))) continue;
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
for (const property of value) {
|
|
207
|
+
if (!property || typeof property !== 'object') continue;
|
|
208
|
+
const propertyKey = objectValue(property, ['key', 'name', 'property']);
|
|
209
|
+
if (propertyKey != null) values[keyPart(propertyKey)] = scalar(objectValue(property, ['value', 'val']));
|
|
210
|
+
}
|
|
211
|
+
} else if (value && typeof value === 'object') {
|
|
212
|
+
for (const [propertyKey, propertyValue] of Object.entries(value)) values[keyPart(propertyKey)] = scalar(propertyValue);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return values;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function canonicalAuthentication(value) {
|
|
220
|
+
const normalized = keyPart(value);
|
|
221
|
+
if (normalized === 'noauthentication') return 'NoAuthentication';
|
|
222
|
+
if (normalized === 'basicauthentication') return 'BasicAuthentication';
|
|
223
|
+
if (normalized === 'principalpropagation') return 'PrincipalPropagation';
|
|
224
|
+
return text(value) || 'Unknown';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function canonicalProxyType(value) {
|
|
228
|
+
const normalized = keyPart(value);
|
|
229
|
+
if (normalized === 'internet') return 'Internet';
|
|
230
|
+
if (normalized === 'onpremise') return 'OnPremise';
|
|
231
|
+
return text(value) || 'Unknown';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeRecord(item, fallbackName = '') {
|
|
235
|
+
const values = propertyValues(item);
|
|
236
|
+
const name = text(values.name || values.destinationname || fallbackName);
|
|
237
|
+
const rawUrl = text(values.url);
|
|
238
|
+
let url;
|
|
239
|
+
let urlError;
|
|
240
|
+
if (rawUrl) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = new URL(rawUrl);
|
|
243
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname) {
|
|
244
|
+
urlError = 'URL must be HTTP(S) without embedded user information';
|
|
245
|
+
} else {
|
|
246
|
+
url = parsed.href;
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
urlError = 'URL is invalid';
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const rawType = text(values.type);
|
|
253
|
+
const authentication = canonicalAuthentication(values.authentication);
|
|
254
|
+
const proxyType = canonicalProxyType(values.proxytype);
|
|
255
|
+
return {
|
|
256
|
+
name,
|
|
257
|
+
type: rawType,
|
|
258
|
+
url,
|
|
259
|
+
urlError,
|
|
260
|
+
client: text(values.sapclient) || '001',
|
|
261
|
+
authentication,
|
|
262
|
+
proxyType,
|
|
263
|
+
user: text(values.user),
|
|
264
|
+
password: values.password == null ? '' : String(values.password),
|
|
265
|
+
locationId: text(values.cloudconnectorlocationid)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function reasonFor(record, hasConnectivity) {
|
|
270
|
+
if (!record.type || keyPart(record.type) !== 'http') return 'Destination Type must be HTTP';
|
|
271
|
+
if (record.urlError) return record.urlError;
|
|
272
|
+
if (!record.url) return 'Destination URL is missing';
|
|
273
|
+
if (record.authentication === 'BasicAuthentication' && (!record.user || !record.password)) return 'BasicAuthentication requires both User and Password';
|
|
274
|
+
if (!['NoAuthentication', 'BasicAuthentication', 'PrincipalPropagation'].includes(record.authentication)) return `Authentication mode ${record.authentication} is unsupported`;
|
|
275
|
+
if (record.proxyType === 'Internet') {
|
|
276
|
+
if (record.authentication === 'PrincipalPropagation') return 'PrincipalPropagation is not supported for Internet destinations';
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
if (record.proxyType === 'OnPremise') {
|
|
280
|
+
if (!hasConnectivity) return 'No Connectivity service instance is available';
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
return `ProxyType ${record.proxyType} is unsupported`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function destinationDescriptor(record, spaceGuid, instance, keyName, connectivity) {
|
|
287
|
+
const cf = {
|
|
288
|
+
spaceGuid,
|
|
289
|
+
destinationInstanceGuid: String(instance.guid),
|
|
290
|
+
destinationInstanceName: String(instance.name || instance.guid),
|
|
291
|
+
destinationKeyName: keyName
|
|
292
|
+
};
|
|
293
|
+
if (record.proxyType === 'OnPremise' && connectivity) {
|
|
294
|
+
cf.connectivityInstanceGuid = String(connectivity.instance.guid);
|
|
295
|
+
cf.connectivityInstanceName = String(connectivity.instance.name || connectivity.instance.guid);
|
|
296
|
+
cf.connectivityKeyName = connectivity.keyName;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
source: 'cloud-foundry',
|
|
300
|
+
name: record.name,
|
|
301
|
+
serverName: `cf:${spaceGuid}:${instance.guid}:${record.name}`,
|
|
302
|
+
client: record.client,
|
|
303
|
+
authentication: record.authentication,
|
|
304
|
+
proxyType: record.proxyType,
|
|
305
|
+
probe: { status: 'unavailable', available: false },
|
|
306
|
+
cf
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function chooseInstances(instances, { input = stdin, output = stdout, message }) {
|
|
311
|
+
if (instances.length === 1) return instances;
|
|
312
|
+
const selected = await checkbox({
|
|
313
|
+
message: colorText(`☁️ ${message}`, 'cyan', output),
|
|
314
|
+
choices: instances.map(instance => ({
|
|
315
|
+
value: instance,
|
|
316
|
+
name: `${instance.name || instance.guid} (${instance.guid})`,
|
|
317
|
+
checked: false
|
|
318
|
+
})),
|
|
319
|
+
required: false,
|
|
320
|
+
shortcuts: { all: 'a', invert: null },
|
|
321
|
+
theme: quietSpinnerTheme
|
|
322
|
+
}, { input, output: promptOutput(output) });
|
|
323
|
+
return selected;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function chooseConnectivity(instances, { input = stdin, output = stdout }) {
|
|
327
|
+
if (instances.length === 1) return instances[0];
|
|
328
|
+
const selected = await checkbox({
|
|
329
|
+
message: colorText('🔌 Select one Connectivity service instance', 'cyan', output),
|
|
330
|
+
choices: instances.map(instance => ({ value: instance, name: `${instance.name || instance.guid} (${instance.guid})`, checked: false })),
|
|
331
|
+
required: false,
|
|
332
|
+
validate: values => values.length <= 1 || 'Select at most one Connectivity service instance',
|
|
333
|
+
shortcuts: { all: 'a', invert: null },
|
|
334
|
+
theme: quietSpinnerTheme
|
|
335
|
+
}, { input, output: promptOutput(output) });
|
|
336
|
+
return selected[0];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readServiceKey(instance, kind, spaceGuid, managedKeys, createdKeys, options) {
|
|
340
|
+
const refs = (managedKeys || []).filter(ref => ref?.kind === kind && ref.spaceGuid === spaceGuid && ref.instanceGuid === instance.guid);
|
|
341
|
+
for (const ref of refs) {
|
|
342
|
+
const instanceName = ref.instanceName || instance.name || instance.guid;
|
|
343
|
+
try {
|
|
344
|
+
const credentials = await serviceKeyCredentials(instanceName, ref.keyName, kind, options);
|
|
345
|
+
return { keyName: ref.keyName, credentials };
|
|
346
|
+
} catch {
|
|
347
|
+
// A stale or unreadable managed key is replaced with a fresh key; old config still owns the original until commit.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const instanceName = String(instance.name || instance.guid);
|
|
351
|
+
const keyName = `${KEY_PREFIX}${randomUUID()}`;
|
|
352
|
+
await runCf(['create-service-key', instanceName, keyName, '--wait'], options);
|
|
353
|
+
const reference = { kind, spaceGuid, instanceGuid: String(instance.guid), instanceName, keyName };
|
|
354
|
+
createdKeys.push(reference);
|
|
355
|
+
const credentials = await serviceKeyCredentials(instanceName, keyName, kind, options);
|
|
356
|
+
return { keyName, credentials };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function serviceKeyCredentials(instanceName, keyName, kind, options) {
|
|
360
|
+
const raw = await runCf(['service-key', '--json', instanceName, keyName], options);
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(raw);
|
|
364
|
+
} catch {
|
|
365
|
+
throw new Error(`The ${kind} service key response was invalid`);
|
|
366
|
+
}
|
|
367
|
+
const credentials = parsed?.credentials || parsed?.service_key?.credentials || parsed;
|
|
368
|
+
if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials)) throw new Error(`The ${kind} service key has no credentials`);
|
|
369
|
+
return credentials;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function destinationServiceCredentials(credentials) {
|
|
373
|
+
const uaa = credentials.uaa && typeof credentials.uaa === 'object' ? credentials.uaa : credentials;
|
|
374
|
+
return {
|
|
375
|
+
uri: text(credentials.uri || credentials.destination_service_uri),
|
|
376
|
+
url: text(uaa.url || credentials.url),
|
|
377
|
+
clientId: text(uaa.clientid || uaa.client_id || credentials.clientid || credentials.client_id),
|
|
378
|
+
clientSecret: text(uaa.clientsecret || uaa.client_secret || credentials.clientsecret || credentials.client_secret)
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function requestJson(url, init, fetchImpl = globalThis.fetch, failureMessage = 'Destination service request failed') {
|
|
383
|
+
let response;
|
|
384
|
+
try {
|
|
385
|
+
response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
386
|
+
} catch {
|
|
387
|
+
throw new Error(failureMessage);
|
|
388
|
+
}
|
|
389
|
+
if (!response?.ok) throw new Error(failureMessage);
|
|
390
|
+
try {
|
|
391
|
+
return await response.json();
|
|
392
|
+
} catch {
|
|
393
|
+
throw new Error(failureMessage);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function destinationToken(credentials, fetchImpl) {
|
|
398
|
+
const config = destinationServiceCredentials(credentials);
|
|
399
|
+
if (!config.uri || !config.url || !config.clientId || !config.clientSecret) throw new Error('Destination service key credentials are incomplete');
|
|
400
|
+
let tokenUrl;
|
|
401
|
+
try {
|
|
402
|
+
tokenUrl = new URL(`${config.url.replace(/\/+$/, '')}/oauth/token`);
|
|
403
|
+
if (!['http:', 'https:'].includes(tokenUrl.protocol) || tokenUrl.username || tokenUrl.password) throw new Error('invalid');
|
|
404
|
+
} catch {
|
|
405
|
+
throw new Error('Destination service token endpoint is invalid');
|
|
406
|
+
}
|
|
407
|
+
const headers = {
|
|
408
|
+
authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64')}`,
|
|
409
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
410
|
+
accept: 'application/json'
|
|
411
|
+
};
|
|
412
|
+
const payload = await requestJson(tokenUrl, {
|
|
413
|
+
method: 'POST', headers, body: new URLSearchParams({ grant_type: 'client_credentials' })
|
|
414
|
+
}, fetchImpl, 'Destination service authentication failed');
|
|
415
|
+
if (typeof payload?.access_token !== 'string' || !payload.access_token) throw new Error('Destination service authentication failed');
|
|
416
|
+
return { uri: config.uri.replace(/\/+$/, ''), token: payload.access_token };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function destinationRecords(credentials, fetchImpl) {
|
|
420
|
+
const token = await destinationToken(credentials, fetchImpl);
|
|
421
|
+
let listUrl;
|
|
422
|
+
try {
|
|
423
|
+
listUrl = new URL(`${token.uri}/destination-configuration/v1/instanceDestinations`);
|
|
424
|
+
} catch {
|
|
425
|
+
throw new Error('Destination service URI is invalid');
|
|
426
|
+
}
|
|
427
|
+
const headers = { authorization: `Bearer ${token.token}`, accept: 'application/json' };
|
|
428
|
+
const payload = await requestJson(listUrl, { headers }, fetchImpl);
|
|
429
|
+
const list = listValue(payload, ['instanceDestinations', 'destinationConfigurations', 'destinations', 'data', 'resources', 'value']);
|
|
430
|
+
if (!list) throw new Error('Destination service returned an invalid instance destination list');
|
|
431
|
+
const records = [];
|
|
432
|
+
for (const item of list) {
|
|
433
|
+
const initial = normalizeRecord(item);
|
|
434
|
+
if (!initial.name) {
|
|
435
|
+
records.push({ record: initial, detailsFailed: false });
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const listedValues = propertyValues(item);
|
|
439
|
+
const basicCredentialsMissing = canonicalAuthentication(listedValues.authentication) === 'BasicAuthentication'
|
|
440
|
+
&& (!text(listedValues.user) || !text(listedValues.password));
|
|
441
|
+
const needsDetails = ['type', 'url', 'authentication', 'proxytype'].some(key => !text(listedValues[key])) || basicCredentialsMissing;
|
|
442
|
+
if (!needsDetails) {
|
|
443
|
+
records.push({ record: initial, detailsFailed: false });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const detailUrl = new URL(`${listUrl.href.replace(/\/+$/, '')}/${encodeURIComponent(initial.name)}`);
|
|
448
|
+
const detail = await requestJson(detailUrl, { headers }, fetchImpl, 'Destination detail request failed');
|
|
449
|
+
const detailValues = propertyValues(detail);
|
|
450
|
+
const record = normalizeRecord(detail, initial.name);
|
|
451
|
+
const has = (...keys) => keys.some(key => Object.hasOwn(detailValues, keyPart(key)));
|
|
452
|
+
const merged = {
|
|
453
|
+
name: record.name || initial.name,
|
|
454
|
+
type: has('type') ? record.type : initial.type,
|
|
455
|
+
url: has('url') ? record.url : initial.url,
|
|
456
|
+
urlError: has('url') ? record.urlError : initial.urlError,
|
|
457
|
+
client: has('sap-client', 'client') ? record.client : initial.client,
|
|
458
|
+
authentication: has('authentication') ? record.authentication : initial.authentication,
|
|
459
|
+
proxyType: has('proxytype') ? record.proxyType : initial.proxyType,
|
|
460
|
+
user: has('user') ? record.user : initial.user,
|
|
461
|
+
password: has('password') ? record.password : initial.password,
|
|
462
|
+
locationId: has('cloudconnectorlocationid') ? record.locationId : initial.locationId
|
|
463
|
+
};
|
|
464
|
+
records.push({ record: merged, detailsFailed: false });
|
|
465
|
+
} catch {
|
|
466
|
+
records.push({ record: initial, detailsFailed: true });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return records;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function noProxyMatch(url, list) {
|
|
473
|
+
const host = url.hostname.toLowerCase();
|
|
474
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80));
|
|
475
|
+
return String(list || '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean).some(entry => {
|
|
476
|
+
if (entry === '*') return true;
|
|
477
|
+
const normalized = entry.replace(/^\./, '');
|
|
478
|
+
const split = normalized.match(/^(.+):(\d+)$/);
|
|
479
|
+
const ruleHost = split ? split[1] : normalized;
|
|
480
|
+
if (split && Number(split[2]) !== port) return false;
|
|
481
|
+
return host === ruleHost || host.endsWith(`.${ruleHost}`);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function dispatcherFor(url, env, forcedProxy) {
|
|
486
|
+
const sanitized = sanitizeChildEnv(env);
|
|
487
|
+
const proxy = forcedProxy || (url.protocol === 'https:' ? sanitized.HTTPS_PROXY || sanitized.https_proxy : sanitized.HTTP_PROXY || sanitized.http_proxy);
|
|
488
|
+
if (!proxy || (!forcedProxy && noProxyMatch(url, sanitized.NO_PROXY || sanitized.no_proxy))) return null;
|
|
489
|
+
return new ProxyAgent({ uri: proxy, proxyTunnel: false });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function probeResult(status) {
|
|
493
|
+
if ((status >= 200 && status < 300) || status === 401 || status === 403) {
|
|
494
|
+
return { status: status === 401 || status === 403 ? 'auth-required' : 'available', available: true, httpStatus: status };
|
|
495
|
+
}
|
|
496
|
+
return { status: 'unavailable', available: false, httpStatus: status };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function probeRecord(record, env, { forcedProxy, fetchImpl = undiciFetch } = {}) {
|
|
500
|
+
const target = new URL('/sap/bc/adt/discovery', record.url);
|
|
501
|
+
const headers = { accept: 'application/xml,text/xml,*/*' };
|
|
502
|
+
if (record.authentication === 'BasicAuthentication') {
|
|
503
|
+
headers.authorization = `Basic ${Buffer.from(`${record.user}:${record.password}`).toString('base64')}`;
|
|
504
|
+
}
|
|
505
|
+
let dispatcher;
|
|
506
|
+
try {
|
|
507
|
+
dispatcher = dispatcherFor(target, env, forcedProxy);
|
|
508
|
+
const response = await fetchImpl(target, {
|
|
509
|
+
method: 'GET',
|
|
510
|
+
headers,
|
|
511
|
+
redirect: 'manual',
|
|
512
|
+
signal: AbortSignal.timeout(5_000),
|
|
513
|
+
...(dispatcher ? { dispatcher } : {})
|
|
514
|
+
});
|
|
515
|
+
return probeResult(response.status || 0);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return { status: error?.name === 'TimeoutError' ? 'timeout' : 'network-error', available: false };
|
|
518
|
+
} finally {
|
|
519
|
+
try { await dispatcher?.destroy(); } catch {}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function currentUserToken(options) {
|
|
524
|
+
const value = text(await runCf(['oauth-token'], options));
|
|
525
|
+
const match = /(?:^|\s)bearer\s+(\S+)/i.exec(value);
|
|
526
|
+
if (!match) throw new Error('Cloud Foundry user token is unavailable');
|
|
527
|
+
return match[1];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function connectivityCredentialsValid(credentials) {
|
|
531
|
+
return Boolean(
|
|
532
|
+
text(credentials?.onpremise_proxy_host) &&
|
|
533
|
+
Number.isInteger(Number(credentials?.onpremise_proxy_http_port)) &&
|
|
534
|
+
Number(credentials.onpremise_proxy_http_port) > 0 &&
|
|
535
|
+
text(credentials?.token_service_url) &&
|
|
536
|
+
text(credentials?.clientid) &&
|
|
537
|
+
text(credentials?.clientsecret)
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function prepareOnPremise(record, connectivity, env, fetchImpl, options) {
|
|
542
|
+
const authMode = record.authentication === 'PrincipalPropagation' ? 'principal-propagation' : 'application';
|
|
543
|
+
const route = await createCfConnectivityProxy({
|
|
544
|
+
destinationUrl: record.url,
|
|
545
|
+
connectivityCredentials: connectivity.credentials,
|
|
546
|
+
locationId: record.locationId,
|
|
547
|
+
authMode,
|
|
548
|
+
userTokenProvider: authMode === 'principal-propagation' ? () => currentUserToken(options) : undefined,
|
|
549
|
+
fetchImpl
|
|
550
|
+
});
|
|
551
|
+
try {
|
|
552
|
+
return await probeRecord(record, env, { forcedProxy: route.url, fetchImpl: undiciFetch });
|
|
553
|
+
} finally {
|
|
554
|
+
await route.close();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function instanceSummary(instance) {
|
|
559
|
+
return { guid: String(instance.guid), name: String(instance.name || instance.guid) };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function discoverCloudFoundryDestinations({
|
|
563
|
+
env = process.env,
|
|
564
|
+
input = stdin,
|
|
565
|
+
output = stdout,
|
|
566
|
+
spaceGuid,
|
|
567
|
+
managedKeys = [],
|
|
568
|
+
execFileImpl = nodeExecFile,
|
|
569
|
+
fetchImpl = globalThis.fetch
|
|
570
|
+
} = {}) {
|
|
571
|
+
const destinations = [];
|
|
572
|
+
const createdKeys = [];
|
|
573
|
+
const warnings = [];
|
|
574
|
+
const options = { env, execFileImpl };
|
|
575
|
+
try {
|
|
576
|
+
const allInstances = await serviceInstances(spaceGuid, options);
|
|
577
|
+
const destinationInstances = [];
|
|
578
|
+
const connectivityInstances = [];
|
|
579
|
+
for (const instance of allInstances) {
|
|
580
|
+
try {
|
|
581
|
+
const offering = await serviceOffering(instance, options);
|
|
582
|
+
const offeringName = text(offering?.name || offering?.label).toLowerCase();
|
|
583
|
+
if (offeringName === 'destination') destinationInstances.push(instanceSummary(instance));
|
|
584
|
+
if (offeringName === 'connectivity') connectivityInstances.push(instanceSummary(instance));
|
|
585
|
+
} catch {
|
|
586
|
+
warnings.push('A Cloud Foundry service instance could not be classified; it was skipped.');
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!destinationInstances.length) {
|
|
590
|
+
warnings.push('No Destination service instance exists in the current CF space; CF import was skipped.');
|
|
591
|
+
return { destinations, createdKeys, warnings };
|
|
592
|
+
}
|
|
593
|
+
const selectedInstances = await chooseInstances(destinationInstances, { input, output, message: 'Select Destination service instances' });
|
|
594
|
+
if (!selectedInstances.length) {
|
|
595
|
+
warnings.push('No Destination service instance was selected; CF import was skipped.');
|
|
596
|
+
return { destinations, createdKeys, warnings };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const recordsByInstance = [];
|
|
600
|
+
for (const instance of selectedInstances) {
|
|
601
|
+
try {
|
|
602
|
+
const key = await readServiceKey(instance, 'destination', spaceGuid, managedKeys, createdKeys, options);
|
|
603
|
+
const records = await destinationRecords(key.credentials, fetchImpl);
|
|
604
|
+
let unnamed = 0;
|
|
605
|
+
for (const item of records) {
|
|
606
|
+
if (!item.record.name) unnamed += 1;
|
|
607
|
+
else recordsByInstance.push({ instance, key, ...item });
|
|
608
|
+
}
|
|
609
|
+
if (unnamed) warnings.push(`Skipped ${unnamed} unnamed destination record${unnamed === 1 ? '' : 's'} from Destination service instance ${instance.name}.`);
|
|
610
|
+
} catch {
|
|
611
|
+
warnings.push(`Destination service instance ${instance.name} could not be read; its records were skipped.`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const possibleOnPremise = recordsByInstance.filter(({ record, detailsFailed }) => !detailsFailed && !reasonFor(record, true) && record.proxyType === 'OnPremise');
|
|
616
|
+
let selectedConnectivity;
|
|
617
|
+
let connectivity;
|
|
618
|
+
if (possibleOnPremise.length) {
|
|
619
|
+
if (!connectivityInstances.length) {
|
|
620
|
+
warnings.push('No Connectivity service instance exists in the current CF space; OnPremise destinations were disabled.');
|
|
621
|
+
} else {
|
|
622
|
+
try {
|
|
623
|
+
selectedConnectivity = await chooseConnectivity(connectivityInstances, { input, output });
|
|
624
|
+
if (!selectedConnectivity) warnings.push('No Connectivity service instance was selected; OnPremise destinations were disabled.');
|
|
625
|
+
else {
|
|
626
|
+
connectivity = await readServiceKey(selectedConnectivity, 'connectivity', spaceGuid, managedKeys, createdKeys, options);
|
|
627
|
+
if (!connectivityCredentialsValid(connectivity.credentials)) {
|
|
628
|
+
connectivity = undefined;
|
|
629
|
+
warnings.push(`Connectivity service instance ${selectedConnectivity.name} has incomplete credentials; OnPremise destinations were disabled.`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
} catch {
|
|
633
|
+
connectivity = undefined;
|
|
634
|
+
warnings.push('Connectivity service credentials could not be read; OnPremise destinations were disabled.');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
for (const item of recordsByInstance) {
|
|
640
|
+
const { instance, key, record, detailsFailed } = item;
|
|
641
|
+
const base = destinationDescriptor(record, spaceGuid, instance, key.keyName, selectedConnectivity && connectivity ? { instance: selectedConnectivity, keyName: connectivity.keyName } : undefined);
|
|
642
|
+
let disabledReason = reasonFor(record, Boolean(connectivity));
|
|
643
|
+
if (detailsFailed) disabledReason ||= 'Destination connection details could not be read';
|
|
644
|
+
if (record.proxyType === 'OnPremise' && !connectivity) disabledReason ||= 'Connectivity service credentials are unavailable';
|
|
645
|
+
if (disabledReason) {
|
|
646
|
+
base.disabledReason = disabledReason;
|
|
647
|
+
base.probe = { status: 'unavailable', available: false };
|
|
648
|
+
destinations.push(base);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (record.proxyType === 'OnPremise') {
|
|
652
|
+
try {
|
|
653
|
+
base.probe = await prepareOnPremise(record, connectivity, env, fetchImpl, options);
|
|
654
|
+
} catch {
|
|
655
|
+
base.disabledReason = record.authentication === 'PrincipalPropagation'
|
|
656
|
+
? 'PrincipalPropagation or Connectivity route setup failed'
|
|
657
|
+
: 'Connectivity route setup failed';
|
|
658
|
+
base.probe = { status: 'unavailable', available: false };
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
base.probe = await probeRecord(record, env, { fetchImpl: undiciFetch });
|
|
662
|
+
}
|
|
663
|
+
destinations.push(base);
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
warnings.push('Cloud Foundry Destination import failed; any usable BAS destinations remain available.');
|
|
667
|
+
}
|
|
668
|
+
return { destinations, createdKeys, warnings };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function configuredValue(env, key) {
|
|
672
|
+
return text(env[key]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function cfServerName(env, name) {
|
|
676
|
+
return `cf:${env.BAS_CF_SPACE_GUID}:${env.BAS_CF_DESTINATION_INSTANCE_GUID}:${name}`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function validateRuntimeRecord(record, connectivityAvailable) {
|
|
680
|
+
const reason = reasonFor(record, connectivityAvailable);
|
|
681
|
+
if (reason) throw new Error(`Configured CF destination is unavailable: ${reason}. Rerun sap-ai-dev --setup after correcting the Destination service record.`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function verifyServiceInstance(instanceGuid, instanceName, spaceGuid, kind, options) {
|
|
685
|
+
let instance;
|
|
686
|
+
try {
|
|
687
|
+
instance = await cfJson(`/v3/service_instances/${encodeURIComponent(instanceGuid)}`, options);
|
|
688
|
+
} catch {
|
|
689
|
+
throw new Error(`Configured ${kind} service instance ${instanceName} is unavailable.`);
|
|
690
|
+
}
|
|
691
|
+
if (instance?.guid !== instanceGuid || instance?.name !== instanceName || instance?.relationships?.space?.data?.guid !== spaceGuid) {
|
|
692
|
+
throw new Error(`Configured ${kind} service instance ${instanceName} does not match the configured space.`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function resolveConfiguredCloudFoundryDestination({ env = process.env, execFileImpl = nodeExecFile, fetchImpl = globalThis.fetch } = {}) {
|
|
697
|
+
const spaceGuid = configuredValue(env, 'BAS_CF_SPACE_GUID');
|
|
698
|
+
const destinationInstanceGuid = configuredValue(env, 'BAS_CF_DESTINATION_INSTANCE_GUID');
|
|
699
|
+
const destinationInstanceName = configuredValue(env, 'BAS_CF_DESTINATION_INSTANCE');
|
|
700
|
+
const destinationKeyName = configuredValue(env, 'BAS_CF_DESTINATION_KEY');
|
|
701
|
+
const name = configuredValue(env, 'BAS_CF_DESTINATION_NAME') || configuredValue(env, 'BAS_VSP_DESTINATION');
|
|
702
|
+
if (!spaceGuid || !destinationInstanceGuid || !destinationInstanceName || !destinationKeyName || !name) {
|
|
703
|
+
throw new Error('Cloud Foundry destination configuration is incomplete; rerun sap-ai-dev --setup.');
|
|
704
|
+
}
|
|
705
|
+
const target = await getCloudFoundryTarget({ env, execFileImpl });
|
|
706
|
+
if (!target.available) throw new Error(`${target.reason.replace(/; CF destinations were skipped\.$/, '')}; run cf target for the configured space and retry.`);
|
|
707
|
+
if (target.spaceGuid !== spaceGuid) throw new Error(`Active Cloud Foundry space does not match configured space ${spaceGuid}; run cf target for that space and retry.`);
|
|
708
|
+
const options = { env, execFileImpl };
|
|
709
|
+
try {
|
|
710
|
+
await verifyServiceInstance(destinationInstanceGuid, destinationInstanceName, spaceGuid, 'Destination', options);
|
|
711
|
+
} catch {
|
|
712
|
+
throw new Error(`Configured Destination service instance ${destinationInstanceName} is unavailable in the configured CF space; rerun sap-ai-dev --setup.`);
|
|
713
|
+
}
|
|
714
|
+
let destinationCredentials;
|
|
715
|
+
try {
|
|
716
|
+
destinationCredentials = await serviceKeyCredentials(destinationInstanceName, destinationKeyName, 'destination', options);
|
|
717
|
+
} catch {
|
|
718
|
+
throw new Error(`Managed Destination service key ${destinationKeyName} for ${destinationInstanceName} is unavailable; rerun sap-ai-dev --setup in the configured space.`);
|
|
719
|
+
}
|
|
720
|
+
let records;
|
|
721
|
+
try {
|
|
722
|
+
records = await destinationRecords(destinationCredentials, fetchImpl);
|
|
723
|
+
} catch {
|
|
724
|
+
throw new Error(`Destination service instance ${destinationInstanceName} could not be read; verify CF login and rerun sap-ai-dev --setup.`);
|
|
725
|
+
}
|
|
726
|
+
const found = records.find(item => item.record.name === name);
|
|
727
|
+
if (!found || found.detailsFailed) throw new Error(`Destination ${name} is no longer available from instance ${destinationInstanceName}; rerun sap-ai-dev --setup.`);
|
|
728
|
+
const record = found.record;
|
|
729
|
+
const serverName = cfServerName(env, name);
|
|
730
|
+
const isOnPremise = record.proxyType === 'OnPremise';
|
|
731
|
+
validateRuntimeRecord(record, true);
|
|
732
|
+
|
|
733
|
+
const childEnv = {};
|
|
734
|
+
if (record.authentication === 'BasicAuthentication') {
|
|
735
|
+
childEnv.SAP_USER = record.user;
|
|
736
|
+
childEnv.SAP_PASSWORD = record.password;
|
|
737
|
+
childEnv.SAP_VERBOSE = 'false';
|
|
738
|
+
}
|
|
739
|
+
let url = record.url;
|
|
740
|
+
let close;
|
|
741
|
+
if (isOnPremise) {
|
|
742
|
+
const connectivityInstanceName = configuredValue(env, 'BAS_CF_CONNECTIVITY_INSTANCE');
|
|
743
|
+
const connectivityKeyName = configuredValue(env, 'BAS_CF_CONNECTIVITY_KEY');
|
|
744
|
+
if (!configuredValue(env, 'BAS_CF_CONNECTIVITY_INSTANCE_GUID') || !connectivityInstanceName || !connectivityKeyName) {
|
|
745
|
+
throw new Error(`Configured OnPremise destination ${name} has no Connectivity service reference; rerun sap-ai-dev --setup.`);
|
|
746
|
+
}
|
|
747
|
+
try {
|
|
748
|
+
await verifyServiceInstance(configuredValue(env, 'BAS_CF_CONNECTIVITY_INSTANCE_GUID'), connectivityInstanceName, spaceGuid, 'Connectivity', options);
|
|
749
|
+
} catch {
|
|
750
|
+
throw new Error(`Configured Connectivity service instance ${connectivityInstanceName} is unavailable in the configured CF space; rerun sap-ai-dev --setup.`);
|
|
751
|
+
}
|
|
752
|
+
let credentials;
|
|
753
|
+
try {
|
|
754
|
+
credentials = await serviceKeyCredentials(connectivityInstanceName, connectivityKeyName, 'connectivity', options);
|
|
755
|
+
} catch {
|
|
756
|
+
throw new Error(`Managed Connectivity service key ${connectivityKeyName} for ${connectivityInstanceName} is unavailable; rerun sap-ai-dev --setup in the configured space.`);
|
|
757
|
+
}
|
|
758
|
+
if (!connectivityCredentialsValid(credentials)) throw new Error(`Connectivity service credentials for ${connectivityInstanceName} are incomplete; rerun sap-ai-dev --setup.`);
|
|
759
|
+
let route;
|
|
760
|
+
try {
|
|
761
|
+
const authMode = record.authentication === 'PrincipalPropagation' ? 'principal-propagation' : 'application';
|
|
762
|
+
route = await createCfConnectivityProxy({
|
|
763
|
+
destinationUrl: record.url,
|
|
764
|
+
connectivityCredentials: credentials,
|
|
765
|
+
locationId: record.locationId,
|
|
766
|
+
authMode,
|
|
767
|
+
userTokenProvider: authMode === 'principal-propagation' ? () => currentUserToken(options) : undefined,
|
|
768
|
+
fetchImpl
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
771
|
+
throw new Error(`Connectivity route for destination ${name} could not be started; verify the service key and CF user identity.`);
|
|
772
|
+
}
|
|
773
|
+
url = record.url;
|
|
774
|
+
childEnv.HTTP_PROXY = route.url;
|
|
775
|
+
childEnv.HTTPS_PROXY = route.url;
|
|
776
|
+
childEnv.http_proxy = route.url;
|
|
777
|
+
childEnv.https_proxy = route.url;
|
|
778
|
+
childEnv.NO_PROXY = '';
|
|
779
|
+
childEnv.no_proxy = '';
|
|
780
|
+
close = route.close;
|
|
781
|
+
}
|
|
782
|
+
return {
|
|
783
|
+
source: 'cloud-foundry',
|
|
784
|
+
name,
|
|
785
|
+
serverName,
|
|
786
|
+
url,
|
|
787
|
+
client: record.client,
|
|
788
|
+
authentication: record.authentication,
|
|
789
|
+
proxyType: record.proxyType,
|
|
790
|
+
childEnv,
|
|
791
|
+
...(close ? { close } : {})
|
|
792
|
+
};
|
|
793
|
+
}
|