sap-ai-dev-toolkit 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -14
- package/package.json +1 -1
- package/src/bas-discovery.mjs +55 -13
- package/src/launcher.mjs +25 -42
- package/src/mcp-proxy.mjs +1 -1
- package/src/setup.mjs +241 -156
- package/src/terminal-ui.mjs +46 -4
- package/scripts/windows-negotiate-helper.ps1 +0 -182
- package/src/windows-sso-adt-proxy.mjs +0 -257
package/src/setup.mjs
CHANGED
|
@@ -58,14 +58,10 @@ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout
|
|
|
58
58
|
if (!windowsSsoSetupAvailable(env)) return 'basic';
|
|
59
59
|
return await selectPrompt({
|
|
60
60
|
message: `Authentication for ${destination.name}`,
|
|
61
|
-
defaultValue: '
|
|
61
|
+
defaultValue: 'sso',
|
|
62
62
|
choices: [
|
|
63
|
-
{ name: '
|
|
64
|
-
{ name: '
|
|
65
|
-
{ name: 'windows-sso with native Windows credential UI / smartcard PIN', value: 'windows-credential-ui', shortcut: 'w' },
|
|
66
|
-
{ name: 'windows-sso with password fallback (self-heal when Negotiate/PIN is unavailable)', value: 'windows-sso-basic-fallback', shortcut: 'f' },
|
|
67
|
-
{ name: 'browser SAML / web SSO (opens browser if supported by VSP)', value: 'browser-saml', shortcut: 'b' },
|
|
68
|
-
{ name: 'SAML username/password', value: 'saml-password', shortcut: 'm' }
|
|
63
|
+
{ name: 'Windows / smart card SSO (Edge signs in automatically)', value: 'sso', shortcut: 's' },
|
|
64
|
+
{ name: 'Username/password', value: 'basic', shortcut: 'p' }
|
|
69
65
|
]
|
|
70
66
|
}, { input, output });
|
|
71
67
|
}
|
|
@@ -112,6 +108,138 @@ async function promptSapClients(destination, { input = stdin, output = stdout }
|
|
|
112
108
|
return parseSapClientList(answer, fallback);
|
|
113
109
|
}
|
|
114
110
|
|
|
111
|
+
function validateAdtUrlCandidate(candidate) {
|
|
112
|
+
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
113
|
+
try {
|
|
114
|
+
const parsed = new URL(candidate);
|
|
115
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
116
|
+
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
117
|
+
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
catch { return 'Enter a valid URL.'; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function localAuthenticationLabel(authMode) {
|
|
124
|
+
return authMode === 'sso' ? 'SSO' : 'Basic';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
128
|
+
const answer = await textPrompt({
|
|
129
|
+
message,
|
|
130
|
+
placeholder: 'y',
|
|
131
|
+
required: false,
|
|
132
|
+
validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
|
|
133
|
+
}, { input, output });
|
|
134
|
+
return !/^n(?:o)?$/i.test(String(answer || '').trim());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false } = {}) {
|
|
138
|
+
const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
|
|
139
|
+
const localDestination = { ...destination, name, serverName: name, client };
|
|
140
|
+
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
141
|
+
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
142
|
+
const credentialInputs = [
|
|
143
|
+
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
144
|
+
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
145
|
+
];
|
|
146
|
+
if (authMode === 'sso') {
|
|
147
|
+
localDestination.childEnv = { SAP_AUTH_MODE: 'sso' };
|
|
148
|
+
localDestination.inputs = [];
|
|
149
|
+
} else {
|
|
150
|
+
localDestination.childEnv = {
|
|
151
|
+
SAP_AUTH_MODE: 'basic',
|
|
152
|
+
SAP_USER: `\${input:${userInputId}}`,
|
|
153
|
+
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
154
|
+
};
|
|
155
|
+
localDestination.inputs = credentialInputs;
|
|
156
|
+
}
|
|
157
|
+
return localDestination;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = stdin, output = stdout } = {}) {
|
|
161
|
+
const parsedAdtUrl = new URL(destination.url);
|
|
162
|
+
if (parsedAdtUrl.protocol !== 'https:' || !isIP(parsedAdtUrl.hostname)) return;
|
|
163
|
+
print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
|
|
164
|
+
let discoveredNames = [];
|
|
165
|
+
try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
|
|
166
|
+
catch { discoveredNames = []; }
|
|
167
|
+
if (!discoveredNames.length) {
|
|
168
|
+
print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const preview = discoveredNames.slice(0, 5).join(', ');
|
|
172
|
+
const useDiscovered = await confirmPrompt(`Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`, { input, output });
|
|
173
|
+
if (useDiscovered) {
|
|
174
|
+
destination.tlsServerNames = discoveredNames;
|
|
175
|
+
destination.tlsServerName = discoveredNames[0];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Manual MCP entry wizard: offered whenever discovery finds nothing to select
|
|
180
|
+
// (no BAS destinations, no Cloud Foundry imports, no SAP GUI entries). It
|
|
181
|
+
// collects the minimal ADT configuration directly from the user and produces
|
|
182
|
+
// the same local destination shape as the SAP GUI flow, so installation,
|
|
183
|
+
// credential inputs, and TLS handling stay identical.
|
|
184
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
|
|
185
|
+
print(output, '');
|
|
186
|
+
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
187
|
+
print(output, colorText(' The ADT URL is the base URL that Eclipse ADT or VS Code ADT tools use, for example https://myhost.example:44300.', 'cyan', output));
|
|
188
|
+
print(output, colorText(' Setup writes one isolated MCP server per system/client pair, with login prompts and TLS self-healing like every other entry.', 'cyan', output));
|
|
189
|
+
const expanded = [];
|
|
190
|
+
const usedNames = new Set();
|
|
191
|
+
let addAnother = true;
|
|
192
|
+
while (addAnother) {
|
|
193
|
+
const name = (await textPrompt({
|
|
194
|
+
message: 'System name',
|
|
195
|
+
placeholder: 'S4H sandbox',
|
|
196
|
+
required: true,
|
|
197
|
+
validate: value => {
|
|
198
|
+
const trimmed = String(value || '').trim();
|
|
199
|
+
if (!trimmed) return 'Enter a name for the system.';
|
|
200
|
+
if (usedNames.has(trimmed.toLowerCase())) return 'A system with this name was already added; choose another name.';
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}, { input, output })).trim();
|
|
204
|
+
usedNames.add(name.toLowerCase());
|
|
205
|
+
const url = await textPrompt({
|
|
206
|
+
message: `ADT URL for ${name}`,
|
|
207
|
+
placeholder: 'https://host:44300',
|
|
208
|
+
required: true,
|
|
209
|
+
validate: validateAdtUrlCandidate
|
|
210
|
+
}, { input, output });
|
|
211
|
+
let parsedUrl;
|
|
212
|
+
try { parsedUrl = new URL(url); }
|
|
213
|
+
catch { parsedUrl = null; }
|
|
214
|
+
const destination = {
|
|
215
|
+
source: 'sap-gui-local',
|
|
216
|
+
name,
|
|
217
|
+
serverName: name,
|
|
218
|
+
url,
|
|
219
|
+
host: parsedUrl?.hostname || '',
|
|
220
|
+
client: '001',
|
|
221
|
+
authentication: 'Basic',
|
|
222
|
+
authMode: 'basic',
|
|
223
|
+
proxyType: 'Internet',
|
|
224
|
+
// Marks wizard-configured systems so the discovery confirmation prompts
|
|
225
|
+
// (clients, ADT URL, login) skip them when they flow through the
|
|
226
|
+
// destination picker.
|
|
227
|
+
configuredManually: true
|
|
228
|
+
};
|
|
229
|
+
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
230
|
+
const clients = await promptSapClients(destination, { input, output });
|
|
231
|
+
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
232
|
+
destination.authentication = localAuthenticationLabel(authMode);
|
|
233
|
+
destination.authMode = authMode;
|
|
234
|
+
for (const client of clients) {
|
|
235
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
|
|
236
|
+
}
|
|
237
|
+
print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
|
|
238
|
+
addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
|
|
239
|
+
}
|
|
240
|
+
return expanded;
|
|
241
|
+
}
|
|
242
|
+
|
|
115
243
|
function localSystemDiscriminator(destination) {
|
|
116
244
|
return [destination.systemId, destination.host, destination.instance].map(value => String(value || '').trim()).filter(Boolean).join(' ');
|
|
117
245
|
}
|
|
@@ -216,6 +344,90 @@ function printConnectionInstructions(output, result) {
|
|
|
216
344
|
}
|
|
217
345
|
|
|
218
346
|
|
|
347
|
+
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
348
|
+
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
349
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
|
|
350
|
+
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
351
|
+
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
352
|
+
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
353
|
+
return leftRank - rightRank || String(left.name).localeCompare(String(right.name));
|
|
354
|
+
});
|
|
355
|
+
const choices = orderedDestinations.map(destination => {
|
|
356
|
+
const probe = destination.probe?.status || 'unknown';
|
|
357
|
+
const source = destination.source === 'cloud-foundry'
|
|
358
|
+
? `CF ${destination.cf.destinationInstanceName}`
|
|
359
|
+
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
360
|
+
const disabled = destination.disabledReason;
|
|
361
|
+
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
362
|
+
return {
|
|
363
|
+
value: destination,
|
|
364
|
+
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
365
|
+
...(disabled ? { disabled } : {}),
|
|
366
|
+
checked: false
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
print(output, '');
|
|
370
|
+
print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
|
|
371
|
+
print(output, '');
|
|
372
|
+
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
373
|
+
print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
|
|
374
|
+
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
375
|
+
print(output, '');
|
|
376
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
|
|
377
|
+
value: destination,
|
|
378
|
+
name: `${destination.name} (manual, client ${destination.client})`
|
|
379
|
+
}));
|
|
380
|
+
let selected = await checkboxPrompt({
|
|
381
|
+
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
382
|
+
choices,
|
|
383
|
+
required: false,
|
|
384
|
+
shortcuts: { all: 'a' },
|
|
385
|
+
actions: { m: addManually }
|
|
386
|
+
}, { input, output });
|
|
387
|
+
|
|
388
|
+
if (!isBas && selected.length) {
|
|
389
|
+
print(output, '');
|
|
390
|
+
print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
|
|
391
|
+
print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
|
|
392
|
+
if (windowsSsoSetupAvailable(env)) print(output, ' Windows / smart card SSO signs in through Microsoft Edge (smart card, Kerberos or company login) and keeps the session refreshed; no password is stored.');
|
|
393
|
+
const selectedNameCounts = selected.reduce((counts, destination) => {
|
|
394
|
+
const key = String(destination.name).toLowerCase();
|
|
395
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
396
|
+
return counts;
|
|
397
|
+
}, new Map());
|
|
398
|
+
const expanded = [];
|
|
399
|
+
for (const destination of selected) {
|
|
400
|
+
// Wizard-configured systems are already complete; only discovered SAP
|
|
401
|
+
// GUI entries need their clients, ADT URL, and login confirmed here.
|
|
402
|
+
if (destination.configuredManually) {
|
|
403
|
+
expanded.push(destination);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
|
|
407
|
+
const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
|
|
408
|
+
const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
|
|
409
|
+
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
410
|
+
const fallback = defaultAdtUrl(destination);
|
|
411
|
+
const url = await textPrompt({
|
|
412
|
+
message: `ADT URL for ${promptLabel}`,
|
|
413
|
+
placeholder: fallback || 'https://host:44300',
|
|
414
|
+
validate: value => validateAdtUrlCandidate(value || fallback),
|
|
415
|
+
required: false
|
|
416
|
+
}, { input, output });
|
|
417
|
+
destination.url = url || fallback;
|
|
418
|
+
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
419
|
+
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
420
|
+
destination.authentication = localAuthenticationLabel(authMode);
|
|
421
|
+
destination.authMode = authMode;
|
|
422
|
+
for (const client of clients) {
|
|
423
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName }));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
selected = expanded;
|
|
427
|
+
}
|
|
428
|
+
return selected;
|
|
429
|
+
}
|
|
430
|
+
|
|
219
431
|
export async function runSetup({
|
|
220
432
|
env = process.env,
|
|
221
433
|
input = stdin,
|
|
@@ -237,6 +449,7 @@ export async function runSetup({
|
|
|
237
449
|
return { skipped: true, reason: 'non-tty' };
|
|
238
450
|
}
|
|
239
451
|
print(output, '');
|
|
452
|
+
print(output, colorText('💡 Tip: you can skip auto-discovery and add a system manually — press m in the destination picker, or take the wizard offer when nothing is found.', 'magenta', output));
|
|
240
453
|
if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
|
|
241
454
|
|
|
242
455
|
let destinations = [];
|
|
@@ -349,160 +562,32 @@ export async function runSetup({
|
|
|
349
562
|
return result.warnings || [];
|
|
350
563
|
};
|
|
351
564
|
const selectable = destinations.filter(destination => !destination.disabledReason);
|
|
352
|
-
|
|
353
|
-
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
354
|
-
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
565
|
+
const exitWithoutDestinations = async () => {
|
|
355
566
|
await reportWarnings(warnings);
|
|
356
567
|
const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
|
|
357
568
|
await reportWarnings(cleanupWarnings);
|
|
358
569
|
return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const choices = orderedDestinations.map(destination => {
|
|
367
|
-
const probe = destination.probe?.status || 'unknown';
|
|
368
|
-
const source = destination.source === 'cloud-foundry'
|
|
369
|
-
? `CF ${destination.cf.destinationInstanceName}`
|
|
370
|
-
: (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
|
|
371
|
-
const disabled = destination.disabledReason;
|
|
372
|
-
const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
|
|
373
|
-
return {
|
|
374
|
-
value: destination,
|
|
375
|
-
name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
|
|
376
|
-
...(disabled ? { disabled } : {}),
|
|
377
|
-
checked: false
|
|
378
|
-
};
|
|
379
|
-
});
|
|
380
|
-
print(output, '');
|
|
381
|
-
print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
|
|
382
|
-
print(output, '');
|
|
383
|
-
print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
|
|
384
|
-
print(output, ' Nothing selected removes this add-on’s MCP entries.');
|
|
385
|
-
print(output, '');
|
|
386
|
-
let selected = await checkboxPrompt({
|
|
387
|
-
message: colorText('🧭 Select destinations', 'cyan', output),
|
|
388
|
-
choices,
|
|
389
|
-
required: false,
|
|
390
|
-
shortcuts: { all: 'a' }
|
|
391
|
-
}, { input, output });
|
|
392
|
-
|
|
393
|
-
if (!isBas && selected.length) {
|
|
570
|
+
};
|
|
571
|
+
let selected;
|
|
572
|
+
if (!selectable.length) {
|
|
573
|
+
// Nothing was detected — no BAS/CF destinations, or no SAP GUI entries.
|
|
574
|
+
// Offer the manual MCP entry wizard before giving up.
|
|
575
|
+
print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
|
|
576
|
+
print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or configure your system manually below. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
|
|
394
577
|
print(output, '');
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
},
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
409
|
-
const fallback = defaultAdtUrl(destination);
|
|
410
|
-
const url = await textPrompt({
|
|
411
|
-
message: `ADT URL for ${promptLabel}`,
|
|
412
|
-
placeholder: fallback || 'https://host:44300',
|
|
413
|
-
validate: value => {
|
|
414
|
-
const candidate = value || fallback;
|
|
415
|
-
if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
|
|
416
|
-
try {
|
|
417
|
-
const parsed = new URL(candidate);
|
|
418
|
-
if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
|
|
419
|
-
if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
|
|
420
|
-
if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
|
|
421
|
-
return true;
|
|
422
|
-
}
|
|
423
|
-
catch { return 'Enter a valid URL.'; }
|
|
424
|
-
},
|
|
425
|
-
required: false
|
|
426
|
-
}, { input, output });
|
|
427
|
-
destination.url = url || fallback;
|
|
428
|
-
const parsedAdtUrl = new URL(destination.url);
|
|
429
|
-
if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
|
|
430
|
-
print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
|
|
431
|
-
let discoveredNames = [];
|
|
432
|
-
try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
|
|
433
|
-
catch { discoveredNames = []; }
|
|
434
|
-
if (discoveredNames.length) {
|
|
435
|
-
const preview = discoveredNames.slice(0, 5).join(', ');
|
|
436
|
-
const answer = await textPrompt({
|
|
437
|
-
message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
|
|
438
|
-
placeholder: 'y',
|
|
439
|
-
required: false,
|
|
440
|
-
validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
|
|
441
|
-
}, { input, output });
|
|
442
|
-
if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
|
|
443
|
-
destination.tlsServerNames = discoveredNames;
|
|
444
|
-
destination.tlsServerName = discoveredNames[0];
|
|
445
|
-
}
|
|
446
|
-
} else {
|
|
447
|
-
print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
451
|
-
destination.authentication = ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
|
|
452
|
-
destination.authMode = authMode;
|
|
453
|
-
for (const client of clients) {
|
|
454
|
-
const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
|
|
455
|
-
const localDestination = { ...destination, name, serverName: name, client };
|
|
456
|
-
const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
457
|
-
const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
458
|
-
const credentialInputs = [
|
|
459
|
-
{ id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
|
|
460
|
-
{ id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
|
|
461
|
-
];
|
|
462
|
-
if (authMode === 'windows-sso') {
|
|
463
|
-
localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
|
|
464
|
-
localDestination.inputs = [];
|
|
465
|
-
} else if (authMode === 'windows-credential-ui') {
|
|
466
|
-
localDestination.childEnv = {
|
|
467
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
468
|
-
SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
|
|
469
|
-
};
|
|
470
|
-
localDestination.inputs = [];
|
|
471
|
-
} else if (authMode === 'windows-sso-basic-fallback') {
|
|
472
|
-
localDestination.childEnv = {
|
|
473
|
-
SAP_AUTH_MODE: 'windows-sso',
|
|
474
|
-
SAP_AUTH_FALLBACK_MODE: 'basic',
|
|
475
|
-
SAP_USER: `\${input:${userInputId}}`,
|
|
476
|
-
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
477
|
-
};
|
|
478
|
-
localDestination.inputs = credentialInputs;
|
|
479
|
-
} else if (authMode === 'browser-saml') {
|
|
480
|
-
localDestination.childEnv = {
|
|
481
|
-
SAP_AUTH_MODE: 'browser-saml',
|
|
482
|
-
SAP_BROWSER_AUTH: 'true',
|
|
483
|
-
SAP_SAML_AUTH: 'true'
|
|
484
|
-
};
|
|
485
|
-
localDestination.inputs = [];
|
|
486
|
-
} else if (authMode === 'saml-password') {
|
|
487
|
-
localDestination.childEnv = {
|
|
488
|
-
SAP_AUTH_MODE: 'saml-password',
|
|
489
|
-
SAP_SAML_AUTH: 'true',
|
|
490
|
-
SAP_SAML_USER: `\${input:${userInputId}}`,
|
|
491
|
-
SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
|
|
492
|
-
};
|
|
493
|
-
localDestination.inputs = credentialInputs;
|
|
494
|
-
} else {
|
|
495
|
-
localDestination.childEnv = {
|
|
496
|
-
SAP_AUTH_MODE: 'basic',
|
|
497
|
-
SAP_USER: `\${input:${userInputId}}`,
|
|
498
|
-
SAP_PASSWORD: `\${input:${passwordInputId}}`
|
|
499
|
-
};
|
|
500
|
-
localDestination.inputs = credentialInputs;
|
|
501
|
-
}
|
|
502
|
-
expanded.push(localDestination);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
selected = expanded;
|
|
578
|
+
const choice = await selectPrompt({
|
|
579
|
+
message: colorText('💡 No systems were detected. Configure an SAP system manually?', 'magenta', output),
|
|
580
|
+
defaultValue: 'manual',
|
|
581
|
+
choices: [
|
|
582
|
+
{ name: '✍️ Start manual entry wizard (system name, ADT URL, client, login)', value: 'manual', shortcut: 'm' },
|
|
583
|
+
{ name: 'Exit setup without changes', value: 'exit', shortcut: 'e' }
|
|
584
|
+
]
|
|
585
|
+
}, { input, output });
|
|
586
|
+
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
587
|
+
selected = await runManualEntryWizard({ env, input, output });
|
|
588
|
+
if (!selected.length) return await exitWithoutDestinations();
|
|
589
|
+
} else {
|
|
590
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
|
|
506
591
|
}
|
|
507
592
|
|
|
508
593
|
let sapDevelopmentServers = [];
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -213,8 +213,11 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
|
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
export async function checkboxPrompt({ message, choices, required = false, shortcuts = { all: 'a' }, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
|
-
if (!Array.isArray(
|
|
216
|
+
export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
|
+
if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
|
|
218
|
+
// Action shortcuts (for example 'm' → manual entry wizard) may append new
|
|
219
|
+
// choices while the picker is open, so the list itself stays mutable.
|
|
220
|
+
let choices = initialChoices;
|
|
218
221
|
const selectable = enabledChoices(choices);
|
|
219
222
|
const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
|
|
220
223
|
let cursor = firstEnabledIndex(choices);
|
|
@@ -261,6 +264,42 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
261
264
|
};
|
|
262
265
|
|
|
263
266
|
return new Promise((resolve, reject) => {
|
|
267
|
+
const actionShortcuts = new Map(Object.entries(actions).filter(([shortcut, action]) => shortcut && typeof action === 'function'));
|
|
268
|
+
const actionShortcutFor = (chunk, key = {}) => {
|
|
269
|
+
if (!actionShortcuts.size) return undefined;
|
|
270
|
+
const typed = String(chunk ?? '').toLowerCase();
|
|
271
|
+
if (typed.length === 1 && actionShortcuts.has(typed)) return actionShortcuts.get(typed);
|
|
272
|
+
if (key.name && actionShortcuts.has(key.name)) return actionShortcuts.get(key.name);
|
|
273
|
+
return undefined;
|
|
274
|
+
};
|
|
275
|
+
// Running an action suspends the picker (listener detached, raw mode off),
|
|
276
|
+
// lets the action drive its own prompts on the same streams, and appends
|
|
277
|
+
// the produced items as pre-selected choices before redrawing. An aborted
|
|
278
|
+
// action (Ctrl+C in a sub-prompt) returns to the picker unchanged instead
|
|
279
|
+
// of tearing down the whole selection.
|
|
280
|
+
const runAction = async action => {
|
|
281
|
+
input.off('keypress', onKeypress);
|
|
282
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(false);
|
|
283
|
+
clearRendered();
|
|
284
|
+
let appended = [];
|
|
285
|
+
try { appended = await action() || []; }
|
|
286
|
+
catch { appended = []; }
|
|
287
|
+
const added = (Array.isArray(appended) ? appended : []).filter(item => item && item.value !== undefined);
|
|
288
|
+
if (added.length) {
|
|
289
|
+
for (const item of added) {
|
|
290
|
+
choices.push({ checked: true, ...item });
|
|
291
|
+
selected.add(choices.length - 1);
|
|
292
|
+
}
|
|
293
|
+
cursor = choices.length - added.length;
|
|
294
|
+
}
|
|
295
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(previousRawMode);
|
|
296
|
+
// Sub-prompts pause the stream on cleanup; resume so keypresses reach
|
|
297
|
+
// the re-attached picker listener.
|
|
298
|
+
if (typeof input.resume === 'function') input.resume();
|
|
299
|
+
input.on('keypress', onKeypress);
|
|
300
|
+
render();
|
|
301
|
+
};
|
|
302
|
+
|
|
264
303
|
const cleanup = () => {
|
|
265
304
|
input.off('keypress', onKeypress);
|
|
266
305
|
if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
|
|
@@ -289,9 +328,12 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
289
328
|
if (selected.has(cursor)) selected.delete(cursor);
|
|
290
329
|
else selected.add(cursor);
|
|
291
330
|
} else if (shortcuts?.all && key.name === shortcuts.all) {
|
|
292
|
-
const allSelected =
|
|
331
|
+
const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
|
|
293
332
|
selected.clear();
|
|
294
|
-
if (!allSelected) for (const choice of
|
|
333
|
+
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
334
|
+
} else if (actionShortcutFor(_chunk, key)) {
|
|
335
|
+
void runAction(actionShortcutFor(_chunk, key));
|
|
336
|
+
return;
|
|
295
337
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
296
338
|
const values = [...selected].sort((a, b) => a - b).map(index => choices[index].value);
|
|
297
339
|
const validation = typeof validate === 'function' ? validate(values) : true;
|