dsh-github-sync
把工作区里的 DSH 插件按「干净副本」推送到各自独立的 GitHub 仓库:/git 指令按需触发,平时不注入任何提示词,token 只存本地一次。
- Stars
- 1
- Language
- JavaScript
- Created
- Oct 5, 2026
- Updated
- Oct 6, 2026
Introduction
GitHub 同步插件 (@local/dsh-github-sync)
专为 DSH 插件开发者设计:把本地工作区里的插件作为干净副本推送到各自独立的 GitHub 仓库,并给新仓库打上 dsh-plugin topic。
核心设计与特性
-
绝对防误触 / 零日常污染(按需触发):
- 平时不注入任何系统提示词,完全不消耗日常聊天的 Context 和 token。
- 插件自身绝不自动上传,也没有任何后台轮询/保存即上传的钩子。
- 必须由人类在聊天框显式输入
/git(例如/git或/git 推送 sticker 插件)才会激活当轮的说明与工具权限。 - 强同步守卫(Tool Guard):即使 AI 产生了幻觉试图私自调用
github_sync_push,若该轮未收到人类/git授权,调用会被底层同步拦截拒绝。这条限制用实测验证过:未授权的会话里调用会直接收到拒绝理由。
-
凭据安全(Token 只存一次,且不可能被推上去):
- GitHub Token 只放在机器级用户目录:
%USERPROFILE%\.dsh\github-sync\config.json。 - 该文件不在任何工作区/仓库里,绝不进 git,所有 DSH 会话和 profile 自动复用。
- 不要把 token 写进插件目录里的
sync.config.json:那个文件在干净副本范围内,等于把密钥放到待上传目录。若真写了,插件会:① 在状态里告警;② 由密钥扫描把含密钥的文件从干净副本中剔除并列为阻断项,绝不推上去。 - 推送时通过临时生成的**凭据助手(Credential Helper)**把 token 交给 git,命令行参数、remote URL、报错信息里都不会出现明文(报错统一脱敏)。
- 密钥扫描覆盖:GitHub 经典/细粒度 token、Slack token、AWS Access Key、
sk-风格密钥、Google API key、私钥文件头。文档里的占位符(如ghp_xxxx)不会误报。
- GitHub Token 只放在机器级用户目录:
-
干净副本(Clean Copy):
- 自动排除运行时与非必要文件:
.git/、node_modules/、cache/、dist/、build/、coverage/、*.status.json、*.local.json、.env*、_scratch/、_research/等。 - 符号链接不跟随(避免把仓库外的内容复制进去)。
- 插件若自带
.gitignore或配置了include白名单,一律尊重(include白名单也不会绕过密钥扫描)。 - 每次提交的内容都精确等于干净副本:发布时会先把镜像工作树清空、再按干净副本写入,因此不只是"只加不删"——先前误提交过的缓存/状态文件,会在下一次提交里作为删除出现在历史中(可追溯,不是静默改写)。
- 单文件大小沿用 GitHub 自己的数字,不自定:默认上限
maxFileBytes = 100 MiB,正是 GitHub 的硬上限(官方原文:GitHub blocks files larger than 100 MiB);超过50 MiB按 GitHub 的规则给警告但仍可推(官方原文:you will receive a warning from Git)。超过上限是阻断项而非静默漏掉——静默丢文件会发布出一个悄悄坏掉的插件。密钥扫描的范围与上限一致,大文本文件无法靠体积绕过扫描。
- 自动排除运行时与非必要文件:
-
提交署名归你(不是工具的假身份):
- 提交的作者署名默认按 token 所属账号派生,邮箱用 GitHub 的 noreply 形式
<ID>+<用户名>@users.noreply.github.com:既能关联到你的头像,又不公开真实邮箱。 - 想用自己的邮箱就配
git.userEmail(git.userName可选)。前提是该邮箱已在 GitHub 账号里验证过:GitHub 靠邮箱归属提交,没验证过的邮箱会让提交显示成一个没有头像、点不开的陌生人。 - 注意 git 的性质:写进提交的邮箱是公开的。不想公开就用默认的 noreply 形式。
- 提交的作者署名默认按 token 所属账号派生,邮箱用 GitHub 的 noreply 形式
-
标准 git 流程:增量提交、真实历史、普通推送:
- 每个插件一个常驻本地镜像仓库(clone),存放在
%USERPROFILE%\.dsh\github-sync\mirrors\<仓库名>,.git保留 → 历史正常累积。 - 每次发布 = 把干净副本同步进镜像 → 一个真实提交 → 普通
git push(不强制)。git log/git blame/git revert全部可用。 - 推送前先
fetch并以远端 tip 为基准提交,所以别人在 GitHub 网页上的修改、或另一台克隆推上来的提交,都会被保留,你的提交落在它们上面(而不是把它们抹掉)。 - 无变更不提交:干净副本与远端一致时不产生空提交,结果为
unchanged。 - 推送被拒绝就如实报错,绝不偷偷强推。拒绝信息会区分「远端有本地没有的提交(竞态,重跑即可)」和「分支受保护(要去改仓库设置)」两种情况。
force保留为显式选项(原本的快照语义):生成一个孤立根提交(干净副本)并强推,远端历史被替换。用途是"这个提交必须从历史里消失"(比如误提交了密钥),不是日常发布。- 镜像坏了/想重新对齐远端:删掉该镜像目录即可,下次会自动重建。
- 每个插件一个常驻本地镜像仓库(clone),存放在
-
开源协议(按需生成,不替你选):
- 插件目录里已有
LICENSE/COPYING等文件时直接沿用。 - 缺失且配置了
github.license时,从 GitHub 官方接口GET /licenses/{key}取权威正文(不自己编),替换版权行后作为LICENSE写进同一个提交。正文会缓存到licensesDir,之后不再依赖网络。 - 未配置时只告警:选协议是法律决定,不该由工具替你定。
- 占位符按各协议实际形态替换(MIT/ISC/BSD 用
[year]/[fullname],Apache-2.0 用[yyyy],MPL-2.0 无占位符则原样保留)。
- 插件目录里已有
-
自适应:先判定"这是什么",再套对应规则:
-
工具会先判断目标是不是 DSH 插件,依据是
package.json的dsh.bundle/dsh.client,或关键词含dsh(cordis.patch.yml同样算),并把这个判定和它的依据报出来(状态、试运行、/git说明里都写),所以判断是可见、可反驳的,不靠猜。 -
不是所有目录都套同一条规则:
dsh-前缀、dsh-plugintopic 只对判定为 DSH 插件的目标生效;普通项目不套用,也不会被打上 DSH 的标签。 -
工作区扫描只认带 DSH 指纹的目录——这是刻意的:否则它会把你的笔记、图片堆、
node_modules一起当成待发布物。 -
扫描结果 ≠ 可发布范围(重要)。扫描器只回答"工作区里有什么看起来像 DSH 插件",它不是权限的来源——权限来自你(或正在那个项目里工作的会话)的要求。所以
github_sync_plan/github_sync_push的plugins参数也可以直接写目录路径(相对或绝对),扫描没认出来的目录照样能发布。详见下一条。
-
-
按路径发布:任何目录都能推:
plugins参数接受三种写法,按顺序解析:插件名(仓库名 / 目录名 / 包名)→ 目录路径 → 都失败才报错。- 所以
/git 推送 my-app(工作区里同名目录)和/git 推送 ../my-app(工作区外)都能用。适合自己的普通项目、别的宿主的插件,或任何你不想为了发布而改目录名/加 DSH 标记的东西。 - 发布流程完全一样:干净副本(排除
node_modules、cache、.env、*.status.json…)、密钥扫描、单文件上限、真实提交与普通推送。唯一的差别是不套 DSH 的命名约定,也不打dsh-plugintopic(除非你显式要求)。 - 计划里会明确标注「显式给出的路径,不在扫描结果里」,并且如果这个目录正好是扫描根目录本身,会额外提醒"会把该目录下所有内容当成一个仓库"。
- 每个目标的仓库名默认取目录名;想改就在配置里写
plugins(键可用请求时的原字符串 / 绝对路径 / 目录名任一):"plugins": { "my-app": { "repo": "my-app-gh", "visibility": "public" } }。 - 唯一会拒绝的路径是与 DSH 自己的目录(
~/.dsh,存着 token、会话记录、发布镜像)相重叠的目录——例如~、~/.dsh、~/.dsh/profiles。这是为了保护密钥,不是约定。
-
命名约定:
dsh-前缀(建议,不是硬墙):- DSH 插件的文件夹建议带
dsh-前缀,仓库名默认就等于那个文件夹名(文件夹 = 插件在磁盘上的身份)。所以dsh-sticker/→ 仓库dsh-sticker。 - 不合规时默认只警告(
naming: "warn"):仓库名会照文件夹名走,在 dsh-plugin 汇总里不太像同类——这是外观问题,不是数据问题,所以不值得拒绝发布。警告里会给出确切的改名建议(例如nova-preset→dsh-nova-preset)以及改名的代价。 - 想恢复硬拦就设
naming: "block";naming: "off"则完全不检查。 - 任何情况下都不会静默改名——只报告,由你决定。
- 包名(
package.json的name)与文件夹名不一致不影响判定:规则只认文件夹。 - 有意的例外:显式写
plugins.<目录名>.repo即按你的值走,不再判定合规。 - 注意:改文件夹名会打断 profile 里按路径建立的
link:依赖,改完需要在插件管理里重新安装/链接。
- DSH 插件的文件夹建议带
-
联合投稿(GitHub topics):
- 默认给判定为 DSH 插件的仓库打上
dsh-plugintopic,全部汇总到 https://github.com/topics/dsh-plugin —— 这就是"联合投稿"的落地方式,不需要任何中心化注册表。 - 改
github.topics可加自己的标签;写[]表示不打标签;单个插件可用plugins.<名>.topics覆盖。 - topics 页只收录公开仓库:私有仓库打了标签也不会出现在那里。要真正"被联合投稿收录",仓库得是
public。 - topics 在代码推送成功之后才设置,所以即使打标签失败也不会影响代码上传。
快速配置使用
1. 填写 GitHub 账号与 Token(只需一次)
编辑用户配置文件(不在任何仓库里):
%USERPROFILE%\.dsh\github-sync\config.json
(插件首次加载会自动生成带注释的模板,所以通常直接打开改就行。)
{
"github": {
"owner": "你的GitHub用户名",
"token": "ghp_xxxx 或 github_pat_xxxx",
"topics": ["dsh-plugin"],
"license": "MIT",
"copyright": "你的署名或组织名"
}
}
owner 也可以写在插件目录的 sync.config.json 里;用户文件里的空字符串不会覆盖插件目录里已填的值(留空即"未指定",要清空请写 null)。
github.license 留空表示不自动生成(只在状态里告警);github.copyright 留空则用 github.owner。
想让提交署自己的名字/邮箱,再加一段 "git": { "userName": "你的名字", "userEmail": "你的邮箱" };该邮箱要先在 GitHub 账号里验证过才会关联到你的头像。留空则自动用账号的 noreply 邮箱(可关联、且不公开真实邮箱)。
Token 权限要求(依据 GitHub REST 官方文档核实)
经典 Token(Classic)— 只勾
repo就够:
repo已包含public_repo,所以勾了它就同时覆盖公开仓库与私有仓库。POST /user/repos(在自己账号下建仓)官方原文:need thepublic_repoorreposcope to create a public repository, andreposcope to create a private repository. →repo一个就够。POST /orgs/{org}/repos(在组织下建仓)官方原文同上,但额外要求:The authenticated user must be a member of the organization.repo同时给到代码读写,所以推送也用它。- 勾
repo时 UI 会自动带上repo:status、repo_deployment、public_repo、repo:invite,无害,不用管。security_events、delete_repo、write:packages都不需要。- 唯一例外:若某插件目录含
.github/workflows/*.yml,需要额外勾workflow(授予"添加和更新 Actions 工作流文件")。首次推送到空仓库时不存在"同名同内容的旧分支",那条豁免不成立。细粒度 Token(Fine-grained):
- 推送代码:
Contents: Read and write。- 自动新建仓库:
Administration: write或Repository creation: write(官方为"满足其一")。- 打 topics:
Administration: write。- 组织启用了 SAML SSO 时,token 需单独授权给该组织,否则 403。
权限不足时不用猜:报错会直接打出 HTTP 状态码与对应提示(401 认证失败 / 403 权限不足 / 404 看不到)。分支保护导致推送被拒时会明确说是分支保护,而不是伪装成别的错误。
2. 在会话中触发(两阶段)
/git 本身只是"去看看",不等于"必须推送":
/git → 调查轮:只读。推送工具会被拒绝
/git 看看 sticker 能不能推 → 调查轮(没有推送意图)
/git 推送 dsh-sticker → 推送轮:这一轮允许上传
/git 先别推送,只看看 → 调查轮(明说"别推"会覆盖掉"推送")
所以典型流程是:先 /git 让我把事实查清并报告,你决定后再 /git 推送 …。这样"看看会推什么"不会被迫变成一次上传,而上传依然只可能发生在你明确要求的当轮——插件永远不会自己上传。
3. 命令与工具说明
/git [要求]:注入当轮说明。带推送意图(推送/上传/发布/同步/push…)才是推送轮,否则是只读的调查轮。github_sync_status:只读查看配置、token 来源、git 可用性、目标清单与判定结果(哪些是 DSH 插件、哪些不是)、协议与镜像位置(只读,不联网)。github_sync_plan:试运行(Dry Run),列出将提交/排除的文件与原因、会打的 topic、协议是沿用还是生成。verbose可看全部排除项(只读,不提交也不推送)。github_sync_push:真正的提交与推送。默认增量提交 + 普通 push;force: true才会用单次快照替换远端历史。仓库不存在时按visibility自动创建,随后打 topics。仅在被/git授权的那一轮可用。
常见的几件运维事
| 想做的事 | 怎么做 |
|---|---|
| 只想看会发生什么 | github_sync_plan(或 github_sync_push 加 dryRun: true),都不提交、不推送 |
| 重跑一次被拒的推送 | 直接再推一次:会先 fetch 远端再提交,通常就正常了 |
| 某个提交必须从历史里消失 | github_sync_push 带 force: true(远端历史被替换成单次快照) |
| 镜像状态坏了 | 删掉 %USERPROFILE%\.dsh\github-sync\mirrors\<仓库名>,下次自动重建 |
| 某个插件不想带协议 | 该插件目录放自己的 LICENSE(会被沿用),或给该插件设 license: "" |
改代码之后:模块缓存(实测结论)
DSH 的 Loader 按模块 URL 缓存,并且不看内容与修改时间:文件内容改掉之后,运行中的进程仍然复用旧模块。实测过两次,结论是:
- 只改入口文件名不够。 单独把入口改名、模块目录原样不动,宿主依旧跑旧逻辑 —— 因为被 import 的模块 URL 没变。
- 每个改过内容的文件都必须换 URL。 要么重启 DSH(最省心,推荐),要么把改动的文件/目录一起改名,再重新 enable 一次 bundle。
cordis.patch.yml里的name指向入口文件,入口改名时同步更新;package.json的exports/files也要跟着改。
实际操作最省事的流程:改完代码 → 重启 DSH。只有在不能重启时才用改名法。
目录结构
main.mjs 入口:系统提示词闸门 + /git 指令 + 三个工具 + 守卫
core/config.js 配置合并、凭据定位、命名与 topic 规范化、告警
core/discover.js 插件发现 + dsh- 前缀约定(文件夹即仓库名)
core/manifest.js 干净副本清单 + 密钥扫描 + glob/gitignore 匹配
core/license.js 协议检测与生成(正文取自 GitHub 官方接口并缓存)
core/git.js 常驻镜像、增量提交、普通/强制推送、凭据助手
core/github.js REST:建仓、查仓库、打 topics
core/sync.js 计划 / 执行编排(命名阻断、协议、topics、结果汇总)
core/prompt.js /git 闸门(按会话、按轮次授权)与工具守卫
sync.config.json 插件自带配置(只放通用默认值)
sync.status.json 运行期诊断(自动生成,永不推送)
(src/、lib/、internal/ 与 index.js、host.mjs、plugin.mjs 都是同一份代码的早期名字:因为上面的模块缓存机制,每次改动都得换 URL,所以历史名被留在了旧代次里。今后请优先重启 DSH,别靠改名。)
发布约定
这个插件自己也遵守它检查别人的那两条约定(/git 的说明里会声明一次):
- 有改动就升
package.json的version。文件变了而版本没变,插件会在推送结果里警告。 - README 末尾保留本节,按版本倒序,每条含版本号与年月日时分。升了版本但 README 里搜不到该版本号,插件同样会警告。
📌 版本历史
v0.4.0(2026-10-07 00:05)
- 按路径发布:扫描结果不再是可发布范围的上限。
github_sync_plan/github_sync_push的plugins参数现在也接受目录路径(相对或绝对),解析顺序是「插件名 → 目录路径 → 报错」。原来只认扫描结果的写法把发现当成了授权,而那两件事毫无关系:扫描器只是便利设施(列一下工作区里有什么),发布权限来自人的要求。 - 于是自己的普通项目、别的宿主的插件、任何不想为发布而改名或加 DSH 标记的目录都可以直接推;会话本身就在该项目开发环境里时,直接给出目录即可。
- 显式目标不套 DSH 命名约定、不打
dsh-plugintopic;其余流程完全一致(干净副本、密钥扫描、单文件上限、真实提交与普通推送)。仓库名默认取目录名,可在plugins里用「请求原字符串 / 绝对路径 / 目录名」任一键覆盖。 - 新增一条安全拒绝(不是约定):与 DSH 自己的目录(
~/.dsh,含 token、会话记录、发布镜像)相重叠的路径一律拒绝,避免误把凭据推上去。目录恰好是扫描根目录本身时会额外提醒。 - 修掉两个实现缺陷:①"没有匹配的插件"曾在解析显式路径之前就发出,导致同一个计划里既有正常条目又有假报错;②读取
package.json复用了扫描器的函数(无 DSH 标记即返回 undefined),导致普通项目的名字与版本读不到(显示0.0.0)。现在"读清单"与"判定是不是插件"是两件事。 /git说明新增一节「范围不等于扫描结果」。
v0.3.0(2026-10-06 18:40)
/git改成两阶段:/git本身只进入调查轮(只读),推送工具会被拒绝并说明"要上传请再发一条带推送意图的/git";只有带推送意图(推送 / 上传 / 发布 / 同步 / push…)才是推送轮。这样"看看会推什么"不再被迫变成一次上传,而上传依然只可能发生在人明确要求的当轮。明说"先别推送/只看"会覆盖掉句中的"推送"。- 判定代替一律套用:工具先判定目标是不是 DSH 插件(依据
package.json的dsh.bundle/dsh.client,或关键词含dsh),并把判定结果与依据报在状态、试运行与/git说明里;dsh-前缀与dsh-plugintopic 只对 DSH 插件生效,普通项目不套用、不打 DSH 标签。 dsh-前缀从硬拦改为默认警告:新增naming: "warn" | "block" | "off",默认warn(仍可发布,警告里给出确切改名建议与改名代价);要恢复原来的硬拦设block。任何情况下都不会静默改名。/git的说明里新增一节讲清"先判定再套规则",并明确:硬性要求只有两条——不要静默改名,不要静默替人决定协议。- 顺带清理:
core/discover.js末尾多余的export default(同一类unwrapExports坑)。
v0.2.2(2026-10-06 17:20)
- 让"消息工厂"的解析变成可失败但不致命:v0.2.1 改成静态
import { createUserMessage } from '@deepseek-ai/dsh-llm',但实测该裸模块名从插件目录解析不到(宿主的包在app.asar内的/dsh/node_modules/,插件在外部,Node 只会从插件目录往上找node_modules)。静态 import 解析失败会让整个 bundle 加载失败——/git闸门与三个工具会一起消失,比原 bug 更严重。 - 现在是加载期后台尝试 + 失败容忍:能解析就用宿主官方工厂,解析不到就用语义等价的本地实现(
deepFreeze(structuredClone({ ...input, role: 'user', id: randomUUID() })),与宿主createMessage逐字对应)。两条路径产出的消息都带非空id,都不会破坏会话回放。 - 新增测试
ghsync-steer-test.mjs:驱动真实 bundle 与真实apply(),断言真正进入收件箱的消息具备id/role/source/content且被冻结、两次注入 id 不同。故意不桩掉@deepseek-ai/dsh-llm——"这个包能否解析"正是必须不影响结果的那件事。 - 顺带记录一个宿主自身的坑:
dsh-agent/README.md里steer()的官方示例只写了content与source(没有id),而steer(input)的实现是原样inbox.splice(input)、不补 id,所以照文档写出来的插件会写出回放时校验失败的会话事件。宿主自己的调用方(dsh-api-session-controller)都是先用createUserMessage造消息再steer,照它做才对。 - 补齐自动生成的用户配置模板:模板原先只有
github.owner/token/topics,新装的人根本不知道还能配提交署名和开源协议。现在模板同时列出git.userName/git.userEmail与github.license/github.copyright,并注明"留空就按账号派生 noreply 邮箱(可关联头像、不公开真实邮箱)""自填邮箱需先在 GitHub 账号里验证过"。默认值仍安全留白:协议留空 = 不替你选协议,只提醒。 - 顺带清理:
core/prompt.js、core/config.js末尾多余的export default(unwrapExports的exports.default ?? exports会因此把具名导出整片遮掉,属同一类坑)。
v0.2.1(2026-10-06 12:36)
- 修复
/git注入的消息缺id,导致会话历史加载失败:/git之前用裸对象{ role, content, source }调agent.steer(),而会话日志按原样记录user/message,缺少id的事件在重启回放时被校验器拒绝——报session event at seq N lacks an identified message,整个会话的历史加载都会失败(不只是/git那一轮)。 - 现在改用 harness 工厂
createUserMessage(来自@deepseek-ai/dsh-llm)构造消息,由它生成并冻结id,与其余用户消息一致。 - 仅影响
/git注入的轮次;已写坏的历史事件需要单独修补,插件本身无法回改。
v0.2.0(2026-10-06 11:31)
- 改用标准 git 流程:每个插件一个常驻本地镜像(
%USERPROFILE%\.dsh\github-sync\mirrors\<仓库名>),每次发布是增量提交并普通推送,历史累积、可用git log/blame/revert;无变更不产生空提交。 - 推送前先
fetch并以远端 tip 为基准提交,所以别人在网页或别处推上来的提交会被保留,不会被覆盖。 - 强推保留为显式选项
force:生成孤立根提交(干净副本)替换远端历史,用于"某个提交必须消失"(例如误提交密钥),不是日常发布。 - 新增开源协议支持:插件已有
LICENSE则沿用;缺失且配置了github.license时从 GitHub 官方接口取正文(替换版权行、缓存到本地)写进同一提交;未配置只告警——选协议是法律决定,不替用户定。 - 提交署名归账号:默认按 token 所属账号派生,用
<ID>+<用户名>@users.noreply.github.com,可关联头像且不公开真实邮箱;可用git.userName/git.userEmail覆盖。 - 单文件上限对齐 GitHub 官方数字:默认
maxFileBytes = 100 MiB(官方硬上限),超过 50 MiB 按官方规则警告但可推;超限是阻断项而非静默漏掉。密钥扫描范围与上限一致。 - 推送被拒时区分竞态(重跑即可)与分支保护(需去仓库设置改),不再笼统报错。
- 提交内容严格等于干净副本:干净副本里没有的文件会在下次提交中作为删除出现在历史里。
- 新增上述两条发布约定检查(有改动没升版本 / 升了版本 README 没记)。
v0.1.0(2026-10-05 16:16)
- 首次发布。把插件作为干净副本推送到独立 GitHub 仓库,单次快照强制推送。
/git单轮授权闸门:平时零注入,插件绝不自动上传,未授权轮的推送工具调用会被硬拦截。- token 只存机器级用户目录,经临时凭据助手交给 git,不进命令行、URL 或报错文本;发布前做密钥扫描。
dsh-前缀命名约定(文件夹即仓库名),不合规列为阻断项并给出确切改名建议。- 默认给仓库打
dsh-plugintopic(联合投稿汇总页),在代码推送成功后设置。