sap-ai-dev-toolkit 0.9.7 β 0.9.8
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/src/launcher.mjs +9 -3
- package/src/local-sap-gui.mjs +6 -2
- package/src/setup.mjs +34 -13
- package/src/terminal-ui.mjs +63 -18
package/package.json
CHANGED
package/src/launcher.mjs
CHANGED
|
@@ -212,10 +212,16 @@ async function configuredLocalSapGuiDestination(env) {
|
|
|
212
212
|
let url = env.SAP_URL;
|
|
213
213
|
// SAP GUI landscapes carry no HTTP port, and the SSO browser cannot sign in on a guessed one.
|
|
214
214
|
if (!hasExplicitPort(url)) {
|
|
215
|
-
const
|
|
215
|
+
const host = new URL(url).hostname;
|
|
216
|
+
logLine(`[sap-ai-dev] ${name}: SAP_URL has no port; port scan in progress on ${host} (usual ADT ports)`);
|
|
217
|
+
let detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
|
|
218
|
+
if (!detected) {
|
|
219
|
+
logLine(`[sap-ai-dev] ${name}: no ADT port on the usual ports; port scan in progress on every conventional SAP port of ${host} (can take a minute)`);
|
|
220
|
+
detected = await detectLocalAdtUrl({ url, client, exhaustive: true }).catch(() => '');
|
|
221
|
+
}
|
|
216
222
|
logLine(detected
|
|
217
|
-
? `[sap-ai-dev] ${name}:
|
|
218
|
-
: `[sap-ai-dev] ${name}:
|
|
223
|
+
? `[sap-ai-dev] ${name}: port scan finished; ADT answers at ${detected}`
|
|
224
|
+
: `[sap-ai-dev] ${name}: port scan finished; no ADT port answered on ${host}; add the port to SAP_URL or rerun sap-ai-dev --setup`);
|
|
219
225
|
url = detected || url;
|
|
220
226
|
}
|
|
221
227
|
return {
|
package/src/local-sap-gui.mjs
CHANGED
|
@@ -409,10 +409,14 @@ export function hasExplicitPort(url) {
|
|
|
409
409
|
|
|
410
410
|
// A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
|
|
411
411
|
// probes the conventional ADT ports and reports which one answers.
|
|
412
|
-
export async function detectAdtUrl(binary, { url, client, env = process.env, timeoutMs =
|
|
412
|
+
export async function detectAdtUrl(binary, { url, client, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
|
|
413
413
|
let configured;
|
|
414
414
|
try { configured = new URL(url); } catch { return ''; }
|
|
415
|
-
const
|
|
415
|
+
const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
|
|
416
|
+
const args = ['detect', host, '--json', ...(client ? ['--client', String(client)] : []), ...(exhaustive ? ['--all'] : []), ...ports.flatMap(port => ['--port', String(port)])];
|
|
417
|
+
// A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
|
|
418
|
+
const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
|
|
419
|
+
env = { ...env, NO_PROXY: noProxy, no_proxy: noProxy };
|
|
416
420
|
const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
|
|
417
421
|
? [process.execPath, binary]
|
|
418
422
|
: (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
|
package/src/setup.mjs
CHANGED
|
@@ -126,19 +126,39 @@ function localAuthenticationLabel(authMode) {
|
|
|
126
126
|
|
|
127
127
|
// The SSO browser signs in on the ADT URL itself, and SAP GUI landscapes never
|
|
128
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 } = {}) {
|
|
129
|
+
async function detectSsoAdtPort(destination, label, client, { input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
|
|
130
130
|
if (!detectAdtUrl || hasExplicitPort(destination.url)) return;
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
const scan = async (message, options) => {
|
|
132
|
+
const stopProgress = startProgress(message, { output, env, label: 'SSO' });
|
|
133
|
+
try { return await detectAdtUrl({ url: destination.url, client, ...options }); }
|
|
134
|
+
catch { return ''; }
|
|
135
|
+
finally { stopProgress(); }
|
|
136
|
+
};
|
|
137
|
+
print(output, formatStatus(`Port scan in progress on ${new URL(destination.url).hostname} to find the ADT port for ${label}.`, 'progress', output, 'SSO'));
|
|
138
|
+
let detected = await scan(`Detecting the ADT port for ${label}`, {});
|
|
139
|
+
if (!detected) {
|
|
140
|
+
print(output, formatStatus(`No ADT port answered on the usual ports for ${label}; port scan in progress on every conventional SAP port (this can take a minute).`, 'progress', output, 'SSO'));
|
|
141
|
+
detected = await scan(`Sweeping SAP HTTP(S) ports for ${label}`, { exhaustive: true });
|
|
142
|
+
}
|
|
143
|
+
// Nothing on the machine knows the port, so the person who does is asked until one answers.
|
|
144
|
+
while (!detected) {
|
|
145
|
+
const host = new URL(destination.url).hostname;
|
|
146
|
+
print(output, formatStatus(`No ADT port answered on ${host}. Ask basis or check SMICM (Goto > Services) for the HTTPS port.`, 'warning', output, 'SSO'));
|
|
147
|
+
const answer = String(await textPrompt({
|
|
148
|
+
message: `ADT port or URL for ${label}`,
|
|
149
|
+
placeholder: '44300 or https://host:44300',
|
|
150
|
+
validate: value => /^\d{2,5}$/.test(String(value).trim()) || validateAdtUrlCandidate(String(value).trim())
|
|
151
|
+
}, { input, output })).trim();
|
|
152
|
+
const candidate = /^\d+$/.test(answer) ? `${new URL(destination.url).origin}:${answer}` : answer;
|
|
153
|
+
const port = Number(new URL(candidate).port || (candidate.startsWith('http:') ? 80 : 443));
|
|
154
|
+
detected = await detectAdtUrl({ url: candidate, client, ports: [port] }).catch(() => '');
|
|
155
|
+
if (!detected && await confirmPrompt(`${candidate} did not answer as ADT. Use it anyway? (y/Enter=yes, n=try another)`, { input, output })) {
|
|
156
|
+
destination.url = candidate;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
140
159
|
}
|
|
141
|
-
|
|
160
|
+
destination.url = detected;
|
|
161
|
+
print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
|
|
@@ -246,7 +266,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
|
|
|
246
266
|
await maybeConfigureTlsSelfHeal(destination, name, { input, output });
|
|
247
267
|
const clients = await promptSapClients(destination, { input, output });
|
|
248
268
|
const authMode = await chooseLocalAuthMode(destination, { input, output, env });
|
|
249
|
-
if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { output, env, detectAdtUrl });
|
|
269
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { input, output, env, detectAdtUrl });
|
|
250
270
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
251
271
|
destination.authMode = authMode;
|
|
252
272
|
for (const client of clients) {
|
|
@@ -398,6 +418,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
398
418
|
message: colorText('π§ Select destinations', 'cyan', output),
|
|
399
419
|
choices,
|
|
400
420
|
required: false,
|
|
421
|
+
searchable: true,
|
|
401
422
|
shortcuts: { all: 'a' },
|
|
402
423
|
actions: { m: addManually },
|
|
403
424
|
confirmEmpty: 'Press Enter again to continue with none (removes this add-onβs MCP entries).'
|
|
@@ -435,7 +456,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
|
|
|
435
456
|
destination.url = url || fallback;
|
|
436
457
|
await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
|
|
437
458
|
const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
|
|
438
|
-
if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { output, env, detectAdtUrl });
|
|
459
|
+
if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { input, output, env, detectAdtUrl });
|
|
439
460
|
destination.authentication = localAuthenticationLabel(authMode);
|
|
440
461
|
destination.authMode = authMode;
|
|
441
462
|
for (const client of clients) {
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -213,11 +213,13 @@ 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, confirmEmpty = '' } = {}, { input = process.stdin, output = process.stdout } = {}) {
|
|
216
|
+
export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate, confirmEmpty = '', searchable = false } = {}, { 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.
|
|
220
220
|
let choices = initialChoices;
|
|
221
|
+
let searchQuery = '';
|
|
222
|
+
let searchMode = false;
|
|
221
223
|
const selectable = enabledChoices(choices);
|
|
222
224
|
const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
|
|
223
225
|
let cursor = firstEnabledIndex(choices);
|
|
@@ -228,40 +230,70 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
228
230
|
// With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
|
|
229
231
|
let awaitingEmptyConfirm = false;
|
|
230
232
|
|
|
233
|
+
const matchingIndexes = () => choices
|
|
234
|
+
.map((choice, index) => ({ choice, index }))
|
|
235
|
+
.filter(({ choice }) => !searchQuery || String(choice.name || '').toLowerCase().startsWith(searchQuery.toLowerCase()))
|
|
236
|
+
.map(({ index }) => index);
|
|
237
|
+
const visibleIndexes = () => {
|
|
238
|
+
const indexes = matchingIndexes();
|
|
239
|
+
if (searchable && (!indexes.includes(cursor) || choices[cursor]?.disabled)) {
|
|
240
|
+
cursor = indexes.find(index => !choices[index]?.disabled) ?? -1;
|
|
241
|
+
}
|
|
242
|
+
return indexes;
|
|
243
|
+
};
|
|
244
|
+
const moveCursor = step => {
|
|
245
|
+
if (!searchable) {
|
|
246
|
+
cursor = nextEnabledIndex(choices, cursor, step);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const indexes = visibleIndexes();
|
|
250
|
+
const visibleChoices = indexes.map(index => choices[index]);
|
|
251
|
+
if (!visibleChoices.some(choice => !choice.disabled)) return;
|
|
252
|
+
const position = nextEnabledIndex(visibleChoices, indexes.indexOf(cursor), step);
|
|
253
|
+
cursor = indexes[position] ?? -1;
|
|
254
|
+
};
|
|
255
|
+
|
|
231
256
|
const write = text => output.write(text);
|
|
232
257
|
const clearRendered = () => {
|
|
233
258
|
if (!renderedLines) return;
|
|
234
259
|
write(`\u001b[${renderedLines}A\u001b[J`);
|
|
235
260
|
renderedLines = 0;
|
|
236
261
|
};
|
|
237
|
-
const ensureVisible = pageSize => {
|
|
238
|
-
if (
|
|
239
|
-
if (
|
|
240
|
-
top = clamp(top, 0, Math.max(0,
|
|
262
|
+
const ensureVisible = (pageSize, visibleCount, cursorPosition) => {
|
|
263
|
+
if (cursorPosition >= 0 && cursorPosition < top) top = cursorPosition;
|
|
264
|
+
if (cursorPosition >= top + pageSize) top = cursorPosition - pageSize + 1;
|
|
265
|
+
top = clamp(top, 0, Math.max(0, visibleCount - pageSize));
|
|
241
266
|
};
|
|
242
267
|
const footer = error => {
|
|
243
268
|
if (error) return [[error, 'red']];
|
|
244
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. Backspace or Esc clears search.', 'yellow']];
|
|
245
271
|
if (!selected.size) return [['Press Space to select the highlighted item (β―), then Enter to confirm.', 'cyan']];
|
|
246
272
|
return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
|
|
247
273
|
};
|
|
248
274
|
const render = (error = '') => {
|
|
249
275
|
const columns = Number.isFinite(output?.columns) ? output.columns : 80;
|
|
250
|
-
const
|
|
251
|
-
|
|
276
|
+
const indexes = visibleIndexes();
|
|
277
|
+
const pageSize = searchable
|
|
278
|
+
? Math.max(1, checkboxPageSize(output, indexes.length + 1) - 1)
|
|
279
|
+
: checkboxPageSize(output, indexes.length);
|
|
280
|
+
const cursorPosition = indexes.indexOf(cursor);
|
|
281
|
+
ensureVisible(pageSize, indexes.length, cursorPosition);
|
|
252
282
|
const lines = [];
|
|
253
283
|
lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
|
|
254
|
-
|
|
284
|
+
if (searchable) lines.push(truncateToColumns(`Search: ${searchQuery || (searchMode ? '' : 'type a prefix; / for a or m')}`, columns));
|
|
285
|
+
const end = Math.min(indexes.length, top + pageSize);
|
|
255
286
|
for (let index = top; index < end; index += 1) {
|
|
256
|
-
const
|
|
257
|
-
const
|
|
287
|
+
const choiceIndex = indexes[index];
|
|
288
|
+
const choice = choices[choiceIndex];
|
|
289
|
+
const pointer = choiceIndex === cursor ? 'β―' : ' ';
|
|
258
290
|
// 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(
|
|
291
|
+
const box = choice.disabled ? '[-]' : (selected.has(choiceIndex) ? colorText('[β]', 'green', output) : '[ ]');
|
|
260
292
|
const disabled = choice.disabled ? ` β ${choice.disabled}` : '';
|
|
261
293
|
lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
|
|
262
294
|
}
|
|
263
|
-
if (
|
|
264
|
-
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${
|
|
295
|
+
if (indexes.length > pageSize) {
|
|
296
|
+
lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${indexes.length}; use β/β to scroll.`, 'cyan', output), columns));
|
|
265
297
|
}
|
|
266
298
|
for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
|
|
267
299
|
clearRendered();
|
|
@@ -328,16 +360,25 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
328
360
|
};
|
|
329
361
|
const onKeypress = (_chunk, key = {}) => {
|
|
330
362
|
if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
363
|
+
else if (searchable && key.name === 'escape') {
|
|
364
|
+
searchQuery = '';
|
|
365
|
+
searchMode = false;
|
|
366
|
+
top = 0;
|
|
367
|
+
} else if (searchable && searchMode && (key.name === 'backspace' || key.name === 'delete')) {
|
|
368
|
+
searchQuery = Array.from(searchQuery).slice(0, -1).join('');
|
|
369
|
+
if (!searchQuery) searchMode = false;
|
|
370
|
+
top = 0;
|
|
371
|
+
} else if (searchable && !searchMode && String(_chunk ?? '') === '/' && !key.ctrl && !key.meta) searchMode = true;
|
|
372
|
+
else if (key.name === 'up' || (!searchable && key.name === 'k')) moveCursor(-1);
|
|
373
|
+
else if (key.name === 'down' || (!searchable && key.name === 'j')) moveCursor(1);
|
|
374
|
+
else if (key.name === 'space' && cursor >= 0 && !choices[cursor]?.disabled) {
|
|
334
375
|
if (selected.has(cursor)) selected.delete(cursor);
|
|
335
376
|
else selected.add(cursor);
|
|
336
|
-
} else if (shortcuts?.all && key.name === shortcuts.all) {
|
|
377
|
+
} else if ((!searchable || !searchMode) && shortcuts?.all && key.name === shortcuts.all) {
|
|
337
378
|
const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
|
|
338
379
|
selected.clear();
|
|
339
380
|
if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
|
|
340
|
-
} else if (actionShortcutFor(_chunk, key)) {
|
|
381
|
+
} else if ((!searchable || !searchMode) && actionShortcutFor(_chunk, key)) {
|
|
341
382
|
awaitingEmptyConfirm = false;
|
|
342
383
|
void runAction(actionShortcutFor(_chunk, key));
|
|
343
384
|
return;
|
|
@@ -351,6 +392,10 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
|
|
|
351
392
|
return render();
|
|
352
393
|
}
|
|
353
394
|
return finish(values);
|
|
395
|
+
} else if (searchable && !key.ctrl && !key.meta && Array.from(String(_chunk ?? '')).length === 1 && !/[\u0000-\u001f\u007f]/u.test(String(_chunk))) {
|
|
396
|
+
searchMode = true;
|
|
397
|
+
searchQuery += String(_chunk);
|
|
398
|
+
top = 0;
|
|
354
399
|
} else {
|
|
355
400
|
return;
|
|
356
401
|
}
|