Files
dsh-abap-workbench/index.js
T

939 lines
36 KiB
JavaScript
Raw Normal View History

/**
* @local/dsh-abap-adt-ui — Host half.
*
* Visual configuration surface for @nefevcore/abap-adt-dsh-plugin:
* - reads/writes the workspace destinations file
* `<workspace>/.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, statSync } 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 }
}
}
/**
* The workspace whose destinations file was written most recently. An empty
* destinations list (a scratch config, or one where every system was removed)
* loses to any workspace that still lists systems, so the settings page never
* opens on an empty page while a usable config exists elsewhere.
*/
async function lastModifiedWorkspace() {
let best
let bestTime = -Infinity
let any
let anyTime = -Infinity
for (const row of listWorkspaces()) {
if (!row.path) continue
for (const fileName of FILE_NAMES) {
const candidate = join(row.path, CONFIG_DIR, fileName)
let mtimeMs
try {
mtimeMs = statSync(candidate).mtimeMs
} catch {
continue
}
if (mtimeMs > anyTime) {
anyTime = mtimeMs
any = row.path
}
if (mtimeMs <= bestTime) continue
try {
const parsed = await parseDestinations(await readFile(candidate, 'utf8'))
if (!normalizeDestinations(parsed.destinations).length) continue
} catch {
continue
}
bestTime = mtimeMs
best = row.path
}
}
return best || any
}
async function stateOf({ sessionId, workspace }) {
const cwd =
workspace ||
resolveCwd(sessionId) ||
(await lastModifiedWorkspace()) ||
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}`)
}