2026-10-19 12:36:27 +08:00
/**
* @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).
*/
2026-11-05 09:19:43 +08:00
import { existsSync , readdirSync , statSync } from 'node:fs'
2026-10-19 12:36:27 +08:00
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' : {
2026-10-21 10:32:46 +08:00
// 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 : '*' ,
2026-10-19 12:36:27 +08:00
allowTransportableEdits : false ,
allowedPackages : '__READ_ONLY_NO_PACKAGE__' ,
allowExecution : false ,
allowBatchWrites : false ,
allowDebugger : false ,
allowDebugVariables : false ,
} ,
debug : {
2026-10-21 10:32:46 +08:00
// Same transport-read exception as read-only; the debugger/execution are
// what this mode adds.
enableTransports : true ,
allowedTransports : '*' ,
2026-10-19 12:36:27 +08:00
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 * : / . t e s t ( l i n e ) )
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 ,
}
}
2026-10-19 14:14:26 +08:00
/** Read the workspace's UI-only settings (whitelists + display labels). */
2026-10-19 12:36:27 +08:00
async function readUiSettings ( workspace ) {
2026-10-19 14:14:26 +08:00
const fallback = { ... WHITELIST _DEFAULTS , labels : { } }
2026-10-19 12:36:27 +08:00
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 ]
2026-10-19 14:14:26 +08:00
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 }
2026-10-19 12:36:27 +08:00
} catch {
return fallback
}
}
async function writeUiSettings ( workspace , values ) {
const payload = {
allowedPackages : values . allowedPackages || WHITELIST _DEFAULTS . allowedPackages ,
allowedTransports : values . allowedTransports || WHITELIST _DEFAULTS . allowedTransports ,
2026-10-19 14:14:26 +08:00
labels : values . labels && typeof values . labels === 'object' ? values . labels : { } ,
2026-10-19 12:36:27 +08:00
}
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 }
}
}
2026-11-05 09:19:43 +08:00
/**
* 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
}
2026-10-19 12:36:27 +08:00
async function stateOf ( { sessionId , workspace } ) {
2026-11-05 09:19:43 +08:00
const cwd =
workspace ||
resolveCwd ( sessionId ) ||
( await lastModifiedWorkspace ( ) ) ||
listWorkspaces ( ) [ 0 ] ? . path ||
process . cwd ( )
2026-10-19 12:36:27 +08:00
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 )
2026-10-19 14:14:26 +08:00
// 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 ] || '' ,
} ) )
2026-10-19 12:36:27 +08:00
return {
workspace : cwd ,
workspaces : listWorkspaces ( ) ,
file : { path : file , exists , bytes : raw . length } ,
chip : chipGate ( cwd , exists ) ,
2026-10-19 14:14:26 +08:00
destinations ,
labels ,
2026-10-19 12:36:27 +08:00
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 )
) {
2026-10-19 14:14:26 +08:00
sendJson ( res , 400 , {
ok : false ,
error : ` defaultDestination " ${ defaultDestination } " is not a destination name (available: demo, ${ destinations . map ( ( dest ) => dest . name ) . join ( ', ' ) || '(none)' } ) ` ,
} )
2026-10-19 12:36:27 +08:00
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 } )
2026-10-19 14:14:26 +08:00
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 )
2026-10-19 12:36:27 +08:00
await writeFile ( file , text , 'utf8' )
2026-10-19 14:14:26 +08:00
sendJson ( res , 200 , { ok : true , path : file , destinations : destinations . length , mode : existingMode , labels } )
2026-10-19 12:36:27 +08:00
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 ) {
2026-10-19 14:14:26 +08:00
// Preserve display labels when persisting whitelists.
await writeUiSettings ( workspace , { ... current , ... nextWhitelist } )
2026-10-19 12:36:27 +08:00
}
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 } ` )
}