sap-ai-dev-toolkit 0.9.6 → 0.9.7
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 +2 -2
- package/package.json +1 -1
- package/src/launcher.mjs +18 -4
- package/src/local-sap-gui.mjs +24 -0
- package/src/mcp-config.mjs +2 -0
- package/src/setup.mjs +29 -9
- package/src/terminal-ui.mjs +19 -7
package/README.md
CHANGED
|
@@ -300,7 +300,7 @@ The installer handles VSP provisioning automatically:
|
|
|
300
300
|
- Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. When the bundled asset is missing (an interrupted install, an antivirus quarantine, or a git checkout without `dist/`), a checksum-anchored download from the matching GitHub release is the fallback; MCP server startup runs it too, because npx entries skip install scripts.
|
|
301
301
|
- No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
|
|
302
302
|
|
|
303
|
-
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default
|
|
303
|
+
Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default: every row starts with an empty box `[ ]`. Use **Space** to tick a destination (`[✓]`) and **Enter** to confirm; the line under the list always names the next key to press. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Pressing **Enter** with nothing selected asks for a second **Enter**, because confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
|
|
304
304
|
|
|
305
305
|
Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. Local SAP GUI MCP server keys use the SAP system ID and client (for example `t4d-100`) instead of the longer SAP GUI description, while `SAP_AI_DEV_TOOLKIT_DESTINATION` still preserves the display name. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup offers Windows / smart card SSO (the default) instead, which writes `SAP_AUTH_MODE=sso` without username/password prompts. At server start, VSP opens Microsoft Edge on the ADT URL; Edge completes the login the same way it does in your browser (smart card certificate and PIN, Kerberos, or company SAML/Entra login), and the session is cached and refreshed automatically. SSO entries need an ADT URL with a host name, not an IP address. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config, and you should run `sap-ai-dev --setup` afterwards.
|
|
306
306
|
|
|
@@ -447,7 +447,7 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
|
|
|
447
447
|
If the `cf` CLI is version 8.18 or newer and authenticated to a targeted space, setup offers an opt-in import from that space's SAP Destination service. It does not create service keys unless you accept. The imported records are limited to the current space and are merged with BAS destinations for selection. OnPremise destinations require choosing a Connectivity service instance; runtime traffic uses that service's proxy, and PrincipalPropagation uses the current CF user's token. Internet destinations use the configured HTTP(S) proxy environment. Setup stores only service-instance/key references in `mcp.json`, not the service-key credentials. At runtime the generated entry verifies the active CF space and resolves those references. Setup removes an add-on-created key only when no remaining CF entry references it and the CLI is targeted to the key's recorded space; keys are left untouched when the space cannot be verified.
|
|
448
448
|
Cloud Foundry HTTP destination probes use forward-form HTTP requests, not CONNECT tunnels; SAP Connectivity requires HTTP from the application to its proxy. OnPremise requests are canceled when the client disconnects or the runtime route closes.
|
|
449
449
|
|
|
450
|
-
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
|
|
450
|
+
Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked takes a second **Enter** and removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
|
|
451
451
|
|
|
452
452
|
When nothing is detected — no BAS destinations, no Cloud Foundry imports, or no local SAP GUI entries — setup offers the **manual entry wizard** instead of stopping. The same wizard is available at any point in the destination picker via the **m** shortcut. It asks for a system name, the ADT HTTP(S) base URL (for example `https://myhost.example:44300`), one or more 3-digit SAP clients, and (on Windows) the login mode. You can add several systems in one run; each becomes the same isolated per-system/client MCP server the discovery flows generate, including credential input prompts and TLS self-healing for IP-based HTTPS URLs. Declining the wizard when nothing was detected exits setup without changes; aborting it inside the picker (Ctrl+C) returns to the selection unchanged.
|
|
453
453
|
|
package/package.json
CHANGED
package/src/launcher.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
|
|
7
|
-
import { discoverSapGuiSystems } from './local-sap-gui.mjs';
|
|
7
|
+
import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
|
|
8
8
|
import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
|
|
9
9
|
import { MCPProxy } from './mcp-proxy.mjs';
|
|
10
10
|
import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
|
|
@@ -186,6 +186,10 @@ async function binaryOrError() {
|
|
|
186
186
|
].join(' '));
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
async function detectLocalAdtUrl(target) {
|
|
190
|
+
return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
|
|
191
|
+
}
|
|
192
|
+
|
|
189
193
|
async function discoverForCommand() {
|
|
190
194
|
const env = { ...runtimeEnv };
|
|
191
195
|
if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
|
|
@@ -205,10 +209,19 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
205
209
|
// Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
|
|
206
210
|
if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
|
|
207
211
|
const cacheKey = `${systemId || new URL(env.SAP_URL).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
|
|
212
|
+
let url = env.SAP_URL;
|
|
213
|
+
// SAP GUI landscapes carry no HTTP port, and the SSO browser cannot sign in on a guessed one.
|
|
214
|
+
if (!hasExplicitPort(url)) {
|
|
215
|
+
const detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
|
|
216
|
+
logLine(detected
|
|
217
|
+
? `[sap-ai-dev] ${name}: SAP_URL has no port; ADT answers at ${detected}`
|
|
218
|
+
: `[sap-ai-dev] ${name}: SAP_URL has no port and no ADT port answered on ${new URL(url).hostname}; add the port to SAP_URL or rerun sap-ai-dev --setup`);
|
|
219
|
+
url = detected || url;
|
|
220
|
+
}
|
|
208
221
|
return {
|
|
209
222
|
source: 'sap-gui-local',
|
|
210
223
|
name,
|
|
211
|
-
url
|
|
224
|
+
url,
|
|
212
225
|
client,
|
|
213
226
|
systemId,
|
|
214
227
|
authentication: 'SSO',
|
|
@@ -280,7 +293,8 @@ async function main() {
|
|
|
280
293
|
}
|
|
281
294
|
if (setup) {
|
|
282
295
|
const setupOptions = {
|
|
283
|
-
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
|
|
296
|
+
includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools'),
|
|
297
|
+
detectAdtUrl: detectLocalAdtUrl
|
|
284
298
|
};
|
|
285
299
|
if (hasFlag('--npx')) {
|
|
286
300
|
// Windows has no npx.exe, so generated entries there launch through
|
|
@@ -335,7 +349,7 @@ async function main() {
|
|
|
335
349
|
if (!runtimeEnv.H2O_URL) {
|
|
336
350
|
if (args().length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
337
351
|
console.error('sap-ai-dev: no BAS environment detected; starting local interactive setup. Use --help for other commands.');
|
|
338
|
-
await runSetup();
|
|
352
|
+
await runSetup({ detectAdtUrl: detectLocalAdtUrl });
|
|
339
353
|
return;
|
|
340
354
|
}
|
|
341
355
|
const binary = await binaryOrError();
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -401,3 +401,27 @@ export function defaultAdtUrl(system) {
|
|
|
401
401
|
const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
402
402
|
return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
|
|
403
403
|
}
|
|
404
|
+
|
|
405
|
+
// new URL() drops a default port, so `https://host:443` must be read from the text.
|
|
406
|
+
export function hasExplicitPort(url) {
|
|
407
|
+
return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
|
|
411
|
+
// probes the conventional ADT ports and reports which one answers.
|
|
412
|
+
export async function detectAdtUrl(binary, { url, client, env = process.env, timeoutMs = 60_000 } = {}) {
|
|
413
|
+
let configured;
|
|
414
|
+
try { configured = new URL(url); } catch { return ''; }
|
|
415
|
+
const args = ['detect', configured.hostname.replace(/^\[(.*)\]$/, '$1'), '--json', ...(client ? ['--client', String(client)] : [])];
|
|
416
|
+
const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
417
|
+
? [process.execPath, binary]
|
|
418
|
+
: (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
|
|
419
|
+
try {
|
|
420
|
+
const { stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 });
|
|
421
|
+
const adt = (JSON.parse(stdout)?.findings || []).filter(finding => finding?.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')));
|
|
422
|
+
// The SSO session cookie is the whole credential; never trade https for http.
|
|
423
|
+
return (adt.find(finding => finding.secure) || (configured.protocol === 'http:' ? adt[0] : undefined))?.url || '';
|
|
424
|
+
} catch {
|
|
425
|
+
return '';
|
|
426
|
+
}
|
|
427
|
+
}
|
package/src/mcp-config.mjs
CHANGED
|
@@ -106,6 +106,8 @@ export async function resolveMcpServerCommand(env = process.env) {
|
|
|
106
106
|
: ['sap-ai-dev'];
|
|
107
107
|
const searchPath = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
|
|
108
108
|
for (const directory of searchPath) {
|
|
109
|
+
// npx prepends its disposable cache bin dir; entries pointing there go stale or vanish with the cache.
|
|
110
|
+
if (/[\\/]_npx[\\/][^\\/]+[\\/]node_modules[\\/]\.bin[\\/]?$/.test(directory)) continue;
|
|
109
111
|
for (const executableName of executableNames) {
|
|
110
112
|
const candidate = resolve(directory, executableName);
|
|
111
113
|
try {
|
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 } from './local-sap-gui.mjs';
|
|
7
|
+
import { defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort } 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,6 +124,23 @@ function localAuthenticationLabel(authMode) {
|
|
|
124
124
|
return authMode === 'sso' ? 'SSO' : 'Basic';
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// The SSO browser signs in on the ADT URL itself, and SAP GUI landscapes never
|
|
128
|
+
// carry the HTTP port, so a portless URL would send it to 443.
|
|
129
|
+
async function detectSsoAdtPort(destination, label, client, { output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
130
|
+
if (!detectAdtUrl || hasExplicitPort(destination.url)) return;
|
|
131
|
+
const stopProgress = startProgress(`Detecting the ADT port for ${label}`, { output, env, label: 'SSO' });
|
|
132
|
+
let detected = '';
|
|
133
|
+
try { detected = await detectAdtUrl({ url: destination.url, client }); }
|
|
134
|
+
catch { detected = ''; }
|
|
135
|
+
finally { stopProgress(); }
|
|
136
|
+
if (detected) {
|
|
137
|
+
destination.url = detected;
|
|
138
|
+
print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
print(output, formatStatus(`No ADT port answered for ${label}; SSO keeps ${destination.url}. If the browser cannot reach it, rerun setup and enter the ADT URL with its port, for example ${new URL(destination.url).origin}:44300.`, 'warning', output, 'SSO'));
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
128
145
|
const answer = await textPrompt({
|
|
129
146
|
message,
|
|
@@ -181,7 +198,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
|
|
|
181
198
|
// collects the minimal ADT configuration directly from the user and produces
|
|
182
199
|
// the same local destination shape as the SAP GUI flow, so installation,
|
|
183
200
|
// credential inputs, and TLS handling stay identical.
|
|
184
|
-
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
|
|
201
|
+
async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
|
|
185
202
|
print(output, '');
|
|
186
203
|
print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
|
|
187
204
|
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));
|
|
@@ -229,6 +246,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
229
246
|
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
230
247
|
const clients = await promptSapClients(destination, { input, output });
|
|
231
248
|
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
249
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { output, env, detectAdtUrl });
|
|
232
250
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
233
251
|
destination.authMode = authMode;
|
|
234
252
|
for (const client of clients) {
|
|
@@ -346,7 +364,7 @@ function printConnectionInstructions(output, result) {
|
|
|
346
364
|
|
|
347
365
|
// Checkbox selection over the discovered destinations plus, outside BAS, the
|
|
348
366
|
// per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
|
|
349
|
-
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
|
|
367
|
+
async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
350
368
|
const orderedDestinations = [...destinations].sort((left, right) => {
|
|
351
369
|
const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
|
|
352
370
|
const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
|
|
@@ -371,9 +389,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
371
389
|
print(output, '');
|
|
372
390
|
print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
|
|
373
391
|
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
392
|
print(output, '');
|
|
376
|
-
const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
|
|
393
|
+
const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
|
|
377
394
|
value: destination,
|
|
378
395
|
name: `${destination.name} (manual, client ${destination.client})`
|
|
379
396
|
}));
|
|
@@ -382,7 +399,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
382
399
|
choices,
|
|
383
400
|
required: false,
|
|
384
401
|
shortcuts: { all: 'a' },
|
|
385
|
-
actions: { m: addManually }
|
|
402
|
+
actions: { m: addManually },
|
|
403
|
+
confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
|
|
386
404
|
}, { input, output });
|
|
387
405
|
|
|
388
406
|
if (!isBas && selected.length) {
|
|
@@ -417,6 +435,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
417
435
|
destination.url = url || fallback;
|
|
418
436
|
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
419
437
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
438
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { output, env, detectAdtUrl });
|
|
420
439
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
421
440
|
destination.authMode = authMode;
|
|
422
441
|
for (const client of clients) {
|
|
@@ -437,7 +456,8 @@ export async function runSetup({
|
|
|
437
456
|
discoverLocalSapGui = discoverSapGuiSystems,
|
|
438
457
|
confirmCfImport = confirmCloudFoundryImport,
|
|
439
458
|
install = installMcpConfig,
|
|
440
|
-
includeSapDevelopmentToolsPrompt = false
|
|
459
|
+
includeSapDevelopmentToolsPrompt = false,
|
|
460
|
+
detectAdtUrl
|
|
441
461
|
} = {}) {
|
|
442
462
|
const isBas = Boolean(env.H2O_URL);
|
|
443
463
|
if (!isBas && (!input.isTTY || !output.isTTY)) {
|
|
@@ -584,10 +604,10 @@ export async function runSetup({
|
|
|
584
604
|
]
|
|
585
605
|
}, { input, output });
|
|
586
606
|
if (choice !== 'manual') return await exitWithoutDestinations();
|
|
587
|
-
selected = await runManualEntryWizard({ env, input, output });
|
|
607
|
+
selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
|
|
588
608
|
if (!selected.length) return await exitWithoutDestinations();
|
|
589
609
|
} else {
|
|
590
|
-
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
|
|
610
|
+
selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
|
|
591
611
|
}
|
|
592
612
|
|
|
593
613
|
let sapDevelopmentServers = [];
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -213,7 +213,7 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
|
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
216
|
+
export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate, confirmEmpty = '' } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
217
217
|
if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
|
|
218
218
|
// Action shortcuts (for example 'm' → manual entry wizard) may append new
|
|
219
219
|
// choices while the picker is open, so the list itself stays mutable.
|
|
@@ -225,6 +225,8 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
225
225
|
let renderedLines = 0;
|
|
226
226
|
let done = false;
|
|
227
227
|
let previousRawMode;
|
|
228
|
+
// With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
|
|
229
|
+
let awaitingEmptyConfirm = false;
|
|
228
230
|
|
|
229
231
|
const write = text => output.write(text);
|
|
230
232
|
const clearRendered = () => {
|
|
@@ -237,6 +239,12 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
237
239
|
if (cursor >= top + pageSize) top = cursor - pageSize + 1;
|
|
238
240
|
top = clamp(top, 0, Math.max(0, choices.length - pageSize));
|
|
239
241
|
};
|
|
242
|
+
const footer = error => {
|
|
243
|
+
if (error) return [[error, 'red']];
|
|
244
|
+
if (awaitingEmptyConfirm) return [['Nothing is selected yet. Press Space to select the highlighted item (❯).', 'yellow'], [confirmEmpty, 'yellow']];
|
|
245
|
+
if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
|
|
246
|
+
return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
|
|
247
|
+
};
|
|
240
248
|
const render = (error = '') => {
|
|
241
249
|
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
242
250
|
const pageSize = checkboxPageSize(output, choices.length);
|
|
@@ -247,17 +255,15 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
247
255
|
for (let index = top; index < end; index += 1) {
|
|
248
256
|
const choice = choices[index];
|
|
249
257
|
const pointer = index === cursor ? '❯' : ' ';
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
: colorText('✗', 'red', output);
|
|
258
|
+
// A red ✗ for "not selected yet" was read as an error, so unselected rows show an empty box.
|
|
259
|
+
const box = choice.disabled ? '[-]' : (selected.has(index) ? colorText('[✓]', 'green', output) : '[ ]');
|
|
253
260
|
const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
|
|
254
|
-
|
|
255
|
-
lines.push(truncateToColumns(label, columns));
|
|
261
|
+
lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
|
|
256
262
|
}
|
|
257
263
|
if (choices.length > pageSize) {
|
|
258
264
|
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
|
|
259
265
|
}
|
|
260
|
-
|
|
266
|
+
for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
|
|
261
267
|
clearRendered();
|
|
262
268
|
write(`${lines.join('\n')}\n`);
|
|
263
269
|
renderedLines = physicalRows(lines, columns);
|
|
@@ -332,6 +338,7 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
332
338
|
selected.clear();
|
|
333
339
|
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
334
340
|
} else if (actionShortcutFor(_chunk, key)) {
|
|
341
|
+
awaitingEmptyConfirm = false;
|
|
335
342
|
void runAction(actionShortcutFor(_chunk, key));
|
|
336
343
|
return;
|
|
337
344
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
@@ -339,10 +346,15 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
339
346
|
const validation = typeof validate === 'function' ? validate(values) : true;
|
|
340
347
|
if (validation !== true) return render(String(validation));
|
|
341
348
|
if (required && !values.length) return render('Select at least one item.');
|
|
349
|
+
if (confirmEmpty && !values.length && !awaitingEmptyConfirm) {
|
|
350
|
+
awaitingEmptyConfirm = true;
|
|
351
|
+
return render();
|
|
352
|
+
}
|
|
342
353
|
return finish(values);
|
|
343
354
|
} else {
|
|
344
355
|
return;
|
|
345
356
|
}
|
|
357
|
+
awaitingEmptyConfirm = false;
|
|
346
358
|
render();
|
|
347
359
|
};
|
|
348
360
|
|