Compare commits

...

16 Commits

Author SHA1 Message Date
Jim Liu 宝玉 adbfa3036b chore: release v1.118.0 2026-05-21 16:56:17 -05:00
Jim Liu 宝玉 6026b619f0 Merge pull request #158 from yelban/feat/codex-imagegen-backend
feat: add codex-imagegen backend for non-Codex runtimes
2026-05-21 16:45:22 -05:00
Jim Liu 宝玉 1406a85331 chore: release v1.117.5 2026-05-21 08:55:07 -05:00
Jim Liu 宝玉 dac5867f8a chore: release v1.117.4 2026-05-21 08:52:09 -05:00
Jim Liu 宝玉 49829e7f98 Merge pull request #159 from JimLiu/feat/wechat-remote-api-bun-fix
feat(baoyu-post-to-wechat): remote-api publishing via SSH SOCKS5 tunnel
2026-05-21 08:46:11 -05:00
Yelban df16bd5d1a feat(codex-imagegen): cwd-drift immunity (path resolve + skip-git-repo-check)
Two improvements so the wrapper works the same regardless of caller cwd
or whether cwd is a git repo:

1. main.ts: resolve all filesystem path args (--prompt-file, --ref,
   --cache-dir, --log-file) to absolute paths up front. Previously only
   --image was resolved. Agents passing relative paths from arbitrary
   working directories (e.g. when SKILL.md is interpreted from a plugin
   install dir) now produce correct file lookups.

2. spawn.ts: pass --skip-git-repo-check to 'codex exec'. Without it,
   codex refuses to run outside a trusted directory:
   'Not inside a trusted directory and --skip-git-repo-check was not
   specified.' This made the wrapper fail when invoked from /tmp or
   from a non-git project tree.

3. baoyu-cover-image/SKILL.md: explicit path resolution note —
   scripts/codex-imagegen.sh lives at plugin/repo root (not shell
   cwd-relative). From the skill base directory it is at
   '../../scripts/codex-imagegen.sh'. Agents should resolve to an
   absolute path before invoking.

Verified:
- 16 unit tests pass
- e2e from /tmp (non-git, relative-path args) → 45s, 1.6MB PNG, status:ok

Composes with the existing cover-image wiring (commit e3932e4).
2026-05-21 16:26:03 +08:00
Yelban 9596d39e7b feat(baoyu-cover-image): wire SKILL.md to call codex-imagegen wrapper
Per review feedback on #158: a backend wrapper alone is not enough —
skills need explicit invocation guidance so the agent can find and
call it.

Updates skills/baoyu-cover-image/SKILL.md in two places:

1. ## Image Generation Tools — new bullet under the backend resolution
   list: 'Codex via codex exec (codex-imagegen)' with the exact command
   shape (--image / --prompt-file / --aspect / --ref / --cache-dir /
   --log-file), JSON output handling, and prerequisites.

2. ### Step 4: Generate Image, step 5 — added a concrete example
   command for the codex-imagegen branch alongside the existing generic
   instruction.

End-to-end verified: with EXTEND.md set to preferred_image_backend:
codex-imagegen, calling the command shape written in SKILL.md produces
a 1672x941 PNG in 83s (status: ok, attempts: 1, cached: false).
Output: /tmp/cover-e2e/cover.png. Structured log:
/tmp/cover-e2e/run.jsonl.

baoyu-cover-image is the validation skill; if reviewers want the same
guidance in baoyu-article-illustrator / baoyu-comic / baoyu-image-cards
/ baoyu-infographic / baoyu-slide-deck, happy to mirror the change.
2026-05-21 15:23:42 +08:00
Jim Liu 宝玉 156f8627c2 ci: install baoyu-post-to-wechat script deps before running tests
`wechat-socks-http.test.ts` (and `wechat-remote-publish.test.ts`
transitively) import `socks` from `wechat-socks-http.ts`. The CI
`npm ci` only installs root deps, so module resolution failed with
`ERR_MODULE_NOT_FOUND: Cannot find package 'socks'`.

Add a scoped `npm install` step for `skills/baoyu-post-to-wechat/scripts/`
with `--ignore-scripts` to skip postinstalls. Other skills with
private script package.json files don't yet have tests that import
their deps, so leaving them out keeps the install fast.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 02:16:31 -05:00
Jim Liu 宝玉 e0b861c148 fix(baoyu-post-to-wechat): make remote-api work under Bun & validate config strictly
The initial remote-api implementation (3b29f3c) relied on
`https.request({ agent: SocksProxyAgent })` to route token/upload/draft
calls through the SSH tunnel. Bun's `https.request` does not honor
Node's `http.Agent` contract, so the agent was silently bypassed and
requests still originated from the local IP — defeating the entire
IP-allowlist purpose. Two follow-on issues compounded it: tests read
the real `~/.baoyu-skills/.env` because Bun's `os.homedir()` ignores
test-time `process.env.HOME` mutations, and invalid config values were
silently coerced to defaults.

P1 — Bun-portable SOCKS routing:
- Drop `socks-proxy-agent` dependency. Add `socks` direct dep.
- New `wechat-socks-http.ts`: raw TCP via `SocksClient.createConnection`
  + `tls.connect({ socket, servername })` + hand-built HTTP/1.1 (status
  line parser, case-insensitive headers, chunked & content-length body
  framing). Works identically under Node and Bun because it avoids
  `http.Agent` entirely.
- Rewrite `wechat-http.ts` as a fetch-based local client and expose
  a `WechatClient = (url, init?) => Promise<WechatHttpResponse>`
  functional abstraction.
- `wechat-api.ts`: replace `agent?: http.Agent` with
  `client: WechatClient = wechatHttp` on the five HTTP-touching
  functions; `withSshTunnel` now yields a `WechatClient`.
- New `wechat-socks-http.test.ts` stands up a real SOCKS5 server
  stub + HTTP echo server and asserts `connectionCount === 1`,
  proving bytes actually traverse the proxy under both runtimes.

P2 — `HOME` honored under Bun:
- `homeDir()` reads `process.env.HOME` / `USERPROFILE` first, falling
  back to `os.homedir()`. `loadWechatExtendConfig` and `loadCredentials`
  use it, restoring test isolation.

P3 — Strict config validation:
- Replace lenient `toOptional*` helpers with `parsePort` /
  `parsePositiveInt` / `parseStrictHostKeyChecking` that throw with
  the key name. `loadWechatExtendConfig` only catches file-read
  errors so parse errors surface to the caller. Flip the corresponding
  test cases.

Verification:
- `npm test`: 261/261 pass.
- `bun test` in `scripts/`: 39/39 pass.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 02:10:23 -05:00
Jim Liu 宝玉 3b29f3c57c feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist
constraints) with a fully local code path: spawn a background `ssh -N -D`
SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through
the existing token/upload/draft flow. No Python helper, no SCP, no
remote files, no `AppSecret` ever leaving the local process.

Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag
rather than substring-replacing `src`) naturally avoids the original
PR's `html.replace(src, url)` HTML-replacement bug.

Changes:
- New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port
  allocation, SOCKS readiness polling, signal-handler-backed cleanup.
- New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a
  `SocksProxyAgent` can be passed through (undici `fetch` ignores agent).
- New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse.
- `wechat-api.ts`: thread optional `agent?` through token/upload/draft;
  add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote
  mode is selected by flag or `default_publish_method: remote-api`.
- `wechat-extend-config.ts`: add typed `remote_publish_*` keys with
  account-over-global fallback and value validation.
- Docs: SKILL.md, references/multi-account.md, references/config/
  first-time-setup.md, README.md, README.zh.md.
- Tests: 17 new node:test cases covering config parsing, SSH arg
  whitelisting, free-port allocation, multipart assembly, and HTTP
  agent threading.

Co-authored-by: Dame5211 <1079825614@qq.com>
2026-05-21 00:59:36 -05:00
Jim Liu 宝玉 f8fb457f36 chore: release v1.117.3 2026-05-20 07:58:44 -05:00
Jim Liu 宝玉 174e472a39 docs(baoyu-wechat-summary): restructure profile fields
Split aliases into group_nicknames (user's own prior names) and
aliases (nicknames from other members). Add tags field for
cross-cutting attributes. Sync SKILL.md version.
2026-05-20 07:58:20 -05:00
Jim Liu 宝玉 8b99fa7af0 fix(baoyu-post-to-wechat): sync SKILL.md version to 1.117.3 2026-05-20 07:58:18 -05:00
Jim Liu 宝玉 6699b802c0 fix(baoyu-diagram): add version field to SKILL.md 2026-05-20 07:58:17 -05:00
Jim Liu 宝玉 edcdc19ac5 feat(ci): add skill release commit validation
Add CI check to ensure commits touching skills/<name>/** use
Conventional Commit subjects. Also validates SKILL.md version
alignment during publish/sync.
2026-05-20 07:58:14 -05:00
Yelban e98fa33bc2 feat: add codex-imagegen backend for non-Codex runtimes
Implements the preferred_image_backend: codex-imagegen config key
already referenced in several SKILL.md files but with no corresponding
backend. Bridges non-Codex runtimes (Claude Code, etc.) to Codex CLI's
built-in image_gen tool via 'codex exec'. Uses the user's Codex
subscription — no OPENAI_API_KEY required.

Reliability:
- Retry with exponential backoff (default 2 retries, configurable)
- 9 classified error kinds (retryable vs non-retryable)
- Node child_process timeout with SIGTERM→SIGKILL ladder

Correctness:
- Verifies image_gen was actually invoked (not bypassed) by checking
  $CODEX_HOME/generated_images/{thread_id}/ for a PNG
- PNG magic byte validation
- Output file size sanity check

Observability:
- Structured JSONL logging with --log-file
- Verbose stderr mode with --verbose
- Returns thread_id, tool_calls, token usage in result JSON
- Raw codex event stream preserved per attempt for debugging

Efficiency:
- Idempotency cache via --cache-dir (sha256 of prompt+aspect+refs)
- Cache hit returns in <0.3s vs 50-90s cold

Features:
- --ref reference images (repeatable, passed through to codex exec --image)
- --retries, --retry-delay, --timeout all configurable
- File lock serializes concurrent invocations

Testing:
- 16 Bun unit tests across parser, cache, validator
- Path-filtered CI workflow at .github/workflows/codex-imagegen-tests.yml
- Composes with existing test.yml without conflicts

Architecture:
- scripts/codex-imagegen.sh: thin bash entrypoint (bun → npx -y bun fallback)
- scripts/codex-imagegen/: TypeScript module
  - main.ts (orchestrator), types.ts (CliOptions/GenerateResult/GenError),
    spawn.ts (child_process), parser.ts (JSONL events), validator.ts
    (image_gen check + PNG magic), cache.ts (sha256 cache + FileLock),
    logger.ts (JSONL structured logger)

Trade-offs documented in docs/codex-imagegen-backend.md:
- 5-10x slower than direct OpenAI API (or near-free on cache hit)
- ToS gray area: image_gen is documented for interactive use; non-
  interactive invocation via codex exec is not explicitly addressed.
  Suitable for personal low-volume use; users are responsible for
  ensuring their usage complies with applicable terms of service.
2026-05-20 00:50:24 +08:00
48 changed files with 3646 additions and 227 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.117.2"
"version": "1.118.0"
},
"plugins": [
{
@@ -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
+10
View File
@@ -11,6 +11,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -18,8 +20,16 @@ jobs:
node-version: 22
cache: npm
- name: Verify skill release commits
run: npm run verify:skill-release-commits
- 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
+47
View File
@@ -2,6 +2,53 @@
English | [中文](./CHANGELOG.zh.md)
## 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
- CI: add skill release commit validation — commits touching `skills/<name>/**` must use Conventional Commit subjects; SKILL.md version validated during publish/sync
### Fixes
- `baoyu-diagram`: add version field to SKILL.md
- `baoyu-post-to-wechat`: sync SKILL.md version
### Documentation
- `baoyu-wechat-summary`: restructure profile fields — split `aliases` into `group_nicknames` (user's own prior names) and `aliases` (nicknames from other members), add `tags` for cross-cutting attributes
## 1.117.2 - 2026-05-17
### Documentation
+47
View File
@@ -2,6 +2,53 @@
[English](./CHANGELOG.md) | 中文
## 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
### 新功能
- CI:新增 skill 发布提交校验 —— 涉及 `skills/<name>/**` 的提交必须使用 Conventional Commit 格式;发布/同步时校验 SKILL.md 版本一致性
### 修复
- `baoyu-diagram`:为 SKILL.md 添加 version 字段
- `baoyu-post-to-wechat`:同步 SKILL.md 版本
### 文档
- `baoyu-wechat-summary`:重构 profile 字段 —— 将 `aliases` 拆分为 `group_nicknames`(用户历史群名)和 `aliases`(其他成员对用户的称呼),新增 `tags` 字段存储横向属性
## 1.117.2 - 2026-05-17
### 文档
+16
View File
@@ -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 |
+13 -1
View File
@@ -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
+4 -1
View File
@@ -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
+256
View File
@@ -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 | 5090 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. **510× 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 |
+4
View File
@@ -23,6 +23,10 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: publish reads the skill directory directly and validates that local package references and CLI bin targets are self-contained.
Every skill release must keep the `version:` in that skill's `SKILL.md` aligned with the version being published. `publish-skill.mjs` and `sync-clawhub.mjs` both reject mismatches so a registry payload cannot ship with stale skill metadata.
Commits that touch `skills/<name>/**` must use Conventional Commit subjects, for example `fix(baoyu-post-to-wechat): handle WeChat editor focus`. CI runs `npm run verify:skill-release-commits` against the pushed or PR commit range so bare subjects like `Fix WeChat browser article publishing` cannot bypass per-skill release versioning silently.
## Shared Workspace Packages
`packages/` is the source of truth for shared runtime code. Most skills consume shared packages from npm with semver ranges. `baoyu-url-to-markdown` is the exception: it vendors the `baoyu-fetch` runtime into `skills/baoyu-url-to-markdown/scripts/lib/` so the published skill is self-contained and does not depend on the `baoyu-fetch` npm package.
+2 -1
View File
@@ -7,7 +7,8 @@
],
"scripts": {
"test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage",
"verify:skill-release-commits": "node ./scripts/verify-skill-release-commits.mjs"
},
"devDependencies": {
"@mozilla/readability": "^0.6.0",
+19
View File
@@ -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" "$@"
+63
View File
@@ -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 });
}
});
+80
View File
@@ -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 {}
}
}
}
+39
View File
@@ -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(" ");
}
+325
View File
@@ -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();
+49
View File
@@ -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);
});
+64
View File
@@ -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");
}
+81
View File
@@ -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,
};
}
+79
View File
@@ -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);
}
}
+98
View File
@@ -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);
});
+55
View File
@@ -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 };
}
+45
View File
@@ -25,6 +25,37 @@ const MIME_MAP = {
".svg": "image/svg+xml",
};
export async function readSkillMetadataVersion(root) {
const skillFile = await findSkillMarkdown(root);
const source = await fs.readFile(skillFile, "utf8");
const version = readSkillFrontmatterVersion(source);
if (!version) {
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
}
return version;
}
export async function validateSkillMetadataVersion(root, expectedVersion) {
const actualVersion = await readSkillMetadataVersion(root);
if (actualVersion !== expectedVersion) {
throw new Error(
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
);
}
}
export function readSkillFrontmatterVersion(source) {
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
if (!match) return null;
for (const line of match[1].split(/\r?\n/)) {
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
if (versionMatch) return versionMatch[1];
}
return null;
}
export async function listReleaseFiles(root) {
const resolvedRoot = path.resolve(root);
const files = [];
@@ -53,6 +84,20 @@ export async function listReleaseFiles(root) {
return files;
}
async function findSkillMarkdown(root) {
const resolvedRoot = path.resolve(root);
for (const name of ["SKILL.md", "skill.md"]) {
const candidate = path.join(resolvedRoot, name);
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// Try the next supported skill filename.
}
}
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
}
export async function validateSelfContainedRelease(root) {
const resolvedRoot = path.resolve(root);
const files = await listReleaseFiles(root);
+31
View File
@@ -6,6 +6,9 @@ import test from "node:test";
import {
listReleaseFiles,
readSkillFrontmatterVersion,
readSkillMetadataVersion,
validateSkillMetadataVersion,
validateSelfContainedRelease,
} from "./release-files.mjs";
@@ -45,6 +48,34 @@ test("listReleaseFiles skips generated paths and returns sorted relative paths",
);
});
test("readSkillFrontmatterVersion reads quoted and unquoted versions", () => {
assert.equal(readSkillFrontmatterVersion("---\nname: demo\nversion: 1.2.3\n---\n"), "1.2.3");
assert.equal(readSkillFrontmatterVersion("---\nversion: \"2.0.0\"\n---\n"), "2.0.0");
assert.equal(readSkillFrontmatterVersion("# Missing frontmatter\n"), null);
});
test("validateSkillMetadataVersion accepts matching SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
assert.equal(await readSkillMetadataVersion(root), "1.2.3");
await assert.doesNotReject(() => validateSkillMetadataVersion(root, "1.2.3"));
});
test("validateSkillMetadataVersion rejects mismatched SKILL.md version", async (t) => {
const root = await makeTempDir("baoyu-release-version-mismatch-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
await writeFile(path.join(root, "SKILL.md"), "---\nname: demo\nversion: 1.2.3\n---\n");
await assert.rejects(
() => validateSkillMetadataVersion(root, "1.2.4"),
/SKILL\.md version mismatch/,
);
});
test("validateSelfContainedRelease accepts file dependencies that stay within the release root", async (t) => {
const root = await makeTempDir("baoyu-release-ok-");
t.after(() => fs.rm(root, { recursive: true, force: true }));
+68
View File
@@ -0,0 +1,68 @@
const SKILL_PATH_PATTERN = /^skills\/([^/]+)\//;
const CONVENTIONAL_SUBJECT_PATTERN =
/^(?<type>[a-z][a-z0-9-]*)(?:\((?<scope>[^()\n]+)\))?(?<breaking>!)?: (?<description>\S[\s\S]*)$/;
export function parseConventionalCommitSubject(subject) {
const match = CONVENTIONAL_SUBJECT_PATTERN.exec(subject.trim());
if (!match?.groups) return null;
return {
type: match.groups.type,
scope: match.groups.scope ?? "",
breaking: Boolean(match.groups.breaking),
description: match.groups.description,
};
}
export function changedSkillsForPaths(paths) {
const skills = new Set();
for (const filePath of paths) {
const normalizedPath = filePath.replaceAll("\\", "/");
const match = SKILL_PATH_PATTERN.exec(normalizedPath);
if (match) skills.add(match[1]);
}
return [...skills].sort((left, right) => left.localeCompare(right));
}
export function validateSkillReleaseCommit({ commit = "", subject, paths }) {
const skills = changedSkillsForPaths(paths);
if (skills.length === 0) return [];
const parsed = parseConventionalCommitSubject(subject);
if (parsed) return [];
return [
{
commit,
subject,
skills,
message: `Commit ${formatCommit(commit)} changes ${formatSkills(skills)} but its subject is not a Conventional Commit: ${subject}`,
},
];
}
export function formatSkillReleaseFailures(failures) {
if (failures.length === 0) return "";
const lines = [
"Skill release commit check failed.",
"",
"Commits that touch skills/<name>/** must use Conventional Commit subjects so per-skill release tooling can derive a version bump.",
"Example: fix(baoyu-post-to-wechat): handle WeChat editor focus",
"",
];
for (const failure of failures) {
lines.push(`- ${failure.message}`);
}
return lines.join("\n");
}
function formatCommit(commit) {
return commit ? commit.slice(0, 12) : "<unknown>";
}
function formatSkills(skills) {
return skills.map((skill) => `skills/${skill}/**`).join(", ");
}
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
changedSkillsForPaths,
formatSkillReleaseFailures,
parseConventionalCommitSubject,
validateSkillReleaseCommit,
} from "./skill-release-guard.mjs";
test("parseConventionalCommitSubject accepts scoped, unscoped, and breaking subjects", () => {
assert.deepEqual(parseConventionalCommitSubject("fix(baoyu-post-to-wechat): repair editor paste"), {
type: "fix",
scope: "baoyu-post-to-wechat",
breaking: false,
description: "repair editor paste",
});
assert.deepEqual(parseConventionalCommitSubject("feat!: change skill metadata format"), {
type: "feat",
scope: "",
breaking: true,
description: "change skill metadata format",
});
assert.equal(parseConventionalCommitSubject("Fix WeChat browser article publishing"), null);
});
test("changedSkillsForPaths returns sorted unique skills", () => {
assert.deepEqual(
changedSkillsForPaths([
"README.md",
"skills/baoyu-post-to-wechat/scripts/wechat-article.ts",
"skills/baoyu-post-to-wechat/SKILL.md",
"skills/baoyu-url-to-markdown/SKILL.md",
]),
["baoyu-post-to-wechat", "baoyu-url-to-markdown"],
);
});
test("validateSkillReleaseCommit ignores non-skill changes", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "Fix test workflow",
paths: [".github/workflows/test.yml"],
}),
[],
);
});
test("validateSkillReleaseCommit rejects non-conventional skill commit subjects", () => {
const failures = validateSkillReleaseCommit({
commit: "81377416b4a7",
subject: "Fix WeChat browser article publishing",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
});
assert.equal(failures.length, 1);
assert.match(failures[0]!.message, /Conventional Commit/);
assert.match(formatSkillReleaseFailures(failures), /fix\(baoyu-post-to-wechat\):/);
});
test("validateSkillReleaseCommit accepts conventional skill commit subjects", () => {
assert.deepEqual(
validateSkillReleaseCommit({
subject: "fix(browser): ensure tab activation before copy/paste in WeChat editor",
paths: ["skills/baoyu-post-to-wechat/scripts/wechat-article.ts"],
}),
[],
);
});
+7 -1
View File
@@ -5,7 +5,12 @@ import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -21,6 +26,7 @@ async function main() {
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
: "";
await validateSkillMetadataVersion(skillDir, options.version);
await validateSelfContainedRelease(skillDir);
const files = await listReleaseFiles(skillDir);
if (files.length === 0) {
+46 -15
View File
@@ -6,7 +6,13 @@ import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { listReleaseFiles, mimeType, validateSelfContainedRelease } from "./lib/release-files.mjs";
import {
listReleaseFiles,
mimeType,
readSkillMetadataVersion,
validateSelfContainedRelease,
validateSkillMetadataVersion,
} from "./lib/release-files.mjs";
const DEFAULT_REGISTRY = "https://clawhub.ai";
@@ -38,9 +44,11 @@ async function main() {
const locals = await mapWithConcurrency(skills, options.concurrency, async (skill) => {
const files = await collectReleaseFiles(skill.folder);
const localVersion = await readSkillMetadataVersion(skill.folder);
const fingerprint = buildFingerprint(files);
return {
...skill,
localVersion,
fileCount: files.length,
fingerprint,
};
@@ -119,12 +127,12 @@ async function main() {
for (const candidate of actionable) {
const version =
candidate.status === "new"
? "1.0.0"
: bumpSemver(candidate.latestVersion, options.bump);
? candidate.localVersion
: resolveUpdateVersion(candidate, options.bump);
console.log(`Publishing ${candidate.slug}@${version}`);
try {
const files = await collectReleaseFiles(candidate.folder);
const files = await collectReleaseFiles(candidate.folder, version);
await publishSkill({
registry,
token: config.token,
@@ -325,7 +333,10 @@ async function hasSkillMarker(folder) {
);
}
async function collectReleaseFiles(root) {
async function collectReleaseFiles(root, expectedVersion = "") {
if (expectedVersion) {
await validateSkillMetadataVersion(root, expectedVersion);
}
await validateSelfContainedRelease(root);
return listReleaseFiles(root);
}
@@ -423,28 +434,48 @@ async function mapWithConcurrency(items, limit, fn) {
function formatCandidate(candidate, bump) {
if (candidate.status === "new") {
return `${candidate.slug} NEW (${candidate.fileCount} files)`;
return `${candidate.slug} NEW ${candidate.localVersion} (${candidate.fileCount} files)`;
}
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${bumpSemver(
candidate.latestVersion,
return `${candidate.slug} UPDATE ${candidate.latestVersion} -> ${resolveUpdateVersion(
candidate,
bump
)} (${candidate.fileCount} files)`;
}
function bumpSemver(version, bump) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
function resolveUpdateVersion(candidate, bump) {
if (compareSemver(candidate.localVersion, candidate.latestVersion) > 0) {
return candidate.localVersion;
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
return bumpSemver(candidate.latestVersion, bump);
}
function compareSemver(left, right) {
const leftParts = parseSemver(left);
const rightParts = parseSemver(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] - rightParts[index];
}
}
return 0;
}
function bumpSemver(version, bump) {
const [major, minor, patch] = parseSemver(version);
if (bump === "major") return `${major + 1}.0.0`;
if (bump === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function parseSemver(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
if (!match) {
throw new Error(`Invalid semver: ${version}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function sanitizeSlug(value) {
return value
.trim()
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { promisify } from "node:util";
import {
formatSkillReleaseFailures,
validateSkillReleaseCommit,
} from "./lib/skill-release-guard.mjs";
const execFileAsync = promisify(execFile);
const ZERO_SHA = /^0{40}$/;
async function main() {
const options = parseArgs(process.argv.slice(2));
const range = await resolveRange(options);
const commits = await listCommits(range);
const failures = [];
for (const commit of commits) {
const [subject, paths] = await Promise.all([readCommitSubject(commit), readCommitPaths(commit)]);
failures.push(...validateSkillReleaseCommit({ commit, subject, paths }));
}
if (failures.length > 0) {
console.error(formatSkillReleaseFailures(failures));
process.exit(1);
}
console.log(`Skill release commit check passed (${commits.length} commit${commits.length === 1 ? "" : "s"} checked).`);
}
function parseArgs(argv) {
const options = {
base: "",
head: "HEAD",
range: "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--base") {
options.base = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--head") {
options.head = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "--range") {
options.range = argv[index + 1] ?? "";
index += 1;
continue;
}
if (arg === "-h" || arg === "--help") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function printUsage() {
console.log(`Usage: verify-skill-release-commits.mjs [--base <rev> --head <rev> | --range <rev-range>]
Checks non-merge commits in the selected range. Any commit that touches
skills/<name>/** must use a Conventional Commit subject, for example:
fix(baoyu-post-to-wechat): handle WeChat editor focus
Without explicit arguments, GitHub Actions event metadata is used when
available. Otherwise the fallback range is HEAD^..HEAD.`);
}
async function resolveRange(options) {
if (options.range) {
return { args: [options.range], label: options.range };
}
if (options.base) {
return {
args: [`${options.base}..${options.head || "HEAD"}`],
label: `${options.base}..${options.head || "HEAD"}`,
};
}
const githubRange = await resolveGitHubRange();
if (githubRange) return githubRange;
return { args: ["HEAD^..HEAD"], label: "HEAD^..HEAD" };
}
async function resolveGitHubRange() {
const eventPath = process.env.GITHUB_EVENT_PATH;
if (!eventPath) return null;
let event = null;
try {
event = JSON.parse(await fs.readFile(eventPath, "utf8"));
} catch {
return null;
}
if (event?.pull_request?.base?.sha) {
const base = event.pull_request.base.sha;
const head = process.env.GITHUB_SHA || "HEAD";
return { args: [`${base}..${head}`], label: `${base}..${head}` };
}
if (event?.before && !ZERO_SHA.test(event.before)) {
const head = event.after || process.env.GITHUB_SHA || "HEAD";
return { args: [`${event.before}..${head}`], label: `${event.before}..${head}` };
}
return null;
}
async function listCommits(range) {
const output = await git(["rev-list", "--no-merges", "--reverse", ...range.args]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function readCommitSubject(commit) {
return git(["log", "-1", "--format=%s", commit]);
}
async function readCommitPaths(commit) {
const output = await git(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", commit]);
return output ? output.split("\n").filter(Boolean) : [];
}
async function git(args) {
const { stdout } = await execFileAsync("git", args, {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return stdout.trimEnd();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+16 -1
View File
@@ -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
+1
View File
@@ -1,6 +1,7 @@
---
name: baoyu-diagram
description: Create professional, dark-themed SVG diagrams of any type — architecture diagrams, flowcharts, sequence diagrams, structural diagrams, mind maps, timelines, illustrative/conceptual diagrams, and more. Use this skill whenever the user asks for any kind of technical or conceptual diagram, visualization of a system, process flow, data flow, component relationship, network topology, decision tree, org chart, state machine, or any visual representation of structure/logic/process. Also trigger when the user says "画个图" "画一个架构图" "diagram" "flowchart" "sequence diagram" "draw me a ..." or uploads content and asks to visualize it. Output is always a standalone .svg file.
version: 1.117.3
---
# Diagram Generator
+45 -16
View File
@@ -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.56.1
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"
}
}
+197 -171
View File
@@ -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 接口只返回 URLmaterial/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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-wechat-summary
description: Summarizes WeChat group chat highlights into a structured digest using the local wx-cli binary (https://github.com/jackwener/wx-cli). Generates a normal digest by default; a roast (毒舌) version is opt-in. Maintains per-group history (history.json + history-digests.jsonl) and per-user profiles across runs, with privacy guardrails baked in. Use when the user asks to "总结群聊", "群聊精华", "群聊摘要", "summarize group chat", "group chat digest", mentions a WeChat group name with a time range, says "帮我看看 XX 群最近聊了什么", "XX 群有什么值得看的", or asks to "回溯画像" / "初始化画像" / "backfill profiles". Adds the roast version when the user says "毒舌版", "roast 版", "再来个毒舌的", or similar.
version: 0.1.0
version: 1.117.3
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-wechat-summary
@@ -32,7 +32,9 @@ YAML frontmatter at the top of every profile file:
---
name: "<current display name>"
wxid: "<wxid>"
aliases: ["<old nickname>", "<even older nickname>"]
group_nicknames: ["<历史群昵称 1>", "<历史群昵称 2>"]
aliases: ["<群友给的称呼 1>", "<群友给的称呼 2>"]
tags: ["<标签 1>", "<标签 2>"]
first_seen: "YYYY-MM-DD"
last_seen: "YYYY-MM-DD"
total_messages: N
@@ -45,12 +47,16 @@ Field rules:
- `name`: the most recent display name from `from_nickname` (or `self_display` for the owning user).
- `wxid`: stable; never changes once written.
- `aliases`: append-only; every prior display name we've seen. Don't include the current `name` in this list.
- `group_nicknames`: append-only history of the user's own prior display names in the group. Push the prior `name` here when `name` changes. Dedupe, preserve chronological order (oldest → newest). Do not include the current `name`.
- `aliases`: nicknames **other members** call this user (e.g., `蛙总`, `老王`, `X 哥`). Dedupe-append when observed in this batch. Do not include the current `name`, and do not duplicate `group_nicknames` entries — those record the user's own past handles, not how the group addresses them.
- `tags`: free-form labels for the user, **independent** of the body's 角色标签 / 人设标签 section. Use for cross-cutting attributes that don't fit the role/personality framing (region, profession, community, recurring long-form interests, etc.). Agent may append or refine when observing stable patterns. No hard cap.
- `first_seen` / `last_seen`: dates of first/most-recent digest appearance, YYYY-MM-DD.
- `total_messages`: cumulative count across all digests this profile has been updated from.
- `digest_appearances`: how many digest files this user has 3+ messages in.
- `avg_messages_per_digest`: `total_messages / digest_appearances`, one decimal.
**Backwards compatibility**: earlier versions of this skill used `aliases` for what is now `group_nicknames`. When reading an existing profile that lacks `group_nicknames` or `tags`, treat missing fields as `[]` and add them on the next write. **Do not auto-migrate** non-empty legacy `aliases` values — the agent can't reliably tell historical display names apart from community-given nicknames. Leave the values in `aliases`; the user can move historical display names into `group_nicknames` manually if desired.
### 1.3 Free-form body — normal profile
Section headers are plain text on their own line. Order is fixed.
@@ -138,8 +144,16 @@ Rules differ per section. Append-only sections must never lose history; mergeabl
### 2.3 Frontmatter on every update
- Update `name` if current display name differs from the recorded one. Push the old name onto `aliases` if not already there.
- If `name` changed, also rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- If the current display name differs from the recorded `name`:
- Push the old `name` onto `group_nicknames` if not already there (dedupe, preserve chronological order).
- Update `name` to the current display name.
- Rename the file from `{wxid}-{old_nickname}.md` to `{wxid}-{new_nickname}.md`.
- Scan this batch for nicknames **other members** use to address this user, and dedupe-append into `aliases`. Signals:
- `@mention` resolving to this `wxid`.
- Direct salutations targeting this user with a name different from `name` (e.g., `蛙总你怎么看`, `老王说得对`).
- Quoted references in the digest body that name this user as someone other than their current `name`.
- Only add when attribution is unambiguous; skip uncertain matches.
- If this batch reveals a stable cross-cutting attribute that doesn't fit the role/personality framing of 角色标签 / 人设标签 (region, profession, community, durable interest, etc.), append or refine `tags`. `tags` is independent of the body's tag sections — don't mirror them.
- Update `last_seen` to the current digest's end date.
- Increment `total_messages` by this batch's message count for this user.
- Increment `digest_appearances` by 1.
@@ -154,7 +168,7 @@ Run after the digest file(s) are written. Iterate over every user with 3+ messag
1. **Look up the profile.**
- Scan `profiles/` (or `profiles-roast/` for the roast pass) for a file whose name starts with `{wxid}-`.
- If found: open it.
- If not found: create a new file using the frontmatter template. `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`.
- If not found: create a new file using the frontmatter template. `group_nicknames = []`, `aliases = []`, `tags = []`, `first_seen = last_seen = current digest end date`, `total_messages = this batch's count`, `digest_appearances = 1`. Then run §2.3 to seed observed aliases/tags from this batch.
2. **Resolve wxid for new users.** When a new user appears, you already know their `wxid` from the wx-cli message data — use it directly. If for some reason only the nickname is known, run `wx contacts --query "{nickname}" --json` to resolve; if multiple matches, prefer the one currently in the group (cross-check `wx members <group>` if needed).
@@ -195,7 +209,7 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
5. **Write profile files.**
- For the normal pass, write to `profiles/{wxid}-{nickname}.md`.
- For the roast pass, write to `profiles-roast/{wxid}-{nickname}.md`.
- Use the most recent nickname as the filename suffix. Push older nicknames into `aliases`.
- Use the most recent nickname as the filename suffix. Push older display names into `group_nicknames` (see step 6 for the field-by-field rules).
- Sort 经典金句,标志性事件,毒舌语录库,经典翻车现场 entries chronologically by date.
- No cap on the size of append-only sections during backfill — let history flow in.
@@ -204,6 +218,9 @@ Triggered when the user says `回溯画像`, `初始化画像`, `backfill profil
- `last_seen` = latest digest date the user appeared in.
- `total_messages` = sum of per-digest counts.
- `digest_appearances` = number of digests the user crossed the 3-message threshold in.
- `group_nicknames` = best-effort. If the same `wxid` appears under multiple distinct display names across historical digests (e.g., via the leaderboard line "X — N 条" where X varied), fill the older ones in chronological order (newest stays in `name`). If chronological order is unclear, dedupe and let later runs correct.
- `aliases` = best-effort. Scan historical digest bodies for forms where another member calls this user by a name different from their current `name` (@mentions, direct salutations). Skip uncertain matches; leave `[]` if nothing reliable surfaces.
- `tags` = `[]`. Backfill does not seed `tags`; let normal runs accumulate them.
7. **Report.** After both passes complete, print a short summary:
- `Backfilled {N} normal profiles from {M} digests.`
@@ -264,7 +281,8 @@ When loading profile context for a fresh digest:
2. For the normal pass, read `profiles/{wxid}-*.md` for each. Skip if missing.
3. If the current run also generates the roast version, **separately** read `profiles-roast/{wxid}-*.md` during the roast generation pass.
4. Compile a condensed working-memory block:
- The user's current `name` and `aliases` (so you can recognize them under different names).
- The user's current `name`, `group_nicknames`, and `aliases` (so you can recognize them under prior display names or community-given nicknames).
- `tags` (cross-cutting attributes — region, profession, community — useful for callouts in 群友画像).
- 角色标签 / 人设标签 (so you can carry forward or contrast).
- The 3-5 most recent 经典金句 / 毒舌语录 entries (so you can detect callbacks and repeats).
- The 3-5 most recent 标志性事件 / 翻车现场 entries (so you can spot recurring themes).