sap-ai-dev-toolkit 0.9.8 → 0.9.9
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/package.json +1 -1
- package/scripts/postinstall.mjs +8 -5
- package/src/launcher.mjs +100 -32
- package/src/local-sap-gui.mjs +100 -10
- package/src/setup.mjs +129 -46
- package/src/terminal-ui.mjs +5 -2
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { closeSync, openSync, realpathSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { installBinary } from '../src/binary.mjs';
|
|
6
|
+
import { detectAdtUrl } from '../src/local-sap-gui.mjs';
|
|
6
7
|
import { runSetup } from '../src/setup.mjs';
|
|
7
8
|
import { installUserAssetsForHarnesses } from '../src/install-user-assets.mjs';
|
|
8
9
|
import { installMcpServersForHarnesses } from '../src/harness-mcp-config.mjs';
|
|
@@ -76,8 +77,9 @@ async function withInstallTerminal(action) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
async function runInstallSetup() {
|
|
80
|
-
|
|
80
|
+
async function runInstallSetup(binary) {
|
|
81
|
+
const detect = binary ? target => detectAdtUrl(binary, { ...target, env: runtimeEnv }) : undefined;
|
|
82
|
+
return withInstallTerminal(terminal => runSetup({ env: runtimeEnv, ...(terminal || {}), detectAdtUrl: detect }));
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function harnessSelectionLabels(ids) {
|
|
@@ -300,15 +302,16 @@ async function main() {
|
|
|
300
302
|
// runs `go` (only the repository-only build:vsp script does), so a toolchain
|
|
301
303
|
// download at install time was pure cost. The pinned, checksum-verified VSP
|
|
302
304
|
// binary ships with the package; the download below is the fallback.
|
|
305
|
+
let binary = brandedEnvValue(runtimeEnv, 'BINARY');
|
|
303
306
|
try {
|
|
304
|
-
if (
|
|
307
|
+
if (binary) {
|
|
305
308
|
await announce('Using the SAP_AI_DEV_TOOLKIT_BINARY override.', 'info');
|
|
306
309
|
} else {
|
|
307
310
|
// The bundled binary resolves in milliseconds; the animation only
|
|
308
311
|
// appears when provisioning actually takes time (download fallback).
|
|
309
312
|
const stopBinaryProgress = startProgress('Preparing the pinned VSP runtime for this platform', { output: process.stderr, env: runtimeEnv });
|
|
310
313
|
try {
|
|
311
|
-
await installBinary(pkg, { env: runtimeEnv });
|
|
314
|
+
binary = await installBinary(pkg, { env: runtimeEnv });
|
|
312
315
|
stopBinaryProgress();
|
|
313
316
|
await announce('Pinned VSP binary installed and ready.', 'success');
|
|
314
317
|
} catch (error) {
|
|
@@ -326,7 +329,7 @@ async function main() {
|
|
|
326
329
|
let setupResult;
|
|
327
330
|
let setupCompleted = false;
|
|
328
331
|
try {
|
|
329
|
-
setupResult = await runInstallSetup();
|
|
332
|
+
setupResult = await runInstallSetup(binary);
|
|
330
333
|
setupCompleted = true;
|
|
331
334
|
} catch (error) {
|
|
332
335
|
await announce(`BAS MCP setup failed: ${error.message}`, 'error');
|
package/src/launcher.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
|
-
import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
|
|
8
|
-
import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
|
|
8
|
+
import { adtBaseUrl, adtPort, detectAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix } from './local-sap-gui.mjs';
|
|
9
|
+
import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './binary.mjs';
|
|
9
10
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
10
11
|
import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
11
12
|
import { runSetup } from './setup.mjs';
|
|
@@ -165,7 +166,14 @@ function probeDiagnostic(destination) {
|
|
|
165
166
|
|
|
166
167
|
// npx MCP entries run with --ignore-scripts, so postinstall's verified
|
|
167
168
|
// download fallback never ran; a missing or corrupt bundled binary heals here.
|
|
168
|
-
|
|
169
|
+
// Port detection and the server share one resolution, so a failed download is not repeated.
|
|
170
|
+
let binaryResolution;
|
|
171
|
+
function binaryOrError() {
|
|
172
|
+
binaryResolution ??= resolveBinary();
|
|
173
|
+
return binaryResolution;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function resolveBinary() {
|
|
169
177
|
const notes = [];
|
|
170
178
|
const found = await findBinary(pkg, { env: runtimeEnv, log: note => notes.push(note) });
|
|
171
179
|
if (found) return found;
|
|
@@ -190,6 +198,68 @@ async function detectLocalAdtUrl(target) {
|
|
|
190
198
|
return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
const adtUrlCachePath = () => join(cacheDirectory(runtimeEnv), 'adt-urls.json');
|
|
202
|
+
|
|
203
|
+
async function cachedAdtUrls() {
|
|
204
|
+
try { return JSON.parse(await readFile(adtUrlCachePath(), 'utf8')) || {}; }
|
|
205
|
+
catch { return {}; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function rememberAdtUrl(configured, found) {
|
|
209
|
+
try {
|
|
210
|
+
const path = adtUrlCachePath();
|
|
211
|
+
const urls = { ...await cachedAdtUrls(), [configured.toLowerCase()]: found };
|
|
212
|
+
await mkdir(dirname(path), { recursive: true });
|
|
213
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
214
|
+
await writeFile(temporary, `${JSON.stringify(urls, null, 2)}\n`);
|
|
215
|
+
await rename(temporary, path);
|
|
216
|
+
} catch {
|
|
217
|
+
// The cache only saves a later scan.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A named port gets one probe, a scan result is remembered, and every port is
|
|
222
|
+
// swept only when the configuration never named one.
|
|
223
|
+
async function resolveLocalAdtUrl(name, configured, client) {
|
|
224
|
+
const log = message => logLine(`[sap-ai-dev] ${name}: ${message}`);
|
|
225
|
+
const detect = target => detectLocalAdtUrl({ client, ...target }).catch(error => ({ url: '', reason: error.message, failed: true }));
|
|
226
|
+
const explicit = hasExplicitPort(configured);
|
|
227
|
+
let verdict = { url: '', reason: '' };
|
|
228
|
+
if (explicit) {
|
|
229
|
+
verdict = await detect({ url: configured, ports: [adtPort(configured)] });
|
|
230
|
+
if (verdict.url) return { url: verdict.url, verified: true };
|
|
231
|
+
log(`ADT did not answer at ${configured}: ${verdict.reason}`);
|
|
232
|
+
if (verdict.unresolved || verdict.failed) return { url: configured, verified: false, reason: verdict.reason };
|
|
233
|
+
}
|
|
234
|
+
const remembered = (await cachedAdtUrls())[configured.toLowerCase()];
|
|
235
|
+
if (remembered) {
|
|
236
|
+
const recheck = await detect({ url: remembered, ports: [adtPort(remembered)] });
|
|
237
|
+
if (recheck.url) {
|
|
238
|
+
log(`ADT answers at ${recheck.url} (found by an earlier port scan)`);
|
|
239
|
+
return { url: recheck.url, verified: true };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const host = new URL(configured).hostname;
|
|
243
|
+
log(`port scan in progress on ${host} (usual ADT ports)`);
|
|
244
|
+
verdict = await detect({ url: configured });
|
|
245
|
+
if (!verdict.url && !explicit && !verdict.unresolved && !verdict.failed) {
|
|
246
|
+
log(`no ADT port on the usual ports (${verdict.reason}); port scan in progress on every conventional SAP port of ${host} (can take a minute)`);
|
|
247
|
+
verdict = await detect({ url: configured, exhaustive: true });
|
|
248
|
+
}
|
|
249
|
+
if (verdict.url) {
|
|
250
|
+
log(`port scan finished; ADT answers at ${verdict.url}`);
|
|
251
|
+
await rememberAdtUrl(configured, verdict.url);
|
|
252
|
+
return { url: verdict.url, verified: true };
|
|
253
|
+
}
|
|
254
|
+
log(`port scan finished; ${verdict.reason}; keeping ${configured}. Add the ADT port to SAP_URL or rerun sap-ai-dev --setup.`);
|
|
255
|
+
return { url: configured, verified: false, reason: verdict.reason };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function noProxyEnv(env, host) {
|
|
259
|
+
const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
|
|
260
|
+
return { NO_PROXY: noProxy, no_proxy: noProxy };
|
|
261
|
+
}
|
|
262
|
+
|
|
193
263
|
async function discoverForCommand() {
|
|
194
264
|
const env = { ...runtimeEnv };
|
|
195
265
|
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
@@ -206,56 +276,54 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
206
276
|
if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
|
|
207
277
|
const client = env.SAP_CLIENT || '001';
|
|
208
278
|
const systemId = env.SAP_SYSTEM_ID || '';
|
|
279
|
+
const configuredUrl = adtBaseUrl(env.SAP_URL);
|
|
209
280
|
// Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
281
|
+
const sso = !['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase());
|
|
282
|
+
const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
|
|
283
|
+
const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
|
|
284
|
+
const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
|
|
285
|
+
// SNI-routed Basic entries pin port and certificate names on purpose; SSO ignores those names.
|
|
286
|
+
const adt = (sso || !tlsServerNames.length) && !hasPathPrefix(configuredUrl)
|
|
287
|
+
? await resolveLocalAdtUrl(name, configuredUrl, client)
|
|
288
|
+
: { url: configuredUrl };
|
|
289
|
+
// The scan reached ADT directly, so an env proxy the browser's PAC would bypass must not reroute VSP.
|
|
290
|
+
const direct = adt.verified ? noProxyEnv(env, new URL(adt.url).hostname) : {};
|
|
291
|
+
const probe = adt.verified === undefined
|
|
292
|
+
? { status: 'configured', available: true }
|
|
293
|
+
: (adt.verified ? { status: `adt-verified at ${adt.url}`, available: true } : { status: `adt-unverified: ${adt.reason}`, available: false });
|
|
294
|
+
if (sso) {
|
|
295
|
+
const cacheKey = `${systemId || new URL(configuredUrl).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
|
|
296
|
+
if (isIP(new URL(adt.url).hostname.replace(/^\[(.*)\]$/, '$1'))) logLine(`[sap-ai-dev] ${name}: SSO is configured for an IP address; Kerberos and the browser certificate check need the DNS host name`);
|
|
227
297
|
return {
|
|
228
298
|
source: 'sap-gui-local',
|
|
229
299
|
name,
|
|
230
|
-
url,
|
|
300
|
+
url: adt.url,
|
|
231
301
|
client,
|
|
232
302
|
systemId,
|
|
233
303
|
authentication: 'SSO',
|
|
234
304
|
// SAP_SAML_AUTH survives sanitizeChildEnv and would make VSP demand SAML credentials.
|
|
235
|
-
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '' },
|
|
236
|
-
probe
|
|
305
|
+
childEnv: { SAP_SSO: 'true', SAP_SSO_SYSTEM: cacheKey, SAP_SAML_AUTH: '', ...direct },
|
|
306
|
+
probe
|
|
237
307
|
};
|
|
238
308
|
}
|
|
239
|
-
const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
|
|
240
|
-
const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
|
|
241
|
-
const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
|
|
242
309
|
const tlsRoute = tlsServerNames.length
|
|
243
|
-
? await createTlsServerNameAdtProxy({ destinationUrl:
|
|
310
|
+
? await createTlsServerNameAdtProxy({ destinationUrl: configuredUrl, tlsServerNames, caFile: tlsCaFile || undefined })
|
|
244
311
|
: null;
|
|
245
312
|
return {
|
|
246
313
|
source: 'sap-gui-local',
|
|
247
314
|
name,
|
|
248
|
-
url: tlsRoute?.url ||
|
|
249
|
-
backendUrl: tlsRoute ?
|
|
315
|
+
url: tlsRoute?.url || adt.url,
|
|
316
|
+
backendUrl: tlsRoute ? configuredUrl : undefined,
|
|
250
317
|
client,
|
|
251
318
|
systemId,
|
|
252
319
|
authentication: 'Basic',
|
|
253
320
|
childEnv: {
|
|
254
321
|
SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
|
|
255
|
-
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
|
|
322
|
+
SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || '',
|
|
323
|
+
...direct
|
|
256
324
|
},
|
|
257
325
|
close: tlsRoute?.close,
|
|
258
|
-
probe: { status:
|
|
326
|
+
probe: tlsRoute ? { status: 'configured-tls-server-name', available: true } : probe
|
|
259
327
|
};
|
|
260
328
|
}
|
|
261
329
|
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { platform, homedir } from 'node:os';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
3
4
|
import { execFile } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -149,6 +150,8 @@ function normalizeRecord(record, sourcePath) {
|
|
|
149
150
|
const port = endpoint ? Number(endpoint[2]) : 0;
|
|
150
151
|
const instance = instanceFrom(record) || (port >= 3200 && port <= 3299 ? String(port - 3200).padStart(2, '0') : '');
|
|
151
152
|
const client = text(record.client || record.sapclient) || '001';
|
|
153
|
+
// A load-balanced entry names the message server, whose port is 3600 + its instance.
|
|
154
|
+
const messageServerPort = text(record.msport).match(/^36(\d{2})$/);
|
|
152
155
|
return {
|
|
153
156
|
source: 'sap-gui-local',
|
|
154
157
|
name,
|
|
@@ -157,6 +160,7 @@ function normalizeRecord(record, sourcePath) {
|
|
|
157
160
|
systemId,
|
|
158
161
|
instance,
|
|
159
162
|
client,
|
|
163
|
+
...(messageServerPort ? { messageServer: { host, httpPort: 8100 + Number(messageServerPort[1]) } } : {}),
|
|
160
164
|
authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
|
|
161
165
|
authMode: 'basic',
|
|
162
166
|
ssoHint: ssoHintFrom(record),
|
|
@@ -182,6 +186,7 @@ function parseLandscapeXml(content, sourcePath) {
|
|
|
182
186
|
// For load-balanced entries `server` is a logon group, not a host.
|
|
183
187
|
if (!messageServer?.host) continue;
|
|
184
188
|
record.server = messageServer.host;
|
|
189
|
+
record.msport = messageServer.port;
|
|
185
190
|
}
|
|
186
191
|
const normalized = normalizeRecord(record, sourcePath);
|
|
187
192
|
if (normalized) records.push(normalized);
|
|
@@ -407,25 +412,110 @@ export function hasExplicitPort(url) {
|
|
|
407
412
|
return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
|
|
408
413
|
}
|
|
409
414
|
|
|
415
|
+
// VSP appends every ADT path to the base URL, so a pasted ADT path would double up.
|
|
416
|
+
export function adtBaseUrl(url) {
|
|
417
|
+
return String(url || '').trim().replace(/[?#].*$/, '').replace(/\/sap\/bc\/adt(?:\/.*)?$/i, '').replace(/\/+$/, '');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A gateway path prefix cannot be probed by host and port alone.
|
|
421
|
+
export function hasPathPrefix(url) {
|
|
422
|
+
return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]+\/[^?#]/i.test(adtBaseUrl(url));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function adtPort(url) {
|
|
426
|
+
const parsed = new URL(url);
|
|
427
|
+
return Number(parsed.port) || (parsed.protocol === 'http:' ? 80 : 443);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// A redirect proves less than a 401 or 200: a gateway can redirect every path.
|
|
431
|
+
function adtRank(finding) {
|
|
432
|
+
return (finding.secure ? 0 : 2) + (finding.status >= 300 && finding.status < 400 ? 1 : 0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Picks the ADT URL from VSP detect's JSON, or explains why there is none.
|
|
436
|
+
export function adtVerdict(result, protocol = 'https:') {
|
|
437
|
+
const findings = Array.isArray(result?.findings) ? result.findings.filter(Boolean) : [];
|
|
438
|
+
const usable = findings
|
|
439
|
+
.filter(finding => finding.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')) && (finding.secure || protocol === 'http:'))
|
|
440
|
+
.sort((left, right) => adtRank(left) - adtRank(right));
|
|
441
|
+
if (usable.length) return { url: usable[0].url, reason: '' };
|
|
442
|
+
const host = result?.host || 'the host';
|
|
443
|
+
const unsearched = Array.isArray(result?.unsearched) ? result.unsearched[0] : undefined;
|
|
444
|
+
if (unsearched) {
|
|
445
|
+
const unresolved = /does not resolve/i.test(String(unsearched.reason));
|
|
446
|
+
return { url: '', unresolved, reason: unresolved ? `${unsearched.object} does not resolve; connect to the company network or VPN` : `${unsearched.object}: ${unsearched.reason}` };
|
|
447
|
+
}
|
|
448
|
+
const plain = findings.find(finding => finding.kind === 'adt');
|
|
449
|
+
// The SSO session cookie is the whole credential; https is never traded for http silently.
|
|
450
|
+
if (plain) return { url: '', reason: `only plain HTTP answers ADT (${plain.url}); enter it explicitly to accept an unencrypted connection` };
|
|
451
|
+
const inactive = findings.find(finding => finding.kind === 'sap-without-adt');
|
|
452
|
+
if (inactive) return { url: '', reason: `SAP answers at ${inactive.url} but /sap/bc/adt is not active; ask basis to activate it in SICF` };
|
|
453
|
+
const tls = findings.find(finding => finding.kind === 'tls-name-mismatch');
|
|
454
|
+
if (tls && /certificate required|bad certificate/i.test(String(tls.detail))) {
|
|
455
|
+
return { url: '', reason: `port ${tls.port} demands a client certificate (smart card), which VSP cannot present` };
|
|
456
|
+
}
|
|
457
|
+
if (tls) return { url: '', reason: `port ${tls.port} presents a certificate ${tls.certHost ? `for ${tls.certHost}` : 'this machine does not trust'}${tls.detail ? ` (${tls.detail})` : ''}` };
|
|
458
|
+
const open = findings.filter(finding => ['open', 'http'].includes(finding.kind)).map(finding => finding.port);
|
|
459
|
+
if (open.length) return { url: '', reason: `${open.length === 1 ? `port ${open[0]} on ${host} accepts` : `ports ${open.join(', ')} on ${host} accept`} connections but none answered as ADT` };
|
|
460
|
+
return { url: '', reason: `no port answered on ${host}` };
|
|
461
|
+
}
|
|
462
|
+
|
|
410
463
|
// A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
|
|
411
464
|
// probes the conventional ADT ports and reports which one answers.
|
|
412
|
-
export async function detectAdtUrl(binary, { url, client, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
|
|
465
|
+
export async function detectAdtUrl(binary, { url, client, instance, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
|
|
413
466
|
let configured;
|
|
414
|
-
try { configured = new URL(url); } catch { return ''; }
|
|
467
|
+
try { configured = new URL(url); } catch { return { url: '', reason: `${url} is not a valid URL` }; }
|
|
415
468
|
const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
|
|
416
|
-
const args = ['detect', host, '--json',
|
|
469
|
+
const args = ['detect', host, '--json',
|
|
470
|
+
...(client ? ['--client', String(client)] : []),
|
|
471
|
+
...(/^\d{2}$/.test(String(instance || '')) ? ['--instance', String(instance)] : []),
|
|
472
|
+
...(exhaustive ? ['--all'] : []),
|
|
473
|
+
...ports.flatMap(port => ['--port', String(port)])];
|
|
417
474
|
// A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
|
|
418
475
|
const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
|
|
419
|
-
env = { ...env, NO_PROXY: noProxy, no_proxy: noProxy };
|
|
420
476
|
const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
421
477
|
? [process.execPath, binary]
|
|
422
478
|
: (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
|
|
479
|
+
let stdout;
|
|
423
480
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return '';
|
|
481
|
+
({ stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env: { ...env, NO_PROXY: noProxy, no_proxy: noProxy }, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 }));
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const why = error.killed ? `timed out after ${Math.round(timeoutMs / 1000)} s` : (typeof error.code === 'string' ? error.code : `exit code ${error.code}`);
|
|
484
|
+
const blocked = ['EACCES', 'EPERM', 'UNKNOWN'].includes(error.code) ? '; antivirus or application control may block it' : '';
|
|
485
|
+
return { url: '', reason: `VSP detect could not run (${why}) from ${binary}${blocked}` };
|
|
430
486
|
}
|
|
487
|
+
try { return adtVerdict(JSON.parse(stdout), configured.protocol); }
|
|
488
|
+
catch { return { url: '', reason: 'VSP detect returned unreadable output' }; }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Application servers appear by their SM51 names, host_SID_NN.
|
|
492
|
+
export function parseMessageServerServers(body, { host = '', systemId = '' } = {}) {
|
|
493
|
+
const domain = isIP(host) ? '' : (/^[^.]+\.(.+)$/.exec(host)?.[1] || '');
|
|
494
|
+
const servers = new Map();
|
|
495
|
+
for (const [, name, sid, instance] of String(body).matchAll(/\b([A-Za-z0-9][A-Za-z0-9.-]*)_([A-Za-z][A-Za-z0-9]{2})_(\d{2})\b/g)) {
|
|
496
|
+
if (systemId && sid.toUpperCase() !== systemId.toUpperCase()) continue;
|
|
497
|
+
const appHost = name.includes('.') || !domain ? name : `${name}.${domain}`;
|
|
498
|
+
servers.set(`${appHost.toLowerCase()}:${instance}`, { host: appHost, instance });
|
|
499
|
+
}
|
|
500
|
+
return [...servers.values()];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// A load-balanced landscape entry names the message server, which serves no
|
|
504
|
+
// ADT; its HTTP port lists the application servers that do, the same source
|
|
505
|
+
// SAP Web Dispatcher reads.
|
|
506
|
+
export async function messageServerAppServers({ host, httpPort, systemId } = {}, { timeoutMs = 5000 } = {}) {
|
|
507
|
+
if (!host || !httpPort) return [];
|
|
508
|
+
const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
509
|
+
for (const path of ['/msgserver/text/logon', '/msgserver/text/serverlist']) {
|
|
510
|
+
let body = '';
|
|
511
|
+
try {
|
|
512
|
+
const response = await fetch(`http://${authority}:${httpPort}${path}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
513
|
+
if (response.ok) body = await response.text();
|
|
514
|
+
} catch {
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const servers = parseMessageServerServers(body, { host, systemId });
|
|
518
|
+
if (servers.length) return servers;
|
|
519
|
+
}
|
|
520
|
+
return [];
|
|
431
521
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { platform } from 'node:os';
|
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
5
|
import { checkboxPrompt, colorText, formatStatus, selectPrompt, startProgress, textPrompt } from './terminal-ui.mjs';
|
|
6
6
|
import { discoverDestinations, remediation } from './bas-discovery.mjs';
|
|
7
|
-
import { defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
|
|
7
|
+
import { adtBaseUrl, adtPort, defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort, hasPathPrefix, messageServerAppServers } from './local-sap-gui.mjs';
|
|
8
8
|
import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
|
|
9
9
|
import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
|
|
10
10
|
import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
|
|
@@ -124,41 +124,96 @@ function localAuthenticationLabel(authMode) {
|
|
|
124
124
|
return authMode === 'sso' ? 'SSO' : 'Basic';
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
// Ports typed at the prompt are range-checked before anything parses them as a URL.
|
|
128
|
+
export function adtCandidate(answer, baseUrl) {
|
|
129
|
+
const value = String(answer || '').trim();
|
|
130
|
+
if (/^\d+$/.test(value)) {
|
|
131
|
+
const port = Number(value);
|
|
132
|
+
if (port < 1 || port > 65535) throw new Error('Enter a port between 1 and 65535, or a full URL.');
|
|
133
|
+
const base = new URL(baseUrl);
|
|
134
|
+
return `${base.protocol}//${base.hostname}:${port}`;
|
|
135
|
+
}
|
|
136
|
+
const validation = validateAdtUrlCandidate(value);
|
|
137
|
+
if (validation !== true) throw new Error(validation);
|
|
138
|
+
return adtBaseUrl(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Cheapest evidence first: named ports get one probe, then the host's usual
|
|
142
|
+
// ports, then the message server's application servers, then every port.
|
|
143
|
+
async function findAdtUrl(destination, label, client, { candidates = [], scan = true, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
144
|
+
const detect = async (message, target) => {
|
|
145
|
+
print(output, formatStatus(message, 'progress', output, 'ADT'));
|
|
146
|
+
const stopProgress = startProgress(message, { output, env, label: 'ADT' });
|
|
147
|
+
try { return await detectAdtUrl({ client, ...target }); }
|
|
148
|
+
catch (error) { return { url: '', reason: error.message }; }
|
|
135
149
|
finally { stopProgress(); }
|
|
136
150
|
};
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
detected = await scan(`Sweeping SAP HTTP(S) ports for ${label}`, { exhaustive: true });
|
|
151
|
+
let verdict = { url: '', reason: `no port was checked for ${label}` };
|
|
152
|
+
for (const candidate of [...new Set([destination.url, ...candidates].filter(url => url && hasExplicitPort(url)))]) {
|
|
153
|
+
verdict = await detect(`Checking ADT at ${candidate}`, { url: candidate, ports: [adtPort(candidate)] });
|
|
154
|
+
if (verdict.url || verdict.unresolved) return verdict;
|
|
142
155
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
if (!scan) return verdict;
|
|
157
|
+
const host = new URL(destination.url).hostname;
|
|
158
|
+
verdict = await detect(`Port scan in progress on ${host} to find the ADT port for ${label}`, { url: destination.url, ...(destination.instance ? { instance: destination.instance } : {}) });
|
|
159
|
+
if (verdict.url || verdict.unresolved) return verdict;
|
|
160
|
+
if (destination.messageServer) {
|
|
161
|
+
print(output, formatStatus(`Asking the message server ${destination.messageServer.host}:${destination.messageServer.httpPort} for the application servers of ${label}`, 'progress', output, 'ADT'));
|
|
162
|
+
for (const server of (await messageServerAppServers({ ...destination.messageServer, systemId: destination.systemId })).slice(0, 3)) {
|
|
163
|
+
const found = await detect(`Port scan in progress on application server ${server.host} (instance ${server.instance})`, { url: `${new URL(destination.url).protocol}//${server.host}`, instance: server.instance });
|
|
164
|
+
if (found.url) return found;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return detect(`No ADT port on the usual ports for ${label} (${verdict.reason}); port scan in progress on every conventional SAP port of ${host} (this can take a minute)`, { url: destination.url, exhaustive: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// An SSO sign-in can only start where ADT answers, so for SSO setup does not
|
|
171
|
+
// end on an unverified URL unless the user chooses to.
|
|
172
|
+
async function settleAdtUrl(destination, label, client, { insist = false, candidates = [], input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
173
|
+
if (!detectAdtUrl) return 'unchecked';
|
|
174
|
+
if (hasPathPrefix(destination.url)) {
|
|
175
|
+
print(output, formatStatus(`${destination.url} has a gateway path, so setup uses it as entered without a port scan.`, 'info', output, 'ADT'));
|
|
176
|
+
return 'unchecked';
|
|
177
|
+
}
|
|
178
|
+
const options = { candidates, output, env, detectAdtUrl };
|
|
179
|
+
let verdict = await findAdtUrl(destination, label, client, options);
|
|
180
|
+
while (!verdict.url) {
|
|
181
|
+
print(output, formatStatus(`No ADT answer for ${label}: ${verdict.reason}.`, 'warning', output, 'ADT'));
|
|
182
|
+
if (!insist) return 'unverified';
|
|
183
|
+
const choice = await selectPrompt({
|
|
184
|
+
message: `ADT for ${label}`,
|
|
185
|
+
defaultValue: 'enter',
|
|
186
|
+
choices: [
|
|
187
|
+
{ name: 'Enter the ADT port or URL (basis finds it in SMICM > Goto > Services)', value: 'enter', shortcut: 'e' },
|
|
188
|
+
{ name: 'Scan again, for example after connecting the VPN', value: 'retry', shortcut: 'r' },
|
|
189
|
+
{ name: `Keep ${destination.url} without an ADT answer`, value: 'keep', shortcut: 'k' },
|
|
190
|
+
{ name: 'Skip this system', value: 'skip', shortcut: 's' }
|
|
191
|
+
]
|
|
192
|
+
}, { input, output });
|
|
193
|
+
if (choice === 'keep') return 'unverified';
|
|
194
|
+
if (choice === 'skip') return 'skipped';
|
|
195
|
+
if (choice === 'enter') {
|
|
196
|
+
const answer = await textPrompt({
|
|
197
|
+
message: `ADT port or URL for ${label}`,
|
|
198
|
+
placeholder: '44300 or https://host:44300',
|
|
199
|
+
validate: value => {
|
|
200
|
+
try { adtCandidate(value, destination.url); return true; }
|
|
201
|
+
catch (error) { return error.message; }
|
|
202
|
+
}
|
|
203
|
+
}, { input, output });
|
|
204
|
+
destination.url = adtCandidate(answer, destination.url);
|
|
158
205
|
}
|
|
206
|
+
const retry = choice === 'retry';
|
|
207
|
+
verdict = await findAdtUrl(destination, label, client, { ...options, candidates: retry ? candidates : [], scan: retry || !hasExplicitPort(destination.url) });
|
|
159
208
|
}
|
|
160
|
-
destination.url =
|
|
161
|
-
print(output, formatStatus(`ADT answers at ${
|
|
209
|
+
destination.url = verdict.url;
|
|
210
|
+
print(output, formatStatus(`ADT answers at ${verdict.url}.`, 'success', output, 'ADT'));
|
|
211
|
+
return 'verified';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function warnSsoIpHost(destination, label, output) {
|
|
215
|
+
if (!isIP(new URL(destination.url).hostname.replace(/^\[(.*)\]$/, '$1'))) return;
|
|
216
|
+
print(output, formatStatus(`${label} uses an IP address; SSO needs the DNS host name for Kerberos and the browser certificate check. Enter the host name if the sign-in fails.`, 'warning', output, 'SSO'));
|
|
162
217
|
}
|
|
163
218
|
|
|
164
219
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
@@ -252,7 +307,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
252
307
|
source: 'sap-gui-local',
|
|
253
308
|
name,
|
|
254
309
|
serverName: name,
|
|
255
|
-
url,
|
|
310
|
+
url: adtBaseUrl(url),
|
|
256
311
|
host: parsedUrl?.hostname || '',
|
|
257
312
|
client: '001',
|
|
258
313
|
authentication: 'Basic',
|
|
@@ -263,16 +318,21 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
263
318
|
// destination picker.
|
|
264
319
|
configuredManually: true
|
|
265
320
|
};
|
|
266
|
-
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
267
321
|
const clients = await promptSapClients(destination, { input, output });
|
|
268
322
|
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
323
|
+
const adt = await settleAdtUrl(destination, name, clients[0], { insist: authMode === 'sso', input, output, env, detectAdtUrl });
|
|
324
|
+
if (adt === 'skipped') {
|
|
325
|
+
print(output, formatStatus(`Skipped ${name}; no MCP server was added for it.`, 'info', output, 'Manual entry'));
|
|
326
|
+
} else {
|
|
327
|
+
if (authMode === 'sso') warnSsoIpHost(destination, name, output);
|
|
328
|
+
else await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
329
|
+
destination.authentication = localAuthenticationLabel(authMode);
|
|
330
|
+
destination.authMode = authMode;
|
|
331
|
+
for (const client of clients) {
|
|
332
|
+
expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
|
|
333
|
+
}
|
|
334
|
+
print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
|
|
274
335
|
}
|
|
275
|
-
print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
|
|
276
336
|
addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
|
|
277
337
|
}
|
|
278
338
|
return expanded;
|
|
@@ -293,6 +353,18 @@ function localClientDestinationName(destination, client, { multipleClients = fal
|
|
|
293
353
|
}
|
|
294
354
|
|
|
295
355
|
|
|
356
|
+
// Two systems sharing a SID cannot be told apart, so neither URL is offered for them.
|
|
357
|
+
function previousLocalAdtUrls(config) {
|
|
358
|
+
const urls = new Map();
|
|
359
|
+
for (const entry of Object.values(config?.servers || {})) {
|
|
360
|
+
const entryEnv = entry?.env || {};
|
|
361
|
+
if (entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE !== 'sap-gui-local' || !entryEnv.SAP_SYSTEM_ID || !entryEnv.SAP_URL) continue;
|
|
362
|
+
const key = String(entryEnv.SAP_SYSTEM_ID).toUpperCase();
|
|
363
|
+
urls.set(key, urls.has(key) && urls.get(key) !== entryEnv.SAP_URL ? '' : entryEnv.SAP_URL);
|
|
364
|
+
}
|
|
365
|
+
return urls;
|
|
366
|
+
}
|
|
367
|
+
|
|
296
368
|
function safeProbe(probe) {
|
|
297
369
|
const result = {};
|
|
298
370
|
if (typeof probe?.status === 'string') result.status = probe.status;
|
|
@@ -330,6 +402,7 @@ function safeLocalSapGuiDestination(destination) {
|
|
|
330
402
|
proxyType: 'Internet',
|
|
331
403
|
tlsServerName: destination.tlsServerName,
|
|
332
404
|
tlsServerNames: destination.tlsServerNames,
|
|
405
|
+
messageServer: destination.messageServer,
|
|
333
406
|
probe: safeProbe(destination.probe),
|
|
334
407
|
childEnv: destination.childEnv || {}
|
|
335
408
|
};
|
|
@@ -384,7 +457,7 @@ function printConnectionInstructions(output, result) {
|
|
|
384
457
|
|
|
385
458
|
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
386
459
|
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
387
|
-
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
460
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl, previousAdtUrls = new Map() } = {}) {
|
|
388
461
|
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
389
462
|
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
390
463
|
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
@@ -446,17 +519,24 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
446
519
|
const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
|
|
447
520
|
const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
|
|
448
521
|
const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
|
|
449
|
-
const
|
|
522
|
+
const guess = defaultAdtUrl(destination);
|
|
523
|
+
// A URL an earlier setup verified beats the 443NN naming convention.
|
|
524
|
+
const fallback = previousAdtUrls.get(String(destination.systemId || '').toUpperCase()) || guess;
|
|
450
525
|
const url = await textPrompt({
|
|
451
526
|
message: `ADT URL for ${promptLabel}`,
|
|
452
527
|
placeholder: fallback || 'https://host:44300',
|
|
453
528
|
validate: value => validateAdtUrlCandidate(value || fallback),
|
|
454
529
|
required: false
|
|
455
530
|
}, { input, output });
|
|
456
|
-
destination.url = url || fallback;
|
|
457
|
-
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
531
|
+
destination.url = adtBaseUrl(url || fallback);
|
|
458
532
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
459
|
-
|
|
533
|
+
const adt = await settleAdtUrl(destination, promptLabel, clients[0], { insist: authMode === 'sso', candidates: url ? [] : [guess], input, output, env, detectAdtUrl });
|
|
534
|
+
if (adt === 'skipped') {
|
|
535
|
+
print(output, formatStatus(`Skipped ${promptLabel}; no MCP server was added for it.`, 'info', output, 'Local SAP GUI'));
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (authMode === 'sso') warnSsoIpHost(destination, promptLabel, output);
|
|
539
|
+
else await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
460
540
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
461
541
|
destination.authMode = authMode;
|
|
462
542
|
for (const client of clients) {
|
|
@@ -524,9 +604,12 @@ export async function runSetup({
|
|
|
524
604
|
let createdKeys = [];
|
|
525
605
|
let configPath;
|
|
526
606
|
let managedKeys = [];
|
|
607
|
+
let previousAdtUrls = new Map();
|
|
527
608
|
try {
|
|
528
609
|
configPath = await resolveMcpConfigPath(env);
|
|
529
|
-
|
|
610
|
+
const existingConfig = await readMcpConfig(configPath);
|
|
611
|
+
managedKeys = collectManagedCloudFoundryKeyReferences(existingConfig);
|
|
612
|
+
previousAdtUrls = previousLocalAdtUrls(existingConfig);
|
|
530
613
|
} catch {
|
|
531
614
|
warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
|
|
532
615
|
}
|
|
@@ -628,7 +711,7 @@ export async function runSetup({
|
|
|
628
711
|
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
629
712
|
if (!selected.length) return await exitWithoutDestinations();
|
|
630
713
|
} else {
|
|
631
|
-
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
|
|
714
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl, previousAdtUrls });
|
|
632
715
|
}
|
|
633
716
|
|
|
634
717
|
let sapDevelopmentServers = [];
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -267,7 +267,7 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
267
267
|
const footer = error => {
|
|
268
268
|
if (error) return [[error, 'red']];
|
|
269
269
|
if (awaitingEmptyConfirm) return [['Nothing is selected yet. Press Space to select the highlighted item (❯).', 'yellow'], [confirmEmpty, 'yellow']];
|
|
270
|
-
if (searchable && matchingIndexes().length === 0) return [['No choices match.
|
|
270
|
+
if (searchable && matchingIndexes().length === 0) return [['No choices match.', 'yellow']];
|
|
271
271
|
if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
|
|
272
272
|
return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
|
|
273
273
|
};
|
|
@@ -281,7 +281,10 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
281
281
|
ensureVisible(pageSize, indexes.length, cursorPosition);
|
|
282
282
|
const lines = [];
|
|
283
283
|
lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
|
|
284
|
-
if (searchable)
|
|
284
|
+
if (searchable) {
|
|
285
|
+
const hint = searchQuery ? 'Backspace edits; Esc clears' : 'type prefix; / before a or m';
|
|
286
|
+
lines.push(truncateToColumns(`Search: ${searchQuery || 'type prefix'} (${hint})`, columns));
|
|
287
|
+
}
|
|
285
288
|
const end = Math.min(indexes.length, top + pageSize);
|
|
286
289
|
for (let index = top; index < end; index += 1) {
|
|
287
290
|
const choiceIndex = indexes[index];
|