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.
- package/README.md +16 -9
- package/package.json +1 -1
- package/scripts/postinstall.mjs +12 -4
- package/scripts/windows-negotiate-helper.ps1 +182 -0
- package/src/install-user-assets.mjs +45 -5
- package/src/launcher.mjs +60 -4
- package/src/local-sap-gui.mjs +217 -0
- package/src/mcp-config.mjs +47 -5
- package/src/mcp-proxy.mjs +1 -0
- package/src/setup.mjs +151 -26
- package/src/tls-adt-proxy.mjs +122 -0
- package/src/windows-sso-adt-proxy.mjs +224 -0
|
@@ -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(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/&/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
|
+
}
|
package/src/mcp-config.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
basDestinations
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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);
|