sap-ai-dev-toolkit 0.9.9914 → 0.9.9916
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 +179 -39
|
@@ -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,165 @@ 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, context) {
|
|
359
|
+
const { env, input, output, verifyIntegration } = context;
|
|
360
|
+
const choice = side.toLowerCase();
|
|
361
|
+
const report = await printIntegrationVerification({ [choice]: config }, { env, output, verifyIntegration });
|
|
362
|
+
if (!report[choice].signed_in && !await confirmPrompt('Save this key anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return { saved: false, report };
|
|
363
|
+
const path = await storeIntegrationKey(side, config, env);
|
|
364
|
+
print(output, formatStatus(`Saved the ${side} key to ${path} (readable only by you; not written to mcp.json).`, 'success', output, 'Integration Suite'));
|
|
365
|
+
let fromEnvironment = null;
|
|
366
|
+
try {
|
|
367
|
+
fromEnvironment = resolveIntegrationSideFromEnvironment(env, side);
|
|
368
|
+
} catch {
|
|
369
|
+
// A broken environment value is reported by the status lines.
|
|
370
|
+
}
|
|
371
|
+
if (fromEnvironment) print(output, formatStatus(`${side} credentials from ${fromEnvironment.source} in this environment take precedence over the saved key.`, 'warning', output, 'Integration Suite'));
|
|
372
|
+
context.recordHealth?.(report);
|
|
373
|
+
return { saved: true, report };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function lacksRoles(side) {
|
|
377
|
+
return Boolean(side?.signed_in) && (!side.ok || side.checks.some(check => check.result === 'forbidden'));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function cloudFoundryStep(message, operation, { env, output }) {
|
|
381
|
+
const stop = startProgress(message, { output, env, label: 'Cloud Foundry' });
|
|
382
|
+
try {
|
|
383
|
+
return await operation();
|
|
384
|
+
} finally {
|
|
385
|
+
stop();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Key from an instance: reuse or create the toolkit's service key, then verify and save it.
|
|
390
|
+
async function useCloudFoundryInstance(side, instanceName, context) {
|
|
391
|
+
const { env, output, cloudFoundry } = context;
|
|
392
|
+
const key = await cloudFoundryStep(`Reading service key ${instanceName}/sap-ai-dev-integration`, () => cloudFoundry.serviceKey(instanceName, { env }), context);
|
|
393
|
+
print(output, formatStatus(`${key.created ? 'Created' : 'Reusing'} service key ${key.keyName} on ${instanceName}.`, 'info', output, 'Cloud Foundry'));
|
|
394
|
+
return acceptIntegrationKey(side, parseIntegrationServiceKey(side, key.credentials), context);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function createDedicatedInstance(side, context) {
|
|
398
|
+
const { env, output, cloudFoundry } = context;
|
|
399
|
+
const service = INTEGRATION_CF_SERVICES[side];
|
|
400
|
+
try {
|
|
401
|
+
await cloudFoundryStep(`Creating ${service.offering}/${service.plan} instance ${service.dedicatedInstance}`, () => cloudFoundry.createInstance(side, { env }), context);
|
|
402
|
+
} catch (error) {
|
|
403
|
+
print(output, formatStatus(`Could not create ${service.dedicatedInstance}: ${cfFailure(error)}`, 'warning', output, 'Cloud Foundry'));
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
print(output, formatStatus(`Created service instance ${service.dedicatedInstance}.`, 'success', output, 'Cloud Foundry'));
|
|
407
|
+
return useCloudFoundryInstance(side, service.dedicatedInstance, context);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Use the logged-in CF space: per side pick the matching instance (or offer to create a dedicated one with the
|
|
411
|
+
// roles the tools need), reuse or create the toolkit's service key, verify it online and save it.
|
|
412
|
+
async function useCloudFoundrySpace(target, context) {
|
|
413
|
+
const { env, input, output, cloudFoundry } = context;
|
|
414
|
+
let instances;
|
|
415
|
+
try {
|
|
416
|
+
instances = await cloudFoundryStep('Looking for Integration Suite service instances in the CF space', () => cloudFoundry.findInstances({ env, spaceGuid: target.spaceGuid }), context);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
print(output, formatStatus(`Could not list service instances: ${cfFailure(error)}`, 'warning', output, 'Cloud Foundry'));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
for (const side of ['CPI', 'APIM']) {
|
|
422
|
+
const service = INTEGRATION_CF_SERVICES[side];
|
|
423
|
+
const candidates = instances[side] || [];
|
|
424
|
+
print(output, '');
|
|
425
|
+
if (!candidates.length) {
|
|
426
|
+
print(output, formatStatus(`No ${service.label} instance with plan ${service.plan} (${service.offering}) in this space.`, 'info', output, SIDE_LABELS[side]));
|
|
427
|
+
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 })) {
|
|
428
|
+
await createDedicatedInstance(side, context);
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const dedicated = candidates.find(candidate => candidate.name === service.dedicatedInstance);
|
|
433
|
+
const instanceName = candidates.length === 1 ? candidates[0].name : await selectPrompt({
|
|
434
|
+
message: colorText(`☁️ ${SIDE_LABELS[side]}: choose the ${service.offering}/${service.plan} instance to use`, 'cyan', output),
|
|
435
|
+
defaultValue: (dedicated || candidates[0]).name,
|
|
436
|
+
choices: [...candidates.map(candidate => ({ name: candidate.name, value: candidate.name })), { name: 'Skip', value: '', shortcut: 's' }]
|
|
437
|
+
}, { input, output });
|
|
438
|
+
if (!instanceName) continue;
|
|
439
|
+
let result;
|
|
440
|
+
try {
|
|
441
|
+
result = await useCloudFoundryInstance(side, instanceName, context);
|
|
442
|
+
} catch (error) {
|
|
443
|
+
print(output, formatStatus(`Could not use ${instanceName}: ${cfFailure(error)}`, 'warning', output, SIDE_LABELS[side]));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const sideReport = result.report[side.toLowerCase()];
|
|
447
|
+
if (instanceName !== service.dedicatedInstance && !dedicated && lacksRoles(sideReport)) {
|
|
448
|
+
print(output, ` The roles of ${instanceName} are fixed on the instance; a new key cannot add them.`);
|
|
449
|
+
print(output, ` A dedicated instance gets: ${service.parameters.roles ? service.parameters.roles.join(', ') : service.parameters.role}.`);
|
|
450
|
+
if (await confirmPrompt(`Create instance ${service.dedicatedInstance} with these roles and use it instead? (y/n, Enter=no)`, { input, output, defaultYes: false })) {
|
|
451
|
+
await createDedicatedInstance(side, context);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Verify the detected credentials online and let the user take CPI/APIM keys from the logged-in CF space or
|
|
458
|
+
// paste them. A key is checked online first and saved to the user-only store (never to mcp.json); it is used
|
|
459
|
+
// when the environment has no key.
|
|
460
|
+
async function configureIntegrationCredentials({ env, input, output, verifyIntegration, cloudFoundry = defaultIntegrationCloudFoundry }) {
|
|
342
461
|
let status = integrationSuiteCredentialStatus(env);
|
|
343
|
-
|
|
462
|
+
// Latest verification per side ({ cpi, apim }); updated whenever a key is checked or saved.
|
|
463
|
+
const health = {};
|
|
464
|
+
const recordHealth = report => {
|
|
465
|
+
for (const side of ['cpi', 'apim']) if (report?.[side]?.configured) health[side] = report[side];
|
|
466
|
+
};
|
|
467
|
+
if (status.found) recordHealth(await printIntegrationVerification(status.config, { env, output, verifyIntegration }));
|
|
468
|
+
let target = { available: false };
|
|
469
|
+
try {
|
|
470
|
+
target = await cloudFoundryStep('Checking the Cloud Foundry login', () => cloudFoundry.getTarget({ env }), { env, output });
|
|
471
|
+
} catch {
|
|
472
|
+
// No CF CLI or no login: the CF option is simply not offered.
|
|
473
|
+
}
|
|
474
|
+
const context = { env, input, output, verifyIntegration, cloudFoundry, recordHealth };
|
|
344
475
|
for (;;) {
|
|
345
476
|
print(output, '');
|
|
477
|
+
const space = target.available ? [target.orgName, target.spaceName].filter(Boolean).join(' / ') || target.spaceGuid : '';
|
|
478
|
+
// Sides whose key signs in but lacks roles: a new key cannot fix that, a dedicated instance can.
|
|
479
|
+
const roleless = ['CPI', 'APIM'].filter(side => lacksRoles(health[side.toLowerCase()]));
|
|
480
|
+
const healthy = status.found && !roleless.length && Object.values(health).every(side => side.ok);
|
|
481
|
+
const fixes = target.available
|
|
482
|
+
? roleless.map(side => ({
|
|
483
|
+
name: `Create ${INTEGRATION_CF_SERVICES[side].dedicatedInstance} in ${space} with the roles the ${side} tools need, then use it`,
|
|
484
|
+
value: `create-${side.toLowerCase()}`
|
|
485
|
+
}))
|
|
486
|
+
: [];
|
|
346
487
|
const choice = await selectPrompt({
|
|
347
488
|
message: colorText('🔑 Integration Suite credentials', 'cyan', output),
|
|
348
|
-
defaultValue: status.found ? 'done' : 'cpi',
|
|
489
|
+
defaultValue: healthy ? 'done' : (fixes[0]?.value ?? (target.available ? 'cf' : (status.found ? 'done' : 'cpi'))),
|
|
349
490
|
choices: [
|
|
350
|
-
|
|
491
|
+
...fixes,
|
|
492
|
+
{ name: !status.found ? 'Continue without credentials (the server will not start until a key is configured)' : (healthy ? 'Continue' : 'Continue with the current key (tools in the ✗ areas will fail with HTTP 403)'), value: 'done', shortcut: 'c' },
|
|
493
|
+
...(target.available ? [{ name: `Use the logged-in CF space (${space}): find instances, reuse or create keys`, value: 'cf', shortcut: 'f' }] : []),
|
|
351
494
|
{ name: 'Paste a Cloud Integration (CPI) service key — Process Integration Runtime, plan api', value: 'cpi', shortcut: 'p' },
|
|
352
495
|
{ name: 'Paste an API Management service key — API Management, plan apiportal-apiaccess', value: 'apim', shortcut: 'a' },
|
|
353
496
|
...(status.found ? [{ name: 'Check the configured credentials online again', value: 'verify', shortcut: 'v' }] : [])
|
|
@@ -355,40 +498,35 @@ async function configureIntegrationCredentials({ env, input, output, verifyInteg
|
|
|
355
498
|
}, { input, output });
|
|
356
499
|
if (choice === 'done') return status;
|
|
357
500
|
if (choice === 'verify') {
|
|
358
|
-
await printIntegrationVerification(status.config, { env, output, verifyIntegration });
|
|
501
|
+
recordHealth(await printIntegrationVerification(status.config, { env, output, verifyIntegration }));
|
|
359
502
|
continue;
|
|
360
503
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
504
|
+
if (choice.startsWith('create-')) {
|
|
505
|
+
await createDedicatedInstance(choice.slice('create-'.length).toUpperCase(), context);
|
|
506
|
+
} else if (choice === 'cf') {
|
|
507
|
+
await useCloudFoundrySpace(target, context);
|
|
508
|
+
} else {
|
|
509
|
+
const side = choice.toUpperCase();
|
|
510
|
+
const pasted = await textPrompt({
|
|
511
|
+
message: `Paste the ${side} service key JSON or the path to its .json file`,
|
|
512
|
+
secret: true,
|
|
513
|
+
validate: value => {
|
|
514
|
+
try {
|
|
515
|
+
pastedServiceKey(side, value, env);
|
|
516
|
+
return true;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
return error.message;
|
|
519
|
+
}
|
|
371
520
|
}
|
|
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.
|
|
521
|
+
}, { input, output });
|
|
522
|
+
await acceptIntegrationKey(side, pastedServiceKey(side, pasted, env), context);
|
|
384
523
|
}
|
|
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
524
|
status = integrationSuiteCredentialStatus(env);
|
|
387
525
|
for (const line of status.lines) print(output, ` ${line}`);
|
|
388
526
|
}
|
|
389
527
|
}
|
|
390
528
|
|
|
391
|
-
async function promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration = defaultVerifyIntegration, ask = true }) {
|
|
529
|
+
async function promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration = defaultVerifyIntegration, integrationCloudFoundry, ask = true }) {
|
|
392
530
|
print(output, '');
|
|
393
531
|
print(output, formatStatus('SAP Integration Suite tools (Cloud Integration + API Management).', 'step', output, 'Integration Suite'));
|
|
394
532
|
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 +543,7 @@ async function promptIntegrationSuite({ env, alreadyConfigured, input, output, v
|
|
|
405
543
|
const defaultYes = status.found;
|
|
406
544
|
if (!await confirmPrompt(`Add the SAP Integration Suite tools? (y/n, Enter=${defaultYes ? 'yes' : 'no'})`, { input, output, defaultYes })) return false;
|
|
407
545
|
}
|
|
408
|
-
await configureIntegrationCredentials({ env, input, output, verifyIntegration });
|
|
546
|
+
await configureIntegrationCredentials({ env, input, output, verifyIntegration, ...(integrationCloudFoundry ? { cloudFoundry: integrationCloudFoundry } : {}) });
|
|
409
547
|
return true;
|
|
410
548
|
}
|
|
411
549
|
|
|
@@ -415,7 +553,8 @@ export async function runIntegrationSuiteSetup({
|
|
|
415
553
|
input = stdin,
|
|
416
554
|
output = stdout,
|
|
417
555
|
install = installMcpConfig,
|
|
418
|
-
verifyIntegration = defaultVerifyIntegration
|
|
556
|
+
verifyIntegration = defaultVerifyIntegration,
|
|
557
|
+
integrationCloudFoundry
|
|
419
558
|
} = {}) {
|
|
420
559
|
if (!input.isTTY || !output.isTTY) {
|
|
421
560
|
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 +566,7 @@ export async function runIntegrationSuiteSetup({
|
|
|
427
566
|
} catch {
|
|
428
567
|
// A missing or unreadable config is created or healed by install.
|
|
429
568
|
}
|
|
430
|
-
await promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration, ask: false });
|
|
569
|
+
await promptIntegrationSuite({ env, alreadyConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
431
570
|
const result = await install([], { env, sapDevelopmentServers: ['integration-suite'], additive: true });
|
|
432
571
|
print(output, '');
|
|
433
572
|
print(output, formatStatus(`Configured the Integration Suite MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'Integration Suite'));
|
|
@@ -771,7 +910,8 @@ export async function runSetup({
|
|
|
771
910
|
includeSapDevelopmentToolsPrompt = false,
|
|
772
911
|
detectAdtUrl,
|
|
773
912
|
discoverSsoBrowsers = findRemoteControllableSsoBrowsers,
|
|
774
|
-
verifyIntegration = defaultVerifyIntegration
|
|
913
|
+
verifyIntegration = defaultVerifyIntegration,
|
|
914
|
+
integrationCloudFoundry
|
|
775
915
|
} = {}) {
|
|
776
916
|
const isBas = Boolean(env.H2O_URL);
|
|
777
917
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -943,7 +1083,7 @@ export async function runSetup({
|
|
|
943
1083
|
|
|
944
1084
|
let sapDevelopmentServers = [];
|
|
945
1085
|
if (integrationOnly) {
|
|
946
|
-
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, ask: false });
|
|
1086
|
+
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
947
1087
|
sapDevelopmentServers = ['integration-suite'];
|
|
948
1088
|
} else if (includeSapDevelopmentToolsPrompt) {
|
|
949
1089
|
print(output, '');
|
|
@@ -963,9 +1103,9 @@ export async function runSetup({
|
|
|
963
1103
|
shortcuts: { all: 'a' }
|
|
964
1104
|
}, { input, output });
|
|
965
1105
|
if (sapDevelopmentServers.includes('integration-suite')) {
|
|
966
|
-
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, ask: false });
|
|
1106
|
+
await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
|
|
967
1107
|
}
|
|
968
|
-
} else if (await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration })) {
|
|
1108
|
+
} else if (await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry })) {
|
|
969
1109
|
// Without --tools, Integration Suite still gets its own yes/no step instead of being hidden.
|
|
970
1110
|
sapDevelopmentServers = ['integration-suite'];
|
|
971
1111
|
}
|