修:draw-ui / oil-motion 原被当子模块指针收录 ⇒ 改为正常文件入库(两份内容原先对别人是空的)

一、问题(本轮实测)
`draw-ui` 与 `oil-motion` 目录里**各自带一个内嵌 `.git`** ⇒ 上一次提交把它们记成了 **gitlink(子模块指针)**
⇒ 仓库里只存了一个不属于任何远端的 commit id,**别人克隆下来这两份是空的** ✗(`git status` 显示 ` m draw-ui` / ` m oil-motion` = 子模块内容有改动)。

二、处置(可回退)
· 把两处的 `.git` **挪走**(⛔ 不是删除)⇒ `归档/内嵌git-20261008/{draw-ui,oil-motion}.git`;
· `git rm --cached` 掉那两个 gitlink,再 `git add` 两个目录 ⇒ **按正常文件入库**(内容才真的进仓库)。

三、副作用(如实记)
挪走 `.git` 后,这两个技能**不能再原地 `git pull` 取上游更新**(要更新得重新拉一份覆盖);
如需恢复其本地仓库,把 `归档/内嵌git-20261008/` 里的 `.git` 挪回原处即可。
This commit is contained in:
admin committed 2026-10-08 22:29:52 +08:00
1 parent e03465c398
commit 237a09a5b0
161 files changed
+19429 -2

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]] $ArgsList
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pythonScript = Join-Path $scriptDir "generate_image.py"
$python = if ($env:DRAW_PYTHON) { $env:DRAW_PYTHON } else { "python" }
$framePath = ""
$remaining = [System.Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $ArgsList.Count; $i++) {
if ($ArgsList[$i] -eq "--frame") {
if ($i + 1 -ge $ArgsList.Count) {
throw "--frame requires a path"
}
$framePath = $ArgsList[$i + 1]
$i++
continue
}
$remaining.Add($ArgsList[$i])
}
$commandArgs = @($pythonScript)
if ($framePath) {
$commandArgs += @("--ref", $framePath)
}
$commandArgs += $remaining.ToArray()
& $python @commandArgs
exit $LASTEXITCODE
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_DIR="${DRAW_VENV:-$HOME/.cache/draw/venv}"
PROVIDER="${DRAW_PROVIDER:-zenmux}"
RAW_ARGS=("$@")
for ((i = 0; i < ${#RAW_ARGS[@]}; i++)); do
case "${RAW_ARGS[$i]}" in
--provider)
if [[ $((i + 1)) -ge ${#RAW_ARGS[@]} ]]; then
echo "[ERROR] --provider requires zenmux or codex." >&2
exit 2
fi
PROVIDER="${RAW_ARGS[$((i + 1))]}"
((i += 1))
;;
--provider=*)
PROVIDER="${RAW_ARGS[$i]#--provider=}"
;;
esac
done
case "$PROVIDER" in
codex)
PYTHON_BIN="${DRAW_PYTHON:-python3}"
;;
zenmux)
PYTHON_BIN="${DRAW_PYTHON:-$VENV_DIR/bin/python3}"
if [[ ! -x "$PYTHON_BIN" ]]; then
mkdir -p "$VENV_DIR"
python3 -m venv "$VENV_DIR"
fi
if ! "$PYTHON_BIN" -c "import google.genai, PIL" >/dev/null 2>&1; then
PIP_DISABLE_PIP_VERSION_CHECK=1 "$PYTHON_BIN" -m pip install --quiet --upgrade pip google-genai pillow
fi
;;
*)
echo "[ERROR] Invalid provider '$PROVIDER'; expected zenmux or codex." >&2
exit 2
;;
esac
# --frame <path>: prepend a frame reference image before all other --ref args.
# Usage: ask_draw.sh --frame /path/to/frame.png [other args...]
# This injects --ref <frame> at the front so the frame is always ref[0].
FRAME_PATH=""
REMAINING_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--frame)
FRAME_PATH="$2"
shift 2
;;
*)
REMAINING_ARGS+=("$1")
shift
;;
esac
done
if [[ -n "$FRAME_PATH" ]]; then
exec "$PYTHON_BIN" "$SCRIPT_DIR/generate_image.py" --ref "$FRAME_PATH" "${REMAINING_ARGS[@]}"
else
exec "$PYTHON_BIN" "$SCRIPT_DIR/generate_image.py" "${REMAINING_ARGS[@]}"
fi
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""按生成前的分段顺序拼接等宽长页,不裁切或缩放原图。"""
from __future__ import annotations
import argparse
import json
import os
import tempfile
from pathlib import Path
from PIL import Image
def assemble(manifest: Path, output: Path, *, force: bool = False) -> tuple[int, int]:
data = json.loads(manifest.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("清单必须是JSON对象")
expected, sections = data.get("expected_sections"), data.get("sections")
if not isinstance(expected, list) or not expected or any(not isinstance(x, str) or not x.strip() for x in expected):
raise ValueError("expected_sections必须是非空ID列表")
if len(set(expected)) != len(expected):
raise ValueError("expected_sections存在重复ID")
if not isinstance(sections, list) or any(not isinstance(x, dict) for x in sections):
raise ValueError("sections必须是包含id和image的列表")
if [x.get("id") for x in sections] != expected:
raise ValueError("实际分段缺失、重复或顺序与expected_sections不符")
if output.suffix.lower() != ".png":
raise ValueError("完整长页输出必须使用.png扩展名")
if output.exists() and not force:
raise FileExistsError(f"输出已存在:{output};确认替换后才使用--force")
images = []
seen_paths = set()
try:
for section in sections:
raw = section.get("image")
if not isinstance(raw, str) or not raw.strip():
raise ValueError(f"分段{section['id']}缺少image路径")
path = (manifest.parent / raw).resolve()
if path == output.resolve() or path in seen_paths:
raise ValueError("不能重复使用同一图片或覆盖输入图片")
seen_paths.add(path)
with Image.open(path) as im:
im.load()
if images and im.width != images[0].width:
raise ValueError(f"分段{section['id']}宽度{im.width}与首段{images[0].width}不一致;不会自动缩放")
images.append(im.convert("RGBA"))
size = (images[0].width, sum(im.height for im in images))
with Image.new("RGBA", size) as page:
offset = 0
for im in images:
page.paste(im, (0, offset))
offset += im.height
output.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=".full-page-", suffix=".png", dir=output.parent)
os.close(fd)
temporary = Path(temp_name)
try:
page.save(temporary, format="PNG")
if force:
os.replace(temporary, output)
else:
# 同目录硬链接原子拒绝覆盖,避免检查后输出被其他任务创建。
os.link(temporary, output)
finally:
temporary.unlink(missing_ok=True)
return size
finally:
for im in images:
im.close()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
try:
width, height = assemble(args.manifest.resolve(), args.output.resolve(), force=args.force)
except (OSError, ValueError, KeyError, Image.DecompressionBombError) as exc:
parser.exit(1, f"[错误] {exc}\n")
print(f"output_path={args.output.resolve()}")
print(f"size={width}x{height}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""检查图片的实际透明度;不会抠图、修改图片或判断构图质量。"""
import argparse
import json
from pathlib import Path
from PIL import Image
def inspect_alpha(path):
with Image.open(path) as im:
native_alpha = "A" in im.getbands() or "transparency" in im.info
histogram = im.convert("RGBA").getchannel("A").histogram()
total = im.width * im.height
transparent = histogram[0]
partial = sum(histogram[1:255])
opaque = histogram[255]
return {
"path": str(Path(path).resolve()),
"mode": im.mode,
"size": [im.width, im.height],
"has_alpha_or_transparency_metadata": native_alpha,
"transparent_pixels": transparent,
"partial_alpha_pixels": partial,
"opaque_pixels": opaque,
"nonopaque_fraction": (transparent + partial) / total,
"has_visible_content": partial + opaque > 0,
"transparency_check_passed": native_alpha
and transparent + partial > 0
and partial + opaque > 0,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", type=Path)
parser.add_argument("--require-transparency", action="store_true")
args = parser.parse_args()
result = inspect_alpha(args.image)
print(json.dumps(result, ensure_ascii=False, indent=2))
if args.require_transparency and not result["transparency_check_passed"]:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
/* 由 prepare_capture.py 绑定测量基线。只读取主文档,不导航、不操作页面。 */
(plan) => {
const elements = plan.targets.map(({name, selector}) => {
let matches;
try { matches = document.querySelectorAll(selector); }
catch { return {name, selector, status: 'invalid_selector', count: 0}; }
if (matches.length !== 1) return {name, selector, count: matches.length, status: matches.length ? 'ambiguous' : 'missing'};
const el = matches[0];
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
let hidden = ['hidden', 'collapse'].includes(style.visibility);
for (let parent = el; parent; parent = parent.parentElement) {
const s = getComputedStyle(parent);
if (s.display === 'none' || Number(s.opacity) === 0) hidden = true;
}
const hasBox = el.getClientRects().length > 0 && rect.width > 0 && rect.height > 0;
const result = {
name, selector, count: 1,
status: hidden ? 'hidden' : hasBox ? 'measured' : 'unmeasurable',
box: [rect.left + scrollX, rect.top + scrollY, rect.width, rect.height],
text: (el.textContent || '').trim().slice(0, 300),
computed: {display: style.display, visibility: style.visibility, opacity: style.opacity, fontFamily: style.fontFamily, fontSize: style.fontSize, lineHeight: style.lineHeight, fontWeight: style.fontWeight, color: style.color, backgroundColor: style.backgroundColor},
viewport_intersects: rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth
};
if (el instanceof HTMLImageElement) result.asset = {src: el.currentSrc, natural_width: el.naturalWidth, natural_height: el.naturalHeight, version: el.dataset.version || null};
return result;
});
return {
measurement_sha256: plan.measurement_sha256,
url: location.href, title: document.title,
viewport: {width: innerWidth, height: innerHeight}, dpr: devicePixelRatio,
scroll: {x: scrollX, y: scrollY}, document_height: document.documentElement.scrollHeight,
fonts_ready: document.fonts.status === 'loaded',
images_ready: [...document.images].every(im => im.complete && im.naturalWidth > 0),
document_overflow_x: document.documentElement.scrollWidth > innerWidth,
captured_at: new Date().toISOString(), elements,
assets: Object.fromEntries(elements.filter(el => el.asset).map(el => [el.name, el.asset]))
};
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""按原始坐标比较截图,不缩放候选图,不把误差分数当成视觉验收。"""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import re
from PIL import Image, ImageChops, ImageEnhance, ImageStat
def safe_name(value):
if not re.fullmatch(r'[\w.-]+', value) or '..' in value:
raise ValueError('名称只能包含文字、数字、短横线、下划线或单个点')
return value
def rms(diff):
stat = ImageStat.Stat(diff)
return math.sqrt(sum(value * value for value in stat.rms) / len(stat.rms))
def make_heatmap(diff):
gray = ImageEnhance.Contrast(diff.convert('L')).enhance(2.2)
gray = ImageEnhance.Brightness(gray).enhance(1.5)
heat = Image.new('RGB', diff.size, (255, 255, 255))
heat.paste(Image.new('RGB', diff.size, (255, 55, 55)), mask=gray)
return heat
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--reference', type=Path, required=True)
parser.add_argument('--candidate', type=Path, required=True)
parser.add_argument('--out-dir', type=Path, required=True)
parser.add_argument('--prefix', default='comparison')
parser.add_argument('--clip', action='append', default=[], help='名称:x,y,w,h;可重复')
parser.add_argument('--allow-height-difference', action='store_true', help='长页诊断:保留高度差,只比较共同坐标,不表示页面完整')
args = parser.parse_args()
reference = Image.open(args.reference).convert('RGB')
candidate = Image.open(args.candidate).convert('RGB')
if reference.width != candidate.width:
parser.error('截图宽度不一致;请先校准视口、DPR及截图缩放方式。不会自动缩放。')
if reference.height != candidate.height and not args.allow_height_difference:
parser.error('截图高度不一致;先修正截图方式。长页诊断可显式使用 --allow-height-difference。')
clips = []
names = {'full', 'overlap', 'reference-tail', 'candidate-tail'}
try:
safe_name(args.prefix)
for raw in args.clip:
name, numbers = raw.split(':', 1)
safe_name(name)
x, y, w, h = map(int, numbers.split(','))
if name in names or min(x, y) < 0 or min(w, h) <= 0 or x+w > reference.width or y+h > reference.height:
raise ValueError('分区重名、尺寸无效或超出参考图')
if y+h > candidate.height and not args.allow_height_difference:
raise ValueError('分区超出候选图')
names.add(name)
clips.append((name, (x, y, x+w, y+h)))
except ValueError as error:
parser.error(str(error))
args.out_dir.mkdir(parents=True, exist_ok=True)
def compare_region(name, ref, cand):
diff = ImageChops.difference(ref, cand)
paths = {suffix: args.out_dir / f'{args.prefix}-{name}-{suffix}.png' for suffix in ('candidate', 'diff', 'heatmap')}
cand.save(paths['candidate'])
diff.save(paths['diff'])
make_heatmap(diff).save(paths['heatmap'])
return {'name': name, 'size': ref.size, 'rms_diff': round(rms(diff), 3),
'mean_abs_diff': round(sum(ImageStat.Stat(diff).mean)/3, 3),
**{key: str(value) for key, value in paths.items()}}
height = min(reference.height, candidate.height)
equal = reference.size == candidate.size
box = (0, 0, reference.width, height)
regions = [compare_region('full' if equal else 'overlap', reference.crop(box), candidate.crop(box))]
tails = {}
for label, image in [('reference', reference), ('candidate', candidate)]:
if image.height > height:
path = args.out_dir / f'{args.prefix}-{label}-tail.png'
image.crop((0, height, image.width, image.height)).save(path)
tails[label] = str(path)
for name, box in clips:
if box[3] > candidate.height:
regions.append({'name': name, 'status': '候选截图缺少完整分区,未计分', 'box': box})
else:
regions.append(compare_region(name, reference.crop(box), candidate.crop(box)))
metrics = {'reference': str(args.reference), 'candidate': str(args.candidate),
'reference_size': reference.size, 'candidate_size_original': candidate.size,
'candidate_size_compared': [candidate.width, height], 'resized': False,
'geometry_match': equal, 'height_delta': candidate.height-reference.height,
'unmatched_tails': tails, 'regions': regions,
'note': '误差不等于还原度;字体、摄影和位置分别判断。尺寸一致也不代表视觉通过。'}
(args.out_dir / f'{args.prefix}-metrics.json').write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(metrics, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+162
View File
@@ -0,0 +1,162 @@
# 可复用凭据输入页
供本机桌面 Skill 配置 API Key、访问令牌等单行凭据。一个 Key 使用紧凑单输入框,多个 Key 在同页纵向排列,共用一个保存按钮。页面默认黑白灰,密钥只存系统凭据库。
## 适用范围
适合没有现成安全配置入口、需要用户首次填写或更换 API Key、访问令牌、Client Secret 的本机桌面 Skill。已有宿主凭据能力优先复用。
| 其他场景 | 使用方式 |
| --- | --- |
| OAuth、验证码或账号登录 | 服务官方授权流程 |
| 模型名、地址、目录等普通设置 | 普通配置文件或设置页 |
| 私钥文件、证书、多行密钥 | 专用凭据或文件授权机制 |
| CI、容器、远程服务器 | 已有 Secret 管理与可信运行时注入,不暴露本机输入页到网络 |
本组件减少密钥进入 Agent 对话和工具输出的机会,不是对同一用户下任意代码执行或浏览器控制的强隔离。
## 安装与单项配置
要求 Node.js 22.18+,以及下文对应系统凭据服务。首次下载依赖后,本地输入页可离线运行:
```bash
npm ci --ignore-scripts
npm run configure -- --label "服务 API Key" --title "输入密钥" --placeholder "粘贴你的密钥"
npm start
```
用户打开返回的本机链接,亲自填写并保存。默认字段文件为 manifests/default.json;configure 只修改非敏感声明,不接收密钥。支持 --dry-run 和 --save-label。
字段声明示例:
```json
{
"version": 1,
"id": "sample-skill",
"label": "服务 API Key",
"credential": "sample-skill/service/default",
"ui": { "title": "输入密钥", "placeholder": "粘贴你的密钥", "saveLabel": "保存" }
}
```
id、label、credential 必填,ui 可省略。每项只接受非空单行密钥,最长 2500 个字符;系统后端的容量限制仍可能更低,失败时不会改存明文文件。密钥值不属于声明字段。
## 同页填写多个 Key
先创建另一项独立声明:
```bash
npm run configure -- --manifest manifests/image.json --id sample-skill --label "图片服务 API Key" --credential sample-skill/image/default --placeholder "粘贴图片服务密钥"
```
临时组合:
```bash
npm start -- --manifest manifests/default.json --manifest manifests/image.json
```
可复用页面配置:
```bash
npm run configure-page -- --page manifests/setup.page.json --manifest manifests/default.json --manifest manifests/image.json --title "连接服务" --label "两项服务" --save-label "保存"
npm start -- --page manifests/setup.page.json
```
页面 JSON 只保存引用与文案:
```json
{
"version": 1,
"manifests": ["default.json", "image.json"],
"ui": { "title": "连接服务", "label": "两项服务", "saveLabel": "保存" }
}
```
每页 1 至 16 项,顺序由 manifests 决定。文件内的路径相对于页面配置文件,CLI 参数路径相对于运行目录。配置命令自动转换为相对路径;支持三端搬移。
只改页面文案:
```bash
npm run configure-page -- --page manifests/setup.page.json --title "配置服务"
```
不传 --manifest 保留列表,传入时替换整组。支持 --dry-run。字段标签和占位文字继续由 configure 修改;同页标题与按钮来自页面 ui。修改配置后重新启动。--page 和启动时的 --manifest 互斥。
已有字段声明的 id 与 credential 不可直接改写;新账号另建声明,不同账号使用不同引用。移除页面字段只改变表单,不删除系统凭据。
## 保存与恢复
尚未配置项必填;已有项显示“已配置”,留空保留,输入新值才替换。原值不会回填页面。替换按钮固定显示“替换并保存”,自定义文案不能覆盖这一提示。
整组预检通过后顺序保存。系统凭据库没有跨项事务:中途失败保留成功项、停止后续写入,并逐项标明结果,不自动回滚。用户先核对状态,再补填未完成项;后端报错可能存在结果不确定的情况,不能据此断言未写入。
提交结束或离开页面会清空输入。会话 30 分钟有效,全部成功后保留结果 90 秒。取消和过期不撤销此前保存。网络超时后先查状态,不重复提交。
Agent 只处理入口与脱敏状态,不自动操作含真实密钥的页面。/agent/status 使用启动令牌认证;只有 saved 表示全部成功,partial 表示部分失败。不要分享本机会话链接或让用户将密钥贴进聊天。
## 存储与平台
| 平台 | 后端 | 前提与管理入口 |
| --- | --- | --- |
| macOS | Keychain 钥匙串 | 当前用户钥匙串可访问;“钥匙串访问”中管理,系统授权由用户确认 |
| Windows | Credential Manager 凭据管理器 | 当前用户会话可用;“凭据管理器 → Windows 凭据”中的对应通用凭据 |
| Linux | secret-tool / Secret Service | libsecret 工具、用户 D-Bus 会话及已解锁的 Secret Service 实现,例如 GNOME Keyring;管理入口依桌面环境而定 |
macOS / Windows 通过 @napi-rs/keyring 原生绑定。Linux 显式调用 secret-tool,保存值经标准输入传递,不使用可能降级到临时 keyutils 的默认绑定。
服务标识为 org.oiloil.skill-credentials,账号属性为声明的 credential。不同后端界面显示名称可能不同。存储属于当前系统用户,本组件不负责跨设备同步、备份或迁移旧文件。
凭据库不可用或被锁定时停止,明确提示检查服务;不自动安装、解锁或降级到 JSON、浏览器存储等明文介质。服务器、无桌面 Linux 或其他凭据实现必须另行确认实际能力。
当前 macOS 已验证;Windows / Linux 已实现适配但尚未实机验证。
## 业务使用
把真实可信程序与环境变量名代入:
```bash
node src/run.ts --manifest manifests/default.json --env SERVICE_API_KEY -- your-program your-arguments
```
一次任务读取多个 Key:
```bash
node src/run.ts --manifest manifests/default.json --env FIRST_API_KEY --manifest manifests/image.json --env SECOND_API_KEY -- your-program your-arguments
```
全部读取成功后才启动程序,重复变量或缺少凭据时停止。页面文件只定义填写方式,业务端仍逐项绑定,避免注入不需要的密钥。密钥不进入命令参数或父进程环境。
包装器不是沙箱:可信子进程取得原值后仍可能打印或外传。需要强隔离时使用独立可信执行服务或宿主权限边界,不依赖密码框或系统凭据库作绝对保证。
检查单项状态:
```bash
npm run status -- --manifest manifests/image.json
```
仅返回配置状态与后端;未配置退出码 2,后端失败退出码 1。可信 Node.js 程序也可内部使用 nativeBackend 的 get / set / delete;保存与删除须用户授权,不向 Agent 暴露任意读取或删除接口。
## 开发验收
```bash
npm run check
npm run build
npm test
npm run test:native
```
常规测试使用假后端与受控 DOM,不操作真实浏览器。原生测试经真实同页 HTTP 入口写入随机假凭据,检查多变量业务读取与隔离,最后清理测试项。修改前端后必须提交一致的 public/app.js,最终用户无需编译。
维护时同时验证单 Key、同页多 Key、留空保留、部分失败重试、未知字段拒绝、会话隔离、状态脱敏及系统后端。不能用另写的静态演示替代正式页面测试。
## 固定业务入口
`manifests/profiles.json` 只保存业务名、声明文件和环境变量,不保存密钥。目标 Skill 接入时提供实际配置名;单项或多项复用相同页面与运行器:
```bash
node src/profile.ts status default
node src/profile.ts setup default
node src/profile.ts run default -- your-program your-arguments
```
status 退出码 0 表示所需凭据可读取,2 表示缺失,1 表示后端或配置错误。setup 由用户亲自填写;run 优先复用运行时环境变量,缺失时仅读取对应声明,不把其他服务凭据注入任务。指定其他服务时同时修改业务参数,不能只换凭据。
@@ -0,0 +1,29 @@
{
"version": 1,
"files": {
"README.md": "c66161233e7b45ed3027d43a1dacbb3f5eca68c8db52cac20d3781f4a868f786",
"tsconfig.web.json": "7ced73bff9909988639b0369bbd4bfbb756d8570f75ac711a02b29a6e42d88e7",
"package-lock.json": "0b457e658ad62b3d699d4aa3b0a504ae03e9f879a9d0a8d8d4e15d0db1a3845c",
"package.json": "82c1b1b85ee7f5ce2be69d2f389167de6a7f3bfb47ef210a83c4dd4ec51d720d",
"tsconfig.json": "005d701b301f56cf02563ba2936134899a60685d1b41536c13726457eab351a5",
"web/app.ts": "c279800cdbdd67b145802ae0716588e605f05c43ad7468b43c1b26392bde461d",
"tests/profile.test.ts": "48dcbbb90f0b39c4adaec1615c51edf33a76fff57549907da5e197ffde425da5",
"tests/configure.test.ts": "3f360ed0102c944b7a502d783eea3c63016fdcaa3c230a1a6ec0865eb513cbd6",
"tests/run.test.ts": "157f946c795d5f5b0a4c049201818bf0f646305050324d14f0913e751df1ca45",
"tests/page.test.ts": "85772cc4b949443ff80d090bc1fc2fbc77f30ec4d8c26e40aa6530dd4270417a",
"tests/native-smoke.ts": "d0a7ed2dd1ce7135a2a63bb8cfb779c24ccba19290cbabefaa280598866e0990",
"tests/ui.test.ts": "613a90efc3607315a058a65a161e8d701453e4cba66a4cef89651a7ecd6c490a",
"tests/credentials.test.ts": "2e33bbdfac5ffe04919526e755680e259e0a4aeef4cf411c5b39b1ccbeac0b13",
"tests/multi-key.test.ts": "1879363e3718e8c538f01dcb180f78cbaa9b4a672e759068ec10b1188c1be654",
"public/index.html": "859d06fa573f9f5409bc7d575e752a06dd08ce77fc67f464eea248f3b8f274be",
"public/style.css": "7a993fc7fbd5edd29dfbff919c3370da3dc44fd6abc022709d55b892e3c0a893",
"public/app.js": "7e08218bf04a2614bf801bc5c1c33f239bcdd2975804aa5869e60fdb6c4aaa3d",
"public/favicon.svg": "574d5efbf305158c498636fbc81ecd27756b7cb7ff77ad73b4051fcf07d8fe42",
"src/cli.ts": "a29c42750d81061f4721ce44ac3684d17d66fec791db155452234116c8988f34",
"src/page.ts": "fbad1e2a9b1b2157444a715f55257f9992fea9b8dd8a9d3af15ef9545775f7f8",
"src/profile.ts": "215ba42fa05cda235d1c9d09d42afbfc8c43a1d53fa4d7b7b4253948d6a3cdaf",
"src/config.ts": "6d84f58c7859b49d291429819a4cd8b73051720ec420b34fcfb86538ed1cb687",
"src/server.ts": "b84d14af6d805e1872875bd1aae94823b80d429139af9dc61ed937281795e2bf",
"src/run.ts": "fe6b0ed145b6737ed472c929bb0e7f6c77721a0e4dfa9b6d785c3f09a2c5c028"
}
}
@@ -0,0 +1,11 @@
{
"version": 1,
"id": "draw-ui",
"label": "图片生成服务 API Key",
"credential": "draw-ui/zenmux/default",
"ui": {
"title": "配置服务",
"placeholder": "粘贴该服务的 API Key",
"saveLabel": "保存"
}
}
@@ -0,0 +1,11 @@
{
"version": 1,
"id": "draw-ui",
"label": "图片 API Key",
"credential": "draw-ui/openai/default",
"ui": {
"title": "配置服务",
"placeholder": "粘贴该服务的 API Key",
"saveLabel": "保存"
}
}
@@ -0,0 +1,17 @@
{
"version": 1,
"profiles": {
"default": [
{
"manifest": "default.json",
"env": "ZENMUX_API_KEY"
}
],
"openai": [
{
"manifest": "openai.json",
"env": "OPENAI_API_KEY"
}
]
}
}
+272
View File
@@ -0,0 +1,272 @@
{
"name": "skill-credentials",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "skill-credentials",
"version": "0.1.0",
"dependencies": {
"@napi-rs/keyring": "2.0.0"
},
"devDependencies": {
"@types/node": "22.20.1",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.18.0"
}
},
"node_modules/@napi-rs/keyring": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.0.0.tgz",
"integrity": "sha512-TnrIt0nO9U2Ue9E9vJQjso1hqhIYiGrn2Ew1HBsMSqQe4+ceOqPJJwRVsV0/Wy7pqp04/doOLASyIFM3gGwZJw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/keyring-darwin-arm64": "2.0.0",
"@napi-rs/keyring-darwin-x64": "2.0.0",
"@napi-rs/keyring-freebsd-x64": "2.0.0",
"@napi-rs/keyring-linux-arm-gnueabihf": "2.0.0",
"@napi-rs/keyring-linux-arm64-gnu": "2.0.0",
"@napi-rs/keyring-linux-arm64-musl": "2.0.0",
"@napi-rs/keyring-linux-riscv64-gnu": "2.0.0",
"@napi-rs/keyring-linux-x64-gnu": "2.0.0",
"@napi-rs/keyring-linux-x64-musl": "2.0.0",
"@napi-rs/keyring-win32-arm64-msvc": "2.0.0",
"@napi-rs/keyring-win32-ia32-msvc": "2.0.0",
"@napi-rs/keyring-win32-x64-msvc": "2.0.0"
}
},
"node_modules/@napi-rs/keyring-darwin-arm64": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.0.0.tgz",
"integrity": "sha512-yvIfviiXpsDSsPdyzWWd7STZt7v774biPfMBpWkiK7rwauwWbOmVjUzgiJ11rbhJbWLqXE3CuQMyLgvcbm3jIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-darwin-x64": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.0.0.tgz",
"integrity": "sha512-XJUONH0c5cg7M9/1Vj3WeIi8TtWYdZlo8Jqho09ga8OWm9cFNxUv7+4QZx5UK/3JSp2qiyhTlresyKdcpwpnRw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-freebsd-x64": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.0.0.tgz",
"integrity": "sha512-u/M114J9Lp3RqtIZihIqhvreNQ5f8wgLFo9tJFy+bIIO/xEHkMR0LENrfUF8hSY6FTNyS2kSbbkN6yOItBFY5g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.0.0.tgz",
"integrity": "sha512-CHMv/KTuELo/MsrGUha52KrFGPBuBkeAonnMFUd3nNcxBTSmeJbCAZTuVSXzpessdpb4tT9xIMQsv2ZdWrl8iw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.0.0.tgz",
"integrity": "sha512-BeUUPGSnW026yDGT4pKuNXDnwxw0xslwiSK6cuOIsDNLi3UO93rfF/7moqKznrBldgsZr8pl9LkMdRk8bnbjEA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.0.0.tgz",
"integrity": "sha512-zj7wZ23Vs7SL4odnGDbWnhZhiyrnEgBe4+8dzKHDi1mTBX1d00FYlGOubkjvx3AeK7mm6G2FyFDDFqIxyR82gA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.0.0.tgz",
"integrity": "sha512-xk/1SOhuk2yQvXiN+pBhR4njSfquLm1SUKUIJcPIeYV1bHFXbUsYrXdfy4NtynSW0lhs41zWjLXSdE8TLeTt2g==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.0.0.tgz",
"integrity": "sha512-12Dq6t2TOrQTibcJcfV5bnHbTvMwEz6zSDqQLHMO2x388gGFQBAeEvw7Hmt+R1QavxspTa2ptgI7axomu9TH+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-linux-x64-musl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.0.0.tgz",
"integrity": "sha512-7NJZvFUiL1FPCrSIQ1L4IUzN6l/2zoxT3IN6j3rHTuDdIEunJaycrUovDMvPlBNP1Vh3fSYz6Pp7tFjdt/1/oQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.0.0.tgz",
"integrity": "sha512-JTktZGXKow0HF/rhaZiQYB8DUS/iSX7S7FvJDoGxPT8mGavKE3w/vadtfKSBNDL4uGef3wu2Ll0XxSfDKBsm2Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.0.0.tgz",
"integrity": "sha512-AzvIFTqn1hJzCPu0foeYWn+kXruAIrVq3Z2IfJ24WPbIXUdD7+rcj49fqco7b14Z6C1aUfsSTyvrAgDfUWhKvA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.0.0.tgz",
"integrity": "sha512-POpEUTV6U+pb69cpuOgtLV4xhcyEWDWq+/9zdiZmNxTL1AenE0MllWrbepS+Hng4M7EarQ5TB3kX69ASqGwfIw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
@@ -0,0 +1,26 @@
{
"name": "skill-credentials",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.18.0"
},
"scripts": {
"build": "tsc -p tsconfig.web.json",
"check": "tsc --noEmit -p tsconfig.json",
"start": "node src/server.ts",
"configure": "node src/cli.ts configure",
"configure-page": "node src/cli.ts configure-page",
"status": "node src/cli.ts status",
"test": "node --test tests/*.test.ts",
"test:native": "node tests/native-smoke.ts"
},
"devDependencies": {
"@types/node": "22.20.1",
"typescript": "~5.9.3"
},
"dependencies": {
"@napi-rs/keyring": "2.0.0"
}
}
+145
View File
@@ -0,0 +1,145 @@
"use strict";
const get = (id) => document.getElementById(id);
const form = get('credential-form');
const button = get('save');
let inputs = [];
let meta;
let busy = false;
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
async function request(url, options = {}) {
let response;
try {
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
}
catch {
throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。');
}
const result = await response.json();
if (!response.ok)
throw new Error(result.error ?? '操作未完成,请重试。');
return result;
}
function message(text) { const node = get('message'); node.textContent = text; node.hidden = false; }
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
function done() {
clearInputs();
form.hidden = true;
get('heading').textContent = '已保存';
get('hint').hidden = true;
message('可以关闭此页,回到对话继续。');
}
function update() {
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|| !inputs.some(input => input.value.trim())
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
if (meta && !busy)
button.textContent = saveLabel();
}
function render() {
clearInputs();
inputs = [];
const multi = meta.fields.length > 1;
form.classList.toggle('multi', multi);
const container = get('fields');
container.replaceChildren();
meta.fields.forEach((field, index) => {
const wrapper = document.createElement('div');
wrapper.className = 'field';
const label = document.createElement('label');
label.htmlFor = 'secret-' + index;
label.textContent = field.label;
if (!multi)
label.className = 'visually-hidden';
if (field.configured) {
const state = document.createElement('span');
state.className = 'field-state';
state.textContent = '已配置';
label.append(state);
}
const input = document.createElement('input');
input.id = label.htmlFor;
input.type = 'password';
input.autocomplete = 'new-password';
input.autocapitalize = 'off';
input.spellcheck = false;
input.maxLength = 2500;
input.required = !field.configured;
input.setAttribute('aria-describedby', 'hint message');
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
wrapper.append(label, input);
container.append(wrapper);
inputs.push(input);
});
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
get('heading').textContent = title;
document.title = title;
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
update();
if (meta.outcome === 'saved')
done();
else if (meta.outcome === 'partial')
message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
else if (meta.outcome !== 'waiting')
message('本次配置已结束,请重新打开入口。');
}
form.addEventListener('input', () => { update(); get('message').hidden = true; });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (busy || button.disabled)
return;
busy = true;
update();
inputs.forEach(input => { input.disabled = true; });
button.textContent = '正在保存…';
try {
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
const result = await request('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
if (result.status === 'saved') {
meta.outcome = 'saved';
done();
}
else {
clearInputs();
meta = await request('/api/meta');
render();
const statuses = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
+ '。请核对状态后重新填写未完成项。');
}
}
catch (error) {
clearInputs();
try {
meta = await request('/api/meta');
render();
}
catch {
meta.outcome = 'unknown';
}
if (meta.outcome !== 'saved')
message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
}
finally {
clearInputs();
busy = false;
inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); });
update();
}
});
window.addEventListener('pagehide', clearInputs);
async function initialize() {
const token = location.hash.slice(1);
if (token) {
history.replaceState(null, '', location.pathname);
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
}
meta = await request('/api/meta');
render();
}
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#292929"/><path d="M12 19v-4a8 8 0 0 1 16 0v4" fill="none" stroke="#eeeeee" stroke-width="3"/><rect x="9" y="18" width="22" height="16" rx="5" fill="#eeeeee"/><circle cx="20" cy="25" r="2" fill="#292929"/></svg>

After

Width:  |  Height:  |  Size: 315 B

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="referrer" content="no-referrer" />
<title>输入密钥</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="/style.css" />
<script type="module" src="/app.js"></script>
</head>
<body>
<main>
<h1 id="heading">输入密钥</h1>
<p id="context" class="context">正在连接…</p>
<form id="credential-form" autocomplete="off">
<div id="fields">
<label for="secret" class="visually-hidden">密钥</label>
<input id="secret" name="secret" type="password" placeholder="粘贴 API Key" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false" maxlength="2500" required disabled aria-describedby="hint message" />
</div>
<button id="save" type="submit" disabled>保存</button>
</form>
<p id="hint" class="hint">仅保存到本机系统凭据库</p>
<p id="message" class="message" role="status" aria-live="polite" hidden></p>
</main>
</body>
</html>
@@ -0,0 +1,143 @@
@charset "UTF-8";
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #282828;
background: #fafafa;
font-synthesis: none;
color-scheme: light;
-webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100svh;
display: grid;
place-items: center;
padding: 24px;
}
main {
width: 100%;
max-width: 320px;
margin-top: -5vh;
}
h1 {
margin: 0 0 6px;
font-size: 20px;
font-weight: 580;
letter-spacing: -.45px;
line-height: 1.45;
}
.context {
margin: 0 0 19px;
font-size: 11.5px;
color: #777777;
line-height: 1.6;
overflow-wrap: anywhere;
}
form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
}
#fields, .field { display: contents; }
form.multi { grid-template-columns: 1fr; gap: 18px; }
.multi #fields { display: grid; gap: 17px; }
.multi .field { display: grid; gap: 7px; }
.multi label { font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.field-state { color: #818181; font-size: 10.5px; margin-left: 7px; }
.multi button { justify-self: end; height: 36px; }
body:has(form.multi) { align-items: safe center; }
body:has(form.multi) main { margin-top: 0; }
input, button {
font: inherit;
border-radius: 8px;
height: 42px;
}
input {
width: 100%;
min-width: 0;
background: #fff;
border: 1px solid #dddddd;
padding: 0 12px;
font-size: 14px;
color: #303030;
outline: none;
box-shadow: 0 1px 2px #00000003;
transition: border-color .15s, box-shadow .15s;
}
input::placeholder {
color: #999999;
font-size: 12px;
}
input:hover:not(:disabled) { border-color: #c5c5c5; }
input:focus {
border-color: #929292;
box-shadow: 0 0 0 3px #eeeeee, 0 1px 2px #00000003;
}
input:disabled { background: #f4f4f4; }
button {
min-width: 62px;
padding: 0 15px;
background: #303030;
border: 1px solid #292929;
color: #fafafa;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
box-shadow: inset 0 1px 0 #ffffff0d, 0 1px 2px #0000000c;
transition: background .15s, border-color .15s, transform .12s;
}
button:hover:not(:disabled) {
background: #454545;
border-color: #3c3c3c;
}
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled {
background: #ececec;
border-color: #e3e3e3;
color: #949494;
box-shadow: none;
cursor: default;
}
button:focus-visible {
outline: 3px solid #c9c9c9;
outline-offset: 3px;
}
.hint {
margin: 12px 0 0;
font-size: 10.5px;
color: #818181;
line-height: 1.7;
}
.message {
margin: 12px 0 0;
font-size: 11px;
color: #5c5c5c;
line-height: 1.8;
}
.message.success { color: #5c5c5c; }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
[hidden] { display: none !important; }
@media (max-width: 480px) {
main { margin-top: -8vh; }
input { font-size: 16px; }
input, button { height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
+96
View File
@@ -0,0 +1,96 @@
import { randomBytes } from 'node:crypto';
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadManifest, nativeBackend, PublicError, validateManifest, type Manifest } from './config.ts';
import { loadPage, validateFields, validatePageUI } from './page.ts';
async function writeConfiguration(file: string, configuration: object) {
await mkdir(path.dirname(file), { recursive: true });
const temporary = file + '.' + randomBytes(8).toString('hex') + '.tmp';
try {
const handle = await open(temporary, 'wx', 0o600);
try { await handle.writeFile(JSON.stringify(configuration, null, 2) + '\n', 'utf8'); await handle.sync(); }
finally { await handle.close(); }
await rename(temporary, file);
} finally { await unlink(temporary).catch(() => {}); }
}
export async function configurePage(file: string, manifests: string[], options: Record<string, string>, dryRun = false) {
if (Object.keys(options).some(key => !['title', 'label', 'save-label'].includes(key))) throw new PublicError('存在不支持的页面配置项。');
let existing: { manifests: string[]; ui?: Record<string, string> } | undefined;
try {
const stat = await lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('页面配置必须是独立的普通文件。');
await loadPage(file);
existing = JSON.parse(await readFile(file, 'utf8'));
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
// 命令参数按当前目录解析;写入页面文件后统一保存为相对该文件的路径。
const paths = manifests.length ? manifests.map(item => path.relative(path.dirname(file), path.resolve(item)).split(path.sep).join('/')) : existing?.manifests ?? [];
validateFields(await Promise.all(paths.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
const ui = { ...existing?.ui };
for (const [flag, key] of [['title', 'title'], ['label', 'label'], ['save-label', 'saveLabel']])
if (options[flag] !== undefined) ui[key] = options[flag];
const configuration = { version: 1, manifests: paths, ui: validatePageUI(ui) };
if (!dryRun) await writeConfiguration(file, configuration);
return { status: dryRun ? 'preview' : 'configured', page: file, configuration };
}
export async function configureManifest(file: string, options: Record<string, string>, dryRun = false) {
const allowed = ['id', 'label', 'credential', 'title', 'placeholder', 'save-label'];
if (Object.keys(options).some(k => !allowed.includes(k))) throw new PublicError('存在不支持的配置项。');
let existing: Manifest | undefined;
try {
const stat = await lstat(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new PublicError('声明必须是独立的普通文件。');
existing = await loadManifest(file);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
if (existing && ((options.id && options.id !== existing.id) || (options.credential && options.credential !== existing.credential)))
throw new PublicError('已有声明的身份和凭据引用不可改写。请为新凭据指定新的 --manifest 文件。');
const draft: Record<string, unknown> = { version: 1, ...existing };
for (const key of ['id', 'label', 'credential']) if (options[key] !== undefined) draft[key] = options[key];
const ui = { ...existing?.ui };
for (const [flag, key] of [['title', 'title'], ['placeholder', 'placeholder'], ['save-label', 'saveLabel']] as const)
if (options[flag] !== undefined) ui[key] = options[flag];
if (Object.keys(ui).length) draft.ui = ui;
const manifest = validateManifest(draft);
if (!dryRun) await writeConfiguration(file, manifest);
return { status: dryRun ? 'preview' : 'configured', manifest: file, configuration: manifest };
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (!command || command === '--help') {
process.stdout.write('configure [--manifest 文件] --id 标识 --label 用途 --credential 引用 [--title 标题] [--placeholder 占位文字] [--save-label 按钮文字] [--dry-run]\nconfigure-page --page 文件 [--manifest 声明(可重复,替换整组)] [--title 标题] [--label 用途] [--save-label 按钮文字] [--dry-run]\nstatus [--manifest 文件]\n');
return;
}
const flags: Record<string, string> = {};
const manifests: string[] = [];
let dryRun = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--dry-run' && ['configure', 'configure-page'].includes(command)) { dryRun = true; continue; }
if (command === 'configure-page' && args[i] === '--manifest' && args[i + 1] && !args[i + 1].startsWith('--')) { manifests.push(args[++i]); continue; }
const name = args[i].replace(/^--/, '');
if (!args[i].startsWith('--') || !args[i + 1] || args[i + 1].startsWith('--') || Object.hasOwn(flags, name))
throw new PublicError('命令参数不正确。');
flags[name] = args[++i];
}
if (command === 'configure-page') {
if (!flags.page) throw new PublicError('请指定 --page 配置文件。');
const file = path.resolve(flags.page); delete flags.page;
process.stdout.write(JSON.stringify(await configurePage(file, manifests, flags, dryRun)) + '\n'); return;
}
const file = path.resolve(flags.manifest ?? fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
delete flags.manifest;
if (command === 'configure') process.stdout.write(JSON.stringify(await configureManifest(file, flags, dryRun)) + '\n');
else if (command === 'status' && !Object.keys(flags).length) {
const m = await loadManifest(file);
const backend = await nativeBackend(m.credential);
const configured = (await backend.get()) !== undefined;
process.stdout.write(JSON.stringify({ id: m.id, credential: m.credential, configured, storage: backend.name }) + '\n');
if (!configured) process.exitCode = 2;
} else throw new PublicError('命令不支持,请使用 --help。');
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '配置操作未完成,请检查文件位置与权限。') + '\n');
process.exitCode = 1;
});
+103
View File
@@ -0,0 +1,103 @@
import { createHmac, randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { execFile, spawn } from 'node:child_process';
export class PublicError extends Error {
status: number;
constructor(message: string, status = 400) { super(message); this.status = status; }
}
export type Manifest = {
version: 1; id: string; label: string; credential: string;
ui?: { title?: string; placeholder?: string; saveLabel?: string };
};
export const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
export async function loadManifest(file: string): Promise<Manifest> {
let m: unknown;
try { m = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取配置声明。'); }
return validateManifest(m);
}
export function validateManifest(m: unknown): Manifest {
if (!object(m) || m.version !== 1 || typeof m.id !== 'string' || !/^[a-z0-9-]{1,80}$/.test(m.id)
|| typeof m.label !== 'string' || !m.label.trim() || m.label.length > 120
|| typeof m.credential !== 'string' || !/^[a-z0-9][a-z0-9/_.-]{0,150}$/.test(m.credential)
|| Object.keys(m).some(k => !['version', 'id', 'label', 'credential', 'ui'].includes(k))) throw new PublicError('配置声明不合法。');
if (m.ui !== undefined && (!object(m.ui) || Object.entries(m.ui).some(([k, v]) =>
!['title', 'placeholder', 'saveLabel'].includes(k) || typeof v !== 'string' || !v.trim() || v.length > 80)))
throw new PublicError('页面配置不合法。');
return m as Manifest;
}
export interface CredentialBackend {
name: string;
get(): Promise<string | undefined>;
set(value: string): Promise<void>;
delete(): Promise<void>;
}
export const service = 'org.oiloil.skill-credentials';
const backendError = () => new PublicError('无法访问系统凭据库,请解锁或检查系统凭据服务后重试。', 503);
// Linux 显式使用 Secret Service,不走可能回退到临时 keyutils 的默认绑定。
function linuxBackend(credential: string): CredentialBackend {
const attributes = ['service', service, 'account', credential];
const run = (args: string[]): Promise<string | undefined> => new Promise((resolve, reject) => {
execFile('secret-tool', args, { timeout: 15_000, maxBuffer: 32_768, encoding: 'utf8' }, (error, stdout, stderr) => {
if (error) {
if (args[0] === 'lookup' && error.code === 1 && !stderr.trim()) resolve(undefined);
else reject(backendError());
} else resolve(stdout.replace(/\r?\n$/, ''));
});
});
return { name: 'Linux Secret Service', get: () => run(['lookup', ...attributes]),
set: value => new Promise((resolve, reject) => {
const child = spawn('secret-tool', ['store', '--label=Skill 凭据', ...attributes], { stdio: ['pipe', 'ignore', 'ignore'], timeout: 15_000, shell: false });
child.once('error', () => reject(backendError()));
child.stdin.on('error', () => reject(backendError()));
child.once('close', code => code === 0 ? resolve() : reject(backendError()));
child.stdin.end(value + '\n');
}),
delete: async () => { await run(['clear', ...attributes]); }
};
}
export async function nativeBackend(credential: string): Promise<CredentialBackend> {
if (process.platform === 'linux') return linuxBackend(credential);
if (!['darwin', 'win32'].includes(process.platform)) throw new PublicError('当前系统暂不支持凭据保存。', 503);
try {
const { AsyncEntry } = await import('@napi-rs/keyring');
const entry = new AsyncEntry(service, credential);
return { name: process.platform === 'darwin' ? 'macOS 钥匙串' : 'Windows 凭据管理器',
// 原生绑定的空结果在实际运行中可能为 null,统一为接口约定的 undefined。
get: async () => { try { return (await entry.getPassword()) ?? undefined; } catch { throw backendError(); } },
set: async value => { try { await entry.setPassword(value); } catch { throw backendError(); } },
delete: async () => { try { await entry.deleteCredential(); } catch { throw backendError(); } }
};
} catch { throw backendError(); }
}
export function createStore(manifest: Manifest, backend: CredentialBackend) {
const salt = randomBytes(32);
let busy = false;
async function status() {
let value: string | undefined;
try { value = await backend.get(); } catch { throw backendError(); }
const revision = createHmac('sha256', salt).update(value === undefined ? 'missing:' : 'exists:').update(value ?? '').digest('hex');
return { revision, configured: value !== undefined, storage: backend.name };
}
async function validate(input: unknown) {
if (!object(input) || typeof input.value !== 'string' || !input.value.trim() || input.value.length > 2500
|| /[\r\n\0]/.test(input.value) || typeof input.revision !== 'string'
|| Object.keys(input).some(k => !['value', 'revision', 'replaceExisting'].includes(k))) throw new PublicError('请填写有效的单行密钥。');
const current = await status();
if (current.revision !== input.revision) throw new PublicError('凭据已发生变化,请刷新后重试。', 409);
if (current.configured && input.replaceExisting !== true) throw new PublicError('已有凭据,请确认替换后保存。', 409);
return { value: input.value.trim() };
}
async function save(input: unknown) {
if (busy) throw new PublicError('正在保存,请稍后。', 409);
busy = true;
try {
const checked = await validate(input);
await backend.set(checked.value);
return { status: 'saved', skill: manifest.id, credential: manifest.credential, configured: true };
} catch (error) { if (error instanceof PublicError) throw error; throw backendError(); }
finally { busy = false; }
}
return { status, save, validate };
}
+27
View File
@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { loadManifest, object, PublicError, validateManifest, type Manifest } from './config.ts';
export type PageUI = { title?: string; label?: string; saveLabel?: string };
export function validatePageUI(ui: unknown): PageUI {
if (!object(ui) || Object.entries(ui).some(([key, value]) =>
!['title', 'label', 'saveLabel'].includes(key) || typeof value !== 'string' || !value.trim() || value.length > 120))
throw new PublicError('页面文案配置不合法。');
return ui as PageUI;
}
export function validateFields(fields: Manifest[]): Manifest[] {
if (!Array.isArray(fields) || fields.length < 1 || fields.length > 16) throw new PublicError('每页需要 1 至 16 项凭据声明。');
fields.forEach(validateManifest);
if (new Set(fields.map(field => field.credential)).size !== fields.length) throw new PublicError('同一页不能重复引用相同凭据。');
return fields;
}
export async function loadPage(file: string) {
let value: unknown;
try { value = JSON.parse(await readFile(file, 'utf8')); } catch { throw new PublicError('无法读取页面配置。'); }
if (!object(value) || value.version !== 1 || Object.keys(value).some(key => !['version', 'manifests', 'ui'].includes(key))
|| !Array.isArray(value.manifests) || !value.manifests.length || value.manifests.length > 16
|| value.manifests.some(item => typeof item !== 'string' || !item.trim())) throw new PublicError('页面配置不合法。');
const ui = validatePageUI(value.ui ?? {});
const manifests = validateFields(await Promise.all(value.manifests.map(item => loadManifest(path.resolve(path.dirname(file), item)))));
return { manifests, ui };
}
@@ -0,0 +1,79 @@
import { readFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { loadManifest, nativeBackend, object, PublicError } from './config.ts';
import { validateFields } from './page.ts';
import { parseBindings, prepareCommand } from './run.ts';
import { startServer } from './server.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
type Binding = { manifest: string; env: string };
export async function loadProfile(name: string, directory = root): Promise<Binding[]> {
let value: unknown;
try { value = JSON.parse(await readFile(path.join(directory, 'manifests/profiles.json'), 'utf8')); }
catch { throw new PublicError('未找到业务凭据配置。'); }
if (!/^[a-z0-9-]+$/.test(name) || !object(value) || value.version !== 1 || !object(value.profiles)
|| !Object.hasOwn(value.profiles, name)) throw new PublicError('请选择已声明的业务配置。');
const items = value.profiles[name];
if (!Array.isArray(items) || !items.length || items.length > 16) throw new PublicError('业务凭据配置不合法。');
const bindings: Binding[] = items.map(item => {
if (!object(item) || Object.keys(item).some(k => !['manifest', 'env'].includes(k))
|| typeof item.manifest !== 'string' || !/^[a-z0-9-]+\.json$/.test(item.manifest)
|| typeof item.env !== 'string') throw new PublicError('业务凭据绑定不合法。');
return { manifest: path.join(directory, 'manifests', item.manifest), env: item.env };
});
parseBindings([...bindingArgs(bindings), '--', 'check']);
validateFields(await Promise.all(bindings.map(b => loadManifest(b.manifest))));
return bindings;
}
function bindingArgs(bindings: Binding[]) {
return bindings.flatMap(b => ['--manifest', b.manifest, '--env', b.env]);
}
export async function profileStatus(bindings: Binding[], environment = process.env,
read = async (ref: string) => (await nativeBackend(ref)).get()) {
const fields = [];
for (const b of bindings) {
const m = await loadManifest(b.manifest);
const fromEnv = Boolean(environment[b.env]?.trim());
const configured = fromEnv || Boolean(await read(m.credential));
fields.push({ credential: m.credential, configured, source: fromEnv ? 'environment' : 'system-store' });
}
return { configured: fields.every(f => f.configured), fields };
}
export async function prepareProfile(bindings: Binding[], command: string[], environment = process.env,
read = async (ref: string) => (await nativeBackend(ref)).get()) {
if (!command.length) throw new PublicError('请指定真实业务程序。');
// 环境注入优先;本次只读取所选业务需要的凭据。
const missing = bindings.filter(b => !environment[b.env]?.trim());
if (!missing.length) return { command: command[0], args: command.slice(1), env: { ...environment } };
return prepareCommand([...bindingArgs(missing), '--', ...command], read, environment);
}
async function main() {
const [action, name, ...args] = process.argv.slice(2);
if (!['status', 'setup', 'run'].includes(action) || !name || (action !== 'run' && args.length)
|| (action === 'run' && (args[0] !== '--' || args.length < 2)))
throw new PublicError('用法:node src/profile.ts status|setup 配置名;node src/profile.ts run 配置名 -- 程序 参数');
const bindings = await loadProfile(name);
if (action === 'status') {
const status = await profileStatus(bindings);
process.stdout.write(JSON.stringify(status) + '\n');
if (!status.configured) process.exitCode = 2;
} else if (action === 'setup') {
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
const app = await startServer({ manifests, onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
process.stdout.write(`本机配置页面(由用户亲自填写,30 分钟内有效):\n${app.url}\n`);
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
} else {
const plan = await prepareProfile(bindings, args.slice(1));
const child = spawn(plan.command, plan.args, { env: plan.env, stdio: 'inherit', shell: false });
for (const b of bindings) delete plan.env[b.env];
child.once('error', () => { process.stderr.write('业务程序启动失败。\n'); process.exitCode = 1; });
child.once('exit', code => { process.exitCode = code ?? 1; });
}
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '配置未完成,请检查依赖与系统凭据服务。') + '\n');
process.exitCode = 1;
});
+61
View File
@@ -0,0 +1,61 @@
import { spawn } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { loadManifest, nativeBackend, PublicError } from './config.ts';
type Binding = { file: string; variable: string };
const defaultManifest = fileURLToPath(new URL('../manifests/default.json', import.meta.url));
const reserved = new Set(['PATH', 'HOME', 'SHELL', 'NODE_OPTIONS', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', 'PYTHONPATH', 'PYTHONSTARTUP', 'COMSPEC', 'SYSTEMROOT']);
export function parseBindings(args: string[]) {
const split = args.indexOf('--');
if (split < 0 || !args[split + 1]) throw new PublicError('请指定要启动的程序。');
const bindings: Binding[] = [];
const variables = new Set<string>();
let pending: string | undefined;
for (let i = 0; i < split; i += 2) {
const value = args[i + 1];
if (i + 1 >= split || !value || value.startsWith('--')) throw new PublicError('凭据绑定参数不完整。');
if (args[i] === '--manifest' && pending === undefined) pending = value;
else if (args[i] === '--env') {
if (!/^[A-Z][A-Z0-9_]*$/.test(value) || reserved.has(value) || variables.has(value))
throw new PublicError('环境变量名重复或不适合注入凭据。');
bindings.push({ file: pending ?? defaultManifest, variable: value });
variables.add(value); pending = undefined;
} else throw new PublicError('凭据绑定参数不正确。');
}
if (pending !== undefined || !bindings.length || bindings.length > 16) throw new PublicError('请提供一至十六组完整的凭据绑定。');
return { bindings, command: args[split + 1], args: args.slice(split + 2) };
}
export async function prepareCommand(
args: string[],
readCredential: (ref: string) => Promise<string | undefined> = async ref => (await nativeBackend(ref)).get(),
baseEnv: NodeJS.ProcessEnv = process.env,
) {
const plan = parseBindings(args);
const manifests = await Promise.all(plan.bindings.map(binding => loadManifest(binding.file)));
const env = { ...baseEnv };
try {
for (let i = 0; i < plan.bindings.length; i++) {
const value = await readCredential(manifests[i].credential);
if (!value) throw new PublicError('有凭据尚未配置,任务未启动。');
env[plan.bindings[i].variable] = value;
}
return { ...plan, env };
} catch (error) {
for (const binding of plan.bindings) delete env[binding.variable];
if (error instanceof PublicError) throw error;
throw new PublicError('凭据读取未完成,任务未启动。');
}
}
async function main() {
const prepared = await prepareCommand(process.argv.slice(2));
const child = spawn(prepared.command, prepared.args, { env: prepared.env, stdio: 'inherit', shell: false });
for (const binding of prepared.bindings) delete prepared.env[binding.variable];
child.once('error', () => { process.stderr.write('无法启动目标程序。\n'); process.exitCode = 1; });
child.once('exit', code => { process.exitCode = code ?? 1; });
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url)
main().catch(error => {
process.stderr.write((error instanceof PublicError ? error.message : '无法读取凭据或启动参数不正确。') + '\n');
process.exitCode = 1;
});
+179
View File
@@ -0,0 +1,179 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { createStore, loadManifest, nativeBackend, object, PublicError, type Manifest, type CredentialBackend } from './config.ts';
import { loadPage, validateFields, validatePageUI, type PageUI } from './page.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
function equal(a: string, b: string) { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }
export async function startServer(options: { manifest?: Manifest; manifests?: Manifest[]; ui?: PageUI; backend?: CredentialBackend; backends?: CredentialBackend[]; port?: number; ttlMs?: number; onComplete?: (result: object) => void }) {
if (options.manifest && options.manifests) throw new PublicError('不能同时指定单项与多项声明。');
const fields = validateFields(options.manifests ?? (options.manifest ? [options.manifest] : []));
const ui = validatePageUI(options.ui ?? {});
if ((options.backends && options.backends.length !== fields.length) || (options.backend && (fields.length !== 1 || options.backends)))
throw new PublicError('凭据后端与字段数量不匹配。');
const stores = await Promise.all(fields.map(async (field, index) => createStore(field,
options.backends?.[index] ?? options.backend ?? await nativeBackend(field.credential))));
const store = stores[0];
const identity = fields.length === 1 ? { skill: fields[0].id, credential: fields[0].credential }
: { credentials: fields.map(field => ({ skill: field.id, credential: field.credential })) };
let lastResult: object | undefined;
const bootstrap = randomBytes(32).toString('hex');
const session = randomBytes(32).toString('hex');
// 同一主机上的不同端口共享 cookie 命名空间,因此每个会话使用独立名称。
const cookieName = 'credentials_session_' + randomBytes(12).toString('hex');
let origin = '';
let outcome = 'waiting';
let saving = false;
let completedTimer: ReturnType<typeof setTimeout> | undefined;
const assets: Record<string, [string, string]> = {
'/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'],
'/style.css': ['style.css', 'text/css; charset=utf-8'], '/favicon.svg': ['favicon.svg', 'image/svg+xml']
};
function json(res: ServerResponse, code: number, body: object) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); }
async function body(req: IncomingMessage) {
if (req.headers['content-type'] !== 'application/json') throw new PublicError('请求格式不支持。', 415);
const parts: Buffer[] = []; let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 256 * 1024) throw new PublicError('请求内容过大。', 413);
parts.push(chunk);
}
try { return JSON.parse(Buffer.concat(parts).toString('utf8')) as unknown; }
catch { throw new PublicError('请求格式不正确。'); }
}
const server = createServer(async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
try {
if (req.headers.host !== new URL(origin).host) throw new PublicError('请求来源不正确。', 403);
const url = new URL(req.url ?? '/', origin);
if (url.search) throw new PublicError('不接受 URL 查询参数。');
if (req.headers.origin && req.headers.origin !== origin) throw new PublicError('不接受跨站请求。', 403);
if (req.method === 'GET' && Object.hasOwn(assets, url.pathname)) {
const [file, type] = assets[url.pathname];
const content = await readFile(path.join(root, 'public', file));
res.writeHead(200, { 'Content-Type': type }); res.end(content); return;
}
if (url.pathname === '/agent/status' && req.method === 'GET') {
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('无权访问。', 401);
json(res, 200, { ...identity, status: outcome, result: lastResult }); return;
}
if (req.method === 'POST' && (req.headers.origin !== origin || req.headers['x-local-request'] !== '1'))
throw new PublicError('请从本地配置页面提交。', 403);
if (url.pathname === '/api/session' && req.method === 'POST') {
if (!equal(req.headers.authorization ?? '', `Bearer ${bootstrap}`)) throw new PublicError('入口已失效,请重新打开工具提供的链接。', 401);
res.setHeader('Set-Cookie', `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${Math.ceil((options.ttlMs ?? 30 * 60_000) / 1000)}`);
json(res, 200, { status: 'ready' }); return;
}
const cookies = (req.headers.cookie ?? '').split(';').map(c => c.trim());
if (!cookies.some(c => equal(c, `${cookieName}=${session}`))) throw new PublicError('请重新打开工具提供的配置链接。', 401);
if (url.pathname === '/api/meta' && req.method === 'GET') {
const metadata = await Promise.all(fields.map(async (field, index) => ({ ...field, ...await stores[index].status() })));
json(res, 200, { ...metadata[0], fields: metadata, page: ui, outcome }); return;
}
if (url.pathname === '/api/save' && req.method === 'POST') {
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束,请稍后确认状态。', 409);
saving = true;
try {
const input = await body(req);
let result;
if (fields.length === 1 && object(input) && !Object.hasOwn(input, 'entries')) result = await store.save(input);
else {
if (!object(input) || Object.keys(input).some(key => key !== 'entries') || !Array.isArray(input.entries)
|| !input.entries.length || input.entries.length > fields.length) throw new PublicError('提交字段不合法。');
const seen = new Set<string>();
const entries = input.entries.map(entry => {
if (!object(entry) || typeof entry.credential !== 'string' || seen.has(entry.credential)) throw new PublicError('提交凭据重复或不合法。');
seen.add(entry.credential);
const index = fields.findIndex(field => field.credential === entry.credential);
if (index === -1) throw new PublicError('提交了页面之外的凭据。');
const { credential, ...payload } = entry;
return { index, payload };
});
// 所有格式、替换授权和版本先验证;系统存储不提供跨项事务。
await Promise.all([
...entries.map(entry => stores[entry.index].validate(entry.payload)),
...fields.map(async (field, index) => {
if (!seen.has(field.credential) && !(await stores[index].status()).configured)
throw new PublicError('请填写所有尚未配置的密钥。');
}),
]);
const results: { credential: string; status: string }[] = [];
let failed = false;
for (const entry of entries) {
let status = 'not_attempted';
if (!failed) {
try { await stores[entry.index].save(entry.payload); status = 'saved'; }
catch { status = 'failed'; failed = true; }
}
results.push({ credential: fields[entry.index].credential, status });
}
result = { ...identity, status: failed ? 'partial' : 'saved', results };
}
outcome = result.status;
lastResult = result;
json(res, 200, result);
options.onComplete?.(result);
if (outcome === 'saved') { completedTimer = setTimeout(close, 90_000); completedTimer.unref(); }
return;
} finally { saving = false; }
}
if (url.pathname === '/api/cancel' && req.method === 'POST') {
if (!['waiting', 'partial'].includes(outcome) || saving) throw new PublicError('配置正在保存或已结束。', 409);
outcome = 'cancelled'; json(res, 200, { status: outcome });
options.onComplete?.({ ...identity, status: outcome });
completedTimer = setTimeout(close, 500); completedTimer.unref(); return;
}
throw new PublicError('接口不存在。', 404);
} catch (error) {
if (!res.headersSent) json(res, error instanceof PublicError ? error.status : 500,
{ error: error instanceof PublicError ? error.message : '操作未完成,请检查系统凭据服务。' });
else res.end();
}
});
server.requestTimeout = 15_000;
server.headersTimeout = 10_000;
server.maxHeadersCount = 30;
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => resolve()); });
const addr = server.address();
if (!addr || typeof addr === 'string') throw new Error('无法启动本机服务。');
origin = `http://127.0.0.1:${addr.port}`;
const expiry = setTimeout(() => {
if (['waiting', 'partial'].includes(outcome)) options.onComplete?.({ ...identity, status: 'expired', result: lastResult });
close();
}, options.ttlMs ?? 30 * 60_000);
expiry.unref();
function close() { clearTimeout(expiry); clearTimeout(completedTimer); server.close(); server.closeAllConnections(); }
return { origin, url: `${origin}/#${bootstrap}`, bootstrap, close };
}
async function main() {
const args = process.argv.slice(2);
const manifestFiles: string[] = [];
let pageFile: string | undefined;
let port = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--manifest' && args[i + 1]) manifestFiles.push(path.resolve(args[++i]));
else if (args[i] === '--page' && args[i + 1] && !pageFile) pageFile = path.resolve(args[++i]);
else if (args[i] === '--port' && /^\d+$/.test(args[i + 1] ?? '')) port = Number(args[++i]);
else throw new PublicError('用法:npm start -- [--manifest 声明路径(可重复) | --page 页面配置] [--port 端口]');
}
if (port > 65535) throw new PublicError('端口不合法。');
if (pageFile && manifestFiles.length) throw new PublicError('--page 与 --manifest 不能同时使用。');
const page = pageFile ? await loadPage(pageFile) : {
manifests: await Promise.all((manifestFiles.length ? manifestFiles : [path.join(root, 'manifests', 'default.json')]).map(loadManifest))
};
const app = await startServer({ ...page, port,
onComplete: result => process.stdout.write(JSON.stringify(result) + '\n') });
process.stdout.write(`本机配置页面(30 分钟内有效):\n${app.url}\n`);
process.once('SIGINT', app.close); process.once('SIGTERM', app.close);
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch(() => { process.stderr.write('启动失败,请检查 Node.js 版本、声明文件和端口。\n'); process.exitCode = 1; });
}
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
const run = (args: string[]) => new Promise<{ code: number; output: string }>(resolve => {
execFile(process.execPath, [script, 'configure', ...args], { encoding: 'utf8' },
(error, stdout, stderr) => resolve({ code: error ? 1 : 0, output: stdout + stderr }));
});
test('真实 CLI:创建、局部修改、幂等与只读预览', async t => {
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
const file = path.join(dir, 'service.credential.json');
const create = ['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default'];
assert.equal((await run(create)).code, 0);
const first = await readFile(file, 'utf8');
assert.equal((await run(create)).code, 0); assert.equal(await readFile(file, 'utf8'), first);
assert.equal((await run(['--manifest', file, '--title', '连接服务', '--placeholder', '输入访问凭据'])).code, 0);
const updated = JSON.parse(await readFile(file, 'utf8'));
assert.equal(updated.credential, 'sample/service/default'); assert.equal(updated.ui.title, '连接服务');
const before = await readFile(file, 'utf8');
assert.equal((await run(['--manifest', file, '--label', '预览', '--dry-run'])).code, 0);
assert.equal(await readFile(file, 'utf8'), before);
});
test('真实 CLI:拒绝更换已有身份、未知字段和不完整声明', async t => {
const dir = await mkdtemp(path.join(tmpdir(), 'credential-configure-')); t.after(() => rm(dir, { recursive: true, force: true }));
const file = path.join(dir, 'service.credential.json');
await run(['--manifest', file, '--id', 'sample-skill', '--label', '服务凭据', '--credential', 'sample/service/default']);
const before = await readFile(file, 'utf8');
assert.equal((await run(['--manifest', file, '--credential', 'other/account'])).code, 1);
assert.equal((await run(['--manifest', file, '--api-key', 'FAKE_VALUE_FOR_TEST_ONLY'])).code, 1);
assert.equal(await readFile(file, 'utf8'), before);
assert.equal((await run(['--manifest', path.join(dir, 'incomplete.json'), '--label', '缺字段'])).code, 1);
});
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { request as httpRequest } from 'node:http';
import { fileURLToPath } from 'node:url';
import { readFile } from 'node:fs/promises';
import { createStore, loadManifest, type CredentialBackend } from '../src/config.ts';
import { startServer } from '../src/server.ts';
const manifest = await loadManifest(fileURLToPath(new URL('../manifests/default.json', import.meta.url)));
const fake = 'TEST_ONLY_NOT_A_REAL_SECRET_12345';
test('通用页面默认使用中性色,不引入未经配置的品牌色', async () => {
const css = await readFile(new URL('../public/style.css', import.meta.url), 'utf8');
for (const match of css.matchAll(/#([0-9a-f]{6})(?:[0-9a-f]{2})?\b/gi)) {
const color = match[1];
assert.equal(color.slice(0, 2), color.slice(2, 4));
assert.equal(color.slice(2, 4), color.slice(4, 6));
}
});
function memory(initial?: string) {
let value = initial;
let writes = 0;
const backend: CredentialBackend = { name: '测试凭据库', get: async () => value,
set: async v => { value = v; writes++; }, delete: async () => { value = undefined; } };
return { backend, value: () => value, writes: () => writes };
}
test('首次保存只写入凭据后端;状态与结果不包含密钥', async () => {
const m = memory();
const store = createStore(manifest, m.backend);
const before = await store.status();
assert.equal(before.configured, false);
const saved = await store.save({ revision: before.revision, value: fake });
assert.equal(m.value(), fake);
const after = await store.status();
assert.equal(after.configured, true);
assert.equal(JSON.stringify({ saved, after }).includes(fake), false);
assert.equal('target' in after, false);
});
test('已有值需要确认替换,旧版本请求不能覆盖新值', async () => {
const m = memory('TEST_OLD');
const store = createStore(manifest, m.backend);
const before = await store.status();
await assert.rejects(store.save({ revision: before.revision, value: fake }), /确认替换/);
assert.equal(m.writes(), 0);
await store.save({ revision: before.revision, value: fake, replaceExisting: true });
await assert.rejects(store.save({ revision: before.revision, value: 'TEST_STALE', replaceExisting: true }), /发生变化/);
assert.equal(m.value(), fake);
});
test('凭据服务不可用时明确失败,不回退文件,不泄漏底层错误', async () => {
const bad: CredentialBackend = { name: '不可用', get: async () => { throw Error(fake); },
set: async () => { throw Error(fake); }, delete: async () => {} };
await assert.rejects(createStore(manifest, bad).status(), e => e instanceof Error && !e.message.includes(fake) && /系统凭据/.test(e.message));
const m = memory(); m.backend.set = async () => { throw Error(fake); };
const store = createStore(manifest, m.backend);
await assert.rejects(store.save({ revision: (await store.status()).revision, value: fake }), e => e instanceof Error && !e.message.includes(fake));
});
test('拒绝路径、旧 JSON 请求、空值、多行及超长密钥', async () => {
const m = memory(); const store = createStore(manifest, m.backend);
const revision = (await store.status()).revision;
for (const input of [{ revision, value: fake, target: '/tmp/unwanted.json' },
{ revision, values: { api_key: fake } }, { revision, value: '' },
{ revision, value: 'one\ntwo' }, { revision, value: 'x'.repeat(2501) }])
await assert.rejects(store.save(input));
assert.equal(m.writes(), 0);
});
test('HTTP 认证、Host 和跨站保护、脱敏及默认单字段占位框', async t => {
const m = memory(); const emitted: object[] = [];
const app = await startServer({ manifest, backend: m.backend, onComplete: v => emitted.push(v) });
t.after(app.close);
assert.equal((await fetch(app.origin + '/api/meta')).status, 401);
const wrongHost = await new Promise<number | undefined>((resolve, reject) => {
const req = httpRequest(app.origin, { headers: { Host: 'attacker.example' } }, res => { res.resume(); resolve(res.statusCode); });
req.on('error', reject); req.end();
});
assert.equal(wrongHost, 403);
const html = await fetch(app.origin);
assert.match(html.headers.get('Content-Security-Policy')!, /frame-ancestors 'none'/);
assert.equal((await html.text()).match(/<input\b/g)?.length, 1);
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
assert.equal(auth.status, 200); headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
assert.equal(meta.label, manifest.label);
assert.deepEqual(meta.ui, manifest.ui);
const payload = { revision: meta.revision, value: fake };
const post = (extras: Record<string, string>) => fetch(app.origin + '/api/save', { method: 'POST', headers: { ...headers, ...extras }, body: JSON.stringify(payload) });
assert.equal((await post({ Origin: 'https://attacker.example' })).status, 403);
assert.equal((await post({ 'X-Local-Request': '' })).status, 403);
const result = await post({}); assert.equal(result.status, 200);
const visible = [await result.text(), JSON.stringify(emitted)];
visible.push(await (await fetch(app.origin + '/api/meta', { headers })).text());
const agent = await (await fetch(app.origin + '/agent/status', { headers })).json();
assert.equal(agent.status, 'saved'); visible.push(JSON.stringify(agent));
assert.equal(visible.some(v => v.includes(fake)), false);
assert.equal((await post({})).status, 409); assert.equal(m.writes(), 1);
});
test('取消不写凭据,取消后不能再保存', async t => {
const m = memory(); const app = await startServer({ manifest, backend: m.backend }); t.after(app.close);
const headers: Record<string, string> = { Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json', Authorization: 'Bearer ' + app.bootstrap };
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers, body: '{}' });
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
assert.equal((await fetch(app.origin + '/api/cancel', { method: 'POST', headers, body: '{}' })).status, 200);
assert.equal((await fetch(app.origin + '/api/save', { method: 'POST', headers, body: '{}' })).status, 409);
assert.equal(m.writes(), 0);
});
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createStore, type CredentialBackend, type Manifest } from '../src/config.ts';
import { startServer } from '../src/server.ts';
const manifest = (name: string): Manifest => ({ version: 1, id: 'sample-skill', label: name, credential: 'sample-skill/' + name });
function memory() {
const values = new Map<string, string>();
const backend = (ref: string): CredentialBackend => ({
name: '测试凭据库', get: async () => values.get(ref),
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); }
});
return { values, backend };
}
test('同一个 Skill 的多个 key 独立保存、替换和删除', async () => {
const m = memory(), a = manifest('first'), b = manifest('second');
const sa = createStore(a, m.backend(a.credential)), sb = createStore(b, m.backend(b.credential));
await sa.save({ value: 'FAKE_FIRST_VALUE', revision: (await sa.status()).revision });
await sb.save({ value: 'FAKE_SECOND_VALUE', revision: (await sb.status()).revision });
await sa.save({ value: 'FAKE_REPLACED_VALUE', revision: (await sa.status()).revision, replaceExisting: true });
assert.equal(m.values.get(b.credential), 'FAKE_SECOND_VALUE');
await m.backend(a.credential).delete();
assert.equal((await sa.status()).configured, false); assert.equal((await sb.status()).configured, true);
});
test('多个端口共享浏览器 cookie 容器时,会话仍互不覆盖', async t => {
const m = memory(), a = manifest('first'), b = manifest('second');
const one = await startServer({ manifest: a, backend: m.backend(a.credential) });
const two = await startServer({ manifest: b, backend: m.backend(b.credential) });
t.after(one.close); t.after(two.close);
const jar = new Map<string, string>();
for (const app of [one, two]) {
const auth = await fetch(app.origin + '/api/session', { method: 'POST',
headers: { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1' } });
assert.equal(auth.status, 200);
const cookie = auth.headers.get('set-cookie')!.split(';')[0], split = cookie.indexOf('=');
jar.set(cookie.slice(0, split), cookie.slice(split + 1));
}
assert.equal(jar.size, 2);
const cookie = [...jar].map(([k, v]) => k + '=' + v).join('; ');
for (const [app, ref, value] of [[one, a.credential, 'FAKE_FIRST_VALUE'], [two, b.credential, 'FAKE_SECOND_VALUE']] as const) {
const headers = { Cookie: cookie, Origin: app.origin, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
const response = await fetch(app.origin + '/api/meta', { headers }); assert.equal(response.status, 200);
const meta = await response.json();
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ revision: meta.revision, value }) });
assert.equal(saved.status, 200);
const status = await (await fetch(app.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + app.bootstrap } })).json();
assert.equal(status.credential, ref); assert.equal(status.status, 'saved');
}
assert.equal(m.values.size, 2);
assert.equal((await fetch(one.origin + '/agent/status', { headers: { Authorization: 'Bearer ' + two.bootstrap } })).status, 401);
});
test('同一凭据的旧会话在其他会话保存后不能覆盖', async () => {
const m = memory(), a = manifest('shared');
const first = createStore(a, m.backend(a.credential)), second = createStore(a, m.backend(a.credential));
const initial = await second.status();
await first.save({ revision: (await first.status()).revision, value: 'FAKE_LATEST_VALUE' });
await assert.rejects(second.save({ revision: initial.revision, value: 'FAKE_STALE_VALUE', replaceExisting: true }), /发生变化/);
assert.equal(m.values.get(a.credential), 'FAKE_LATEST_VALUE');
});
async function session(app: Awaited<ReturnType<typeof startServer>>) {
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap,
'X-Local-Request': '1', 'Content-Type': 'application/json' };
const auth = await fetch(app.origin + '/api/session', { method: 'POST', headers });
headers.Cookie = auth.headers.get('set-cookie')!.split(';')[0];
return {
meta: async () => (await fetch(app.origin + '/api/meta', { headers })).json(),
save: (entries: object[]) => fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries }) }),
agent: async () => (await fetch(app.origin + '/agent/status', { headers })).json(),
};
}
test('同页多 key 真实 HTTP 保存、文案与状态脱敏', async t => {
const m = memory(), fields = [manifest('one'), manifest('two')], emitted: object[] = [];
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)),
ui: { title: '连接服务', label: '双服务配置', saveLabel: '确认保存' }, onComplete: value => emitted.push(value) });
t.after(app.close); const client = await session(app); const meta = await client.meta();
assert.equal(meta.fields.length, 2); assert.equal(meta.page.title, '连接服务');
const response = await client.save(meta.fields.map((field: any, index: number) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH_' + index })));
assert.equal(response.status, 200); const result = await response.json(); assert.equal(result.status, 'saved');
assert.equal(m.values.get(fields[0].credential), 'FAKE_BATCH_0'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH_1');
assert.equal(JSON.stringify([result, await client.meta(), await client.agent(), emitted]).includes('FAKE_BATCH_'), false);
});
test('整组预检拒绝无效项、重复项、越界引用、未确认替换和缺失项,零写入', async t => {
const m = memory(), fields = [manifest('one'), manifest('two')];
m.values.set(fields[0].credential, 'FAKE_OLD');
const app = await startServer({ manifests: fields, backends: fields.map(field => m.backend(field.credential)) });
t.after(app.close); const client = await session(app); const meta = await client.meta();
const entries = meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_NEW', replaceExisting: true }));
for (const invalid of [
[entries[0]], [entries[0], { ...entries[1], value: 'multi\nline' }],
[entries[0], entries[0]], [entries[0], { ...entries[1], credential: 'not/in/page' }],
[{ ...entries[0], replaceExisting: false }, entries[1]],
[entries[0], { ...entries[1], revision: 'stale' }],
[entries[0], { ...entries[1], target: '/tmp/plaintext.json' }],
]) { assert.ok((await client.save(invalid)).status >= 400); assert.deepEqual([...m.values], [[fields[0].credential, 'FAKE_OLD']]); }
});
test('已有项留空不覆盖,中途失败保留已保存项并可仅重试失败项', async t => {
const m = memory(), fields = [manifest('one'), manifest('two'), manifest('three'), manifest('four')];
m.values.set(fields[0].credential, 'FAKE_KEEP');
const backends = fields.map(field => m.backend(field.credential));
let fail = true; const original = backends[2].set;
backends[2].set = async value => { if (fail) throw Error(value); await original(value); };
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
const client = await session(app); let meta = await client.meta();
const response = await client.save(meta.fields.slice(1).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_BATCH' })));
const result = await response.json(); assert.equal(result.status, 'partial');
assert.deepEqual(result.results.map((item: any) => item.status), ['saved', 'failed', 'not_attempted']);
assert.equal(m.values.has(fields[3].credential), false);
assert.equal((await client.agent()).status, 'partial'); assert.equal(JSON.stringify(result).includes('FAKE_BATCH'), false);
assert.equal(m.values.get(fields[0].credential), 'FAKE_KEEP'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
fail = false; meta = await client.meta();
const retry = await client.save(meta.fields.slice(2).map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_RETRY' })));
assert.equal((await retry.json()).status, 'saved'); assert.equal(m.values.get(fields[1].credential), 'FAKE_BATCH');
});
test('16 项预检并行读取,写入仍顺序执行且每项写前复验', async t => {
const fields = Array.from({ length: 16 }, (_, index) => manifest('key-' + index));
let active = 0, peak = 0, reads = 0, writes = 0;
const backends = fields.map((): CredentialBackend => ({ name: '测试凭据库',
get: async () => {
reads++; active++; peak = Math.max(peak, active);
await new Promise(resolve => setTimeout(resolve, 2)); active--; return undefined;
},
set: async () => { assert.equal(active, 0); writes++; }, delete: async () => {},
}));
const app = await startServer({ manifests: fields, backends }); t.after(app.close);
const client = await session(app); const meta = await client.meta();
peak = 0; reads = 0;
const response = await client.save(meta.fields.map((field: any) => ({ credential: field.credential, revision: field.revision, value: 'FAKE_ONLY' })));
assert.equal((await response.json()).status, 'saved');
assert.equal(peak, 16); assert.equal(reads, 32); assert.equal(writes, 16);
});
test('页面拒绝重复引用、超限字段和空配置', async () => {
const m = memory(), field = manifest('one');
for (const fields of [[], [field, field], Array.from({ length: 17 }, (_, index) => manifest('key-' + index))])
await assert.rejects(startServer({ manifests: fields, backends: fields.map(item => m.backend(item.credential)) }));
});
@@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { nativeBackend, type CredentialBackend } from '../src/config.ts';
import { startServer } from '../src/server.ts';
const namespace = 'test/' + randomUUID();
const refs = [namespace + '/first', namespace + '/second'];
const entries: { backend: CredentialBackend; owned: boolean }[] = [];
const directory = await mkdtemp(path.join(tmpdir(), 'credential-native-'));
let phase = '初始化';
let cleanupFailed = false;
let app: Awaited<ReturnType<typeof startServer>> | undefined;
try {
for (const ref of refs) {
const backend = await nativeBackend(ref);
if (await backend.get() !== undefined) throw Error();
entries.push({ backend, owned: false });
}
const values = ['TEST_ONLY_A_' + randomUUID(), 'TEST_ONLY_B_' + randomUUID()];
phase = '同页 HTTP 多 key 保存与回读';
app = await startServer({ manifests: refs.map((credential, index) => ({ version: 1, id: 'sample-skill', label: '测试服务' + index, credential })) });
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
const metadata = await (await fetch(app.origin + '/api/meta', { headers })).json();
entries.forEach(entry => { entry.owned = true; });
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({
entries: metadata.fields.map((field: { credential: string; revision: string }, index: number) => ({ credential: field.credential, revision: field.revision, value: values[index] }))
}) });
if (!saved.ok || (await saved.json()).status !== 'saved') throw Error();
for (let i = 0; i < entries.length; i++) if (await entries[i].backend.get() !== values[i]) throw Error();
phase = '替换隔离';
values[0] = 'TEST_ONLY_A_REPLACED_' + randomUUID();
await entries[0].backend.set(values[0]);
if (await entries[1].backend.get() !== values[1]) throw Error();
phase = '多 key 业务读取';
const files = [path.join(directory, 'first.json'), path.join(directory, 'second.json')];
for (let i = 0; i < files.length; i++)
await writeFile(files[i], JSON.stringify({ version: 1, id: 'sample-skill', label: '测试服务', credential: refs[i] }));
const script = fileURLToPath(new URL('../src/run.ts', import.meta.url));
await new Promise<void>((resolve, reject) => execFile(process.execPath, [script,
'--manifest', files[0], '--env', 'FIRST_API_KEY', '--manifest', files[1], '--env', 'SECOND_API_KEY', '--',
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY?.startsWith("TEST_ONLY_A_REPLACED_") && process.env.SECOND_API_KEY?.startsWith("TEST_ONLY_B_") ? 0 : 1)'],
{ timeout: 20_000 }, error => error ? reject(Error()) : resolve()));
phase = '删除隔离';
await entries[0].backend.delete();
if (await entries[0].backend.get() !== undefined || await entries[1].backend.get() !== values[1]) throw Error();
process.stdout.write('系统凭据库多 key 保存、替换隔离、业务读取和删除隔离验证通过(假凭据)。\n');
} catch {
process.stderr.write('系统凭据库验证未通过,阶段:' + phase + '。\n'); process.exitCode = 1;
} finally {
app?.close();
for (const entry of entries) {
if (!entry.owned) continue;
try {
if (await entry.backend.get() !== undefined) await entry.backend.delete();
if (await entry.backend.get() !== undefined) cleanupFailed = true;
} catch { cleanupFailed = true; }
}
await rm(directory, { recursive: true, force: true });
if (cleanupFailed) {
process.stderr.write('测试凭据清理失败,测试引用前缀:' + namespace + '\n'); process.exitCode = 1;
} else process.stdout.write('测试凭据和临时声明已清理。\n');
}
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { loadPage } from '../src/page.ts';
test('页面配置真实 CLI:相对路径、独立字段文案、更新与只读预览', async t => {
const dir = await mkdtemp(path.join(tmpdir(), 'credential-page-')); t.after(() => rm(dir, { recursive: true, force: true }));
const files = ['one', 'two'].map(name => path.join(dir, name + '.json'));
for (const [index, file] of files.entries()) await writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务' + index,
credential: 'sample/' + index, ui: { placeholder: '请输入测试凭据' + index } }));
const page = path.join(dir, 'page.json');
const script = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
const run = (args: string[]) => new Promise<number>(resolve => execFile(process.execPath,
[script, 'configure-page', '--page', page, ...args], error => resolve(error ? 1 : 0)));
const args = ['--manifest', files[0], '--manifest', files[1], '--title', '两项凭据'];
assert.equal(await run(args), 0);
assert.deepEqual(JSON.parse(await readFile(page, 'utf8')).manifests, ['one.json', 'two.json']);
const loaded = await loadPage(page); assert.equal(loaded.manifests.length, 2); assert.equal(loaded.ui.title, '两项凭据');
assert.equal(loaded.manifests[1].ui?.placeholder, '请输入测试凭据1');
assert.equal(await run(['--label', '业务配置']), 0);
const before = await readFile(page, 'utf8');
assert.equal(await run(['--title', '只读预览', '--dry-run']), 0); assert.equal(await readFile(page, 'utf8'), before);
assert.notEqual(await run(['--manifest', files[0], '--manifest', files[0]]), 0);
assert.notEqual(await run(['--api-key', 'FAKE_ONLY']), 0);
assert.equal(await readFile(page, 'utf8'), before);
});
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { loadProfile, prepareProfile, profileStatus } from '../src/profile.ts';
import { loadManifest, type CredentialBackend } from '../src/config.ts';
import { startServer } from '../src/server.ts';
test('真实分发配置:正式页面保存后可被对应业务环境读取,状态与参数不含值', async () => {
const root = fileURLToPath(new URL('../', import.meta.url));
const config = JSON.parse(await readFile(path.join(root, 'manifests/profiles.json'), 'utf8'));
for (const name of Object.keys(config.profiles)) {
const bindings = await loadProfile(name);
const manifests = await Promise.all(bindings.map(b => loadManifest(b.manifest)));
const values = new Map<string, string>();
const backendFactory = async (ref: string): Promise<CredentialBackend> => ({
name: 'fake', get: async () => values.get(ref),
set: async value => { values.set(ref, value); }, delete: async () => { values.delete(ref); },
});
const app = await startServer({ manifests, backends: await Promise.all(manifests.map(m => backendFactory(m.credential))) });
try {
const headers: Record<string, string> = { Origin: app.origin, Authorization: 'Bearer ' + app.bootstrap, 'X-Local-Request': '1', 'Content-Type': 'application/json' };
const session = await fetch(app.origin + '/api/session', { method: 'POST', headers });
headers.Cookie = session.headers.get('set-cookie')!.split(';')[0];
assert.equal((await fetch(app.origin)).status, 200);
const meta = await (await fetch(app.origin + '/api/meta', { headers })).json();
const saved = await fetch(app.origin + '/api/save', { method: 'POST', headers, body: JSON.stringify({ entries: meta.fields.map((f: { credential: string; revision: string }, i: number) => ({ credential: f.credential, revision: f.revision, value: 'TEST_ONLY_PROFILE_' + i })) }) });
assert.equal((await saved.json()).status, 'saved');
const status = await profileStatus(bindings, {}, async ref => values.get(ref));
assert.equal(status.configured, true);
assert.equal(JSON.stringify(status).includes('TEST_ONLY'), false);
const plan = await prepareProfile(bindings, ['business-program', '--input', 'a b'], {}, async ref => values.get(ref));
for (const b of bindings) assert.match(plan.env[b.env]!, /^TEST_ONLY_PROFILE_/);
assert.equal(JSON.stringify(plan.args).includes('TEST_ONLY'), false);
assert.deepEqual(plan.args, ['--input', 'a b']);
} finally { app.close(); }
}
});
test('已有环境凭据无需读取系统库;缺失或后端失败时不启动业务', async () => {
const bindings = await loadProfile('default');
const env = Object.fromEntries(bindings.map(b => [b.env, 'TEST_ONLY_ENV']));
const noRead = async () => { throw Error('TEST_ONLY_FAILURE'); };
assert.equal((await profileStatus(bindings, env, noRead)).configured, true);
assert.equal((await prepareProfile(bindings, ['business'], env, noRead)).env[bindings[0].env], 'TEST_ONLY_ENV');
await assert.rejects(prepareProfile(bindings, ['business'], {}, async () => undefined), /未配置/);
await assert.rejects(prepareProfile(bindings, ['business'], {}, noRead), error => error instanceof Error && !error.message.includes('TEST_ONLY'));
});
test('配置拒绝跨目录声明、危险变量与未知业务', async t => {
const dir = await mkdtemp(path.join(tmpdir(), 'credential-profile-')); t.after(() => rm(dir, { recursive: true, force: true }));
await mkdir(path.join(dir, 'manifests'));
for (const binding of [{ manifest: '../outside.json', env: 'API_KEY' }, { manifest: 'default.json', env: 'NODE_OPTIONS' }]) {
await writeFile(path.join(dir, 'manifests/profiles.json'), JSON.stringify({ version: 1, profiles: { default: [binding] } }));
await assert.rejects(loadProfile('default', dir));
}
await assert.rejects(loadProfile('unknown', dir));
});
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import path from 'node:path';
import { tmpdir } from 'node:os';
import { execFile } from 'node:child_process';
import { parseBindings, prepareCommand } from '../src/run.ts';
async function fixture() {
const dir = await mkdtemp(path.join(tmpdir(), 'credential-run-'));
const files = [path.join(dir, 'first.json'), path.join(dir, 'second.json')];
await Promise.all(files.map((file, i) => writeFile(file, JSON.stringify({ version: 1, id: 'sample-skill', label: '服务', credential: 'sample/key-' + i }))));
return { dir, files, cleanup: () => rm(dir, { recursive: true, force: true }) };
}
test('多个 key 同时注入一个真实子进程,命令参数不包含值', async t => {
const f = await fixture(); t.after(f.cleanup);
const plan = await prepareCommand(['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--',
process.execPath, '-e', 'process.exit(process.env.FIRST_API_KEY && process.env.SECOND_API_KEY && process.env.FIRST_API_KEY !== process.env.SECOND_API_KEY ? 0 : 1)'],
async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : 'FAKE_SECOND_VALUE');
assert.equal(plan.env.FIRST_API_KEY, 'FAKE_FIRST_VALUE'); assert.equal(plan.env.SECOND_API_KEY, 'FAKE_SECOND_VALUE');
assert.equal(JSON.stringify(plan.args).includes('FAKE_'), false);
await new Promise<void>((resolve, reject) => execFile(plan.command, plan.args, { env: plan.env }, error => error ? reject(error) : resolve()));
});
test('任一 key 缺失或后端失败,不返回可启动的命令,也不修改父环境', async t => {
const f = await fixture(); t.after(f.cleanup);
const args = ['--manifest', f.files[0], '--env', 'FIRST_API_KEY', '--manifest', f.files[1], '--env', 'SECOND_API_KEY', '--', 'unused'];
const base = { KEEP: 'unchanged' };
await assert.rejects(prepareCommand(args, async ref => ref.endsWith('0') ? 'FAKE_FIRST_VALUE' : undefined, base), /未配置/);
await assert.rejects(prepareCommand(args, async () => { throw Error('FAKE_VALUE_MUST_NOT_LEAK'); }, base),
error => error instanceof Error && !error.message.includes('FAKE_'));
assert.deepEqual(base, { KEEP: 'unchanged' });
});
test('拒绝重复变量和不完整绑定,兼容单 key 的原入口', () => {
assert.throws(() => parseBindings(['--env', 'DUP_KEY', '--env', 'DUP_KEY', '--', 'unused']), /重复/);
assert.throws(() => parseBindings(['--manifest', 'a.json', '--manifest', 'b.json', '--env', 'A_KEY', '--', 'unused']));
assert.throws(() => parseBindings(['--env', 'HOME', '--', 'unused']));
assert.equal(parseBindings(['--env', 'SERVICE_API_KEY', '--', 'program', 'arg']).bindings.length, 1);
});
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { runInNewContext } from 'node:vm';
import { readFile } from 'node:fs/promises';
// 运行实际构建产物的 DOM 契约测试,不启动浏览器,不接触真实凭据。
class Element {
children: Element[] = [];
value = ''; textContent = ''; hidden = false; disabled = false; type = ''; required = false; placeholder = '';
className = ''; id = ''; htmlFor = '';
listeners: Record<string, (event: object) => unknown> = {};
classes = new Set<string>();
classList = { toggle: (key: string, enabled: boolean) => enabled ? this.classes.add(key) : this.classes.delete(key) };
append(...children: Element[]) { this.children.push(...children); }
replaceChildren() { this.children = []; }
setAttribute() {}
addEventListener(event: string, callback: (event: object) => unknown) { this.listeners[event] = callback; }
}
async function harness(configured = false, fail = false) {
const nodes = Object.fromEntries(['credential-form', 'fields', 'save', 'heading', 'context', 'hint', 'message'].map(key => [key, new Element()]));
const fields = [0, 1].map(index => ({ id: 'sample', label: index ? '<img src=x>' : '语音服务', credential: 'sample/' + index,
configured: index === 0 && configured, revision: 'revision-' + index, storage: '测试凭据库', ui: { placeholder: '测试输入' } }));
const metadata = { fields, page: { title: '连接服务', label: '两个服务', saveLabel: '确认保存' }, outcome: 'waiting' };
const submitted: any[] = [];
const lifecycle: Record<string, () => void> = {};
const context = { document: { getElementById: (id: string) => nodes[id], createElement: () => new Element(), title: '' },
location: { hash: '', pathname: '/' }, history: { replaceState() {} }, AbortSignal,
window: { addEventListener: (key: string, callback: () => void) => { lifecycle[key] = callback; } },
fetch: async (url: string, options: any) => {
if (url === '/api/meta') return { ok: true, json: async () => structuredClone(metadata) };
assert.equal(url, '/api/save'); submitted.push(JSON.parse(options.body));
if (fail) {
metadata.fields[0].configured = true; metadata.outcome = 'partial';
return { ok: true, json: async () => ({ status: 'partial', results: [{ credential: 'sample/0', status: 'saved' }, { credential: 'sample/1', status: 'failed' }] }) };
}
metadata.outcome = 'saved'; return { ok: true, json: async () => ({ status: 'saved' }) };
} };
runInNewContext(await readFile(new URL('../public/app.js', import.meta.url), 'utf8'), context);
await new Promise(resolve => setImmediate(resolve));
const inputs = () => nodes.fields.children.map(field => field.children[1]);
const input = () => nodes['credential-form'].listeners.input({});
const submit = () => nodes['credential-form'].listeners.submit({ preventDefault() {} });
return { nodes, inputs, input, submit, submitted, lifecycle };
}
test('真实前端产物:配置生成两个密码框、纯文本标签、必填与成功清空', async () => {
const h = await harness();
assert.equal(h.inputs().length, 2); assert.ok(h.inputs().every(input => input.type === 'password' && input.required));
assert.equal(h.nodes.fields.children[1].children[0].textContent, '<img src=x>');
assert.equal(h.nodes.heading.textContent, '连接服务');
h.inputs()[0].value = 'FAKE_ONE'; h.input(); assert.equal(h.nodes.save.disabled, true);
h.inputs()[1].value = 'FAKE_TWO'; h.input(); assert.equal(h.nodes.save.disabled, false);
await h.submit(); assert.equal(h.submitted[0].entries.length, 2);
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.nodes['credential-form'].hidden, true);
});
test('真实前端产物:已有项留空保留,替换按钮明确,离开清空', async () => {
const h = await harness(true);
assert.equal(h.inputs()[0].required, false); assert.match(h.inputs()[0].placeholder, /留空保留/);
h.inputs()[0].value = 'FAKE_REPLACE'; h.input(); assert.equal(h.nodes.save.textContent, '替换并保存');
h.inputs()[0].value = ''; h.inputs()[1].value = 'FAKE_SECOND'; h.input();
await h.submit(); assert.equal(h.submitted[0].entries.length, 1); assert.equal(h.submitted[0].entries[0].credential, 'sample/1');
h.inputs()[0].value = 'FAKE_LEAVE'; h.lifecycle.pagehide(); assert.equal(h.inputs()[0].value, '');
});
test('真实前端产物:部分失败不显示全部保存,刷新状态并允许补填', async () => {
const h = await harness(false, true);
h.inputs().forEach(input => { input.value = 'FAKE_ONLY'; }); h.input(); await h.submit();
assert.equal(h.nodes['credential-form'].hidden, false); assert.match(h.nodes.message.textContent, /未确认成功/);
assert.ok(h.inputs().every(input => !input.value)); assert.equal(h.inputs()[0].required, false);
assert.equal(h.inputs()[1].required, true); assert.equal(h.nodes.save.disabled, true);
h.inputs()[1].value = 'FAKE_RETRY'; h.input(); assert.equal(h.nodes.save.disabled, false);
});
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "web/**/*.ts", "tests/**/*.ts"]
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"rootDir": "web",
"outDir": "public",
"types": []
},
"include": ["web/**/*.ts"]
}
+96
View File
@@ -0,0 +1,96 @@
type Field = { id: string; label: string; credential: string; revision: string; configured: boolean; storage: string; ui?: { title?: string; placeholder?: string; saveLabel?: string } };
type Metadata = { fields: Field[]; page: { title?: string; label?: string; saveLabel?: string }; outcome: string };
type SaveResult = { status: string; results?: { credential: string; status: string }[] };
const get = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const form = get<HTMLFormElement>('credential-form');
const button = get<HTMLButtonElement>('save');
let inputs: HTMLInputElement[] = [];
let meta: Metadata;
let busy = false;
const saveLabel = () => inputs.some((input, index) => input.value.trim() && meta.fields[index].configured)
? '替换并保存' : (meta.page.saveLabel ?? (meta.fields.length === 1 ? meta.fields[0].ui?.saveLabel : undefined) ?? '保存');
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
let response: Response;
try {
response = await fetch(url, { ...options, credentials: 'same-origin', cache: 'no-store',
headers: { 'Content-Type': 'application/json', 'X-Local-Request': '1', ...options.headers }, signal: AbortSignal.timeout(25_000) });
} catch { throw new Error('未收到服务回复,请刷新确认保存结果,不要重复提交。'); }
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? '操作未完成,请重试。');
return result as T;
}
function message(text: string) { const node = get('message'); node.textContent = text; node.hidden = false; }
function clearInputs() { inputs.forEach(input => { input.value = ''; }); }
function done() {
clearInputs(); form.hidden = true; get('heading').textContent = '已保存';
get('hint').hidden = true; message('可以关闭此页,回到对话继续。');
}
function update() {
button.disabled = busy || !meta || !['waiting', 'partial'].includes(meta.outcome)
|| !inputs.some(input => input.value.trim())
|| inputs.some((input, index) => !meta.fields[index].configured && !input.value.trim());
if (meta && !busy) button.textContent = saveLabel();
}
function render() {
clearInputs(); inputs = [];
const multi = meta.fields.length > 1;
form.classList.toggle('multi', multi);
const container = get('fields'); container.replaceChildren();
meta.fields.forEach((field, index) => {
const wrapper = document.createElement('div'); wrapper.className = 'field';
const label = document.createElement('label'); label.htmlFor = 'secret-' + index; label.textContent = field.label;
if (!multi) label.className = 'visually-hidden';
if (field.configured) {
const state = document.createElement('span'); state.className = 'field-state'; state.textContent = '已配置'; label.append(state);
}
const input = document.createElement('input'); input.id = label.htmlFor; input.type = 'password';
input.autocomplete = 'new-password'; input.autocapitalize = 'off'; input.spellcheck = false; input.maxLength = 2500;
input.required = !field.configured; input.setAttribute('aria-describedby', 'hint message');
input.placeholder = field.configured ? '留空保留,输入则替换' : (field.ui?.placeholder ?? '粘贴 API Key');
input.disabled = !['waiting', 'partial'].includes(meta.outcome);
wrapper.append(label, input); container.append(wrapper); inputs.push(input);
});
get('context').textContent = meta.page.label ?? (multi ? meta.fields.length + ' 项凭据' : meta.fields[0].label);
const title = meta.page.title ?? (multi ? '输入密钥' : meta.fields[0].ui?.title) ?? '输入密钥';
get('heading').textContent = title; document.title = title;
const storage = [...new Set(meta.fields.map(field => field.storage))].join('、');
get('hint').textContent = '仅保存到' + storage + (meta.fields.some(field => field.configured) ? ' · 已配置项留空保留' : '');
update();
if (meta.outcome === 'saved') done();
else if (meta.outcome === 'partial') message('上次仅部分保存。请核对已配置项,补填未完成项后重试。');
else if (meta.outcome !== 'waiting') message('本次配置已结束,请重新打开入口。');
}
form.addEventListener('input', () => { update(); get('message').hidden = true; });
form.addEventListener('submit', async event => {
event.preventDefault(); if (busy || button.disabled) return;
busy = true; update(); inputs.forEach(input => { input.disabled = true; }); button.textContent = '正在保存…';
try {
const entries = inputs.flatMap((input, index) => input.value.trim() ? [{ credential: meta.fields[index].credential,
value: input.value, revision: meta.fields[index].revision, replaceExisting: meta.fields[index].configured }] : []);
const result = await request<SaveResult>('/api/save', { method: 'POST', body: JSON.stringify({ entries }) });
if (result.status === 'saved') { meta.outcome = 'saved'; done(); }
else {
clearInputs(); meta = await request<Metadata>('/api/meta'); render();
const statuses: Record<string, string> = { saved: '已保存', failed: '未确认成功', not_attempted: '未尝试' };
message((result.results ?? []).map(item => (meta.fields.find(field => field.credential === item.credential)?.label ?? '凭据') + ':' + (statuses[item.status] ?? '请核对')).join(';')
+ '。请核对状态后重新填写未完成项。');
}
} catch (error) {
clearInputs();
try { meta = await request<Metadata>('/api/meta'); render(); }
catch { meta.outcome = 'unknown'; }
if (meta.outcome !== 'saved') message(error instanceof Error ? error.message : '保存未完成,请刷新确认结果。');
} finally {
clearInputs(); busy = false; inputs.forEach(input => { input.disabled = !['waiting', 'partial'].includes(meta.outcome); }); update();
}
});
window.addEventListener('pagehide', clearInputs);
async function initialize() {
const token = location.hash.slice(1);
if (token) {
history.replaceState(null, '', location.pathname);
await request('/api/session', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' });
}
meta = await request<Metadata>('/api/meta'); render();
}
initialize().catch(error => { get('context').textContent = '暂时无法连接'; message(error instanceof Error ? error.message : '请重新打开配置入口。'); });
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import binascii
import json
import mimetypes
import os
import re
import sys
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
DEFAULT_MODEL = os.getenv("DRAW_MODEL", "openai/gpt-image-2")
DEFAULT_PROVIDER = os.getenv("DRAW_PROVIDER", "zenmux")
DEFAULT_BASE_URL = os.getenv("ZENMUX_VERTEX_BASE_URL", "https://zenmux.ai/api/vertex-ai")
DEFAULT_OUTPUT_ROOT = Path.home() / ".local" / "share" / "draw" / "outputs"
DEFAULT_MIME = "image/png"
DEFAULT_CODEX_MODEL = os.getenv("DRAW_CODEX_MODEL", "gpt-5.6")
def _read_env_value(path: Path, key: str) -> str:
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return ""
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
name, value = stripped.split("=", 1)
if name.strip() != key:
continue
value = value.strip().strip('"').strip("'")
return value
return ""
def resolve_api_key() -> str:
env = os.getenv("ZENMUX_API_KEY", "").strip()
if env:
return env
cwd = Path.cwd().resolve()
for directory in [cwd, *cwd.parents]:
found = _read_env_value(directory / ".env.local", "ZENMUX_API_KEY")
if found:
return found
config_path = Path.home() / ".config" / "see" / "api_key"
try:
return config_path.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return ""
def sanitize_name(value: str, fallback: str = "image") -> str:
value = value.strip()
value = re.sub(r"[\\/:*?\"<>|]+", "-", value)
value = re.sub(r"\s+", "-", value)
value = re.sub(r"-+", "-", value).strip("-_.")
if not value:
return fallback
return value[:80]
def build_output_path(*, output_arg: str, image_type: str, topic: str, explicit_name: str, ext: str) -> Path:
if output_arg:
out = Path(output_arg).expanduser().resolve()
if out.suffix:
return out
return out.with_suffix(ext)
now = datetime.now()
day_dir = DEFAULT_OUTPUT_ROOT / now.strftime("%Y-%m-%d")
day_dir.mkdir(parents=True, exist_ok=True)
base_name = sanitize_name(explicit_name or topic, fallback=image_type)
return day_dir / f"{now.strftime('%Y%m%d-%H%M%S')}__{image_type}__{base_name}{ext}"
def metadata_path_for(image_path: Path) -> Path:
return image_path.with_suffix(image_path.suffix + ".json")
def _output_paths_for_check(output_path: Path) -> list[Path]:
"""Return every path that a writer may choose before its MIME type is known."""
output_path = output_path.resolve()
if output_path.suffix:
return [output_path]
# The current writers default to PNG, while render_response may infer another
# image extension from the model response. Include existing siblings so an
# unknown future MIME type cannot silently replace one of them.
paths = [output_path.with_suffix(".png")]
pattern = f"{output_path.name}.*"
paths.extend(path for path in output_path.parent.glob(pattern) if path.is_file())
return list(dict.fromkeys(paths))
def ensure_output_available(output_path: Path) -> None:
"""Reject an output or its metadata before any remote generation starts."""
conflicts: list[Path] = []
for candidate in _output_paths_for_check(output_path):
if candidate.exists():
conflicts.append(candidate)
metadata_path = metadata_path_for(candidate)
if metadata_path.exists():
conflicts.append(metadata_path)
if conflicts:
paths = ", ".join(str(path) for path in dict.fromkeys(conflicts))
raise FileExistsError(f"输出或 metadata 已存在,拒绝覆盖:{paths}")
def _write_new_bytes(path: Path, data: bytes) -> None:
"""Create a file without ever replacing an existing output."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("xb") as handle:
handle.write(data)
def guess_extension(mime_type: str | None) -> str:
if not mime_type:
return ".png"
guessed = mimetypes.guess_extension(mime_type)
if guessed == ".jpe":
return ".jpg"
return guessed or ".png"
# Type only controls aspect ratio, prompt is fully controlled by caller
ASPECT_RATIOS = {
"ultrawide": "21:9",
"wide": "16:9",
"square": "1:1",
"portrait": "3:4",
"classic": "4:3",
}
CODEX_SIZE_PRESETS = {
"ultrawide": "1536x640",
"wide": "1536x864",
"classic": "1024x768",
"square": "1024x1024",
"portrait": "768x1024",
}
MODE_PROMPTS = {
"normal": "",
"replicate": (
"Use the reference image as the primary visual source. Recreate the UI screen as closely as possible. "
"Preserve layout, spacing, typography hierarchy, colors, shadows, border radius, icon style, density, "
"and the relative position of every major element. Do not redesign unless the prompt explicitly asks for changes. "
"If text is unreadable, preserve its visual length, alignment, and hierarchy. Output only the clean UI mockup, "
"with no browser chrome, watermark, annotations, or surrounding device frame."
),
"frame-lock": (
"Use the first reference image as a locked application frame. Preserve the sidebar, top navigation, brand area, "
"and persistent chrome as closely as possible. Redesign or generate only the content area requested by the prompt. "
"Keep the result as a clean full-screen UI mockup with no browser chrome or watermark."
),
"asset-redraw": (
"Use the reference image to recreate only the requested visual asset as a clean standalone asset. Remove surrounding "
"UI, labels, browser chrome, mockup frames, and unrelated elements unless explicitly requested. Preserve the source "
"asset's proportions, material, color, and brand feel with high clarity and generous padding."
),
}
def effective_prompt(prompt: str, mode: str) -> str:
mode_prompt = MODE_PROMPTS.get(mode, "")
if not mode_prompt:
return prompt
return f"{mode_prompt}\n\nUser request:\n{prompt}"
def download_file(url: str, dest: Path, timeout: int = 120) -> None:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=timeout) as response:
dest.write_bytes(response.read())
def resolve_ref(raw: str, tmp_dir: Path) -> Path:
parsed = urllib.parse.urlparse(raw)
if parsed.scheme in {"http", "https"}:
suffix = Path(parsed.path).suffix or ".png"
dest = tmp_dir / f"ref-{len(list(tmp_dir.iterdir())) + 1}{suffix}"
download_file(raw, dest)
return dest
path = Path(raw).expanduser().resolve()
if not path.exists():
raise FileNotFoundError(f"Reference not found: {raw}")
return path
def load_genai():
try:
from google import genai
from google.genai import types
except ModuleNotFoundError as exc:
raise SystemExit(
"[ERROR] Missing dependency `google-genai`. Re-run via scripts/ask_draw.sh so it can auto-install it."
) from exc
return genai, types
def build_contents(*, prompt: str, types, refs: Iterable[Path]):
parts = [types.Part.from_text(text=prompt)]
for ref in refs:
mime = mimetypes.guess_type(ref.name)[0] or DEFAULT_MIME
parts.append(types.Part.from_bytes(data=ref.read_bytes(), mime_type=mime))
return parts
def extract_parts(response) -> list:
if getattr(response, "parts", None):
return list(response.parts)
candidates = getattr(response, "candidates", None) or []
parts = []
for candidate in candidates:
content = getattr(candidate, "content", None)
if content and getattr(content, "parts", None):
parts.extend(content.parts)
return parts
def render_response(*, response, output_path: Path) -> tuple[str, str]:
text_parts: list[str] = []
image_written = False
image_mime = DEFAULT_MIME
pending_bytes: bytes | None = None
for part in extract_parts(response):
text = getattr(part, "text", None)
if text:
text_parts.append(text.strip())
inline_data = getattr(part, "inline_data", None)
if inline_data:
data = inline_data.data
if isinstance(data, str):
pending_bytes = base64.b64decode(data)
else:
pending_bytes = data
image_mime = getattr(inline_data, "mime_type", None) or DEFAULT_MIME
if pending_bytes:
final_path = output_path
if not output_path.suffix:
final_path = output_path.with_suffix(guess_extension(image_mime))
_write_new_bytes(final_path, pending_bytes)
image_written = True
else:
final_path = output_path
if not image_written:
raise RuntimeError("Model returned no image data.")
return final_path.as_posix(), "\n".join([t for t in text_parts if t]).strip()
def resolve_codex_api_key() -> str:
key = os.getenv("OPENAI_IMAGE_API_KEY") or os.getenv("OPENAI_API_KEY")
return key.strip() if key else ""
def resolve_codex_base_url() -> str:
base_url = (os.getenv("OPENAI_IMAGE_BASE_URL") or "https://api.openai.com/v1").rstrip("/")
parsed = urllib.parse.urlparse(base_url)
if parsed.scheme and parsed.netloc and parsed.path in ("", "/"):
return f"{base_url}/v1"
return base_url
def resolve_codex_model(override: str = "") -> str:
return override.strip() or DEFAULT_CODEX_MODEL
def join_endpoint(base_url: str, endpoint: str) -> str:
base = base_url.rstrip("/")
endpoint = endpoint.lstrip("/")
if base.endswith("/v1") and endpoint.startswith("v1/"):
endpoint = endpoint[3:]
return f"{base}/{endpoint}"
def ref_to_input_image(path: Path) -> dict:
mime = mimetypes.guess_type(path.name)[0] or DEFAULT_MIME
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return {"type": "input_image", "image_url": f"data:{mime};base64,{encoded}"}
def looks_like_base64_image(value: str) -> bool:
if len(value) < 200:
return False
compact = value.strip()
if compact.startswith("data:image/"):
compact = compact.split(",", 1)[-1]
try:
head = base64.b64decode(compact[:256] + "==", validate=False)
except Exception:
return False
return head.startswith(b"\x89PNG") or head.startswith(b"\xff\xd8\xff") or head.startswith(b"RIFF")
def find_image_result_recursive(value: object) -> str | None:
if isinstance(value, dict):
value_type = value.get("type")
for key in ("result", "b64_json", "image_base64"):
item = value.get(key)
if isinstance(item, str) and (value_type == "image_generation_call" or looks_like_base64_image(item)):
return item.split(",", 1)[-1] if item.startswith("data:image/") else item
for item in value.values():
found = find_image_result_recursive(item)
if found:
return found
elif isinstance(value, list):
for item in value:
found = find_image_result_recursive(item)
if found:
return found
elif isinstance(value, str) and looks_like_base64_image(value):
return value.split(",", 1)[-1] if value.startswith("data:image/") else value
return None
def request_codex_image(
*, prompt: str, refs: list[Path], image_type: str, model: str, output_path: Path
) -> Path:
api_key = resolve_codex_api_key()
if not api_key:
raise RuntimeError("No OPENAI_IMAGE_API_KEY or OPENAI_API_KEY found.")
base_url = resolve_codex_base_url()
endpoint = join_endpoint(base_url, "responses")
content: list[dict] = [{"type": "input_text", "text": prompt}]
content.extend(ref_to_input_image(ref) for ref in refs)
payload = {
"model": model,
"instructions": "Use the image_generation tool to create exactly the requested image. Do not add extra text.",
"stream": False,
"store": False,
"input": [{"role": "user", "content": content}],
"tools": [{"type": "image_generation", "size": CODEX_SIZE_PRESETS.get(image_type, "1024x1024")}],
"tool_choice": "required",
}
request = urllib.request.Request(
endpoint,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=600) as response:
raw = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} from Codex image API:\n{details}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Could not reach Codex image API: {exc.reason}") from exc
try:
response_payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Codex image API returned non-JSON response:\n{raw[:1500]}") from exc
image_b64 = find_image_result_recursive(response_payload)
if not image_b64:
raise RuntimeError("No image result found in Codex Responses API output.")
final_path = output_path if output_path.suffix else output_path.with_suffix(".png")
final_path.parent.mkdir(parents=True, exist_ok=True)
try:
image_bytes = base64.b64decode(image_b64, validate=True)
except (binascii.Error, ValueError, TypeError) as exc:
raise RuntimeError("Codex image API returned invalid base64 image data.") from exc
_write_new_bytes(final_path, image_bytes)
return final_path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate UI images via ZenMux or a Codex OpenAI-compatible provider.")
parser.add_argument("--type", choices=sorted(ASPECT_RATIOS.keys()), default="wide", help="Aspect ratio preset.")
parser.add_argument("--prompt", required=True, help="Full prompt for image generation.")
parser.add_argument("--ref", action="append", default=[], help="Reference image path or URL (repeatable).")
parser.add_argument("--name", default="", help="Optional short output name.")
parser.add_argument("-o", "--output", default="", help="Output image path.")
parser.add_argument("--provider", choices=["zenmux", "codex"], default=DEFAULT_PROVIDER, help="Image backend.")
parser.add_argument("--mode", choices=sorted(MODE_PROMPTS.keys()), default="normal", help="UI prompt wrapper.")
parser.add_argument("--model", default="", help="Model override (defaults per provider).")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=argparse.SUPPRESS)
return parser.parse_args()
def _uses_generate_images_api(model: str) -> bool:
"""Models that require the generate_images / edit_image API instead of generate_content."""
return model.startswith("openai/")
def _run_generate_images(*, client, model: str, prompt: str, refs: list[Path], types) -> tuple[bytes, str]:
"""Call generate_images (or edit_image when refs are provided) and return (image_bytes, response_text)."""
if refs:
# Use edit_image with reference images
# First ref becomes the base image
base_image_path = refs[0]
base_mime = mimetypes.guess_type(base_image_path.name)[0] or DEFAULT_MIME
base_image = types.Image(image_bytes=base_image_path.read_bytes(), mime_type=base_mime)
reference_images = [
types.RawReferenceImage(reference_id=1, reference_image=base_image)
]
# Additional refs as extra references
for i, ref_path in enumerate(refs[1:], start=2):
ref_mime = mimetypes.guess_type(ref_path.name)[0] or DEFAULT_MIME
ref_img = types.Image(image_bytes=ref_path.read_bytes(), mime_type=ref_mime)
reference_images.append(
types.RawReferenceImage(reference_id=i, reference_image=ref_img)
)
response = client.models.edit_image(
model=model,
prompt=prompt,
reference_images=reference_images,
)
else:
response = client.models.generate_images(
model=model,
prompt=prompt,
)
generated = getattr(response, "generated_images", None)
if not generated:
raise RuntimeError("Model returned no generated images.")
image_obj = generated[0].image
image_bytes = getattr(image_obj, "image_bytes", None)
if image_bytes is None:
# Some versions expose .data as base64
raw = getattr(image_obj, "data", None)
if isinstance(raw, str):
image_bytes = base64.b64decode(raw)
elif isinstance(raw, bytes):
image_bytes = raw
if not image_bytes:
raise RuntimeError("Could not extract image bytes from generate_images response.")
return image_bytes, ""
def main() -> int:
args = parse_args()
aspect_ratio = ASPECT_RATIOS[args.type]
prompt = effective_prompt(args.prompt, args.mode)
model = resolve_codex_model(args.model) if args.provider == "codex" else (args.model or DEFAULT_MODEL)
output_path = build_output_path(
output_arg=args.output,
image_type=args.type,
topic=args.name or "image",
explicit_name=args.name,
ext=".png",
)
ensure_output_available(output_path)
with tempfile.TemporaryDirectory(prefix="draw-refs-") as tmp:
tmp_dir = Path(tmp)
refs = [resolve_ref(raw, tmp_dir) for raw in args.ref]
if args.provider == "codex":
final_path = request_codex_image(
prompt=prompt,
refs=refs,
image_type=args.type,
model=model,
output_path=output_path,
)
response_text = ""
else:
api_key = resolve_api_key()
if not api_key:
print(
"[ERROR] No ZENMUX_API_KEY found. Set it as env var, in .env.local, or in ~/.config/see/api_key",
file=sys.stderr,
)
return 1
genai, types = load_genai()
# OpenAI image models via ZenMux can take longer; bump timeout to 5 minutes.
timeout = 300 if _uses_generate_images_api(model) else 120
client = genai.Client(
api_key=api_key,
vertexai=True,
http_options=types.HttpOptions(api_version="v1", base_url=args.base_url, timeout=timeout * 1000),
)
if _uses_generate_images_api(model):
image_bytes, response_text = _run_generate_images(
client=client, model=model, prompt=prompt, refs=refs, types=types,
)
final_path = output_path if output_path.suffix else output_path.with_suffix(".png")
final_path.parent.mkdir(parents=True, exist_ok=True)
_write_new_bytes(final_path, image_bytes)
else:
response = client.models.generate_content(
model=model,
contents=build_contents(prompt=prompt, types=types, refs=refs),
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(aspect_ratio=aspect_ratio),
),
)
final_path_str, response_text = render_response(response=response, output_path=output_path)
final_path = Path(final_path_str)
meta_path = metadata_path_for(final_path)
metadata = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"type": args.type,
"aspect_ratio": aspect_ratio,
"prompt": prompt,
"raw_prompt": args.prompt,
"refs": [str(path) for path in refs],
"provider": args.provider,
"mode": args.mode,
"model": model,
"base_url": args.base_url if args.provider == "zenmux" else resolve_codex_base_url(),
"output_path": str(final_path),
"response_text": response_text,
}
meta_path.parent.mkdir(parents=True, exist_ok=True)
with meta_path.open("x", encoding="utf-8") as handle:
handle.write(json.dumps(metadata, ensure_ascii=False, indent=2))
print(f"output_path={final_path}")
print(f"metadata_path={meta_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""测量已标注的参考图区域,输出原始几何、局部颜色、重复网格和阈值扫描;不猜测原始 CSS。"""
from __future__ import annotations
import argparse
import base64
import hashlib
import html
import json
import math
from pathlib import Path
from statistics import median
from PIL import Image
def positive(value, name):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise ValueError(f'{name} 必须是有限正数')
return value
def integer(value, name):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f'{name} 必须是整数')
return value
def box_checked(box, size):
if not isinstance(box, list) or len(box) != 4:
raise ValueError('box 必须为 [x,y,width,height]')
x, y, w, h = [integer(v, 'box 坐标') for v in box]
if min(x, y) < 0 or min(w, h) <= 0 or x+w > size[0] or y+h > size[1]:
raise ValueError('标注越界或面积无效;不自动裁短')
return x, y, w, h
def distribution(values):
values = sorted(values)
def quantile(p):
pos = (len(values)-1)*p
lo, hi = math.floor(pos), math.ceil(pos)
return round(values[lo] + (values[hi]-values[lo])*(pos-lo), 3)
return {'median': quantile(.5), 'p10': quantile(.1), 'p90': quantile(.9)}
def measure(manifest_path, out):
data = json.loads(manifest_path.read_text(encoding='utf-8'))
source = (manifest_path.parent / data['reference']).resolve()
with Image.open(source) as original:
im = original.convert('RGBA')
css_width = data.get('css_viewport_width')
factor = positive(css_width, 'css_viewport_width')/im.width if css_width is not None else None
regions = []
names = set()
def take_name(item):
name = item['name']
if not isinstance(name, str) or not name.strip() or name in names:
raise ValueError('标注名称必须非空且唯一')
names.add(name)
return name
for item in data.get('regions', []):
name = take_name(item)
kind = item.get('kind', 'box')
if kind not in ('box', 'color'):
raise ValueError('kind 只支持 box 或 color')
x,y,w,h = box_checked(item['box'], im.size)
box_type = item.get('box_type', 'sample' if kind == 'color' else 'element')
if box_type not in ('element', 'ink', 'sample') or (kind == 'color' and box_type != 'sample'):
raise ValueError('box_type 必须为 element、ink 或 sample;颜色区只能为 sample')
region = {'name': name, 'kind':kind, 'box_type':box_type, 'box':[x,y,w,h],
'normalized_box':[round(x/im.width,6),round(y/im.height,6),round(w/im.width,6),round(h/im.height,6)],
'boundary_evidence':'Agent 选择的标注范围,非自动识别的真实元素边界'}
if factor is not None:
region['css_box'] = [round(v*factor,3) for v in [x,y,w,h]]
if 'selector' in item: region['selector'] = item['selector']
region['dom_comparable'] = box_type == 'element'
if 'group' in item:
if box_type != 'element': raise ValueError('重复网格只接受元素框,不能混用字形框或采样框')
if not isinstance(item['group'],str) or not item['group'].strip(): raise ValueError('group 必须是非空字符串')
region['group'] = item['group']
if kind == 'color':
inset=integer(item.get('inset',0),'inset')
if inset < 0 or inset*2 >= min(w,h): raise ValueError('inset 使采样区域无效')
crop = im.crop((x+inset,y+inset,x+w-inset,y+h-inset))
# 只统计完全不透明的像素,不将透明底错误统计为黑色。
pixels = [p for p in crop.get_flattened_data() if p[3] == 255] if hasattr(crop,'get_flattened_data') else [p for p in crop.getdata() if p[3] == 255]
if not pixels: raise ValueError(f'{name} 没有不透明的颜色样本')
channels = [distribution([p[c] for p in pixels]) for c in range(3)]
rgb = [round(c['median']) for c in channels]
region['color'] = {'median_hex':'#' + ''.join(f'{v:02X}' for v in rgb),'rgb_channels':channels,
'opaque_samples':len(pixels),'excluded_nonopaque':crop.width*crop.height-len(pixels),
'sample_box':[x+inset,y+inset,w-inset*2,h-inset*2],
'max_channel_p90_minus_p10':round(max(c['p90']-c['p10'] for c in channels),3),
'meaning':'局部像素统计,不证明原始色值、纯色或渐变模型'}
regions.append(region)
groups=[]
for name in sorted({r['group'] for r in regions if 'group' in r}):
members=sorted([r for r in regions if r.get('group')==name],key=lambda r:r['box'][0])
if len(members)<2: raise ValueError('重复网格至少需要两个标注')
gaps=[b['box'][0]-(a['box'][0]+a['box'][2]) for a,b in zip(members,members[1:])]
groups.append({'name':name,'members':[r['name'] for r in members],'x_gaps':gaps,
'median_width':median([r['box'][2] for r in members]),'median_height':median([r['box'][3] for r in members]),
'median_gap':median(gaps),'y_spread':max(r['box'][1] for r in members)-min(r['box'][1] for r in members),
'width_spread':max(r['box'][2] for r in members)-min(r['box'][2] for r in members),
'meaning':'横向重复结构的描述统计,不自动把不等宽设计改成等宽'})
scans=[]
for spec in data.get('vertical_scans',[]):
name=take_name(spec)
x,y,_,h=box_checked([spec['x'],spec['y'],1,spec['height']],im.size)
floor=integer(spec['min_channel'],'min_channel'); spread=integer(spec['max_channel_spread'],'max_channel_spread')
run_min=integer(spec.get('min_run',1),'min_run')
if not 0<=floor<=255 or not 0<=spread<=255 or run_min<1: raise ValueError('扫描阈值无效')
runs=[];start=None
for yy in range(y,y+h+1):
p=im.getpixel((x,yy)) if yy<y+h else None
match=p is not None and p[3]==255 and min(p[:3])>=floor and max(p[:3])-min(p[:3])<=spread
if match and start is None: start=yy
if not match and start is not None:
if yy-start>=run_min:runs.append({'y_start':start,'y_end_exclusive':yy,'height':yy-start})
start=None
scans.append({'name':name,'configuration':spec,'runs':runs,'meaning':'满足给定颜色阈值的连续像素,不自动认定为区块边界'})
if not regions and not scans: raise ValueError('至少提供一个区域或扫描')
result={'source':str(source),'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'source_size':list(im.size),
'css_mapping':{'assumed_viewport_width':css_width,'css_per_image_pixel':factor,'meaning':'实现约定,不证明参考图原始 DPR 或 CSS 视口'},
'regions':regions,'groups':groups,'vertical_scans':scans}
if 'expected' in data:
result['expected'] = data['expected']
result['expected_assets'] = data.get('expected_assets', {})
result['input_manifest_sha256'] = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
# 所有输入检查完成后才创建输出,且拒绝复用已有轮次。
out.mkdir(parents=True,exist_ok=False)
(out/'measurements.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
# PNG 编码原图用于自包含的 SVG 标注层;不缩放、去背或重建素材。
import io
buffer=io.BytesIO();im.save(buffer,format='PNG')
encoded=base64.b64encode(buffer.getvalue()).decode('ascii')
svg=[f'<svg xmlns="http://www.w3.org/2000/svg" width="{im.width}" height="{im.height}" viewBox="0 0 {im.width} {im.height}">',f'<image width="{im.width}" height="{im.height}" href="data:image/png;base64,{encoded}"/>']
for region in regions:
x,y,w,h=region['box'];label=html.escape(region['name']);color='#36edcc' if region['kind']=='box' else '#f6cb46'
svg.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="none" stroke="{color}" stroke-width="2"/><text x="{x+3}" y="{y+15}" fill="{color}" stroke="#000" stroke-width="3" paint-order="stroke" font-size="13" font-family="sans-serif">{label}</text>')
svg.append('</svg>')
(out/'annotations.svg').write_text('\n'.join(svg),encoding='utf-8')
return result
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest',type=Path,required=True)
parser.add_argument('--out-dir',type=Path,required=True)
args=parser.parse_args()
try:
result=measure(args.manifest,args.out_dir)
print(json.dumps({'status':'标注区域测量完成,设计关系仍需判断','regions':len(result['regions']),'groups':len(result['groups']),'output':str(args.out_dir)},ensure_ascii=False))
except (OSError,ValueError,KeyError,TypeError) as error:
parser.exit(2,f'测量失败:{error}\n')
if __name__=='__main__':main()
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""从同一测量基线生成浏览器采集表达式与验收配置,不启动浏览器。"""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
from urllib.parse import urlparse
from PIL import Image
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def read_baseline(path, expected_digest=None):
actual_digest = digest(path)
if expected_digest is not None and actual_digest != expected_digest:
raise ValueError('测量基线已改动;为新基线创建新轮次,不能沿用旧采集')
data = json.loads(path.read_text(encoding='utf-8'))
source = Path(data['source'])
if not source.is_absolute(): source = path.parent / source
if digest(source) != data['source_sha256']:
raise ValueError('参考图已改动,与测量基线不符')
with Image.open(source) as image:
if list(image.size) != data['source_size']: raise ValueError('参考图尺寸与测量基线不符')
return data, source, actual_digest
def validate_expected(data):
expected = data.get('expected')
if not isinstance(expected, dict): raise ValueError('先在区域清单填写 expected 并重新测量,不能后补截图基线')
if not isinstance(expected.get('title'), str) or not expected['title'].strip(): raise ValueError('缺少页面标题')
url = urlparse(expected.get('url', ''))
if url.scheme not in ('http', 'https') or not url.hostname: raise ValueError('页面 URL 必须为 http(s) 地址')
for value in [expected['viewport']['width'], expected['viewport']['height'], expected['dpr']]:
if isinstance(value, bool) or not isinstance(value, (int,float)) or not math.isfinite(value) or value <= 0:
raise ValueError('视口与 DPR 必须为有限正数')
if type(expected.get('full_page')) is not bool or expected.get('screenshot_scale') not in ('css','device'):
raise ValueError('明确 full_page 布尔值与 screenshot_scale: css/device')
if data['css_mapping']['assumed_viewport_width'] != expected['viewport']['width']:
raise ValueError('测量的 CSS 映射与截图视口不一致')
factor = expected['viewport']['width']/data['source_size'][0]
if not math.isclose(data['css_mapping']['css_per_image_pixel'], factor):
raise ValueError('测量基线的比例无效')
scale = expected['dpr'] if expected['screenshot_scale'] == 'device' else 1
if abs(expected['viewport']['width']*scale - data['source_size'][0]) >= 1:
raise ValueError('截图输出宽度与参考图不等;调整截图像素模式或另建基线,不自动缩放')
if not expected['full_page'] and abs(expected['viewport']['height']*scale - data['source_size'][1]) >= 1:
raise ValueError('视口截图高度与参考图不等;长图应明确 full_page')
return expected
def targets_for(data):
names = set()
targets = []
for region in data['regions']:
name = region['name']
if name in names: raise ValueError('测量区域重名')
names.add(name)
if 'selector' not in region: continue
if not isinstance(region['selector'], str) or not region['selector'].strip(): raise ValueError('selector 必须为非空 CSS 选择器')
targets.append({'name': name, 'selector': region['selector']})
if not targets: raise ValueError('至少为一个标注提供 selector,才能生成采集计划')
return targets
def prepare(path, out):
path, out = path.resolve(), out.resolve()
data, source, baseline_digest = read_baseline(path)
expected = validate_expected(data)
targets = targets_for(data)
plan = {'measurement_sha256':baseline_digest, 'targets':targets}
collector = Path(__file__).with_name('collect_browser.js').read_text(encoding='utf-8')
expression = f'({collector})({json.dumps(plan,ensure_ascii=False)})'
run = {'reference':os.path.relpath(source, out), 'candidate':'candidate.png', 'capture_metadata':'capture.json',
'expected':expected, 'measurement_baseline':os.path.relpath(path.resolve(),out),
'measurement_sha256':baseline_digest, 'allow_height_difference':expected['full_page'],
'expected_assets':data.get('expected_assets',{}),
'clips':[f"region-{i:03d}:"+','.join(str(v) for v in r['box']) for i,r in enumerate(data['regions'])]}
out.mkdir(parents=True, exist_ok=False)
for filename, obj in [('run.json',run),('capture-plan.json',plan)]:
(out/filename).write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8')
(out/'collect.js').write_text(expression,encoding='utf-8')
(out/'round-notes.md').write_text('# 本轮校准记录\n\n填写本轮目标区域、依据、调整的变量,以及报告产生后确认的收益与退步;区分测量事实与视觉判断。\n',encoding='utf-8')
return run
def compare_geometry(run, capture, base, previous=None):
path = (base / run['measurement_baseline']).resolve()
data, source, baseline_digest = read_baseline(path, run['measurement_sha256'])
expected = validate_expected(data)
if run['expected'] != expected or (base/run['reference']).resolve() != source.resolve():
raise ValueError('验收配置与测量基线的参考图或截图条件不一致')
if run.get('expected_assets',{}) != data.get('expected_assets',{}):
raise ValueError('关键素材约定与测量基线不一致')
if capture.get('measurement_sha256') != baseline_digest:
raise ValueError('采集脚本来自其他测量基线;重新准备并采集')
targets = targets_for(data)
rows = capture.get('elements')
if not isinstance(rows,list): raise ValueError('缺少生成采集脚本产生的 elements')
actual = {}
for row in rows:
if row['name'] in actual: raise ValueError('采集结果包含重复区域')
actual[row['name']] = row
if set(actual) != {r['name'] for r in targets}: raise ValueError('采集区域与测量清单不一致')
regions = []
for r in data['regions']:
if 'selector' not in r:
regions.append({'name':r['name'],'status':'reference_only','reason':'仅测量参考图,没有 DOM 选择器'})
continue
row = actual[r['name']]
if row['selector'] != r['selector']: raise ValueError('采集选择器与测量清单不一致')
status = row['status']
if status not in ('measured','missing','ambiguous','hidden','unmeasurable','invalid_selector'):
raise ValueError('未知采集状态')
item = {'name':r['name'],'selector':r['selector'],'status':status,'box_type':r['box_type']}
if status == 'measured':
if row.get('count') != 1: raise ValueError('可测量元素必须唯一匹配')
box = row.get('box')
if not isinstance(box,list) or len(box)!=4 or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) for v in box) or min(box[2:])<=0:
raise ValueError('采集元素框无效')
item['actual_css_box'] = box
item['computed'] = row.get('computed',{})
if r['box_type'] != 'element':
item['status'] = 'visual_only'
item['reason'] = '字形框或采样区不与 DOM 元素外框比较'
else:
factor=data['css_mapping']['css_per_image_pixel']
ref=[v*factor for v in r['box']]
item['reference_css_box']=[round(v,3) for v in ref]
item['delta_css']=[round(a-b,3) for a,b in zip(box,ref)]
item['max_abs_delta_css']=round(max(abs(a-b) for a,b in zip(box,ref)),3)
regions.append(item)
incomplete = [r['name'] for r in regions if r['status'] not in ('measured','visual_only','reference_only')]
result = {'measurement_sha256':baseline_digest,'complete':not incomplete,'unavailable_regions':incomplete,
'regions':regions,'note':'偏差是 CSS 像素,不是还原率;布局可测量不证明未遮挡、字体正确或交互可用。'}
if previous is not None:
previous_report=json.loads(previous.read_text(encoding='utf-8'))
before=previous_report.get('geometry',{})
if before.get('measurement_sha256') != baseline_digest:
raise ValueError('前轮报告不是同一测量基线,不能声称前后改善')
for key in ('viewport','dpr','full_page','screenshot_scale','title','url'):
if previous_report['capture'].get(key) != capture.get(key):
raise ValueError(f'前后采集条件不同:{key}')
old={r['name']:r for r in before['regions']}
changes=[]
for item in regions:
prev=old.get(item['name'],{})
a,b=prev.get('max_abs_delta_css'),item.get('max_abs_delta_css')
changes.append({'name':item['name'],'before':a,'after':b,'delta':round(b-a,3) if a is not None and b is not None else None,
'change':'unavailable' if a is None or b is None else 'reduced' if b<a else 'increased' if b>a else 'unchanged'})
result['changes']=changes
return result
def write_geometry_markdown(out, geometry):
def cell(value): return str(value).replace('|','\\|').replace('\n',' ')
lines=['# 元素几何偏差','',geometry['note'],'', '| 区域 | 状态 | Δx | Δy | Δ宽 | Δ高 |','| --- | --- | --- | --- | --- | --- |']
for r in geometry['regions']:
lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['status'],*r.get('delta_css',['—']*4)])+' |')
if 'changes' in geometry:
lines+=['','## 相对前轮的最大绝对偏差','','| 区域 | 前轮 | 本轮 | 变化 |','| --- | --- | --- | --- |']
for r in geometry['changes']: lines.append('| '+' | '.join(cell(v) for v in [r['name'],r['before'],r['after'],r['change']])+' |')
(out/'geometry.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--measurements',type=Path,required=True)
p.add_argument('--out-dir',type=Path,required=True)
args=p.parse_args()
try:
prepare(args.measurements,args.out_dir)
print(json.dumps({'status':'采集计划已生成;在已授权浏览器执行 collect.js 并保存真实截图','output':str(args.out_dir)},ensure_ascii=False))
except (OSError,ValueError,KeyError,TypeError) as error:
p.exit(2,f'准备失败:{error}\n')
if __name__=='__main__':main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image
def foreground_mask(img: Image.Image, *, threshold: int) -> Image.Image:
rgb = img.convert("RGB")
return Image.eval(rgb, lambda px: 0 if px >= threshold else 255).convert("L")
def trim(img: Image.Image, *, threshold: int, padding: int) -> Image.Image:
mask = foreground_mask(img, threshold=threshold)
bbox = mask.getbbox()
if bbox is None:
return img
left, top, right, bottom = bbox
left = max(0, left - padding)
top = max(0, top - padding)
right = min(img.width, right + padding)
bottom = min(img.height, bottom + padding)
return img.crop((left, top, right, bottom))
def trim_alpha(img: Image.Image, *, padding: int) -> Image.Image:
rgba = img.convert("RGBA")
alpha = rgba.getchannel("A")
bbox = alpha.getbbox()
if bbox is None:
return rgba
left, top, right, bottom = bbox
left = max(0, left - padding)
top = max(0, top - padding)
right = min(rgba.width, right + padding)
bottom = min(rgba.height, bottom + padding)
return rgba.crop((left, top, right, bottom))
def white_to_alpha(img: Image.Image, *, threshold: int, feather: int) -> Image.Image:
rgba = img.convert("RGBA")
pixels = rgba.load()
for y in range(rgba.height):
for x in range(rgba.width):
red, green, blue, alpha = pixels[x, y]
brightness = max(red, green, blue)
if red >= threshold and green >= threshold and blue >= threshold:
pixels[x, y] = (255, 255, 255, 0)
continue
if feather > 0 and brightness >= threshold - feather:
new_alpha = int(alpha * (threshold - brightness + feather) / feather)
pixels[x, y] = (red, green, blue, max(0, min(alpha, new_alpha)))
return rgba
def parse_hex_color(value: str) -> tuple[int, int, int]:
raw = value.strip().lstrip("#")
if len(raw) != 6:
raise ValueError("key color must be a 6-digit hex color, for example #00ff00")
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
def key_color_to_alpha(
img: Image.Image,
*,
key_color: tuple[int, int, int],
threshold: int,
feather: int,
despill: bool,
edge_contract: int,
) -> Image.Image:
rgba = img.convert("RGBA")
pixels = rgba.load()
kr, kg, kb = key_color
for y in range(rgba.height):
for x in range(rgba.width):
red, green, blue, alpha = pixels[x, y]
distance = ((red - kr) ** 2 + (green - kg) ** 2 + (blue - kb) ** 2) ** 0.5
if distance <= threshold:
pixels[x, y] = (red, green, blue, 0)
continue
if feather > 0 and distance <= threshold + feather:
new_alpha = int(alpha * (distance - threshold) / feather)
pixels[x, y] = (red, green, blue, max(0, min(alpha, new_alpha)))
if despill:
pixels = rgba.load()
for y in range(rgba.height):
for x in range(rgba.width):
red, green, blue, alpha = pixels[x, y]
if alpha == 0:
continue
# Remove green spill on semi-transparent and edge pixels.
green_excess = green - max(red, blue)
if green_excess <= 0:
continue
edge_factor = 1.0 - (alpha / 255.0)
reduction = int(green_excess * (0.72 + 0.28 * edge_factor))
green = max(max(red, blue), green - reduction)
pixels[x, y] = (red, green, blue, alpha)
if edge_contract > 0:
rgba = contract_alpha_edge(rgba, pixels=edge_contract)
return rgba
def contract_alpha_edge(img: Image.Image, *, pixels: int) -> Image.Image:
rgba = img.convert("RGBA")
alpha = rgba.getchannel("A")
try:
from PIL import ImageFilter
for _ in range(pixels):
alpha = alpha.filter(ImageFilter.MinFilter(3))
rgba.putalpha(alpha)
except Exception:
pass
return rgba
def main() -> int:
parser = argparse.ArgumentParser(
description="Prepare AI-generated UI assets by trimming white padding and optionally making white background transparent."
)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--no-trim", action="store_true", help="Keep original canvas size.")
parser.add_argument("--alpha", action="store_true", help="Convert near-white background to transparent alpha.")
parser.add_argument("--key-color", help="Convert a chroma key color such as #00ff00 to transparent alpha.")
parser.add_argument("--threshold", type=int, default=248, help="Near-white threshold, 0-255.")
parser.add_argument("--key-threshold", type=int, default=28, help="Chroma key distance threshold.")
parser.add_argument("--feather", type=int, default=12, help="Soft alpha range below threshold.")
parser.add_argument("--despill", action="store_true", help="Reduce green spill on chroma-keyed edges.")
parser.add_argument("--edge-contract", type=int, default=0, help="Shrink alpha edge by this many pixels after keying.")
parser.add_argument("--padding", type=int, default=24, help="Padding kept around trimmed foreground.")
args = parser.parse_args()
img = Image.open(args.input).convert("RGBA")
if args.key_color:
img = key_color_to_alpha(
img,
key_color=parse_hex_color(args.key_color),
threshold=args.key_threshold,
feather=args.feather,
despill=args.despill,
edge_contract=args.edge_contract,
)
if not args.no_trim:
img = trim_alpha(img, padding=args.padding)
elif not args.no_trim:
img = trim(img, threshold=args.threshold, padding=args.padding)
if args.alpha:
img = white_to_alpha(img, threshold=args.threshold, feather=args.feather)
args.output.parent.mkdir(parents=True, exist_ok=True)
img.save(args.output)
print(f"wrote {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""验证已授权浏览器产生的截图与元数据,再运行像素对比;本脚本不启动浏览器。"""
import argparse
import json
from pathlib import Path
import subprocess
import sys
from urllib.parse import urldefrag, urlparse
from urllib.request import urlopen
from html.parser import HTMLParser
from PIL import Image
from prepare_capture import compare_geometry, write_geometry_markdown
def verify(manifest_path, out_dir, previous=None):
if out_dir.exists():
raise ValueError('输出目录已存在;使用新的轮次目录,拒绝覆盖')
data = json.loads(manifest_path.read_text(encoding='utf-8'))
base = manifest_path.parent
expected = data['expected']
capture = json.loads((base / data['capture_metadata']).read_text(encoding='utf-8'))
candidate = base / data['candidate']
reference = base / data['reference']
failures = []
def check(condition, message):
if not condition:
failures.append(message)
for key in ('url', 'title', 'viewport', 'dpr'):
if key not in capture or key not in expected:
raise ValueError(f'缺少必填字段:{key}')
check(bool(expected['title']) and capture['title'] == expected['title'], '页面标题不匹配')
check(bool(expected['url']) and urldefrag(capture['url'])[0] == urldefrag(expected['url'])[0], '页面URL不匹配')
check(capture['viewport'] == expected['viewport'], '实际视口不匹配')
check(capture['dpr'] == expected['dpr'] and capture['dpr'] > 0, '实际DPR不匹配')
check(capture.get('fonts_ready') is True, '字体尚未加载完成')
check(capture.get('images_ready') is True, '图片尚未加载完成')
check(capture.get('full_page') == expected['full_page'], '截图范围与约定不一致')
scale_mode = capture['screenshot_scale']
check(scale_mode == expected['screenshot_scale'] and scale_mode in ('css', 'device'), '截图像素缩放方式不匹配')
scale = capture['dpr'] if scale_mode == 'device' else 1
target_height = capture['document_height'] if capture['full_page'] else capture['viewport']['height']
with Image.open(candidate) as image:
check(abs(image.width - capture['viewport']['width']*scale) < 1, '截图宽度与元数据不符')
check(abs(image.height - target_height*scale) < 1, '截图高度与元数据不符')
for name, asset in data.get('expected_assets', {}).items():
actual = capture.get('assets', {}).get(name, {})
check(bool(asset) and all(actual.get(key) == value for key, value in asset.items()), f'资源版本或尺寸不匹配:{name}')
if failures:
raise ValueError(';'.join(failures))
geometry = None
if 'measurement_baseline' in data:
geometry = compare_geometry(data, capture, base, previous)
elif previous is not None:
raise ValueError('前后比较需要由测量基线生成的验收配置')
command = [sys.executable, str(Path(__file__).with_name('compare_mockup.py')),
'--reference', str(reference), '--candidate', str(candidate), '--out-dir', str(out_dir), '--prefix', 'verify']
if data.get('allow_height_difference'):
command.append('--allow-height-difference')
for clip in data.get('clips', []):
command.extend(['--clip', clip])
out_dir.mkdir(parents=True, exist_ok=False)
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode:
raise ValueError(result.stderr.strip())
actual_sections = {section['name']: section for section in capture.get('sections', [])}
positions = []
for name, target_y in data.get('section_positions', {}).items():
actual = actual_sections.get(name)
positions.append({'name': name, 'expected_y': target_y, 'actual_y': actual['y'] if actual else None,
'delta_y': round(actual['y']-target_y, 3) if actual else None})
report = {'status': '截图身份与渲染前提通过;视觉仍需复核', 'manifest': str(manifest_path.resolve()),
'capture': capture, 'section_positions': positions,
'comparison': str(out_dir / 'verify-metrics.json')}
if geometry is not None:
report['geometry'] = geometry
write_geometry_markdown(out_dir, geometry)
if not geometry['complete']:
report['status'] = '截图前提通过,但元素采集不完整,需处理报告中的缺项'
(out_dir / 'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(report, ensure_ascii=False, indent=2))
if geometry is not None and not geometry['complete']:
raise ValueError('元素采集不完整;诊断报告已保存')
def preflight(manifest_path):
expected = json.loads(manifest_path.read_text(encoding='utf-8'))['expected']
url = expected['url']
if urlparse(url).hostname not in ('127.0.0.1', 'localhost', '::1'):
raise ValueError('启动前检查仅用于本地页面')
class TitleParser(HTMLParser):
def __init__(self):
super().__init__()
self.inside = False
self.parts = []
def handle_starttag(self, tag, attrs):
if tag == 'title': self.inside = True
def handle_endtag(self, tag):
if tag == 'title': self.inside = False
def handle_data(self, text):
if self.inside: self.parts.append(text)
with urlopen(url, timeout=8) as response:
if urldefrag(response.geturl())[0] != urldefrag(url)[0]:
raise ValueError('页面重定向到其他URL')
html = response.read(2_000_000).decode('utf-8')
parser = TitleParser()
parser.feed(html)
actual = ''.join(parser.parts).strip()
if not expected['title'] or actual != expected['title']:
raise ValueError('本地HTML标题不匹配,禁止继续浏览器验证')
print(json.dumps({'status': '本地服务与页面身份检查通过', 'url': url, 'title': actual}, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--out-dir', type=Path)
parser.add_argument('--previous', type=Path, help='同一测量基线下前轮 verification.json,用于报告改善与退步')
parser.add_argument('--preflight', action='store_true', help='仅核对本地HTML服务与标题;成功后才能导航')
args = parser.parse_args()
try:
if args.preflight:
preflight(args.manifest)
elif args.out_dir is None:
parser.error('截图验收需要 --out-dir')
else:
verify(args.manifest, args.out_dir, args.previous)
except (ValueError, KeyError, OSError, TypeError) as error:
parser.exit(2, f'验证失败:{error}\n')
if __name__ == '__main__':
main()
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# 兼容脚本名称;截图由当前已授权的宿主浏览器生成,不再隐式启动另一套浏览器。
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec python3 "$SCRIPT_DIR/verify_capture.py" "$@"