commit fdafd5d6b303e87bfa825dfcd2402c153d6c8b29 Author: 江晨 <135297162+jiangchen1245@users.noreply.github.com> Date: Mon Aug 17 11:54:40 2026 +0800 chore: 初始化 dsh-enhancements 留档仓库 - 插件源码:dsh-usage(用户头像 + DeepSeek 官方 API 消耗面板) - 工具:dl-gh.mjs(node fetch 下载 gh,绕开沙箱 curl 限制) - 文档:PLAN.md 路线图 + mmx 视觉桥接补丁说明 - 资产:DeepSeek 鲸鱼图标 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96270e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 依赖与本地缓存 +node_modules/ +tools/gh-download/ +tools/gh-extract/ +*.log + +# 凭据与敏感信息(绝不入库) +.credentials.yaml +*.pem +*.key +.env + +# 系统文件 +Thumbs.db +.DS_Store + +# DSH 本地状态 +sessions/ +storages/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9f3880 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# dsh-enhancements — DeepSeek Harness 增强插件集 + +把 DeepSeek Harness 升级为 **Codex 级智能助手** 的全部开发成果归档于此: +插件源码、桌面应用壳脚本、视觉桥接补丁、文档与规划。 + +> 由本机 agent 会话持续开发,随开发进度同步推送到 GitHub / Gitea。 + +## 目录结构 + +``` +dsh-enhancements/ +├── README.md # 本文件 +├── docs/ +│ ├── PLAN.md # 总体规划(Codex 化路线图) +│ └── mmx-image-bridge-PATCH.md # 聊天发图→mmx 视觉识别 桥接补丁说明 +├── plugins/ # 每个插件一个目录(源码 + 说明) +│ └── dsh-usage/ # 用户头像 + API 消耗面板 +├── presets/ # Agent 预设(落盘形式,重启可用) +├── tools/ # 辅助脚本 +│ ├── dl-gh.mjs # 用 node fetch 下载 gh CLI(沙箱内 curl 被禁时的替代) +│ └── gh-download/ # 下载缓存(不入库,见 .gitignore) +└── assets/ # 图标等资源 + └── dsh.ico # DeepSeek 鲸鱼图标(托盘/快捷方式用) +``` + +## 已包含 + +- **桌面应用壳**:`C:\Users\a1703\.dsh\bin\dsh-app.ps1`(托盘 + Edge 独立窗口)与桌面快捷方式 `DeepSeek Harness.lnk` —— 见 docs/ 说明 +- **视觉桥接**:聊天窗口发图 → MiniMax M3(NVIDIA 托管)识别 +- **dsh-usage 插件**:侧边栏底部 🐋 用户头像 → DeepSeek 官方余额 API 消耗面板 + +## 安全说明 + +- 本仓库**绝不包含**任何 API Key、Token、密码。 +- 部署机上的凭据位于 `%DSH_HOME%\.credentials.yaml`,由运行时读取,不入库。 +- Gitea 等远程地址的凭据使用系统凭据管理器,不写入 `.git/config`。 + +## 快速开始(新机器) + +1. 安装 Node.js ≥ 18(带全局 fetch) +2. 把 `tools/dl-gh.mjs` 下载的 gh 解压到 PATH,或直接用 git +3. 按 `docs/PLAN.md` 各阶段说明启用插件 diff --git a/assets/dsh.ico b/assets/dsh.ico new file mode 100644 index 0000000..9799baf Binary files /dev/null and b/assets/dsh.ico differ diff --git a/assets/user-screenshot.png b/assets/user-screenshot.png new file mode 100644 index 0000000..75003d2 Binary files /dev/null and b/assets/user-screenshot.png differ diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..b9a3aa1 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,66 @@ +# 总体规划:把 DeepSeek Harness 升级为 Codex 级智能助手 + +> 状态:进行中。本文件是路线图,随开发更新。 + +## 目标 + +用户头像/API 消耗、GitHub/Gitea 集成、定时任务、电脑操作模式(截图+视觉+鼠标键盘)、 +内置浏览器操控、吉祥物悬浮助手,全部成果留档并发布到 GitHub/Gitea 仓库。 + +## 现状盘点(已验证) + +- 桌面应用化:快捷方式 → 托盘(dsh-app.ps1)→ Edge 独立窗口 ✅ +- 聊天发图 + 视觉理解:mmx 桥接(MiniMax M3 via NVIDIA)✅ +- 动态 Cordis 插件(Host/Client 双向)✅ +- git 2.55 + GitHub(jiangchen1245)+ 自建 Gitea(gitea.truestudio.online)✅ +- DeepSeek 官方余额 API 可用(GET https://api.deepseek.com/user/balance)✅ + +## 阶段 + +### 1️⃣ GitHub / Gitea 集成(进行中) +- [x] 验证 Gitea 凭据(git 协议 + API basic auth) +- [ ] 装 gh CLI(沙箱内 github CDN 不通,需换网络或用户手动装) +- [ ] dsh-git 插件:repo_status / gh_issue / gh_pr / gh_commit / gh_push +- [ ] GUI"Git 中心"面板 + +### 2️⃣ 定时任务插件 +- [ ] dsh-scheduler:cron 表达式 + 动作(命令/工具调用) +- [ ] 持久化(重启不丢)+ GUI 管理面板 + +### 3️⃣ 电脑操作模式(核心) +- [ ] screen_capture(截图) +- [ ] screen_vision(mmx M3 视觉识别元素/坐标) +- [ ] screen_click / screen_type / screen_key(user32) +- [ ] screen_list_windows / screen_ui_tree(UI Automation) +- 闭环:截图 → 看懂 → 操作 → 再看 + +### 4️⃣ 内置浏览器操控 +- [ ] Edge CDP 驱动(导航/点击/填表/截页) +- [ ] GUI 内嵌浏览器面板(webServer 反代 iframe) + +### 5️⃣ 吉祥物悬浮助手 +- [ ] shell.overlay 右下角 🐋 浮窗 + 迷你对话 + 快捷按钮 + +### 6️⃣ 体验打磨 +- [ ] Agent Presets(computer-use / dev) +- [ ] 快捷键、布局优化、开机自启 + +### 7️⃣ 留档与发布 +- [x] 本地仓库 D:\project\dsh-enhancements(git init + 结构) +- [ ] 推送到 Gitea jiangchen/dsh-enhancements +- [ ] 推送到 GitHub jiangchen1245/dsh-enhancements +- [ ] 脱敏检查(无 Key/Token/密码) + +## 技术要点 + +- 沙箱:curl/winget 的 HTTPS 被禁,node fetch 可用 → 网络操作统一走 node +- 客户端无 window/document/fetch → 打开外链由 Host 侧 shell 完成 +- 动态插件不持久 → 每个插件同时落盘为 presets/ 文件与源码归档 +- 升级覆盖 → mmx 桥接补丁保留重放脚本(PATCH.md) +- 凭据 → 一律系统凭据管理器,不入库、不进 .git/config + +## 安全红线 + +- 仓库绝不包含 API Key / Token / 密码 +- Gitea remote 不用内嵌密码 URL;用凭据管理器 +- .credentials.yaml 只留在部署机 diff --git a/docs/mmx-image-bridge-PATCH.md b/docs/mmx-image-bridge-PATCH.md new file mode 100644 index 0000000..6e13b8c --- /dev/null +++ b/docs/mmx-image-bridge-PATCH.md @@ -0,0 +1,53 @@ +# MMX 图片桥接补丁(聊天窗口发图 → mmx-vision 技能识别) + +日期:2026-08-12 +目的:让 DeepSeek Harness 的聊天窗口可以直接发送/粘贴图片。图片被持久化为附件, +发给模型时由适配器转成"附件路径文本提示",agent 收到后用 mmx-vision 技能(本地 +Node 脚本调用 NVIDIA 托管的 MiniMax M3)识别或按用户意图取用。 + +## 相关自定义(本机桌面应用壳) + +- `C:\Users\a1703\.dsh\bin\dsh-app.ps1`:托盘应用(无窗口后台运行 dsh web + 系统托盘, + 右键"打开主界面 / 退出",打开用 Edge `--app` 独立窗口)。快捷方式 + `桌面\DeepSeek Harness.lnk` 指向它(powershell -WindowStyle Hidden -File)。 +- `C:\Users\a1703\.dsh\assets\dsh.ico`:多尺寸(16-256)DeepSeek 鲸鱼图标, + 由 `favicon.svg` 经 Edge 无头渲染 + System.Drawing 缩放生成,供托盘/快捷方式使用。 +- 旧版 `C:\Users\a1703\.dsh\bin\dsh-web.cmd`:手动模式(显示控制台窗口),备用。 + +## 修改的文件(全部位于 npx 缓存安装,重启 Harness 生效) + +### 0. @deepseek-ai/dsh-web-frontend/dist(应用窗口图标) +- 新增 `favicon-256.png`、`favicon.ico`(多尺寸鲸鱼图标)。 +- `index.html`:favicon 链接增加 `favicon-256.png`(放在 svg 前)。 +- `manifest.webmanifest`:icons 增加 png/ico 条目。 +- 作用:Edge `--app` 独立窗口与浏览器标签显示 DeepSeek 鲸鱼图标而不是 Edge 图标。 + 静态文件实时生效(服务直接读磁盘),无需重启。 + +### 1. @deepseek-ai/dsh-llm-deepseek/lib/index.js(deepseek 适配器) +- 原行为:内容含 image 块时抛 `UNSUPPORTED_CONTENT`(拒绝整轮对话)。 +- 新行为:`assertTextOnly` 改为 `imageBlocksText`,把每个 image 块序列化成一行 + `[用户发送的图片附件 id=sha256:xxx 类型=image/png 文件=/attachments/v1/objects/xx/xxx]` + 文本;`serializeMessages` 的用户消息文本 = 原文本 + 图片提示。 +- 附件路径规则与 `@deepseek-ai/dsh-attachment-local` 的存储约定一致 + (对象路径 `objects//`,attachmentId 前缀 `sha256:`)。 + +### 2. @deepseek-ai/dsh-host-apiproxy/lib/index.js(host API 代理) +- `prompt` RPC:删除"模型 inputModalities 不含 image 就拒绝(MODEL_DOES_NOT_SUPPORT_IMAGES)"检查, + 图片一律放行(由适配器文本化)。 +- `selectModel` RPC:删除"会话已含图片时禁止切换到不支持图片的模型"检查。 + +## 生效条件 +- 重启 Harness 进程后生效(node_modules 中的运行时文件改动不会热加载)。 +- 备份:同目录 `index.js.mmx-bak`(每个改过的包旁边)。 + +## 升级后重放 +重新安装/升级 @deepseek-ai/dsh 后,以上两处改动会被覆盖。重放步骤: +1. 用 `node --check` 验证改动文件语法。 +2. 对每个包:把 `assertTextOnly` 的抛错逻辑替换为 `imageBlocksText`(见下), + 并删除 api-proxy 两处图片能力检查。 +3. 或直接 diff 当前 `.mmx-bak` 与新文件,把差异搬过去。 + +## 不影响 +- 客户端 UI 无需改动(图片 rail/拖放/粘贴本来就完整)。 +- pi-ai 模型路由(真多模态)不受影响。 +- 会话历史中的图片块在每次模型调用时都会文本化,客户端仍按附件渲染缩略图。 diff --git a/plugins/dsh-usage/README.md b/plugins/dsh-usage/README.md new file mode 100644 index 0000000..7f090c5 --- /dev/null +++ b/plugins/dsh-usage/README.md @@ -0,0 +1,35 @@ +# dsh-usage — 用户头像 + API 消耗面板 + +在 DeepSeek Harness 侧边栏底部(设置旁)添加一个 Codex 式**用户头像**入口, +点击弹出模态面板,展示 **DeepSeek 官方 API 余额/消耗**,并可一键打开官网用量页。 + +## 效果 + +- 侧边栏底部(设置按钮旁):圆形 🐋 头像按钮(窄栏只显图标,宽栏带"API 用量"文字) +- 点击 → 居中模态面板: + - 账户可用状态 + - 各币种:总额 / 赠送余额 / 充值余额(来自官方 `GET https://api.deepseek.com/user/balance`) + - 按钮:刷新 / 打开官网用量页 / 关闭 + +## 实现 + +| 端 | 机制 | +|---|---| +| Host | `harness.handle('usage/balance')`:shell 运行 node 脚本读取 `%DSH_HOME%\.credentials.yaml` 的 `DEEPSEEK_API_KEY`,调用官方余额 API,返回 JSON(密钥不出进程日志);`usage/openPortal`:打开官网用量页 | +| Client | `sidebar.footer.action` 注册头像按钮;`shell.overlay` 注册模态面板;两个组件通过插件内微型 store 共享开关状态;样式用主题 CSS 变量(`--dsw-alias-*`) | + +## 为什么用官方 API + +DeepSeek 官方提供余额接口(`/user/balance`),返回 `is_available` + `balance_infos[]`。 +用量明细官网暂无公开 API,故面板提供"打开官网用量页"(platform.deepseek.com/usage)兜底。 + +## 文件 + +- `host.js` — Host 半(RPC + 官方 API 调用) +- `client.js` — Client 半(头像 + 模态 UI) +- 部署方式:通过动态 Cordis 插件(`cordis_define` / `cordis_run`)或本仓库 `presets/` 下的预设文件 + +## 前置 + +- `%DSH_HOME%\.credentials.yaml` 中存在 `DEEPSEEK_API_KEY` +- 官方余额 API 需可访问(api.deepseek.com) diff --git a/plugins/dsh-usage/client.js b/plugins/dsh-usage/client.js new file mode 100644 index 0000000..59228a3 --- /dev/null +++ b/plugins/dsh-usage/client.js @@ -0,0 +1,111 @@ +// dsh-usage 插件 — Client 半 +// 注册两处 UI: +// sidebar.footer.action 'dsh-usage-avatar' — 设置旁的 🐋 用户头像按钮 +// shell.overlay 'dsh-usage-modal' — 点击头像弹出的官方余额模态面板 +// 两个组件通过插件内微型 store(open + listeners)共享开关状态。 +return { + apply(ctx) { + const slots = ctx.get('slots') + if (slots === undefined) return + styles.insert(` +.dsh-ua-avatar { display:flex; align-items:center; gap:8px; width:100%; padding:7px 10px; background:transparent; border:0; border-radius:8px; color:var(--dsw-alias-label-primary); cursor:pointer; font:inherit; } +.dsh-ua-avatar:hover { background:var(--dsw-alias-bg-layer-2); } +.dsh-ua-avatar.active { background:var(--dsw-alias-bg-layer-2); } +.dsh-ua-avatar-icon { width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:15px; flex:none; background:var(--dsw-alias-bg-layer-2); border:1px solid var(--dsw-alias-border-l1); } +.dsh-ua-avatar-label { font-size:12px; color:var(--dsw-alias-label-secondary); } +.dsh-ua-backdrop { position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; pointer-events:auto; } +.dsh-ua-modal { width:380px; max-width:92vw; background:var(--dsw-alias-bg-overlay); border:1px solid var(--dsw-alias-border-l1); border-radius:12px; box-shadow:0 12px 40px rgba(0,0,0,.3); padding:16px; color:var(--dsw-alias-label-primary); pointer-events:auto; font-size:13px; } +.dsh-ua-modal h3 { margin:0 0 12px; font-size:15px; display:flex; align-items:center; gap:8px; } +.dsh-ua-row { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--dsw-alias-border-l1); } +.dsh-ua-row:last-child { border-bottom:0; } +.dsh-ua-row .k { color:var(--dsw-alias-label-secondary); } +.dsh-ua-row .v { font-weight:600; } +.dsh-ua-ok { color:var(--dsw-alias-state-success-primary); } +.dsh-ua-err { color:var(--dsw-alias-state-error-primary); } +.dsh-ua-btns { display:flex; gap:8px; margin-top:14px; } +.dsh-ua-btn { flex:1; padding:7px 10px; border-radius:8px; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-primary); cursor:pointer; font:inherit; font-size:12px; } +.dsh-ua-btn:hover { background:var(--dsw-alias-bg-layer-2); } +.dsh-ua-btn.primary { background:var(--dsw-alias-brand-primary); color:#fff; border-color:transparent; } +`) + const store = { open: false, listeners: new Set() } + const setOpen = (v) => { store.open = v; store.listeners.forEach((fn) => fn()) } + const useOpen = () => { + const [s, setS] = React.useState(store.open) + React.useEffect(() => { store.listeners.add(setS); return () => store.listeners.delete(setS) }, []) + return s + } + + const UsageModal = (props) => { + const [state, setState] = React.useState({ loading: true, data: null, error: null }) + const load = () => { + setState({ loading: true, data: null, error: null }) + host.call('usage/balance', {}).then((data) => { + setState({ loading: false, data: data, error: null }) + }).catch((e) => { + setState({ loading: false, data: null, error: String((e && e.message) || e) }) + }) + } + React.useEffect(() => { load() }, []) + const openPortal = () => { host.call('usage/openPortal', {}) } + const data = state.data + const rows = [] + if (data && Array.isArray(data.balance_infos)) { + data.balance_infos.forEach((b) => { + rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-total' }, + React.createElement('span', { className: 'k' }, '总额 (' + b.currency + ')'), + React.createElement('span', { className: 'v' }, String(b.total_balance)))) + rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-granted' }, + React.createElement('span', { className: 'k' }, '赠送余额'), + React.createElement('span', { className: 'v' }, String(b.granted_balance)))) + rows.push(React.createElement('div', { className: 'dsh-ua-row', key: b.currency + '-topped' }, + React.createElement('span', { className: 'k' }, '充值余额'), + React.createElement('span', { className: 'v' }, String(b.topped_up_balance)))) + }) + } + let body = null + if (state.loading) { + body = React.createElement('div', { className: 'dsh-ua-row' }, React.createElement('span', { className: 'k' }, '正在查询官方余额…')) + } else if (state.error || (data && data.error)) { + body = React.createElement('div', { className: 'dsh-ua-err' }, String(state.error || (data && data.error))) + } else { + body = React.createElement('div', null, + React.createElement('div', { className: 'dsh-ua-row' }, + React.createElement('span', { className: 'k' }, '账户可用'), + React.createElement('span', { className: 'v ' + (data.is_available ? 'dsh-ua-ok' : 'dsh-ua-err') }, data.is_available ? '可用' : '不可用')), + rows) + } + return React.createElement('div', { className: 'dsh-ua-backdrop', onClick: props.onClose }, + React.createElement('div', { className: 'dsh-ua-modal', onClick: (e) => e.stopPropagation() }, + React.createElement('h3', null, '🐋 DeepSeek API 用量', + React.createElement('span', { style: { marginLeft: 'auto', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, '官方余额')), + body, + React.createElement('div', { className: 'dsh-ua-btns' }, + React.createElement('button', { className: 'dsh-ua-btn', onClick: load }, '刷新'), + React.createElement('button', { className: 'dsh-ua-btn', onClick: openPortal }, '打开官网用量页'), + React.createElement('button', { className: 'dsh-ua-btn primary', onClick: props.onClose }, '关闭')))) + } + + slots.inject('sidebar.footer.action', () => slots.register( + { name: 'sidebar.footer.action', id: 'dsh-usage-avatar', order: 100, label: 'API 用量' }, + (props) => { + const open = useOpen() + return React.createElement('button', { + className: 'dsh-ua-avatar' + (open ? ' active' : ''), + title: 'API 用量 / 余额', + onClick: () => setOpen(true), + }, + React.createElement('span', { className: 'dsh-ua-avatar-icon' }, '🐋'), + props.wide ? React.createElement('span', { className: 'dsh-ua-avatar-label' }, 'API 用量') : null) + } + )) + + slots.inject('shell.overlay', () => slots.register( + { name: 'shell.overlay', id: 'dsh-usage-modal', order: 100 }, + () => { + const open = useOpen() + if (!open) return null + return React.createElement(UsageModal, { onClose: () => setOpen(false) }) + } + )) + }, +} diff --git a/plugins/dsh-usage/host.js b/plugins/dsh-usage/host.js new file mode 100644 index 0000000..629b3d1 --- /dev/null +++ b/plugins/dsh-usage/host.js @@ -0,0 +1,47 @@ +// dsh-usage 插件 — Host 半 +// 通过 cordis_define 定义(动态 Cordis 插件)。 +// 提供两个 Package-private RPC: +// usage/balance — 读取 %DSH_HOME%\.credentials.yaml 的 DEEPSEEK_API_KEY, +// 调用 DeepSeek 官方余额 API(GET /user/balance),返回 JSON。 +// usage/openPortal— 用系统默认浏览器打开官网用量页。 +// 密钥只在 node 子进程内读取与使用,绝不打印、不入库。 +return { + inject: ['shell'], + apply(ctx) { + harness.handle('usage/balance', async (args) => { + const script = [ + "const fs = require('node:fs');", + "const os = require('node:os');", + "const path = require('node:path');", + "const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');", + "const file = path.join(home, '.credentials.yaml');", + "let key = null;", + "try { const txt = fs.readFileSync(file, 'utf8'); const m = txt.match(/DEEPSEEK_API_KEY:\\s*(\\S+)/); if (m) key = m[1]; } catch (e) {}", + "if (!key) { console.log(JSON.stringify({ error: 'no-api-key', file: file })); process.exit(0); }", + "fetch('https://api.deepseek.com/user/balance', { headers: { Authorization: 'Bearer ' + key, Accept: 'application/json' }, signal: AbortSignal.timeout(15000) })", + " .then(r => r.json().then(j => console.log(JSON.stringify(Object.assign({ status: r.status }, j)))))", + " .catch(e => console.log(JSON.stringify({ error: String((e && e.message) || e) })));", + ].join('\n') + try { + const spec = ctx.shell.resolve({ command: 'node', args: ['-e', script] }) + const res = await ctx.shell.run(spec) + const stdout = String(res.stdout || '') + const lines = stdout.trim().split(/\r?\n/).filter(Boolean) + if (lines.length) return JSON.parse(lines[lines.length - 1]) + return { error: 'empty-output', stderr: String(res.stderr || '').slice(0, 400) } + } catch (e) { + return { error: String((e && e.message) || e) } + } + }) + + harness.handle('usage/openPortal', async () => { + try { + const spec = ctx.shell.resolve({ command: 'cmd', args: ['/c', 'start', '', 'https://platform.deepseek.com/usage'] }) + await ctx.shell.run(spec) + return { ok: true } + } catch (e) { + return { ok: false, error: String((e && e.message) || e) } + } + }) + }, +} diff --git a/tools/dl-gh.mjs b/tools/dl-gh.mjs new file mode 100644 index 0000000..7462b98 --- /dev/null +++ b/tools/dl-gh.mjs @@ -0,0 +1,21 @@ +// 下载 GitHub CLI (gh) 最新 Windows x64 便携版 zip。 +// 用法: node dl-gh.mjs [输出目录] → 打印 { tag, file, bytes } +// 沙箱内 curl/winget 的 HTTPS 被禁,但 node fetch 可用,故用 node 下载。 +import fs from 'node:fs' +import path from 'node:path' + +const outDir = path.resolve(process.argv[2] || '.') +fs.mkdirSync(outDir, { recursive: true }) + +const relUrl = 'https://api.github.com/repos/cli/cli/releases/latest' +const rel = await (await fetch(relUrl, { headers: { 'User-Agent': 'dsh-enhancements' }, signal: AbortSignal.timeout(30000) })).json() +if (!rel.tag_name) throw new Error('bad release response: ' + JSON.stringify(rel).slice(0, 200)) +const asset = rel.assets.find((a) => a.name.endsWith('windows_amd64.zip')) +if (!asset) throw new Error('no windows_amd64.zip asset in ' + rel.tag_name) + +const out = path.join(outDir, asset.name) +const res = await fetch(asset.browser_download_url, { signal: AbortSignal.timeout(300000) }) +if (!res.ok) throw new Error('download failed: HTTP ' + res.status) +const buf = Buffer.from(await res.arrayBuffer()) +fs.writeFileSync(out, buf) +console.log(JSON.stringify({ tag: rel.tag_name, file: out, bytes: buf.length }))