sap-ai-dev-toolkit 0.9.9914 → 0.9.9915
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/skills/integration-suite-delivery/SKILL.md +1 -1
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/cf-destination.mjs +5 -5
- package/src/cf-integration.mjs +74 -0
- package/src/integration-http.mjs +17 -1
- package/src/integration-verify.mjs +1 -1
- package/src/setup.mjs +157 -36
|
@@ -23,7 +23,7 @@ The Integration Suite companion is never destination-prefixed; its tools are alr
|
|
|
23
23
|
- APIM products and key value maps: `apim_list_api_products`, `apim_get_api_product`, `apim_create_api_product`, `apim_update_api_product`, `apim_list_key_value_maps`, `apim_get_key_value_map`, `apim_create_key_value_map`, `apim_set_key_value_map_entries`.
|
|
24
24
|
- Anything else (access policies, JMS queues, applications, developers, certificate store references): the read-only `cpi_odata_get` / `apim_odata_get`; start with path `/$metadata` to see the entity sets.
|
|
25
25
|
|
|
26
|
-
`cpi_*` tools appear only when CPI credentials are configured and `apim_*` tools only when APIM credentials are configured. Start every session with `integration_connection_info` and `verify: true` to see which side is available, which tenant hosts are bound, and which tool areas the service key lacks a role for. When a side is missing
|
|
26
|
+
`cpi_*` tools appear only when CPI credentials are configured and `apim_*` tools only when APIM credentials are configured. Start every session with `integration_connection_info` and `verify: true` to see which side is available, which tenant hosts are bound, and which tool areas the service key lacks a role for. When a side is missing, cannot sign in, or lacks roles, ask the user to run `sap-ai-dev --setup-integration` in a terminal: it takes the key from the logged-in Cloud Foundry space (and can create a dedicated instance with the needed roles) or from a pasted key, checks it online, and stores it user-only. Never ask for a key in the chat.
|
|
27
27
|
|
|
28
28
|
## Exposing an SAP OData service through API Management
|
|
29
29
|
|
package/README.md
CHANGED
|
@@ -232,8 +232,9 @@ Every interactive `sap-ai-dev --setup` asks **"Add the SAP Integration Suite too
|
|
|
232
232
|
To set up only this server, run **`sap-ai-dev --setup-integration`**. In both routes the credential step:
|
|
233
233
|
|
|
234
234
|
1. **Checks detected credentials online.** It signs in and runs one read-only request per tool area (packages, deployed artifacts, message logs, security material, number ranges, Partner Directory, data stores, JMS; API proxies, providers, products, key value maps, virtual hosts, and policy readiness, which exports an existing API proxy bundle the way `apim_attach_api_proxy_policies` does). Each area is marked ✓, ✗ (missing role) or – (not available on the tenant or plan, or no proxy exists yet to test the export with).
|
|
235
|
-
2. **
|
|
236
|
-
3. **
|
|
235
|
+
2. **Uses the logged-in Cloud Foundry space (default when `cf` is logged in).** It finds the Process Integration Runtime instance with plan `api` (`it-rt`) and the API Management instance with plan `apiportal-apiaccess`, lets you choose when there are several, and reuses or creates one service key named `sap-ai-dev-integration` on it. The key is then verified and saved like a pasted one. Roles are fixed on the instance, so when the key signs in but lacks roles, or a side has no instance, the wizard offers to create a dedicated instance (`sap-ai-dev-integration-cpi` with `AuthGroup_Administrator`, `AuthGroup_BusinessExpert`, `AuthGroup_IntegrationDeveloper`, `AuthGroup_TenantPartnerDirectoryConfigurator`, `AccessAllAccessPoliciesArtifacts`; `sap-ai-dev-integration-apim` with `APIPortal.Administrator`). It never does this without your `y`.
|
|
236
|
+
3. **Lets you paste a service key.** Paste a CPI key (Process Integration Runtime, plan `api`) or an APIM key (API Management, plan `apiportal-apiaccess`), either as JSON (multi-line paste works; input is hidden) or as the path to the downloaded `.json` file. The key is verified the same way before it is saved.
|
|
237
|
+
4. **Saves only what the server needs.** The URL, token URL, client id and secret go to `~/.config/sap-ai-dev-toolkit/integration-suite.json`, readable only by you. The secret is never printed and never written to `mcp.json`. A key that cannot sign in is saved only if you confirm.
|
|
237
238
|
|
|
238
239
|
Environment credentials take precedence over a saved key. Re-check at any time with `sap-ai-integration --check`, or ask the agent to call `integration_connection_info` with `verify: true`. Configure CPI, APIM, or both; per side the first match wins:
|
|
239
240
|
|
package/package.json
CHANGED
package/src/cf-destination.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function cliError(error) {
|
|
|
26
26
|
return failure;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async function runCf(args, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
29
|
+
export async function runCf(args, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
30
30
|
try {
|
|
31
31
|
const run = execFileImpl === nodeExecFile ? execFile : promisify(execFileImpl);
|
|
32
32
|
const result = await run('cf', args, {
|
|
@@ -86,7 +86,7 @@ export async function getCloudFoundryTarget({ env = process.env, execFileImpl =
|
|
|
86
86
|
if (!spaceGuid || /\s/.test(spaceGuid)) {
|
|
87
87
|
return { available: false, reason: 'Cloud Foundry CLI is not authenticated to a targeted space; CF destinations were skipped.' };
|
|
88
88
|
}
|
|
89
|
-
return { available: true, spaceGuid };
|
|
89
|
+
return { available: true, spaceGuid, spaceName, orgName: /^\s*org:\s*(.*?)\s*$/im.exec(target)?.[1] || '' };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function missingServiceKey(error) {
|
|
@@ -159,7 +159,7 @@ export async function findOrphanedCloudFoundryServiceKeys({ env = process.env, s
|
|
|
159
159
|
return orphans;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
async function cfJson(path, options) {
|
|
162
|
+
export async function cfJson(path, options) {
|
|
163
163
|
const value = await runCf(['curl', path], options);
|
|
164
164
|
try {
|
|
165
165
|
return JSON.parse(value);
|
|
@@ -177,7 +177,7 @@ function apiPath(href) {
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
async function serviceInstances(spaceGuid, options) {
|
|
180
|
+
export async function serviceInstances(spaceGuid, options) {
|
|
181
181
|
const instances = [];
|
|
182
182
|
let path = `/v3/service_instances?space_guids=${encodeURIComponent(spaceGuid)}&per_page=500`;
|
|
183
183
|
const visited = new Set();
|
|
@@ -398,7 +398,7 @@ async function readServiceKey(instance, kind, spaceGuid, managedKeys, createdKey
|
|
|
398
398
|
return { keyName, credentials };
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
async function serviceKeyCredentials(instanceName, keyName, kind, options) {
|
|
401
|
+
export async function serviceKeyCredentials(instanceName, keyName, kind, options) {
|
|
402
402
|
const raw = await runCf(['service-key', '--json', instanceName, keyName], options);
|
|
403
403
|
let parsed;
|
|
404
404
|
try {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Integration Suite credentials from the logged-in Cloud Foundry space: find the Process Integration
|
|
2
|
+
// Runtime (plan api) and API Management (plan apiportal-apiaccess) instances, reuse or create one
|
|
3
|
+
// named service key per instance, and optionally create a dedicated instance with the roles the tools need.
|
|
4
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
5
|
+
import { cfJson, runCf, serviceInstances, serviceKeyCredentials } from './cf-destination.mjs';
|
|
6
|
+
|
|
7
|
+
// Not the toolkit's destination-key prefix: the destination orphan sweep must not offer to delete these keys.
|
|
8
|
+
export const INTEGRATION_KEY_NAME = 'sap-ai-dev-integration';
|
|
9
|
+
|
|
10
|
+
export const INTEGRATION_CF_SERVICES = Object.freeze({
|
|
11
|
+
CPI: Object.freeze({
|
|
12
|
+
offering: 'it-rt',
|
|
13
|
+
plan: 'api',
|
|
14
|
+
label: 'Process Integration Runtime',
|
|
15
|
+
dedicatedInstance: 'sap-ai-dev-integration-cpi',
|
|
16
|
+
// Role templates of the Cloud Integration API plan covering design, deployment, monitoring,
|
|
17
|
+
// security material and Partner Directory work done by the cpi_* tools.
|
|
18
|
+
parameters: Object.freeze({
|
|
19
|
+
'grant-types': ['client_credentials'],
|
|
20
|
+
'redirect-uris': [],
|
|
21
|
+
roles: ['AuthGroup_Administrator', 'AuthGroup_BusinessExpert', 'AuthGroup_IntegrationDeveloper', 'AuthGroup_TenantPartnerDirectoryConfigurator', 'AccessAllAccessPoliciesArtifacts']
|
|
22
|
+
})
|
|
23
|
+
}),
|
|
24
|
+
APIM: Object.freeze({
|
|
25
|
+
offering: 'apimanagement-apiportal',
|
|
26
|
+
plan: 'apiportal-apiaccess',
|
|
27
|
+
label: 'API Management, API portal',
|
|
28
|
+
dedicatedInstance: 'sap-ai-dev-integration-apim',
|
|
29
|
+
parameters: Object.freeze({ role: 'APIPortal.Administrator' })
|
|
30
|
+
})
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Integration Suite instances in the space, by side: { CPI: [{ guid, name }], APIM: [...] }. */
|
|
34
|
+
export async function findIntegrationInstances({ env = process.env, spaceGuid, execFileImpl = nodeExecFile } = {}) {
|
|
35
|
+
const options = { env, execFileImpl };
|
|
36
|
+
const plans = new Map();
|
|
37
|
+
const found = { CPI: [], APIM: [] };
|
|
38
|
+
for (const instance of await serviceInstances(spaceGuid, options)) {
|
|
39
|
+
const planGuid = instance?.relationships?.service_plan?.data?.guid;
|
|
40
|
+
if (!planGuid || !instance?.name) continue;
|
|
41
|
+
if (!plans.has(planGuid)) {
|
|
42
|
+
try {
|
|
43
|
+
const plan = await cfJson(`/v3/service_plans/${encodeURIComponent(planGuid)}?include=service_offering`, options);
|
|
44
|
+
plans.set(planGuid, { plan: plan?.name, offering: plan?.included?.service_offerings?.[0]?.name });
|
|
45
|
+
} catch {
|
|
46
|
+
plans.set(planGuid, {});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const { plan, offering } = plans.get(planGuid);
|
|
50
|
+
for (const [side, service] of Object.entries(INTEGRATION_CF_SERVICES)) {
|
|
51
|
+
if (offering === service.offering && plan === service.plan) found[side].push({ guid: String(instance.guid), name: String(instance.name) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Credentials of the toolkit's service key on an instance; the key is created when it does not exist yet. */
|
|
58
|
+
export async function integrationServiceKey(instanceName, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
59
|
+
const options = { env, execFileImpl };
|
|
60
|
+
try {
|
|
61
|
+
return { keyName: INTEGRATION_KEY_NAME, created: false, credentials: await serviceKeyCredentials(instanceName, INTEGRATION_KEY_NAME, 'Integration Suite', options) };
|
|
62
|
+
} catch {
|
|
63
|
+
// No readable key of that name yet.
|
|
64
|
+
}
|
|
65
|
+
await runCf(['create-service-key', instanceName, INTEGRATION_KEY_NAME, '--wait'], options);
|
|
66
|
+
return { keyName: INTEGRATION_KEY_NAME, created: true, credentials: await serviceKeyCredentials(instanceName, INTEGRATION_KEY_NAME, 'Integration Suite', options) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Create the dedicated instance for a side with the roles the tools need; returns { guid?, name }. */
|
|
70
|
+
export async function createIntegrationInstance(side, { env = process.env, execFileImpl = nodeExecFile } = {}) {
|
|
71
|
+
const service = INTEGRATION_CF_SERVICES[side];
|
|
72
|
+
await runCf(['create-service', service.offering, service.plan, service.dedicatedInstance, '-c', JSON.stringify(service.parameters), '--wait'], { env, execFileImpl });
|
|
73
|
+
return { name: service.dedicatedInstance };
|
|
74
|
+
}
|
package/src/integration-http.mjs
CHANGED
|
@@ -80,6 +80,22 @@ function setCookiePairs(headers) {
|
|
|
80
80
|
return values.map(value => value.split(';')[0].trim()).filter(Boolean);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// undici flags EnvHttpProxyAgent as experimental with a process warning that lands in the middle of the
|
|
84
|
+
// setup wizard output; only that warning is dropped, and only while the agent is constructed.
|
|
85
|
+
function envProxyAgent(options) {
|
|
86
|
+
const emitWarning = process.emitWarning;
|
|
87
|
+
process.emitWarning = (warning, ...rest) => {
|
|
88
|
+
const code = typeof rest[0] === 'object' ? rest[0]?.code : rest[1];
|
|
89
|
+
if (code === 'UNDICI-EHPA') return;
|
|
90
|
+
emitWarning.call(process, warning, ...rest);
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
return new EnvHttpProxyAgent(options);
|
|
94
|
+
} finally {
|
|
95
|
+
process.emitWarning = emitWarning;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/** Create a tenant-scoped client using OAuth client credentials. */
|
|
84
100
|
export function createServiceClient({
|
|
85
101
|
label,
|
|
@@ -97,7 +113,7 @@ export function createServiceClient({
|
|
|
97
113
|
const httpProxy = env.HTTP_PROXY || env.http_proxy;
|
|
98
114
|
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
|
|
99
115
|
const dispatcher = httpProxy || httpsProxy
|
|
100
|
-
?
|
|
116
|
+
? envProxyAgent({ httpProxy, httpsProxy, noProxy: env.NO_PROXY || env.no_proxy })
|
|
101
117
|
: undefined;
|
|
102
118
|
const transportOptions = dispatcher ? { dispatcher } : {};
|
|
103
119
|
let cachedToken = null;
|
|
@@ -109,7 +109,7 @@ export async function verifyIntegrationClients({ cpi, apim } = {}) {
|
|
|
109
109
|
export function verificationLines(label, host, side) {
|
|
110
110
|
if (!side?.configured) return [];
|
|
111
111
|
if (!side.signed_in) return [`✗ ${label} ${host}: sign-in failed — ${side.error}`];
|
|
112
|
-
const lines = [
|
|
112
|
+
const lines = [side.ok ? `✓ ${label} ${host}: signed in` : `✗ ${label} ${host}: signed in, but ${side.checks[0]?.area ?? 'the core area'} is not readable with this key`];
|
|
113
113
|
for (const check of side.checks) {
|
|
114
114
|
const mark = check.result === 'ok' ? '✓' : (check.result === 'unavailable' ? '–' : '✗');
|
|
115
115
|
lines.push(` ${mark} ${check.area}${check.result === 'ok' ? '' : ` — ${check.detail}`}`);
|
package/src/setup.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedC
|
|
|
10
10
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
11
11
|
import { parseIntegrationServiceKey, resolveIntegrationConfig, resolveIntegrationSideFromEnvironment } from './integration-config.mjs';
|
|
12
12
|
import { readStoredIntegrationKeys, storeIntegrationKey } from './integration-credentials.mjs';
|
|
13
|
+
import { INTEGRATION_CF_SERVICES, createIntegrationInstance, findIntegrationInstances, integrationServiceKey } from './cf-integration.mjs';
|
|
13
14
|
import { createIntegrationClients, verificationLines, verifyIntegrationClients } from './integration-verify.mjs';
|
|
14
15
|
import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
|
|
15
16
|
import { findRemoteControllableSsoBrowsers } from './sso-browsers.mjs';
|
|
@@ -331,23 +332,148 @@ async function printIntegrationVerification(config, { env, output, verifyIntegra
|
|
|
331
332
|
if (report.ok) {
|
|
332
333
|
print(output, formatStatus(`Integration Suite credentials verified${missing ? `; ${missing} area${missing === 1 ? ' needs' : 's need'} an extra role on the service key` : ''}.`, missing ? 'warning' : 'success', output, 'Integration Suite'));
|
|
333
334
|
} else {
|
|
334
|
-
|
|
335
|
+
const signedIn = [report.cpi, report.apim].some(side => side?.signed_in);
|
|
336
|
+
print(output, formatStatus(signedIn
|
|
337
|
+
? 'The key signs in but lacks the roles for the core area; use an instance with the needed roles (the Cloud Foundry option can create one) or replace the key.'
|
|
338
|
+
: 'Integration Suite sign-in failed; fix or replace the service key before using the tools.', 'warning', output, 'Integration Suite'));
|
|
335
339
|
}
|
|
336
340
|
return report;
|
|
337
341
|
}
|
|
338
342
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
343
|
+
const defaultIntegrationCloudFoundry = Object.freeze({
|
|
344
|
+
getTarget: getCloudFoundryTarget,
|
|
345
|
+
findInstances: findIntegrationInstances,
|
|
346
|
+
serviceKey: integrationServiceKey,
|
|
347
|
+
createInstance: createIntegrationInstance
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const SIDE_LABELS = Object.freeze({ CPI: 'Cloud Integration (CPI)', APIM: 'API Management' });
|
|
351
|
+
|
|
352
|
+
function cfFailure(error) {
|
|
353
|
+
const detail = String(error?.stderr || error?.message || '').replace(/\s+/g, ' ').trim();
|
|
354
|
+
return detail ? detail.slice(0, 300) : 'Cloud Foundry CLI command failed';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Verify one side's key online, then save it user-only. A key that cannot sign in is saved only when confirmed.
|
|
358
|
+
async function acceptIntegrationKey(side, config, { env, input, output, verifyIntegration }) {
|
|
359
|
+
const choice = side.toLowerCase();
|
|
360
|
+
const report = await printIntegrationVerification({ [choice]: config }, { env, output, verifyIntegration });
|
|
361
|
+
if (!report[choice].signed_in && !await confirmPrompt('Save this key anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return { saved: false, report };
|
|
362
|
+
const path = await storeIntegrationKey(side, config, env);
|
|
363
|
+
print(output, formatStatus(`Saved the ${side} key to ${path} (readable only by you; not written to mcp.json).`, 'success', output, 'Integration Suite'));
|
|
364
|
+
let fromEnvironment = null;
|
|
365
|
+
try {
|
|
366
|
+
fromEnvironment = resolveIntegrationSideFromEnvironment(env, side);
|
|
367
|
+
} catch {
|
|
368
|
+
// A broken environment value is reported by the status lines.
|
|
369
|
+
}
|
|
370
|
+
if (fromEnvironment) print(output, formatStatus(`${side} credentials from ${fromEnvironment.source} in this environment take precedence over the saved key.`, 'warning', output, 'Integration Suite'));
|
|
371
|
+
return { saved: true, report };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function lacksRoles(side) {
|
|
375
|
+
return Boolean(side?.signed_in) && (!side.ok || side.checks.some(check => check.result === 'forbidden'));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function cloudFoundryStep(message, operation, { env, output }) {
|
|
379
|
+
const stop = startProgress(message, { output, env, label: 'Cloud Foundry' });
|
|
380
|
+
try {
|
|
381
|
+
return await operation();
|
|
382
|
+
} finally {
|
|
383
|
+
stop();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Key from an instance: reuse or create the toolkit's service key, then verify and save it.
|
|
388
|
+
async function useCloudFoundryInstance(side, instanceName, context) {
|
|
389
|
+
const { env, output, cloudFoundry } = context;
|
|
390
|
+
const key = await cloudFoundryStep(`Reading service key ${instanceName}/sap-ai-dev-integration`, () => cloudFoundry.serviceKey(instanceName, { env }), context);
|
|
391
|
+
print(output, formatStatus(`${key.created ? 'Created' : 'Reusing'} service key ${key.keyName} on ${instanceName}.`, 'info', output, 'Cloud Foundry'));
|
|
392
|
+
return acceptIntegrationKey(side, parseIntegrationServiceKey(side, key.credentials), context);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function createDedicatedInstance(side, context) {
|
|
396
|
+
const { env, output, cloudFoundry } = context;
|
|
397
|
+
const service = INTEGRATION_CF_SERVICES[side];
|
|
398
|
+
try {
|
|
399
|
+
await cloudFoundryStep(`Creating ${service.offering}/${service.plan} instance ${service.dedicatedInstance}`, () => cloudFoundry.createInstance(side, { env }), context);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
print(output, formatStatus(`Could not create ${service.dedicatedInstance}: ${cfFailure(error)}`, 'warning', output, 'Cloud Foundry'));
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
print(output, formatStatus(`Created service instance ${service.dedicatedInstance}.`, 'success', output, 'Cloud Foundry'));
|
|
405
|
+
return useCloudFoundryInstance(side, service.dedicatedInstance, context);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Use the logged-in CF space: per side pick the matching instance (or offer to create a dedicated one with the
|
|
409
|
+
// roles the tools need), reuse or create the toolkit's service key, verify it online and save it.
|
|
410
|
+
async function useCloudFoundrySpace(target, context) {
|
|
411
|
+
const { env, input, output, cloudFoundry } = context;
|
|
412
|
+
let instances;
|
|
413
|
+
try {
|
|
414
|
+
instances = await cloudFoundryStep('Looking for Integration Suite service instances in the CF space', () => cloudFoundry.findInstances({ env, spaceGuid: target.spaceGuid }), context);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
print(output, formatStatus(`Could not list service instances: ${cfFailure(error)}`, 'warning', output, 'Cloud Foundry'));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
for (const side of ['CPI', 'APIM']) {
|
|
420
|
+
const service = INTEGRATION_CF_SERVICES[side];
|
|
421
|
+
const candidates = instances[side] || [];
|
|
422
|
+
print(output, '');
|
|
423
|
+
if (!candidates.length) {
|
|
424
|
+
print(output, formatStatus(`No ${service.label} instance with plan ${service.plan} (${service.offering}) in this space.`, 'info', output, SIDE_LABELS[side]));
|
|
425
|
+
if (await confirmPrompt(`Create instance ${service.dedicatedInstance} (${service.offering}, plan ${service.plan}) with the roles the tools need? (y/n, Enter=no)`, { input, output, defaultYes: false })) {
|
|
426
|
+
await createDedicatedInstance(side, context);
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const dedicated = candidates.find(candidate => candidate.name === service.dedicatedInstance);
|
|
431
|
+
const instanceName = candidates.length === 1 ? candidates[0].name : await selectPrompt({
|
|
432
|
+
message: colorText(`☁️ ${SIDE_LABELS[side]}: choose the ${service.offering}/${service.plan} instance to use`, 'cyan', output),
|
|
433
|
+
defaultValue: (dedicated || candidates[0]).name,
|
|
434
|
+
choices: [...candidates.map(candidate => ({ name: candidate.name, value: candidate.name })), { name: 'Skip', value: '', shortcut: 's' }]
|
|
435
|
+
}, { input, output });
|
|
436
|
+
if (!instanceName) continue;
|
|
437
|
+
let result;
|
|
438
|
+
try {
|
|
439
|
+
result = await useCloudFoundryInstance(side, instanceName, context);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
print(output, formatStatus(`Could not use ${instanceName}: ${cfFailure(error)}`, 'warning', output, SIDE_LABELS[side]));
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const sideReport = result.report[side.toLowerCase()];
|
|
445
|
+
if (instanceName !== service.dedicatedInstance && !dedicated && lacksRoles(sideReport)) {
|
|
446
|
+
print(output, ` The roles of ${instanceName} are fixed on the instance; a new key cannot add them.`);
|
|
447
|
+
print(output, ` A dedicated instance gets: ${service.parameters.roles ? service.parameters.roles.join(', ') : service.parameters.role}.`);
|
|
448
|
+
if (await confirmPrompt(`Create instance ${service.dedicatedInstance} with these roles and use it instead? (y/n, Enter=no)`, { input, output, defaultYes: false })) {
|
|
449
|
+
await createDedicatedInstance(side, context);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Verify the detected credentials online and let the user take CPI/APIM keys from the logged-in CF space or
|
|
456
|
+
// paste them. A key is checked online first and saved to the user-only store (never to mcp.json); it is used
|
|
457
|
+
// when the environment has no key.
|
|
458
|
+
async function configureIntegrationCredentials({ env, input, output, verifyIntegration, cloudFoundry = defaultIntegrationCloudFoundry }) {
|
|
342
459
|
let status = integrationSuiteCredentialStatus(env);
|
|
343
460
|
if (status.found) await printIntegrationVerification(status.config, { env, output, verifyIntegration });
|
|
461
|
+
let target = { available: false };
|
|
462
|
+
try {
|
|
463
|
+
target = await cloudFoundryStep('Checking the Cloud Foundry login', () => cloudFoundry.getTarget({ env }), { env, output });
|
|
464
|
+
} catch {
|
|
465
|
+
// No CF CLI or no login: the CF option is simply not offered.
|
|
466
|
+
}
|
|
467
|
+
const context = { env, input, output, verifyIntegration, cloudFoundry };
|
|
344
468
|
for (;;) {
|
|
345
469
|
print(output, '');
|
|
470
|
+
const space = target.available ? [target.orgName, target.spaceName].filter(Boolean).join(' / ') || target.spaceGuid : '';
|
|
346
471
|
const choice = await selectPrompt({
|
|
347
472
|
message: colorText('🔑 Integration Suite credentials', 'cyan', output),
|
|
348
|
-
defaultValue: status.found ? 'done' : 'cpi',
|
|
473
|
+
defaultValue: status.found ? 'done' : (target.available ? 'cf' : 'cpi'),
|
|
349
474
|
choices: [
|
|
350
475
|
{ name: status.found ? 'Continue' : 'Continue without credentials (the server will not start until a key is configured)', value: 'done', shortcut: 'c' },
|
|
476
|
+
...(target.available ? [{ name: `Use the logged-in Cloud Foundry space (${space}): find the instances, create or reuse service keys, verify`, value: 'cf', shortcut: 'f' }] : []),
|
|
351
477
|
{ name: 'Paste a Cloud Integration (CPI) service key — Process Integration Runtime, plan api', value: 'cpi', shortcut: 'p' },
|
|
352
478
|
{ name: 'Paste an API Management service key — API Management, plan apiportal-apiaccess', value: 'apim', shortcut: 'a' },
|
|
353
479
|
...(status.found ? [{ name: 'Check the configured credentials online again', value: 'verify', shortcut: 'v' }] : [])
|
|
@@ -358,37 +484,30 @@ async function configureIntegrationCredentials({ env, input, output, verifyInteg
|
|
|
358
484
|
await printIntegrationVerification(status.config, { env, output, verifyIntegration });
|
|
359
485
|
continue;
|
|
360
486
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
487
|
+
if (choice === 'cf') {
|
|
488
|
+
await useCloudFoundrySpace(target, context);
|
|
489
|
+
} else {
|
|
490
|
+
const side = choice.toUpperCase();
|
|
491
|
+
const pasted = await textPrompt({
|
|
492
|
+
message: `Paste the ${side} service key JSON or the path to its .json file`,
|
|
493
|
+
secret: true,
|
|
494
|
+
validate: value => {
|
|
495
|
+
try {
|
|
496
|
+
pastedServiceKey(side, value, env);
|
|
497
|
+
return true;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return error.message;
|
|
500
|
+
}
|
|
371
501
|
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const config = pastedServiceKey(side, pasted, env);
|
|
375
|
-
const report = await printIntegrationVerification({ [choice]: config }, { env, output, verifyIntegration });
|
|
376
|
-
if (!report[choice].signed_in && !await confirmPrompt('Save this key anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) continue;
|
|
377
|
-
const path = await storeIntegrationKey(side, config, env);
|
|
378
|
-
print(output, formatStatus(`Saved the ${side} key to ${path} (readable only by you; not written to mcp.json).`, 'success', output, 'Integration Suite'));
|
|
379
|
-
let fromEnvironment = null;
|
|
380
|
-
try {
|
|
381
|
-
fromEnvironment = resolveIntegrationSideFromEnvironment(env, side);
|
|
382
|
-
} catch {
|
|
383
|
-
// A broken environment value is reported by the status lines below.
|
|
502
|
+
}, { input, output });
|
|
503
|
+
await acceptIntegrationKey(side, pastedServiceKey(side, pasted, env), context);
|
|
384
504
|
}
|
|
385
|
-
if (fromEnvironment) print(output, formatStatus(`${side} credentials from ${fromEnvironment.source} in this environment take precedence over the saved key.`, 'warning', output, 'Integration Suite'));
|
|
386
505
|
status = integrationSuiteCredentialStatus(env);
|
|
387
506
|
for (const line of status.lines) print(output, ` ${line}`);
|
|
388
507
|
}
|
|
389
508
|
}
|
|
390
509
|
|
|
391
|
-
async function promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration = defaultVerifyIntegration, ask = true }) {
|
|
510
|
+
async function promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration = defaultVerifyIntegration, integrationCloudFoundry, ask = true }) {
|
|
392
511
|
print(output, '');
|
|
393
512
|
print(output, formatStatus('SAP Integration Suite tools (Cloud Integration + API Management).', 'step', output, 'Integration Suite'));
|
|
394
513
|
print(output, ' Adds the sap-ai-integration MCP server: iFlows, value mappings, security material and monitoring in CPI; API providers, proxies, policies, products and key value maps in APIM.');
|
|
@@ -405,7 +524,7 @@ async function promptIntegrationSuite({ env, alreadyConfigured, input, output, v
|
|
|
405
524
|
const defaultYes = status.found;
|
|
406
525
|
if (!await confirmPrompt(`Add the SAP Integration Suite tools? (y/n, Enter=${defaultYes ? 'yes' : 'no'})`, { input, output, defaultYes })) return false;
|
|
407
526
|
}
|
|
408
|
-
await configureIntegrationCredentials({ env, input, output, verifyIntegration });
|
|
527
|
+
await configureIntegrationCredentials({ env, input, output, verifyIntegration, ...(integrationCloudFoundry ? { cloudFoundry: integrationCloudFoundry } : {}) });
|
|
409
528
|
return true;
|
|
410
529
|
}
|
|
411
530
|
|
|
@@ -415,7 +534,8 @@ export async function runIntegrationSuiteSetup({
|
|
|
415
534
|
input = stdin,
|
|
416
535
|
output = stdout,
|
|
417
536
|
install = installMcpConfig,
|
|
418
|
-
verifyIntegration = defaultVerifyIntegration
|
|
537
|
+
verifyIntegration = defaultVerifyIntegration,
|
|
538
|
+
integrationCloudFoundry
|
|
419
539
|
} = {}) {
|
|
420
540
|
if (!input.isTTY || !output.isTTY) {
|
|
421
541
|
print(output, formatStatus('Interactive setup needs a terminal. Rerun sap-ai-dev --setup-integration from a terminal.', 'warning', output, 'SAP AI Dev Toolkit'));
|
|
@@ -427,7 +547,7 @@ export async function runIntegrationSuiteSetup({
|
|
|
427
547
|
} catch {
|
|
428
548
|
// A missing or unreadable config is created or healed by install.
|
|
429
549
|
}
|
|
430
|
-
await promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration, ask: false });
|
|
550
|
+
await promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
431
551
|
const result = await install([], { env, sapDevelopmentServers: ['integration-suite'], additive: true });
|
|
432
552
|
print(output, '');
|
|
433
553
|
print(output, formatStatus(`Configured the Integration Suite MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'Integration Suite'));
|
|
@@ -771,7 +891,8 @@ export async function runSetup({
|
|
|
771
891
|
includeSapDevelopmentToolsPrompt = false,
|
|
772
892
|
detectAdtUrl,
|
|
773
893
|
discoverSsoBrowsers = findRemoteControllableSsoBrowsers,
|
|
774
|
-
verifyIntegration = defaultVerifyIntegration
|
|
894
|
+
verifyIntegration = defaultVerifyIntegration,
|
|
895
|
+
integrationCloudFoundry
|
|
775
896
|
} = {}) {
|
|
776
897
|
const isBas = Boolean(env.H2O_URL);
|
|
777
898
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -943,7 +1064,7 @@ export async function runSetup({
|
|
|
943
1064
|
|
|
944
1065
|
let sapDevelopmentServers = [];
|
|
945
1066
|
if (integrationOnly) {
|
|
946
|
-
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, ask: false });
|
|
1067
|
+
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
947
1068
|
sapDevelopmentServers = ['integration-suite'];
|
|
948
1069
|
} else if (includeSapDevelopmentToolsPrompt) {
|
|
949
1070
|
print(output, '');
|
|
@@ -963,9 +1084,9 @@ export async function runSetup({
|
|
|
963
1084
|
shortcuts: { all: 'a' }
|
|
964
1085
|
}, { input, output });
|
|
965
1086
|
if (sapDevelopmentServers.includes('integration-suite')) {
|
|
966
|
-
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, ask: false });
|
|
1087
|
+
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
967
1088
|
}
|
|
968
|
-
} else if (await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration })) {
|
|
1089
|
+
} else if (await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry })) {
|
|
969
1090
|
// Without --tools, Integration Suite still gets its own yes/no step instead of being hidden.
|
|
970
1091
|
sapDevelopmentServers = ['integration-suite'];
|
|
971
1092
|
}
|