mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad1755fa23 | |||
| 3cc1225b18 | |||
| 460bd087c6 | |||
| daf0fb7bec | |||
| adbfa3036b | |||
| 6026b619f0 | |||
| 1406a85331 | |||
| dac5867f8a | |||
| 49829e7f98 | |||
| df16bd5d1a | |||
| 9596d39e7b | |||
| 156f8627c2 | |||
| e0b861c148 | |||
| 3b29f3c57c | |||
| e98fa33bc2 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.117.3"
|
||||
"version": "1.119.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -22,6 +22,7 @@
|
||||
"./skills/baoyu-danger-gemini-web",
|
||||
"./skills/baoyu-danger-x-to-markdown",
|
||||
"./skills/baoyu-diagram",
|
||||
"./skills/baoyu-electron-extract",
|
||||
"./skills/baoyu-format-markdown",
|
||||
"./skills/baoyu-imagine",
|
||||
"./skills/baoyu-infographic",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: codex-imagegen tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/codex-imagegen/**'
|
||||
- 'scripts/codex-imagegen.sh'
|
||||
- '.github/workflows/codex-imagegen-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Show Bun version
|
||||
run: bun --version
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: scripts/codex-imagegen
|
||||
run: bun test
|
||||
|
||||
- name: Bundle smoke test (catches import/syntax errors)
|
||||
run: bun build --target=node scripts/codex-imagegen/main.ts --outfile /tmp/main-build.js
|
||||
|
||||
- name: Help output smoke test
|
||||
run: bun scripts/codex-imagegen/main.ts --help
|
||||
@@ -26,5 +26,10 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install baoyu-post-to-wechat script dependencies
|
||||
run: |
|
||||
cd skills/baoyu-post-to-wechat/scripts
|
||||
npm install --no-audit --no-fund --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
@@ -2,6 +2,46 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.119.0 - 2026-05-24
|
||||
|
||||
### Features
|
||||
- `baoyu-electron-extract`: new skill that extracts resources and JavaScript from any installed Electron app's `app.asar`. Restores original sources from embedded `sourcesContent` in `.js.map` files when present (TypeScript/JSX included) or formats minified JS/CSS with Prettier in place when not. Source-map paths are resolved relative to each `.js.map` file first, so bundler-relative entries like `../../src/main.ts` restore to readable paths under `restored/` instead of hashed placeholders. Always skips `node_modules` and `webpack/runtime/*` entries. Auto-discovers apps on macOS (`/Applications`, `~/Applications`) and Windows (`%LOCALAPPDATA%\Programs`, `%PROGRAMFILES%`, `%PROGRAMFILES(X86)%`, `%APPDATA%`); other platforms can pass `--asar <path>` explicitly. Safety: refuses to write to `/`, the user home, or the current working directory, and refuses non-empty existing output dirs without `--force`
|
||||
|
||||
## 1.118.0 - 2026-05-21
|
||||
|
||||
### Features
|
||||
- `codex-imagegen`: new image-generation backend for non-Codex runtimes (e.g., Claude Code) — spawns `codex exec --json --sandbox danger-full-access` and delegates to Codex CLI's built-in `image_gen` tool, so no `OPENAI_API_KEY` is required. Ships with idempotency cache, file-lock concurrency control, JSONL event-stream parsing, PNG magic-byte validation, and exponential-backoff retries (by @yelban, #158)
|
||||
- `baoyu-cover-image`: wire `SKILL.md` to call the `codex-imagegen` wrapper when `preferred_image_backend: codex-imagegen` is set, with `--timeout` documented for slow networks
|
||||
|
||||
### Refactor
|
||||
- `codex-imagegen`: enforce `--prompt` / `--prompt-file` mutual exclusion in code (was docs-only)
|
||||
- `codex-imagegen`: replace `(opts as any).__promptFile` hack with a typed `promptFile` field on `CliOptions`
|
||||
- `codex-imagegen`: replace inline `cp|mv ... generated_images` regex with the shared `findCpToTarget` helper
|
||||
- `codex-imagegen`: propagate `attempts` on error responses (previously hardcoded to `0`)
|
||||
- `codex-imagegen`: drop dead `parseFinalJson()` + matching test (wrapper ignores agent-reported JSON in favor of disk verification)
|
||||
|
||||
### Security
|
||||
- `codex-imagegen`: reject `--image` / `--ref` paths containing shell metacharacters before interpolating them into the agent instruction sent to `codex exec --sandbox danger-full-access`
|
||||
|
||||
### Credits
|
||||
- `codex-imagegen` backend contributed by @yelban (#158)
|
||||
|
||||
## 1.117.5 - 2026-05-21
|
||||
|
||||
### Credits
|
||||
- `baoyu-post-to-wechat`: remote API publishing update credited to Dame5211 <1079825614@qq.com>
|
||||
|
||||
## 1.117.4 - 2026-05-21
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: add remote API publishing via an SSH SOCKS5 tunnel
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: make remote API publishing work under Bun and strictly validate remote publish config
|
||||
|
||||
### CI
|
||||
- Install `baoyu-post-to-wechat` script dependencies before running tests
|
||||
|
||||
## 1.117.3 - 2026-05-20
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,46 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.119.0 - 2026-05-24
|
||||
|
||||
### 新功能
|
||||
- `baoyu-electron-extract`:新增 skill,可从任意已安装的 Electron 应用的 `app.asar` 中提取资源和 JavaScript。`.js.map` 文件内嵌 `sourcesContent` 时还原原始源码(含 TypeScript/JSX),否则用 Prettier 原地美化压缩后的 JS/CSS。source-map 路径先相对各 `.js.map` 文件解析,因此 `../../src/main.ts` 这类打包器相对路径会还原为 `restored/` 下的可读路径,而不是哈希占位符。始终跳过 `node_modules` 和 `webpack/runtime/*` 条目。macOS 下自动从 `/Applications` 和 `~/Applications` 发现应用,Windows 下从 `%LOCALAPPDATA%\Programs`、`%PROGRAMFILES%`、`%PROGRAMFILES(X86)%`、`%APPDATA%` 发现;其他平台请用 `--asar <path>` 显式指定。安全:拒绝写入 `/`、用户主目录或当前工作目录,未加 `--force` 时拒绝写入非空的已有输出目录
|
||||
|
||||
## 1.118.0 - 2026-05-21
|
||||
|
||||
### 新功能
|
||||
- `codex-imagegen`:新增面向非 Codex 运行时(如 Claude Code)的图像生成后端 —— 通过 `codex exec --json --sandbox danger-full-access` 调用 Codex CLI 内置的 `image_gen` 工具,无需 `OPENAI_API_KEY`。内置幂等缓存、文件锁并发控制、JSONL 事件流解析、PNG 魔术字节校验和指数退避重试 (by @yelban, #158)
|
||||
- `baoyu-cover-image`:在 `SKILL.md` 中接入 `codex-imagegen` 包装脚本(当 `preferred_image_backend: codex-imagegen` 时生效),并补充慢网络下的 `--timeout` 参数说明
|
||||
|
||||
### 重构
|
||||
- `codex-imagegen`:在代码中强制校验 `--prompt` 与 `--prompt-file` 互斥(此前仅在文档说明)
|
||||
- `codex-imagegen`:将 `(opts as any).__promptFile` 这一 hack 改为 `CliOptions` 上类型化的 `promptFile` 字段
|
||||
- `codex-imagegen`:用复用的 `findCpToTarget` 辅助函数替换内联的 `cp|mv ... generated_images` 正则
|
||||
- `codex-imagegen`:错误返回时正确透传 `attempts`(此前硬编码为 `0`)
|
||||
- `codex-imagegen`:删除无用的 `parseFinalJson()` 函数及对应测试(包装脚本以磁盘校验为准,不再依赖 agent 自报 JSON)
|
||||
|
||||
### 安全
|
||||
- `codex-imagegen`:在拼入发往 `codex exec --sandbox danger-full-access` 的 agent 指令前,拒绝包含 shell 元字符的 `--image` / `--ref` 路径
|
||||
|
||||
### 致谢
|
||||
- `codex-imagegen` 后端由 @yelban 贡献 (#158)
|
||||
|
||||
## 1.117.5 - 2026-05-21
|
||||
|
||||
### 致谢
|
||||
- `baoyu-post-to-wechat`:远程 API 发布更新感谢 Dame5211 <1079825614@qq.com>
|
||||
|
||||
## 1.117.4 - 2026-05-21
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:新增通过 SSH SOCKS5 隧道进行远程 API 发布
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复远程 API 发布在 Bun 下的运行问题,并严格校验远程发布配置
|
||||
|
||||
### CI
|
||||
- 测试前安装 `baoyu-post-to-wechat` 脚本依赖
|
||||
|
||||
## 1.117.3 - 2026-05-20
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -66,6 +66,22 @@ Skills that prompt users for choices MUST declare the tool-selection convention
|
||||
|
||||
Skills that render images MUST declare the backend-selection convention **inline** in exactly one place per `SKILL.md` — a `## Image Generation Tools` section near the top (after `## User Input Tools`). Do NOT link out to [docs/image-generation-tools.md](docs/image-generation-tools.md); that doc is the author-side canonical source — copy its body into each SKILL.md. Concrete tool names (`imagegen`, `image_generate`, `baoyu-imagine`) elsewhere in a skill are treated as examples — other runtimes substitute their local equivalent under the rule. The rule is stateless: use whatever backend is available; if multiple, ask the user once; if none, ask how to proceed. Every rendered image's full prompt must be written to a standalone `prompts/NN-*.md` file before any backend is invoked. Backend skills (`baoyu-imagine`, `baoyu-image-gen`, `baoyu-danger-gemini-web`) are exempt — they render directly rather than selecting a backend.
|
||||
|
||||
### `codex-imagegen` Backend
|
||||
|
||||
A backend for non-Codex runtimes (e.g., Claude Code) that generates images by spawning `codex exec --json --sandbox danger-full-access` and delegating to Codex CLI's built-in `image_gen` tool. Uses the user's Codex subscription — no `OPENAI_API_KEY` required.
|
||||
|
||||
Invoke via:
|
||||
|
||||
```bash
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image <output.png> \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9 \
|
||||
--cache-dir ~/.cache/baoyu-codex-imagegen
|
||||
```
|
||||
|
||||
Stdout emits a single JSON line: `{"status":"ok","path":...,"bytes":N,...}`. On failure, `{"status":"error","error_kind":...}`. Skills route here by setting `preferred_image_backend: codex-imagegen` in EXTEND.md. Full reference: [docs/codex-imagegen-backend.md](docs/codex-imagegen-backend.md).
|
||||
|
||||
## Deprecated Skills
|
||||
|
||||
| Skill | Note |
|
||||
|
||||
@@ -612,8 +612,9 @@ Post content to WeChat Official Account (微信公众号). Two modes available:
|
||||
|
||||
| Method | Speed | Requirements |
|
||||
|--------|-------|--------------|
|
||||
| API (Recommended) | Fast | API credentials |
|
||||
| API (Recommended) | Fast | API credentials (local IP allowlisted in WeChat) |
|
||||
| Browser | Slow | Chrome, login session |
|
||||
| Remote API | Fast | API credentials + SSH-reachable server whose IP is on WeChat's allowlist |
|
||||
|
||||
**API Configuration** (for faster publishing):
|
||||
|
||||
@@ -631,6 +632,17 @@ To obtain credentials:
|
||||
|
||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||
|
||||
**Remote API Method** (for when WeChat's IP allowlist excludes your local machine): tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on the allowlist. No files are written to the remote host and `AppSecret` never leaves the local process. Add to your EXTEND.md:
|
||||
|
||||
```yaml
|
||||
# Optional: only set when WeChat's IP allowlist excludes your local machine
|
||||
remote_publish_host: server.example.com
|
||||
remote_publish_user: deploy
|
||||
remote_publish_identity_file: ~/.ssh/id_ed25519
|
||||
```
|
||||
|
||||
Then publish with `--remote` (or set `default_publish_method: remote-api`). Authentication is SSH key only; only the typed `remote_publish_*` keys are honored.
|
||||
|
||||
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
|
||||
|
||||
```bash
|
||||
@@ -1142,6 +1154,43 @@ Summarize WeChat group chat highlights into a structured digest. Extracts topics
|
||||
- Normal and roast (毒舌) digest versions
|
||||
- Profile backfill from historical digests
|
||||
|
||||
#### baoyu-electron-extract
|
||||
|
||||
Extract resources and JavaScript from any installed Electron app's `app.asar`. When `.js.map` files embed `sourcesContent`, restores the original source tree (TypeScript/JSX included); otherwise formats the minified JS/CSS with Prettier in place. Always skips `node_modules`. Works on macOS and Windows; pass `--asar <path>` on other platforms.
|
||||
|
||||
```bash
|
||||
# Extract by app name (default output: ~/Downloads/Codex-electron-extract/)
|
||||
/baoyu-electron-extract Codex
|
||||
|
||||
# Extract by absolute path (.app bundle, install dir, or .asar file)
|
||||
/baoyu-electron-extract "/Applications/Visual Studio Code.app"
|
||||
/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
|
||||
|
||||
# Custom output directory
|
||||
/baoyu-electron-extract Codex --output ~/work/codex-source
|
||||
|
||||
# Preview discovery without writing anything
|
||||
/baoyu-electron-extract Codex --dry-run
|
||||
|
||||
# Overwrite an existing output directory
|
||||
/baoyu-electron-extract Codex --force
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `<app>` | App name or absolute path (required unless `--asar`) | — |
|
||||
| `--output`, `-o` | Output directory | `~/Downloads/<AppName>-electron-extract` |
|
||||
| `--asar` | Override the resolved `.asar` path | auto-discovered |
|
||||
| `--force`, `-f` | Allow writing into a non-empty existing output dir | false |
|
||||
| `--skip-format` | Skip Prettier formatting | false |
|
||||
| `--skip-restore` | Skip source-map restoration | false |
|
||||
| `--no-unpacked` | Don't copy `app.asar.unpacked/` alongside | false |
|
||||
| `--dry-run` | Print resolved paths and exit without writing | false |
|
||||
| `--json` | Emit one JSON-line summary on stdout | false |
|
||||
|
||||
**Output layout**: `extract-report.json` (counts, warnings, paths), `extracted/` (raw asar, formatted in place when no map), `extracted.unpacked/` (native modules if present), and `restored/` (rebuilt source tree from `.js.map` files).
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||
|
||||
+41
-1
@@ -612,8 +612,9 @@ clawhub install baoyu-markdown-to-html
|
||||
|
||||
| 方式 | 速度 | 要求 |
|
||||
|------|------|------|
|
||||
| API(推荐) | 快 | API 凭证 |
|
||||
| API(推荐) | 快 | API 凭证(本机 IP 在公众号白名单内) |
|
||||
| 浏览器 | 慢 | Chrome,登录会话 |
|
||||
| 远程 API | 快 | API 凭证 + 一台 IP 在公众号白名单内、可 SSH 登录的服务器 |
|
||||
|
||||
**API 配置**(更快的发布方式):
|
||||
|
||||
@@ -631,6 +632,8 @@ WECHAT_APP_SECRET=你的AppSecret
|
||||
|
||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
**远程 API 方式**(适用于本机 IP 不在公众号白名单内的情况):通过 SSH SOCKS5 动态端口转发,将对 `api.weixin.qq.com` 的 HTTPS 调用转发到 IP 在白名单内的服务器上。Markdown 渲染、图片处理、草稿组装仍在本地完成;远端不会落任何文件,`AppSecret` 不会离开本地进程。仅支持 SSH 密钥认证,且只接受类型化的 `remote_publish_*` 配置项,不透传任意 ssh 选项。在 EXTEND.md 中配置 `remote_publish_host` 等字段后,发布时加上 `--remote`(或将 `default_publish_method` 设为 `remote-api`)。
|
||||
|
||||
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
|
||||
|
||||
```bash
|
||||
@@ -1142,6 +1145,43 @@ AI 驱动的生成后端。
|
||||
- 正常版和毒舌版两种风格
|
||||
- 支持从历史摘要回溯初始化画像
|
||||
|
||||
#### baoyu-electron-extract
|
||||
|
||||
从任意已安装的 Electron 应用的 `app.asar` 中提取资源和 JavaScript。当 `.js.map` 内嵌 `sourcesContent` 时,还原原始源码树(含 TypeScript/JSX);否则用 Prettier 原地美化压缩后的 JS/CSS。始终跳过 `node_modules`。支持 macOS 和 Windows,其他平台请用 `--asar <path>` 指定 asar 文件。
|
||||
|
||||
```bash
|
||||
# 按应用名提取(默认输出:~/Downloads/<AppName>-electron-extract/)
|
||||
/baoyu-electron-extract Codex
|
||||
|
||||
# 按绝对路径提取(.app 包、安装目录或 .asar 文件均可)
|
||||
/baoyu-electron-extract "/Applications/Visual Studio Code.app"
|
||||
/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
|
||||
|
||||
# 自定义输出目录
|
||||
/baoyu-electron-extract Codex --output ~/work/codex-source
|
||||
|
||||
# 仅预览发现的路径,不写入任何文件
|
||||
/baoyu-electron-extract Codex --dry-run
|
||||
|
||||
# 覆盖已存在的输出目录
|
||||
/baoyu-electron-extract Codex --force
|
||||
```
|
||||
|
||||
**选项**:
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `<app>` | 应用名或绝对路径(未给 `--asar` 时必填) | — |
|
||||
| `--output`, `-o` | 输出目录 | `~/Downloads/<AppName>-electron-extract` |
|
||||
| `--asar` | 覆盖解析得到的 `.asar` 路径 | 自动发现 |
|
||||
| `--force`, `-f` | 允许写入非空的已有输出目录 | false |
|
||||
| `--skip-format` | 跳过 Prettier 格式化 | false |
|
||||
| `--skip-restore` | 跳过 source-map 还原 | false |
|
||||
| `--no-unpacked` | 不复制同级的 `app.asar.unpacked/` | false |
|
||||
| `--dry-run` | 打印解析路径后退出,不写文件 | false |
|
||||
| `--json` | 在 stdout 输出一行 JSON 概要 | false |
|
||||
|
||||
**输出结构**:`extract-report.json`(计数、警告、路径),`extracted/`(asar 原始内容,无 map 时原地美化),`extracted.unpacked/`(存在时复制的原生模块),以及 `restored/`(基于 `.js.map` 重建的源码树)。
|
||||
|
||||
## 环境配置
|
||||
|
||||
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# `codex-imagegen` Backend
|
||||
|
||||
Generate images via Codex CLI's built-in `image_gen` tool from non-Codex runtimes (e.g., Claude Code). The wrapper spawns `codex exec --json` and lets the user's existing Codex subscription drive image generation — **no `OPENAI_API_KEY` required**.
|
||||
|
||||
This backend implements the `preferred_image_backend: codex-imagegen` config key already referenced in several `SKILL.md` files across this repo.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| **Reliability**: retry + exponential backoff | Default 2 retries |
|
||||
| **Verification**: confirms `image_gen` was actually invoked (not bypassed) | Checks `$CODEX_HOME/generated_images/{thread_id}/` |
|
||||
| **Verification**: PNG magic-byte sanity check | ✓ |
|
||||
| **Idempotency cache**: reuses output for same prompt+aspect+refs | `--cache-dir` |
|
||||
| **Concurrency control**: file lock prevents parallel `codex exec` collisions | Built-in |
|
||||
| **Structured logging**: JSONL log file | `--log-file` |
|
||||
| **Token usage returned** | Embedded in result JSON |
|
||||
| **`--ref` reference images** | Repeatable |
|
||||
| **Unit tests** | 16 tests (parser / cache / validator) |
|
||||
| **Error classification**: retryable vs non-retryable | 9 `error_kind` values |
|
||||
|
||||
## Why this backend
|
||||
|
||||
| Scenario | Conventional backend | This backend |
|
||||
|----------|---------------------|--------------|
|
||||
| You have a Codex subscription | OpenAI Images API costs add up per image | Subscription already covers it — zero marginal API cost |
|
||||
| No `OPENAI_API_KEY` available | `baoyu-imagine` needs an API key | `codex login` is enough |
|
||||
| Want to use GPT Image 2 | Only via OpenAI API | Codex's `image_gen` *is* GPT Image 2 |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npm install -g @openai/codex
|
||||
codex login # signs in with your OpenAI account (subscription)
|
||||
codex --version # confirm >= 0.130
|
||||
```
|
||||
|
||||
`bun` is preferred for running the wrapper. On macOS:
|
||||
|
||||
```bash
|
||||
brew install oven-sh/bun/bun
|
||||
```
|
||||
|
||||
If `bun` is missing, the shell entrypoint falls back to `npx -y bun`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Direct CLI
|
||||
|
||||
```bash
|
||||
# Inline prompt
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image /tmp/cat.png \
|
||||
--prompt "A friendly orange cat, watercolor"
|
||||
|
||||
# Prompt from file
|
||||
./scripts/codex-imagegen.sh \
|
||||
--image cover.png \
|
||||
--prompt-file prompts/01-cover.md \
|
||||
--aspect 16:9
|
||||
|
||||
# Verbose mode for debugging
|
||||
./scripts/codex-imagegen.sh -v --image dog.png --prompt "A corgi" --aspect 1:1
|
||||
```
|
||||
|
||||
On success, stdout emits a single JSON line:
|
||||
|
||||
```json
|
||||
{"status":"ok","path":"/tmp/cat.png","bytes":2567101,"elapsed_seconds":53}
|
||||
```
|
||||
|
||||
On failure, exit code is non-zero and stderr contains the error message.
|
||||
|
||||
### Enabling within image skills
|
||||
|
||||
Image-generating skills (e.g., `baoyu-cover-image`, `baoyu-article-illustrator`) already support a `preferred_image_backend` preference. To route them through this backend, set the following in the corresponding `EXTEND.md`:
|
||||
|
||||
```yaml
|
||||
# ~/.baoyu-skills/baoyu-cover-image/EXTEND.md
|
||||
preferred_image_backend: codex-imagegen
|
||||
```
|
||||
|
||||
When the LLM runs the skill, it reads the preference and — guided by the `### codex-imagegen Backend` section in `CLAUDE.md` — invokes `scripts/codex-imagegen.sh`.
|
||||
|
||||
> **Note**: The integration is mediated by the LLM reading `CLAUDE.md`. It is not a hard binding. If a skill does not route to the backend automatically, mentioning it explicitly in the prompt works.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Flag | Required | Description |
|
||||
|------|----------|-------------|
|
||||
| `--image <path>` | ✓ | Output PNG path (absolute recommended; relative paths are resolved against cwd) |
|
||||
| `--prompt <text>` | one of | Prompt string (mutually exclusive with `--prompt-file`) |
|
||||
| `--prompt-file <path>` | one of | Read prompt from file (mutually exclusive with `--prompt`) |
|
||||
| `--aspect <ratio>` | | Aspect ratio. Default `1:1`. Common: `16:9`, `9:16`, `4:3`, `2.35:1` |
|
||||
| `--ref <file>` | | Reference image path (repeatable) |
|
||||
| `--timeout <ms>` | | `codex exec` timeout in ms. Default `300000` |
|
||||
| `--retries <n>` | | Retry count on retryable errors. Default `2` (total attempts = retries + 1) |
|
||||
| `--retry-delay <ms>` | | Base delay between retries (exponential backoff). Default `1500` |
|
||||
| `--cache-dir <path>` | | Enable idempotency cache (reuses output for same prompt+aspect+refs) |
|
||||
| `--log-file <path>` | | Structured JSONL log path (appended) |
|
||||
| `-v` / `--verbose` | | Mirror log entries to stderr |
|
||||
| `-h` / `--help` | | Show usage |
|
||||
|
||||
## Structured Output
|
||||
|
||||
On success, stdout contains a single JSON line:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"path": "/tmp/owl.png",
|
||||
"bytes": 1693831,
|
||||
"elapsed_seconds": 87,
|
||||
"thread_id": "019e40e8-daef-7c60-943d-5e7bb3f6cb3d",
|
||||
"attempts": 1,
|
||||
"cached": false,
|
||||
"usage": {
|
||||
"input": 110899,
|
||||
"cached_input": 83456,
|
||||
"output": 457,
|
||||
"reasoning": 47
|
||||
},
|
||||
"tool_calls": [
|
||||
{"tool": "shell", "status": "completed"},
|
||||
{"tool": "agent_message", "status": "completed"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Cache hits return with `elapsed_seconds: 0`, `cached: true`, `attempts: 0`.
|
||||
|
||||
On failure, exit code is `1` and the JSON contains `error` and `error_kind`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": "image_gen was not invoked: no PNG in ...",
|
||||
"error_kind": "no_image_gen_tool_use"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Kinds
|
||||
|
||||
| `error_kind` | Retryable | Meaning |
|
||||
|--------------|-----------|---------|
|
||||
| `codex_not_installed` | ✗ | `codex` CLI not found |
|
||||
| `invalid_args` | ✗ | Argument parsing error |
|
||||
| `prompt_file_missing` | ✗ | `--prompt-file` path does not exist |
|
||||
| `spawn_failed` | ✓ | `codex exec` exited non-zero |
|
||||
| `timeout` | ✓ | Exceeded `--timeout` |
|
||||
| `no_image_gen_tool_use` | ✓ | Agent did not invoke `image_gen` (it took another path) |
|
||||
| `output_missing` | ✓ | Output file not created |
|
||||
| `invalid_png` | ✓ | Output is not a valid PNG |
|
||||
| `agent_refused` | ✓ | No `thread_id` in event stream (Codex refused to respond) |
|
||||
| `lock_busy` | ✗ | Concurrency lock acquisition timed out |
|
||||
|
||||
## Measured Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| First-run latency | 50–90 s |
|
||||
| Cache-hit latency | < 0.3 s |
|
||||
| Output dimensions | 1024×1024, 1672×941 (16:9), etc. — chosen by `image_gen` |
|
||||
| Output format | PNG (RGB, 8-bit) |
|
||||
| Token usage per call | ~110k input (~80k cached) + ~500 output |
|
||||
| Quota source | Codex subscription (does not consume OpenAI API quota) |
|
||||
| Default timeout | 300 s (5 min) |
|
||||
|
||||
## Limitations & Risks
|
||||
|
||||
1. **5–10× slower than direct API**. `codex exec` cold-starts the agent, loads the built-in `image_gen` SKILL.md, and runs reasoning before invoking the tool. Cache hits avoid this for repeated prompts.
|
||||
2. **ToS gray area**. Codex's `image_gen` tool is designed for interactive use. Invoking it programmatically via `codex exec` from an external agent is not explicitly addressed by current OpenAI policies. Suggested guardrails:
|
||||
- Personal, low-volume use is reasonable.
|
||||
- Not recommended for production automation or high-volume batch jobs.
|
||||
- Users are responsible for ensuring their usage complies with applicable terms of service.
|
||||
3. **Sandbox permissions**. The wrapper passes `--sandbox danger-full-access` so the spawned agent can move the rendered PNG out of `$CODEX_HOME/generated_images/`. This is necessary because the agent must `cp`/`mv` the file to the user-specified output path.
|
||||
4. **Concurrency = 1**. The file lock serializes concurrent invocations to avoid `codex exec` collisions. Parallel calls queue.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | `error_kind` | Resolution |
|
||||
|---------|--------------|------------|
|
||||
| `command not found: codex` | `codex_not_installed` | `npm install -g @openai/codex` |
|
||||
| `codex exec` fails | `spawn_failed` | Check `codex login` status; inspect `raw_log` path |
|
||||
| Timeout | `timeout` | Pass `--timeout 600000` (10 min) for slow networks |
|
||||
| Agent skipped `image_gen` | `no_image_gen_tool_use` | Auto-retries; consider sharpening the prompt — abstract prompts let the agent wander |
|
||||
| Output missing | `output_missing` | Agent did not `cp` to the target path; check `raw_log` for the actual save location under `generated_images/` |
|
||||
| Lock held | `lock_busy` | Wait for the in-flight request to finish; or `rm ~/.cache/baoyu-codex-imagegen/codex-exec.lock` |
|
||||
| Low image quality | — | Sharpen the prompt, try a different aspect, or supply `--ref` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
scripts/codex-imagegen.sh # thin bash entrypoint
|
||||
scripts/codex-imagegen/
|
||||
├── main.ts # parseArgs → cache → lock → retry loop → emit JSON
|
||||
├── types.ts # CliOptions, GenerateResult, GenError, ErrorKind
|
||||
├── spawn.ts # spawn codex exec --json --sandbox danger-full-access
|
||||
├── parser.ts # parse JSONL event stream → toolCalls, usage, thread_id
|
||||
├── validator.ts # verify image_gen invocation + PNG magic + file size
|
||||
├── cache.ts # cacheKey(sha256), FileLock, lookup/store
|
||||
├── logger.ts # JsonLogger (verbose stderr + JSONL file)
|
||||
├── parser.test.ts
|
||||
├── cache.test.ts
|
||||
└── validator.test.ts
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd scripts/codex-imagegen && bun test
|
||||
```
|
||||
|
||||
## Internal Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CC[Claude Code / any caller]
|
||||
WRAPPER[scripts/codex-imagegen.sh]
|
||||
CODEX["codex exec --json<br/>--sandbox danger-full-access"]
|
||||
AGENT[Codex agent]
|
||||
TOOL[image_gen built-in tool]
|
||||
DEFAULT["$CODEX_HOME/<br/>generated_images/{thread_id}/"]
|
||||
OUT[/specified OUTPUT path/]
|
||||
|
||||
CC -->|exec wrapper| WRAPPER
|
||||
WRAPPER -->|stdin: instruction| CODEX
|
||||
CODEX --> AGENT
|
||||
AGENT -->|tool call| TOOL
|
||||
TOOL -->|writes file| DEFAULT
|
||||
AGENT -->|agent cp/mv| OUT
|
||||
WRAPPER -->|verify + parse| CC
|
||||
|
||||
classDef cc fill:#1e40af,color:#fff,stroke:#93c5fd
|
||||
classDef cdx fill:#7c2d12,color:#fff,stroke:#fdba74
|
||||
class CC,WRAPPER cc
|
||||
class CODEX,AGENT,TOOL cdx
|
||||
```
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Bash entrypoint + TypeScript implementation** — the shell wrapper picks the runtime (`bun` preferred, falling back to `npx -y bun`); TypeScript handles the orchestration, parsing, retry, cache, and logging. This mirrors the project's existing `scripts/*.mjs` and `skills/<skill>/scripts/main.ts` pattern.
|
||||
2. **`--sandbox danger-full-access`** — necessary so the spawned agent can `cp`/`mv` the rendered PNG out of `$CODEX_HOME/generated_images/` to the user-specified path. Standard sandboxes block this.
|
||||
3. **Parse the JSONL event stream** — the final `agent_message` and intermediate `command_execution` events let the wrapper verify what actually happened (was `image_gen` called? did `cp` reach the right destination?), which is far more reliable than scraping freeform stdout.
|
||||
4. **Infrastructure, not a skill** — this backend is a CLI utility that skills route to via `preferred_image_backend`. It belongs in `scripts/`, not `skills/`, because it has no `SKILL.md` and is never loaded directly by an agent.
|
||||
5. **File lock instead of internal queue** — keeps the implementation small and works across multiple shell sessions or processes invoking the same wrapper concurrently.
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `scripts/codex-imagegen.sh` | CLI entrypoint |
|
||||
| `scripts/codex-imagegen/` | TypeScript implementation |
|
||||
| `docs/codex-imagegen-backend.md` | This document |
|
||||
| `CLAUDE.md` | Tells LLMs how to invoke this backend |
|
||||
| `.github/workflows/codex-imagegen-tests.yml` | CI unit tests |
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# codex-imagegen: generate images via Codex CLI's built-in image_gen tool
|
||||
# Thin shell wrapper — implementation in codex-imagegen/main.ts (Bun TypeScript)
|
||||
#
|
||||
# Usage: ./codex-imagegen.sh --help
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if command -v bun &>/dev/null; then
|
||||
BUN_X="bun"
|
||||
elif command -v npx &>/dev/null; then
|
||||
BUN_X="npx -y bun"
|
||||
else
|
||||
echo "Error: bun or npx required. Install: brew install oven-sh/bun/bun" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec $BUN_X "$SCRIPT_DIR/codex-imagegen/main.ts" "$@"
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||
|
||||
test("cacheKey is deterministic and order-independent for refs", () => {
|
||||
const k1 = cacheKey("hello", "16:9", ["a.png", "b.png"]);
|
||||
const k2 = cacheKey("hello", "16:9", ["b.png", "a.png"]);
|
||||
expect(k1).toBe(k2);
|
||||
const k3 = cacheKey("hello", "16:9", []);
|
||||
expect(k3).not.toBe(k1);
|
||||
const k4 = cacheKey("hello", "1:1", []);
|
||||
expect(k4).not.toBe(k3);
|
||||
});
|
||||
|
||||
test("lookupCache returns null on miss, path on hit", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
|
||||
try {
|
||||
expect(await lookupCache(dir, "abc")).toBeNull();
|
||||
const fake = path.join(dir, "abc.png");
|
||||
await writeFile(fake, Buffer.alloc(2000));
|
||||
expect(await lookupCache(dir, "abc")).toBe(fake);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("storeCache copies source into cache", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-test-"));
|
||||
const src = path.join(dir, "src.png");
|
||||
try {
|
||||
await writeFile(src, Buffer.from("xxxx".repeat(1000)));
|
||||
await storeCache(dir, "key1", src);
|
||||
const cached = await lookupCache(dir, "key1");
|
||||
expect(cached).not.toBeNull();
|
||||
const a = await readFile(src);
|
||||
const b = await readFile(cached!);
|
||||
expect(a.equals(b)).toBe(true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("FileLock prevents concurrent acquisition", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-lock-"));
|
||||
try {
|
||||
const lockPath = path.join(dir, "x.lock");
|
||||
const lock1 = new FileLock(lockPath);
|
||||
const lock2 = new FileLock(lockPath);
|
||||
await lock1.acquire(1000);
|
||||
let lock2Acquired = false;
|
||||
const p = lock2.acquire(500).then(() => (lock2Acquired = true)).catch(() => {});
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(lock2Acquired).toBe(false);
|
||||
await lock1.release();
|
||||
await p;
|
||||
expect(lock2Acquired).toBe(true);
|
||||
await lock2.release();
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile, copyFile, stat } from "node:fs/promises";
|
||||
import { existsSync, openSync, closeSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
export function cacheKey(prompt: string, aspect: string, refs: string[]): string {
|
||||
const h = createHash("sha256");
|
||||
h.update(prompt);
|
||||
h.update("|");
|
||||
h.update(aspect);
|
||||
h.update("|");
|
||||
for (const r of [...refs].sort()) h.update(r);
|
||||
return h.digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export async function lookupCache(cacheDir: string, key: string): Promise<string | null> {
|
||||
const entry = path.join(cacheDir, `${key}.png`);
|
||||
try {
|
||||
const s = await stat(entry);
|
||||
if (s.size > 1000) return entry;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function storeCache(cacheDir: string, key: string, sourcePath: string): Promise<void> {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
const entry = path.join(cacheDir, `${key}.png`);
|
||||
await copyFile(sourcePath, entry);
|
||||
}
|
||||
|
||||
export class FileLock {
|
||||
private fd: number | null = null;
|
||||
constructor(private lockPath: string) {}
|
||||
|
||||
async acquire(timeoutMs = 30_000): Promise<void> {
|
||||
const start = Date.now();
|
||||
await mkdir(path.dirname(this.lockPath), { recursive: true });
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
this.fd = openSync(this.lockPath, "wx");
|
||||
return;
|
||||
} catch (e: any) {
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
if (await this.isStale()) {
|
||||
try {
|
||||
await this.release(true);
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
await delay(200);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to acquire lock at ${this.lockPath} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
private async isStale(): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(this.lockPath);
|
||||
return Date.now() - s.mtimeMs > 10 * 60 * 1000;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async release(force = false): Promise<void> {
|
||||
if (this.fd != null) {
|
||||
try {
|
||||
closeSync(this.fd);
|
||||
} catch {}
|
||||
this.fd = null;
|
||||
}
|
||||
if (existsSync(this.lockPath) || force) {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
try {
|
||||
await unlink(this.lockPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export interface LogEntry {
|
||||
ts: string;
|
||||
level: "info" | "warn" | "error";
|
||||
event: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export class JsonLogger {
|
||||
constructor(private logFile: string | null, public verbose: boolean) {}
|
||||
|
||||
async log(level: LogEntry["level"], event: string, extra: Record<string, unknown> = {}): Promise<void> {
|
||||
const entry: LogEntry = { ts: new Date().toISOString(), level, event, ...extra };
|
||||
const line = JSON.stringify(entry);
|
||||
if (this.verbose) process.stderr.write(`[${level}] ${event} ${jsonExtras(extra)}\n`);
|
||||
if (this.logFile) {
|
||||
await mkdir(path.dirname(this.logFile), { recursive: true });
|
||||
await appendFile(this.logFile, line + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
info(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("info", event, extra);
|
||||
}
|
||||
warn(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("warn", event, extra);
|
||||
}
|
||||
error(event: string, extra?: Record<string, unknown>) {
|
||||
return this.log("error", event, extra);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonExtras(extra: Record<string, unknown>): string {
|
||||
const entries = Object.entries(extra);
|
||||
if (entries.length === 0) return "";
|
||||
return entries.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { readFile, mkdir, copyFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { GenError, type CliOptions, type GenerateResult } from "./types.ts";
|
||||
import { runCodexExec } from "./spawn.ts";
|
||||
import { findCpToTarget, verifyImageGenWasInvoked, verifyOutput } from "./validator.ts";
|
||||
import { cacheKey, lookupCache, storeCache, FileLock } from "./cache.ts";
|
||||
import { JsonLogger } from "./logger.ts";
|
||||
|
||||
const HELP = `codex-imagegen — generate images via Codex CLI's image_gen tool
|
||||
|
||||
Usage:
|
||||
codex-imagegen --image <output.png> [--prompt <text> | --prompt-file <path>] [options]
|
||||
|
||||
Required:
|
||||
--image <path> Output PNG path
|
||||
--prompt <text> Prompt text (or use --prompt-file)
|
||||
--prompt-file <path> Read prompt from file
|
||||
|
||||
Options:
|
||||
--aspect <ratio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 2.35:1). Default: 1:1
|
||||
--ref <file> Reference image (repeatable)
|
||||
--timeout <ms> Codex exec timeout in ms. Default: 300000
|
||||
--retries <n> Retry attempts on retryable errors. Default: 2
|
||||
--retry-delay <ms> Base retry delay (exponential). Default: 1500
|
||||
--cache-dir <path> Enable idempotency cache. Disabled by default.
|
||||
--log-file <path> Append JSONL log
|
||||
-v, --verbose Verbose stderr logging
|
||||
-h, --help Show this help
|
||||
|
||||
Stdout: single JSON line on success or failure.
|
||||
`;
|
||||
|
||||
const SHELL_METACHAR = /[;|&`$<>\n\r()'"]/;
|
||||
|
||||
function assertSafePath(label: string, value: string): void {
|
||||
if (SHELL_METACHAR.test(value)) {
|
||||
throw new GenError(
|
||||
"invalid_args",
|
||||
`${label} contains shell metacharacters and would be unsafe to interpolate into the codex instruction: ${value}`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
prompt: "",
|
||||
promptFile: null,
|
||||
outputPath: "",
|
||||
aspect: "1:1",
|
||||
refImages: [],
|
||||
timeoutMs: 300_000,
|
||||
retries: 2,
|
||||
retryDelayMs: 1500,
|
||||
cacheDir: null,
|
||||
logFile: null,
|
||||
verbose: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => argv[++i];
|
||||
switch (a) {
|
||||
case "--prompt": opts.prompt = next(); break;
|
||||
case "--prompt-file": opts.promptFile = next(); break;
|
||||
case "--image": opts.outputPath = next(); break;
|
||||
case "--aspect": opts.aspect = next(); break;
|
||||
case "--ref": opts.refImages.push(next()); break;
|
||||
case "--timeout": opts.timeoutMs = Number(next()); break;
|
||||
case "--retries": opts.retries = Number(next()); break;
|
||||
case "--retry-delay": opts.retryDelayMs = Number(next()); break;
|
||||
case "--cache-dir": opts.cacheDir = next(); break;
|
||||
case "--log-file": opts.logFile = next(); break;
|
||||
case "-v":
|
||||
case "--verbose": opts.verbose = true; break;
|
||||
case "-h":
|
||||
case "--help": process.stdout.write(HELP); process.exit(0);
|
||||
default: throw new GenError("invalid_args", `Unknown argument: ${a}`, false);
|
||||
}
|
||||
}
|
||||
if (!opts.outputPath) throw new GenError("invalid_args", "--image is required", false);
|
||||
if (opts.prompt && opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt and --prompt-file are mutually exclusive", false);
|
||||
}
|
||||
if (!opts.prompt && !opts.promptFile) {
|
||||
throw new GenError("invalid_args", "--prompt or --prompt-file required", false);
|
||||
}
|
||||
|
||||
// Resolve every filesystem path to absolute up front, so behavior is
|
||||
// independent of the caller's cwd. This matters when the wrapper is
|
||||
// invoked from a skill running in an arbitrary working directory.
|
||||
const cwd = process.cwd();
|
||||
const toAbs = (p: string) => (path.isAbsolute(p) ? p : path.resolve(cwd, p));
|
||||
|
||||
opts.outputPath = toAbs(opts.outputPath);
|
||||
if (opts.promptFile) opts.promptFile = toAbs(opts.promptFile);
|
||||
opts.refImages = opts.refImages.map(toAbs);
|
||||
if (opts.cacheDir) opts.cacheDir = toAbs(opts.cacheDir);
|
||||
if (opts.logFile) opts.logFile = toAbs(opts.logFile);
|
||||
|
||||
// The output and ref paths are interpolated raw into the agent instruction
|
||||
// sent to `codex exec --sandbox danger-full-access`. A path containing shell
|
||||
// metacharacters could be misread by the agent's shell when it cp's the
|
||||
// result into place. Reject upfront rather than trusting the agent to quote.
|
||||
assertSafePath("--image path", opts.outputPath);
|
||||
for (const ref of opts.refImages) assertSafePath("--ref path", ref);
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function loadPrompt(opts: CliOptions): Promise<string> {
|
||||
if (opts.prompt) return opts.prompt;
|
||||
const file = opts.promptFile!;
|
||||
try {
|
||||
return await readFile(file, "utf-8");
|
||||
} catch {
|
||||
throw new GenError("prompt_file_missing", `Prompt file not found: ${file}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
function buildInstruction(prompt: string, opts: CliOptions): string {
|
||||
const refHint = opts.refImages.length > 0
|
||||
? `\nREFERENCE IMAGES (attached above): ${opts.refImages.length} image(s) provided for style/composition guidance.\n`
|
||||
: "";
|
||||
return `You have an internal tool called image_gen for image generation. Use it.
|
||||
|
||||
TASK: Generate an image with the spec below, then save to disk.
|
||||
|
||||
PROMPT:
|
||||
${prompt}
|
||||
|
||||
ASPECT RATIO: ${opts.aspect}
|
||||
OUTPUT PATH: ${opts.outputPath}
|
||||
${refHint}
|
||||
STEPS:
|
||||
1. Call image_gen with the prompt and aspect ratio above${opts.refImages.length > 0 ? " (using the attached reference images for guidance)" : ""}.
|
||||
2. Move or copy the resulting image from Codex default location ($CODEX_HOME/generated_images/...) to: ${opts.outputPath}
|
||||
3. Verify with: ls -la ${opts.outputPath}
|
||||
4. Reply with ONLY this JSON line (no markdown fences, no other text):
|
||||
{"status":"ok","path":"${opts.outputPath}","bytes":<file_size_in_bytes>}
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Do NOT use curl, wget, Python, or any external API.
|
||||
- Do NOT use bash to fabricate an image; only image_gen produces real pixels.
|
||||
- Use ONLY the image_gen internal tool.`;
|
||||
}
|
||||
|
||||
async function attemptGenerate(
|
||||
opts: CliOptions,
|
||||
instruction: string,
|
||||
attempt: number,
|
||||
log: JsonLogger,
|
||||
): Promise<{ bytes: number; threadId: string | null; usage: any; toolCalls: any[] }> {
|
||||
await log.info("attempt.start", { attempt, output: opts.outputPath, aspect: opts.aspect });
|
||||
|
||||
const run = await runCodexExec({
|
||||
instruction,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
refImages: opts.refImages,
|
||||
});
|
||||
|
||||
await log.info("codex.completed", {
|
||||
duration_ms: run.durationMs,
|
||||
thread_id: run.threadId,
|
||||
tool_calls: run.toolCalls.length,
|
||||
usage: run.usage,
|
||||
raw_log: run.rawLogPath,
|
||||
});
|
||||
|
||||
// verify: thread id must be present
|
||||
if (!run.threadId) {
|
||||
throw new GenError("agent_refused", "No thread id in event stream");
|
||||
}
|
||||
|
||||
// verify: image_gen was actually invoked (check $CODEX_HOME/generated_images/{threadId}/)
|
||||
const ver = await verifyImageGenWasInvoked(run.threadId);
|
||||
if (!ver.ok) {
|
||||
// secondary verify: did tool_calls include cp/mv from generated_images to our target
|
||||
if (!findCpToTarget(run.toolCalls, opts.outputPath)) {
|
||||
throw new GenError("no_image_gen_tool_use", `image_gen was not invoked: ${ver.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// verify output
|
||||
const { bytes } = await verifyOutput(opts.outputPath);
|
||||
|
||||
return {
|
||||
bytes,
|
||||
threadId: run.threadId,
|
||||
usage: run.usage,
|
||||
toolCalls: run.toolCalls.map((tc) => ({ tool: tc.tool, status: tc.status })),
|
||||
};
|
||||
}
|
||||
|
||||
async function generate(opts: CliOptions, log: JsonLogger): Promise<GenerateResult> {
|
||||
const startEpoch = Date.now();
|
||||
const prompt = await loadPrompt(opts);
|
||||
|
||||
// Cache lookup
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
const cached = await lookupCache(opts.cacheDir, key);
|
||||
if (cached) {
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
await copyFile(cached, opts.outputPath);
|
||||
const s = await stat(opts.outputPath);
|
||||
await log.info("cache.hit", { key, source: cached });
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: s.size,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: 0,
|
||||
cached: true,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
};
|
||||
}
|
||||
await log.info("cache.miss", { key });
|
||||
}
|
||||
|
||||
// lock to prevent concurrent codex exec
|
||||
const lockDir = opts.cacheDir ?? path.join(homedir(), ".cache", "baoyu-codex-imagegen");
|
||||
const lock = new FileLock(path.join(lockDir, "codex-exec.lock"));
|
||||
try {
|
||||
await lock.acquire(60_000);
|
||||
} catch (e) {
|
||||
throw new GenError("lock_busy", String(e), false);
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(opts.outputPath), { recursive: true });
|
||||
const instruction = buildInstruction(prompt, opts);
|
||||
|
||||
let lastErr: GenError | null = null;
|
||||
let lastAttempt = 0;
|
||||
try {
|
||||
for (let attempt = 1; attempt <= opts.retries + 1; attempt++) {
|
||||
lastAttempt = attempt;
|
||||
try {
|
||||
const result = await attemptGenerate(opts, instruction, attempt, log);
|
||||
|
||||
// write to cache
|
||||
if (opts.cacheDir) {
|
||||
const key = cacheKey(prompt, opts.aspect, opts.refImages);
|
||||
await storeCache(opts.cacheDir, key, opts.outputPath);
|
||||
await log.info("cache.stored", { key });
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
path: opts.outputPath,
|
||||
bytes: result.bytes,
|
||||
elapsed_seconds: Math.round((Date.now() - startEpoch) / 1000),
|
||||
thread_id: result.threadId,
|
||||
attempts: attempt,
|
||||
cached: false,
|
||||
usage: result.usage,
|
||||
tool_calls: result.toolCalls,
|
||||
};
|
||||
} catch (e) {
|
||||
lastErr = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.warn("attempt.failed", {
|
||||
attempt,
|
||||
kind: lastErr.kind,
|
||||
retryable: lastErr.retryable,
|
||||
error: lastErr.message,
|
||||
});
|
||||
if (!lastErr.retryable || attempt > opts.retries) break;
|
||||
const wait = opts.retryDelayMs * Math.pow(2, attempt - 1);
|
||||
await log.info("retry.wait", { wait_ms: wait, next_attempt: attempt + 1 });
|
||||
await delay(wait);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
const err = lastErr ?? new GenError("spawn_failed", "Unknown failure");
|
||||
err.attempts = lastAttempt;
|
||||
throw err;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let opts: CliOptions;
|
||||
try {
|
||||
opts = parseArgs(process.argv.slice(2));
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("invalid_args", String(e), false);
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const log = new JsonLogger(opts.logFile, opts.verbose);
|
||||
await log.info("start", { output: opts.outputPath, aspect: opts.aspect, refs: opts.refImages.length });
|
||||
|
||||
try {
|
||||
const result = await generate(opts, log);
|
||||
await log.info("done", { bytes: result.bytes, attempts: result.attempts, cached: result.cached });
|
||||
process.stdout.write(JSON.stringify(result) + "\n");
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
const err = e instanceof GenError ? e : new GenError("spawn_failed", String(e));
|
||||
await log.error("failed", { kind: err.kind, error: err.message, attempts: err.attempts ?? 0 });
|
||||
const out: GenerateResult = {
|
||||
status: "error",
|
||||
path: opts.outputPath,
|
||||
bytes: 0,
|
||||
elapsed_seconds: 0,
|
||||
thread_id: null,
|
||||
attempts: err.attempts ?? 0,
|
||||
cached: false,
|
||||
usage: null,
|
||||
tool_calls: [],
|
||||
error: err.message,
|
||||
error_kind: err.kind,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out) + "\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { parseEventStream, hasImageGenInvocation } from "./parser.ts";
|
||||
|
||||
const REAL_PoC_STREAM = `{"type":"thread.started","thread_id":"019e40d3-30e3-7030-874d-773bc0d6d1eb"}
|
||||
{"type":"turn.started"}
|
||||
{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"sed -n '1,5p' /tmp/x.md","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","status":"in_progress"}}
|
||||
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"cp /Users/x/.codex/generated_images/019e40d3/ig_abc.png /tmp/out.png","exit_code":0,"status":"completed"}}
|
||||
{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"status\\":\\"ok\\",\\"path\\":\\"/tmp/out.png\\",\\"bytes\\":1234567}"}}
|
||||
{"type":"turn.completed","usage":{"input_tokens":100000,"cached_input_tokens":80000,"output_tokens":500,"reasoning_output_tokens":50}}`;
|
||||
|
||||
test("parseEventStream extracts threadId, toolCalls, agentMessage, usage", () => {
|
||||
const r = parseEventStream(REAL_PoC_STREAM);
|
||||
expect(r.threadId).toBe("019e40d3-30e3-7030-874d-773bc0d6d1eb");
|
||||
expect(r.toolCalls.length).toBe(3);
|
||||
expect(r.usage).toEqual({
|
||||
input: 100000,
|
||||
cached_input: 80000,
|
||||
output: 500,
|
||||
reasoning: 50,
|
||||
});
|
||||
expect(r.agentMessage).toContain('"status":"ok"');
|
||||
});
|
||||
|
||||
test("parseEventStream tolerates malformed lines", () => {
|
||||
const stream = `not json at all
|
||||
{"type":"thread.started","thread_id":"abc"}
|
||||
{partial json
|
||||
{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0}}`;
|
||||
const r = parseEventStream(stream);
|
||||
expect(r.threadId).toBe("abc");
|
||||
expect(r.usage?.input).toBe(1);
|
||||
});
|
||||
|
||||
test("hasImageGenInvocation finds shell calls touching generated_images", () => {
|
||||
const r = parseEventStream(REAL_PoC_STREAM);
|
||||
// image_gen itself is not an event; inferred via generated_images cp path
|
||||
// this test only verifies parser behavior; driver logic lives in validator
|
||||
const hasCp = r.toolCalls.some((tc) => tc.command?.includes("generated_images"));
|
||||
expect(hasCp).toBe(true);
|
||||
});
|
||||
|
||||
test("hasImageGenInvocation (proper) returns false when no image_gen tool", () => {
|
||||
expect(hasImageGenInvocation([{ id: "1", tool: "shell", status: "completed" }])).toBe(false);
|
||||
expect(
|
||||
hasImageGenInvocation([{ id: "1", tool: "image_gen", status: "completed" }]),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CodexRunResult, ToolCall, TokenUsage } from "./types.ts";
|
||||
|
||||
export function parseEventStream(raw: string): Omit<CodexRunResult, "rawLogPath" | "durationMs"> {
|
||||
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
||||
let threadId: string | null = null;
|
||||
let agentMessage: string | null = null;
|
||||
let usage: TokenUsage | null = null;
|
||||
const toolCallsById = new Map<string, ToolCall>();
|
||||
|
||||
for (const line of lines) {
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const type = event?.type;
|
||||
if (type === "thread.started") {
|
||||
threadId = event.thread_id ?? null;
|
||||
} else if (type === "item.started" || type === "item.completed") {
|
||||
const item = event.item;
|
||||
if (!item?.id) continue;
|
||||
const tc: ToolCall = {
|
||||
id: item.id,
|
||||
tool: deriveToolName(item),
|
||||
status: item.status ?? (type === "item.completed" ? "completed" : "in_progress"),
|
||||
command: item.command,
|
||||
};
|
||||
toolCallsById.set(item.id, tc);
|
||||
if (item.type === "agent_message" && type === "item.completed") {
|
||||
agentMessage = String(item.text ?? "");
|
||||
}
|
||||
} else if (type === "turn.completed") {
|
||||
const u = event.usage;
|
||||
if (u) {
|
||||
usage = {
|
||||
input: u.input_tokens ?? 0,
|
||||
cached_input: u.cached_input_tokens ?? 0,
|
||||
output: u.output_tokens ?? 0,
|
||||
reasoning: u.reasoning_output_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
threadId,
|
||||
toolCalls: Array.from(toolCallsById.values()),
|
||||
agentMessage,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveToolName(item: any): string {
|
||||
if (item.type === "command_execution") return "shell";
|
||||
if (item.type === "agent_message") return "agent_message";
|
||||
if (item.type === "image_gen" || item.type === "image_generation") return "image_gen";
|
||||
if (typeof item.tool === "string") return item.tool;
|
||||
return item.type ?? "unknown";
|
||||
}
|
||||
|
||||
export function hasImageGenInvocation(toolCalls: ToolCall[]): boolean {
|
||||
return toolCalls.some((tc) => tc.tool === "image_gen");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFile, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { GenError, type CodexRunResult } from "./types.ts";
|
||||
import { parseEventStream } from "./parser.ts";
|
||||
|
||||
export interface SpawnInput {
|
||||
instruction: string;
|
||||
timeoutMs: number;
|
||||
refImages?: string[];
|
||||
}
|
||||
|
||||
export async function runCodexExec(input: SpawnInput): Promise<CodexRunResult> {
|
||||
const start = Date.now();
|
||||
const logDir = await mkdtemp(path.join(tmpdir(), "codex-imggen-"));
|
||||
const rawLogPath = path.join(logDir, "stream.jsonl");
|
||||
|
||||
// --skip-git-repo-check: lets the wrapper run from non-git cwds
|
||||
// (e.g. /tmp, or a skill installed under ~/.claude/plugins/...).
|
||||
// Without it, codex refuses with "Not inside a trusted directory".
|
||||
const args = [
|
||||
"exec",
|
||||
"--json",
|
||||
"--sandbox",
|
||||
"danger-full-access",
|
||||
"--skip-git-repo-check",
|
||||
];
|
||||
for (const img of input.refImages ?? []) {
|
||||
args.push("--image", img);
|
||||
}
|
||||
args.push("-");
|
||||
|
||||
let timedOut = false;
|
||||
const child = spawn("codex", args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.stdin.write(input.instruction);
|
||||
child.stdin.end();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 2000);
|
||||
}, input.timeoutMs);
|
||||
|
||||
const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
await writeFile(rawLogPath, stdout + (stderr ? `\n--- stderr ---\n${stderr}` : ""));
|
||||
|
||||
if (timedOut) {
|
||||
throw new GenError("timeout", `codex exec exceeded ${input.timeoutMs}ms (log: ${rawLogPath})`);
|
||||
}
|
||||
if (exit.code !== 0) {
|
||||
if (stderr.includes("command not found") || stderr.includes("not found: codex")) {
|
||||
throw new GenError("codex_not_installed", "codex CLI not installed", false);
|
||||
}
|
||||
throw new GenError(
|
||||
"spawn_failed",
|
||||
`codex exec exited ${exit.code} signal=${exit.signal} (log: ${rawLogPath})`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseEventStream(stdout);
|
||||
return {
|
||||
...parsed,
|
||||
rawLogPath,
|
||||
durationMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface CliOptions {
|
||||
prompt: string;
|
||||
promptFile: string | null;
|
||||
outputPath: string;
|
||||
aspect: string;
|
||||
refImages: string[];
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
retryDelayMs: number;
|
||||
cacheDir: string | null;
|
||||
logFile: string | null;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
tool: string;
|
||||
status: string;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
input: number;
|
||||
cached_input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface CodexRunResult {
|
||||
threadId: string | null;
|
||||
toolCalls: ToolCall[];
|
||||
agentMessage: string | null;
|
||||
usage: TokenUsage | null;
|
||||
rawLogPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
status: "ok" | "error";
|
||||
path: string;
|
||||
bytes: number;
|
||||
elapsed_seconds: number;
|
||||
thread_id: string | null;
|
||||
attempts: number;
|
||||
cached: boolean;
|
||||
usage: TokenUsage | null;
|
||||
tool_calls: { tool: string; status: string }[];
|
||||
error?: string;
|
||||
error_kind?: ErrorKind;
|
||||
}
|
||||
|
||||
export type ErrorKind =
|
||||
| "codex_not_installed"
|
||||
| "invalid_args"
|
||||
| "prompt_file_missing"
|
||||
| "spawn_failed"
|
||||
| "timeout"
|
||||
| "no_image_gen_tool_use"
|
||||
| "output_missing"
|
||||
| "invalid_png"
|
||||
| "agent_refused"
|
||||
| "lock_busy";
|
||||
|
||||
export const RETRYABLE: ReadonlySet<ErrorKind> = new Set([
|
||||
"spawn_failed",
|
||||
"timeout",
|
||||
"no_image_gen_tool_use",
|
||||
"output_missing",
|
||||
"invalid_png",
|
||||
"agent_refused",
|
||||
]);
|
||||
|
||||
export class GenError extends Error {
|
||||
attempts?: number;
|
||||
constructor(public kind: ErrorKind, message: string, public retryable?: boolean) {
|
||||
super(message);
|
||||
this.retryable = retryable ?? RETRYABLE.has(kind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { verifyOutput, verifyImageGenWasInvoked, findCpToTarget } from "./validator.ts";
|
||||
import { GenError } from "./types.ts";
|
||||
|
||||
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
test("verifyOutput passes for valid PNG", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "good.png");
|
||||
await writeFile(p, Buffer.concat([PNG_HEADER, Buffer.alloc(5000)]));
|
||||
const r = await verifyOutput(p);
|
||||
expect(r.bytes).toBeGreaterThan(1000);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects missing file", async () => {
|
||||
await expect(verifyOutput("/no/such/file.png")).rejects.toBeInstanceOf(GenError);
|
||||
});
|
||||
|
||||
test("verifyOutput rejects tiny file", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "tiny.png");
|
||||
await writeFile(p, "tiny");
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/too small/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyOutput rejects non-PNG magic", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "cig-val-"));
|
||||
try {
|
||||
const p = path.join(dir, "fake.png");
|
||||
await writeFile(p, Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(5000)]));
|
||||
await expect(verifyOutput(p)).rejects.toThrow(/not a valid PNG/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked false when no thread directory", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const r = await verifyImageGenWasInvoked("no-such-thread");
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyImageGenWasInvoked true when PNG exists in thread dir", async () => {
|
||||
const orig = process.env.CODEX_HOME;
|
||||
const tempHome = await mkdtemp(path.join(tmpdir(), "cig-home-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
try {
|
||||
const threadDir = path.join(tempHome, "generated_images", "thread-xyz");
|
||||
await mkdir(threadDir, { recursive: true });
|
||||
await writeFile(path.join(threadDir, "ig_abc.png"), Buffer.alloc(100));
|
||||
const r = await verifyImageGenWasInvoked("thread-xyz");
|
||||
expect(r.ok).toBe(true);
|
||||
} finally {
|
||||
process.env.CODEX_HOME = orig;
|
||||
await rm(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findCpToTarget detects cp from generated_images", () => {
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
tool: "shell",
|
||||
status: "completed",
|
||||
command: "cp ~/.codex/generated_images/thread/ig_x.png /tmp/out.png",
|
||||
},
|
||||
],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
findCpToTarget(
|
||||
[{ id: "1", tool: "shell", status: "completed", command: "ls /tmp" }],
|
||||
"/tmp/out.png",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { stat, readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { GenError } from "./types.ts";
|
||||
import type { ToolCall } from "./types.ts";
|
||||
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
export function codexHome(): string {
|
||||
return process.env.CODEX_HOME ?? path.join(homedir(), ".codex");
|
||||
}
|
||||
|
||||
export async function verifyImageGenWasInvoked(threadId: string | null): Promise<{ ok: boolean; reason?: string }> {
|
||||
if (!threadId) return { ok: false, reason: "no thread id" };
|
||||
const dir = path.join(codexHome(), "generated_images", threadId);
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const pngs = entries.filter((e) => e.toLowerCase().endsWith(".png"));
|
||||
if (pngs.length === 0) return { ok: false, reason: `no PNG in ${dir}` };
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: `cannot read ${dir}: ${e?.code ?? e?.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function findCpToTarget(toolCalls: ToolCall[], target: string): boolean {
|
||||
return toolCalls.some(
|
||||
(tc) =>
|
||||
tc.tool === "shell" &&
|
||||
typeof tc.command === "string" &&
|
||||
(tc.command.includes(target) || tc.command.includes(path.basename(target))) &&
|
||||
/\b(cp|mv|cat)\b/.test(tc.command) &&
|
||||
tc.command.includes("generated_images"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyOutput(outputPath: string): Promise<{ bytes: number }> {
|
||||
let s;
|
||||
try {
|
||||
s = await stat(outputPath);
|
||||
} catch {
|
||||
throw new GenError("output_missing", `Output file not created: ${outputPath}`);
|
||||
}
|
||||
if (s.size < 1000) {
|
||||
throw new GenError("invalid_png", `Output file too small (${s.size} bytes)`);
|
||||
}
|
||||
const file = Bun.file(outputPath);
|
||||
const head = new Uint8Array(await file.slice(0, 8).arrayBuffer());
|
||||
for (let i = 0; i < PNG_MAGIC.length; i++) {
|
||||
if (head[i] !== PNG_MAGIC[i]) {
|
||||
throw new GenError("invalid_png", `Output is not a valid PNG (magic mismatch)`);
|
||||
}
|
||||
}
|
||||
return { bytes: s.size };
|
||||
}
|
||||
@@ -29,6 +29,19 @@ When this skill needs to render an image, resolve the backend in this order:
|
||||
2. **Saved preference** — if `EXTEND.md` sets `preferred_image_backend` to a backend available right now, use it.
|
||||
3. **Auto-select** (when the preference is `auto`, unset, or the pinned backend isn't available):
|
||||
- **Codex (`imagegen`)** — first, inspect your available-skills / tool inventory. If a skill named `imagegen` is listed, you are running inside Codex and MUST use it: invoke via the `Skill` tool with `skill: "imagegen"`, passing the saved prompt file's content (plus output path and aspect ratio per Codex `imagegen`'s own args). Codex `imagegen` is the official raster backend in that runtime and outranks any non-native skill (e.g., `baoyu-imagine`) unless the user has explicitly pinned a different `preferred_image_backend`.
|
||||
- **Codex via `codex exec` (`codex-imagegen`)** — if the current runtime does NOT expose a native `imagegen` skill but the `codex` CLI is on `PATH` and `codex login` is active (e.g., Claude Code with Codex CLI installed), invoke the `codex-imagegen` wrapper. **Path resolution**: `scripts/codex-imagegen.sh` lives at the plugin/repo root, NOT relative to your shell cwd. From this `SKILL.md`'s base directory, the wrapper is at `../../scripts/codex-imagegen.sh` — resolve to an absolute path before invoking. Command shape:
|
||||
```bash
|
||||
<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh \
|
||||
--image <absolute_output_path> \
|
||||
--prompt-file <absolute_path_to_prompts/NN-cover-[slug].md> \
|
||||
--aspect <ratio> \
|
||||
[--ref <absolute_file>]... \
|
||||
[--timeout <ms>] \
|
||||
[--cache-dir ~/.cache/baoyu-codex-imagegen] \
|
||||
[--log-file <absolute_jsonl_log_path>]
|
||||
```
|
||||
`--timeout` defaults to 300000 (5 min) per codex exec attempt; raise it (e.g. `--timeout 600000` for 10 min) on slow networks or large prompts.
|
||||
All input paths to the wrapper are auto-resolved against the wrapper's `process.cwd()` if you pass relative ones, but agents should pass absolute paths to be robust against cwd drift. Parse the single-line JSON on stdout. On `{"status":"ok",...}` proceed to Step 5. On `{"status":"error","error_kind":...}` report the `error_kind` to the user and (if retryable) ask whether to retry or fall back to another backend. The wrapper uses the user's Codex subscription — no `OPENAI_API_KEY` needed.
|
||||
- **Other runtime-native tools** — if the runtime exposes a different native image tool (e.g., Hermes `image_generate`), use it the same way.
|
||||
- Otherwise, if exactly one non-native backend is installed (e.g., `baoyu-imagine`), use it.
|
||||
- Otherwise (multiple non-native backends with no runtime-native tool), ask the user once — batch with any other initial questions.
|
||||
@@ -214,7 +227,9 @@ Save to `prompts/cover.md`. Template: [references/workflow/prompt-template.md](r
|
||||
4. **Process references** from prompt frontmatter:
|
||||
- `direct` usage → pass via `--ref` (use ref-capable backend)
|
||||
- `style`/`palette` → extract traits, append to prompt
|
||||
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio
|
||||
5. **Generate**: Call the chosen backend with the prompt file, output path, aspect ratio.
|
||||
- **`codex-imagegen`**: invoke `<ABSOLUTE_PLUGIN_ROOT>/scripts/codex-imagegen.sh` (NOT a cwd-relative `./scripts/...` — resolve the absolute path from this skill's base directory: `../../scripts/codex-imagegen.sh`) with `--image <ABSOLUTE_output>` `--prompt-file <ABSOLUTE_prompts/01-cover-[slug].md>` `--aspect <ratio>` (add `--ref <ABSOLUTE_file>` per reference, `--cache-dir ~/.cache/baoyu-codex-imagegen` to enable the idempotency cache, `--timeout <ms>` to override the default 300000 / 5-min per-attempt limit on slow networks). All input paths to the wrapper are auto-resolved against its `process.cwd()` if relative, but passing absolutes is more robust. Read the stdout JSON; act on `status` and `error_kind`.
|
||||
- **Codex `imagegen` (native)** or other runtime-native tools / `baoyu-imagine` skill: per the rule in `## Image Generation Tools` above.
|
||||
6. On failure: auto-retry once
|
||||
|
||||
### Step 5: Completion Report
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: baoyu-electron-extract
|
||||
description: Extracts resources and JavaScript from any installed Electron app (`.asar` bundle), restoring original sources from `.js.map` files when available or formatting minified code with Prettier otherwise. Use when user wants to "extract Electron app", "decompile Electron", "get the source code of <app>", "inspect app.asar", "看 Electron 应用源码", "提取 .asar", or asks how a desktop Electron app is built. Skips `node_modules` and supports both macOS and Windows.
|
||||
version: 1.119.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-electron-extract
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Electron App Extract
|
||||
|
||||
Extracts resources and code from an installed Electron app's `app.asar`. When a `.js.map` is present, restores the original source files from the embedded `sourcesContent`; otherwise formats the minified code with Prettier. Source-map paths are resolved relative to the `.js.map` file first, so bundled paths like `../../src/main.ts` restore to readable paths such as `restored/src/main.ts` instead of hashed placeholders. Always skips `node_modules`. Works on macOS and Windows.
|
||||
|
||||
## User Input Tools
|
||||
|
||||
When this skill prompts the user, follow this tool-selection rule (priority order):
|
||||
|
||||
1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent.
|
||||
2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question.
|
||||
3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order.
|
||||
|
||||
Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes.
|
||||
|
||||
## Script Directory
|
||||
|
||||
Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||
|
||||
| Script | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------ |
|
||||
| `scripts/main.ts` | App discovery + asar extraction + source-map restoration + Prettier formatting |
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill whenever the user wants to look inside an installed Electron application or inspect its bundled code. Trigger phrases include:
|
||||
|
||||
- "extract Electron app", "decompile this Electron app", "unpack app.asar"
|
||||
- "show me the source of <app>", "look inside <app>", "how is <app> built"
|
||||
- "get the source code of Codex / Cursor / Discord / Slack / VS Code / Notion / Obsidian / ChatGPT desktop"
|
||||
- "提取 Electron 应用", "看 <app> 的源码", "反编译 Electron", "解包 app.asar", "还原 source map"
|
||||
|
||||
Both **app name** (e.g., `Codex`) and **absolute path** (e.g., `/Applications/Codex.app`, a `.asar` file, or a Windows install dir) are accepted. The script handles discovery for both platforms.
|
||||
|
||||
## Workflow
|
||||
|
||||
**1. Determine the input.** Ask the user for the app name or path if they haven't given one. If they want a custom output directory, ask for that too.
|
||||
|
||||
**2. Run the script.**
|
||||
|
||||
```bash
|
||||
${BUN_X} {baseDir}/scripts/main.ts "<app>" [--output <dir>] [--asar <path>] [--force]
|
||||
```
|
||||
|
||||
Start with `--dry-run` first if you're unsure whether discovery will find the right bundle — it prints the resolved paths and exits without touching the filesystem.
|
||||
|
||||
**3. Handle the result.**
|
||||
|
||||
- **Success** → report the output paths and the counts (extracted / restored / formatted).
|
||||
- **Multiple matches** → the script lists candidates and exits non-zero. Show the user the candidates, ask which one to use (via `AskUserQuestion` or the runtime equivalent), then re-run with the chosen absolute path.
|
||||
- **Existing non-empty output dir** → the script refuses without `--force`. Ask the user whether to overwrite (`--force`) or pick a new `--output` path.
|
||||
- **Unsupported platform / no match** → suggest passing `--asar /full/path/to/app.asar` if the user knows where the bundle lives.
|
||||
|
||||
**4. Point the user at the result.** The default output dir is `~/Downloads/<AppName>-electron-extract/`. The most interesting subdirectory depends on what was found:
|
||||
|
||||
- `restored/` exists → the original source tree was reconstructed from `.js.map` files; this is what to read first.
|
||||
- Only `extracted/` exists (no maps) → the JS/CSS in `extracted/` was Prettier-formatted in place; read from there.
|
||||
|
||||
## Source-map path restoration
|
||||
|
||||
The script should preserve original source names and directory structure as much as the source map allows:
|
||||
|
||||
- Resolve each `sources[]` entry with `sourceRoot` when present, then relative to the `.js.map` file's directory inside `extracted/`.
|
||||
- Collapse normal bundler-relative paths into the restored project tree. For example, `.vite/main/index.js.map` + `../../src/main.ts` becomes `restored/src/main.ts`.
|
||||
- If a source path climbs above `extracted/`, keep the readable remaining path under `restored/` instead of hashing it. For example, `.vite/main/index.js.map` + `../../../shared/src/lib/foo.ts` becomes `restored/shared/src/lib/foo.ts`.
|
||||
- Strip URL/query decorations from source names, including common `webpack://`, `file://`, and `?loader` suffixes.
|
||||
- Use `restored/__unknown/<hash>.<ext>` only when the source name is empty or cannot be reduced to a safe file path.
|
||||
- Continue skipping `node_modules` and `webpack/runtime/*` entries; these are bundler/runtime noise, not app sources.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Extract by app name (default output: ~/Downloads/Codex-electron-extract/)
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex
|
||||
|
||||
# Extract by absolute path (works for .app bundles, install dirs, or .asar files)
|
||||
${BUN_X} {baseDir}/scripts/main.ts "/Applications/Visual Studio Code.app"
|
||||
${BUN_X} {baseDir}/scripts/main.ts "C:\Users\you\AppData\Local\Programs\codex"
|
||||
${BUN_X} {baseDir}/scripts/main.ts --asar /Applications/Codex.app/Contents/Resources/app.asar Codex
|
||||
|
||||
# Custom output
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --output ~/work/codex-source
|
||||
|
||||
# Preview discovery without writing anything
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --dry-run
|
||||
|
||||
# Overwrite an existing output dir
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --force
|
||||
|
||||
# Machine-readable result (one JSON line on stdout)
|
||||
${BUN_X} {baseDir}/scripts/main.ts Codex --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Short | Description | Default |
|
||||
| ---------------- | ----- | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `<app>` | | App name or absolute path. Required unless `--asar` is given. | — |
|
||||
| `--output` | `-o` | Output directory | `~/Downloads/<AppName>-electron-extract` |
|
||||
| `--asar` | | Override the resolved `.asar` path | auto-discovered |
|
||||
| `--force` | `-f` | Allow writing into a non-empty existing output dir | false |
|
||||
| `--skip-format` | | Skip Prettier formatting | false |
|
||||
| `--skip-restore` | | Skip source-map restoration | false |
|
||||
| `--no-unpacked` | | Don't copy `app.asar.unpacked/` alongside | false |
|
||||
| `--dry-run` | | Print resolved paths and exit without writing | false |
|
||||
| `--json` | | Emit one JSON-line summary on stdout (suppresses normal output) | false |
|
||||
|
||||
## Output layout
|
||||
|
||||
```
|
||||
~/Downloads/<AppName>-electron-extract/
|
||||
├── extract-report.json # JSON summary: counts, warnings, resolved paths
|
||||
├── extracted/ # raw asar contents (JS/CSS Prettier-formatted when no map)
|
||||
│ └── ... # node_modules left untouched (skipped from format)
|
||||
├── extracted.unpacked/ # copied from <asar>.unpacked/ if present
|
||||
│ └── ... # native modules (.node), large assets
|
||||
└── restored/ # only present if at least one .js.map was usable
|
||||
└── <original/source/tree> # rebuilt from sourcesContent in each .js.map
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **node_modules** is always skipped — both for source-map restoration and Prettier formatting — because vendored dependencies are noise when inspecting an app.
|
||||
- **Source-map restoration** only works when the `.js.map` embeds `sourcesContent`. This is the common case for modern bundlers (webpack, esbuild, Vite, rollup). If a map references external `.ts`/`.js` files without embedding them, that map is skipped and the corresponding `.js` is Prettier-formatted instead. Skipped maps are listed in `extract-report.json` under `warnings`.
|
||||
- **Readable paths over hashes** — don't treat `../` segments in source-map paths as automatically unsafe. First resolve them from the map location and then sanitize the final output path so it still stays under `restored/`. Hash fallback is only for unusable source names.
|
||||
- **App discovery** searches `/Applications` + `~/Applications` on macOS, and `%LOCALAPPDATA%\Programs`, `%PROGRAMFILES%`, `%PROGRAMFILES(X86)%`, `%APPDATA%` on Windows. If discovery finds multiple matches, the script exits and lists them — re-run with an absolute path. On Linux or other platforms, pass `--asar /path/to/app.asar` explicitly.
|
||||
- **Safety** — the script refuses to write to `/`, the user home directly, or the current working directory, and refuses to populate an existing non-empty output dir without `--force`.
|
||||
- **No global installs** — `@electron/asar` and `prettier` are resolved on-the-fly via `npx -y`. First run will be slower while npx caches them.
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeSourcePath, restoreFromMap } from "./main.ts";
|
||||
|
||||
test("normalizes Vite source map paths into readable project paths", () => {
|
||||
const extractedRoot = path.join(os.tmpdir(), "app", "extracted");
|
||||
const mapPath = path.join(extractedRoot, ".vite", "main", "index.js.map");
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"../../src/main/agent/claude-agent.ts",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
undefined
|
||||
),
|
||||
"src/main/agent/claude-agent.ts"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"../../../shared/src/lib/prompt-classification.ts",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
undefined
|
||||
),
|
||||
"shared/src/lib/prompt-classification.ts"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeSourcePath(
|
||||
"./src/renderer/app.tsx",
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
"webpack://moss/"
|
||||
),
|
||||
"moss/src/renderer/app.tsx"
|
||||
);
|
||||
});
|
||||
|
||||
test("restores source map sources without hashing usable relative paths", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "baoyu-electron-extract-"));
|
||||
try {
|
||||
const extractedRoot = path.join(root, "extracted");
|
||||
const restoredRoot = path.join(root, "restored");
|
||||
const mapDir = path.join(extractedRoot, ".vite", "main");
|
||||
const mapPath = path.join(mapDir, "index.js.map");
|
||||
await mkdir(mapDir, { recursive: true });
|
||||
await writeFile(
|
||||
mapPath,
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
file: "index.js",
|
||||
sources: [
|
||||
"../../src/main.ts",
|
||||
"../../src/common/ipcChannels.ts",
|
||||
"../../../shared/src/lib/prompt-classification.ts",
|
||||
"webpack://moss/./src/renderer/app.tsx",
|
||||
"../../../../node_modules/pkg/index.js",
|
||||
"..\\..\\node_modules\\windows-pkg\\index.js",
|
||||
"webpack/runtime/chunk loading",
|
||||
],
|
||||
sourcesContent: [
|
||||
"export const main = true;\n",
|
||||
"export const ipc = true;\n",
|
||||
"export const shared = true;\n",
|
||||
"export const renderer = true;\n",
|
||||
"module.exports = {};\n",
|
||||
"module.exports = {};\n",
|
||||
"runtime();\n",
|
||||
],
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const warnings: string[] = [];
|
||||
const restored = restoreFromMap(
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
restoredRoot,
|
||||
warnings
|
||||
);
|
||||
|
||||
assert.equal(restored, 4);
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.equal(
|
||||
await readFile(path.join(restoredRoot, "src", "main.ts"), "utf8"),
|
||||
"export const main = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(restoredRoot, "src", "common", "ipcChannels.ts"),
|
||||
"utf8"
|
||||
),
|
||||
"export const ipc = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(
|
||||
restoredRoot,
|
||||
"shared",
|
||||
"src",
|
||||
"lib",
|
||||
"prompt-classification.ts"
|
||||
),
|
||||
"utf8"
|
||||
),
|
||||
"export const shared = true;\n"
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(
|
||||
path.join(restoredRoot, "moss", "src", "renderer", "app.tsx"),
|
||||
"utf8"
|
||||
),
|
||||
"export const renderer = true;\n"
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,928 @@
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
posix as posixPath,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from "path";
|
||||
import { createHash } from "crypto";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import { homedir } from "os";
|
||||
import { pathToFileURL } from "url";
|
||||
|
||||
interface Options {
|
||||
app: string;
|
||||
output?: string;
|
||||
asar?: string;
|
||||
force: boolean;
|
||||
skipFormat: boolean;
|
||||
skipRestore: boolean;
|
||||
noUnpacked: boolean;
|
||||
dryRun: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedApp {
|
||||
appName: string;
|
||||
asarPath: string;
|
||||
unpackedDir: string | null;
|
||||
installRoot: string | null;
|
||||
}
|
||||
|
||||
interface Counts {
|
||||
extractedFiles: number;
|
||||
restoredFiles: number;
|
||||
formattedFiles: number;
|
||||
skippedNodeModules: number;
|
||||
unpackedFiles: number;
|
||||
}
|
||||
|
||||
interface Report {
|
||||
appName: string;
|
||||
source: {
|
||||
input: string;
|
||||
asar: string;
|
||||
platform: string;
|
||||
installRoot: string | null;
|
||||
};
|
||||
output: {
|
||||
root: string;
|
||||
extracted: string;
|
||||
unpacked: string | null;
|
||||
restored: string | null;
|
||||
};
|
||||
counts: Counts;
|
||||
warnings: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return `Usage: main.ts <app> [options]
|
||||
|
||||
Extract resources & JavaScript from an installed Electron app.
|
||||
|
||||
Arguments:
|
||||
<app> App name (e.g. "Codex", "Visual Studio Code") or absolute
|
||||
path to a .app bundle, install directory, or .asar file.
|
||||
|
||||
Options:
|
||||
-o, --output PATH Output directory (default: ~/Downloads/<App>-electron-extract)
|
||||
--asar PATH Override the resolved .asar path
|
||||
-f, --force Allow writing into a non-empty existing output dir
|
||||
--skip-format Skip Prettier formatting (extract only)
|
||||
--skip-restore Skip source-map restoration
|
||||
--no-unpacked Don't copy app.asar.unpacked/ alongside
|
||||
--dry-run Print resolved paths and exit without writing
|
||||
--json Emit a single JSON line summary to stdout
|
||||
-h, --help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function fail(message: string, json: boolean): never {
|
||||
if (json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ status: "error", error: message }) + "\n"
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Options {
|
||||
const opts: Options = {
|
||||
app: "",
|
||||
force: false,
|
||||
skipFormat: false,
|
||||
skipRestore: false,
|
||||
noUnpacked: false,
|
||||
dryRun: false,
|
||||
json: false,
|
||||
};
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "-h" || a === "--help") {
|
||||
process.stdout.write(usage());
|
||||
process.exit(0);
|
||||
} else if (a === "-o" || a === "--output") {
|
||||
opts.output = argv[++i];
|
||||
} else if (a === "--asar") {
|
||||
opts.asar = argv[++i];
|
||||
} else if (a === "-f" || a === "--force") {
|
||||
opts.force = true;
|
||||
} else if (a === "--skip-format") {
|
||||
opts.skipFormat = true;
|
||||
} else if (a === "--skip-restore") {
|
||||
opts.skipRestore = true;
|
||||
} else if (a === "--no-unpacked") {
|
||||
opts.noUnpacked = true;
|
||||
} else if (a === "--dry-run") {
|
||||
opts.dryRun = true;
|
||||
} else if (a === "--json") {
|
||||
opts.json = true;
|
||||
} else if (a.startsWith("-")) {
|
||||
throw new Error(`unknown option: ${a}\n\n${usage()}`);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
if (positional.length === 0 && !opts.asar) {
|
||||
throw new Error(
|
||||
`missing <app> argument (or pass --asar PATH)\n\n${usage()}`
|
||||
);
|
||||
}
|
||||
if (positional.length > 1) {
|
||||
throw new Error(
|
||||
`too many positional arguments: ${positional.join(", ")}\n\n${usage()}`
|
||||
);
|
||||
}
|
||||
opts.app = positional[0] ?? "";
|
||||
return opts;
|
||||
}
|
||||
|
||||
function sanitizeAppName(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function appNameFromPath(p: string): string {
|
||||
const parts = resolve(p).split(sep);
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i].endsWith(".app")) return parts[i].slice(0, -4);
|
||||
}
|
||||
const n = basename(p);
|
||||
if (n === "app.asar") {
|
||||
if (
|
||||
parts.length >= 3 &&
|
||||
parts[parts.length - 2].toLowerCase() === "resources"
|
||||
) {
|
||||
return parts[parts.length - 3];
|
||||
}
|
||||
if (parts.length >= 2) return parts[parts.length - 2];
|
||||
}
|
||||
if (n.endsWith(".asar")) return n.slice(0, -5);
|
||||
if (n.endsWith(".app")) return n.slice(0, -4);
|
||||
return n;
|
||||
}
|
||||
|
||||
function existsAsFile(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function existsAsDir(p: string): boolean {
|
||||
try {
|
||||
return statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findAsarUnderDir(dir: string): string | null {
|
||||
const candidates = [
|
||||
join(dir, "resources", "app.asar"),
|
||||
join(dir, "Resources", "app.asar"),
|
||||
join(dir, "app.asar"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsAsFile(c)) return c;
|
||||
}
|
||||
if (!existsAsDir(dir)) return null;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const nested = join(dir, entry.name, "resources", "app.asar");
|
||||
if (existsAsFile(nested)) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveByPath(input: string): ResolvedApp | null {
|
||||
if (!existsSync(input)) return null;
|
||||
const st = statSync(input);
|
||||
if (st.isFile() && input.endsWith(".asar")) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: input,
|
||||
unpackedDir: existsAsDir(input + ".unpacked")
|
||||
? input + ".unpacked"
|
||||
: null,
|
||||
installRoot: dirname(input),
|
||||
};
|
||||
}
|
||||
if (st.isDirectory()) {
|
||||
if (input.endsWith(".app")) {
|
||||
const asar = join(input, "Contents", "Resources", "app.asar");
|
||||
if (existsAsFile(asar)) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: asar,
|
||||
unpackedDir: existsAsDir(asar + ".unpacked")
|
||||
? asar + ".unpacked"
|
||||
: null,
|
||||
installRoot: input,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const asar = findAsarUnderDir(input);
|
||||
if (asar) {
|
||||
return {
|
||||
appName: appNameFromPath(input),
|
||||
asarPath: asar,
|
||||
unpackedDir: existsAsDir(asar + ".unpacked")
|
||||
? asar + ".unpacked"
|
||||
: null,
|
||||
installRoot: input,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function listAppCandidates(name: string): string[] {
|
||||
const lower = name.toLowerCase();
|
||||
const stripped = lower.endsWith(".app") ? lower.slice(0, -4) : lower;
|
||||
const matches: string[] = [];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const roots = ["/Applications", join(homedir(), "Applications")];
|
||||
for (const root of roots) {
|
||||
if (!existsAsDir(root)) continue;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const en = entry.name.toLowerCase();
|
||||
const enStripped = en.endsWith(".app") ? en.slice(0, -4) : en;
|
||||
if (
|
||||
en === lower ||
|
||||
en === `${stripped}.app` ||
|
||||
enStripped === stripped
|
||||
) {
|
||||
matches.push(join(root, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (process.platform === "win32") {
|
||||
const roots = [
|
||||
process.env.LOCALAPPDATA
|
||||
? join(process.env.LOCALAPPDATA, "Programs")
|
||||
: null,
|
||||
process.env.PROGRAMFILES || null,
|
||||
process.env["PROGRAMFILES(X86)"] || null,
|
||||
process.env.APPDATA || null,
|
||||
].filter((x): x is string => Boolean(x));
|
||||
for (const root of roots) {
|
||||
if (!existsAsDir(root)) continue;
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.toLowerCase() === stripped) {
|
||||
matches.push(join(root, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveApp(input: string): ResolvedApp {
|
||||
if (isAbsolute(input)) {
|
||||
const r = resolveByPath(input);
|
||||
if (r) return r;
|
||||
throw new Error(`path exists but no app.asar found at or under: ${input}`);
|
||||
}
|
||||
|
||||
if (process.platform !== "darwin" && process.platform !== "win32") {
|
||||
throw new Error(
|
||||
`platform ${process.platform} is not auto-supported. Pass --asar /path/to/app.asar to override.`
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = listAppCandidates(input);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
`could not find an installed app matching "${input}". Try passing an absolute path or --asar.`
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = candidates
|
||||
.map((c) => resolveByPath(c))
|
||||
.filter((r): r is ResolvedApp => r !== null);
|
||||
|
||||
if (resolved.length === 0) {
|
||||
throw new Error(
|
||||
`found app directories for "${input}" but none contained app.asar:\n ${candidates.join(
|
||||
"\n "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (resolved.length > 1) {
|
||||
throw new Error(
|
||||
`multiple apps match "${input}". Re-run with an absolute path:\n ${resolved
|
||||
.map((r) => r.installRoot ?? r.asarPath)
|
||||
.join("\n ")}`
|
||||
);
|
||||
}
|
||||
return resolved[0];
|
||||
}
|
||||
|
||||
function downloadsDir(): string {
|
||||
return join(homedir(), "Downloads");
|
||||
}
|
||||
|
||||
function assertSafeOutputDir(outputDir: string, appName: string): void {
|
||||
const root =
|
||||
process.platform === "win32" ? outputDir.split(sep)[0] + sep : "/";
|
||||
const home = resolve(homedir());
|
||||
const cwd = resolve(process.cwd());
|
||||
const forbidden = [root, home, cwd];
|
||||
if (forbidden.includes(outputDir)) {
|
||||
throw new Error(`output directory is unsafe to write into: ${outputDir}`);
|
||||
}
|
||||
const base = basename(outputDir).toLowerCase();
|
||||
const tag = sanitizeAppName(appName).toLowerCase();
|
||||
if (!base.includes(tag.toLowerCase()) && !base.includes("electron-extract")) {
|
||||
throw new Error(
|
||||
`output directory basename must contain the app name (or "electron-extract"): ${outputDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dirIsNonEmpty(p: string): boolean {
|
||||
try {
|
||||
return readdirSync(p).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runQuiet(
|
||||
cmd: string,
|
||||
args: string[]
|
||||
): { code: number; stderr: string; stdout: string } {
|
||||
const r = spawnSync(cmd, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (r.error) return { code: 1, stderr: r.error.message, stdout: "" };
|
||||
return {
|
||||
code: r.status ?? 1,
|
||||
stderr: r.stderr ?? "",
|
||||
stdout: r.stdout ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function runForwarded(cmd: string, args: string[]): Promise<number> {
|
||||
return new Promise((res) => {
|
||||
const proc = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit"] });
|
||||
proc.on("close", (code) => res(code ?? 1));
|
||||
proc.on("error", () => res(1));
|
||||
});
|
||||
}
|
||||
|
||||
async function extractAsar(
|
||||
asarPath: string,
|
||||
dest: string,
|
||||
json: boolean
|
||||
): Promise<void> {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
const args = ["-y", "@electron/asar", "extract", asarPath, dest];
|
||||
const code = json
|
||||
? await new Promise<number>((res) => {
|
||||
const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let err = "";
|
||||
p.stderr?.on("data", (d) => (err += d.toString()));
|
||||
p.on("close", (c) => {
|
||||
if ((c ?? 1) !== 0) process.stderr.write(err);
|
||||
res(c ?? 1);
|
||||
});
|
||||
p.on("error", (e) => {
|
||||
process.stderr.write(e.message);
|
||||
res(1);
|
||||
});
|
||||
})
|
||||
: await runForwarded("npx", args);
|
||||
if (code !== 0)
|
||||
throw new Error(`@electron/asar extract failed (code ${code})`);
|
||||
}
|
||||
|
||||
type FileKind = "js" | "css" | "other";
|
||||
|
||||
function classifyExt(p: string): FileKind {
|
||||
const e = extname(p).toLowerCase();
|
||||
if (e === ".js" || e === ".mjs" || e === ".cjs") return "js";
|
||||
if (e === ".css") return "css";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function walk(
|
||||
root: string,
|
||||
out: { jsFiles: string[]; cssFiles: string[] },
|
||||
counts: Counts
|
||||
): void {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules") {
|
||||
counts.skippedNodeModules += 1;
|
||||
continue;
|
||||
}
|
||||
const full = join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, out, counts);
|
||||
} else if (entry.isFile()) {
|
||||
counts.extractedFiles += 1;
|
||||
const k = classifyExt(full);
|
||||
if (k === "js") out.jsFiles.push(full);
|
||||
else if (k === "css") out.cssFiles.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function countFiles(dir: string): number {
|
||||
let total = 0;
|
||||
if (!existsAsDir(dir)) return 0;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
for (const entry of readdirSync(cur, { withFileTypes: true })) {
|
||||
const full = join(cur, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile()) total += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.split(sep).join("/");
|
||||
}
|
||||
|
||||
function sourcePathExt(src: string): string {
|
||||
return extname(src.split(/[?#]/)[0]) || ".txt";
|
||||
}
|
||||
|
||||
function stripSourceDecorations(src: string): {
|
||||
path: string;
|
||||
hadProtocol: boolean;
|
||||
} {
|
||||
let s = src.trim();
|
||||
let hadProtocol = false;
|
||||
if (/^webpack:\/\/\/?/.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^webpack:\/\/\/?/, "");
|
||||
} else if (/^file:\/\//.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^file:\/\//, "");
|
||||
} else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) {
|
||||
hadProtocol = true;
|
||||
s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
|
||||
}
|
||||
return { path: s.split(/[?#]/)[0].replace(/\\/g, "/"), hadProtocol };
|
||||
}
|
||||
|
||||
function isSafeRelativePath(p: string): boolean {
|
||||
return (
|
||||
p !== "" &&
|
||||
p !== "." &&
|
||||
p !== ".." &&
|
||||
!p.startsWith("../") &&
|
||||
!p.includes("/../")
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackUnknownPath(src: string): string {
|
||||
const h = createHash("sha1").update(src).digest("hex").slice(0, 10);
|
||||
return posixPath.join("__unknown", `${h}${sourcePathExt(src)}`);
|
||||
}
|
||||
|
||||
function normalizeForOutputPath(p: string): string {
|
||||
let s = p.replace(/\\/g, "/").replace(/^[a-zA-Z]:\//, "");
|
||||
if (s.startsWith("/")) s = s.replace(/^\/+/, "");
|
||||
return posixPath.normalize(s);
|
||||
}
|
||||
|
||||
function sanitizeEscapedSourcePath(
|
||||
normalized: string,
|
||||
original: string
|
||||
): string {
|
||||
let s = normalized
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/^[a-zA-Z]:\//, "")
|
||||
.replace(/^\/+/, "");
|
||||
while (s.startsWith("../")) s = s.slice(3);
|
||||
s = posixPath.normalize(s);
|
||||
if (!isSafeRelativePath(s)) return fallbackUnknownPath(original);
|
||||
return s;
|
||||
}
|
||||
|
||||
function shouldSkipRestoredSource(src: string): boolean {
|
||||
const s = src.replace(/\\/g, "/");
|
||||
return s.includes("node_modules/") || s.startsWith("webpack/runtime/");
|
||||
}
|
||||
|
||||
function sourceWithRoot(
|
||||
src: string,
|
||||
sourceRoot: unknown
|
||||
): { path: string; hadProtocol: boolean } {
|
||||
const source = stripSourceDecorations(src);
|
||||
if (typeof sourceRoot !== "string" || sourceRoot.trim() === "") return source;
|
||||
|
||||
const root = stripSourceDecorations(sourceRoot);
|
||||
const sourceIsAbsolute =
|
||||
source.path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(source.path);
|
||||
if (!root.path || source.hadProtocol || sourceIsAbsolute) {
|
||||
return {
|
||||
path: source.path,
|
||||
hadProtocol: source.hadProtocol || root.hadProtocol,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: posixPath.join(root.path.replace(/\\/g, "/"), source.path),
|
||||
hadProtocol: source.hadProtocol || root.hadProtocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSourcePath(
|
||||
src: string,
|
||||
mapPath: string,
|
||||
extractedRoot: string,
|
||||
sourceRoot: unknown
|
||||
): string {
|
||||
const source = sourceWithRoot(src, sourceRoot);
|
||||
if (!source.path) return "";
|
||||
|
||||
if (
|
||||
!source.hadProtocol &&
|
||||
(source.path.startsWith("./") || source.path.startsWith("../"))
|
||||
) {
|
||||
const mapRelDir = toPosix(relative(extractedRoot, dirname(mapPath))) || ".";
|
||||
const relativeToExtracted = posixPath.normalize(
|
||||
posixPath.join(mapRelDir, source.path)
|
||||
);
|
||||
if (isSafeRelativePath(relativeToExtracted)) return relativeToExtracted;
|
||||
return sanitizeEscapedSourcePath(relativeToExtracted, src);
|
||||
}
|
||||
|
||||
const normalized = normalizeForOutputPath(source.path);
|
||||
if (isSafeRelativePath(normalized)) return normalized;
|
||||
return sanitizeEscapedSourcePath(normalized, src);
|
||||
}
|
||||
|
||||
function restoredTargetPath(
|
||||
restoredRoot: string,
|
||||
sourcePath: string
|
||||
): string | null {
|
||||
const target = resolve(restoredRoot, ...sourcePath.split("/"));
|
||||
const rel = relative(restoredRoot, target);
|
||||
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
|
||||
return target;
|
||||
}
|
||||
|
||||
interface MapData {
|
||||
sources?: unknown;
|
||||
sourcesContent?: unknown;
|
||||
sourceRoot?: unknown;
|
||||
}
|
||||
|
||||
export function restoreFromMap(
|
||||
mapPath: string,
|
||||
extractedRoot: string,
|
||||
restoredRoot: string,
|
||||
warnings: string[]
|
||||
): number {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(mapPath, "utf8");
|
||||
} catch (e: any) {
|
||||
warnings.push(`read ${mapPath}: ${e.message}`);
|
||||
return 0;
|
||||
}
|
||||
let data: MapData;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch (e: any) {
|
||||
warnings.push(`parse ${mapPath}: ${e.message}`);
|
||||
return 0;
|
||||
}
|
||||
const sources = Array.isArray(data.sources)
|
||||
? (data.sources as unknown[])
|
||||
: null;
|
||||
const contents = Array.isArray(data.sourcesContent)
|
||||
? (data.sourcesContent as unknown[])
|
||||
: null;
|
||||
if (!sources || !contents) return 0;
|
||||
if (sources.length !== contents.length) {
|
||||
warnings.push(`${mapPath}: sources/sourcesContent length mismatch`);
|
||||
}
|
||||
const n = Math.min(sources.length, contents.length);
|
||||
let written = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const src = sources[i];
|
||||
const content = contents[i];
|
||||
if (typeof src !== "string" || typeof content !== "string") continue;
|
||||
if (shouldSkipRestoredSource(src)) continue;
|
||||
const restoredPath = normalizeSourcePath(
|
||||
src,
|
||||
mapPath,
|
||||
extractedRoot,
|
||||
data.sourceRoot
|
||||
);
|
||||
if (!restoredPath) continue;
|
||||
const target = restoredTargetPath(restoredRoot, restoredPath);
|
||||
if (!target) {
|
||||
warnings.push(`${mapPath}: unsafe restored path for source ${src}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content);
|
||||
written += 1;
|
||||
} catch (e: any) {
|
||||
warnings.push(`write ${target}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function formatBatches(files: string[], json: boolean): Promise<void> {
|
||||
const maxArgLen = 50_000;
|
||||
let batch: string[] = [];
|
||||
let len = 0;
|
||||
const flush = async () => {
|
||||
if (batch.length === 0) return;
|
||||
const args = ["-y", "prettier", "--write", ...batch];
|
||||
const code = json
|
||||
? await new Promise<number>((res) => {
|
||||
const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let err = "";
|
||||
p.stderr?.on("data", (d) => (err += d.toString()));
|
||||
p.on("close", (c) => {
|
||||
if ((c ?? 1) !== 0) process.stderr.write(err);
|
||||
res(c ?? 1);
|
||||
});
|
||||
p.on("error", (e) => {
|
||||
process.stderr.write(e.message);
|
||||
res(1);
|
||||
});
|
||||
})
|
||||
: await runForwarded("npx", args);
|
||||
if (code !== 0) {
|
||||
process.stderr.write(
|
||||
`prettier batch exited with code ${code} (continuing)\n`
|
||||
);
|
||||
}
|
||||
batch = [];
|
||||
len = 0;
|
||||
};
|
||||
|
||||
return (async () => {
|
||||
for (const f of files) {
|
||||
batch.push(f);
|
||||
len += f.length + 1;
|
||||
if (len >= maxArgLen) await flush();
|
||||
}
|
||||
await flush();
|
||||
})();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const started = Date.now();
|
||||
let opts: Options;
|
||||
try {
|
||||
opts = parseArgs(process.argv.slice(2));
|
||||
} catch (e: any) {
|
||||
process.stderr.write(`Error: ${e.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let resolved: ResolvedApp;
|
||||
try {
|
||||
if (opts.asar) {
|
||||
const r = resolveByPath(opts.asar);
|
||||
if (!r) throw new Error(`--asar path not found: ${opts.asar}`);
|
||||
const appName =
|
||||
opts.app && !isAbsolute(opts.app)
|
||||
? opts.app
|
||||
: appNameFromPath(opts.asar);
|
||||
resolved = { ...r, appName };
|
||||
} else {
|
||||
resolved = resolveApp(opts.app);
|
||||
}
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const outputDir = resolve(
|
||||
opts.output ??
|
||||
join(
|
||||
downloadsDir(),
|
||||
`${sanitizeAppName(resolved.appName)}-electron-extract`
|
||||
)
|
||||
);
|
||||
try {
|
||||
assertSafeOutputDir(outputDir, resolved.appName);
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const extractedDir = join(outputDir, "extracted");
|
||||
const unpackedOut = join(outputDir, "extracted.unpacked");
|
||||
const restoredDir = join(outputDir, "restored");
|
||||
|
||||
if (opts.dryRun) {
|
||||
const summary = {
|
||||
status: "dry-run",
|
||||
appName: resolved.appName,
|
||||
asar: resolved.asarPath,
|
||||
unpacked: resolved.unpackedDir,
|
||||
output: outputDir,
|
||||
extracted: extractedDir,
|
||||
extractedUnpacked: opts.noUnpacked
|
||||
? null
|
||||
: resolved.unpackedDir
|
||||
? unpackedOut
|
||||
: null,
|
||||
restored: opts.skipRestore ? null : restoredDir,
|
||||
};
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify(summary) + "\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[dry-run] App: ${resolved.appName}\n` +
|
||||
`[dry-run] Asar: ${resolved.asarPath}\n` +
|
||||
`[dry-run] Unpacked: ${resolved.unpackedDir ?? "(none)"}\n` +
|
||||
`[dry-run] Output: ${outputDir}\n` +
|
||||
`[dry-run] extracted: ${extractedDir}\n` +
|
||||
`[dry-run] unpacked: ${
|
||||
opts.noUnpacked
|
||||
? "(skipped)"
|
||||
: resolved.unpackedDir
|
||||
? unpackedOut
|
||||
: "(none)"
|
||||
}\n` +
|
||||
`[dry-run] restored: ${
|
||||
opts.skipRestore ? "(skipped)" : restoredDir
|
||||
}\n`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(outputDir) && dirIsNonEmpty(outputDir) && !opts.force) {
|
||||
fail(
|
||||
`output directory exists and is not empty: ${outputDir}\n Pass --force to allow writing into it, or choose another --output.`,
|
||||
opts.json
|
||||
);
|
||||
}
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
if (!opts.json) {
|
||||
process.stdout.write(`App: ${resolved.appName}\n`);
|
||||
process.stdout.write(`Asar: ${resolved.asarPath}\n`);
|
||||
process.stdout.write(`Output: ${outputDir}\n`);
|
||||
process.stdout.write(`Extracting asar...\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
await extractAsar(resolved.asarPath, extractedDir, opts.json);
|
||||
} catch (e: any) {
|
||||
fail(e.message, opts.json);
|
||||
}
|
||||
|
||||
const counts: Counts = {
|
||||
extractedFiles: 0,
|
||||
restoredFiles: 0,
|
||||
formattedFiles: 0,
|
||||
skippedNodeModules: 0,
|
||||
unpackedFiles: 0,
|
||||
};
|
||||
const warnings: string[] = [];
|
||||
|
||||
let unpackedFinal: string | null = null;
|
||||
if (
|
||||
!opts.noUnpacked &&
|
||||
resolved.unpackedDir &&
|
||||
existsAsDir(resolved.unpackedDir)
|
||||
) {
|
||||
if (!opts.json) process.stdout.write(`Copying app.asar.unpacked...\n`);
|
||||
try {
|
||||
cpSync(resolved.unpackedDir, unpackedOut, { recursive: true });
|
||||
counts.unpackedFiles = countFiles(unpackedOut);
|
||||
unpackedFinal = unpackedOut;
|
||||
} catch (e: any) {
|
||||
warnings.push(`copy unpacked: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const walked = { jsFiles: [] as string[], cssFiles: [] as string[] };
|
||||
walk(extractedDir, walked, counts);
|
||||
|
||||
const toFormat: string[] = [];
|
||||
const restoredMapFiles = new Set<string>();
|
||||
const skipFormatSet = new Set<string>();
|
||||
|
||||
if (!opts.skipRestore) {
|
||||
for (const js of walked.jsFiles) {
|
||||
const mapPath = js + ".map";
|
||||
if (!existsAsFile(mapPath)) continue;
|
||||
const w = restoreFromMap(mapPath, extractedDir, restoredDir, warnings);
|
||||
if (w > 0) {
|
||||
counts.restoredFiles += w;
|
||||
restoredMapFiles.add(mapPath);
|
||||
skipFormatSet.add(js);
|
||||
const license = js + ".LICENSE.txt";
|
||||
if (existsAsFile(license)) skipFormatSet.add(license);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.skipFormat) {
|
||||
for (const js of walked.jsFiles)
|
||||
if (!skipFormatSet.has(js)) toFormat.push(js);
|
||||
for (const css of walked.cssFiles) toFormat.push(css);
|
||||
if (toFormat.length > 0) {
|
||||
if (!opts.json)
|
||||
process.stdout.write(
|
||||
`Formatting ${toFormat.length} file(s) with prettier...\n`
|
||||
);
|
||||
await formatBatches(toFormat, opts.json);
|
||||
counts.formattedFiles = toFormat.length;
|
||||
}
|
||||
}
|
||||
|
||||
const report: Report = {
|
||||
appName: resolved.appName,
|
||||
source: {
|
||||
input: opts.app,
|
||||
asar: resolved.asarPath,
|
||||
platform: process.platform,
|
||||
installRoot: resolved.installRoot,
|
||||
},
|
||||
output: {
|
||||
root: outputDir,
|
||||
extracted: extractedDir,
|
||||
unpacked: unpackedFinal,
|
||||
restored: counts.restoredFiles > 0 ? restoredDir : null,
|
||||
},
|
||||
counts,
|
||||
warnings,
|
||||
durationMs: Date.now() - started,
|
||||
};
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
join(outputDir, "extract-report.json"),
|
||||
JSON.stringify(report, null, 2)
|
||||
);
|
||||
} catch (e: any) {
|
||||
warnings.push(`write extract-report.json: ${e.message}`);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(JSON.stringify({ status: "ok", ...report }) + "\n");
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`\nDone in ${(report.durationMs / 1000).toFixed(1)}s\n` +
|
||||
` Extracted: ${counts.extractedFiles} file(s) into ${extractedDir}\n` +
|
||||
(counts.unpackedFiles > 0
|
||||
? ` Unpacked copied: ${counts.unpackedFiles} file(s) into ${unpackedOut}\n`
|
||||
: "") +
|
||||
` Restored: ${counts.restoredFiles} file(s)` +
|
||||
(counts.restoredFiles > 0 ? ` into ${restoredDir}\n` : `\n`) +
|
||||
` Formatted: ${counts.formattedFiles} file(s)\n` +
|
||||
` node_modules dirs skipped: ${counts.skippedNodeModules}\n` +
|
||||
(warnings.length > 0
|
||||
? ` Warnings: ${warnings.length} (see extract-report.json)\n`
|
||||
: "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectRun(): boolean {
|
||||
if (import.meta.main) return true;
|
||||
const scriptPath = process.argv[1];
|
||||
return Boolean(
|
||||
scriptPath && pathToFileURL(resolve(scriptPath)).href === import.meta.url
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
main().catch((e) => {
|
||||
process.stderr.write(`Unexpected error: ${e?.stack ?? e}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -51,6 +51,16 @@ Check these paths in order; first hit wins:
|
||||
|
||||
Minimum working examples — see `references/usage-examples.md` for the full set including per-provider invocations and batch mode.
|
||||
|
||||
### Identity-preserving reference prompts
|
||||
|
||||
When the user wants a real person/character/object preserved from reference images, do **not** replace the reference with a long generic description. Prefer short, hard identity-preservation language:
|
||||
|
||||
- "Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject."
|
||||
- "Only change scene, clothing, pose, lighting, rendering style, and composition. Keep the face/proportions/hair/key accessories/overall identity from the references."
|
||||
- If using multiple references, state that they are the same subject and should jointly define identity.
|
||||
|
||||
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
|
||||
|
||||
```bash
|
||||
# Basic
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
@@ -71,6 +81,16 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider d
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
```
|
||||
|
||||
## Reference-Image Identity Preservation
|
||||
|
||||
When the user wants a person/object preserved from reference images:
|
||||
|
||||
- Prefer a small curated set of existing source references (usually 2–4) over many images; large multi-megabyte refs can destabilize streaming providers.
|
||||
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
|
||||
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
|
||||
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
|
||||
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -118,6 +138,18 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
|
||||
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
### Codex/ChatGPT OAuth is not an OpenAI API key
|
||||
|
||||
`--provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
|
||||
|
||||
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider:
|
||||
|
||||
- In Codex runtime: use the native `imagegen` skill/tool.
|
||||
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
|
||||
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
|
||||
|
||||
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Priority (highest → lowest) applies to every provider:
|
||||
@@ -211,11 +243,17 @@ Rule of thumb: once prompt files are saved and the task is "generate all of thes
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with unsupported provider/model → error with fix hint
|
||||
|
||||
### Codex image2 fallback
|
||||
|
||||
If `--provider openai` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/usage-examples.md` | Extended CLI examples across providers and batch mode |
|
||||
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through the standard OpenAI API-key provider |
|
||||
| `references/codex-image2-fallback.md` | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
|
||||
| `references/providers/dashscope.md` | DashScope families, sizes, limits |
|
||||
| `references/providers/zai.md` | Z.AI GLM-image / cogview-4 |
|
||||
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: codex-image2-fallback
|
||||
description: Fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available
|
||||
---
|
||||
|
||||
# Codex Image2 Fallback
|
||||
|
||||
The `openai` provider can fail with:
|
||||
|
||||
```text
|
||||
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.
|
||||
```
|
||||
|
||||
This is expected. The `openai` provider uses the public OpenAI Images API and needs `OPENAI_API_KEY`. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
|
||||
|
||||
## Practical fallback pattern
|
||||
|
||||
1. Try this skill when provider credentials are available.
|
||||
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
|
||||
3. Prefer a Codex/native raster backend in this order:
|
||||
- Codex runtime native `imagegen` skill/tool, if available.
|
||||
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
|
||||
- Hermes native `image_generate`, if available.
|
||||
4. Be transparent about reference-image behavior:
|
||||
- If the fallback backend accepts references, pass the reference images.
|
||||
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
|
||||
5. Return the generated media path or structured backend error promptly.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Codex OAuth vs OpenAI API key
|
||||
|
||||
This deprecated skill keeps cross-cutting image-generation guidance in sync with `baoyu-imagine`.
|
||||
|
||||
The `openai` provider uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
|
||||
|
||||
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make the existing `openai` provider work because the auth, route, and payload shape differ.
|
||||
|
||||
## What to use instead
|
||||
|
||||
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
||||
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it.
|
||||
- If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits.
|
||||
- If first-class Codex OAuth support is added, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
|
||||
|
||||
## Reference-image prompting note
|
||||
|
||||
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Prefer direct wording:
|
||||
|
||||
> Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
|
||||
@@ -52,6 +52,16 @@ Legacy compatibility: if `.baoyu-skills/baoyu-image-gen/EXTEND.md` exists and th
|
||||
|
||||
Minimum working examples — see `references/usage-examples.md` for the full set including per-provider invocations and batch mode.
|
||||
|
||||
### Identity-preserving reference prompts
|
||||
|
||||
When the user wants a real person/character/object preserved from reference images, do **not** replace the reference with a long generic description. Prefer short, hard identity-preservation language:
|
||||
|
||||
- "Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject."
|
||||
- "Only change scene, clothing, pose, lighting, rendering style, and composition. Keep the face/proportions/hair/key accessories/overall identity from the references."
|
||||
- If using multiple references, state that they are the same subject and should jointly define identity.
|
||||
|
||||
Pitfall: long descriptions like "young East Asian woman, oval face, clear eyes..." can cause the model to synthesize a new person matching the description instead of preserving the referenced person.
|
||||
|
||||
```bash
|
||||
# Basic
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
@@ -75,6 +85,16 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
|
||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
```
|
||||
|
||||
## Reference-Image Identity Preservation
|
||||
|
||||
When the user wants a person/object preserved from reference images:
|
||||
|
||||
- Prefer a small curated set of existing source references (usually 2–4) over many images; large multi-megabyte refs can destabilize streaming providers.
|
||||
- Make the prompt say the references are the same subject and the output must use that identity. Avoid long generic facial-feature descriptions that can cause the model to synthesize a new similar-looking person.
|
||||
- Do not use newly generated outputs as references unless the user explicitly asks; generated refs compound drift.
|
||||
- If results become too polished or influencer-like, reduce stylized refs and add explicit anti-beautification constraints (no face slimming, eye enlargement, heavy makeup, commercial travel shoot, over-smoothing).
|
||||
- If the subject should look younger/older, preserve the face and express age through clothing, posture, scene, and styling; do not ask the model to change facial identity.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
@@ -122,6 +142,18 @@ ${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4
|
||||
|
||||
**Load priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||
|
||||
### Codex/ChatGPT OAuth is not an OpenAI API key
|
||||
|
||||
`--provider openai --model gpt-image-2` uses the standard OpenAI Images API (`/v1/images/generations` or `/v1/images/edits`) and requires `OPENAI_API_KEY`. A Codex or ChatGPT desktop login is a different entitlement and is not a drop-in replacement for `OPENAI_API_KEY`; do not paste a Codex OAuth token into `OPENAI_API_KEY` or only set `OPENAI_BASE_URL` to a Codex backend.
|
||||
|
||||
If the user wants to use their Codex subscription / GPT Image 2 entitlement without an OpenAI API key, route through a Codex-native backend instead of this skill's `openai` provider:
|
||||
|
||||
- In Codex runtime: use the native `imagegen` skill/tool.
|
||||
- In non-Codex runtimes with `codex` CLI installed and logged in: use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it (for example `baoyu-cover-image`). Resolve it from the plugin/repo root and pass absolute prompt/output/reference paths.
|
||||
- In Hermes runtimes with a native `image_generate` tool: use that tool as a fallback, and state whether reference images were passed directly or reconstructed from extracted traits.
|
||||
|
||||
Do not modify the existing `openai` provider to silently consume Codex OAuth. If first-class Codex OAuth support is added to `baoyu-imagine`, implement it as a distinct provider (for example `openai-codex`) with its own auth, route, request shape, docs, and tests. See `references/codex-oauth-vs-openai-api-key.md`.
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Priority (highest → lowest) applies to every provider:
|
||||
@@ -219,11 +251,17 @@ Rule of thumb: once prompt files are saved and the task is "generate all of thes
|
||||
- Invalid aspect ratio → warning, proceed with default
|
||||
- Reference images with unsupported provider/model → error with fix hint
|
||||
|
||||
### Codex image2 fallback
|
||||
|
||||
If `--provider openai --model gpt-image-2` fails because `OPENAI_API_KEY` is missing but the current runtime has a native image-generation backend or the repo-level `codex-imagegen` wrapper is available, use that path rather than leaving the user waiting. Be explicit about whether the fallback is true reference-image generation or only a text-prompt reconstruction from extracted visual traits. See `references/codex-image2-fallback.md`.
|
||||
|
||||
## References
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `references/usage-examples.md` | Extended CLI examples across providers and batch mode |
|
||||
| `references/codex-oauth-vs-openai-api-key.md` | Why Codex/ChatGPT OAuth image2 entitlement is not usable through baoyu-imagine's standard OpenAI API-key provider |
|
||||
| `references/codex-image2-fallback.md` | Practical fallback behavior when OpenAI API credentials are absent but Codex/native image generation is available |
|
||||
| `references/providers/dashscope.md` | DashScope families, sizes, limits |
|
||||
| `references/providers/zai.md` | Z.AI GLM-image / cogview-4 |
|
||||
| `references/providers/minimax.md` | MiniMax image-01 + subject reference |
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: codex-image2-fallback
|
||||
description: Fallback behavior when baoyu-imagine lacks OpenAI API credentials but Codex/native image generation is available
|
||||
---
|
||||
|
||||
# Codex Image2 Fallback
|
||||
|
||||
When using `baoyu-imagine` with `--provider openai --model gpt-image-2`, the CLI can fail with:
|
||||
|
||||
```text
|
||||
OPENAI_API_KEY is required. Codex/ChatGPT desktop login does not automatically grant OpenAI Images API access to this script.
|
||||
```
|
||||
|
||||
This is expected. The `openai` provider uses the public OpenAI Images API and needs `OPENAI_API_KEY`. Codex / ChatGPT image2 entitlement is a separate runtime-native path.
|
||||
|
||||
## Practical fallback pattern
|
||||
|
||||
1. Try `baoyu-imagine` when provider credentials are available.
|
||||
2. If it fails only because `OPENAI_API_KEY` is missing, do not leave the user waiting.
|
||||
3. Prefer a Codex/native raster backend in this order:
|
||||
- Codex runtime native `imagegen` skill/tool, if available.
|
||||
- Repo-level `scripts/codex-imagegen.sh`, if `codex` CLI is installed/logged in and the calling skill supports the wrapper.
|
||||
- Hermes native `image_generate`, if available.
|
||||
4. Be transparent about reference-image behavior:
|
||||
- If the fallback backend accepts references, pass the reference images.
|
||||
- If it does not, derive a concise identity-preserving prompt from the references and state that it is a text-description fallback, not strict reference-image editing.
|
||||
5. Return the generated media path or structured backend error promptly.
|
||||
|
||||
## User-facing wording
|
||||
|
||||
Use concise wording such as:
|
||||
|
||||
> The OpenAI API path needs `OPENAI_API_KEY`; Codex login is a separate image2 backend. I used the available Codex/native image backend instead. Reference images were [passed directly / reconstructed from visual traits].
|
||||
|
||||
Avoid implying that `baoyu-imagine --provider openai` can use Codex OAuth without a dedicated provider implementation.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Codex OAuth vs OpenAI API key for baoyu-imagine
|
||||
|
||||
`baoyu-imagine --provider openai` uses the standard OpenAI Images API and requires `OPENAI_API_KEY`. It calls OpenAI-compatible image endpoints such as `/images/generations` and `/images/edits`.
|
||||
|
||||
Codex / ChatGPT login is different. Codex image generation is driven by Codex OAuth and the Codex runtime's `image_gen` capability, not by the public OpenAI Images API key path. A Codex OAuth token is not a drop-in replacement for `OPENAI_API_KEY`, and setting `OPENAI_BASE_URL` to a Codex backend will not make baoyu-imagine's existing `openai` provider work because the auth, route, and payload shape differ.
|
||||
|
||||
## What to use instead
|
||||
|
||||
- If running inside Codex and the native `imagegen` skill/tool is available, use it directly.
|
||||
- If running outside Codex but the `codex` CLI is installed and logged in, use the repo-level `scripts/codex-imagegen.sh` wrapper when the calling skill supports it. The wrapper invokes `codex exec` and the Codex `image_gen` tool; no `OPENAI_API_KEY` is required.
|
||||
- If running inside Hermes and a native `image_generate` tool is available, use that as a runtime-native fallback. Be explicit about whether reference images are passed directly or only reconstructed from extracted traits.
|
||||
- If the user wants `baoyu-imagine` itself to support Codex OAuth, add a distinct provider such as `openai-codex` rather than modifying the existing `openai` provider.
|
||||
|
||||
## Reference-image prompting note
|
||||
|
||||
When using actual reference images for identity preservation, avoid long generic descriptions of the subject. Long descriptions can cause the model to synthesize a new similar-looking person/object. Prefer direct wording:
|
||||
|
||||
> Use the person/object in the reference image(s) as the same identity. Do not redesign it or create a similar-looking new subject. Only change scene, clothing, pose, lighting, rendering style, and composition.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-post-to-wechat
|
||||
description: Posts content to WeChat Official Account (微信公众号) via API or Chrome CDP. Supports article posting (文章) with HTML, markdown, or plain text input, and image-text posting (贴图, formerly 图文) with multiple images. Markdown article workflows default to converting ordinary external links into bottom citations for WeChat-friendly output. Use when user mentions "发布公众号", "post to wechat", "微信公众号", or "贴图/图文/文章".
|
||||
version: 1.117.3
|
||||
version: 1.117.5
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-post-to-wechat
|
||||
@@ -64,13 +64,26 @@ Found → read, parse, apply. Not found → run first-time setup (`references/co
|
||||
```md
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
default_publish_method: api
|
||||
default_publish_method: browser
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
chrome_profile_path: /path/to/chrome/profile
|
||||
|
||||
# Remote API publishing (optional) — only set if WeChat's IP allowlist
|
||||
# excludes your local machine. See "Remote API Method" below.
|
||||
# remote_publish_host: server.example.com
|
||||
# remote_publish_user: deploy
|
||||
# remote_publish_port: 22
|
||||
# remote_publish_identity_file: ~/.ssh/id_ed25519
|
||||
# remote_publish_known_hosts_file: ~/.ssh/known_hosts
|
||||
# remote_publish_strict_host_key_checking: accept-new
|
||||
# remote_publish_connect_timeout: 10
|
||||
# remote_publish_proxy_jump: bastion.example.com
|
||||
```
|
||||
|
||||
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only (no passwords).
|
||||
|
||||
**Theme options**: default, grace, simple, modern. **Color presets**: blue, green, vermilion, yellow, purple, sky, rose, olive, black, gray, pink, red, orange (or hex).
|
||||
|
||||
**Value priority**: CLI args → frontmatter → EXTEND.md (account-level → global) → skill defaults.
|
||||
@@ -148,11 +161,14 @@ Ask method unless specified in EXTEND.md or CLI:
|
||||
|
||||
| Method | Speed | Requires |
|
||||
|--------|-------|----------|
|
||||
| `api` (Recommended) | Fast | API credentials |
|
||||
| `api` (Recommended) | Fast | API credentials (local IP allowlisted) |
|
||||
| `browser` | Slow | Chrome + logged-in session |
|
||||
| `remote-api` | Fast | API credentials + an SSH-reachable server whose IP is on the WeChat allowlist |
|
||||
|
||||
**API selected + missing credentials** → run guided setup per `references/api-setup.md` (writes to `.baoyu-skills/.env`).
|
||||
|
||||
**`remote-api` method**: WeChat's "公众号设置 → IP 白名单" often limits API access to one or two fixed IPs. If your local machine's IP is not on that list but a cloud server's is, use `remote-api`: all markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally, and only the outbound HTTPS calls to `api.weixin.qq.com` (token, uploadimg, add_material, draft/add) are tunneled through an SSH SOCKS5 dynamic port forward (`ssh -N -D`) so that WeChat sees the remote server as the source IP. No files are written to the remote host; `AppSecret` never leaves the local process. Requires only `sshd` and outbound network on the remote host — no Python, no agent process. See "Remote API Method" below.
|
||||
|
||||
### Step 3: Resolve Theme/Color and Validate Metadata
|
||||
|
||||
1. **Theme**: CLI `--theme` → EXTEND.md `default_theme` → `default` (first match wins; do NOT ask if resolved).
|
||||
@@ -183,6 +199,14 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> [--color <color>
|
||||
|
||||
Always pass `--theme` even if it's `default`. Only pass `--color` when explicitly set by the user or EXTEND.md.
|
||||
|
||||
**Remote API method** (same script, adds `--remote`):
|
||||
|
||||
```bash
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme <theme> --remote [--remote-host <host>] [--remote-user <user>] [--remote-port <port>] [--remote-identity-file <path>] [--remote-known-hosts-file <path>] [--remote-strict-host-key-checking yes|no|accept-new] [--remote-connect-timeout <s>] [--remote-proxy-jump <spec>]
|
||||
```
|
||||
|
||||
Any `--remote-*` flag implies `--remote`. CLI values override account-level then global `remote_publish_*` keys from EXTEND.md. Setting `default_publish_method: remote-api` also enables remote mode without `--remote`.
|
||||
|
||||
**`draft/add` payload rules**:
|
||||
- Endpoint: `POST https://api.weixin.qq.com/cgi-bin/draft/add?access_token=ACCESS_TOKEN`
|
||||
- `article_type`: `news` (default) or `newspic`
|
||||
@@ -225,19 +249,20 @@ Files created:
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
| Feature | Image-Text | Article (API) | Article (Browser) |
|
||||
|---------|:---:|:---:|:---:|
|
||||
| Plain text input | ✗ | ✓ | ✓ |
|
||||
| HTML input | ✗ | ✓ | ✓ |
|
||||
| Markdown input | Title/content | ✓ | ✓ |
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✗ |
|
||||
| Comment control | ✗ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Slow |
|
||||
| Feature | Image-Text | Article (API) | Article (Remote API) | Article (Browser) |
|
||||
|---------|:---:|:---:|:---:|:---:|
|
||||
| Plain text input | ✗ | ✓ | ✓ | ✓ |
|
||||
| HTML input | ✗ | ✓ | ✓ | ✓ |
|
||||
| Markdown input | Title/content | ✓ | ✓ | ✓ |
|
||||
| Multiple images | ✓ (up to 9) | ✓ (inline) | ✓ (inline) | ✓ (inline) |
|
||||
| Themes | ✗ | ✓ | ✓ | ✓ |
|
||||
| Auto-generate metadata | ✗ | ✓ | ✓ | ✓ |
|
||||
| Default cover fallback (`imgs/cover.png`) | ✗ | ✓ | ✓ | ✗ |
|
||||
| Comment control | ✗ | ✓ | ✓ | ✗ |
|
||||
| Requires Chrome | ✓ | ✗ | ✗ | ✓ |
|
||||
| Requires API credentials | ✗ | ✓ | ✓ | ✗ |
|
||||
| Requires SSH-reachable server with allowlisted IP | ✗ | ✗ | ✓ | ✗ |
|
||||
| Speed | Medium | Fast | Fast | Slow |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -251,6 +276,10 @@ Files created:
|
||||
| No cover image | Add frontmatter cover or place `imgs/cover.png` in article directory |
|
||||
| Wrong comment defaults | Check `need_open_comment` / `only_fans_can_comment` in EXTEND.md |
|
||||
| Paste fails | Check system clipboard permissions |
|
||||
| `Remote publish host is required` | Set `--remote-host` or `remote_publish_host` in EXTEND.md |
|
||||
| `SOCKS proxy on 127.0.0.1:… not ready` | SSH could not start the tunnel — check key, host, `StrictHostKeyChecking`, or use `--remote-connect-timeout` |
|
||||
| `ssh exited early` during remote publish | Verify the user can `ssh` non-interactively to the server; raise `--remote-connect-timeout` if the link is slow |
|
||||
| Remote API call returns `errcode 40164` (invalid IP) | The remote server's egress IP is not on WeChat's allowlist; add it in 公众号设置 → IP 白名单 |
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -86,8 +86,12 @@ options:
|
||||
description: "Fast, requires API credentials (AppID + AppSecret)"
|
||||
- label: "browser"
|
||||
description: "Slow, requires Chrome and login session"
|
||||
- label: "remote-api"
|
||||
description: "Fast, tunnels WeChat API calls through SSH to a server whose IP is on the WeChat allowlist"
|
||||
```
|
||||
|
||||
If the user selects `remote-api`, prompt for `remote_publish_host` and (optionally) `remote_publish_user`, `remote_publish_identity_file`. These can also be filled in later by editing EXTEND.md.
|
||||
|
||||
### Question 4: Default Author
|
||||
|
||||
```yaml
|
||||
@@ -157,13 +161,26 @@ options:
|
||||
```md
|
||||
default_theme: [default/grace/simple/modern]
|
||||
default_color: [preset name, hex, or empty for theme default]
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name or empty]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
chrome_profile_path:
|
||||
|
||||
# Remote API publishing — only fill in if default_publish_method is remote-api
|
||||
# or you plan to pass --remote on the CLI.
|
||||
remote_publish_host:
|
||||
remote_publish_user:
|
||||
remote_publish_port:
|
||||
remote_publish_identity_file:
|
||||
remote_publish_known_hosts_file:
|
||||
remote_publish_strict_host_key_checking:
|
||||
remote_publish_connect_timeout:
|
||||
remote_publish_proxy_jump:
|
||||
```
|
||||
|
||||
Raw `ssh` / `scp` options are intentionally not supported; only the typed keys above are honored. Authentication is SSH key only.
|
||||
|
||||
### Multi-Account
|
||||
|
||||
```md
|
||||
@@ -174,15 +191,19 @@ accounts:
|
||||
- name: [display name]
|
||||
alias: [short key, e.g. "baoyu"]
|
||||
default: true
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
app_id: [WeChat App ID, optional]
|
||||
app_secret: [WeChat App Secret, optional]
|
||||
# Remote API publishing (optional, per-account override of globals)
|
||||
remote_publish_host:
|
||||
remote_publish_user:
|
||||
remote_publish_identity_file:
|
||||
- name: [second account name]
|
||||
alias: [short key, e.g. "ai-tools"]
|
||||
default_publish_method: [api/browser]
|
||||
default_publish_method: [api/browser/remote-api]
|
||||
default_author: [author name]
|
||||
need_open_comment: [1/0]
|
||||
only_fans_can_comment: [1/0]
|
||||
|
||||
@@ -37,7 +37,7 @@ accounts:
|
||||
|
||||
## Per-Account vs Global Keys
|
||||
|
||||
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`.
|
||||
**Per-account** (also accepted globally as fallback): `default_publish_method`, `default_author`, `need_open_comment`, `only_fans_can_comment`, `app_id`, `app_secret`, `chrome_profile_path`, `remote_publish_host`, `remote_publish_user`, `remote_publish_port`, `remote_publish_identity_file`, `remote_publish_known_hosts_file`, `remote_publish_strict_host_key_checking`, `remote_publish_connect_timeout`, `remote_publish_proxy_jump`.
|
||||
|
||||
**Global-only** (always shared): `default_theme`, `default_color`.
|
||||
|
||||
@@ -99,3 +99,54 @@ ${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-too
|
||||
${BUN_X} {baseDir}/scripts/wechat-article.ts --markdown <file> --theme default --account baoyu
|
||||
${BUN_X} {baseDir}/scripts/wechat-browser.ts --markdown <file> --images ./photos/ --account baoyu
|
||||
```
|
||||
|
||||
## Remote API Publishing
|
||||
|
||||
`wechat-api.ts` supports a `remote-api` mode that tunnels WeChat API calls through an SSH SOCKS5 dynamic port forward to a server whose IP is on WeChat's allowlist. Markdown rendering, image processing, draft assembly, and HTML rewriting still happen locally; only outbound HTTPS calls to `api.weixin.qq.com` traverse the tunnel. No files are written to the remote host and `AppSecret` never leaves the local process. The remote host needs only `sshd` and outbound network access.
|
||||
|
||||
### Per-Account Configuration
|
||||
|
||||
```md
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
default_publish_method: browser # browser remains the default
|
||||
|
||||
accounts:
|
||||
- name: 宝玉的技术分享
|
||||
alias: baoyu
|
||||
default: true
|
||||
default_publish_method: api
|
||||
default_author: 宝玉
|
||||
app_id: your_wechat_app_id
|
||||
app_secret: your_wechat_app_secret
|
||||
- name: AI工具集
|
||||
alias: ai-tools
|
||||
default_publish_method: remote-api
|
||||
default_author: AI工具集
|
||||
app_id: your_ai_tools_app_id
|
||||
app_secret: your_ai_tools_app_secret
|
||||
remote_publish_host: ai-tools-server.example.com
|
||||
remote_publish_user: deploy
|
||||
remote_publish_port: 22
|
||||
remote_publish_identity_file: /home/me/.ssh/id_ed25519
|
||||
remote_publish_known_hosts_file: /home/me/.ssh/known_hosts
|
||||
remote_publish_strict_host_key_checking: accept-new
|
||||
```
|
||||
|
||||
Account-level `remote_publish_*` values override top-level globals. CLI `--remote-*` flags override both.
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Use the account's default_publish_method (remote-api here):
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account ai-tools
|
||||
|
||||
# Force remote mode regardless of default_publish_method:
|
||||
${BUN_X} {baseDir}/scripts/wechat-api.ts <file> --theme default --account baoyu --remote --remote-host other-server.example.com
|
||||
```
|
||||
|
||||
### Security Notes
|
||||
|
||||
- Authentication is SSH key only. Passwords and `ssh-askpass` are not used.
|
||||
- Only the typed `remote_publish_*` keys are read; raw `ssh` / `scp` options are intentionally not supported.
|
||||
- The tunnel forwards raw TCP; TLS verification for `api.weixin.qq.com` is still performed end-to-end by the local process.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0",
|
||||
"socks": "^2.8.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -165,6 +166,8 @@
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"jimp": ["jimp@1.6.1", "", { "dependencies": { "@jimp/core": "1.6.1", "@jimp/diff": "1.6.1", "@jimp/js-bmp": "1.6.1", "@jimp/js-gif": "1.6.1", "@jimp/js-jpeg": "1.6.1", "@jimp/js-png": "1.6.1", "@jimp/js-tiff": "1.6.1", "@jimp/plugin-blit": "1.6.1", "@jimp/plugin-blur": "1.6.1", "@jimp/plugin-circle": "1.6.1", "@jimp/plugin-color": "1.6.1", "@jimp/plugin-contain": "1.6.1", "@jimp/plugin-cover": "1.6.1", "@jimp/plugin-crop": "1.6.1", "@jimp/plugin-displace": "1.6.1", "@jimp/plugin-dither": "1.6.1", "@jimp/plugin-fisheye": "1.6.1", "@jimp/plugin-flip": "1.6.1", "@jimp/plugin-hash": "1.6.1", "@jimp/plugin-mask": "1.6.1", "@jimp/plugin-print": "1.6.1", "@jimp/plugin-quantize": "1.6.1", "@jimp/plugin-resize": "1.6.1", "@jimp/plugin-rotate": "1.6.1", "@jimp/plugin-threshold": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1" } }, "sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw=="],
|
||||
@@ -277,6 +280,10 @@
|
||||
|
||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
|
||||
"socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"@jsquash/webp": "^1.5.0",
|
||||
"baoyu-chrome-cdp": "^0.1.0",
|
||||
"baoyu-md": "^0.1.0",
|
||||
"jimp": "^1.6.0"
|
||||
"jimp": "^1.6.0",
|
||||
"socks": "^2.8.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,25 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
||||
import {
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
loadCredentials,
|
||||
type ResolvedAccount,
|
||||
type StrictHostKeyChecking,
|
||||
} from "./wechat-extend-config.ts";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
prepareWechatBodyImageUpload,
|
||||
needsWechatBodyImageProcessing,
|
||||
detectImageFormatFromBuffer,
|
||||
} from "./wechat-image-processor.ts";
|
||||
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||
import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";
|
||||
import {
|
||||
type RemotePublishConfig,
|
||||
normalizeRemoteConfig,
|
||||
withSshTunnel,
|
||||
} from "./wechat-remote-publish.ts";
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string;
|
||||
@@ -62,13 +74,17 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||
async function fetchAccessToken(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const res = await client(url);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as AccessTokenResponse;
|
||||
const data = await res.json<AccessTokenResponse>();
|
||||
if (data.errcode) {
|
||||
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -83,86 +99,12 @@ function toHttpsUrl(url: string | undefined): string {
|
||||
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||
}
|
||||
|
||||
async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${imagePath}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0];
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Image not found: ${resolvedPath}`);
|
||||
}
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
contentType = mimeTypes[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
// Detect actual format from magic bytes to fix extension/content-type mismatches
|
||||
// (e.g. CDNs serving WebP for URLs with .png extension)
|
||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||
if (detected && detected.contentType !== contentType) {
|
||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||
contentType = detected.contentType;
|
||||
fileExt = detected.fileExt;
|
||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
imagePath: string,
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body"
|
||||
uploadType: "body" | "material" = "body",
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
@@ -187,6 +129,7 @@ async function uploadImage(
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
client,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
@@ -201,39 +144,27 @@ async function uploadImage(
|
||||
}
|
||||
}
|
||||
|
||||
// 实际的微信上传函数
|
||||
async function uploadToWechat(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material"
|
||||
uploadType: "body" | "material",
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<UploadResponse> {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
||||
const header = [
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
|
||||
`Content-Type: ${contentType}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
|
||||
const headerBuffer = Buffer.from(header, "utf-8");
|
||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename, contentType, data: fileBuffer },
|
||||
]);
|
||||
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await fetch(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
});
|
||||
|
||||
const data = await res.json() as UploadResponse;
|
||||
const data = await res.json<UploadResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -248,6 +179,7 @@ async function uploadImagesInHtml(
|
||||
contentImages: ImageInfo[] = [],
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
@@ -268,7 +200,7 @@ async function uploadImagesInHtml(
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
@@ -284,8 +216,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
@@ -296,7 +227,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -320,8 +251,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
@@ -331,7 +261,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -351,7 +281,8 @@ async function uploadImagesInHtml(
|
||||
|
||||
async function publishToDraft(
|
||||
options: ArticleOptions,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
client: WechatClient = wechatHttp,
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
@@ -388,15 +319,13 @@ async function publishToDraft(
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await client(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
});
|
||||
|
||||
const data = await res.json() as PublishResponse;
|
||||
const data = await res.json<PublishResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -494,6 +423,15 @@ Options:
|
||||
--account <alias> Select account by alias (for multi-account setups)
|
||||
--no-cite Disable bottom citations for ordinary external links in markdown mode
|
||||
--dry-run Parse and render only, don't publish
|
||||
--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server
|
||||
--remote-host <h> Remote server host (implies --remote)
|
||||
--remote-user <u> SSH user (default: root, implies --remote)
|
||||
--remote-port <n> SSH port (default: 22, implies --remote)
|
||||
--remote-identity-file <p> SSH private key path (implies --remote)
|
||||
--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)
|
||||
--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)
|
||||
--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)
|
||||
--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)
|
||||
--help Show this help
|
||||
|
||||
Frontmatter Fields (markdown):
|
||||
@@ -539,6 +477,15 @@ interface CliArgs {
|
||||
account?: string;
|
||||
citeStatus: boolean;
|
||||
dryRun: boolean;
|
||||
remote: boolean;
|
||||
remoteHost?: string;
|
||||
remoteUser?: string;
|
||||
remotePort?: number;
|
||||
remoteIdentityFile?: string;
|
||||
remoteKnownHostsFile?: string;
|
||||
remoteStrictHostKeyChecking?: StrictHostKeyChecking;
|
||||
remoteConnectTimeout?: number;
|
||||
remoteProxyJump?: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
@@ -553,6 +500,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
theme: "default",
|
||||
citeStatus: true,
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -582,6 +530,47 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
args.citeStatus = false;
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg === "--remote") {
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-host" && argv[i + 1]) {
|
||||
args.remoteHost = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-user" && argv[i + 1]) {
|
||||
args.remoteUser = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-port" && argv[i + 1]) {
|
||||
const n = Number.parseInt(argv[++i]!, 10);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remotePort = n;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-identity-file" && argv[i + 1]) {
|
||||
args.remoteIdentityFile = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {
|
||||
args.remoteKnownHostsFile = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {
|
||||
const v = argv[++i]!.toLowerCase();
|
||||
if (v !== "yes" && v !== "no" && v !== "accept-new") {
|
||||
console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {
|
||||
const n = Number.parseInt(argv[++i]!, 10);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
args.remoteConnectTimeout = n;
|
||||
args.remote = true;
|
||||
} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {
|
||||
args.remoteProxyJump = argv[++i];
|
||||
args.remote = true;
|
||||
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
|
||||
i++;
|
||||
} else if (!arg.startsWith("-")) {
|
||||
@@ -607,6 +596,27 @@ function extractHtmlTitle(html: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {
|
||||
const host = args.remoteHost ?? resolved.remote_publish_host;
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +
|
||||
"or an account-level remote_publish_host.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
user: args.remoteUser ?? resolved.remote_publish_user,
|
||||
port: args.remotePort ?? resolved.remote_publish_port,
|
||||
identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,
|
||||
knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,
|
||||
strictHostKeyChecking:
|
||||
args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,
|
||||
connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,
|
||||
proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -709,8 +719,6 @@ async function main(): Promise<void> {
|
||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||
}
|
||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
@@ -722,62 +730,80 @@ async function main(): Promise<void> {
|
||||
: rawCoverPath;
|
||||
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||
const method = useRemote ? "remote-api" : "api";
|
||||
|
||||
let thumbMediaId = "";
|
||||
const publishWith = async (client: WechatClient): Promise<void> => {
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
// 封面图片使用 material/add_material 接口
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
client,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
throw new Error("newspic requires at least one image in content.");
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken, client);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
method,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
};
|
||||
|
||||
if (useRemote) {
|
||||
const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));
|
||||
console.error(
|
||||
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
|
||||
);
|
||||
await withSshTunnel(remoteConfig, async (client) => {
|
||||
await publishWith(client);
|
||||
});
|
||||
} else {
|
||||
await publishWith(wechatHttp);
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
console.error("Error: newspic requires at least one image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { loadCredentials } from "./wechat-extend-config.ts";
|
||||
import {
|
||||
loadCredentials,
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
} from "./wechat-extend-config.ts";
|
||||
|
||||
function useCwd(t: TestContext, cwd: string): void {
|
||||
const previous = process.cwd();
|
||||
@@ -73,6 +77,28 @@ async function writeEnvFile(root: string, content: string): Promise<void> {
|
||||
await fs.writeFile(envPath, content);
|
||||
}
|
||||
|
||||
async function writeExtendFile(root: string, content: string): Promise<void> {
|
||||
const extendPath = path.join(root, ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md");
|
||||
await fs.mkdir(path.dirname(extendPath), { recursive: true });
|
||||
await fs.writeFile(extendPath, content);
|
||||
}
|
||||
|
||||
function useXdgConfigHome(t: TestContext, value: string | undefined): void {
|
||||
const previous = process.env.XDG_CONFIG_HOME;
|
||||
if (value === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = value;
|
||||
}
|
||||
t.after(() => {
|
||||
if (previous === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
return;
|
||||
}
|
||||
process.env.XDG_CONFIG_HOME = previous;
|
||||
});
|
||||
}
|
||||
|
||||
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
@@ -119,6 +145,149 @@ test("loadCredentials prefers a complete process.env pair over lower-priority fi
|
||||
assert.deepEqual(credentials.skippedSources, []);
|
||||
});
|
||||
|
||||
test("resolveAccount returns global remote_publish_* values when no account is configured", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"default_publish_method: remote-api",
|
||||
"remote_publish_host: bastion.example.com",
|
||||
"remote_publish_user: deploy",
|
||||
"remote_publish_port: 2222",
|
||||
"remote_publish_identity_file: /home/me/.ssh/id_ed25519",
|
||||
"remote_publish_known_hosts_file: /home/me/.ssh/known_hosts",
|
||||
"remote_publish_strict_host_key_checking: accept-new",
|
||||
"remote_publish_connect_timeout: 12",
|
||||
"remote_publish_proxy_jump: jump.example.com",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const config = loadWechatExtendConfig();
|
||||
const resolved = resolveAccount(config);
|
||||
|
||||
assert.equal(resolved.default_publish_method, "remote-api");
|
||||
assert.equal(resolved.remote_publish_host, "bastion.example.com");
|
||||
assert.equal(resolved.remote_publish_user, "deploy");
|
||||
assert.equal(resolved.remote_publish_port, 2222);
|
||||
assert.equal(resolved.remote_publish_identity_file, "/home/me/.ssh/id_ed25519");
|
||||
assert.equal(resolved.remote_publish_known_hosts_file, "/home/me/.ssh/known_hosts");
|
||||
assert.equal(resolved.remote_publish_strict_host_key_checking, "accept-new");
|
||||
assert.equal(resolved.remote_publish_connect_timeout, 12);
|
||||
assert.equal(resolved.remote_publish_proxy_jump, "jump.example.com");
|
||||
});
|
||||
|
||||
test("resolveAccount lets account-level remote_publish_* override globals", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"default_publish_method: browser",
|
||||
"remote_publish_host: global.example.com",
|
||||
"remote_publish_user: deploy",
|
||||
"remote_publish_port: 22",
|
||||
"accounts:",
|
||||
" - name: Primary",
|
||||
" alias: primary",
|
||||
" default: true",
|
||||
" remote_publish_host: primary.example.com",
|
||||
" remote_publish_user: primary-user",
|
||||
" remote_publish_port: 2200",
|
||||
" remote_publish_identity_file: /p/id_primary",
|
||||
" - name: Secondary",
|
||||
" alias: secondary",
|
||||
" remote_publish_proxy_jump: jump.example.com",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const config = loadWechatExtendConfig();
|
||||
const primary = resolveAccount(config, "primary");
|
||||
assert.equal(primary.alias, "primary");
|
||||
assert.equal(primary.remote_publish_host, "primary.example.com");
|
||||
assert.equal(primary.remote_publish_user, "primary-user");
|
||||
assert.equal(primary.remote_publish_port, 2200);
|
||||
assert.equal(primary.remote_publish_identity_file, "/p/id_primary");
|
||||
assert.equal(primary.remote_publish_proxy_jump, undefined);
|
||||
|
||||
const secondary = resolveAccount(config, "secondary");
|
||||
assert.equal(secondary.alias, "secondary");
|
||||
assert.equal(secondary.remote_publish_host, "global.example.com");
|
||||
assert.equal(secondary.remote_publish_user, "deploy");
|
||||
assert.equal(secondary.remote_publish_port, 22);
|
||||
assert.equal(secondary.remote_publish_proxy_jump, "jump.example.com");
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_port", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_port: 99999",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_port: 99999/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_connect_timeout", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_connect_timeout: 0",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(() => loadWechatExtendConfig(), /Invalid remote_publish_connect_timeout: 0/);
|
||||
});
|
||||
|
||||
test("loadWechatExtendConfig throws on invalid remote_publish_strict_host_key_checking", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-extend-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-extend-home-");
|
||||
|
||||
useCwd(t, cwdRoot);
|
||||
useHome(t, homeRoot);
|
||||
useXdgConfigHome(t, undefined);
|
||||
|
||||
await writeExtendFile(
|
||||
cwdRoot,
|
||||
[
|
||||
"remote_publish_host: example.com",
|
||||
"remote_publish_strict_host_key_checking: maybe",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => loadWechatExtendConfig(),
|
||||
/Invalid remote_publish_strict_host_key_checking: maybe/,
|
||||
);
|
||||
});
|
||||
|
||||
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
export type StrictHostKeyChecking = "yes" | "no" | "accept-new";
|
||||
|
||||
export interface WechatAccount {
|
||||
name: string;
|
||||
alias: string;
|
||||
@@ -13,6 +15,14 @@ export interface WechatAccount {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
}
|
||||
|
||||
export interface WechatExtendConfig {
|
||||
@@ -23,6 +33,14 @@ export interface WechatExtendConfig {
|
||||
need_open_comment?: number;
|
||||
only_fans_can_comment?: number;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
accounts?: WechatAccount[];
|
||||
}
|
||||
|
||||
@@ -36,6 +54,14 @@ export interface ResolvedAccount {
|
||||
app_id?: string;
|
||||
app_secret?: string;
|
||||
chrome_profile_path?: string;
|
||||
remote_publish_host?: string;
|
||||
remote_publish_user?: string;
|
||||
remote_publish_port?: number;
|
||||
remote_publish_identity_file?: string;
|
||||
remote_publish_known_hosts_file?: string;
|
||||
remote_publish_strict_host_key_checking?: StrictHostKeyChecking;
|
||||
remote_publish_connect_timeout?: number;
|
||||
remote_publish_proxy_jump?: string;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
@@ -46,6 +72,34 @@ function toBool01(v: string): number {
|
||||
return v === "1" || v === "true" ? 1 : 0;
|
||||
}
|
||||
|
||||
function homeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || os.homedir();
|
||||
}
|
||||
|
||||
function parsePort(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n < 1 || n > 65535) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected integer 1-65535)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parsePositiveInt(key: string, v: string): number {
|
||||
const n = Number.parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || String(n) !== v.trim() || n <= 0) {
|
||||
throw new Error(`Invalid ${key}: ${v} (expected positive integer)`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseStrictHostKeyChecking(key: string, v: string): StrictHostKeyChecking {
|
||||
const lower = v.toLowerCase();
|
||||
if (lower === "yes" || lower === "no" || lower === "accept-new") {
|
||||
return lower;
|
||||
}
|
||||
throw new Error(`Invalid ${key}: ${v} (expected yes|no|accept-new)`);
|
||||
}
|
||||
|
||||
function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
const config: WechatExtendConfig = {};
|
||||
const lines = content.split("\n");
|
||||
@@ -106,6 +160,14 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
case "need_open_comment": config.need_open_comment = toBool01(val); break;
|
||||
case "only_fans_can_comment": config.only_fans_can_comment = toBool01(val); break;
|
||||
case "chrome_profile_path": config.chrome_profile_path = val; break;
|
||||
case "remote_publish_host": config.remote_publish_host = val; break;
|
||||
case "remote_publish_user": config.remote_publish_user = val; break;
|
||||
case "remote_publish_port": config.remote_publish_port = parsePort("remote_publish_port", val); break;
|
||||
case "remote_publish_identity_file": config.remote_publish_identity_file = val; break;
|
||||
case "remote_publish_known_hosts_file": config.remote_publish_known_hosts_file = val; break;
|
||||
case "remote_publish_strict_host_key_checking": config.remote_publish_strict_host_key_checking = parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", val); break;
|
||||
case "remote_publish_connect_timeout": config.remote_publish_connect_timeout = parsePositiveInt("remote_publish_connect_timeout", val); break;
|
||||
case "remote_publish_proxy_jump": config.remote_publish_proxy_jump = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +185,18 @@ function parseWechatExtend(content: string): WechatExtendConfig {
|
||||
app_id: a.app_id || undefined,
|
||||
app_secret: a.app_secret || undefined,
|
||||
chrome_profile_path: a.chrome_profile_path || undefined,
|
||||
remote_publish_host: a.remote_publish_host || undefined,
|
||||
remote_publish_user: a.remote_publish_user || undefined,
|
||||
remote_publish_port: a.remote_publish_port ? parsePort("remote_publish_port", a.remote_publish_port) : undefined,
|
||||
remote_publish_identity_file: a.remote_publish_identity_file || undefined,
|
||||
remote_publish_known_hosts_file: a.remote_publish_known_hosts_file || undefined,
|
||||
remote_publish_strict_host_key_checking: a.remote_publish_strict_host_key_checking
|
||||
? parseStrictHostKeyChecking("remote_publish_strict_host_key_checking", a.remote_publish_strict_host_key_checking)
|
||||
: undefined,
|
||||
remote_publish_connect_timeout: a.remote_publish_connect_timeout
|
||||
? parsePositiveInt("remote_publish_connect_timeout", a.remote_publish_connect_timeout)
|
||||
: undefined,
|
||||
remote_publish_proxy_jump: a.remote_publish_proxy_jump || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -133,18 +207,19 @@ export function loadWechatExtendConfig(): WechatExtendConfig {
|
||||
const paths = [
|
||||
path.join(process.cwd(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(
|
||||
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
|
||||
process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"),
|
||||
"baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"
|
||||
),
|
||||
path.join(os.homedir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
path.join(homeDir(), ".baoyu-skills", "baoyu-post-to-wechat", "EXTEND.md"),
|
||||
];
|
||||
for (const p of paths) {
|
||||
let content: string;
|
||||
try {
|
||||
const content = fs.readFileSync(p, "utf-8");
|
||||
return parseWechatExtend(content);
|
||||
content = fs.readFileSync(p, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
return parseWechatExtend(content);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -168,6 +243,15 @@ export function resolveAccount(config: WechatExtendConfig, alias?: string): Reso
|
||||
app_id: acct?.app_id,
|
||||
app_secret: acct?.app_secret,
|
||||
chrome_profile_path: acct?.chrome_profile_path ?? config.chrome_profile_path,
|
||||
remote_publish_host: acct?.remote_publish_host ?? config.remote_publish_host,
|
||||
remote_publish_user: acct?.remote_publish_user ?? config.remote_publish_user,
|
||||
remote_publish_port: acct?.remote_publish_port ?? config.remote_publish_port,
|
||||
remote_publish_identity_file: acct?.remote_publish_identity_file ?? config.remote_publish_identity_file,
|
||||
remote_publish_known_hosts_file: acct?.remote_publish_known_hosts_file ?? config.remote_publish_known_hosts_file,
|
||||
remote_publish_strict_host_key_checking:
|
||||
acct?.remote_publish_strict_host_key_checking ?? config.remote_publish_strict_host_key_checking,
|
||||
remote_publish_connect_timeout: acct?.remote_publish_connect_timeout ?? config.remote_publish_connect_timeout,
|
||||
remote_publish_proxy_jump: acct?.remote_publish_proxy_jump ?? config.remote_publish_proxy_jump,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -273,7 +357,7 @@ function resolveCredentialSource(
|
||||
|
||||
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
||||
const homeEnvPath = path.join(homeDir(), ".baoyu-skills", ".env");
|
||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||
const homeEnv = loadEnvFile(homeEnvPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { buildMultipart, wechatHttp } from "./wechat-http.ts";
|
||||
|
||||
interface ReceivedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<{ baseUrl: string; received: ReceivedRequest[] }> {
|
||||
const received: ReceivedRequest[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, echo: { url: req.url, method: req.method } }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Failed to start echo server");
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
return { baseUrl, received };
|
||||
}
|
||||
|
||||
test("wechatHttp performs a GET and passes through query string", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/token?grant_type=client_credential&appid=AID`);
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; echo: { url: string; method: string } }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "GET");
|
||||
assert.equal(received[0]!.url, "/cgi-bin/token?grant_type=client_credential&appid=AID");
|
||||
assert.equal(received[0]!.body.length, 0);
|
||||
});
|
||||
|
||||
test("wechatHttp POST sends JSON body with content-length header", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.equal(received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("wechatHttp .text() returns the raw response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/hello`);
|
||||
const text = await res.text();
|
||||
assert.match(text, /"ok":true/);
|
||||
});
|
||||
|
||||
test("wechatHttp .buffer() returns a Buffer of the response body", async (t) => {
|
||||
const { baseUrl } = await startEchoServer(t);
|
||||
const res = await wechatHttp(`${baseUrl}/`);
|
||||
const buf = await res.buffer();
|
||||
assert.ok(Buffer.isBuffer(buf));
|
||||
assert.ok(buf.length > 0);
|
||||
});
|
||||
|
||||
test("buildMultipart produces a parsable multipart payload", () => {
|
||||
const fileData = Buffer.from("ZZZ");
|
||||
const { contentType, body } = buildMultipart([
|
||||
{
|
||||
name: "media",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
data: fileData,
|
||||
},
|
||||
]);
|
||||
|
||||
const boundaryMatch = contentType.match(/^multipart\/form-data; boundary=(.+)$/);
|
||||
assert.ok(boundaryMatch, `expected boundary in Content-Type, got ${contentType}`);
|
||||
const boundary = boundaryMatch![1]!;
|
||||
const text = body.toString("binary");
|
||||
assert.ok(text.startsWith(`--${boundary}\r\n`), "body must start with opening boundary");
|
||||
assert.ok(
|
||||
text.endsWith(`--${boundary}--\r\n`),
|
||||
"body must end with closing boundary",
|
||||
);
|
||||
assert.match(
|
||||
text,
|
||||
/Content-Disposition: form-data; name="media"; filename="image\.png"\r\n/,
|
||||
);
|
||||
assert.match(text, /Content-Type: image\/png\r\n/);
|
||||
// The raw file bytes must appear verbatim after a blank line.
|
||||
assert.ok(
|
||||
text.includes("\r\n\r\nZZZ\r\n"),
|
||||
"body must contain the raw file bytes after the part headers",
|
||||
);
|
||||
});
|
||||
|
||||
test("wechatHttp accepts a multipart body produced by buildMultipart", async (t) => {
|
||||
const { baseUrl, received } = await startEchoServer(t);
|
||||
const fileData = Buffer.from("HELLO");
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename: "x.png", contentType: "image/png", data: fileData },
|
||||
]);
|
||||
const res = await wechatHttp(`${baseUrl}/cgi-bin/media/uploadimg?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(received.length, 1);
|
||||
assert.equal(received[0]!.method, "POST");
|
||||
assert.match(received[0]!.headers["content-type"]!, /^multipart\/form-data; boundary=/);
|
||||
assert.ok(received[0]!.body.includes(Buffer.from("HELLO")));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface WechatHttpInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | Buffer;
|
||||
}
|
||||
|
||||
export interface WechatHttpResponse {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
buffer(): Promise<Buffer>;
|
||||
text(): Promise<string>;
|
||||
json<T = unknown>(): Promise<T>;
|
||||
}
|
||||
|
||||
export type WechatClient = (
|
||||
url: string,
|
||||
init?: WechatHttpInit,
|
||||
) => Promise<WechatHttpResponse>;
|
||||
|
||||
export interface MultipartFilePart {
|
||||
name: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
export interface MultipartBody {
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
export function buildMultipart(parts: MultipartFilePart[]): MultipartBody {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const header =
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"\r\n` +
|
||||
`Content-Type: ${part.contentType}\r\n\r\n`;
|
||||
chunks.push(Buffer.from(header, "utf-8"));
|
||||
chunks.push(part.data);
|
||||
chunks.push(Buffer.from("\r\n", "utf-8"));
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--\r\n`, "utf-8"));
|
||||
|
||||
return {
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
body: Buffer.concat(chunks),
|
||||
};
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
|
||||
const out: Record<string, string | string[] | undefined> = {};
|
||||
headers.forEach((value, key) => {
|
||||
const existing = out[key];
|
||||
if (existing === undefined) {
|
||||
out[key] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
out[key] = [existing, value];
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export const wechatHttp: WechatClient = async (url, init = {}) => {
|
||||
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (init.body !== undefined) {
|
||||
body = Buffer.isBuffer(init.body)
|
||||
? new Uint8Array(init.body.buffer, init.body.byteOffset, init.body.byteLength)
|
||||
: init.body;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method, headers, body });
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: headersToRecord(res.headers),
|
||||
async buffer() {
|
||||
return buf;
|
||||
},
|
||||
async text() {
|
||||
return buf.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(buf.toString("utf-8")) as T;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
detectImageFormatFromBuffer,
|
||||
} from "./wechat-image-processor.ts";
|
||||
|
||||
export type { WechatUploadAsset };
|
||||
|
||||
const MIME_TYPES_BY_EXT: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
export async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${imagePath}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0]!;
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Image not found: ${resolvedPath}`);
|
||||
}
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = MIME_TYPES_BY_EXT[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||
if (detected && detected.contentType !== contentType) {
|
||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||
contentType = detected.contentType;
|
||||
fileExt = detected.fileExt;
|
||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildSshArgs,
|
||||
findFreePort,
|
||||
normalizeRemoteConfig,
|
||||
} from "./wechat-remote-publish.ts";
|
||||
|
||||
test("normalizeRemoteConfig requires a host", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "" }),
|
||||
/Remote publish host is required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: " " }),
|
||||
/Remote publish host is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig applies user/port defaults and trims host", () => {
|
||||
const result = normalizeRemoteConfig({ host: " example.com " });
|
||||
assert.equal(result.host, "example.com");
|
||||
assert.equal(result.user, "root");
|
||||
assert.equal(result.port, 22);
|
||||
assert.equal(result.identityFile, undefined);
|
||||
assert.equal(result.knownHostsFile, undefined);
|
||||
assert.equal(result.strictHostKeyChecking, undefined);
|
||||
assert.equal(result.connectTimeout, undefined);
|
||||
assert.equal(result.proxyJump, undefined);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig preserves explicit user, port, and SSH options", () => {
|
||||
const result = normalizeRemoteConfig({
|
||||
host: "example.com",
|
||||
user: "deploy",
|
||||
port: 2222,
|
||||
identityFile: "/home/me/.ssh/id_ed25519",
|
||||
knownHostsFile: "/home/me/.ssh/known_hosts",
|
||||
strictHostKeyChecking: "accept-new",
|
||||
connectTimeout: 15,
|
||||
proxyJump: "bastion.example.com",
|
||||
});
|
||||
assert.equal(result.user, "deploy");
|
||||
assert.equal(result.port, 2222);
|
||||
assert.equal(result.identityFile, "/home/me/.ssh/id_ed25519");
|
||||
assert.equal(result.knownHostsFile, "/home/me/.ssh/known_hosts");
|
||||
assert.equal(result.strictHostKeyChecking, "accept-new");
|
||||
assert.equal(result.connectTimeout, 15);
|
||||
assert.equal(result.proxyJump, "bastion.example.com");
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid port", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 0 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 65536 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", port: 1.5 }),
|
||||
/Invalid remote publish port/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid connect timeout", () => {
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: 0 }),
|
||||
/Invalid remote_publish_connect_timeout/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeRemoteConfig({ host: "example.com", connectTimeout: -3 }),
|
||||
/Invalid remote_publish_connect_timeout/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig rejects invalid strict host key checking", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeRemoteConfig({
|
||||
host: "example.com",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
strictHostKeyChecking: "maybe" as any,
|
||||
}),
|
||||
/Invalid remote_publish_strict_host_key_checking/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeRemoteConfig falls back to default user when blank string provided", () => {
|
||||
const result = normalizeRemoteConfig({ host: "example.com", user: " " });
|
||||
assert.equal(result.user, "root");
|
||||
});
|
||||
|
||||
test("buildSshArgs emits the whitelisted minimum set", () => {
|
||||
const args = buildSshArgs(
|
||||
{ host: "example.com", user: "root", port: 22 },
|
||||
1080,
|
||||
);
|
||||
assert.deepEqual(args, [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", "127.0.0.1:1080",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", "22",
|
||||
"root@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshArgs threads optional ssh options in stable order", () => {
|
||||
const args = buildSshArgs(
|
||||
{
|
||||
host: "example.com",
|
||||
user: "deploy",
|
||||
port: 2222,
|
||||
identityFile: "/p/id_ed25519",
|
||||
knownHostsFile: "/p/known_hosts",
|
||||
strictHostKeyChecking: "accept-new",
|
||||
connectTimeout: 12,
|
||||
proxyJump: "bastion.example.com",
|
||||
},
|
||||
1080,
|
||||
);
|
||||
assert.deepEqual(args, [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", "127.0.0.1:1080",
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", "2222",
|
||||
"-i", "/p/id_ed25519",
|
||||
"-o", "UserKnownHostsFile=/p/known_hosts",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=12",
|
||||
"-J", "bastion.example.com",
|
||||
"deploy@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildSshArgs does not emit raw ssh options for unknown fields", () => {
|
||||
const args = buildSshArgs(
|
||||
{
|
||||
host: "example.com",
|
||||
user: "root",
|
||||
port: 22,
|
||||
// No extra unknown keys are accepted — typed config is the whitelist.
|
||||
},
|
||||
1080,
|
||||
);
|
||||
assert.equal(
|
||||
args.filter((a) => a === "-o" || a.startsWith("--")).length,
|
||||
3, // ExitOnForwardFailure, ServerAliveInterval, ServerAliveCountMax (and only those)
|
||||
"buildSshArgs must only emit the three baseline -o options when no extras given",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSshArgs rejects invalid SOCKS port", () => {
|
||||
assert.throws(
|
||||
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 0),
|
||||
/Invalid SOCKS port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildSshArgs({ host: "example.com", user: "root", port: 22 }, 70_000),
|
||||
/Invalid SOCKS port/,
|
||||
);
|
||||
});
|
||||
|
||||
test("findFreePort returns a usable loopback port", async () => {
|
||||
const port = await findFreePort();
|
||||
assert.ok(port > 0 && port < 65536, `expected valid port, got ${port}`);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import net from "node:net";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { StrictHostKeyChecking } from "./wechat-extend-config.ts";
|
||||
import type { WechatClient } from "./wechat-http.ts";
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
export interface RemotePublishConfig {
|
||||
host: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
knownHostsFile?: string;
|
||||
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||
connectTimeout?: number;
|
||||
proxyJump?: string;
|
||||
}
|
||||
|
||||
export interface NormalizedRemotePublishConfig {
|
||||
host: string;
|
||||
user: string;
|
||||
port: number;
|
||||
identityFile?: string;
|
||||
knownHostsFile?: string;
|
||||
strictHostKeyChecking?: StrictHostKeyChecking;
|
||||
connectTimeout?: number;
|
||||
proxyJump?: string;
|
||||
}
|
||||
|
||||
export interface SshTunnel {
|
||||
port: number;
|
||||
client: WechatClient;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartSshTunnelOptions {
|
||||
readyTimeoutMs?: number;
|
||||
killTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_USER = "root";
|
||||
const DEFAULT_PORT = 22;
|
||||
const DEFAULT_READY_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_KILL_TIMEOUT_MS = 3_000;
|
||||
const SSH_LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
export function normalizeRemoteConfig(config: RemotePublishConfig): NormalizedRemotePublishConfig {
|
||||
if (!config.host || !config.host.trim()) {
|
||||
throw new Error("Remote publish host is required (set remote_publish_host or --remote-host).");
|
||||
}
|
||||
|
||||
const port = config.port ?? DEFAULT_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid remote publish port: ${config.port}`);
|
||||
}
|
||||
|
||||
if (config.connectTimeout !== undefined) {
|
||||
if (!Number.isInteger(config.connectTimeout) || config.connectTimeout <= 0) {
|
||||
throw new Error(`Invalid remote_publish_connect_timeout: ${config.connectTimeout}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
config.strictHostKeyChecking !== undefined &&
|
||||
config.strictHostKeyChecking !== "yes" &&
|
||||
config.strictHostKeyChecking !== "no" &&
|
||||
config.strictHostKeyChecking !== "accept-new"
|
||||
) {
|
||||
throw new Error(`Invalid remote_publish_strict_host_key_checking: ${config.strictHostKeyChecking}`);
|
||||
}
|
||||
|
||||
return {
|
||||
host: config.host.trim(),
|
||||
user: (config.user ?? DEFAULT_USER).trim() || DEFAULT_USER,
|
||||
port,
|
||||
identityFile: config.identityFile,
|
||||
knownHostsFile: config.knownHostsFile,
|
||||
strictHostKeyChecking: config.strictHostKeyChecking,
|
||||
connectTimeout: config.connectTimeout,
|
||||
proxyJump: config.proxyJump,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSshArgs(config: NormalizedRemotePublishConfig, socksPort: number): string[] {
|
||||
if (!Number.isInteger(socksPort) || socksPort < 1 || socksPort > 65535) {
|
||||
throw new Error(`Invalid SOCKS port: ${socksPort}`);
|
||||
}
|
||||
|
||||
const args: string[] = [
|
||||
"-N",
|
||||
"-T",
|
||||
"-D", `${SSH_LOOPBACK_HOST}:${socksPort}`,
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=30",
|
||||
"-o", "ServerAliveCountMax=3",
|
||||
"-p", String(config.port),
|
||||
];
|
||||
|
||||
if (config.identityFile) {
|
||||
args.push("-i", config.identityFile);
|
||||
}
|
||||
if (config.knownHostsFile) {
|
||||
args.push("-o", `UserKnownHostsFile=${config.knownHostsFile}`);
|
||||
}
|
||||
if (config.strictHostKeyChecking) {
|
||||
args.push("-o", `StrictHostKeyChecking=${config.strictHostKeyChecking}`);
|
||||
}
|
||||
if (config.connectTimeout !== undefined) {
|
||||
args.push("-o", `ConnectTimeout=${config.connectTimeout}`);
|
||||
}
|
||||
if (config.proxyJump) {
|
||||
args.push("-J", config.proxyJump);
|
||||
}
|
||||
|
||||
args.push(`${config.user}@${config.host}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function findFreePort(): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, SSH_LOOPBACK_HOST, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire free port")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForSocksReady(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown = undefined;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await tryConnect(port);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
throw new Error(`SOCKS proxy on ${SSH_LOOPBACK_HOST}:${port} not ready within ${timeoutMs}ms${lastError ? `: ${(lastError as Error).message}` : ""}`);
|
||||
}
|
||||
|
||||
function tryConnect(port: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: SSH_LOOPBACK_HOST, port });
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function startSshTunnel(
|
||||
config: NormalizedRemotePublishConfig,
|
||||
options: StartSshTunnelOptions = {},
|
||||
): Promise<SshTunnel> {
|
||||
const readyTimeout = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
const killTimeout = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
||||
|
||||
const port = await findFreePort();
|
||||
const args = buildSshArgs(config, port);
|
||||
|
||||
console.error(`[wechat-remote-publish] Starting SSH SOCKS5 tunnel: ssh ${args.join(" ")}`);
|
||||
const child = spawn("ssh", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessByStdio<null, Readable, Readable>;
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk.toString("utf-8"));
|
||||
});
|
||||
|
||||
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
||||
child.once("exit", (code, signal) => {
|
||||
earlyExit = { code, signal };
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForSocksReady(port, readyTimeout);
|
||||
} catch (err) {
|
||||
await killChild(child, killTimeout);
|
||||
const stderrTail = stderrChunks.join("").trim().split("\n").slice(-5).join("\n");
|
||||
const suffix = stderrTail ? `\nssh stderr (tail):\n${stderrTail}` : "";
|
||||
const exitSuffix = earlyExit
|
||||
? `\nssh exited early with code=${earlyExit.code} signal=${earlyExit.signal}`
|
||||
: "";
|
||||
throw new Error(`${(err as Error).message}${exitSuffix}${suffix}`);
|
||||
}
|
||||
|
||||
const client = createSocksClient({ host: SSH_LOOPBACK_HOST, port });
|
||||
|
||||
const signalHandlers: Array<{ signal: NodeJS.Signals; handler: () => void }> = [];
|
||||
let closed = false;
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const { signal, handler } of signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
await killChild(child, killTimeout);
|
||||
};
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
const handler = () => {
|
||||
void close();
|
||||
};
|
||||
process.once(signal, handler);
|
||||
signalHandlers.push({ signal, handler });
|
||||
}
|
||||
|
||||
return { port, client, close };
|
||||
}
|
||||
|
||||
async function killChild(child: ChildProcessByStdio<null, Readable, Readable>, killTimeoutMs: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
child.once("exit", () => resolve());
|
||||
});
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}, killTimeoutMs);
|
||||
|
||||
try {
|
||||
await exited;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withSshTunnel<T>(
|
||||
config: NormalizedRemotePublishConfig,
|
||||
fn: (client: WechatClient) => Promise<T>,
|
||||
options?: StartSshTunnelOptions,
|
||||
): Promise<T> {
|
||||
const tunnel = await startSshTunnel(config, options);
|
||||
try {
|
||||
return await fn(tunnel.client);
|
||||
} finally {
|
||||
await tunnel.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import { createSocksClient } from "./wechat-socks-http.ts";
|
||||
|
||||
interface EchoServer {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
received: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: Buffer }>;
|
||||
}
|
||||
|
||||
async function startEchoServer(t: TestContext): Promise<EchoServer> {
|
||||
const received: EchoServer["received"] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
received.push({
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks),
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, url: req.url }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("echo server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, port, received };
|
||||
}
|
||||
|
||||
interface FakeSocks5 {
|
||||
port: number;
|
||||
connectionCount: () => number;
|
||||
destinations: () => Array<{ host: string; port: number }>;
|
||||
}
|
||||
|
||||
async function startFakeSocks5(t: TestContext): Promise<FakeSocks5> {
|
||||
let connectionCount = 0;
|
||||
const destinations: Array<{ host: string; port: number }> = [];
|
||||
|
||||
const server = net.createServer((client) => {
|
||||
connectionCount++;
|
||||
let phase: "greeting" | "request" | "tunnel" = "greeting";
|
||||
let buf = Buffer.alloc(0);
|
||||
let upstream: net.Socket | undefined;
|
||||
|
||||
const tryParse = () => {
|
||||
if (phase === "greeting") {
|
||||
if (buf.length < 2) return;
|
||||
const nMethods = buf[1]!;
|
||||
if (buf.length < 2 + nMethods) return;
|
||||
buf = buf.subarray(2 + nMethods);
|
||||
client.write(Buffer.from([0x05, 0x00]));
|
||||
phase = "request";
|
||||
}
|
||||
if (phase === "request") {
|
||||
if (buf.length < 5) return;
|
||||
if (buf[0] !== 0x05 || buf[1] !== 0x01) {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const atyp = buf[3];
|
||||
let addrEnd: number;
|
||||
let host: string;
|
||||
if (atyp === 0x01) {
|
||||
if (buf.length < 4 + 4 + 2) return;
|
||||
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
|
||||
addrEnd = 4 + 4;
|
||||
} else if (atyp === 0x03) {
|
||||
const dlen = buf[4]!;
|
||||
if (buf.length < 4 + 1 + dlen + 2) return;
|
||||
host = buf.subarray(5, 5 + dlen).toString("ascii");
|
||||
addrEnd = 4 + 1 + dlen;
|
||||
} else {
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
const port = (buf[addrEnd]! << 8) | buf[addrEnd + 1]!;
|
||||
destinations.push({ host, port });
|
||||
const totalLen = addrEnd + 2;
|
||||
const remaining = buf.subarray(totalLen);
|
||||
buf = Buffer.alloc(0);
|
||||
|
||||
upstream = net.connect({ host, port }, () => {
|
||||
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
phase = "tunnel";
|
||||
if (remaining.length > 0) {
|
||||
upstream!.write(remaining);
|
||||
}
|
||||
});
|
||||
upstream.on("data", (data: Buffer) => {
|
||||
client.write(data);
|
||||
});
|
||||
upstream.on("end", () => {
|
||||
try { client.end(); } catch { /* noop */ }
|
||||
});
|
||||
upstream.on("error", () => {
|
||||
try { client.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
client.on("data", (chunk: Buffer) => {
|
||||
if (phase === "tunnel") {
|
||||
upstream?.write(chunk);
|
||||
return;
|
||||
}
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
tryParse();
|
||||
});
|
||||
client.on("end", () => {
|
||||
try { upstream?.end(); } catch { /* noop */ }
|
||||
});
|
||||
client.on("error", () => {
|
||||
try { upstream?.destroy(); } catch { /* noop */ }
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("socks server bind failed");
|
||||
const port = address.port;
|
||||
t.after(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
return {
|
||||
port,
|
||||
connectionCount: () => connectionCount,
|
||||
destinations: () => destinations,
|
||||
};
|
||||
}
|
||||
|
||||
test("createSocksClient routes plain HTTP through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/token?appid=AID`);
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
const data = await res.json<{ ok: boolean; url: string }>();
|
||||
assert.equal(data.ok, true);
|
||||
assert.equal(data.url, "/cgi-bin/token?appid=AID");
|
||||
|
||||
assert.equal(
|
||||
socks.connectionCount(),
|
||||
1,
|
||||
"SOCKS5 proxy must have received exactly one connection (proves bytes were routed through it)",
|
||||
);
|
||||
|
||||
const dests = socks.destinations();
|
||||
assert.equal(dests.length, 1);
|
||||
assert.equal(dests[0]!.host, "127.0.0.1");
|
||||
assert.equal(dests[0]!.port, echo.port);
|
||||
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "GET");
|
||||
assert.equal(echo.received[0]!.url, "/cgi-bin/token?appid=AID");
|
||||
});
|
||||
|
||||
test("createSocksClient sends POST body through the SOCKS5 proxy", async (t) => {
|
||||
const echo = await startEchoServer(t);
|
||||
const socks = await startFakeSocks5(t);
|
||||
|
||||
const client = createSocksClient({ host: "127.0.0.1", port: socks.port });
|
||||
|
||||
const body = JSON.stringify({ articles: [{ title: "hi" }] });
|
||||
const res = await client(`${echo.baseUrl}/cgi-bin/draft/add?access_token=T`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(socks.connectionCount(), 1);
|
||||
assert.equal(echo.received.length, 1);
|
||||
assert.equal(echo.received[0]!.method, "POST");
|
||||
assert.equal(echo.received[0]!.headers["content-type"], "application/json");
|
||||
assert.equal(echo.received[0]!.headers["content-length"], String(Buffer.byteLength(body)));
|
||||
assert.equal(echo.received[0]!.body.toString("utf-8"), body);
|
||||
});
|
||||
|
||||
test("createSocksClient rejects invalid proxy ports", () => {
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 0 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "127.0.0.1", port: 70_000 }),
|
||||
/Invalid SOCKS proxy port/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createSocksClient({ host: "", port: 1080 }),
|
||||
/SOCKS proxy host required/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { URL } from "node:url";
|
||||
import { SocksClient } from "socks";
|
||||
|
||||
import type {
|
||||
WechatClient,
|
||||
WechatHttpInit,
|
||||
WechatHttpResponse,
|
||||
} from "./wechat-http.ts";
|
||||
|
||||
export interface SocksProxyEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function createSocksClient(proxy: SocksProxyEndpoint): WechatClient {
|
||||
if (!proxy.host) throw new Error("SOCKS proxy host required");
|
||||
if (!Number.isInteger(proxy.port) || proxy.port < 1 || proxy.port > 65535) {
|
||||
throw new Error(`Invalid SOCKS proxy port: ${proxy.port}`);
|
||||
}
|
||||
|
||||
return async (url, init = {}) => {
|
||||
return wechatHttpViaSocks(url, init, proxy);
|
||||
};
|
||||
}
|
||||
|
||||
async function wechatHttpViaSocks(
|
||||
rawUrl: string,
|
||||
init: WechatHttpInit,
|
||||
proxy: SocksProxyEndpoint,
|
||||
): Promise<WechatHttpResponse> {
|
||||
const url = new URL(rawUrl);
|
||||
const isHttps = url.protocol === "https:";
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error(`Unsupported protocol for SOCKS client: ${url.protocol}`);
|
||||
}
|
||||
const targetPort = url.port ? Number(url.port) : isHttps ? 443 : 80;
|
||||
|
||||
const { socket: tcpSocket } = await SocksClient.createConnection({
|
||||
proxy: { host: proxy.host, port: proxy.port, type: 5 },
|
||||
command: "connect",
|
||||
destination: { host: url.hostname, port: targetPort },
|
||||
});
|
||||
|
||||
let stream: net.Socket | tls.TLSSocket;
|
||||
if (isHttps) {
|
||||
const tlsSocket = tls.connect({ socket: tcpSocket, servername: url.hostname });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onSecure = () => {
|
||||
tlsSocket.removeListener("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
tlsSocket.removeListener("secureConnect", onSecure);
|
||||
try {
|
||||
tlsSocket.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
tcpSocket.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
reject(err);
|
||||
};
|
||||
tlsSocket.once("secureConnect", onSecure);
|
||||
tlsSocket.once("error", onError);
|
||||
});
|
||||
stream = tlsSocket;
|
||||
} else {
|
||||
stream = tcpSocket;
|
||||
}
|
||||
|
||||
try {
|
||||
return await sendRequestAndReadResponse(stream, url, init);
|
||||
} finally {
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRequestAndReadResponse(
|
||||
stream: net.Socket | tls.TLSSocket,
|
||||
url: URL,
|
||||
init: WechatHttpInit,
|
||||
): Promise<WechatHttpResponse> {
|
||||
const method = init.method ?? (init.body !== undefined ? "POST" : "GET");
|
||||
const body =
|
||||
init.body === undefined
|
||||
? undefined
|
||||
: Buffer.isBuffer(init.body)
|
||||
? init.body
|
||||
: Buffer.from(init.body, "utf-8");
|
||||
|
||||
const userHeaders = init.headers ?? {};
|
||||
const headerMap = new Map<string, string>();
|
||||
for (const [k, v] of Object.entries(userHeaders)) {
|
||||
headerMap.set(k.toLowerCase(), `${k}: ${v}`);
|
||||
}
|
||||
if (!headerMap.has("host")) headerMap.set("host", `Host: ${url.host}`);
|
||||
if (!headerMap.has("user-agent")) {
|
||||
headerMap.set("user-agent", "User-Agent: baoyu-skills-wechat-api");
|
||||
}
|
||||
headerMap.set("connection", "Connection: close");
|
||||
if (body && !headerMap.has("content-length")) {
|
||||
headerMap.set("content-length", `Content-Length: ${body.length}`);
|
||||
}
|
||||
|
||||
const path = `${url.pathname || "/"}${url.search}`;
|
||||
const requestHeader = Buffer.from(
|
||||
`${method} ${path} HTTP/1.1\r\n` +
|
||||
Array.from(headerMap.values()).join("\r\n") +
|
||||
"\r\n\r\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
await writeAll(stream, requestHeader);
|
||||
if (body) await writeAll(stream, body);
|
||||
|
||||
const raw = await readToEnd(stream);
|
||||
return parseHttpResponse(raw);
|
||||
}
|
||||
|
||||
function writeAll(stream: net.Socket | tls.TLSSocket, data: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(data, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readToEnd(stream: net.Socket | tls.TLSSocket): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function parseHttpResponse(raw: Buffer): WechatHttpResponse {
|
||||
const headerEnd = raw.indexOf("\r\n\r\n");
|
||||
if (headerEnd < 0) {
|
||||
throw new Error("Malformed HTTP response: missing header terminator");
|
||||
}
|
||||
const headerText = raw.subarray(0, headerEnd).toString("utf-8");
|
||||
let bodyBytes = raw.subarray(headerEnd + 4);
|
||||
|
||||
const lines = headerText.split("\r\n");
|
||||
const statusLine = lines.shift() ?? "";
|
||||
const statusMatch = statusLine.match(/^HTTP\/[\d.]+\s+(\d+)(?:\s+(.*))?$/);
|
||||
if (!statusMatch) {
|
||||
throw new Error(`Malformed HTTP status line: ${statusLine}`);
|
||||
}
|
||||
const status = Number.parseInt(statusMatch[1]!, 10);
|
||||
const statusText = statusMatch[2] ?? "";
|
||||
|
||||
const headers: Record<string, string | string[] | undefined> = {};
|
||||
const lowercaseHeaders: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const colon = line.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const value = line.slice(colon + 1).trim();
|
||||
const lower = key.toLowerCase();
|
||||
const existing = headers[lower];
|
||||
if (existing === undefined) {
|
||||
headers[lower] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
headers[lower] = [existing, value];
|
||||
}
|
||||
lowercaseHeaders[lower] = value;
|
||||
}
|
||||
|
||||
const transferEncoding = (lowercaseHeaders["transfer-encoding"] ?? "").toLowerCase();
|
||||
if (transferEncoding.split(",").map((s) => s.trim()).includes("chunked")) {
|
||||
bodyBytes = dechunk(bodyBytes);
|
||||
} else if (lowercaseHeaders["content-length"] !== undefined) {
|
||||
const length = Number.parseInt(lowercaseHeaders["content-length"]!, 10);
|
||||
if (Number.isFinite(length) && length >= 0) {
|
||||
bodyBytes = bodyBytes.subarray(0, length);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
async buffer() {
|
||||
return bodyBytes;
|
||||
},
|
||||
async text() {
|
||||
return bodyBytes.toString("utf-8");
|
||||
},
|
||||
async json<T = unknown>() {
|
||||
return JSON.parse(bodyBytes.toString("utf-8")) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dechunk(raw: Buffer): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
let offset = 0;
|
||||
while (offset < raw.length) {
|
||||
const lineEnd = raw.indexOf("\r\n", offset);
|
||||
if (lineEnd < 0) break;
|
||||
const sizeText = raw.subarray(offset, lineEnd).toString("ascii").split(";")[0]!.trim();
|
||||
const size = Number.parseInt(sizeText, 16);
|
||||
if (!Number.isFinite(size) || size < 0) {
|
||||
throw new Error(`Invalid chunked-encoding size: ${sizeText}`);
|
||||
}
|
||||
offset = lineEnd + 2;
|
||||
if (size === 0) break;
|
||||
if (offset + size > raw.length) {
|
||||
throw new Error("Chunked-encoding body truncated");
|
||||
}
|
||||
parts.push(raw.subarray(offset, offset + size));
|
||||
offset += size + 2;
|
||||
}
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
Reference in New Issue
Block a user