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.
@@ -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 or cannot sign in, ask the user to run `sap-ai-dev --setup-integration` in a terminal (paste, online check, user-only storage) instead of pasting a key into the chat.
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. **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.
236
- 3. **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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.9914",
3
+ "version": "0.9.9916",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
@@ -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
+ }
@@ -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
- ? new EnvHttpProxyAgent({ httpProxy, httpsProxy, noProxy: env.NO_PROXY || env.no_proxy })
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 = [`${side.ok ? '✓' : '✗'} ${label} ${host}: signed in`];
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
- print(output, formatStatus('Integration Suite verification failed; fix or replace the service key before using the tools.', 'warning', output, 'Integration Suite'));
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
- // Verify the detected credentials online and let the user paste CPI/APIM service keys. A pasted key is checked
340
- // online first and saved to the user-only store (never to mcp.json); it is used when the environment has no key.
341
- async function configureIntegrationCredentials({ env, input, output, verifyIntegration }) {
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
- if (status.found) await printIntegrationVerification(status.config, { env, output, verifyIntegration });
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
- { name: status.found ? 'Continue' : 'Continue without credentials (the server will not start until a key is configured)', value: 'done', shortcut: 'c' },
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
- const side = choice.toUpperCase();
362
- const pasted = await textPrompt({
363
- message: `Paste the ${side} service key JSON or the path to its .json file`,
364
- secret: true,
365
- validate: value => {
366
- try {
367
- pastedServiceKey(side, value, env);
368
- return true;
369
- } catch (error) {
370
- return error.message;
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
- }, { input, output });
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
  }