commit d9cc954f37b2ef5c097ea12abd303c486788ae18 Author: QQ597901027 Date: Mon Oct 19 12:36:27 2026 +0800 feat: DSH ABAP 工作台插件(预设 + 配置页 + 状态条,依赖 abap-adt-dsh-plugin) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38b6363 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +.tmp*/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2adb8b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 QQ597901027 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..81e9e6d --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# DSH ABAP 工作台(dsh-abap-workbench) + +一个 DeepSeek Harness (DSH) **bundle 插件**,把 SAP ABAP 开发工作流装进 DSH: + +- **ABAP 预设「ABAP 工作台」**:新建会话时选择它,即挂载 `adt_*` 工具(32 个,经 ADT REST 访问 SAP); +- **三种运行模式**(按工作区切换,安全基线默认只读): + - **只读**:写/激活/删除/传输/执行/调试全部关闭,只做调查与补丁建议; + - **调试**:允许执行触发断点 + 调试器(`adt_debug`),写与传输仍关闭; + - **更改**:按白名单可写、允许传输(每次写需给请求号)、允许执行与调试器。 +- **可视化配置页**(设置 → ABAP ADT):工作区目的地增删改、连接测试、默认目的地、运行模式、可写白名单(`allowedPackages` / `allowedTransports`); +- **会话状态徽章**:仅在配置了 ABAP 的工作区显示(存在 `.dsh-abap-adt/destinations.yaml`),显示默认目的地可切换 + 当前模式徽章。 + +ADT 工具本体来自依赖包 [`@nefevcore/abap-adt-dsh-plugin`](https://www.npmjs.com/package/@nefevcore/abap-adt-dsh-plugin)(MIT),本插件只负责预设、策略、配置页与状态条。 + +--- + +## 安装(在另一台 DSH 上) + +前提:DSH ≥ `0.2.1-rc.2`、`pnpm` 在 PATH 上、已配置好 DSH 的模型与凭据。 + +### 方式一:从 Git 仓库安装(推荐) + +```bash +# 1. 克隆到任意工作目录 +git clone https://www.heyoung.top/QQ597901027/dsh-abap-workbench.git +cd dsh-abap-workbench +``` + +然后二选一: + +- **命令行**(DSH 已提供 plugin 子命令,转发给 profile 的 pnpm): + ```bash + dsh plugin --profile web add file:$(pwd) + ``` + 再把包名加入 profile 的 `dsh.profile.bundles`(见下方“手工登记”),或直接用方式二让 Plugin Manager 完成登记。 +- **Plugin Manager(推荐)**:把本仓库目录作为 bundle 安装: + ``` + plugin_manager { action: "install_bundle", target: "<本仓库绝对路径>" } + ``` + 它会完成 pnpm 安装 + bundle 登记;安装完成后**重启 DSH**。 + +### 方式二:从 npm/其它 registry(如果已发布) + +``` +plugin_manager { action: "install_bundle", target: "dsh-abap-workbench@0.1.0" } +``` + +### 手工登记(仅当自行 `pnpm add` 时) + +profile 的 `package.json`: + +```json +{ + "dependencies": { "dsh-abap-workbench": "file:<本仓库绝对路径>" }, + "dsh": { "profile": { "bundles": ["...", "dsh-abap-workbench"] } } +} +``` + +改完执行 `pnpm install`(关闭 DSH 后执行最稳,避免 Windows 上正在使用的原生模块被占用),再启动。 + +### 安装后 + +1. **重启 DSH**; +2. 新建会话 → 预设选择器里选 **“ABAP 工作台”**; +3. 打开 **设置 → ABAP ADT**: + - 填工作区与目的地(URL / client / 语言 / 用户名 / `passwordEnv`); + - 密码不要写明文:在 DSH 凭据库或环境变量里放 `ADT_DEV_PASSWORD` 之类的引用名; + - 点「测试连接」确认 ADT 服务可达(需在 SAP 侧 SICF 激活 `/sap/bc/adt`); + - 选择运行模式(默认只读),需要写时切到「更改」并配置白名单。 + +--- + +## 使用 + +### 目的地与凭据 + +- 目的地配置写在**工作区** `/.dsh-abap-adt/destinations.yaml`(按会话工作区隔离,工具调用时热读); +- 密码只写引用名(`passwordEnv`),真值放 DSH 凭据库(`~/.dsh/.credentials.yaml`)或进程环境变量; +- 内置 `demo` 目的地是 in-process mock ADT 服务器(127.0.0.1),可零配置演练;它始终按预设的只读基线执行。 + +### 运行模式 + +| 模式 | 策略 | 说明 | +|---|---|---| +| 只读 | `allowedPackages` 哨兵值、`enableTransports=false`、`allowTransportableEdits=false`、`allowExecution=false`、`allowDebugger=false` | 安全基线 | +| 调试 | 只读 + `allowExecution=true` + `allowDebugger=true` | 断点/单步/变量/栈 | +| 更改 | `allowTransportableEdits=true`、`enableTransports=true`、`allowExecution=true`、`allowDebugger=true`、白名单 | 写前 diff、传输请求纪律 | + +- 模式切换**只在设置页**,会话里不提供开关(安全); +- 只读/调试下白名单不生效但会保留,切回更改时复用; +- 白名单存在 `/.dsh-abap-adt/ui-settings.json`(本插件私有,abap-adt 不会读取)。 + +### 安全 + +- 只读 ≠ 无害:仍能读取 SAP 源码与业务数据,建议连开发/测试系统 + 最小权限账号; +- 「更改」模式是真实可写:`Z*`/`$TMP`(或你的白名单)可编辑/激活/执行/调试;传输释放没有工具,仍由人在 SE10 执行; +- 首次接入建议先把模式保持「只读」,验证完再切换。 + +--- + +## 目录结构 + +``` +cordis.patch.yml # bundle patch:插入 preset-abap-adt(预设)+ abap-adt-ui(本插件 Host 半) +index.js # Host 半:HTTP 接口 /abap-adt/{state,save,mode,default,ping},读写工作区配置 +client.js # Client 半:设置页(目的地/模式/白名单/连接测试)+ 会话状态徽章 +locale/{zh,en}.json# 插件显示名与描述 +icon.svg # 插件图标 +package.json # dsh.bundle / dsh.client 声明;依赖 @nefevcore/abap-adt-dsh-plugin +``` + +- 预设里的 `abap-adt` 行引用依赖包 `@nefevcore/abap-adt-dsh-plugin`(安装本插件时由 pnpm 拉取); +- 运行模式通过写入工作区策略生效,预设本身只带只读安全基线; +- `index.js` 只用 Node 内置模块,无需额外依赖。 + +## 开发 + +```bash +node --check index.js +node --check client.js +``` + +修改 `cordis.patch.yml` 后重启 DSH(bundle patch 在启动时应用)。若改了依赖版本,更新 `package.json` 后重新安装本 bundle。 + +## License + +MIT。ADT 工具本体版权归 [@nefevcore/abap-adt-dsh-plugin](https://github.com/nefevcore/dsh-adt) 作者(MIT)。 diff --git a/client.js b/client.js new file mode 100644 index 0000000..93c2d32 --- /dev/null +++ b/client.js @@ -0,0 +1,797 @@ +window.__ModuleLoader__.load({ + id: 'dsh-abap-workbench', + factory(require) { + const React = require('react') + const h = React.createElement + const { useCallback, useEffect, useMemo, useState } = React + + const NS = 'abap-adt-ui' + const API = '/abap-adt' + const DICTS = { + zh: { + sectionLabel: 'ABAP ADT', + title: 'ABAP ADT 配置', + subtitle: '管理当前工作区的 SAP ADT 目的地(写入 .dsh-abap-adt/destinations.yaml,立即生效)', + readOnlyBadge: '只读预设', + workspace: '工作区', + workspaceHint: '目的地文件写到该工作区的 .dsh-abap-adt/ 目录', + refresh: '刷新', + destinations: '目的地', + empty: '还没有配置目的地。内置 demo 模拟系统始终可用。', + add: '新增目的地', + name: '名称', + url: 'URL', + client: '客户端', + language: '语言', + username: '用户名', + passwordEnv: '密码引用', + passwordHint: '只填引用名(如 ADT_DEV_PASSWORD),真值存 DSH 凭据库或环境变量;不要写明文', + strictSSL: '严格 TLS', + profile: '环境', + description: '说明', + defaultDestination: '默认目的地', + save: '保存', + saving: '保存中…', + saved: '已保存', + modeTitle: '运行模式(本工作区)', + modes: { 'read-only': '只读', debug: '调试', change: '更改' }, + modeHints: { + 'read-only': '只读:写/激活/删除/传输/执行/调试全部关闭,只做调查与补丁建议。', + debug: '调试:允许执行以触发断点,可用 adt_debug 设断点/单步/看变量;写与传输仍关闭。', + change: '更改:按白名单可写(含传输,每次写需给请求号),允许执行与调试器;写前先给用户看 diff。', + }, + modeSwitched: '运行模式已切换', + whitelistTitle: '可写白名单(更改模式)', + allowedPackages: '允许编辑的包', + allowedPackagesHint: '逗号分隔的 glob,例如 Z*,$TMP,Y*;$TMP 为本地对象包', + allowedTransports: '允许的传输请求号', + allowedTransportsHint: '逗号分隔的 glob,例如 D01K96*;* 表示不限制(仍要求用户提供请求号)', + whitelistDisabled: '白名单只在「更改」模式生效;先切到更改模式再编辑', + applyWhitelist: '应用白名单', + whitelistSaved: '白名单已保存并写入策略', + policyTitle: '当前生效策略(来自 destinations.yaml 的 policy 段)', + policyNote: '运行模式由本工作区决定(上方切换),以 policy 段写入 destinations.yaml;预设本身只带安全基线(只读)。', + test: '测试连接', + testing: '测试中…', + remove: '删除', + loadError: '读取配置失败', + saveError: '保存失败', + chipPrefix: 'SAP', + chipDemo: 'demo', + chipDest: '个目的地', + chipReadOnly: '只读', + chipError: 'ADT 状态未知', + chipTitle: '切换该工作区的默认 ADT 目的地(作用于本工作区所有会话)', + chipSwitched: '已切换,下次 adt_* 调用生效', + chipSwitchFailed: '切换失败', + chipGateNote: '状态条只在对该工作区配置了 ABAP(存在 .dsh-abap-adt/destinations.yaml)时显示;可在插件配置 workspaces 白名单里进一步限定。', + fileLabel: '配置文件', + notExists: '(尚未创建,保存后生成)', + never: '—', + }, + en: { + sectionLabel: 'ABAP ADT', + title: 'ABAP ADT Settings', + subtitle: 'Manage SAP ADT destinations for this workspace (written to .dsh-abap-adt/destinations.yaml, applied immediately)', + readOnlyBadge: 'Read-only preset', + workspace: 'Workspace', + workspaceHint: 'The destinations file is written under .dsh-abap-adt/ in this workspace', + refresh: 'Refresh', + destinations: 'Destinations', + empty: 'No destinations configured yet. The built-in demo mock system is always available.', + add: 'Add destination', + name: 'Name', + url: 'URL', + client: 'Client', + language: 'Language', + username: 'User', + passwordEnv: 'Password ref', + passwordHint: 'Use a reference name (e.g. ADT_DEV_PASSWORD); the value lives in the DSH credential store or environment. Never paste a password here.', + strictSSL: 'Strict TLS', + profile: 'Profile', + description: 'Description', + defaultDestination: 'Default destination', + save: 'Save', + saving: 'Saving…', + saved: 'Saved', + modeTitle: 'Run mode (this workspace)', + modes: { 'read-only': 'Read-only', debug: 'Debug', change: 'Change' }, + modeHints: { + 'read-only': 'Read-only: writes, activation, deletion, transports, execution and the debugger are off; investigate and propose patches only.', + debug: 'Debug: execution is allowed to trigger breakpoints; adt_debug is available; writes and transports stay off.', + change: 'Change: whitelisted packages are writable (transports need the request number on every write), execution and the debugger are allowed; show a diff before writing.', + }, + modeSwitched: 'Run mode switched', + whitelistTitle: 'Write whitelists (change mode)', + allowedPackages: 'Editable packages', + allowedPackagesHint: 'Comma-separated globs, e.g. Z*,$TMP,Y*; $TMP is the local-objects package', + allowedTransports: 'Allowed transport numbers', + allowedTransportsHint: 'Comma-separated globs, e.g. D01K96*; * means unrestricted (the request number is still required from the user)', + whitelistDisabled: 'Whitelists only apply in Change mode; switch to Change first', + applyWhitelist: 'Apply whitelists', + whitelistSaved: 'Whitelists saved and written into the policy', + policyTitle: 'Effective policy (from the destinations.yaml policy block)', + policyNote: 'The run mode belongs to this workspace (switch above) and is written as a policy block into destinations.yaml; the preset itself only carries the read-only baseline.', + test: 'Test connection', + testing: 'Testing…', + remove: 'Remove', + policyTitle: 'Permission policy (locked by the preset)', + policyNote: 'The current session preset locks SAP writes (sentinel allowedPackages + transports/execution/debugger off). This page manages destinations only; switch to a writable preset to edit code.', + loadError: 'Failed to load configuration', + saveError: 'Save failed', + chipPrefix: 'SAP', + chipDemo: 'demo', + chipDest: 'dest(s)', + chipReadOnly: 'read-only', + chipError: 'ADT status unknown', + chipTitle: 'Switch this workspace default ADT destination (applies to every session in the workspace)', + chipSwitched: 'Switched — applies to the next adt_* call', + chipSwitchFailed: 'Switch failed', + chipGateNote: 'The status chip only appears where ABAP is configured for the workspace (.dsh-abap-adt/destinations.yaml exists); narrow it further with the plugin config workspaces allowlist.', + fileLabel: 'Config file', + notExists: ' (not created yet — save to create)', + never: '—', + }, + } + + const C = { + text: 'var(--dsw-alias-label-primary, #1f2328)', + muted: 'var(--dsw-alias-label-secondary, #6b7280)', + border: 'var(--dsw-alias-border-l1, rgba(128,128,128,.28))', + borderStrong: 'var(--dsw-alias-border-l2, rgba(128,128,128,.45))', + surface: 'var(--dsw-alias-bg-layer-1, #ffffff)', + surface2: 'var(--dsw-alias-bg-layer-2, #f5f6f7)', + brand: 'var(--dsw-alias-brand-primary, #0a6ed1)', + ok: 'var(--dsw-alias-state-success-primary, #1a7f37)', + err: 'var(--dsw-alias-state-error-primary, #cf222e)', + warn: 'var(--dsw-alias-state-warn-primary, #9a6700)', + } + + const inputStyle = { + boxSizing: 'border-box', + width: '100%', + padding: '6px 8px', + fontSize: 13, + color: C.text, + background: C.surface, + border: `1px solid ${C.border}`, + borderRadius: 6, + outline: 'none', + } + + const buttonStyle = (primary) => ({ + padding: '6px 12px', + fontSize: 13, + cursor: 'pointer', + borderRadius: 6, + border: `1px solid ${primary ? C.brand : C.borderStrong}`, + background: primary ? C.brand : 'transparent', + color: primary ? '#ffffff' : C.text, + }) + + const smallButtonStyle = { + padding: '4px 8px', + fontSize: 12, + cursor: 'pointer', + borderRadius: 5, + border: `1px solid ${C.border}`, + background: 'transparent', + color: C.text, + } + + async function api(path, init) { + const response = await fetch(`${API}${path}`, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + ...init, + }) + const payload = await response.json().catch(() => ({ ok: false, error: `HTTP ${response.status}` })) + if (!response.ok || payload.ok === false) throw new Error(payload.error || `HTTP ${response.status}`) + return payload + } + + function blankDestination() { + return { name: '', url: '', client: '100', language: 'EN', username: '', passwordEnv: '', strictSSL: false, profile: 'dev', description: '' } + } + + function Field({ label, hint, children }) { + return h( + 'label', + { style: { display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 } }, + h('span', { style: { fontSize: 12, color: C.muted } }, label), + children, + hint ? h('span', { style: { fontSize: 11, color: C.muted, lineHeight: 1.4 } }, hint) : null, + ) + } + + function Badge({ children, tone }) { + const toneColor = + tone === 'ok' ? C.ok : tone === 'warn' ? C.warn : tone === 'err' ? C.err : tone === 'brand' ? C.brand : C.muted + return h( + 'span', + { + style: { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: '2px 8px', + fontSize: 11, + fontWeight: 600, + borderRadius: 999, + border: `1px solid ${toneColor}`, + color: toneColor, + background: 'transparent', + whiteSpace: 'nowrap', + }, + }, + children, + ) + } + + function SettingsPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [workspace, setWorkspace] = useState('') + const [workspaces, setWorkspaces] = useState([]) + const [file, setFile] = useState(null) + const [defaultDestination, setDefaultDestination] = useState('') + const [destinations, setDestinations] = useState([]) + const [policy, setPolicy] = useState({}) + const [mode, setMode] = useState('read-only') + const [whitelist, setWhitelist] = useState(null) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [messageTone, setMessageTone] = useState('ok') + const [tests, setTests] = useState({}) + const [testing, setTesting] = useState({}) + + const load = useCallback(async (target) => { + setLoading(true) + setError('') + setMessage('') + try { + const query = new URLSearchParams() + if (target) query.set('workspace', target) + const payload = await api(`/state?${query.toString()}`) + setWorkspace(payload.workspace || '') + setWorkspaces(payload.workspaces || []) + setFile(payload.file || null) + setDefaultDestination(payload.defaultDestination || '') + setDestinations(payload.destinations || []) + setPolicy(payload.policy || {}) + setMode(payload.mode || 'read-only') + setWhitelist(payload.whitelist || null) + setTests({}) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const destinationNames = useMemo( + () => destinations.map((dest) => dest.name).filter((value) => value && value.trim()), + [destinations], + ) + + const updateDestination = (index, key, value) => { + setDestinations((rows) => rows.map((row, rowIndex) => (rowIndex === index ? { ...row, [key]: value } : row))) + } + + const addDestination = () => { + setDestinations((rows) => [...rows, blankDestination()]) + setMessage('') + } + + const removeDestination = (index) => { + setDestinations((rows) => rows.filter((_, rowIndex) => rowIndex !== index)) + setMessage('') + } + + const save = async () => { + setSaving(true) + setMessage('') + try { + // Round-trip only the fields this page edits: schema defaults such as + // timeoutMs must not be re-serialized from a display value. + const payloadDestinations = destinations.map((dest) => ({ + name: dest.name, + url: dest.url, + description: dest.description, + client: dest.client, + language: dest.language, + username: dest.username, + passwordEnv: dest.passwordEnv, + strictSSL: dest.strictSSL, + profile: dest.profile, + })) + const payload = await api('/save', { + method: 'POST', + body: JSON.stringify({ workspace, defaultDestination, destinations: payloadDestinations }), + }) + setMessage(`${DICTS.zh.saved}: ${payload.path}`) + setMessageTone('ok') + await load(workspace) + } catch (err) { + setMessage(`${DICTS.zh.saveError}: ${err.message}`) + setMessageTone('err') + } finally { + setSaving(false) + } + } + + const switchMode = async (next) => { + if (next === mode || !workspace) return + setSaving(true) + setMessage('') + try { + await api('/mode', { method: 'POST', body: JSON.stringify({ workspace, mode: next }) }) + await load(workspace) + setMessage(`${DICTS.zh.modeSwitched}: ${DICTS.zh.modes[next]}`) + setMessageTone('ok') + } catch (err) { + setMessage(`${DICTS.zh.saveError}: ${err.message}`) + setMessageTone('err') + } finally { + setSaving(false) + } + } + + const applyWhitelist = async () => { + if (!workspace || !whitelist) return + setSaving(true) + setMessage('') + try { + await api('/mode', { + method: 'POST', + body: JSON.stringify({ + workspace, + mode, + allowedPackages: whitelist.allowedPackages, + allowedTransports: whitelist.allowedTransports, + }), + }) + await load(workspace) + setMessage(DICTS.zh.whitelistSaved) + setMessageTone('ok') + } catch (err) { + setMessage(`${DICTS.zh.saveError}: ${err.message}`) + setMessageTone('err') + } finally { + setSaving(false) + } + } + + const test = async (dest) => { + const key = dest.name || `#${destinations.indexOf(dest) + 1}` + setTesting((map) => ({ ...map, [key]: true })) + try { + const payload = await api('/ping', { + method: 'POST', + body: JSON.stringify({ + url: dest.url, + client: dest.client, + language: dest.language, + username: dest.username, + passwordEnv: dest.passwordEnv, + strictSSL: dest.strictSSL, + }), + }) + setTests((map) => ({ ...map, [key]: payload.result })) + } catch (err) { + setTests((map) => ({ ...map, [key]: { ok: false, error: err.message, status: 0, ms: 0 } })) + } finally { + setTesting((map) => ({ ...map, [key]: false })) + } + } + + if (loading) { + return h('div', { style: { padding: 24, color: C.muted, fontSize: 13 } }, '…') + } + + const header = h( + 'div', + { style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, + h('div', { style: { fontSize: 18, fontWeight: 700, color: C.text } }, DICTS.zh.title), + h(Badge, { tone: mode === 'change' ? 'err' : mode === 'debug' ? 'brand' : 'warn' }, DICTS.zh.modes[mode] || mode), + h('div', { style: { flex: 1 } }), + h('button', { style: smallButtonStyle, onClick: () => load(workspace), type: 'button' }, DICTS.zh.refresh), + ) + + const errorBox = error + ? h( + 'div', + { + style: { + marginTop: 12, + padding: '10px 12px', + borderRadius: 8, + border: `1px solid ${C.err}`, + color: C.err, + fontSize: 13, + }, + }, + `${DICTS.zh.loadError}: ${error}`, + ) + : null + + const workspaceRow = h( + 'div', + { style: { display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12, marginTop: 16 } }, + h( + Field, + { label: DICTS.zh.workspace, hint: DICTS.zh.workspaceHint }, + h('input', { + style: inputStyle, + value: workspace, + list: 'abap-adt-workspaces', + onChange: (event) => setWorkspace(event.target.value), + onBlur: () => { + if (workspace && workspace !== file?.path?.replace(/[\\/]\.dsh-abap-adt[\\/].*$/, '')) load(workspace) + }, + placeholder: 'D:\\AIagent\\SAP', + }), + ), + h( + Field, + { label: DICTS.zh.fileLabel }, + h( + 'div', + { style: { fontSize: 12, color: C.muted, wordBreak: 'break-all', paddingTop: 6 } }, + file?.path ? `${file.path}${file.exists ? '' : DICTS.zh.notExists}` : DICTS.zh.never, + ), + ), + h( + 'datalist', + { id: 'abap-adt-workspaces' }, + workspaces.map((item) => h('option', { key: item.id || item.path, value: item.path }, item.title)), + ), + ) + + const destinationCards = destinations.length + ? destinations.map((dest, index) => { + const key = dest.name || `#${index + 1}` + const result = tests[key] + return h( + 'div', + { + key: index, + style: { + marginTop: 12, + padding: 12, + borderRadius: 10, + border: `1px solid ${C.border}`, + background: C.surface2, + display: 'flex', + flexDirection: 'column', + gap: 10, + }, + }, + h( + 'div', + { style: { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 } }, + h(Field, { label: DICTS.zh.name }, h('input', { style: inputStyle, value: dest.name || '', onChange: (e) => updateDestination(index, 'name', e.target.value), placeholder: 'dev' })), + h(Field, { label: DICTS.zh.url }, h('input', { style: inputStyle, value: dest.url || '', onChange: (e) => updateDestination(index, 'url', e.target.value), placeholder: 'https://sap.example.com:44300' })), + h(Field, { label: DICTS.zh.client }, h('input', { style: inputStyle, value: dest.client || '', onChange: (e) => updateDestination(index, 'client', e.target.value), placeholder: '100' })), + h(Field, { label: DICTS.zh.language }, h('input', { style: inputStyle, value: dest.language || '', onChange: (e) => updateDestination(index, 'language', e.target.value), placeholder: 'ZH' })), + ), + h( + 'div', + { style: { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 } }, + h(Field, { label: DICTS.zh.username }, h('input', { style: inputStyle, value: dest.username || '', onChange: (e) => updateDestination(index, 'username', e.target.value) })), + h(Field, { label: DICTS.zh.passwordEnv, hint: DICTS.zh.passwordHint }, h('input', { style: inputStyle, value: dest.passwordEnv || '', onChange: (e) => updateDestination(index, 'passwordEnv', e.target.value), placeholder: 'ADT_DEV_PASSWORD' })), + h(Field, { label: DICTS.zh.profile }, h('select', { style: inputStyle, value: dest.profile || 'dev', onChange: (e) => updateDestination(index, 'profile', e.target.value) }, ['dev', 'qa', 'prd'].map((value) => h('option', { key: value, value }, value)))), + h(Field, { label: DICTS.zh.description }, h('input', { style: inputStyle, value: dest.description || '', onChange: (e) => updateDestination(index, 'description', e.target.value) })), + ), + h( + 'div', + { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } }, + h( + 'label', + { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: C.muted } }, + h('input', { type: 'checkbox', checked: dest.strictSSL !== false, onChange: (e) => updateDestination(index, 'strictSSL', e.target.checked) }), + DICTS.zh.strictSSL, + ), + h('div', { style: { flex: 1 } }), + result + ? h( + 'span', + { style: { fontSize: 12, color: result.ok ? C.ok : C.err } }, + `${result.status || 'ERR'} · ${result.ms}ms${result.error ? ` · ${result.error}` : ''}`, + ) + : null, + h('button', { type: 'button', style: smallButtonStyle, onClick: () => test(dest), disabled: testing[key] }, testing[key] ? DICTS.zh.testing : DICTS.zh.test), + h('button', { type: 'button', style: smallButtonStyle, onClick: () => removeDestination(index) }, DICTS.zh.remove), + ), + ) + }) + : h('div', { style: { marginTop: 12, fontSize: 13, color: C.muted } }, DICTS.zh.empty) + + const policyRows = Object.entries(policy).length + ? Object.entries(policy).map(([key, value]) => + h( + 'div', + { key, style: { display: 'flex', gap: 10, fontSize: 12, padding: '3px 0' } }, + h('span', { style: { minWidth: 210, color: C.muted, fontFamily: 'ui-monospace, monospace' } }, key), + h('span', { style: { color: C.text, wordBreak: 'break-all' } }, Array.isArray(value) ? value.join(', ') || '(empty)' : String(value)), + ), + ) + : h('div', { style: { fontSize: 12, color: C.muted } }, '—') + + return h( + 'div', + { style: { padding: '20px 24px 40px', color: C.text, maxWidth: 1080 } }, + header, + h('div', { style: { marginTop: 6, fontSize: 12.5, color: C.muted } }, DICTS.zh.subtitle), + errorBox, + workspaceRow, + h('div', { style: { marginTop: 6, fontSize: 11.5, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.chipGateNote), + h( + 'div', + { + style: { + marginTop: 16, + padding: 12, + borderRadius: 10, + border: `1px solid ${C.border}`, + background: C.surface2, + }, + }, + h( + 'div', + { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, + h('span', { style: { fontSize: 13, fontWeight: 650 } }, DICTS.zh.modeTitle), + ['read-only', 'debug', 'change'].map((value) => + h( + 'button', + { + key: value, + type: 'button', + disabled: saving || !workspace, + onClick: () => switchMode(value), + style: { ...buttonStyle(value === mode), padding: '5px 14px' }, + }, + DICTS.zh.modes[value], + ), + ), + h('div', { style: { flex: 1 } }), + h(Badge, { tone: mode === 'change' ? 'err' : mode === 'debug' ? 'brand' : 'warn' }, DICTS.zh.modes[mode] || mode), + ), + h('div', { style: { marginTop: 8, fontSize: 12, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.modeHints[mode] || ''), + whitelist + ? h( + 'div', + { style: { marginTop: 12, paddingTop: 12, borderTop: `1px solid ${C.border}` } }, + h('div', { style: { fontSize: 12.5, fontWeight: 600, marginBottom: 8 } }, DICTS.zh.whitelistTitle), + h( + 'div', + { style: { display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 12, alignItems: 'end' } }, + h( + Field, + { + label: DICTS.zh.allowedPackages, + hint: whitelist.editable ? DICTS.zh.allowedPackagesHint : DICTS.zh.whitelistDisabled, + }, + h('input', { + style: inputStyle, + value: whitelist.allowedPackages || '', + disabled: !whitelist.editable || saving, + onChange: (event) => setWhitelist((value) => ({ ...value, allowedPackages: event.target.value })), + }), + ), + h( + Field, + { + label: DICTS.zh.allowedTransports, + hint: whitelist.editable ? DICTS.zh.allowedTransportsHint : DICTS.zh.whitelistDisabled, + }, + h('input', { + style: inputStyle, + value: whitelist.allowedTransports || '', + disabled: !whitelist.editable || saving, + onChange: (event) => setWhitelist((value) => ({ ...value, allowedTransports: event.target.value })), + }), + ), + h( + 'button', + { + type: 'button', + style: buttonStyle(true), + disabled: !whitelist.editable || saving, + onClick: applyWhitelist, + }, + DICTS.zh.applyWhitelist, + ), + ), + ) + : null, + ), + h( + 'div', + { style: { marginTop: 24, display: 'flex', alignItems: 'center', gap: 10 } }, + h('div', { style: { fontSize: 15, fontWeight: 650 } }, DICTS.zh.destinations), + h('div', { style: { flex: 1 } }), + h('button', { type: 'button', style: smallButtonStyle, onClick: addDestination }, `+ ${DICTS.zh.add}`), + ), + destinationCards, + h( + 'div', + { style: { marginTop: 16, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } }, + h( + Field, + { label: DICTS.zh.defaultDestination }, + h( + 'select', + { style: inputStyle, value: defaultDestination, onChange: (e) => setDefaultDestination(e.target.value) }, + h('option', { value: '' }, 'demo'), + destinationNames.map((value) => h('option', { key: value, value }, value)), + ), + ), + ), + h( + 'div', + { style: { marginTop: 16, display: 'flex', alignItems: 'center', gap: 12 } }, + h('button', { type: 'button', style: buttonStyle(true), onClick: save, disabled: saving || !workspace }, saving ? DICTS.zh.saving : DICTS.zh.save), + message ? h('span', { style: { fontSize: 12, color: messageTone === 'err' ? C.err : C.ok, wordBreak: 'break-all' } }, message) : null, + ), + h( + 'div', + { style: { marginTop: 28, padding: 14, borderRadius: 10, border: `1px solid ${C.border}`, background: C.surface2 } }, + h('div', { style: { fontSize: 14, fontWeight: 650 } }, DICTS.zh.policyTitle), + h('div', { style: { marginTop: 6, fontSize: 12.5, color: C.muted, lineHeight: 1.5 } }, DICTS.zh.policyNote), + h('div', { style: { marginTop: 10 } }, policyRows), + ), + ) + } + + function StatusChip(props) { + const sessionId = props?.sessionId + const [state, setState] = useState(null) + const [failed, setFailed] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState('') + + useEffect(() => { + let alive = true + const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : '' + api(`/state${query}`) + .then((payload) => { + if (alive) setState(payload) + }) + .catch(() => { + if (alive) setFailed(true) + }) + return () => { + alive = false + } + }, [sessionId]) + + if (failed) return null + if (!state) return null + + // Workspace gate: only workspaces that actually have an ABAP + // destinations file (or are explicitly allowlisted on the Host) get the + // chip — unrelated workspaces and modes stay clean. + const enabled = state.chip ? state.chip.enabled : Boolean(state.file?.exists) + if (!enabled) return null + + const destinations = state.destinations || [] + const names = destinations.map((dest) => dest.name).filter(Boolean) + const selected = state.defaultDestination || 'demo' + const options = ['demo', ...names.filter((name) => name !== 'demo')] + const modeText = DICTS.zh.modes[state.mode] + const modeBadge = modeText + ? { text: modeText, tone: state.mode === 'change' ? 'err' : state.mode === 'debug' ? 'brand' : 'warn' } + : null + + const switchDefault = async (value) => { + if (value === selected) return + setBusy(true) + setMessage('') + try { + await api('/default', { + method: 'POST', + body: JSON.stringify({ workspace: state.workspace, defaultDestination: value }), + }) + setState((previous) => (previous ? { ...previous, defaultDestination: value } : previous)) + setMessage(DICTS.zh.chipSwitched) + } catch (err) { + setMessage(`${DICTS.zh.chipSwitchFailed}: ${err.message}`) + } finally { + setBusy(false) + } + } + + return h( + 'span', + { + title: `${DICTS.zh.chipTitle}\n${state.file?.path || ''}`, + style: { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '3px 8px', + borderRadius: 999, + border: `1px solid ${C.border}`, + background: 'transparent', + fontSize: 11.5, + color: C.muted, + whiteSpace: 'nowrap', + userSelect: 'none', + }, + }, + h('span', { style: { width: 6, height: 6, borderRadius: '50%', background: names.length ? C.ok : C.warn } }), + h('span', { style: { fontSize: 11.5, color: C.muted } }, DICTS.zh.chipPrefix), + h( + 'select', + { + value: selected, + disabled: busy, + onChange: (event) => switchDefault(event.target.value), + style: { + appearance: 'none', + border: 'none', + background: 'transparent', + color: C.text, + fontSize: 11.5, + fontWeight: 600, + padding: '0 2px', + cursor: busy ? 'progress' : 'pointer', + maxWidth: 170, + }, + }, + options.map((name) => h('option', { key: name, value: name }, name)), + ), + names.length ? h('span', { style: { fontSize: 11, color: C.muted } }, `${names.length} ${DICTS.zh.chipDest}`) : null, + modeBadge ? h(Badge, { tone: modeBadge.tone }, modeBadge.text) : null, + message ? h('span', { style: { fontSize: 10.5, color: C.muted, maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis' } }, message) : null, + ) + } + + return { + inject: ['slots'], + apply(ctx) { + let t = (key) => DICTS.zh[key] ?? key + try { + if (ctx.locale) { + const disposeLocale = ctx.locale.register(NS, { en: DICTS.en, zh: DICTS.zh }) + ctx.effect?.(() => disposeLocale) + const bound = ctx.locale.bind(NS) + t = (key, params) => bound(key, params) + } + } catch (err) { + // Locale is optional: fall back to the shipped Chinese strings. + console.warn('abap-adt-ui: locale registration failed', err) + } + + ctx.slots.inject('settings.section', () => + ctx.slots.register( + { + name: 'settings.section', + id: 'abap-adt', + order: 30, + label: () => t('sectionLabel'), + }, + SettingsPage, + ), + ) + + ctx.slots.inject('conversation.input.left', () => + ctx.slots.register( + { + name: 'conversation.input.left', + id: 'abap-adt-status', + order: 40, + label: () => t('chipPrefix'), + }, + StatusChip, + ), + ) + }, + } + }, +}) diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..3cbe236 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,193 @@ +# ABAP 工作台预设(DSH ≥ 0.2.0 声明行格式)。 +# +# 只有一个 @deepseek-ai/dsh-agent-preset 声明,避免预设选择器被多个 ABAP 预设占满。 +# 运行模式(只读 / 调试 / 更改)不放预设里,而是按工作区写入 +# /.dsh-abap-adt/destinations.yaml 的 policy 键: +# - 只读(默认,继承下方安全基线) +# - 调试:allowExecution: true, allowDebugger: true +# - 更改:enableTransports: true, allowTransportableEdits: true, allowDebugger: true, +# allowExecution: true;allowedPackages / allowedTransports 取工作区白名单 +# (默认 Z*,$TMP 与 *,可在设置页编辑) +# 切换入口:设置 → ABAP ADT → 运行模式;状态条徽章同步显示当前模式。 +# +# 只有选择本预设的会话才挂载 adt_* 工具;全局/standard 会话不挂载。 +# 只读 ≠ 无害:仍能读取 SAP 源码与业务数据。建议连开发/测试系统 + 最小权限账号。 +- insert: + - id: preset-abap-adt + name: '@deepseek-ai/dsh-agent-preset' + config: + id: abap-adt + name: ABAP 工作台 + description: >- + 单个 SAP ABAP 预设:只读 / 调试 / 更改三种运行模式由当前工作区的权限策略决定, + 在「设置 → ABAP ADT → 运行模式」切换;工具集固定,模式只影响策略。 + order: 5 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + prefix: |- + 你是 SAP ABAP 工作台代理(只读 / 调试 / 更改三种模式由当前工作区的策略决定),运行在 DeepSeek Harness 上(模型 {{model}}),通过 adt_* 工具经 ADT REST 访问 SAP 系统。当前工作目录见运行时上下文。 + + 第一步永远是:adt_permissions 查看当前生效策略,adt_list_destinations 看目的地;引用目的地必须用精确名字(拼错会报错,不会静默落到默认系统)。模式以 adt_permissions 的结果为准,不要凭猜测——同一条指令在不同工作区可能是不同模式。 + + 三种模式与纪律: + 1) 只读模式(写/激活/删除/传输/执行/调试均被拒):只做调查与解释,输出结论 + 证据 + 建议补丁;遇到 [POLICY] 拒绝时如实告诉用户,不要换参数重试,可建议用户在该工作区的设置页把模式切到「调试」或「更改」。 + 2) 调试模式(只读 + 允许执行 + 调试器):先用只读手段定位(adt_search / adt_object_read / adt_where_used / adt_dumps);adt_debug 是唯一调试工具:先 action:"setBreakpoint",再触发代码路径,然后 action:"listen" 长轮询;命中后用 variables / stack / step(stepInto/stepOver/stepReturn/stepContinue)检查;结束必须 action:"detach"——一个目的地一个调试会话,detach 后不能重连。断点只对目标用户的客户代码生效,命中一次后要重新设置;用 adt_execute 触发程序或 if_oo_adt_classrun 类。调试变量写值默认关闭,需要时由用户显式开启。 + 3) 更改模式(按当前工作区白名单可写(默认 Z* 与 $TMP)、允许传输、允许执行与调试器):传输请求纪律最高优先——对可传输包的任何修改前先问用户要请求号(adt_transports {status:"modifiable"} 可列出打开请求),每次写操作显式传 transport 参数,绝不让后端自己开 task;$TMP 本地包不需要请求号;包归属不清楚时先问。释放传输是人的决定,没有对应工具,也不主动提议。写前必须先给用户看 Before/After diff(结构类型用字段级 patch,源码类型给完整新源码)并等确认;读后改:adt_object_read 建快照,adt_object_edit 在锁下校验,[CONFLICT] 重新读重新改,persisted:false 视为硬失败;顺序 check(adt_check)→ lock → write → activate,激活不级联(主程序与 include 要在一次 adt_activate 里),HTTP 200 不等于激活成功。验证阶梯:adt_run_unit_tests → adt_run_atc → adt_execute;数据用 adt_data_preview(ABAP SQL:DESCENDING、无 LIMIT、length/offset 分页)。做到 release-ready 为止,报告改了哪些对象、记到哪个请求号、check/激活/单测/ATC 结论。 + + 通用规则:先读后答,不臆测对象内容;列表输出自带截断说明,只有「没有说明的空列表」才是真为空;结构化类型(DOMA/DTEL/TTYP/MSAG)读作 JSON;用户要改动时给可直接应用的补丁(Before/After 或 unified diff);被 [POLICY] 拒绝时说清是哪条策略并建议切换模式,不要绕过或反复重试。 + - id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + - id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' + - id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + - id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false + - id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: command-goal + name: '@deepseek-ai/dsh-command-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + - id: planning + name: cordis:group + group: true + isolate: + planMode: true + config: + - id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs to exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + - id: compaction + name: cordis:group + group: true + isolate: + compaction: true + toolResultPruner: true + config: + - id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + - id: command-compact + name: '@deepseek-ai/dsh-command-compact' + - id: tool-result-pruner + name: '@deepseek-ai/dsh-compaction-tool-result-pruner' + config: + thresholdChars: 8192 + headChars: 4096 + tailChars: 1024 + - id: delegation + name: cordis:group + group: true + isolate: + workflowEngine: true + config: + - id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + - id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + - id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + modelSelectionSettings: true + backgroundMode: continuable + - id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + backgroundMode: continuable + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + disabled: true + config: + subagentProvider: spawn + maxRounds: 64 + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + fetch: true + searchTimeoutMs: 60000 + - id: present + name: '@deepseek-ai/dsh-tool-present' + - id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true +# ============ ABAP 工具与安全基线(Read-Only baseline)============ +# 预设只带「安全基线」:写/激活/删除/传输/执行/调试全部关闭。 +# 运行模式由工作区文件 /.dsh-abap-adt/destinations.yaml 的 policy +# 覆盖(该工作区内的目的地按工作区策略执行): +# 只读 = 继承本基线;调试 = allowExecution + allowDebugger; +# 更改 = allowTransportableEdits + enableTransports + allowedPackages Z*,$TMP。 +# 切换入口:设置 → ABAP ADT → 运行模式(写回工作区文件,立即生效)。 + - id: abap-adt + name: '@nefevcore/abap-adt-dsh-plugin' + config: + demo: true + defaultDestination: demo + enableTransports: false + allowTransportableEdits: false + allowedPackages: "__READ_ONLY_NO_PACKAGE__" + allowedTransports: "__READ_ONLY_NO_TRANSPORT__" + allowExecution: false + allowBatchWrites: false + allowDebugger: false + allowDebugVariables: false + blockedTablesProfile: 'off' + destinations: [] + - id: abap-adt-ui + name: 'dsh-abap-workbench' + config: {} diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..827f7a2 --- /dev/null +++ b/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..233044d --- /dev/null +++ b/index.js @@ -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 + * `/.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}`) +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..8e1b823 --- /dev/null +++ b/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ABAP ADT Settings", + "description": "Visual configuration for SAP ABAP ADT: per-workspace destinations, default system, connection test, read-only policy summary, and a session status badge." + } +} diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..cd1e8a8 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ABAP ADT 配置", + "description": "可视化配置 SAP ABAP ADT:工作区目的地增删改、默认系统、连接测试,以及只读策略说明与会话内状态徽章。" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc90b4a --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "dsh-abap-workbench", + "version": "0.1.0", + "private": false, + "description": "SAP ABAP 工作台:一个 DSH bundle,内含 ABAP 预设(只读/调试/更改,按工作区策略切换)、可视化配置页(目的地、连接测试、运行模式、可写白名单)和会话状态徽章。依赖 @nefevcore/abap-adt-dsh-plugin 提供 adt_* 工具。", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./locale/*.json": "./locale/*.json", + "./icon": "./icon.svg" + }, + "files": [ + "index.js", + "client.js", + "cordis.patch.yml", + "locale/*.json", + "icon.svg", + "README.md", + "LICENSE" + ], + "dependencies": { + "@nefevcore/abap-adt-dsh-plugin": "^0.12.2" + }, + "dsh": { + "engines": { + "dsh": ">=0.2.1-rc.2" + }, + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-conversation" + ] + } + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://www.heyoung.top/QQ597901027/dsh-abap-workbench.git" + }, + "keywords": [ + "dsh", + "dsh-plugin", + "deepseek-harness", + "sap", + "abap", + "adt" + ] +}