sap-ai-dev-toolkit 0.7.8 → 0.8.0

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.
@@ -0,0 +1,217 @@
1
+ import { platform, homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readdir, readFile, stat } from 'node:fs/promises';
4
+
5
+ function text(value) { return value == null ? '' : String(value).trim(); }
6
+ function decodeXml(value) {
7
+ return text(value)
8
+ .replace(/"/g, '"')
9
+ .replace(/'/g, "'")
10
+ .replace(/&lt;/g, '<')
11
+ .replace(/&gt;/g, '>')
12
+ .replace(/&amp;/g, '&');
13
+ }
14
+
15
+ async function exists(path) {
16
+ try { await stat(path); return true; }
17
+ catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
18
+ }
19
+
20
+ function unique(values) {
21
+ return [...new Set(values.filter(Boolean))];
22
+ }
23
+
24
+ export function sapGuiLandscapeCandidates(env = process.env) {
25
+ const home = env.HOME || env.USERPROFILE || homedir();
26
+ const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
27
+ const candidates = [];
28
+ if (platform() === 'win32' || appData) {
29
+ candidates.push(
30
+ join(appData || '', 'SAP', 'Common', 'SAPUILandscape.xml'),
31
+ join(appData || '', 'SAP', 'Common', 'SAPUILandscapeGlobal.xml'),
32
+ join(appData || '', 'SAP', 'Common', 'saplogon.ini')
33
+ );
34
+ }
35
+ candidates.push(
36
+ join(home, 'Library', 'Preferences', 'SAP', 'SAPGUILandscape.xml'),
37
+ join(home, 'Library', 'Preferences', 'SAP', 'SAPUILandscape.xml'),
38
+ join(home, 'Library', 'Preferences', 'SAP', 'connections'),
39
+ join(home, '.SAPGUI', 'SAPGUILandscape.xml')
40
+ );
41
+ return unique(candidates);
42
+ }
43
+
44
+ function attributes(fragment) {
45
+ const result = {};
46
+ for (const match of fragment.matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*"([^"]*)"|([A-Za-z_:][\w:.-]*)\s*=\s*'([^']*)'/g)) {
47
+ result[String(match[1] || match[3]).toLowerCase()] = decodeXml(match[2] ?? match[4]);
48
+ }
49
+ return result;
50
+ }
51
+
52
+ function ssoHintFrom(record) {
53
+ const ssoKeys = ['sncname', 'sncpartnername', 'sncmode', 'snc_mode', 'sncqop', 'snc_qop', 'use_sso', 'sso'];
54
+ if (ssoKeys.some(key => {
55
+ const value = text(record[key]);
56
+ return value && !['0', 'false', 'no', 'off'].includes(value.toLowerCase());
57
+ })) return true;
58
+ const values = [record.authentication, record.auth].map(text).filter(Boolean);
59
+ return values.some(value => /snc|sso|kerberos|spnego|secure\s*login/i.test(value) || ['1', 'true', 'yes'].includes(value.toLowerCase()));
60
+ }
61
+
62
+ function instanceFrom(value) {
63
+ const candidates = [value?.instance, value?.instancenumber, value?.instanceNumber, value?.sysnr, value?.systemnumber];
64
+ for (const candidate of candidates) {
65
+ const match = text(candidate).match(/^\d{2}$/);
66
+ if (match) return match[0];
67
+ }
68
+ return '';
69
+ }
70
+
71
+ function normalizeRecord(record, sourcePath) {
72
+ const type = text(record.type || record.kind).toLowerCase();
73
+ if (type && !['sapgui', 'r/3', 'sap'].some(token => type.includes(token))) return null;
74
+ const name = text(record.name || record.description || record.desc || record.systemid || record.sid || record.uuid);
75
+ const address = text(record.server || record.host || record.applicationserver || record.messageserver || record.mshost);
76
+ // SAP GUI ports are DIAG dispatcher ports, not HTTP ports.
77
+ const endpoint = address.match(/^(\[[^\]]+\]|[^:/]+):(\d+)$/);
78
+ const host = endpoint ? endpoint[1] : address;
79
+ const systemId = text(record.systemid || record.sid || record.r3name).toUpperCase();
80
+ if (!name || !host) return null;
81
+ const port = endpoint ? Number(endpoint[2]) : 0;
82
+ const instance = instanceFrom(record) || (port >= 3200 && port <= 3299 ? String(port - 3200).padStart(2, '0') : '');
83
+ const client = text(record.client || record.sapclient) || '001';
84
+ return {
85
+ source: 'sap-gui-local',
86
+ name,
87
+ serverName: name,
88
+ host,
89
+ systemId,
90
+ instance,
91
+ client,
92
+ authentication: ssoHintFrom(record) ? 'Basic/SSO hint' : 'Basic',
93
+ authMode: 'basic',
94
+ ssoHint: ssoHintFrom(record),
95
+ proxyType: 'Internet',
96
+ landscapePath: sourcePath,
97
+ probe: { status: 'needs-adt-url' }
98
+ };
99
+ }
100
+
101
+ function parseLandscapeXml(content, sourcePath) {
102
+ const records = [];
103
+ // Comments must not turn deleted entries into selectable systems.
104
+ content = content.replace(/<!--[\s\S]*?-->/g, '');
105
+ const messageServers = new Map();
106
+ for (const match of content.matchAll(/<Messageserver\b([^>]*)>/gi)) {
107
+ const record = attributes(match[1]);
108
+ if (record.uuid) messageServers.set(record.uuid, record);
109
+ }
110
+ for (const match of content.matchAll(/<Service\b([^>]*)\/?>(?:\s*<\/Service>)?/gi)) {
111
+ const record = attributes(match[1]);
112
+ if (record.msid) {
113
+ const messageServer = messageServers.get(record.msid);
114
+ // For load-balanced entries `server` is a logon group, not a host.
115
+ if (!messageServer?.host) continue;
116
+ record.server = messageServer.host;
117
+ }
118
+ const normalized = normalizeRecord(record, sourcePath);
119
+ if (normalized) records.push(normalized);
120
+ }
121
+ // SAP GUI for Java files may use generic Item/Connection elements.
122
+ for (const match of content.matchAll(/<(?:Item|Connection)\b([^>]*)\/?>(?:\s*<\/(?:Item|Connection)>)?/gi)) {
123
+ const normalized = normalizeRecord(attributes(match[1]), sourcePath);
124
+ if (normalized) records.push(normalized);
125
+ }
126
+ return records;
127
+ }
128
+
129
+ function parseSapLogonIni(content, sourcePath) {
130
+ const sections = new Map();
131
+ let section = '';
132
+ for (const line of content.split(/\r?\n/)) {
133
+ const header = line.match(/^\s*\[(.+)]\s*$/);
134
+ if (header) { section = header[1].toLowerCase(); if (!sections.has(section)) sections.set(section, {}); continue; }
135
+ const entry = line.match(/^\s*([^=]+?)\s*=\s*(.*?)\s*$/);
136
+ if (entry && section) sections.get(section)[entry[1].toLowerCase()] = entry[2];
137
+ }
138
+ const descriptions = sections.get('description') || sections.get('descriptions') || {};
139
+ const servers = sections.get('server') || sections.get('servers') || {};
140
+ const systems = sections.get('system') || sections.get('systems') || {};
141
+ const databases = sections.get('database') || sections.get('databases') || {};
142
+ const clients = sections.get('client') || sections.get('clients') || {};
143
+ const instances = sections.get('systemnumber') || {};
144
+ const sncNames = sections.get('sncname') || {};
145
+ const sncModes = sections.get('snc') || sections.get('sncmode') || {};
146
+ const keys = unique([...Object.keys(descriptions), ...Object.keys(servers)]);
147
+ return keys.map(key => normalizeRecord({
148
+ name: descriptions[key] || systems[key] || key,
149
+ server: servers[key],
150
+ systemid: databases[key] || systems[key],
151
+ client: clients[key],
152
+ systemnumber: instances[key],
153
+ sncname: sncNames[key],
154
+ sncmode: sncModes[key]
155
+ }, sourcePath)).filter(Boolean);
156
+ }
157
+
158
+ async function readGuiFile(path) {
159
+ const bytes = await readFile(path);
160
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return bytes.subarray(2).toString('utf16le');
161
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return bytes.subarray(2).swap16().toString('utf16le');
162
+ return bytes.toString('utf8').replace(/^\uFEFF/, '');
163
+ }
164
+
165
+ async function parseConnectionsFile(path) {
166
+ const content = await readGuiFile(path);
167
+ if (content.includes('<')) return parseLandscapeXml(content, path);
168
+ return content.split(/\r?\n/).map((line, index) => {
169
+ const parts = line.split(/[;,\t]/).map(text).filter(Boolean);
170
+ if (parts.length < 2) return null;
171
+ return normalizeRecord({ name: parts[0] || `SAP GUI ${index + 1}`, server: parts.find(part => /[A-Za-z]/.test(part) && part.includes('.')) || parts[1], systemid: parts.find(part => /^[A-Za-z0-9]{3}$/.test(part)) }, path);
172
+ }).filter(Boolean);
173
+ }
174
+
175
+ export async function discoverSapGuiSystems({ env = process.env, paths = sapGuiLandscapeCandidates(env) } = {}) {
176
+ const systems = [];
177
+ for (const path of unique(paths)) {
178
+ if (!path) continue;
179
+ try {
180
+ if (!await exists(path)) continue;
181
+ const info = await stat(path);
182
+ if (info.isDirectory()) {
183
+ for (const name of await readdir(path)) {
184
+ const child = join(path, name);
185
+ try {
186
+ if ((await stat(child)).isFile()) systems.push(...await parseConnectionsFile(child));
187
+ } catch {
188
+ // One unreadable child must not hide the rest of the directory.
189
+ }
190
+ }
191
+ } else if (/saplogon\.ini$/i.test(path)) {
192
+ systems.push(...parseSapLogonIni(await readGuiFile(path), path));
193
+ } else {
194
+ systems.push(...await parseConnectionsFile(path));
195
+ }
196
+ } catch {
197
+ // Ignore unreadable or unfamiliar SAP GUI files; discovery is best-effort.
198
+ }
199
+ }
200
+ const seen = new Set();
201
+ return systems.filter(system => {
202
+ const key = `${system.name}\0${system.host}\0${system.systemId}\0${system.instance}\0${system.client}`.toLowerCase();
203
+ if (seen.has(key)) return false;
204
+ seen.add(key);
205
+ return true;
206
+ }).sort((a, b) => a.name.localeCompare(b.name));
207
+ }
208
+
209
+ export function defaultAdtUrl(system) {
210
+ const host = text(system.host);
211
+ const instance = text(system.instance);
212
+ if (!host) return '';
213
+ // Router strings and other DIAG routes cannot be used as HTTP hosts.
214
+ if (/[\s/@?#]/.test(host)) return '';
215
+ const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
216
+ return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
217
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants } from 'node:fs';
3
3
  import { access, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
4
+ import { homedir, platform } from 'node:os';
5
5
  import { basename, delimiter, dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { brandedEnvValue } from './branding.mjs';
@@ -103,11 +103,22 @@ export async function resolveMcpConfigPath(env = process.env) {
103
103
  .find(value => typeof value === 'string' && value.length > 0);
104
104
  if (configuredPath) return configuredPath;
105
105
  const home = env.HOME || homedir();
106
- const candidates = [
106
+ const appData = env.APPDATA || (env.USERPROFILE ? join(env.USERPROFILE, 'AppData', 'Roaming') : '');
107
+ const localVsCodeCandidates = [
108
+ ...(appData ? [join(appData, 'Code', 'User', 'mcp.json'), join(appData, 'Code - Insiders', 'User', 'mcp.json')] : []),
109
+ ...(platform() === 'darwin' ? [
110
+ join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'),
111
+ join(home, 'Library', 'Application Support', 'Code - Insiders', 'User', 'mcp.json')
112
+ ] : []),
113
+ join(home, '.config', 'Code', 'User', 'mcp.json'),
114
+ join(home, '.config', 'Code - Insiders', 'User', 'mcp.json')
115
+ ];
116
+ const basCandidates = [
107
117
  join(home, '.vscode', 'data', 'User', 'mcp.json'),
108
118
  join(home, '.vscode-server', 'data', 'User', 'mcp.json'),
109
119
  join(home, '.code-server', 'data', 'User', 'mcp.json')
110
120
  ];
121
+ const candidates = env.H2O_URL ? [...basCandidates, ...localVsCodeCandidates] : [...localVsCodeCandidates, ...basCandidates];
111
122
  for (const candidate of candidates) if (await exists(candidate)) return candidate;
112
123
  return candidates[0];
113
124
  }
@@ -141,15 +152,17 @@ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SE
141
152
 
142
153
  export function buildMcpEntries(destinations, env = process.env) {
143
154
  const h2oUrl = env.H2O_URL;
144
- if (!h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
155
+ const hasNonLocal = destinations.some(destination => destination?.source !== 'sap-gui-local');
156
+ if (destinations.length && hasNonLocal && !h2oUrl) throw new Error('H2O_URL is required to write SAP AI Dev Toolkit configuration');
145
157
  const entries = Object.create(null);
146
158
  const selected = [...destinations].sort((a, b) => String(a.serverName || a.name).localeCompare(String(b.serverName || b.name)));
147
159
  for (const destination of selected) {
148
160
  const isCf = destination.source === 'cloud-foundry';
161
+ const isLocalSapGui = destination.source === 'sap-gui-local';
149
162
  const name = generatedServerName(isCf ? destination.serverName : destination.name);
150
163
  if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name} (two destination names normalize to the same lowercase server name)`);
151
164
  const entryEnv = {
152
- H2O_URL: String(h2oUrl),
165
+ ...(h2oUrl ? { H2O_URL: String(h2oUrl) } : {}),
153
166
  SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
154
167
  };
155
168
  if (isCf) {
@@ -171,6 +184,18 @@ export function buildMcpEntries(destinations, env = process.env) {
171
184
  BAS_CF_CONNECTIVITY_KEY: String(cf.connectivityKeyName)
172
185
  });
173
186
  }
187
+ } else if (isLocalSapGui) {
188
+ if (!destination.url) throw new Error(`Local SAP GUI destination "${destination.name}" is missing an ADT URL`);
189
+ Object.assign(entryEnv, {
190
+ SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE: 'sap-gui-local',
191
+ SAP_AI_DEV_TOOLKIT_DESTINATION: String(destination.name),
192
+ SAP_URL: String(destination.url),
193
+ SAP_CLIENT: String(destination.client || '001'),
194
+ ...(destination.systemId ? { SAP_SYSTEM_ID: String(destination.systemId) } : {}),
195
+ ...(Array.isArray(destination.tlsServerNames) && destination.tlsServerNames.length ? { SAP_TLS_SERVER_NAMES: destination.tlsServerNames.join(',') } : {}),
196
+ ...(!Array.isArray(destination.tlsServerNames) && destination.tlsServerName ? { SAP_TLS_SERVER_NAME: String(destination.tlsServerName) } : {}),
197
+ ...(destination.childEnv || {})
198
+ });
174
199
  } else {
175
200
  entryEnv.SAP_AI_DEV_TOOLKIT_DESTINATION = String(destination.name);
176
201
  }
@@ -316,6 +341,12 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
316
341
  ...buildMcpEntries(destinations, env),
317
342
  ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [], { packageVersions: companionVersions })
318
343
  };
344
+ const generatedInputs = new Map();
345
+ for (const destination of destinations) {
346
+ for (const input of Array.isArray(destination?.inputs) ? destination.inputs : []) {
347
+ if (input?.id) generatedInputs.set(input.id, input);
348
+ }
349
+ }
319
350
  const launcherCommand = options.command || (destinations.length ? await resolveMcpServerCommand(env) : null);
320
351
  if (launcherCommand) {
321
352
  for (const entry of Object.values(generated)) {
@@ -325,12 +356,19 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
325
356
  }
326
357
  }
327
358
  const servers = Object.create(null);
359
+ const managedInputIds = new Set();
328
360
  for (const [name, entry] of Object.entries(config.servers)) {
329
361
  const managed = isPackageLauncher(entry)
330
362
  && typeof destinationValue(entry?.env) === 'string'
331
363
  && (destinationSource(entry.env) === 'cloud-foundry'
332
364
  ? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
333
- : destinationSource(entry.env) === undefined);
365
+ : (destinationSource(entry.env) === undefined || destinationSource(entry.env) === 'sap-gui-local'));
366
+ if (managed && destinationSource(entry.env) === 'sap-gui-local') {
367
+ for (const value of Object.values(entry.env || {})) {
368
+ const match = typeof value === 'string' ? value.match(/^\$\{input:([^}]+)}$/) : null;
369
+ if (match) managedInputIds.add(match[1]);
370
+ }
371
+ }
334
372
  if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
335
373
  }
336
374
  for (const name of Object.keys(generated)) {
@@ -339,6 +377,10 @@ export async function installMcpConfig(destinationsOrOptions, options = {}) {
339
377
  }
340
378
  }
341
379
  config.servers = { ...servers, ...generated };
380
+ if (generatedInputs.size || managedInputIds.size) {
381
+ const existingInputs = Array.isArray(config.inputs) ? config.inputs.filter(input => !generatedInputs.has(input?.id) && !managedInputIds.has(input?.id)) : [];
382
+ config.inputs = [...existingInputs, ...generatedInputs.values()];
383
+ }
342
384
  await writeConfig(path, config);
343
385
  return { path, servers: generated };
344
386
  }
package/src/mcp-proxy.mjs CHANGED
@@ -168,6 +168,7 @@ function closeDestinationRoute(destination) {
168
168
 
169
169
 
170
170
  function useProxyAuthentication(destination) {
171
+ if (destination.source === 'sap-gui-local') return destination.authentication === 'WindowsSSO';
171
172
  return destination.source !== 'cloud-foundry' || destination.authentication === 'PrincipalPropagation';
172
173
  }
173
174
 
package/src/setup.mjs CHANGED
@@ -1,9 +1,13 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
3
+ import { platform } from 'node:os';
4
+ import { isIP } from 'node:net';
5
+ import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
4
6
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
7
+ import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
5
8
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
9
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
10
+ import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
7
11
 
8
12
  const SETUP_COMMAND = 'sap-ai-dev --setup';
9
13
 
@@ -11,6 +15,24 @@ function print(output, message) {
11
15
  output.write(`${message}\n`);
12
16
  }
13
17
 
18
+ function windowsSsoSetupAvailable(env = process.env) {
19
+ return platform() === 'win32' || env.SAP_AI_DEV_TOOLKIT_ENABLE_WINDOWS_SSO_SETUP === 'true';
20
+ }
21
+
22
+ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout, env = process.env } = {}) {
23
+ if (!windowsSsoSetupAvailable(env)) return 'basic';
24
+ const answer = await textPrompt({
25
+ message: `Authentication for ${destination.name} (password/sso)`,
26
+ placeholder: 'password',
27
+ required: false,
28
+ validate: value => {
29
+ const candidate = String(value || 'password').trim().toLowerCase();
30
+ return ['password', 'basic', 'sso', 'windows-sso'].includes(candidate) || 'Enter password or sso.';
31
+ }
32
+ }, { input, output });
33
+ const normalized = String(answer || 'password').trim().toLowerCase();
34
+ return ['sso', 'windows-sso'].includes(normalized) ? 'windows-sso' : 'basic';
35
+ }
14
36
 
15
37
  async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
16
38
  const readline = createInterface({ input, output });
@@ -46,6 +68,26 @@ function safeBasDestination(destination) {
46
68
  };
47
69
  }
48
70
 
71
+ function safeLocalSapGuiDestination(destination) {
72
+ return {
73
+ source: 'sap-gui-local',
74
+ name: destination.name,
75
+ serverName: destination.serverName || destination.name,
76
+ url: destination.url,
77
+ host: destination.host,
78
+ systemId: destination.systemId,
79
+ instance: destination.instance,
80
+ client: destination.client || '001',
81
+ authentication: destination.authentication || 'Basic',
82
+ authMode: destination.authMode,
83
+ proxyType: 'Internet',
84
+ tlsServerName: destination.tlsServerName,
85
+ tlsServerNames: destination.tlsServerNames,
86
+ probe: safeProbe(destination.probe),
87
+ childEnv: destination.childEnv || {}
88
+ };
89
+ }
90
+
49
91
  function safeCloudFoundryDestination(destination) {
50
92
  const cf = destination.cf || {};
51
93
  return {
@@ -99,28 +141,41 @@ export async function runSetup({
99
141
  output = stdout,
100
142
  discover = discoverDestinations,
101
143
  discoverCf = discoverCloudFoundryDestinations,
144
+ discoverLocalSapGui = discoverSapGuiSystems,
102
145
  confirmCfImport = confirmCloudFoundryImport,
103
146
  install = installMcpConfig,
104
147
  includeSapDevelopmentToolsPrompt = false
105
148
  } = {}) {
106
- if (!env.H2O_URL) {
107
- print(output, formatStatus(`Setup skipped: H2O_URL is not set. Run ${SETUP_COMMAND} in a BAS dev space.`, 'info', output, 'SAP AI Dev Toolkit'));
149
+ const isBas = Boolean(env.H2O_URL);
150
+ if (!isBas && (!input.isTTY || !output.isTTY)) {
151
+ print(output, formatStatus('BAS destination setup was skipped because H2O_URL is not set.', 'info', output, 'SAP AI Dev Toolkit'));
108
152
  return { skipped: true, reason: 'non-bas' };
109
153
  }
110
154
  if (!input.isTTY || !output.isTTY) {
111
- print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a BAS terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
155
+ print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
112
156
  return { skipped: true, reason: 'non-tty' };
113
157
  }
114
158
  print(output, '');
115
- print(output, formatStatus('Contacting BAS to discover destinations; this may take a moment.', 'progress', output, 'Setup'));
159
+ print(output, formatStatus(isBas ? 'Contacting BAS to discover destinations; this may take a moment.' : 'Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
116
160
 
117
- let basDestinations;
118
- try {
119
- basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
120
- } catch (error) {
121
- throw new Error(`BAS discovery failed: ${error.message}`);
161
+ let destinations = [];
162
+ if (isBas) {
163
+ let basDestinations;
164
+ try {
165
+ basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
166
+ } catch (error) {
167
+ throw new Error(`BAS discovery failed: ${error.message}`);
168
+ }
169
+ destinations = basDestinations.map(safeBasDestination);
170
+ } else {
171
+ let localSystems;
172
+ try {
173
+ localSystems = await discoverLocalSapGui({ env });
174
+ } catch (error) {
175
+ throw new Error(`SAP GUI discovery failed: ${error.message}`);
176
+ }
177
+ destinations = localSystems.map(safeLocalSapGuiDestination);
122
178
  }
123
- const destinations = basDestinations.map(safeBasDestination);
124
179
  const warnings = [];
125
180
  let createdKeys = [];
126
181
  let configPath;
@@ -132,9 +187,9 @@ export async function runSetup({
132
187
  warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
133
188
  }
134
189
 
135
- const target = await getCloudFoundryTarget({ env });
190
+ const target = isBas ? await getCloudFoundryTarget({ env }) : { available: false, reason: 'Cloud Foundry import is only available in BAS setup.' };
136
191
  if (!target.available) {
137
- warnings.push(target.reason);
192
+ if (isBas) warnings.push(target.reason);
138
193
  } else {
139
194
  // Sweep for service keys this toolkit created but no config references
140
195
  // (an interrupted earlier setup leaves them behind; a Destination key
@@ -205,8 +260,8 @@ export async function runSetup({
205
260
  };
206
261
  const selectable = destinations.filter(destination => !destination.disabledReason);
207
262
  if (!selectable.length) {
208
- print(output, formatStatus('No selectable BAS or Cloud Foundry destinations were found.', 'warning', output, 'Setup'));
209
- print(output, remediation);
263
+ print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
264
+ print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
210
265
  await reportWarnings(warnings);
211
266
  const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
212
267
  await reportWarnings(cleanupWarnings);
@@ -222,9 +277,9 @@ export async function runSetup({
222
277
  const probe = destination.probe?.status || 'unknown';
223
278
  const source = destination.source === 'cloud-foundry'
224
279
  ? `CF ${destination.cf.destinationInstanceName}`
225
- : 'BAS';
280
+ : (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
226
281
  const disabled = destination.disabledReason;
227
- const state = disabled ? 'disabled' : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`);
282
+ const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
228
283
  return {
229
284
  value: destination,
230
285
  name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
@@ -245,6 +300,74 @@ export async function runSetup({
245
300
  shortcuts: { all: 'a' }
246
301
  }, { input, output });
247
302
 
303
+ if (!isBas && selected.length) {
304
+ print(output, '');
305
+ print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
306
+ if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
307
+ for (const destination of selected) {
308
+ const fallback = defaultAdtUrl(destination);
309
+ const url = await textPrompt({
310
+ message: `ADT URL for ${destination.name}`,
311
+ placeholder: fallback || 'https://host:44300',
312
+ validate: value => {
313
+ const candidate = value || fallback;
314
+ if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
315
+ try {
316
+ const parsed = new URL(candidate);
317
+ if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
318
+ if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
319
+ if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
320
+ return true;
321
+ }
322
+ catch { return 'Enter a valid URL.'; }
323
+ },
324
+ required: false
325
+ }, { input, output });
326
+ destination.url = url || fallback;
327
+ const parsedAdtUrl = new URL(destination.url);
328
+ if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
329
+ print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
330
+ let discoveredNames = [];
331
+ try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
332
+ catch { discoveredNames = []; }
333
+ if (discoveredNames.length) {
334
+ const preview = discoveredNames.slice(0, 5).join(', ');
335
+ const answer = await textPrompt({
336
+ message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${destination.name}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
337
+ placeholder: 'y',
338
+ required: false,
339
+ validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
340
+ }, { input, output });
341
+ if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
342
+ destination.tlsServerNames = discoveredNames;
343
+ destination.tlsServerName = discoveredNames[0];
344
+ }
345
+ } else {
346
+ print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
347
+ }
348
+ }
349
+ const authMode = await chooseLocalAuthMode(destination, { input, output, env });
350
+ destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
351
+ destination.authMode = authMode;
352
+ if (authMode === 'windows-sso') {
353
+ destination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
354
+ destination.inputs = [];
355
+ } else {
356
+ const userInputId = `sap-ai-dev-${destination.serverName || destination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
357
+ const passwordInputId = `sap-ai-dev-${destination.serverName || destination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
358
+ destination.childEnv = {
359
+ SAP_AUTH_MODE: 'basic',
360
+ SAP_USER: `\${input:${userInputId}}`,
361
+ SAP_PASSWORD: `\${input:${passwordInputId}}`
362
+ };
363
+ destination.inputs = [
364
+ { id: userInputId, type: 'promptString', description: `SAP user for ${destination.name}` },
365
+ { id: passwordInputId, type: 'promptString', description: `SAP password for ${destination.name}`, password: true }
366
+ ];
367
+ }
368
+ }
369
+ }
370
+
248
371
  let sapDevelopmentServers = [];
249
372
  if (includeSapDevelopmentToolsPrompt) {
250
373
  print(output, '');
@@ -269,18 +392,20 @@ export async function runSetup({
269
392
  const location = result?.path ? ` in ${result.path}` : '';
270
393
  print(output, '');
271
394
  const configuredCount = selected.length + sapDevelopmentServers.length;
272
- print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, 'BAS setup'));
395
+ print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
273
396
  printConnectionInstructions(output, result);
274
397
  let cleanupWarnings = [];
275
398
  const finalPath = result?.path || configPath;
276
- if (!finalPath) {
277
- cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
278
- } else {
279
- try {
280
- const finalConfig = await readMcpConfig(finalPath);
281
- cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
282
- } catch {
283
- cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
399
+ if (isBas) {
400
+ if (!finalPath) {
401
+ cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
402
+ } else {
403
+ try {
404
+ const finalConfig = await readMcpConfig(finalPath);
405
+ cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
406
+ } catch {
407
+ cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
408
+ }
284
409
  }
285
410
  }
286
411
  await reportWarnings(warnings);