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 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. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
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: env.SAP_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();
@@ -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
+ }
@@ -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 = [];
@@ -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
- const marker = selected.has(index)
251
- ? colorText('✓', 'green', output)
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
- const label = `${pointer}${marker} ${choice.name}${disabled}`;
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
- if (error) lines.push(truncateToColumns(colorText(` ${error}`, 'red', output), columns));
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