dsh-tiddlywiki 0.23.1 → 0.23.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +21 -1
- package/cordis.patch.yml +13 -13
- package/docs/code-review-2026-09-17.md +151 -0
- package/docs/wechat-publish-setup.md +75 -3
- package/lib/client.js +2 -2
- package/lib/index.js +1535 -306
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/settings-page.ts +24 -2
- package/src/client/state.ts +39 -39
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +22 -1
- package/src/host/config.ts +82 -9
- package/src/host/git.ts +175 -3
- package/src/host/routes.ts +224 -2
- package/src/host/seed-wechat-docs.ts +1 -1
- package/src/host/seed-wechat-publish.ts +81 -0
- package/src/host/seeds.ts +12 -1
- package/src/host/tools.ts +24 -3
- package/src/host/wechat-publish.ts +690 -0
- package/src/index.ts +70 -6
- package/tools/wechat/install-wechat-adapters.mjs +6 -1
- package/tools/wechat/publish-note-imgs.js +379 -0
- package/tools/wechat/publish-note.js +6 -3
- package/tools/wechat/weixin-flow.js +42 -11
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dsh-tiddlywiki contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-tiddlywiki contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
| 🧯 **路由不会拖垮进程** | 所有路由经 `guardHandler` 包装:任何 rejection(含代理里 try 之外的 `new URL()`)都变成 413/500 响应,而不是宿主未处理的 promise rejection(那会**直接结束 dsh web 进程**并挂死请求);剪藏桥 listen 后保留常驻 `error` 监听(v0.19.3) |
|
|
38
38
|
| 📊 **回复流卡片** | 工具结果显示原生 TW 卡片(**按笔记自己的内容类型渲染**:Markdown 笔记就是 Markdown,v0.18.0),检索/最近列表带**命中处摘要**(v0.22.8);`[标题](/dsh-tiddlywiki/tw/#标题)` 点击直达 TW 面板 |
|
|
39
39
|
| 📤 **发送给 Agent** | TW 笔记工具栏一键把当前笔记注入所选 dsh 会话(可选工作模式/权限/附加说明);成功/失败会弹出提示(v0.20.0 修复:此前提示把自由文本当 tiddler 标题传给 TW notifier,全部静默) |
|
|
40
|
-
| 📮 **发布到微信公众号**(**可选,默认关**) | 把笔记一键发到公众号**草稿箱**(可选点发表):TW 渲染 → 补内联样式 → 浏览器自动化复用你已登录的后台会话。**绕开官方 API 权限封锁**(2025-07 起个人主体账号的发布接口被回收),个人号可用;发表需管理员扫一次码。**需额外安装**(opencli + 浏览器扩展),插件不替你装;**关闭时完全不打扰**(不注入提示词、不写文档)。**带发布元数据**(`pub-state`/`pub-platform`/`pub-wechat-*` + `no-publish`
|
|
40
|
+
| 📮 **发布到微信公众号**(**可选,默认关**) | 把笔记一键发到公众号**草稿箱**(可选点发表):TW 渲染 → 补内联样式 → 浏览器自动化复用你已登录的后台会话。**绕开官方 API 权限封锁**(2025-07 起个人主体账号的发布接口被回收),个人号可用;发表需管理员扫一次码。**需额外安装**(opencli + 浏览器扩展),插件不替你装;**关闭时完全不打扰**(不注入提示词、不写文档)。**带发布元数据**(`pub-state`/`pub-platform`/`pub-wechat-*` + `no-publish` 标签)避免重发或误发。**v0.23.3 起 TW 工具栏有「发布到公众号」按钮**(预检 + 进度轮询,只到草稿箱),也可继续用 CLI。见 [docs/wechat-publish-setup.md](docs/wechat-publish-setup.md) |
|
|
41
41
|
| 🧭 **内嵌编辑器** | 中央列内嵌完整 TW 5 编辑器(同源代理,Tailscale/内网/域名/HTTPS 均可) |
|
|
42
42
|
| 🗂️ **右侧边栏 Tab** | DSH 新右侧栏(rightbar):首页「TiddlyWiki 知识库」入口一键打开,与聊天并排;链接点击可直达(v0.16.21) |
|
|
43
43
|
| 🧪 **审计守门** | 第四轮审计(v0.20.0)把 CI 与 `npm run verify:*` 合成一份清单,并补上 `verify-constants`(filter 长度预算)、`/render` 403、auth 打码、notify 与草稿避让回归 |
|
|
@@ -109,6 +109,10 @@ dsh plugin --profile web add link:/path/to/dsh-tiddlywiki
|
|
|
109
109
|
2. 冲突后用 `tiddlywiki_git_resolve` 二选一解决,再重新 sync(**绝不自动覆盖**)。
|
|
110
110
|
3. 收工 `tiddlywiki_git_sync action=sync`(pull → commit → push)。
|
|
111
111
|
|
|
112
|
+
> 🛡 **带着冲突绝不提交**(v0.23.4):`commit` 之前会探测「进行中的 rebase / 未合并路径 / **工作树里残留的冲突标记**」,命中就**拒绝提交**(`/sync` 返回 409 + 文件名、自动提交只上报一次、`/status` 与设置页状态行显示「冲突未解决(N 个文件,已阻止提交)」)。这条守卫来自真实事故:autostash 重新应用冲突后 `rebase --abort` 已无事可 abort,冲突标记留在工作树里被自动提交**永久写进了 git 历史**(连带插件自己解析不了配置)。**删冲突标记的方式**:解决后 `tiddlywiki_git_resolve`(或手动编辑掉 `<<<<<<<`/`=======`/`>>>>>>>` 三行)再 sync。
|
|
113
|
+
>
|
|
114
|
+
> ⚙️ **配置文件坏了会拒绝保存**(v0.23.4):若 `$:/plugins/dsh-tiddlywiki/config` 不是合法 JSON(最典型就是残留冲突标记),设置页保存会**被拒绝**并在页面顶部显示红色横幅——而不是像以前那样拿空配置合并、把你其余的设置(`prompt.extra`/`git.remote`/`ui.*`…)一次抹掉。修好该 tiddler(或删掉它回落 `config:` 块)后即可正常保存。
|
|
115
|
+
|
|
112
116
|
> 🔍 **检索/最近跳过二进制附件**(v0.16.20):`search`/`recent` 在**服务端**只取文本 tiddler(无 `type` 字段或 `text/*`),图片/音频/视频/PDF/zip 等二进制 tiddler(base64 正文)不参与检索、不出现在结果里——大 wiki(如数千张书籍扫描页图)从此从 515MB/17s 降到 ~0.4s,也不会被同名书页图刷屏。`get` 对二进制 tiddler 只返回元数据(`binary=true` + 类型/大小 + 链接),不返回 base64 正文。
|
|
113
117
|
|
|
114
118
|
> ⚠️ `fields.type` 是 TW 的**内容类型保留字段**(`text/markdown` 等),业务分类请放 `tags`,别写进 `fields.type`。
|
|
@@ -163,6 +167,11 @@ dsh plugin --profile web add link:/path/to/dsh-tiddlywiki
|
|
|
163
167
|
|
|
164
168
|
把 wiki 里的任意笔记**一键发到微信公众号草稿箱**(可选直接发表)。整套能力放在 `tools/wechat/`,**不依赖公众号服务端 API**——因为 2025-07 起官方已回收个人主体账号的「发布能力」接口权限;本方案改用**浏览器自动化复用你已登录的后台会话**,所以个人号也能用。
|
|
165
169
|
|
|
170
|
+
**两条路,随便挑一条**:
|
|
171
|
+
|
|
172
|
+
- **点按钮(v0.23.3,日常推荐)**:开启可选功能后,笔记工具栏出现「**发布到公众号**」。点它 → 先**预检**(opencli 在不在、adapter 缺不缺)→ 弹确认框(自动列出 `no-publish` / `pub-state` 警告)→「开始存草稿」→ 覆盖层每 2 秒显示进度与日志。**只到草稿箱为止**,发表请到后台点(需扫码)。背后是宿主进程起一个单并发的后台任务(`POST /dsh-tiddlywiki/wechat/publish` + 轮询 `…/status`),标题经 **UTF-8 文件**(`--title-file`)传给 adapter——不进 argv,避免 Windows `cmd.exe` shim 把 `&` 当命令分隔符、把中文解成乱码。
|
|
173
|
+
- **敲命令**(等价,适合批量 / 脚本化):
|
|
174
|
+
|
|
166
175
|
```bash
|
|
167
176
|
# ① 先按 docs/wechat-publish-setup.md 装好 opencli + 浏览器扩展,并登录公众号
|
|
168
177
|
# ② 一次性:装 adapter 到本机 opencli(幂等,会自检扩展/登录状态)
|
|
@@ -200,6 +209,7 @@ seed 是把「wiki 里预置内容」随插件分发的机制:**ONE-SHOT(只
|
|
|
200
209
|
| | `tw-web-host` | TW 前端 API 基址 → 同源代理(内嵌编辑器前提) | 自动写 |
|
|
201
210
|
| 📖 **起步**(默认写、可移除,**想要完整体验建档案**) | `doc-note` | 「dsh-tiddlywiki 插件说明」笔记 | 自动写 |
|
|
202
211
|
| | `starter-docs` | 「示例与文档」:主题汇总页·模板 + 教程 + 三个示例主题页(日志/决策记录/排障) | 自动写 |
|
|
212
|
+
| | `publish-spec` / `wechat-setup` / `wechat-publish`(**带 gate**) | 只有开启「可选功能:微信公众号发布」才写:发布元数据规范 + 换机还原指南 + 「**发布到公众号**」工具栏按钮插件(v0.23.3) | 开启后自动写 |
|
|
203
213
|
| 🎀 **可选**(默认不写、设置页手动、可移除,**可有可无**) | `home-index` | 首页(四象限待办 + 快速记笔记 + 所有标签/所有文章 + 📚 文档栏)——文档中心的「门面」 | 手动 |
|
|
204
214
|
| | `all-articles` | 「所有文章」两列分页总览(🤖 Agent / 👤 人工) | 手动 |
|
|
205
215
|
| | `ui-styles` | 自定义样式 5 张(编辑器美化 / 窄屏侧栏 / menubar 加高 / 批注弹窗等) | 手动 |
|
|
@@ -255,6 +265,12 @@ seed 是把「wiki 里预置内容」随插件分发的机制:**ONE-SHOT(只
|
|
|
255
265
|
showSessionTab: true
|
|
256
266
|
showRightbarTab: true # DSH 右侧边栏提供 TiddlyWiki 入口/Tab
|
|
257
267
|
sendToAgent: { enabled: true }
|
|
268
|
+
wechat: # 可选功能:微信公众号发布(默认关;三条 /wechat/* 路由在关时一律 403)
|
|
269
|
+
enabled: false # 开:注入发布约定 + 启动写 3 个 gated seed(元数据规范/换机指南/工具栏按钮)
|
|
270
|
+
adapter: "publish-note" # publish-note-imgs = 正文内嵌多图版(需该 adapter 已装)
|
|
271
|
+
command: "opencli" # CLI 路径(不在 PATH / 用了别名时填绝对路径)
|
|
272
|
+
token: "" # 非空时 /wechat/* 要求请求头 x-wechat-publish-token(按钮自动带)
|
|
273
|
+
dsn: "" # adapter 回连 DSH 的基址;空 = 按请求端口推导 loopback
|
|
258
274
|
uiLanguage: "" # 留空不干预;"zh-Hans" 自动启用简体
|
|
259
275
|
auth:
|
|
260
276
|
username: "" # 默认 loopback 匿名;暴露到非回环才需要
|
|
@@ -389,6 +405,10 @@ lib/ # 预构建产物(发布含 lib/**,提交入库;
|
|
|
389
405
|
|
|
390
406
|
> 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
|
|
391
407
|
|
|
408
|
+
- **v0.23.4**(2026-09-18):**三处硬化,全部来自 v0.23.3 上线当天暴露的真实事故**。① **提交前拦冲突(数据安全)**:`git pull --rebase --autostash` 的 **autostash 重新应用**冲突后,`rebase --abort` 已无事可 abort,冲突标记留在工作树里,随后 60s 的 AutoCommitter 照常 `git add -A && commit` —— `<<<<<<< Updated upstream` 就这样被**永久提交并推送**进了 wiki 的配置 tiddler(`0b19ca6`,2026-09-17),插件随即解析不了自己的配置(微信发布一直是关的、`prompt.extra` 静默失效)。现在 `GitFace.conflictState()` 三重探测(进行中的 rebase / 未合并路径 / **工作树里残留的冲突块**),`commit()` 直接抛 `GitConflictStateError` 拒绝提交;`/sync` 回 409 + 文件名、agent 工具返回结构化失败、AutoCommitter 去重上报一次(不去刷屏);`pull()` 在 abort 后**复检**(autostash 形态正是 abort 之后才暴露);`/status` 与设置页状态行显示「冲突未解决(N 个文件,已阻止提交)」。⚠️ `MERGE_HEAD`/`CHERRY_PICK_HEAD` 故意**不**硬拒——`git commit` 正是收尾它们的动作(真 git E2E 抓出了这个设计错误)。② **配置解析失败拒绝写(数据丢失)**:`ConfigStore.set()` 在存量 tiddler 解析失败时,原先拿**空缓存**合并 → 一次设置页保存就把配置写成只剩表单里那几个字段的"残版"(18 日 13:38 实测:`prompt.extra`/`git.remote`/`ui.*` 全丢)。现在抛 `ConfigUnreadableError`、不 PUT,并把原因经 `/admin/state` 的 `configError` 渲染成设置页顶部红色横幅;瞬时**读**失败仍走"合并到缓存"(回归断言守住)。③ **adapter 版本校验**:`/wechat/ready` 原先只数文件在不在,旧版 adapter(不认识 `--title-file`、标题仍是必填位置参数)也会报"就绪",点按钮才在 opencli 里报"缺少必填参数"。现在按**定义**判新旧(`weixin-flow.js` 必须 `export function resolveNoteTitle(`、两个入口必须声明 `name: 'titleFile'`——**不是**字符串出现,注释里提到不算),`missing`(不存在)与 `stale`(过旧)语义互斥;按钮预检与路由 503 分别说「版本过旧」和「缺少发布脚本」。bundle 升 `0.2.0`。守门:新增 `scripts/verify-conflict-config-guards.mjs`(23 条:伪 exec 纯逻辑 + **真起临时 git 仓库的冲突 E2E**(真 merge 冲突 / 真残留标记,且解决后必须能提交)+ ConfigStore 拒绝写/回归/接线断言;**已反向验证**:拆掉 `contentScan` 或配置拒绝各红 2 条),`verify-wechat-publish.mjs` 17→24 条(+注释里提及新符号必须判旧、真源码符号对齐),bundle 守门 +3 条。
|
|
409
|
+
- **v0.23.3**(2026-09-18):**TW 笔记工具栏新增「发布到公众号」按钮**(可选功能,与既有 CLI 等价,只是不用敲命令)。链路:TW 按钮 → `POST /dsh-tiddlywiki/wechat/publish` → 宿主 spawn `opencli weixin publish-note` → `GET …/wechat/publish/status` 轮询 → 覆盖层显示进度与日志尾部。**只到草稿箱为止**,发表仍由人工扫码(沿用 v0.23.2 定的策略)。三处硬骨头:① **标题不进 argv**——Windows 上 `opencli` 是 `.cmd` shim,必须经 cmd.exe 启动,于是标题里的 `&`/`|`/`^`/引号成了命令注入面、中文标题还会被 cmd 的代码页解成乱码;改成宿主写 UTF-8 文件 + adapter 新增 **`--title-file`**(位置参数照旧可用,两者都给时位置参数优先)。② **任务化而非同步请求**——命令要跑几十秒到几分钟,`POST` 立即返回 `jobId`,单并发(第二次 409)、15 分钟上限,超时用 `taskkill /T` 杀**整棵进程树**(只杀 cmd.exe 会留下孤儿 node 抓着 job 目录,实测 `EBUSY`)。③ **可选功能默认不打扰**——`wechat.enabled` 关着时三条 `/wechat/*` 路由一律 403、按钮插件不写进 wiki(新增 gated 起步 seed `wechat-publish`,与 `publish-spec`/`wechat-setup` 同进同退)。新增 `src/host/wechat-publish.ts`(命令组装纯函数 + job registry + 就绪探测)、三条路由(方法/同源守卫 + 可选 `x-wechat-publish-token`;`wechat.token` 与其它密钥一样在 `/admin/state` 打码)、TW bundle `$:/plugins/dsh/wechat-publish`(`scripts/bundle/wechat-publish/` + build/gen/verify 流水线,`wechat-publish.bundle.json` 逐字节守门)。守门:`scripts/verify-wechat-publish.mjs`(17 条:纯函数 + **真跑 spawn 的「假 opencli」E2E**(含标题不进 argv 的逐字断言)+ 真实 HTTP 路由守卫;**已反向验证**:拆掉 `guardWechat` 即红)+ `scripts/verify-wechat-publish-bundle.mjs`(进 `verify:static`)+ `verify-wechat-adapters.mjs` 新增「两个 publish adapter 都必须走 `resolveNoteTitle`」。
|
|
410
|
+
- **v0.23.2**(2026-09-17):**多图发布命令 `publish-note-imgs` + 发布策略定为「到草稿箱为止」**。TW 笔记内嵌 `[img[...]]` 经 `/render` 是 data URI,微信存草稿会过滤非 mmbiz 图——新命令先把正文全部图片经 DataTransfer 逐张上传 CDN(轮询计数到期望值),按 DOM 顺序重写正文 src 再 insertHTML,从正文第一张设封面;发布前同样读 `pub-state`/`no-publish`(只告警不阻断)。`selectCoverFromContent` 封面校验改轮询,修「实际成功却报失败」的假阴性(实测 `list_ex` 的 `cover` 已是 mmbiz 而流程报失败)。安装脚本 FILES 收编至 5 个 adapter,守门断言同步。**策略**:adapter 负责到草稿落盘为止,发表(含原创声明/作者/留言等确认弹窗 + 管理员扫码)由人工完成——`--publish` 保留但 best-effort,遇弹窗会等到超时。文档新增:发表确认弹窗说明、`stale page identity` 续作法、`list_ex`/`appmsgpublish` 核实命令、GitHub ext release 落后 Web Store 备注、npm allowScripts 拦 postinstall 无害备注、**eval 诊断后台必须过滤不可见 toast 残留**(踩过:把残留文案当实时状态误判账号被拦,实际账号正常)。
|
|
411
|
+
|
|
392
412
|
- **v0.23.1**(2026-09-17):**新增 `wechat-setup` seed——「微信公众号发布指南」也随插件分发**(补齐 v0.23.0 的缺口:当时只有「发布元数据规范」进了 seed,387 行的安装/换机还原指南只存在于仓库与 npm 包的 `docs/` 里,wiki 里那篇「换机还原清单」是手写指针笔记)。与 `publish-spec` 完全同模式:起步层(`startup: true`)+ `gate: (ctx) => ctx.wechat === true`——**只在设置页开启「微信公众号发布」时**启动写入(笔记「微信公众号发布指南」,`dsh-docs` 标签自动进首页插件文档栏),不开该功能的用户 wiki 不出现、设置页手动「初始化」不受 gate 约束。**内容单一来源**:新 gen 脚本 `scripts/gen-seed-wechat-docs.mjs` 从 `docs/wechat-publish-setup.md` 生成 `src/host/seed-wechat-docs.ts`(照 send-to-agent 的 gen 流水线,勿手改常量),新守门 `scripts/verify-wechat-docs-seed.mjs`(进 `verify:unit`)断言 seed 常量与文档**逐字节一致** + 注册表 gate 接线(2 个 gated seed = gate 谓词恰好 2 处)。顺带:`verify-package-contents.mjs` 的 `npm pack` 从管道捕获改为**文件重定向 + 临时缓存目录**(DSH 沙箱禁止命名管道 stdio → 原 `exec` 实现 EPERM;npm 默认缓存在沙箱外也要绕开),本地终于能跑这条守门。selftest / verify-seed-error-policy 的 gate 断言改为**从注册表派生** gated 清单(新增 gated seed 不再漏改硬编码);设置页与 config 注释同步「开启后写两篇文档」;docs/seed-initialization.md 补齐 publish-spec / wechat-setup 两行表格。
|
|
393
413
|
|
|
394
414
|
- **v0.23.0**(2026-09-17):**新增「发布到微信公众号」+ 发布元数据**(`tools/wechat/`,**可选功能,默认关闭,需额外安装**,不动 host 代码)。把 wiki 笔记一键发到公众号**草稿箱**,可选点发表。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
# dsh-tiddlywiki bundle patch: inserts the plugin row into the web profile
|
|
2
|
-
# roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
|
|
3
|
-
# field) over dsh-base; activate with
|
|
4
|
-
# `dsh plugin --profile web add link:<path-to-this-package>` and restart dsh web.
|
|
5
|
-
#
|
|
6
|
-
# The row is a bare plugin by package name: the node half (exports ".") runs
|
|
7
|
-
# in the host process (WikiServer, routes, tiddlywiki_* tools, prompt section,
|
|
8
|
-
# auto-commit), and the `dsh.client` declaration in package.json makes the
|
|
9
|
-
# browser half (exports "./client", served at /plugins/dsh-tiddlywiki/client.js)
|
|
10
|
-
# load in the web GUI.
|
|
11
|
-
- insert:
|
|
12
|
-
- id: dsh-tiddlywiki
|
|
13
|
-
name: dsh-tiddlywiki
|
|
1
|
+
# dsh-tiddlywiki bundle patch: inserts the plugin row into the web profile
|
|
2
|
+
# roster. Applied as a profile bundle layer (the `dsh.bundle.patch` manifest
|
|
3
|
+
# field) over dsh-base; activate with
|
|
4
|
+
# `dsh plugin --profile web add link:<path-to-this-package>` and restart dsh web.
|
|
5
|
+
#
|
|
6
|
+
# The row is a bare plugin by package name: the node half (exports ".") runs
|
|
7
|
+
# in the host process (WikiServer, routes, tiddlywiki_* tools, prompt section,
|
|
8
|
+
# auto-commit), and the `dsh.client` declaration in package.json makes the
|
|
9
|
+
# browser half (exports "./client", served at /plugins/dsh-tiddlywiki/client.js)
|
|
10
|
+
# load in the web GUI.
|
|
11
|
+
- insert:
|
|
12
|
+
- id: dsh-tiddlywiki
|
|
13
|
+
name: dsh-tiddlywiki
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# dsh-tiddlywiki 全面代码审查(v0.22.10,2026-09-17)
|
|
2
|
+
|
|
3
|
+
审查对象:`1d67543`(v0.22.10)工作区,覆盖 host 数据写路径、HTTP/路由/安全层、进程/生命周期/存储/seed、以及全部 client 代码。
|
|
4
|
+
|
|
5
|
+
**结论**:本地四套校验(`verify:static` / `verify:unit` / `verify:e2e` / `verify:large`)全绿,`typecheck` 通过,`npm run build` 重建 `lib/` 与提交版**逐字节一致**(无脏构建产物)。但审查发现 **3 处 P0/P1 安全问题**——v0.19.3 / v0.20.0 建立的「插件密钥命名空间不可经代理读出」这条防线**实测已被绕过**。
|
|
6
|
+
|
|
7
|
+
标注约定:✅ = 我在本机实测/读码亲自复现;○ = 读码确认(未运行时复现)。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## P0 — 安全
|
|
12
|
+
|
|
13
|
+
### 1. ✅ 代理的「密钥命名空间」封锁可用**百分号编码的单段路径**绕过
|
|
14
|
+
|
|
15
|
+
`src/host/routes.ts:565-575`(`isBlockedProxyPath`,用于 `/api` 与 `/tw`)
|
|
16
|
+
|
|
17
|
+
守卫只在路径里含**字面量 `/tiddlers/`** 时才生效。而 TW core 的 `get-tiddler-html.js` 路由是 `path = /^\/([^\/]+)$/`(**单段**,用 `decodeURIComponentSafe` 解码),于是把 `/` 编码成 `%2F` 就能绕开那个字面量匹配。
|
|
18
|
+
|
|
19
|
+
本机实测(只读 GET):
|
|
20
|
+
|
|
21
|
+
| 请求 | 结果 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `GET /dsh-tiddlywiki/tw/tiddlers/$:/plugins/dsh-tiddlywiki/config` | **403**(守卫生效) |
|
|
24
|
+
| `GET /dsh-tiddlywiki/tw/%24%3A%2Fplugins%2Fdsh-tiddlywiki%2Fconfig` | **200**,676 字节插件配置 JSON |
|
|
25
|
+
| `GET /dsh-tiddlywiki/api/%24%3A%2F...%2Fconfig` | **200**(同上) |
|
|
26
|
+
| `GET /dsh-tiddlywiki/get?title=...` | 403(这条路由没问题) |
|
|
27
|
+
|
|
28
|
+
同一编码手法对 `/api` 同样有效。**这次没有泄密**:本机 `$:/plugins/dsh-tiddlywiki/config` 里没有 `bridge.token` / `auth.password`,`git.remote` 也是不含凭据的纯 https 地址——但 README 恰恰叫用户去设 `bridge.token`,一旦设了,任何**同源**读取方(页面内 XSS、被注入的 TW 内容、其它插件)即可拿到明文。
|
|
29
|
+
|
|
30
|
+
**修复**:别再用 `/tiddlers/` 这个标记做判定。应把整条被代理路径解码、归一后,判断解码结果是否落在受保护标题上(覆盖 TW 各条路由形态),或在转发前对每个路径段解码后比对。
|
|
31
|
+
|
|
32
|
+
### 2. ✅ `/tw/` 索引页把整个 store(含插件配置)原样吐出
|
|
33
|
+
|
|
34
|
+
`src/host/routes.ts:1474-1553`(`handleTwProxy`)
|
|
35
|
+
|
|
36
|
+
`$:/core/save/all`(`templates/save-all.tid`)没有排除插件命名空间,所以 `GET /dsh-tiddlywiki/tw/` 会内嵌每一条 tiddler。本机实测:**33,643,875 字节**,且正文中确实包含 `$:/plugins/dsh-tiddlywiki/config`。
|
|
37
|
+
|
|
38
|
+
这条路径完全绕过了 `/admin/state` 的密钥打码(`admin.ts:385-414`)。
|
|
39
|
+
|
|
40
|
+
**修复**:在 store 的 publish 过滤里排除 `$:/plugins/dsh-tiddlywiki/`;更彻底的做法是共享 token 根本不进 wiki tiddler。
|
|
41
|
+
|
|
42
|
+
### 3. ✅ `POST /render` 只挡了 `title` 分支,`text` 分支的**转写**可读出被保护条目
|
|
43
|
+
|
|
44
|
+
`src/host/routes.ts:1394-1405`
|
|
45
|
+
|
|
46
|
+
v0.20.0 的加固只覆盖 `title`;`text` 分支未校验,而 TW 的 `/render` 会解析 `{{…}}` 转写,`contextTitle` 同样未校验。本机实测:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
POST /render {"text":"{{$:/plugins/dsh-tiddlywiki/config}}","type":"text/vnd.tiddlywiki"}
|
|
50
|
+
→ 200,2744 字节,净化后的片段里是配置 JSON 的完整内容
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
即:加固过的这条路由,本身仍可把密钥渲染出来。
|
|
54
|
+
|
|
55
|
+
**修复**:`text` 分支拒绝含受保护标题的正文,并校验 `contextTitle`;或把转写放进受限上下文。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## P1/P2 — 数据安全与生命周期
|
|
60
|
+
|
|
61
|
+
### 4. ○ `tiddlywiki_delete`:回收站索引的守卫发生在**破坏性步骤之后**
|
|
62
|
+
|
|
63
|
+
`src/host/tools.ts:761`(`wiki.delete`)先于 `:768-769`(`readTrashIndex` + 中止判定)。
|
|
64
|
+
|
|
65
|
+
于是「快照 PUT 成功 → 原条目已删除 → 索引读失败」时,工具报的是「本次操作已中止」,但实际上原标题**已经没了**,而回收站副本**没进索引**:`trash list` 看不到、`restore` 找不到、`empty` 清不掉——成为永久孤儿(只能从 git 里捞)。
|
|
66
|
+
|
|
67
|
+
代码注释(`tools.ts:762-767`)与回归测试(`verify-audit-fixes.mjs:335`)都声称「先读索引再删除」,与实际执行顺序相反;这也是测试为何没抓到它的原因。
|
|
68
|
+
|
|
69
|
+
**修复**:把 `readTrashIndex` 与该守卫上移到快照 PUT 之前;若保留删除后失败的可能,错误信息里必须带上 `trashTitle` 以便恢复。
|
|
70
|
+
|
|
71
|
+
### 5. ○ `assertNoConflict` 把两个令牌当**或**处理,且 `revision` 不跨重启存活
|
|
72
|
+
|
|
73
|
+
`src/host/write-policy.ts:276-279`
|
|
74
|
+
|
|
75
|
+
先判 `modified` 命中即 return;再判 `revision` 命中即 return。TW 的 `revision` 是 `core/modules/wiki.js` 里的内存 hashmap(`changeCount`,**不持久化**,重启后每条回到 1)——我已在 `node_modules/tiddlywiki/core/modules/wiki.js:151-155` 确认。
|
|
76
|
+
|
|
77
|
+
后果:agent 读到 `revision: 1` → 人类在 TW 里改动 → 一次 pull/重启让 revision 复位 → agent 带 `expectedRevision: 1` 的写入被**接受**,人类的改动被静默覆盖。更糟的是**同时传两个令牌并不更安全**:`modified` 不匹配时会落到 `revision` 分支继续写。
|
|
78
|
+
|
|
79
|
+
**修复**:调用方给了哪个令牌就必须匹配哪个,任一不匹配即拒;并修正三处工具描述(`tools.ts:503`、`719`、`973`)不要暗示 revision 跨重启有效。
|
|
80
|
+
|
|
81
|
+
### 6. ✅ `fs.watch` 没有 `'error'` 监听器,可让整个 dsh web 进程退出
|
|
82
|
+
|
|
83
|
+
`src/index.ts:279-294`(`watchWiki`)
|
|
84
|
+
|
|
85
|
+
只挂了 `'change'`。Node 的 EventEmitter 在**没有 `'error'` 监听器**时会把 `error` 事件抛成未捕获异常(本机已用最小复现确认),而宿主没有 `uncaughtException` 处理器。`fs.watch` 的 `error` 是**异步**事件(Linux `ENOSPC` 用尽 inotify watch、Windows `EPERM`),外面那个 try/catch 只能接住同步创建失败。
|
|
86
|
+
|
|
87
|
+
**修复**:`watcher.on('error', () => {})`(我们自己的写入仍会 `touch()` 自动提交,退化是安全的)。
|
|
88
|
+
|
|
89
|
+
### 7. ○ `stop()` 不能取消在途的 `start()`,可泄漏孤儿 TW 子进程
|
|
90
|
+
|
|
91
|
+
`src/host/wiki.ts:550-587` vs `303`/`307`/`351`
|
|
92
|
+
|
|
93
|
+
`startOnce()` 在 `await this.ensureWiki()`(302)与 `await this.findFreePort()`(307)处让出执行权;`stop()` 在此期间把 `this.child` 取走并置空(559-560)。等 `startOnce()` 继续走到 `spawn()`(351)并赋值 `this.child` 时,**已经没人会去杀它**了。
|
|
94
|
+
|
|
95
|
+
可达路径:`scheduleRestart()` 的定时器在调用 `start()` **之前**就清掉了 `this.restartTimer`(517),所以随后的 `stop()` 既没定时器可取消、也看不到子进程;以及 `index.ts:884-888` 那个 3 秒 teardown race 超时。
|
|
96
|
+
|
|
97
|
+
**修复**:在 `spawn()` 前立刻复查 `if (this.stopping) return this.status()`(`stopping` 已有,无需新状态)。
|
|
98
|
+
|
|
99
|
+
### 8. ○ 回滚失败会让自动提交**永久停摆**
|
|
100
|
+
|
|
101
|
+
`src/host/wiki-switch.ts:86-99` + `src/index.ts:753-754`
|
|
102
|
+
|
|
103
|
+
外层失败路径已经先跑过 `teardownExtras()`(753,会 dispose AutoCommitter 与 fs watcher)再 `applyLocation`。而 `restore()` 里 `startServer()`(90)排在 `setupExtras()`(92)**之前**——若回滚时 `startServer()` 抛错(例如旧 wiki 撞上硬就绪上限),`catch` 直接返回,`setupExtras()` 永不执行,且没有任何其它路径会重新装配。此后所有 wiki 写入都不会再被提交,而旧子进程可能因「迟到就绪」复探仍在服务,`/status` 看起来一切正常。
|
|
104
|
+
|
|
105
|
+
**修复**:在 `restore()` 里用 `finally` 重新装配 extras。
|
|
106
|
+
|
|
107
|
+
### 9. ○ `runSwitch` 缺 `disposed` 守卫
|
|
108
|
+
|
|
109
|
+
`src/index.ts:736-770`。`disposed` 在作用域内(571 声明)但 `runSwitch` 从不检查:热重载/关闭期间的在途切换会重新挂上 committer+watcher,并可能在 teardown 已 `stop()` 之后 spawn 出新的 TW 子进程。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## P3 — 低危 / 行为瑕疵
|
|
114
|
+
|
|
115
|
+
10. ✅ **`escapePromptBraces` 对连续花括号失效**(`src/host/prompt.ts:142-144`)。`replace(/\{\{/g, …)` 只替换**不重叠**匹配,本机实测:3 个及以上连续 `{` 会残留 `{{`(`{{{name}}}` → `{<ZWSP>{{name}}}`),而 DSH 对未知变量**直接抛错**,会炸掉整个系统提示词装配——正是该函数存在的理由。现有断言(`verify-prompt.mjs:141`)只覆盖最简单的单个 `{{cwd}}`。**修复**:循环替换至无 `{{`,或在任意相邻两括号间插入 ZWSP。
|
|
116
|
+
11. ✅ **`readLimit` 把空参数当 0**(`src/host/routes.ts:315-318`):`?limit=` → `Number('' ?? 15)` = 0 → 夹到 1。本机实测 `/recent?limit=` 返回 `limit=1`,而 `/recent` 是 15。**修复**:空值按缺省处理(`readOptionalLimit` 已是对的)。
|
|
117
|
+
12. ✅ **`git pull` 失败后无条件 `rebase --abort`**(`src/host/git.ts:159-160`):只要 pull 失败就 abort——包括用户在 wiki 仓库里**自己正在做的 rebase**。本机真实仓库复现:`rebase in progress: True` → `pull --rebase` 报 `Pulling is not possible because you have unmerged files` → 插件执行 `rebase --abort` → 用户的 rebase 与冲突解决被清掉(提交仍在 reflog/ORIG_HEAD,可恢复)。**修复**:仅在确有 rebase 进行中且失败原因是冲突时才 abort。
|
|
118
|
+
13. ○ **clip-bridge 的 OPTIONS 预检排在 Host 白名单之前**(`src/host/clip-bridge.ts:688` 早于 `:694`):DNS-rebinding 页面能拿到 204 + `Access-Control-Allow-Origin: *` + `Allow-Private-Network: true`(仅信息泄露:确认回环端口有服务;后续 POST 仍 403)。
|
|
119
|
+
14. ○ **`ensureTiddlerTimestamps` 在只给了 `created` 时把 `modified` 钉死成 `created`**(`src/host/tw-api.ts:189`)。真实受害者就是配置 tiddler:`config.ts:215-221` 每次保存都只带 `created`,于是 `modified` 永远停在首次写入的时刻——与该文件 `198-200` 行自述的意图(「modified tracks edits」)相反。**本机磁盘实测**:`$__plugins_dsh-tiddlywiki_config.json.meta` 里 `created` 与 `modified` **完全相同**(`20260917033457660`)。`routes.ts:447-457` 因为显式传了 `modified` 才躲过。**修复**:缺的那一半用 `now`(仍保留「绝不改写已有值」的保证)。
|
|
120
|
+
15. ○ **`append` 在 `mode=prepend` 时回执里谎报段落**(`src/host/tools.ts:884` vs `:908`,render 在 `:866`):`heading` 只在 append 分支生效,却无条件回显,模型会以为写进了「日志」段。
|
|
121
|
+
16. ○ **`$:/` 系统条目其实会被软删除**(`src/host/tools.ts:743-744`):注释写「system tiddlers are never nested」,但条件只排除了 `TRASH_PREFIX`。`delete('$:/dsh-tiddlywiki/trash-index')` 会把索引本身移入回收站,随后读到 404 → 当成「回收站为空」→ 索引被重写成 1 条,**丢掉全部回收站记录**(正是 v0.19.5 想防的那类损失,只是换了扇门)。
|
|
122
|
+
17. ○ **`/render` 内层 catch 把超限/中断报成 400**(`src/host/routes.ts:1377-1382`):`body too large` 本应 413。
|
|
123
|
+
18. ○ **`put` 能把二进制附件改成损坏的文本**(`src/host/tools.ts:534`):按设计保留基底 `type`,于是对 `type: image/png` 的既有附件写入 markdown,type 仍是 `image/png` → 图裂,且回执里 `typeChanged` 为空(类型确实没变)。
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## 已复核为正确的部分(尽力想证伪但没成功)
|
|
128
|
+
|
|
129
|
+
- **写策略的字段保留**:`cleanTiddler` 确实摊平了 `get-tiddler.js` 塞进 `fields` 的未知字段;`applyCustomFields` 拒绝 `title/text/tags/created/modified` 而放行 `type`;「覆盖保留 type/tags/自定义字段」的契约成立。
|
|
130
|
+
- **v0.22.10 的时间戳语义**:`formatTiddlerDate` ⇄ `parseTiddlerDate` 对 **1970–9999 年间所有可达时刻**往返一致(含 `.000`、`+1ms`、`+999ms`、1969 年末);`stampTiddlerTimes` 始终从基底取 `created`、从 `now` 取 `modified`,不会让 `modified` 早于 `created`;覆盖不会丢原 `created`;`ensureTiddlerTimestamps` 只补缺、绝不改写(回收站快照、恢复、`/edit` 草稿的显式值都存活)。仅当 `new Date()` 落到公元 1000 年前/10000 年后才不往返——生产不可达。
|
|
131
|
+
- **回收站快照/恢复的时间戳**:删除时保留原 `created`+`modified`,恢复同理,所以恢复的笔记不会跳到「最近修改」顶部;索引的 404 与读失败区分正确。
|
|
132
|
+
- **`batch_put` 有界并发**:结果按下标回填、逐条容错、计数器只在同步段内改、`autoCommit()` 在循环外只调一次。
|
|
133
|
+
- **`attach` 覆盖保护**:同名默认拒绝、需 `force`;附件本身与 `noteTitle` 嵌入都走共享写策略。
|
|
134
|
+
- **HTTP 层的方法校验**:routes.ts 与 admin.ts 的每一个写 handler 都声明了方法、每一个读 handler 都用 `rejectNonRead`,29 处注册全被 `guardHandler` 包裹;`readBodyStream` 在 `aborted`/`close` 上会 settle,不会挂死请求。
|
|
135
|
+
- **`/admin/restart` 即便没走 `beginMutation` 也不可能双重重启**:`WikiServer.start()/restart()` 是单飞的。
|
|
136
|
+
- **密钥打码**:`maskConfigSecrets`/`stripMaskedSecrets` 对 `bridge.token`、`auth.password`、`ui.sendToAgent.token`、`git.remote` 的往返正确;本机 `/status` 返回的是不含凭据的 remote。
|
|
137
|
+
- **SSRF 守卫**:`isPrivateAddress`/`ipv6ToBytes` 是逐字节且完备的(`::1`、`::ffff:7f00:1`、NAT64、6to4、IPv4-compatible 都判私网),解析不可判时 fail-closed;`resolvePublicTarget` 用自定义 `lookup` **钉住**已校验 IP 防 rebinding,每一跳重定向都重新校验;`hostAllowed` 字母精确;`safeTokenEqual` 两侧哈希。
|
|
138
|
+
- **seed 的 ONE-SHOT 语义**:只有 404 才算缺失(`readSeedTiddler`/`presentOf`/`inspectSeedContent`),非 force 绝不覆盖已存在条目;`refreshSeedMarker` 跳过无变化的 PUT。
|
|
139
|
+
- **子进程退出处理**:身份检查防止被替换的旧子进程清掉新子进程的状态;`stop()` 会先清「迟到就绪」复探。
|
|
140
|
+
- **净化器**:`codePointToChar` 已把数字实体夹在 `0x10FFFF`,`�` 不再抛 `RangeError`。
|
|
141
|
+
- **client 半部**:我逐文件读了 `tw-frame.ts`、`panel.ts`、`index.ts`、`sidebar-entry.ts`、`theme-sync.ts`、`quick-note-dock.ts`、`session-summary.ts`、`status-cache.ts`、`sync-button.ts`、`editor-popup.ts`、`knowledge-fab.ts`、`tool-views.ts`、`note-widget.ts` 的关键段:定时器/监听/observer/ResizeObserver 在 dispose 路径上都被回收、可见性都在 `await` 之后复查、`iframe.src` 从不作为加载判据(只用 `dataset.loaded`)、`contentDocument` 读取都有守卫、`dangerouslySetInnerHTML` 只吃宿主净化过的 `/render` 输出。**未发现新缺陷。**
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 建议的修复顺序
|
|
146
|
+
|
|
147
|
+
1. **P0 三连**(安全,同一个根因族):代理路径判定重写(#1)+ store 排除插件命名空间(#2)+ `/render` 的 `text`/`contextTitle` 校验(#3)。
|
|
148
|
+
2. **P1/P2 数据安全**:#4(删除前先读索引)、#5(令牌 AND 语义 + revision 不可持久化的措辞)、#6(`fs.watch` error 监听)、#7(`spawn` 前复查 `stopping`)、#8(`restore()` 用 finally 重装 extras)、#9(`runSwitch` 的 disposed 守卫)。
|
|
149
|
+
3. **P3**:#10–#18,其中 #10(提示词转义)、#11(空 limit)、#14(配置 `modified` 钉死)最值得顺手带上。
|
|
150
|
+
|
|
151
|
+
守门建议:#1/#2/#3 各加一条 E2E(用编码路径与转写正文断言 403),#4 加一条「索引读失败时原条目必须仍在」的顺序断言,#5 加一条「modified 不匹配即便 revision 相同也必须拒」的纯函数断言,#6/#7 各加一条源码级或注入式断言,#10 扩展现有 `verify-prompt.mjs` 断言(连续花括号),#11 加一条空 `?limit=` 的路由断言。
|
|
@@ -74,9 +74,21 @@ opencli weixin publish-note "笔记标题" --trace retain-on-failure -f json
|
|
|
74
74
|
> **opencli 版本建议 ≥ 1.8.7**(本方案在该版本实测通过;1.7.x 的 `browser`
|
|
75
75
|
> 子命令语法不同,且内置 weixin adapter 不完整)。
|
|
76
76
|
>
|
|
77
|
+
> **新版 npm 的 allowScripts 机制会拦 opencli 的 postinstall 脚本**(安装时打
|
|
78
|
+
> warning):**无害,可忽略**——postinstall 只装 bash/zsh/fish 补全(Windows 用不上),
|
|
79
|
+
> adapters 随 npm 包自带,功能不受影响。
|
|
80
|
+
>
|
|
77
81
|
> 第 7 步之前,插件不会注入任何发布相关提示词、也不会往 wiki 写「发布元数据规范」。
|
|
78
82
|
> 也就是说:**没装好工具链时开关可以一直关着,不影响任何其他功能。**
|
|
79
83
|
|
|
84
|
+
### 自动化的终点:草稿箱(2026-09-17 与用户确认的策略)
|
|
85
|
+
|
|
86
|
+
**adapter 负责到「草稿落盘」为止**:填标题/作者/摘要、写正文、传图、设封面、存草稿。
|
|
87
|
+
**发表由人工在后台完成**——点「发表」时平台会弹确认对话框(原创声明、作者、留言
|
|
88
|
+
设置等)以及最后的管理员扫码;原创声明涉及账号权益,人工点最稳妥。`--publish`
|
|
89
|
+
选项保留(best-effort:点「发表」后轮询等扫码),但**不处理中途弹窗**,遇到弹窗
|
|
90
|
+
会一直等到超时。
|
|
91
|
+
|
|
80
92
|
---
|
|
81
93
|
|
|
82
94
|
## 2. 安装 Browser Bridge 扩展(关键前置)
|
|
@@ -93,6 +105,10 @@ opencli weixin publish-note "笔记标题" --trace retain-on-failure -f json
|
|
|
93
105
|
3. 浏览器打开 `chrome://extensions` → 开启右上角「开发者模式」
|
|
94
106
|
4. 点「加载已解压的扩展程序」→ 选择解压出的目录
|
|
95
107
|
|
|
108
|
+
> ⚠️ GitHub 的 ext release **经常落后于 Web Store**(2026-09-17 实测只到
|
|
109
|
+
> `ext-v1.0.21`,Web Store 已是 1.0.24)。**扩展版本以 Chrome Web Store 为准**;
|
|
110
|
+
> GitHub 方式只作 Web Store 打不开时的离线兜底。
|
|
111
|
+
|
|
96
112
|
**验证**:
|
|
97
113
|
|
|
98
114
|
```bash
|
|
@@ -134,8 +150,52 @@ opencli weixin publish-note "某篇笔记标题" --cover ./cover.png --trace ret
|
|
|
134
150
|
|
|
135
151
|
# ④ 直接发表(⚠️ 会真发文章,需管理员扫码)
|
|
136
152
|
opencli weixin publish-note "某篇笔记标题" --publish --trace retain-on-failure -f json
|
|
153
|
+
|
|
154
|
+
# ⑤ 正文多图(v0.23.2+):TW 笔记内嵌 [img[...]] 经 /render 变成 data URI,
|
|
155
|
+
# publish-note 传不了(微信存草稿时过滤非 mmbiz 图)——用 publish-note-imgs:
|
|
156
|
+
opencli weixin publish-note-imgs "某篇笔记标题" --images <图片目录> --trace retain-on-failure -f json
|
|
157
|
+
opencli weixin publish-note-imgs "某篇笔记标题" --images "01.png|02.png|03.png" -f json
|
|
158
|
+
# · --images 传目录时按文件名排序 = 正文图片出现顺序(封面图排第一)
|
|
159
|
+
# · 也可用 | 分隔的路径列表精确指定顺序
|
|
160
|
+
# · 流程:先逐张上传 CDN → 按 DOM 顺序重写正文 src → insertHTML → 选封面 → 存草稿
|
|
161
|
+
# · 发表前同样读 pub-state / no-publish(只告警不阻断),--publish 可直接发表
|
|
137
162
|
```
|
|
138
163
|
|
|
164
|
+
### 3.6 在 TiddlyWiki 里点按钮发布(v0.23.3,日常推荐)
|
|
165
|
+
|
|
166
|
+
不想敲命令时,用**笔记工具栏的「发布到公众号」按钮**(嵌入式 TW 面板、右侧栏 tab、原生编辑弹窗里都在):
|
|
167
|
+
|
|
168
|
+
1. 设置页勾选「可选功能:微信公众号发布」并保存 → 重启 dsh web 后,启动 seed 会把按钮插件
|
|
169
|
+
`$:/plugins/dsh/wechat-publish` 写进 wiki(老 wiki 也可在设置页「初始化」区单独写它)。
|
|
170
|
+
2. 打开任意笔记 → 工具栏点「发布到公众号」→ 按钮**先预检**(opencli 跑不跑得起来、adapter 缺哪些文件),
|
|
171
|
+
再弹确认框(自动列出 `no-publish` / `pub-state` 警告)→ 点「开始存草稿」。
|
|
172
|
+
3. 宿主进程起一个**后台任务**,覆盖层每 2 秒显示状态与日志尾部。**只到草稿箱为止,不自动发表。**
|
|
173
|
+
|
|
174
|
+
按钮背后的三条路由(同源;写操作有方法 + CSRF 守卫;`wechat.enabled` 关着时一律 403):
|
|
175
|
+
|
|
176
|
+
| 路由 | 方法 | 作用 |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `/dsh-tiddlywiki/wechat/ready` | GET | 预检:`opencli --version` 能否跑通、adapter 缺哪些文件 |
|
|
179
|
+
| `/dsh-tiddlywiki/wechat/publish` | POST | 起任务(body `{title, adapter?}`);单并发,第二次调用 409 |
|
|
180
|
+
| `/dsh-tiddlywiki/wechat/publish/status` | GET | 轮询任务(`?id=`;不带 id 取最新/正在跑的那个) |
|
|
181
|
+
|
|
182
|
+
可配项(设置页目前只暴露 `enabled`,其余写进配置 tiddler
|
|
183
|
+
`$:/plugins/dsh-tiddlywiki/config` 的 `wechat` 块,保存即生效):
|
|
184
|
+
|
|
185
|
+
| 键 | 默认 | 说明 |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `wechat.enabled` | `false` | 总开关;关着时按钮不写入、三条路由 403 |
|
|
188
|
+
| `wechat.adapter` | `publish-note` | 换成 `publish-note-imgs` 走正文内嵌多图(需该 adapter 已装) |
|
|
189
|
+
| `wechat.command` | `opencli` | CLI 路径(装了别名、不在 PATH 时用) |
|
|
190
|
+
| `wechat.token` | 空 | 非空时要求请求头 `x-wechat-publish-token`(按钮会自动带上) |
|
|
191
|
+
| `wechat.dsn` | 空 | adapter 回连 DSH 的基址;留空按请求端口推导 `http://127.0.0.1:<端口>/dsh-tiddlywiki` |
|
|
192
|
+
| `wechat.endpoint` | 空 | **只被 TW 侧读**:覆盖按钮请求的基址(默认 `location.origin + /dsh-tiddlywiki`);反向代理/远程访问场景用 |
|
|
193
|
+
|
|
194
|
+
⚠️ 宿主用 **`--title-file`**(v0.23.3 新增参数)把标题经 UTF-8 文件交给 adapter:标题不进 argv,
|
|
195
|
+
否则 Windows 上 `opencli` 的 `.cmd` shim 会把 `&`/`|`/`^` 当命令分隔符,中文标题还会被 cmd 的
|
|
196
|
+
代码页解码成乱码。位置参数 `<title>` 照旧可用(两者都给时位置参数优先)。
|
|
197
|
+
⚠️ 按钮**不替你发表**:发表要管理员扫码(§4.2),请在公众号后台点「发表」。
|
|
198
|
+
|
|
139
199
|
---
|
|
140
200
|
|
|
141
201
|
## 3.5 发布元数据(重要:避免重发/误发)
|
|
@@ -253,6 +313,13 @@ Navigation rejected
|
|
|
253
313
|
|
|
254
314
|
两者都要扫码 → 默认选**不吃额度**的「发表」。
|
|
255
315
|
|
|
316
|
+
### 4.4 发表阶段的确认弹窗 adapter 不处理(v0.23.x 现状)
|
|
317
|
+
|
|
318
|
+
`publishDraft` 点完「发表」后**只轮询成功信号**,不会点任何平台弹窗。发表链路上
|
|
319
|
+
还有:原创声明/作者/留言设置等**确认对话框**(2026-09-17 人工发表实测必经)与最后
|
|
320
|
+
的管理员扫码。所以**推荐流程到草稿箱为止**(见 §1),发表环节人工做;`--publish`
|
|
321
|
+
只是 best-effort,遇到弹窗会卡到超时。排错见 §8。
|
|
322
|
+
|
|
256
323
|
---
|
|
257
324
|
|
|
258
325
|
## 5. 为什么不用官方 API(背景,避免走回头路)
|
|
@@ -278,7 +345,8 @@ tools/wechat/
|
|
|
278
345
|
├── wechat-html.js # 排版装饰器:语义 HTML → 内联样式 HTML(主题表在这里改)
|
|
279
346
|
├── weixin-flow.js # 浏览器流程:登录检测/填表/写正文/传图/设封面/存草稿/发表
|
|
280
347
|
├── create-article.js # 命令:从本地 HTML 文件建草稿(可选发表)
|
|
281
|
-
├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview)
|
|
348
|
+
├── publish-note.js # 命令:从 TW 笔记建草稿(主入口,含 --preview;单图 --cover)
|
|
349
|
+
├── publish-note-imgs.js # 命令:多图版——正文内嵌 data URI 图全部上传 CDN 再发布
|
|
282
350
|
└── install-wechat-adapters.mjs # 一键安装到 ~/.opencli/clis/weixin/
|
|
283
351
|
```
|
|
284
352
|
|
|
@@ -363,9 +431,13 @@ input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
|
363
431
|
| 找不到笔记 | tiddler 标题要**完全精确**(含空格/标点);`opencli weixin publish-note "标题"` |
|
|
364
432
|
| `/render 返回 HTTP 404` | 标题不存在;先用 `tiddlywiki_search` 或 TW 面板确认 |
|
|
365
433
|
| 无法连接 DSH | `--dsn` 默认 `http://127.0.0.1:3080/dsh-tiddlywiki`;DSH 没跑或端口不同就改它 |
|
|
366
|
-
| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover
|
|
434
|
+
| 草稿显示「内容不完整」 | 缺封面 → 传 `--cover`(或用 publish-note-imgs 从正文第一张自动设) |
|
|
367
435
|
| 图片没上传 | 单图 > 8MB(DataTransfer 限制)→ 先压缩 |
|
|
368
|
-
| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout
|
|
436
|
+
| 发表卡住超时 | 没扫码,或扫码没完成 → 调大 `--timeout`;若卡在原创声明/留言等确认弹窗 → 推荐流程本就到草稿箱为止,到后台人工点发表(见 §1、§4.4) |
|
|
437
|
+
| `stale page identity` | 命令执行中标签页被手工关闭/导航了。**草稿不丢**——重开编辑页续作:`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&type=77&appmsgid=<id>&idx=0&token=<token>`(token 用 `opencli browser wx eval 'location.href.match(/token=(\d+)/)[1]'` 从后台首页取) |
|
|
438
|
+
| 封面警告「设置失败」但草稿其实有封面 | 已知**误报**(校验时机太早);以草稿箱实际显示为准(2026-09-17 实测:`list_ex` 接口 `cover` 字段已是 mmbiz 地址)。v0.23.2 起改为轮询校验 |
|
|
439
|
+
| 用 eval 诊断后台状态时读到「未实名」「未设置头像和名称」等提示 | ⚠️ 后台 DOM 里常残留**不可见的历史 toast 节点**——查询必须过滤可见性(`offsetHeight > 0`),勿把残留文案当实时状态(2026-09-17 踩过:据此误判账号被平台拦截,实际账号正常并成功发表含原创声明)。同理,判断原创声明是否生效以草稿箱/发表记录为准,勿只看侧栏文案 |
|
|
440
|
+
| 需要核实草稿是否落盘 | 后台 ajax:`/cgi-bin/appmsg?action=list_ex&type=77&orderby=create_time&token=<token>&f=json&begin=0&count=5`(在 mp.weixin.qq.com 页面上下文执行;`cover`/`digest`/`update_time` 一目了然);发表记录:`/cgi-bin/appmsgpublish?sub=list&...&f=json`(`publish_page.publish_list[].publish_info` 里是 JSON 字符串,含 `content_url`) |
|
|
369
441
|
|
|
370
442
|
---
|
|
371
443
|
|