/** * @local/dsh-abap-adt-ui — Host half. * * Visual configuration surface for @nefevcore/abap-adt-dsh-plugin: * - reads/writes the workspace destinations file * `/.dsh-abap-adt/destinations.yaml` (the nearest config layer * the abap-adt plugin re-reads on every adt_* tool call, so edits apply * instantly without a restart); * - tests an ADT endpoint (GET /sap/bc/adt/discovery with Basic auth); * - exposes everything through three plain HTTP routes consumed by the * Client half (settings page + composer status chip). * * No npm dependencies: only node builtins, so a workspace-linked bundle * resolves without its own node_modules. Password values are never accepted: * the page only stores a `passwordEnv` reference, which is resolved through * the DSH credential service (falling back to the process environment). */ import { existsSync, readdirSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { request as httpRequest } from 'node:http' import { request as httpsRequest } from 'node:https' import { isAbsolute, join } from 'node:path' import { pathToFileURL } from 'node:url' const name = 'abap-adt-ui' const CONFIG_DIR = '.dsh-abap-adt' const FILE_NAMES = ['destinations.yaml', 'destinations.yml'] const DESTINATION_KEYS = [ 'name', 'url', 'description', 'client', 'language', 'username', 'passwordEnv', 'strictSSL', 'timeoutMs', 'profile', ] const POLICY_KEYS = [ 'enableTransports', 'allowedTransports', 'allowTransportableEdits', 'allowedPackages', 'allowExecution', 'allowBatchWrites', 'blockedTablesProfile', 'blockedTables', 'allowedTables', 'allowDebugger', 'allowDebugVariables', ] /** Routes are prefix-matched so the Client can add query strings freely. */ const ROUTE_PREFIX = '/abap-adt' /** * Run modes a workspace can switch between. Each mode is written as top-level * policy keys into that workspace's destinations.yaml, which the abap-adt * plugin applies to the destinations defined in the same file (nearest layer). * The preset keeps a read-only baseline, so an untouched workspace stays safe. */ const MODE_POLICIES = { 'read-only': { // Transport READS stay available in read-only mode: `enableTransports` // gates the transport tool family (adt_transports / adt_object_versions — // both read-only tools); writes are blocked by the package sentinel below // plus allowTransportableEdits=false. enableTransports: true, allowedTransports: '*', allowTransportableEdits: false, allowedPackages: '__READ_ONLY_NO_PACKAGE__', allowExecution: false, allowBatchWrites: false, allowDebugger: false, allowDebugVariables: false, }, debug: { // Same transport-read exception as read-only; the debugger/execution are // what this mode adds. enableTransports: true, allowedTransports: '*', allowTransportableEdits: false, allowedPackages: '__READ_ONLY_NO_PACKAGE__', allowExecution: true, allowBatchWrites: false, allowDebugger: true, allowDebugVariables: false, }, change: { enableTransports: true, allowedTransports: '*', allowTransportableEdits: true, allowedPackages: 'Z*,$TMP', allowExecution: true, allowBatchWrites: false, // Change mode also carries the debugger: editing code needs the debugger. allowDebugger: true, allowDebugVariables: false, }, } /** Defaults for the change-mode whitelists, editable from the settings page. */ const WHITELIST_DEFAULTS = { allowedPackages: 'Z*,$TMP', allowedTransports: '*' } /** Per-workspace UI-only settings (never read by the abap-adt plugin). */ const UI_SETTINGS_FILE = 'ui-settings.json' const MANAGED_MODE_START = '# >>> ABAP 运行模式(由设置页「ABAP ADT」维护,切换模式会重写本段)' const MANAGED_MODE_END = '# <<< ABAP 运行模式' const MANAGED_MODE_KEYS = Object.keys(MODE_POLICIES['read-only']) /** Derive the workspace mode from the policy actually present in its file. */ function modeFromPolicy(policy) { if (policy?.allowTransportableEdits === true) return 'change' if (policy?.allowDebugger === true || policy?.allowExecution === true) return 'debug' return 'read-only' } export const inject = ['webServer'] export function apply(ctx, config) { const cfg = config ?? {} const log = ctx.logger?.(name) ?? console const profileDir = cfg.profileDir || process.env.DSH_PROFILE_DIR || '' /** * Candidate paths of the abap-adt core entry, first existing wins. The Host * process does not necessarily inherit DSH_PROFILE_DIR, so fall back to the * DSH home layout and scan the profile roots for an installed core. */ function coreEntryCandidates() { const suffix = 'node_modules/@nefevcore/abap-adt-core/lib/index.js' const candidates = [] if (cfg.coreEntry) candidates.push(cfg.coreEntry) const home = process.env.USERPROFILE || process.env.HOME || '' const dshHome = process.env.DSH_HOME || (home ? join(home, '.dsh') : '') const profilesRoot = dshHome ? join(dshHome, 'profiles') : '' if (profileDir) candidates.push(join(profileDir, suffix)) if (profilesRoot) { if (process.env.DSH_PROFILE) candidates.push(join(profilesRoot, process.env.DSH_PROFILE, suffix)) try { for (const entry of readdirSync(profilesRoot)) candidates.push(join(profilesRoot, entry, suffix)) } catch { /* no profiles directory */ } } return [...new Set(candidates)].filter((candidate) => candidate && existsSync(candidate)) } // The abap-adt core is imported lazily and cached: it is a heavy module // (abaplint) and only needed when the page actually reads an existing file. let corePromise function loadCore() { if (!corePromise) { const [entry] = coreEntryCandidates() corePromise = entry ? import(pathToFileURL(entry).href).catch((err) => { log.warn?.(`abap-adt-ui: core import failed: ${err.message}`) return undefined }) : Promise.resolve(undefined) } return corePromise } // ---------------------------------------------------------------- YAML ---- const tokens = ['true', 'false', 'null', '~'] function yamlScalar(value) { if (value === true || value === false) return String(value) if (typeof value === 'number' && Number.isFinite(value)) return String(value) if (value === null || value === undefined) return "''" const text = String(value) if (text === '') return "''" // Keep numeric-looking values (SAP client '100', system numbers) as // strings: the abap-adt schema declares them as strings, and an unquoted // 100 would parse as a number. if (/^-?\d+(\.\d+)?$/.test(text)) return `'${text}'` if (/^[A-Za-z0-9_@.$*?\-/]+$/.test(text) && !tokens.includes(text.toLowerCase())) { // A leading `*`/`?` would be a YAML alias/indicator; quote those. if (!/^[*?&!|>%@`{]/.test(text)) return text } return `'${text.replace(/'/g, "''")}'` } function parseScalar(raw) { let text = String(raw ?? '').trim() if (text === '') return '' if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) { return text.slice(1, -1).replace(/''/g, "'") } if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) { try { return JSON.parse(text) } catch { return text.slice(1, -1) } } const lower = text.toLowerCase() if (lower === 'true') return true if (lower === 'false') return false if (lower === 'null' || lower === '~') return null return text } /** * Parse the destinations file. The abap-adt core's own parser is used when * it is resolvable (full schema compatibility, including files the plugin * itself generated); otherwise this tolerant line parser handles the format * this page writes plus ordinary hand edits. */ async function parseDestinations(raw) { const core = await loadCore() if (core?.parseExternalConfigText) { try { const parsed = core.parseExternalConfigText(raw) const layer = parsed?.config ?? parsed?.layer ?? parsed if (layer && typeof layer === 'object') { return { destinations: Array.isArray(layer.destinations) ? layer.destinations : [], defaultDestination: layer.defaultDestination, policy: pickPolicy(layer), parser: 'core', } } } catch (err) { log.warn?.(`abap-adt-ui: core parse failed, using fallback: ${err.message}`) } } return { ...fallbackParse(raw), parser: 'fallback' } } function pickPolicy(layer) { const policy = {} for (const key of POLICY_KEYS) { if (layer[key] !== undefined && layer[key] !== null) policy[key] = layer[key] } return policy } function fallbackParse(raw) { const result = { destinations: [], defaultDestination: undefined, policy: {} } let current let inDestinations = false for (const rawLine of raw.split(/\r?\n/)) { const line = rawLine.replace(/\s+$/, '') if (!line.trim() || line.trim().startsWith('#')) continue const indent = line.match(/^\s*/)[0].length const body = line.trim() if (indent === 0) { current = undefined inDestinations = false const m = body.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/) if (!m) continue const [, key, rest] = m if (key === 'destinations') { inDestinations = true if (rest.trim() === '[]') result.destinations = [] continue } if (key === 'defaultDestination') { result.defaultDestination = parseScalar(rest) continue } if (POLICY_KEYS.includes(key)) { result.policy[key] = parseScalar(rest) } continue } if (!inDestinations) continue const item = body.match(/^-\s*(.*)$/) if (item) { current = {} result.destinations.push(current) const m = item[1].trim().match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/) if (m) current[m[1]] = parseScalar(m[2]) continue } const pair = body.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/) if (pair && current) current[pair[1]] = parseScalar(pair[2]) } return result } function emitDestinations({ defaultDestination, destinations, policy }) { const lines = [ '# ABAP ADT 目的地配置 — 由 DSH 设置页「ABAP ADT」维护,也可手动编辑。', '# abap-adt 插件在每次 adt_* 工具调用时重读本文件,改动无需重启。', '# 密码只写引用名(passwordEnv),真值走 DSH 凭据库或进程环境变量。', '', ] if (defaultDestination) lines.push(`defaultDestination: ${yamlScalar(defaultDestination)}`) if (!destinations.length) { lines.push('destinations: []') } else { lines.push('destinations:') for (const dest of destinations) { lines.push(` - name: ${yamlScalar(dest.name)}`) for (const key of DESTINATION_KEYS) { if (key === 'name') continue const value = dest[key] if (value === undefined || value === null || value === '') continue lines.push(` ${key}: ${yamlScalar(value)}`) } } } for (const key of POLICY_KEYS) { const value = policy?.[key] if (value === undefined || value === null || value === '') continue if (Array.isArray(value)) { if (!value.length) continue lines.push(`${key}:`) for (const item of value) lines.push(` - ${yamlScalar(item)}`) } else { lines.push(`${key}: ${yamlScalar(value)}`) } } return `${lines.join('\n')}\n` } /** * Change only the top-level `defaultDestination` line of an existing file, * preserving the rest of the text (comments, ordering, hand edits). Used by * the composer status chip, which switches the default without rewriting the * destination list. */ function setDefaultInText(raw, value) { const lines = raw.split(/\r?\n/) const index = lines.findIndex((line) => /^defaultDestination\s*:/.test(line)) const rendered = value ? `defaultDestination: ${yamlScalar(value)}` : undefined if (index >= 0) { if (rendered) lines[index] = rendered else lines.splice(index, 1) } else if (rendered) { // Insert after the leading comment/blank header, before the first key. let insertAt = 0 for (let i = 0; i < lines.length; i += 1) { const text = lines[i].trim() if (text === '' || text.startsWith('#')) insertAt = i + 1 else break } lines.splice(insertAt, 0, rendered, '') } return lines.join('\n') } /** * Rewrite the managed run-mode policy block. Any previous managed block and * any top-level copies of the managed keys are removed first so the YAML can * never contain duplicate keys (the abap-adt parser rejects those). */ /** The policy actually written for a mode, with the change-mode whitelists. */ function policyForMode(mode, whitelist = {}) { const base = MODE_POLICIES[mode] if (!base) throw new Error(`unknown mode: ${mode}`) if (mode !== 'change') return { ...base } return { ...base, allowedPackages: whitelist.allowedPackages || WHITELIST_DEFAULTS.allowedPackages, allowedTransports: whitelist.allowedTransports || WHITELIST_DEFAULTS.allowedTransports, } } /** Read the workspace's UI-only settings (whitelists + display labels). */ async function readUiSettings(workspace) { const fallback = { ...WHITELIST_DEFAULTS, labels: {} } try { const raw = await readFile(join(workspace, CONFIG_DIR, UI_SETTINGS_FILE), 'utf8') const parsed = JSON.parse(raw) const pick = (key) => typeof parsed?.[key] === 'string' && parsed[key].trim() ? parsed[key].trim() : fallback[key] const labels = {} if (parsed?.labels && typeof parsed.labels === 'object') { for (const [name, label] of Object.entries(parsed.labels)) { if (typeof name === 'string' && name && typeof label === 'string' && label.trim()) { labels[name] = label.trim().slice(0, 80) } } } return { allowedPackages: pick('allowedPackages'), allowedTransports: pick('allowedTransports'), labels } } catch { return fallback } } async function writeUiSettings(workspace, values) { const payload = { allowedPackages: values.allowedPackages || WHITELIST_DEFAULTS.allowedPackages, allowedTransports: values.allowedTransports || WHITELIST_DEFAULTS.allowedTransports, labels: values.labels && typeof values.labels === 'object' ? values.labels : {}, } await mkdir(join(workspace, CONFIG_DIR), { recursive: true }) await writeFile(join(workspace, CONFIG_DIR, UI_SETTINGS_FILE), `${JSON.stringify(payload, null, 2)}\n`, 'utf8') return payload } /** * Rewrite the managed run-mode policy block. Any previous managed block and * any top-level copies of the managed keys are removed first so the YAML can * never contain duplicate keys (the abap-adt parser rejects those). */ function setModeInText(raw, mode, whitelist = {}) { const policy = policyForMode(mode, whitelist) const lines = raw.split(/\r?\n/) const start = lines.findIndex((line) => line.trim() === MANAGED_MODE_START) const end = lines.findIndex((line) => line.trim() === MANAGED_MODE_END) if (start >= 0 && end > start) lines.splice(start, end - start + 1) const managed = new RegExp(`^(?:${MANAGED_MODE_KEYS.join('|')})\\s*:`) const body = lines.filter((line) => !managed.test(line)).join('\n').replace(/\n+$/, '') const block = [ MANAGED_MODE_START, ...MANAGED_MODE_KEYS.map((key) => `${key}: ${yamlScalar(policy[key])}`), MANAGED_MODE_END, ].join('\n') return `${body}\n\n${block}\n` } function samePath(a, b) { const norm = (value) => String(value ?? '') .trim() .replace(/[/\\]+$/, '') .replace(/\//g, '\\') .toLowerCase() return norm(a) === norm(b) } /** * Whether the composer status chip should appear for this workspace. By * default it only shows where ABAP is actually configured (a destinations * file exists); an explicit `workspaces` config allowlist narrows that * further so unrelated workspaces stay clean. */ function chipGate(cwd, exists) { const allow = Array.isArray(cfg.workspaces) ? cfg.workspaces.filter((value) => typeof value === 'string' && value.trim()) : [] if (!allow.length) { return { enabled: exists, reason: exists ? 'workspace-configured' : 'workspace-not-configured' } } const allowed = allow.some((value) => samePath(value, cwd)) return { enabled: exists && allowed, reason: allowed ? (exists ? 'workspace-configured' : 'workspace-not-configured') : 'workspace-not-allowed', } } // ------------------------------------------------------------ workspace ---- function workspaceFile(workspace) { return join(workspace, CONFIG_DIR, FILE_NAMES[0]) } function existingWorkspaceFile(workspace) { for (const fileName of FILE_NAMES) { const candidate = join(workspace, CONFIG_DIR, fileName) if (existsSync(candidate)) return candidate } return workspaceFile(workspace) } function resolveCwd(sessionId) { try { const agent = ctx.get?.('agents')?.get?.(sessionId) const cwd = agent?.session?.header?.cwd if (typeof cwd === 'string' && cwd) return cwd } catch { /* optional service */ } return undefined } function listWorkspaces() { try { const registry = ctx.get?.('workspaceRegistry') const rows = registry?.list?.() ?? [] return rows.map((row) => ({ id: String(row.id ?? ''), path: String(row.path ?? ''), title: String(row.title ?? row.path ?? ''), })) } catch { return [] } } /** Session preset id → ABAP mode, used by the composer chip badge. */ function presetInfoOf(sessionId) { const modes = cfg.modes ?? { 'abap-adt': 'read-only', 'abap-adt-debug': 'debug', 'abap-adt-change': 'change', } try { const agent = ctx.get?.('agents')?.get?.(sessionId) if (!agent) return { presetId: undefined, mode: undefined } const registry = ctx.get?.('agentPresets') let presetId = registry?.composedPreset?.(agent.ctx) if (typeof presetId !== 'string' || !presetId) { presetId = agent?.session?.header?.agentPreset ?? agent?.agentPreset } if (typeof presetId !== 'string' || !presetId) return { presetId: undefined, mode: undefined } return { presetId, mode: typeof modes === 'object' ? modes[presetId] : undefined } } catch { return { presetId: undefined, mode: undefined } } } async function stateOf({ sessionId, workspace }) { const cwd = workspace || resolveCwd(sessionId) || listWorkspaces()[0]?.path || process.cwd() const file = existingWorkspaceFile(cwd) let raw = '' let exists = false try { raw = await readFile(file, 'utf8') exists = true } catch { /* first run: no file yet */ } const parsed = exists ? await parseDestinations(raw) : { destinations: [], defaultDestination: undefined, policy: {}, parser: 'none' } const presetInfo = presetInfoOf(sessionId) const mode = modeFromPolicy(parsed.policy) const whitelist = await readUiSettings(cwd) // Display labels are UI-only: attach the Chinese label beside the stable // destination name so the page/chip can show it without touching the plugin // schema (which accepts only the known destination keys). const labels = whitelist.labels || {} const destinations = normalizeDestinations(parsed.destinations).map((dest) => ({ ...dest, label: labels[dest.name] || '', })) return { workspace: cwd, workspaces: listWorkspaces(), file: { path: file, exists, bytes: raw.length }, chip: chipGate(cwd, exists), destinations, labels, defaultDestination: parsed.defaultDestination || '', policy: parsed.policy || {}, parser: parsed.parser, presetId: presetInfo.presetId, mode, // Change-mode whitelists are UI-only settings; only the change mode // writes them into the plugin policy, so they are editable just there. whitelist: { allowedPackages: whitelist.allowedPackages, allowedTransports: whitelist.allowedTransports, defaults: { ...WHITELIST_DEFAULTS }, editable: mode === 'change' && exists, }, preset: { // The policy itself is pinned by the session's ABAP preset row; this // page only displays it. Keep the wording deployment-neutral. note: String( cfg.policyNote ?? '运行模式由本工作区决定(上方切换),以 policy 段写入 destinations.yaml;预设本身只带安全基线(只读)。', ), }, } } function normalizeDestinations(rows) { return (Array.isArray(rows) ? rows : []).map((row) => { const out = {} for (const key of DESTINATION_KEYS) { if (row?.[key] !== undefined) out[key] = row[key] } return out }) } // ---------------------------------------------------------------- HTTP ----- function sendJson(res, status, payload) { const body = JSON.stringify(payload) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }) res.end(body) } async function readJson(req) { let text = '' for await (const chunk of req) { text += chunk if (text.length > 512 * 1024) throw new Error('request body too large') } return text ? JSON.parse(text) : {} } function validateDestinations(input) { const rows = Array.isArray(input) ? input : [] if (rows.length > 50) throw new Error('too many destinations (max 50)') const names = new Set() const cleaned = [] for (const [index, row] of rows.entries()) { if (!row || typeof row !== 'object') throw new Error(`destination #${index + 1}: invalid entry`) const dest = {} for (const key of DESTINATION_KEYS) { if (row[key] === undefined || row[key] === null || row[key] === '') continue dest[key] = typeof row[key] === 'boolean' ? row[key] : String(row[key]) } if (!dest.name) throw new Error(`destination #${index + 1}: name is required`) if (!/^[A-Za-z0-9_.-]{1,40}$/.test(dest.name)) { throw new Error(`destination "${dest.name}": name may only contain letters, digits, _ . - (max 40)`) } if (names.has(dest.name.toLowerCase())) throw new Error(`duplicate destination name: ${dest.name}`) names.add(dest.name.toLowerCase()) if (!dest.url) throw new Error(`destination "${dest.name}": url is required`) let parsedUrl try { parsedUrl = new URL(dest.url) } catch { throw new Error(`destination "${dest.name}": url is not a valid absolute URL`) } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { throw new Error(`destination "${dest.name}": url must be http(s)`) } if (typeof dest.strictSSL === 'string') { dest.strictSSL = dest.strictSSL === 'true' ? true : dest.strictSSL === 'false' ? false : dest.strictSSL if (typeof dest.strictSSL !== 'boolean') throw new Error(`destination "${dest.name}": strictSSL must be boolean`) } // timeoutMs is a NUMBER in the abap-adt schema; a quoted value makes the // whole workspace file invalid for the plugin, so normalize it here. if (dest.timeoutMs !== undefined) { const timeoutMs = Number(dest.timeoutMs) if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error(`destination "${dest.name}": timeoutMs must be a positive number`) } dest.timeoutMs = timeoutMs } if (dest.profile && !['dev', 'qa', 'prd'].includes(String(dest.profile))) { throw new Error(`destination "${dest.name}": profile must be dev, qa or prd`) } cleaned.push(dest) } return cleaned } async function resolvePassword(ref) { if (!ref) return undefined try { const credentials = ctx.get?.('credentials') if (credentials?.resolve) { const resolved = await credentials.resolve(ref) if (resolved?.value) return resolved.value } } catch { /* fall through to env */ } return process.env[ref] } function pingAdt(options) { return new Promise((resolve) => { let target try { target = new URL(options.url) } catch { resolve({ ok: false, status: 0, ms: 0, error: 'invalid url' }) return } const basePath = target.pathname.replace(/\/+$/, '') const discoveryPath = basePath.endsWith('/sap/bc/adt') ? `${basePath}/discovery` : `${basePath}/sap/bc/adt/discovery` const query = new URLSearchParams() if (options.client) query.set('sap-client', String(options.client)) if (options.language) query.set('sap-language', String(options.language)) const suffix = query.toString() const path = suffix ? `${discoveryPath}?${suffix}` : discoveryPath const isHttps = target.protocol === 'https:' const requestFn = isHttps ? httpsRequest : httpRequest const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 15000 const started = Date.now() const headers = { Accept: 'application/xml, text/plain, */*', 'User-Agent': 'dsh-abap-adt-ui' } if (options.username && options.password !== undefined) { headers.Authorization = `Basic ${Buffer.from(`${options.username}:${options.password}`).toString('base64')}` } const req = requestFn( { hostname: target.hostname, port: target.port || (isHttps ? 443 : 80), path, method: 'GET', headers, timeout: timeoutMs, rejectUnauthorized: options.strictSSL !== false, }, (res) => { res.resume() res.on('end', () => { const status = res.statusCode ?? 0 resolve({ ok: status >= 200 && status < 500 && status !== 401, status, ms: Date.now() - started, // 401 already proves the endpoint is alive; surface it distinctly. error: status === 401 ? 'unauthorized (endpoint reachable, check user/password)' : undefined, }) }) }, ) req.on('timeout', () => req.destroy(new Error(`timeout after ${timeoutMs}ms`))) req.on('error', (err) => resolve({ ok: false, status: 0, ms: Date.now() - started, error: err.message })) req.end() }) } async function handle(req, res) { const url = new URL(req.url ?? '/', 'http://localhost') const route = url.pathname.startsWith(ROUTE_PREFIX) ? url.pathname.slice(ROUTE_PREFIX.length) : '' try { if (req.method === 'GET' && route === '/state') { const payload = await stateOf({ sessionId: url.searchParams.get('sessionId') || undefined, workspace: url.searchParams.get('workspace') || undefined, }) sendJson(res, 200, { ok: true, ...payload }) return } if (req.method === 'GET' && route === '/workspaces') { sendJson(res, 200, { ok: true, workspaces: listWorkspaces() }) return } if (req.method === 'POST' && route === '/save') { const body = await readJson(req) const workspace = String(body.workspace ?? '') if (!workspace || !isAbsolute(workspace) || !existsSync(workspace)) { sendJson(res, 400, { ok: false, error: 'workspace must be an existing absolute directory' }) return } const destinations = validateDestinations(body.destinations) const defaultDestination = String(body.defaultDestination ?? '') // `demo` is the plugin's built-in in-process mock destination and is // always valid even though it never appears in the destinations list. if ( defaultDestination && defaultDestination !== 'demo' && !destinations.some((dest) => dest.name === defaultDestination) ) { sendJson(res, 400, { ok: false, error: `defaultDestination "${defaultDestination}" is not a destination name (available: demo, ${destinations.map((dest) => dest.name).join(', ') || '(none)'})`, }) return } const file = existingWorkspaceFile(workspace) // Preserve the current run mode: mode lives in its own managed block. let existingMode = 'read-only' if (existsSync(file)) { try { existingMode = modeFromPolicy((await parseDestinations(await readFile(file, 'utf8'))).policy) } catch { /* keep the safe baseline */ } } await mkdir(join(workspace, CONFIG_DIR), { recursive: true }) const uiSettings = await readUiSettings(workspace) // Display labels: keep only labels of destinations that still exist. const names = new Set(destinations.map((dest) => dest.name)) let labels = uiSettings.labels || {} if (body.labels && typeof body.labels === 'object') { labels = {} for (const [name, label] of Object.entries(body.labels)) { if (typeof name === 'string' && name && typeof label === 'string' && label.trim()) { labels[name] = label.trim().slice(0, 80) } } } labels = Object.fromEntries(Object.entries(labels).filter(([name]) => names.has(name))) await writeUiSettings(workspace, { ...uiSettings, labels }) const text = setModeInText(emitDestinations({ defaultDestination, destinations }), existingMode, uiSettings) await writeFile(file, text, 'utf8') sendJson(res, 200, { ok: true, path: file, destinations: destinations.length, mode: existingMode, labels }) return } if (req.method === 'POST' && route === '/mode') { const body = await readJson(req) const workspace = String(body.workspace ?? '') if (!workspace || !isAbsolute(workspace) || !existsSync(workspace)) { sendJson(res, 400, { ok: false, error: 'workspace must be an existing absolute directory' }) return } const mode = String(body.mode ?? '') if (!MODE_POLICIES[mode]) { sendJson(res, 400, { ok: false, error: `mode must be one of ${Object.keys(MODE_POLICIES).join(', ')}` }) return } const file = existingWorkspaceFile(workspace) let raw = '' try { raw = await readFile(file, 'utf8') } catch { throw new Error('该工作区还没有 destinations.yaml,请先保存目的地配置') } const current = await readUiSettings(workspace) const nextWhitelist = { allowedPackages: typeof body.allowedPackages === 'string' && body.allowedPackages.trim() ? body.allowedPackages.trim() : current.allowedPackages, allowedTransports: typeof body.allowedTransports === 'string' && body.allowedTransports.trim() ? body.allowedTransports.trim() : current.allowedTransports, } for (const [key, value] of Object.entries(nextWhitelist)) { if (value.length > 400 || /[\r\n]/.test(value)) { sendJson(res, 400, { ok: false, error: `${key} must be a single line under 400 characters` }) return } } if (body.allowedPackages !== undefined || body.allowedTransports !== undefined) { // Preserve display labels when persisting whitelists. await writeUiSettings(workspace, { ...current, ...nextWhitelist }) } await writeFile(file, setModeInText(raw, mode, nextWhitelist), 'utf8') sendJson(res, 200, { ok: true, path: file, mode, policy: policyForMode(mode, nextWhitelist), whitelist: nextWhitelist, }) return } if (req.method === 'POST' && route === '/default') { const body = await readJson(req) const workspace = String(body.workspace ?? '') if (!workspace || !isAbsolute(workspace) || !existsSync(workspace)) { sendJson(res, 400, { ok: false, error: 'workspace must be an existing absolute directory' }) return } const next = String(body.defaultDestination ?? '') const file = existingWorkspaceFile(workspace) let raw = '' try { raw = await readFile(file, 'utf8') } catch { throw new Error('该工作区还没有 destinations.yaml,请先在「设置 → ABAP ADT」里保存配置') } const parsed = await parseDestinations(raw) const names = normalizeDestinations(parsed.destinations).map((dest) => dest.name) if (next && next !== 'demo' && !names.includes(next)) { sendJson(res, 400, { ok: false, error: `defaultDestination "${next}" is not a destination name (have: demo, ${names.join(', ') || '(none)'})` }) return } await writeFile(file, setDefaultInText(raw, next), 'utf8') sendJson(res, 200, { ok: true, path: file, defaultDestination: next }) return } if (req.method === 'POST' && route === '/ping') { const body = await readJson(req) if (!body.url) { sendJson(res, 400, { ok: false, error: 'url is required' }) return } const password = await resolvePassword(String(body.passwordEnv ?? body.passwordRef ?? '')) const result = await pingAdt({ url: String(body.url), client: body.client, language: body.language, username: body.username, password, strictSSL: body.strictSSL, timeoutMs: body.timeoutMs, }) sendJson(res, 200, { ok: true, result }) return } sendJson(res, 404, { ok: false, error: `unknown route: ${req.method} ${url.pathname}` }) } catch (err) { log.warn?.(`abap-adt-ui: ${req.method} ${url.pathname} failed: ${err.message}`) sendJson(res, 400, { ok: false, error: err.message }) } } const dispose = ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }) ctx.effect?.(() => dispose) log.info?.(`abap-adt-ui: routes registered under ${ROUTE_PREFIX}`) }