feat: DSH ABAP 工作台插件(预设 + 配置页 + 状态条,依赖 abap-adt-dsh-plugin)
This commit is contained in:
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* @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); defaults when absent. */
|
||||
async function readUiSettings(workspace) {
|
||||
const fallback = { ...WHITELIST_DEFAULTS }
|
||||
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]
|
||||
return { allowedPackages: pick('allowedPackages'), allowedTransports: pick('allowedTransports') }
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
async function writeUiSettings(workspace, values) {
|
||||
const payload = {
|
||||
allowedPackages: values.allowedPackages || WHITELIST_DEFAULTS.allowedPackages,
|
||||
allowedTransports: values.allowedTransports || WHITELIST_DEFAULTS.allowedTransports,
|
||||
}
|
||||
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)
|
||||
return {
|
||||
workspace: cwd,
|
||||
workspaces: listWorkspaces(),
|
||||
file: { path: file, exists, bytes: raw.length },
|
||||
chip: chipGate(cwd, exists),
|
||||
destinations: normalizeDestinations(parsed.destinations),
|
||||
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` })
|
||||
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 whitelist = await readUiSettings(workspace)
|
||||
const text = setModeInText(emitDestinations({ defaultDestination, destinations }), existingMode, whitelist)
|
||||
await writeFile(file, text, 'utf8')
|
||||
sendJson(res, 200, { ok: true, path: file, destinations: destinations.length, mode: existingMode })
|
||||
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) {
|
||||
await writeUiSettings(workspace, 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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user