sap-ai-dev-toolkit 0.3.1 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/abap-developer.agent.md +11 -7
- package/.github/agents/abap-runtime-debugger.agent.md +3 -1
- package/.github/agents/rap-service-developer.agent.md +12 -8
- package/.github/agents/sap-solution-architect.agent.md +5 -3
- package/.github/skills/abap-development/SKILL.md +6 -4
- package/.github/skills/rap-development/SKILL.md +6 -4
- package/.github/skills/rap-service-delivery/SKILL.md +7 -5
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +5 -4
- package/README.md +27 -8
- package/package.json +2 -1
- package/scripts/install-local.mjs +115 -0
- package/src/bas-destination-relay.mjs +282 -0
- package/src/bas-discovery.mjs +4 -0
- package/src/credential-overrides.mjs +75 -0
- package/src/credentials-store.mjs +123 -0
- package/src/launcher.mjs +24 -4
- package/src/mcp-proxy.mjs +120 -32
- package/src/setup.mjs +115 -2
- package/src/terminal-ui.mjs +84 -0
- package/test/bas-destination-relay.test.mjs +238 -0
- package/test/cf-runtime.test.mjs +27 -5
- package/test/copilot-content.test.mjs +56 -0
- package/test/credentials-store.test.mjs +187 -0
- package/test/discovery.test.mjs +1 -1
- package/test/launcher.test.mjs +52 -0
- package/test/mcp-proxy.test.mjs +59 -3
- package/test/setup-cf.test.mjs +131 -4
package/src/mcp-proxy.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
|
|
|
4
4
|
import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
|
|
5
5
|
import { createEngineeringTools } from './engineering-tools.mjs';
|
|
6
6
|
import { brandedEnvValue } from './branding.mjs';
|
|
7
|
+
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
7
8
|
|
|
8
9
|
const JSONRPC = '2.0';
|
|
9
10
|
const FORWARDED_METHODS = new Set(['ping', 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list', 'completion/complete', 'logging/setLevel']);
|
|
@@ -58,8 +59,27 @@ function closeDestinationRoute(destination) {
|
|
|
58
59
|
return closedRoutes.get(destination);
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
function useBasDestinationRelay(destination, env = process.env) {
|
|
63
|
+
if (String(brandedEnvValue(env, 'DISABLE_BAS_RELAY') || '').toLowerCase() === 'true') return false;
|
|
64
|
+
if (destination.source === 'cloud-foundry') return false;
|
|
65
|
+
if (!destination.authentication) return false;
|
|
66
|
+
return Boolean(destination.url && String(destination.url).includes('.dest'));
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function childEnvironment(destination, env) {
|
|
62
|
-
|
|
70
|
+
const childEnv = { ...sanitizeChildEnv(env), ...(destination.childEnv || {}) };
|
|
71
|
+
// The VSP child must reach the local BAS relay directly, never through the
|
|
72
|
+
// BAS .dest proxy. Without a loopback exception every child request would
|
|
73
|
+
// be double-proxied and the relay's session pairing could never apply.
|
|
74
|
+
if (destination.relay) {
|
|
75
|
+
const loopback = '127.0.0.1,localhost';
|
|
76
|
+
for (const key of ['NO_PROXY', 'no_proxy']) {
|
|
77
|
+
const entries = String(childEnv[key] || '').split(',').map(value => value.trim()).filter(Boolean);
|
|
78
|
+
for (const host of loopback.split(',')) if (!entries.some(entry => entry.toLowerCase() === host)) entries.push(host);
|
|
79
|
+
childEnv[key] = entries.join(',');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return childEnv;
|
|
63
83
|
}
|
|
64
84
|
|
|
65
85
|
|
|
@@ -95,12 +115,14 @@ class Child {
|
|
|
95
115
|
this.process.on('error', error => {
|
|
96
116
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child process error: ${diagnosticText(error.message)}`);
|
|
97
117
|
this.fail(error);
|
|
98
|
-
|
|
118
|
+
// Relay routes survive child crashes so self-healing restarts can reuse
|
|
119
|
+
// them; they are closed by the proxy shutdown path instead.
|
|
120
|
+
if (!destination.relay) void closeDestinationRoute(destination);
|
|
99
121
|
});
|
|
100
122
|
this.process.on('exit', (code, signal) => {
|
|
101
123
|
if (!this.closing && !this.pending.size && this.options.log) this.options.log(`[${destination.name}] VSP child exited (${code ?? signal})`);
|
|
102
124
|
this.fail(new Error(`child exited (${code ?? signal})`));
|
|
103
|
-
void closeDestinationRoute(destination);
|
|
125
|
+
if (!destination.relay) void closeDestinationRoute(destination);
|
|
104
126
|
});
|
|
105
127
|
}
|
|
106
128
|
|
|
@@ -209,36 +231,87 @@ export class MCPProxy {
|
|
|
209
231
|
start() {
|
|
210
232
|
if (this.started) return this;
|
|
211
233
|
this.started = true;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
this.starting = (async () => {
|
|
235
|
+
for (const originalDestination of this.destinations) {
|
|
236
|
+
let destination = originalDestination;
|
|
237
|
+
try {
|
|
238
|
+
if (useBasDestinationRelay(originalDestination, this.env)) {
|
|
239
|
+
const relay = createBasDestinationRelay(originalDestination, { env: this.env, log: this.log });
|
|
240
|
+
const originalClose = originalDestination.close;
|
|
241
|
+
const relayUrl = await relay.ready;
|
|
242
|
+
destination = {
|
|
243
|
+
...originalDestination,
|
|
244
|
+
url: relayUrl,
|
|
245
|
+
relay: true,
|
|
246
|
+
async close() {
|
|
247
|
+
await originalClose?.();
|
|
248
|
+
await relay.close();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
this.log(`[${originalDestination.name}] BAS destination relay enabled (${relayUrl})`);
|
|
252
|
+
}
|
|
253
|
+
this.log(`[${destination.name}] starting VSP child (client=${destination.client || '001'})`);
|
|
254
|
+
this.children.push({
|
|
255
|
+
destination,
|
|
256
|
+
child: new Child(this.binary, destination, {
|
|
257
|
+
env: this.env,
|
|
258
|
+
spawn: this.spawn,
|
|
259
|
+
args: this.childArgs?.(destination),
|
|
260
|
+
log: this.log,
|
|
261
|
+
onNotification: notification => {
|
|
262
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
263
|
+
else this.pendingNotifications.push(notification);
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
this.log(`[${destination.name}] failed to start child: ${diagnosticText(error.message)}`);
|
|
269
|
+
void closeDestinationRoute(destination);
|
|
270
|
+
}
|
|
231
271
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
272
|
+
if (!this.children.length) {
|
|
273
|
+
this.started = false;
|
|
274
|
+
for (const destination of this.destinations) void closeDestinationRoute(destination);
|
|
275
|
+
throw new Error('No destination child could be started');
|
|
276
|
+
}
|
|
277
|
+
return this;
|
|
278
|
+
})();
|
|
279
|
+
// Mark the startup promise as handled here; initializeChildren re-awaits
|
|
280
|
+
// it so startup failures still surface as MCP initialization errors
|
|
281
|
+
// without triggering an unhandled rejection in between.
|
|
282
|
+
this.starting.catch(() => {});
|
|
238
283
|
return this;
|
|
239
284
|
}
|
|
240
285
|
|
|
286
|
+
// Self-healing mode 3: transparently restart a crashed VSP child so an
|
|
287
|
+
// MCP tools/call does not permanently fail after a single crash. The
|
|
288
|
+
// restarted child re-initializes and re-registers tools before the call
|
|
289
|
+
// is retried once.
|
|
290
|
+
async restartChild(entry) {
|
|
291
|
+
if (this.shuttingDown) throw new Error('MCP proxy is shutting down');
|
|
292
|
+
const name = entry.destination.name;
|
|
293
|
+
this.log(`[${name}] VSP child crashed; self-healing restart in progress`);
|
|
294
|
+
await entry.child.close().catch(() => {});
|
|
295
|
+
const child = new Child(this.binary, entry.destination, {
|
|
296
|
+
env: this.env,
|
|
297
|
+
spawn: this.spawn,
|
|
298
|
+
args: this.childArgs?.(entry.destination),
|
|
299
|
+
log: this.log,
|
|
300
|
+
onNotification: notification => {
|
|
301
|
+
if (this.clientInitialized) this.output(JSON.stringify(notification));
|
|
302
|
+
else this.pendingNotifications.push(notification);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
entry.child = child;
|
|
306
|
+
entry.server = await child.initialize(this.lastInitializeParams || {});
|
|
307
|
+
await child.listTools().catch(() => {});
|
|
308
|
+
this.log(`[${name}] VSP child self-healing restart complete`);
|
|
309
|
+
return entry;
|
|
310
|
+
}
|
|
311
|
+
|
|
241
312
|
async initializeChildren(params) {
|
|
313
|
+
await this.starting;
|
|
314
|
+
this.lastInitializeParams = params;
|
|
242
315
|
const healthy = [];
|
|
243
316
|
for (const entry of this.children) {
|
|
244
317
|
this.log(`[${entry.destination.name}] initializing VSP MCP session`);
|
|
@@ -342,6 +415,19 @@ export class MCPProxy {
|
|
|
342
415
|
return response.error ? { ...response, id: message.id } : rpcResult(message.id, response.result);
|
|
343
416
|
} catch (error) {
|
|
344
417
|
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} failed after ${Date.now() - startedAt}ms: ${diagnosticText(error.rpcError?.message || error.message)}`);
|
|
418
|
+
// Self-healing: a dead child (crash, OOM, transient pipe break) is
|
|
419
|
+
// restarted and the call retried once before surfacing the error.
|
|
420
|
+
const childBroken = mapped.entry.child.exited || !mapped.entry.child.process.stdin.writable;
|
|
421
|
+
if (childBroken && !this.shuttingDown) {
|
|
422
|
+
try {
|
|
423
|
+
await this.restartChild(mapped.entry);
|
|
424
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} retried after self-healing restart`);
|
|
425
|
+
const retried = await mapped.entry.child.request('tools/call', upstreamParams);
|
|
426
|
+
return retried.error ? { ...retried, id: message.id } : rpcResult(message.id, retried.result);
|
|
427
|
+
} catch (restartError) {
|
|
428
|
+
this.log(`[${mapped.entry.destination.name}] tools/call ${toolName} self-healing restart failed: ${diagnosticText(restartError.message)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
345
431
|
return error.rpcError ? rpcError(message.id, error.rpcError.code || -32001, error.rpcError.message || error.message, error.rpcError.data) : rpcError(message.id, -32001, `Destination ${mapped.entry.destination.name} failed: ${error.message}`);
|
|
346
432
|
}
|
|
347
433
|
}
|
|
@@ -393,10 +479,12 @@ export class MCPProxy {
|
|
|
393
479
|
async close() {
|
|
394
480
|
if (this.shuttingDown) return;
|
|
395
481
|
this.shuttingDown = true;
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
await entry
|
|
399
|
-
await closeDestinationRoute(destination);
|
|
482
|
+
const closed = new Set();
|
|
483
|
+
await Promise.all(this.children.map(async entry => {
|
|
484
|
+
await entry.child.close();
|
|
485
|
+
await closeDestinationRoute(entry.destination);
|
|
486
|
+
closed.add(entry.destination);
|
|
400
487
|
}));
|
|
488
|
+
await Promise.all(this.destinations.filter(destination => !closed.has(destination)).map(destination => closeDestinationRoute(destination)));
|
|
401
489
|
}
|
|
402
490
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline/promises';
|
|
2
2
|
import { stdin, stdout } from 'node:process';
|
|
3
|
-
import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
|
|
3
|
+
import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
|
|
4
4
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
5
5
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
6
6
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
7
|
+
import { readCredentials, removeDestinationCredentials, resolveCredentialsPath, storeDestinationCredentials } from './credentials-store.mjs';
|
|
8
|
+
import { canPromptForCredentials, credentialKeyForDestination, credentialModeForDestination } from './credential-overrides.mjs';
|
|
9
|
+
import { createBasDestinationRelay } from './bas-destination-relay.mjs';
|
|
7
10
|
|
|
8
11
|
const SETUP_COMMAND = 'sap-ai-dev --setup';
|
|
9
12
|
|
|
@@ -23,6 +26,90 @@ async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
async function probeBasOnPremCredentialRoute({ destination, user, password, env }) {
|
|
30
|
+
const relay = createBasDestinationRelay({
|
|
31
|
+
...destination,
|
|
32
|
+
credentials: { user, password, mode: 'bas-tunnel' }
|
|
33
|
+
}, { env, log: () => {} });
|
|
34
|
+
try {
|
|
35
|
+
await relay.ready;
|
|
36
|
+
return await relay.probeCsrfSession();
|
|
37
|
+
} finally {
|
|
38
|
+
await relay.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function promptForDestinationCredentials({ destinations, credentialsPath, existing = {}, input = stdin, output = stdout, env = process.env, verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute } = {}) {
|
|
43
|
+
const eligible = destinations.filter(canPromptForCredentials);
|
|
44
|
+
const retained = new Set();
|
|
45
|
+
if (!eligible.length) return retained;
|
|
46
|
+
|
|
47
|
+
print(output, '');
|
|
48
|
+
print(output, formatStatus('Credential overrides are optional and apply only to selected BasicAuthentication destinations.', 'step', output, 'Credentials'));
|
|
49
|
+
print(output, ` Overrides are stored beside your MCP config in ${credentialsPath} with owner-only permissions (never inside mcp.json).`);
|
|
50
|
+
print(output, ' For OnPremise destinations, BAS/Cloud Foundry proxy credentials remain separate from the SAP backend credentials.');
|
|
51
|
+
print(output, '');
|
|
52
|
+
|
|
53
|
+
for (const destination of eligible) {
|
|
54
|
+
const key = credentialKeyForDestination(destination);
|
|
55
|
+
if (!key) continue;
|
|
56
|
+
const mode = credentialModeForDestination(destination);
|
|
57
|
+
const known = existing[key];
|
|
58
|
+
const routeLabel = `${destination.source === 'cloud-foundry' ? 'Cloud Foundry' : 'BAS'} ${destination.proxyType} — ${destination.name}`;
|
|
59
|
+
const answer = await textPrompt({
|
|
60
|
+
message: colorText(`🔐 ${routeLabel} — override SAP credentials? (y/n)`, 'cyan', output),
|
|
61
|
+
required: true,
|
|
62
|
+
validate: value => /^(?:y|yes|n|no)$/i.test(value.trim()) || 'Enter y or n.'
|
|
63
|
+
}, { input, output });
|
|
64
|
+
if (/^n/i.test(answer.trim())) {
|
|
65
|
+
await removeDestinationCredentials(credentialsPath, [key]);
|
|
66
|
+
print(output, formatStatus(`Credential override disabled for ${destination.name}.`, 'info', output, 'Credentials'));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (known?.user && known?.password) {
|
|
71
|
+
print(output, formatStatus(`Credentials for ${destination.name} are already stored. Press Enter at both prompts to keep them.`, 'info', output, 'Credentials'));
|
|
72
|
+
}
|
|
73
|
+
const userAnswer = await textPrompt({
|
|
74
|
+
message: colorText(`👤 ${routeLabel} — SAP user`, 'cyan', output),
|
|
75
|
+
required: false,
|
|
76
|
+
placeholder: known ? 'Enter keeps the stored user' : 'SAP user name'
|
|
77
|
+
}, { input, output });
|
|
78
|
+
const user = userAnswer.trim() || known?.user || '';
|
|
79
|
+
const passwordAnswer = await textPrompt({
|
|
80
|
+
message: colorText(`🔒 ${routeLabel} — SAP password${known ? ' (Enter keeps stored password)' : ''}`, 'cyan', output),
|
|
81
|
+
secret: true,
|
|
82
|
+
required: false
|
|
83
|
+
}, { input, output });
|
|
84
|
+
const password = passwordAnswer || known?.password || '';
|
|
85
|
+
if (!user || !password) {
|
|
86
|
+
await removeDestinationCredentials(credentialsPath, [key]);
|
|
87
|
+
print(output, formatStatus(`No complete credentials for ${destination.name}; no override was stored.`, 'warning', output, 'Credentials'));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (mode === 'bas-tunnel') {
|
|
92
|
+
let route;
|
|
93
|
+
try {
|
|
94
|
+
route = await verifyOnPremCredentialRoute({ destination, user, password, env });
|
|
95
|
+
} catch {
|
|
96
|
+
route = null;
|
|
97
|
+
}
|
|
98
|
+
if (!(route?.httpStatus >= 200 && route.httpStatus < 300) || !route.tokenReceived || !(route.cookieCount > 0)) {
|
|
99
|
+
await removeDestinationCredentials(credentialsPath, [key]);
|
|
100
|
+
print(output, formatStatus(`No override stored for ${destination.name}: the BAS/Cloud Connector route did not return both a CSRF token and session cookie.`, 'warning', output, 'Credentials'));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const host = mode === 'direct' ? (destination.backendUrl || known?.host || '') : '';
|
|
106
|
+
await storeDestinationCredentials(credentialsPath, key, { host, user, password, mode });
|
|
107
|
+
retained.add(key);
|
|
108
|
+
print(output, formatStatus(`Stored credential override for ${destination.name}.`, 'success', output, 'Credentials'));
|
|
109
|
+
}
|
|
110
|
+
return retained;
|
|
111
|
+
}
|
|
112
|
+
|
|
26
113
|
function safeProbe(probe) {
|
|
27
114
|
const result = {};
|
|
28
115
|
if (typeof probe?.status === 'string') result.status = probe.status;
|
|
@@ -39,6 +126,8 @@ function safeBasDestination(destination) {
|
|
|
39
126
|
url: destination.url,
|
|
40
127
|
client: destination.client,
|
|
41
128
|
authentication: destination.authentication,
|
|
129
|
+
proxyType: destination.proxyType,
|
|
130
|
+
backendUrl: destination.backendUrl || null,
|
|
42
131
|
probe: safeProbe(destination.probe)
|
|
43
132
|
};
|
|
44
133
|
}
|
|
@@ -84,6 +173,7 @@ function printConnectionInstructions(output, result) {
|
|
|
84
173
|
print(output, ` • ${name} — ${detail}`);
|
|
85
174
|
}
|
|
86
175
|
print(output, formatStatus('Open the Command Palette → “MCP: List Servers”, select a generated server, and choose “Start Server”.', 'step', output, 'Next'));
|
|
176
|
+
print(output, formatStatus('In GitHub Copilot Chat, select an agent, open the Chat tools picker, and enable each selected destination server for this chat.', 'step', output, 'Copilot Chat'));
|
|
87
177
|
print(output, formatStatus('Inspect or edit entries with “MCP: Open User Configuration”.', 'info', output, 'Config'));
|
|
88
178
|
}
|
|
89
179
|
|
|
@@ -96,6 +186,7 @@ export async function runSetup({
|
|
|
96
186
|
discoverCf = discoverCloudFoundryDestinations,
|
|
97
187
|
confirmCfImport = confirmCloudFoundryImport,
|
|
98
188
|
install = installMcpConfig,
|
|
189
|
+
verifyOnPremCredentialRoute = probeBasOnPremCredentialRoute,
|
|
99
190
|
includeSapDevelopmentToolsPrompt = false
|
|
100
191
|
} = {}) {
|
|
101
192
|
if (!env.H2O_URL) {
|
|
@@ -117,6 +208,7 @@ export async function runSetup({
|
|
|
117
208
|
}
|
|
118
209
|
const destinations = basDestinations.map(safeBasDestination);
|
|
119
210
|
const warnings = [];
|
|
211
|
+
const credentialWarnings = [];
|
|
120
212
|
let createdKeys = [];
|
|
121
213
|
let configPath;
|
|
122
214
|
let managedKeys = [];
|
|
@@ -228,6 +320,26 @@ export async function runSetup({
|
|
|
228
320
|
shortcuts: { all: 'a' }
|
|
229
321
|
}, { input, output });
|
|
230
322
|
}
|
|
323
|
+
|
|
324
|
+
// Ask for an explicit credential override choice for each selected supported destination.
|
|
325
|
+
const credentialsPath = await resolveCredentialsPath(env, configPath);
|
|
326
|
+
try {
|
|
327
|
+
const existingMap = (await readCredentials(credentialsPath).catch(() => ({ destinations: {} }))).destinations || {};
|
|
328
|
+
const retained = await promptForDestinationCredentials({
|
|
329
|
+
destinations: selected,
|
|
330
|
+
credentialsPath,
|
|
331
|
+
existing: existingMap,
|
|
332
|
+
input,
|
|
333
|
+
output,
|
|
334
|
+
env,
|
|
335
|
+
verifyOnPremCredentialRoute
|
|
336
|
+
});
|
|
337
|
+
const stale = Object.keys(existingMap).filter(name => !retained.has(name));
|
|
338
|
+
if (stale.length) await removeDestinationCredentials(credentialsPath, stale);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (/Prompt interrupted/.test(error.message)) throw error;
|
|
341
|
+
credentialWarnings.push(`Credential overrides could not be stored: ${error.message}`);
|
|
342
|
+
}
|
|
231
343
|
try {
|
|
232
344
|
const result = await install(selected, { env, sapDevelopmentServers });
|
|
233
345
|
const location = result?.path ? ` in ${result.path}` : '';
|
|
@@ -248,8 +360,9 @@ export async function runSetup({
|
|
|
248
360
|
}
|
|
249
361
|
}
|
|
250
362
|
await reportWarnings(warnings);
|
|
363
|
+
await reportWarnings(credentialWarnings);
|
|
251
364
|
await reportWarnings(cleanupWarnings);
|
|
252
|
-
return { selected, ...(result || {}), warnings: [...warnings, ...cleanupWarnings] };
|
|
365
|
+
return { selected, ...(result || {}), warnings: [...warnings, ...credentialWarnings, ...cleanupWarnings] };
|
|
253
366
|
} catch (error) {
|
|
254
367
|
const cleanupWarnings = [];
|
|
255
368
|
if (createdKeys.length) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -210,3 +210,87 @@ export async function checkboxPrompt({ message, choices, required = false, short
|
|
|
210
210
|
}
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
|
+
|
|
214
|
+
// Single-line text/secret prompt matching the checkbox prompt's interaction
|
|
215
|
+
// style. Secrets are masked with ●, support backspace, and can be re-prompted
|
|
216
|
+
// through `validate`. Ctrl+C rejects with 'Prompt interrupted'.
|
|
217
|
+
export async function textPrompt({ message, secret = false, required = true, placeholder = '', validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
218
|
+
let value = '';
|
|
219
|
+
let renderedLines = 0;
|
|
220
|
+
let done = false;
|
|
221
|
+
let previousRawMode;
|
|
222
|
+
|
|
223
|
+
const write = text => output.write(text);
|
|
224
|
+
const masked = () => '●'.repeat(value.length);
|
|
225
|
+
const clearRendered = () => {
|
|
226
|
+
if (!renderedLines) return;
|
|
227
|
+
write(`\u001b[${renderedLines}A\u001b[J`);
|
|
228
|
+
renderedLines = 0;
|
|
229
|
+
};
|
|
230
|
+
const render = (error = '') => {
|
|
231
|
+
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
232
|
+
const lines = [];
|
|
233
|
+
const hint = secret ? ' (input is hidden)' : '';
|
|
234
|
+
lines.push(truncateToColumns(`${message}${hint}`, columns));
|
|
235
|
+
const shown = secret ? masked() : value;
|
|
236
|
+
const display = shown || colorText(placeholder, 'cyan', output);
|
|
237
|
+
lines.push(truncateToColumns(`❯ ${display}`, columns));
|
|
238
|
+
if (error) lines.push(colorText(` ${error}`, 'red', output));
|
|
239
|
+
clearRendered();
|
|
240
|
+
write(`${lines.join('\n')}\n`);
|
|
241
|
+
renderedLines = lines.length;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const cleanup = () => {
|
|
246
|
+
input.off('keypress', onKeypress);
|
|
247
|
+
if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
|
|
248
|
+
if (typeof input.pause === 'function') input.pause();
|
|
249
|
+
write('\u001b[?25h');
|
|
250
|
+
};
|
|
251
|
+
const finish = result => {
|
|
252
|
+
if (done) return;
|
|
253
|
+
done = true;
|
|
254
|
+
clearRendered();
|
|
255
|
+
const summary = secret ? '✓ saved' : (result || '(empty)');
|
|
256
|
+
write(`${stripAnsi(message)} ${summary}\n`);
|
|
257
|
+
cleanup();
|
|
258
|
+
resolve(result);
|
|
259
|
+
};
|
|
260
|
+
const fail = error => {
|
|
261
|
+
if (done) return;
|
|
262
|
+
done = true;
|
|
263
|
+
cleanup();
|
|
264
|
+
reject(error);
|
|
265
|
+
};
|
|
266
|
+
const onKeypress = (chunk, key = {}) => {
|
|
267
|
+
if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
|
|
268
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
269
|
+
const validation = typeof validate === 'function' ? validate(value) : true;
|
|
270
|
+
if (validation !== true) return render(String(validation));
|
|
271
|
+
if (required && !value.length) return render('Enter a value (or press Ctrl+C to skip).');
|
|
272
|
+
return finish(value);
|
|
273
|
+
}
|
|
274
|
+
if (key.name === 'backspace' || key.name === 'delete') {
|
|
275
|
+
value = value.slice(0, -1);
|
|
276
|
+
} else if (key.sequence && !key.ctrl && !key.meta && /^.$/u.test(key.sequence)) {
|
|
277
|
+
value += key.sequence;
|
|
278
|
+
} else {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
render();
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
readline.emitKeypressEvents(input);
|
|
286
|
+
previousRawMode = input.isRaw;
|
|
287
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(true);
|
|
288
|
+
if (typeof input.resume === 'function') input.resume();
|
|
289
|
+
write('\u001b[?25l');
|
|
290
|
+
input.on('keypress', onKeypress);
|
|
291
|
+
render();
|
|
292
|
+
} catch (error) {
|
|
293
|
+
fail(error);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|