889 lines
34 KiB
JavaScript
889 lines
34 KiB
JavaScript
/**
|
|
* @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 } 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': {
|
|
enableTransports: false,
|
|
allowedTransports: '__READ_ONLY_NO_TRANSPORT__',
|
|
allowTransportableEdits: false,
|
|
allowedPackages: '__READ_ONLY_NO_PACKAGE__',
|
|
allowExecution: false,
|
|
allowBatchWrites: false,
|
|
allowDebugger: false,
|
|
allowDebugVariables: false,
|
|
},
|
|
debug: {
|
|
enableTransports: false,
|
|
allowedTransports: '__READ_ONLY_NO_TRANSPORT__',
|
|
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}`)
|
|
}
|