From 53fb097a64ad9b37664fd7eb647ecbbc1f8808b3 Mon Sep 17 00:00:00 2001 From: DSH Publisher Date: Tue, 13 Oct 2026 16:08:08 +0800 Subject: [PATCH] =?UTF-8?q?chore(dsh):=20=E5=8F=91=E5=B8=83=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=20'gitea'=20=EF=BC=88DeepSeek=20Harness=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=202026-10-13=2008:08=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gitea-publisher/README.md | 117 +++++++ gitea-publisher/client.js | 360 ++++++++++++++++++++++ gitea-publisher/cordis.patch.yml | 18 ++ gitea-publisher/index.js | 502 +++++++++++++++++++++++++++++++ gitea-publisher/package.json | 26 ++ test/e2e-client.mjs | 172 +++++++++++ test/e2e-host.mjs | 367 ++++++++++++++++++++++ test/verify-real.mjs | 241 +++++++++++++++ 8 files changed, 1803 insertions(+) create mode 100644 gitea-publisher/README.md create mode 100644 gitea-publisher/client.js create mode 100644 gitea-publisher/cordis.patch.yml create mode 100644 gitea-publisher/index.js create mode 100644 gitea-publisher/package.json create mode 100644 test/e2e-client.mjs create mode 100644 test/e2e-host.mjs create mode 100644 test/verify-real.mjs diff --git a/gitea-publisher/README.md b/gitea-publisher/README.md new file mode 100644 index 0000000..3fd474f --- /dev/null +++ b/gitea-publisher/README.md @@ -0,0 +1,117 @@ +# @local/gitea-publisher + +DeepSeek Harness 插件:在**会话顶栏**加一个「Gitea」按钮,把当前会话工作区的 +最终项目源码做成一次快照提交,push 到本地部署的 Gitea。 +**一个工作区对应一个独立仓库**:不同工作区各推各的仓库,互不混库。 + +## 工作方式 + +- 宿主半边(`index.js`):`giteaPublish` 服务,导出 `status` / `publish` 两个 Remote 方法 + (typert SRC 回退模式,零依赖,只用 node: 内置模块 + 系统 `git`)。 +- 浏览器半边(`client.js`):注册进 `conversation.session.header.actions` 槽位, + 用 `ctx.remote.$mount` 挂载描述符;点按钮 → 宿主执行快照并推送。 +- **一个工作区一个仓库**:目标仓库名按工作区目录 basename 派生(非法字符换 `-`,截 60 字符); + 目录名已被别的工作区绑定、或在 Gitea 上已存在(不许悄悄复用未知仓库)时,自动加 + `-<工作区路径sha1前8位>` 后缀区分。**首次发布成功后**才写入绑定表 + `~/.dsh/gitea-publisher-map.json`(原子写;推失败不留脏绑定),此后该工作区永远推绑定到的仓库, + 改名重绑用面板请求里的 `repo` 字段(也持久进绑定表)。`repoFromWorkspace: false` + 退回旧行为(所有会话共用配置里的固定 `repo`)。 +- 发布流程:`GET /user` 验证凭证 → 仓库不存在时自动建仓(owner 是组织就走 + `POST /orgs/{owner}/repos`,个人走 `POST /user/repos`;可见性由 `private` 控制, + **默认 false=完全公开**)→ + 工作区复制到临时目录(跳过 `.git`/`node_modules`/`dist` 等垃圾目录与超限大文件, + **`.gitignore` 在 `git add` 阶段自然生效**)→ `git init` → + **先 fetch 远端目标分支并基于它提交**(线性历史、永远可快进;内容没变就不产生新提交, + 返回 `unchanged: true` 幂等成功)→ `git push`(凭证走 URL 内嵌 basic auth, + 对外报错文本会抹掉 token 及其 URL 转义形态)。 +- `force: true` 改变语义:跳过 fetch,直接推「单提交快照历史」并 `--force` 覆盖远端分支。 +- 认证两种模式:`username` + `token(=密码)` 走 HTTP Basic(适配没开放令牌 API 的实例); + `username` 留空则按 `token ` 头。 +- 按钮弹层显示目标仓库、发布进度、成功(commit/文件数/新建或无变更标注/仓库链接)或失败原因。 + +## 配置 + +写在 profile 的 `~/.dsh/profiles/web/cordis.patch.yml`(gitea-publisher 行的 +`config.gitea`;bundle 自带的 `cordis.patch.yml` 只是默认值副本)。本机已接入: + +```yaml +config: + gitea: + baseUrl: "http://10.0.0.131:3000" # 不带结尾斜杠 + username: "dsh" # 填了就走 Basic 认证(账号密码) + token: "<账号密码或个人访问令牌>" # 该实例未开放令牌 API,用账号密码 + owner: "XH" # 组织 XH:自动建仓建到组织下(个人用户名亦可) + branch: "main" + createIfMissing: true + private: false # 自动创建时可见性:false=完全公开(默认) + force: false + maxFileBytes: 31457280 + # repoFromWorkspace: true # 默认开:一工作区一仓库;false = 旧的固定单仓库模式 + # repo: "xxx" # 仅固定模式使用;per-workspace 模式下忽略 + # mapFile: "~/.dsh/gitea-publisher-map.json" # 工作区→仓库 绑定表位置 +``` + +> 注意:密码明文存在 profile 配置里,这台机器上等价于「本机可读」,介意的话 +> 在 Gitea 侧给 `dsh` 单独建低权限账号或恢复令牌 API 后换 PAT。 + +## 本机安装与热更新(实测结论) + +这台机器 PATH 上没有 pnpm,手工接线: + +1. `~/.dsh/profiles/web/node_modules/@local/gitea-publisher/` 是一个 **dev-link 中继目录**: + `index.js` 是入口中继(每次 apply 带一次性 query 重新 import 工作区真实代码,绕开 + ESM 模块缓存;`.heartbeat.json` 为可观测证据),`client.js` 是指向工作区的符号链接, + `package.json` 是内容拷贝(若改了包 `dsh.*` 字段需重新 `cp`)。 +2. profile `cordis.patch.yml` 末尾的 `- insert:` 行(id `gitea-publisher`)。 +3. **profile 装载/配置变更需要重启 dsh-web 才在宿主生效**(实测:这台机器的 systemd + 单元用裸 `node` 启动、没有 `--expose-internals`,dsh-hmr 起不来 → 没有任何 + patch/模块文件监视;此前「新行热挂上」的现象只是 Web 服务按请求重扫 client 模块清单, + 宿主侧并没有装载)。浏览器端刷新页面后才能看到按钮。 + 可选改进:给 unit 的 ExecStart 换成 `node --expose-internals /usr/local/bin/dsh web …` + 并重启一次,之后 HMR 链路(改代码热重载)就能用。 + +## 真机验证(2026-10-13,`test/verify-real.mjs` 全绿,每工作区模式) + +对真实实例 `http://10.0.0.131:3000`(账号密码 Basic 认证)用**临时合成工作区**跑通 +(绑定表放隔离临时文件,测试仓库结尾自动删除): + +- status 配置校验 / API 可达(apiUser=dsh)、`repoPolicy=per-workspace`、未绑定工作区预测派生名 +- **提交**:按目录名自动在组织 **XH** 下建公开仓库(如 `XH/dsh-verify-ws`)+ 推送(commit/files 回执正常) +- **独立性**:换另一个工作区目录 → 另建 `XH/dsh-verify-other`,两仓库互不共享;重发命中已绑定仓库(`repoSource=bound`) +- (历史)旧固定模式曾用本项目工作区建 `XH/gitea-publisher`,验证条目同上;切换后本工作区首次点击会改推 `XH/gitea` +- **归属与可见性**:API 匿名可读、`private=false`、owner=XH,匿名 git 拉取 200 +- **拉取**:clone 回本地,文件树、HEAD 与回执一致 +- **归档**:`GET /api/v1/repos/XH/gitea-publisher/archive/main.zip` 下载校验,zip 内容有效 +- 同内容重发 → `unchanged: true` 幂等;内容变化重发 → 基于远端线性提交可快进 +- `force` 重发 → 单提交历史覆盖,远端 fetch 到预期 HEAD +- 全程返回文本无凭证泄漏(含 URL 百分号转义形态) + +另有离线套件:`test/e2e-host.mjs`(假 Gitea + 真 git http-backend 全链路)、 +`test/e2e-client.mjs`(描述符校验/字典完备性/组件渲染)。 + +## 客户端接线的三个坑(真实浏览器里踩过,全是静默故障) + +1. **动态 `$mount` 的命名空间不能静态 inject**。`remote.giteaPublish` 是本包 apply 里 + `$mount` 之后才出现的,把它写进 `exports.inject` 会让 fiber 永远等待自己提供 → 整个 + 客户端 boot 报 `pending (waiting for service: remote.giteaPublish)`(官方 typed client + 能静态注入是因为挂载方是 boot 期的 dsh-api-remotes,不是同一个包)。 +2. **`ctx.remote.` 属性糖受 inject 守卫**,动态挂载后直接取属性会抛 + `cannot get property "remote.giteaPublish" without inject`;官方同款解法是 + `ctx.get('remote.giteaPublish')`(见 validateContribution 自身)。 +3. **命名空间方法的返回值带 `{ok, value}` RPC 信封**(src-json 的宿主返回值在 `value` + 里)。不解包的话 `status.configured` 恒为 undefined——面板渲染成"目标"标签下空白、 + 发布按钮消失的半残状态,且不报任何错。解包在 inject 闭包里做:信封 `ok:false` + 是协议层失败(撤回/断连),宿主业务失败在 `value.ok=false`。 + +UI 侧兜底:status 加载失败/20 秒超时都会显示错误文本 + 重试按钮,不再有无限 +"正在检查配置…"。宿主重启会让旧页面的连接失效,**重启 dsh-web 后浏览器必须刷新一次**。 + +## 已知边界 + +- 按钮推的是**当前会话**的工作区(lookup 由 sessionId 解析出 workspaceRoot)。 +- 每工作区仓库的判定发生在**每次 status/publish 时**:绑定表被手工删掉后,若派生名对应仓库 + 已在远端存在,会被视为「占用」而加后缀,不会重新认领旧名(防止两个工作区抢同一仓库)。 +- 工作区目录改名 = 换仓库(绑定按工作区路径记);换机器/换路径同理。 +- `force: true` 会用单提交快照覆盖远端分支历史,确认后再开。 +- 会话工作区里若已有 `.git`,其历史不会被带进快照(只发源码现状)。 +- 大文件阈值 `maxFileBytes` 之外的文件跳过并在结果里报告数量。 diff --git a/gitea-publisher/client.js b/gitea-publisher/client.js new file mode 100644 index 0000000..6b31215 --- /dev/null +++ b/gitea-publisher/client.js @@ -0,0 +1,360 @@ +// @local/gitea-publisher — 浏览器半边。 +// 在会话顶栏动作区(conversation.session.header.actions)放一个「发布到 Gitea」按钮: +// - 挂载时用 ctx.remote.$mount 注册 typert 描述符(参数 codec 只需通过严格性校验, +// create() 惰性、上线路径永不调用,所以给哑工厂即可;result 用 src-json,宿主返回值原样到达)。 +// - publish 的第一个位置参数是 sessionId —— 它对应 lookup 参数(wire: workspaceFileScopeId), +// 宿主端由 workspaceFiles 的 lookup 解析成 { sessionId, workspaceRoot }。 +window.__ModuleLoader__.load({ + id: '@local/gitea-publisher', + factory(require) { + const React = require('react'); + + const css = [ + '.gpub_root{position:relative;display:inline-flex}', + '.gpub_trigger{border-radius:var(--dsw-radius-sm);min-height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:0;align-items:center;gap:5px;padding:3px 6px;font-size:12px;line-height:18px;display:inline-flex}', + '.gpub_trigger:hover,.gpub_trigger:focus-visible{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-l1)}', + '.gpub_trigger:disabled{cursor:default;opacity:.7}', + '.gpub_dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-state-error-primary)}', + '.gpub_dotOk{background:var(--dsw-alias-state-success-primary, var(--dsw-alias-label-quaternary))}', + '.gpub_panel{box-sizing:border-box;position:absolute;top:calc(100% + 6px);right:0;z-index:120;width:340px;max-width:min(340px,calc(100vw - 24px));border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);box-shadow:var(--dsw-elevation-prominent);border:0;padding:10px 12px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}', + '.gpub_row{display:flex;flex-direction:column;gap:2px;min-width:0}', + '.gpub_label{color:var(--dsw-alias-label-tertiary);font-size:11px}', + '.gpub_value{font-family:var(--dsw-font-mono);word-break:break-all}', + '.gpub_error{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto;margin:0}', + '.gpub_ok{color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:2px;min-width:0}', + '.gpub_link{color:var(--dsw-alias-label-secondary);text-underline-offset:2px}', + '.gpub_actions{display:flex;gap:6px;margin-top:2px}', + '.gpub_btn{border-radius:var(--dsw-radius-sm);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-specific-menu);color:var(--dsw-alias-label-secondary);cursor:pointer;padding:3px 10px;font-size:12px;line-height:18px}', + '.gpub_btn:hover,.gpub_btn:focus-visible{background:var(--dsw-alias-fill-l1);color:var(--dsw-alias-label-primary)}', + '.gpub_btnPrimary{border-color:var(--dsw-alias-border-accent, var(--dsw-alias-border-l2));color:var(--dsw-alias-label-primary)}', + '.gpub_spinner{animation:gpub-spin .9s linear infinite;transform-origin:center}', + '@keyframes gpub-spin{to{transform:rotate(360deg)}}', + ].join(''); + + const zh = { + 'label': 'Gitea', + 'title': '发布到 Gitea', + 'loading': '正在检查配置…', + 'notConfigured': 'Gitea 尚未配置', + 'missing': '缺少配置项:{keys}', + 'baseUrlInvalid': 'baseUrl 必须以 http:// 或 https:// 开头。', + 'branchInvalid': 'branch 名称不合法。', + 'configHint': '在 profile 的 cordis.patch.yml 中给 gitea-publisher 行的 config.gitea 填入 baseUrl / token / repo。', + 'target': '目标', + 'apiError': 'API 检查失败:{detail}', + 'gitMissing': '宿主上没有 git,可执行文件不可用,无法发布。', + 'publish': '发布', + 'republish': '再推一次', + 'close': '关闭', + 'publishing': '正在快照工作区并推送到 Gitea…', + 'success': '已推送到 {repo}', + 'createdNote': '(仓库为本次自动创建)', + 'publicRepo': '公开', + 'privateRepo': '私有', + 'commit': 'commit {sha} · {files} 个文件 → {branch}', + 'unchangedNote': '(内容未变化,远端已是最新)', + 'repoBound': '(已绑定本工作区)', + 'repoNew': '(未绑定:将按工作区目录名新建)', + 'repoCollision': '(目录名撞车:自动加后缀区分)', + 'repoPending': '(等待会话工作区解析)', + 'error.bad-repo-name': '仓库名不合法。', + 'skipped': '跳过 {count} 个超大文件', + 'errorHead': '发布失败', + 'error.not-configured': 'Gitea 尚未配置。', + 'error.busy': '上一次发布还在进行中。', + 'error.bad-branch': '分支名不合法。', + 'error.workspace-unavailable': '找不到这个会话的工作区目录。', + 'error.git-missing': '宿主机上没有可用的 git。', + 'error.git-init-failed': '临时 git 仓库初始化失败。', + 'error.git-add-failed': 'git add 失败。', + 'error.git-commit-failed': 'git commit 失败。', + 'error.git-reset-failed': '对齐远端分支失败。', + 'error.fetch-failed': '拉取远端分支失败。', + 'error.no-files': '快照里没有可提交的文件(可能都被 .gitignore 排除了)。', + 'error.repo-missing': '目标仓库不存在,且未开启自动创建。', + 'error.repo-create-failed': '自动创建仓库失败。', + 'error.repo-check-failed': '查询目标仓库失败。', + 'error.api-unreachable': '连不上 Gitea API。', + 'error.api-error': 'Gitea API 返回异常。', + 'error.auth-failed': '令牌无效或无权限。', + 'error.push-rejected': '远端分支拒绝了这次推送。', + 'error.push-timeout': '推送超时。', + 'error.push-failed': 'git push 失败。', + 'error.cancelled': '已取消。', + 'error.internal': '插件内部错误。', + 'statusFailed': '连不上宿主插件服务。', + 'statusHint': '请刷新页面(Ctrl+Shift+R);若刚重启过 dsh-web,必须刷新一次。', + 'retry': '重试', + }; + const en = { + 'label': 'Gitea', + 'title': 'Publish to Gitea', + 'loading': 'Checking configuration…', + 'notConfigured': 'Gitea is not configured', + 'missing': 'Missing settings: {keys}', + 'baseUrlInvalid': 'baseUrl must start with http:// or https://.', + 'branchInvalid': 'Invalid branch name.', + 'configHint': 'Fill config.gitea (baseUrl / token / repo) on the gitea-publisher row in the profile cordis.patch.yml.', + 'target': 'Target', + 'apiError': 'API check failed: {detail}', + 'gitMissing': 'No usable git executable on the host.', + 'publish': 'Publish', + 'republish': 'Push again', + 'close': 'Close', + 'publishing': 'Snapshotting the workspace and pushing to Gitea…', + 'success': 'Pushed to {repo}', + 'createdNote': '(repository was created just now)', + 'publicRepo': 'public', + 'privateRepo': 'private', + 'commit': 'commit {sha} · {files} files → {branch}', + 'unchangedNote': '(no changes — remote is already up to date)', + 'repoBound': '(bound to this workspace)', + 'repoNew': '(unbound: will create a repo named after the workspace directory)', + 'repoCollision': '(directory-name collision: suffixed automatically)', + 'repoPending': '(waiting for workspace resolution)', + 'error.bad-repo-name': 'Invalid repository name.', + 'skipped': '{count} oversized files skipped', + 'errorHead': 'Publish failed', + 'error.not-configured': 'Gitea is not configured.', + 'error.busy': 'A publish is already running.', + 'error.bad-branch': 'Invalid branch name.', + 'error.workspace-unavailable': 'This session’s workspace directory is missing.', + 'error.git-missing': 'No usable git executable on the host.', + 'error.git-init-failed': 'Failed to initialize the temporary git repository.', + 'error.git-add-failed': 'git add failed.', + 'error.git-commit-failed': 'git commit failed.', + 'error.git-reset-failed': 'Failed to align with the remote branch.', + 'error.fetch-failed': 'Failed to fetch the remote branch.', + 'error.no-files': 'Nothing to commit in the snapshot (all files are .gitignore-d).', + 'error.repo-missing': 'The target repository does not exist and auto-create is off.', + 'error.repo-create-failed': 'Could not create the repository.', + 'error.repo-check-failed': 'Could not look up the target repository.', + 'error.api-unreachable': 'The Gitea API is unreachable.', + 'error.api-error': 'The Gitea API returned an error.', + 'error.auth-failed': 'Invalid token or missing permission.', + 'error.push-rejected': 'The remote branch rejected the push.', + 'error.push-timeout': 'The push timed out.', + 'error.push-failed': 'git push failed.', + 'error.cancelled': 'Cancelled.', + 'error.internal': 'Internal plugin error.', + 'statusFailed': 'Cannot reach the host plugin service.', + 'statusHint': 'Reload the page (Ctrl+Shift+R). If dsh-web was just restarted, a reload is required.', + 'retry': 'Retry', + }; + + const BranchIcon = ({ spin }) => React.createElement('svg', { + width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', + className: spin ? 'gpub_spinner' : undefined, 'aria-hidden': 'true', + }, spin + ? React.createElement('circle', { cx: 8, cy: 8, r: 6, stroke: 'currentColor', strokeWidth: 1.5, strokeDasharray: '26 12', strokeLinecap: 'round' }) + : React.createElement(React.Fragment, null, + React.createElement('path', { d: 'M5 3.5v7', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }), + React.createElement('circle', { cx: 5, cy: 12, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }), + React.createElement('circle', { cx: 5, cy: 3, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }), + React.createElement('circle', { cx: 11.5, cy: 4.5, r: 1.7, stroke: 'currentColor', strokeWidth: 1.3 }), + React.createElement('path', { d: 'M11.5 6.3c0 2.4-1.8 3.2-3.6 3.6', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }))); + + /** 顶栏入口:按钮 + 弹出面板(状态、发布结果、错误)。 */ + function PublishAction({ sessionId, t, getStatus, runPublish }) { + const [status, setStatus] = React.useState(null); + const [statusErr, setStatusErr] = React.useState(null); + const [reloadKey, setReloadKey] = React.useState(0); + const [phase, setPhase] = React.useState('idle'); + const [result, setResult] = React.useState(null); + const [open, setOpen] = React.useState(false); + const rootRef = React.useRef(null); + + React.useEffect(() => { + let alive = true; + setStatusErr(null); + // 超时兜底:连接半死时 RPC 可能永远 pending,宁可报“连不上”也别假加载。 + const deadline = new Promise((_resolve, reject) => { + const timer = setTimeout(() => reject(new Error('timeout (20s)')), 20000); + if (timer.unref) timer.unref(); + }); + Promise.race([Promise.resolve().then(() => getStatus(sessionId)), deadline]).then( + (value) => { if (alive) setStatus(value && typeof value === 'object' ? value : null); }, + (error) => { if (alive) { setStatus(null); setStatusErr(String(error?.message ?? error)); } }, + ); + return () => { alive = false; }; + }, [getStatus, reloadKey, sessionId]); + + React.useEffect(() => { + if (!open) return; + const onPointer = (event) => { + if (rootRef.current !== null && !rootRef.current.contains(event.target)) setOpen(false); + }; + document.addEventListener('pointerdown', onPointer); + return () => document.removeEventListener('pointerdown', onPointer); + }, [open]); + + const doPublish = () => { + setPhase('publishing'); + setResult(null); + Promise.resolve().then(() => runPublish(sessionId, {})).then( + (value) => { setPhase('idle'); setResult(value && typeof value === 'object' ? value : { ok: false, error: 'internal' }); }, + (error) => { setPhase('idle'); setResult({ ok: false, error: 'internal', detail: String(error?.message ?? error) }); }, + ); + }; + + const onClick = () => { + if (phase === 'publishing') return; + if (open) { setOpen(false); return; } + setOpen(true); + if (result === null && status !== null && status.configured === true && phase === 'idle') doPublish(); + }; + + const notConfigured = status !== null && status.configured === false; + const target = status !== null && status.configured === true + ? `${status.baseUrl}/${status.owner ?? ''}/${status.repo ?? '…'} @ ${status.branch}` + : null; + // 每工作区仓库模式下,给目标加一句状态注记(已绑定 / 将按目录名新建 / 撞名加后缀)。 + const repoNote = status !== null && status.configured === true && status.repoPolicy === 'per-workspace' + ? (status.repo === null ? t('repoPending') + : status.repoSource === 'bound' ? t('repoBound') + : status.repoSource === 'derived-collision' ? t('repoCollision') + : t('repoNew')) + : null; + + return React.createElement('span', { className: 'gpub_root', ref: rootRef }, + React.createElement('style', null, css), + React.createElement('button', { + type: 'button', + className: 'gpub_trigger', + title: t('title'), + onClick, + }, + React.createElement(BranchIcon, { spin: phase === 'publishing' }), + React.createElement('span', null, t('label')), + status !== null && (notConfigured ? React.createElement('span', { className: 'gpub_dot' }) : React.createElement('span', { className: 'gpub_dot gpub_dotOk' }))), + open ? React.createElement('div', { className: 'gpub_panel', role: 'dialog' }, + status === null && statusErr !== null + ? React.createElement(React.Fragment, null, + React.createElement('div', { className: 'gpub_row' }, React.createElement('strong', null, t('statusFailed'))), + React.createElement('div', { className: 'gpub_label' }, String(statusErr)), + React.createElement('div', { className: 'gpub_label' }, t('statusHint')), + React.createElement('div', { className: 'gpub_row' }, + React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setReloadKey((k) => k + 1) }, t('retry')), + React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setOpen(false) }, t('close')))) + : status === null + ? React.createElement('div', { className: 'gpub_label' }, t('loading')) + : notConfigured ? React.createElement(React.Fragment, null, + React.createElement('div', { className: 'gpub_row' }, + React.createElement('strong', null, t('notConfigured')), + Array.isArray(status.missing) && status.missing.length > 0 + ? React.createElement('div', { className: 'gpub_label' }, t('missing', { keys: status.missing.join(', ') })) + : null, + status.baseUrlInvalid === true ? React.createElement('div', { className: 'gpub_label' }, t('baseUrlInvalid')) : null, + status.branchInvalid === true ? React.createElement('div', { className: 'gpub_label' }, t('branchInvalid')) : null, + React.createElement('div', { className: 'gpub_label' }, t('configHint')))) + : React.createElement(React.Fragment, null, + React.createElement('div', { className: 'gpub_row' }, + React.createElement('span', { className: 'gpub_label' }, t('target')), + React.createElement('span', { className: 'gpub_value' }, target ?? ''), + repoNote !== null ? React.createElement('span', { className: 'gpub_label' }, repoNote) : null), + status.apiError ? React.createElement('div', { className: 'gpub_label' }, t('apiError', { detail: status.apiError })) : null, + status.gitAvailable === false ? React.createElement('div', { className: 'gpub_label' }, t('gitMissing')) : null), + phase === 'publishing' ? React.createElement('div', { className: 'gpub_row' }, + React.createElement('span', { className: 'gpub_label' }, t('publishing'))) : null, + phase === 'idle' && result !== null ? (result.ok === true + ? React.createElement('div', { className: 'gpub_ok' }, + React.createElement('div', null, t('success', { repo: '' }), ' ', + React.createElement('a', { className: 'gpub_link', href: result.repo ?? '#', target: '_blank', rel: 'noreferrer' }, `${result.owner}/${result.repoName}`)), + result.created === true ? React.createElement('span', { className: 'gpub_label' }, t('createdNote') + ' · ' + (result.isPrivate === true ? t('privateRepo') : t('publicRepo'))) : null, + result.unchanged === true ? React.createElement('span', { className: 'gpub_label' }, t('unchangedNote')) : null, + React.createElement('span', { className: 'gpub_label' }, t('commit', { sha: result.commit ?? '?', files: result.files ?? 0, branch: result.branch ?? '' })), + result.skippedCount > 0 ? React.createElement('span', { className: 'gpub_label' }, t('skipped', { count: result.skippedCount })) : null) + : React.createElement('div', { className: 'gpub_row' }, + React.createElement('strong', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, t('errorHead')), + React.createElement('p', { className: 'gpub_error' }, [t(`error.${result.error ?? 'internal'}`), result.detail].filter(Boolean).join('\n')))) : null, + React.createElement('div', { className: 'gpub_actions' }, + phase === 'idle' && (result === null || result.ok !== true) && status !== null && status.configured === true && status.gitAvailable !== false + ? React.createElement('button', { type: 'button', className: 'gpub_btn gpub_btnPrimary', onClick: doPublish }, result === null ? t('publish') : t('republish')) + : phase === 'idle' && result !== null && result.ok === true + ? React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: doPublish }, t('republish')) + : null, + React.createElement('button', { type: 'button', className: 'gpub_btn', onClick: () => setOpen(false) }, t('close')))) + : null); + } + + // typert 客户端贡献:参数 codec 必须是 strict 哑工厂(校验存在性,线上传输不经过它); + // result 用 src-json,宿主返回的 JSON 原样交给组件。 + const strict = (typeSymbol) => ({ mode: 'strict', typeSymbol, create: () => ({}) }); + const contribution = { + package: '@local/gitea-publisher', + descriptors: [ + { + id: '@local/gitea-publisher#giteaPublish/status', + service: 'giteaPublish', + namespace: 'giteaPublish', + method: 'status', + invocation: { kind: 'direct' }, + parameters: [ + { name: 'workspaceFileScope', wire: 'workspaceFileScopeId', source: 'lookup', lookup: 'workspaceFileScope', codec: strict('@local/gitea-publisher#SessionId') }, + ], + cancellation: { parameter: 'signal' }, + result: { mode: 'src-json' }, + }, + { + id: '@local/gitea-publisher#giteaPublish/publish', + service: 'giteaPublish', + namespace: 'giteaPublish', + method: 'publish', + invocation: { kind: 'direct' }, + parameters: [ + { name: 'workspaceFileScope', wire: 'workspaceFileScopeId', source: 'lookup', lookup: 'workspaceFileScope', codec: strict('@local/gitea-publisher#SessionId') }, + { name: 'request', wire: 'request', source: 'json', codec: strict('@local/gitea-publisher#PublishRequest') }, + ], + cancellation: { parameter: 'signal' }, + result: { mode: 'src-json' }, + }, + ], + }; + + const exports = {}; + exports.inject = ['remote', 'slots', 'locale']; + + function apply(ctx) { + ctx.effect(() => ctx.locale.register('gitea', { zh, en }), 'gitea-publisher: dictionaries'); + // remote.giteaPublish 在 $mount 完成后才作为服务出现:不能静态 inject(会死锁启动), + // 也不能在组件里裸访问(渲染可能早于 mount 完成,报 without inject)。用 ready promise 衔接。 + let resolveReady; + const ready = new Promise((resolve) => { resolveReady = resolve; }); + ctx.effect(async () => { + const dispose = await ctx.remote.$mount(contribution); + // 注意:ctx.remote. 的属性糖受 inject 守卫(同包静态注入才放行,而这里 + // 静态注入会死锁启动),必须用 ctx.get 拿挂载后的命名空间服务。 + const api = ctx.get('remote.giteaPublish'); + if (api === undefined) throw new Error('gitea-publisher: remote namespace did not appear after $mount'); + resolveReady(api); + return () => dispose(); + }, 'gitea-publisher: remote'); + ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'gitea-publish', + order: 35, + locale: 'gitea', + inject: () => ({ + // 命名空间方法返回 {ok, value} 的 RPC 信封(src-json 的宿主返回值在 value 里); + // 信封 ok:false 只在协议层失败(撤回/取消/断连)时出现,宿主业务失败在 value.ok=false。 + getStatus: async (sessionId) => { + const api = await ready; + const envelope = await api.status(sessionId); + if (envelope && envelope.ok === true) return envelope.value; + throw new Error(envelope?.error?.message ?? 'giteaPublish/status: RPC failed'); + }, + runPublish: async (sessionId, request) => { + const api = await ready; + const envelope = await api.publish(sessionId, request ?? {}); + if (envelope && envelope.ok === true) return envelope.value; + const detail = envelope?.error ? [envelope.error.code, envelope.error.message].filter(Boolean).join(': ') : 'RPC failed'; + return { ok: false, error: 'internal', detail }; + }, + }), + }, PublishAction)); + } + + exports.apply = apply; + return exports; + }, +}); diff --git a/gitea-publisher/cordis.patch.yml b/gitea-publisher/cordis.patch.yml new file mode 100644 index 0000000..bcef340 --- /dev/null +++ b/gitea-publisher/cordis.patch.yml @@ -0,0 +1,18 @@ +# @local/gitea-publisher 的 bundle 补丁:装载宿主插件行。 +# 接入信息(baseUrl/token/owner/repo)默认留空 —— 未配置时按钮显示「未配置」,不会误发。 +# 配置可以写在安装后的 profile cordis.patch.yml 里覆盖同 id 行的 config,无需改这里。 +- insert: + - id: gitea-publisher + name: "@local/gitea-publisher" + config: + gitea: + baseUrl: "" # 例如 http://127.0.0.1:3000(不带结尾斜杠) + username: "" # 填了就走 Basic 认证(账号密码);留空则按个人访问令牌 + token: "" # 个人访问令牌,或 username 对应的账号密码 + owner: "" # 目标 owner:组织名(如 XH)或个人用户名;留空 = 令牌/账号所属用户 + repo: "" # 目标仓库名(不存在时可自动创建,见 createIfMissing) + branch: "main" # 推送到的分支 + createIfMissing: true # 仓库不存在时自动创建(owner 是组织则建到组织下) + private: false # 自动创建时可见性:false=完全公开(默认),true=私有 + force: false # true = 用快照历史强推分支(覆盖远端历史) + maxFileBytes: 31457280 # 单文件超过 30 MiB 跳过不提交 diff --git a/gitea-publisher/index.js b/gitea-publisher/index.js new file mode 100644 index 0000000..e4662bf --- /dev/null +++ b/gitea-publisher/index.js @@ -0,0 +1,502 @@ +// @local/gitea-publisher — 宿主半边。 +// +// 职责:在宿主(服务器)上执行「把工作区源码快照 push 到 Gitea」。 +// 浏览器通过 typert Remote(SRC 回退模式)调用这里的 status / publish 两个方法, +// 因此本文件需要: +// 1. 一个普通服务对象,带 typertRemote 绑定(鸭子类型,形状见 dsh-api-gateway); +// 2. 原型上以字符串键 "@deepseek-ai/dsh-typert-protocol/remote-methods" 声明导出方法; +// 3. 方法参数名从 Function.prototype.toString 解析 —— 参数必须写成裸标识符, +// 末尾名为 signal 的参数会被自动接上取消信号。 +// 刻意不 import 任何 @deepseek-ai/* 包:bundle 从 profile 的 node_modules 加载, +// 只依赖 node: 内置模块,避免解析问题。 +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +const SERVICE_KEY = 'giteaPublish'; +const NAMESPACE = 'giteaPublish'; +const REMOTE_METHODS_KEY = '@deepseek-ai/dsh-typert-protocol/remote-methods'; +const DEFAULT_MAX_FILE_BYTES = 31457280; // 30 MiB +const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +// Gitea 仓库名字符集(另有首尾不能是 . / 的约束,由 sanitizeRepoName 兜住)。 +const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/; +const DEFAULT_MAP_FILE = join(homedir() || tmpdir(), '.dsh', 'gitea-publisher-map.json'); + +// 快照阶段就跳过的目录(.gitignore 负责精细排除,这里只是别把巨型垃圾搬进临时目录)。 +const EXCLUDED_DIRS = new Set([ + '.git', '.dsh', 'node_modules', '.pnpm-store', '.venv', 'venv', '__pycache__', + '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox', '.next', '.nuxt', '.turbo', + '.cache', '.parcel-cache', '.svelte-kit', 'dist', 'build', 'out', 'coverage', '.idea', +]); + +/** 给 git 子进程的统一环境:禁交互提示、隔离全局配置,避免 safe.directory / credential helper 干扰。 */ +const GIT_ENV = { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_OPTIONAL_LOCKS: '0', + GIT_FLUSH: '0', + LC_ALL: 'C', + HOME: tmpdir(), +}; + +/** 跑一条 git 命令,永远 resolve 成 { code, out, err, timedOut },不抛异常。 */ +function runGit(args, cwd, { timeoutMs = 60000, env = {}, signal = undefined } = {}) { + return new Promise((resolvePromise) => { + let child; + try { + child = spawn('git', args, { cwd, env: { ...GIT_ENV, ...env }, stdio: ['ignore', 'pipe', 'pipe'], ...(signal ? { signal } : {}) }); + } catch (error) { + resolvePromise({ code: -1, out: '', err: String(error?.message ?? error), timedOut: false }); + return; + } + let out = ''; + let err = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { child.kill('SIGKILL'); } catch {} + }, timeoutMs); + child.stdout?.on('data', (chunk) => { out = (out + chunk).slice(-40000); }); + child.stderr?.on('data', (chunk) => { err = (err + chunk).slice(-40000); }); + child.on('error', (error) => { + clearTimeout(timer); + resolvePromise({ code: -1, out, err: String(error?.message ?? error), timedOut }); + }); + child.on('close', (code, closeSignal) => { + clearTimeout(timer); + if (code !== 0 && !timedOut && closeSignal) err = (err ? err + '\n' : '') + `git ${args[0]} 被信号 ${closeSignal} 终止`; + resolvePromise({ code: code ?? -1, out, err, timedOut }); + }); + }); +} + +/** 把工作区文件复制到临时目录;跳过排除目录 / 符号链接 / 超大文件。gitignore 在 git add 阶段自然生效。 */ +function copySnapshot(srcRoot, destRoot, maxFileBytes) { + let files = 0; + let bytes = 0; + const skipped = []; + const walk = (srcDir, relDir) => { + for (const entry of readdirSync(join(srcDir), { withFileTypes: true })) { + const rel = relDir ? `${relDir}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.has(entry.name)) continue; + mkdirSync(join(destRoot, rel), { recursive: true }); + walk(join(srcDir, entry.name), rel); + continue; + } + if (!entry.isFile()) continue; + let size = 0; + try { size = statSync(join(srcDir, entry.name)).size; } catch { continue; } + if (size > maxFileBytes) { skipped.push(rel); continue; } + copyFileSync(join(srcDir, entry.name), join(destRoot, rel)); + files += 1; + bytes += size; + } + }; + walk(srcRoot, ''); + return { files, bytes, skipped }; +} + +class GiteaPublishService { + constructor(ctx, config) { + this.ctx = ctx; + this.config = config; + this.busy = false; + // typert Remote 绑定(gateway 鸭子类型检查:service / serviceKey / namespace)。 + this.typertRemote = Object.freeze({ service: this, serviceKey: SERVICE_KEY, namespace: NAMESPACE }); + } + + // ---------- 配置 ---------- + + normalizedConfig() { + const g = (this.config && typeof this.config === 'object') ? (this.config.gitea ?? {}) : {}; + const baseUrl = String(g.baseUrl ?? '').trim().replace(/\/+$/, ''); + const token = String(g.token ?? '').trim(); + const username = String(g.username ?? '').trim(); + const owner = String(g.owner ?? '').trim(); + const repo = String(g.repo ?? '').trim(); + const branch = String(g.branch ?? '').trim() || 'main'; + // 默认「一个工作区一个仓库」:目标仓库名按工作区目录自动派生并绑定; + // repoFromWorkspace: false 退回旧行为(所有会话共用 repo 这一个仓库)。 + const repoFromWorkspace = g.repoFromWorkspace !== false; + const missing = []; + if (!baseUrl) missing.push('baseUrl'); + if (!token) missing.push('token'); + if (!repo && !repoFromWorkspace) missing.push('repo'); + return { + baseUrl, + token, + username, + owner, + repo, + branch, + repoFromWorkspace, + mapFile: String(g.mapFile ?? '').trim() || DEFAULT_MAP_FILE, + force: g.force === true, + createIfMissing: g.createIfMissing !== false, + private: g.private === true, // 默认 false:新建仓库完全公开(匿名可拉取) + maxFileBytes: Number.isFinite(g.maxFileBytes) && g.maxFileBytes > 0 ? g.maxFileBytes : DEFAULT_MAX_FILE_BYTES, + baseUrlInvalid: baseUrl !== '' && !/^https?:\/\//.test(baseUrl), + branchInvalid: !BRANCH_RE.test(branch), + missing, + }; + } + + /** 从任何对外文本里抹掉凭证(git 报错时可能把带凭证的 URL 原样打出来,且密码是百分号转义后的形态)。 */ + sanitize(config, text) { + let out = String(text ?? ''); + if (config.token) { + out = out.split(config.token).join('***'); + out = out.split(encodeURIComponent(config.token)).join('***'); + } + return out.slice(0, 1200); + } + + // ---------- 一个工作区一个仓库:绑定映射 + 名称解析 ---------- + // 绑定关系存在宿主本地 sidecar(默认 ~/.dsh/gitea-publisher-map.json): + // { "version": 1, "bindings": { "": { "repo": "", "at": "" } } } + // 首次发布按工作区目录名派生仓库名;重命名/挂载点变化导致目录名不同时, + // 已有绑定保证继续推到同一仓库。request.repo 可显式换绑(会记入映射)。 + + loadBindings(mapFile) { + try { + const doc = JSON.parse(readFileSync(mapFile, 'utf8')); + const bindings = doc && typeof doc.bindings === 'object' && doc.bindings !== null ? doc.bindings : {}; + const out = {}; + for (const [root, entry] of Object.entries(bindings)) { + if (typeof root !== 'string') continue; + // 兼容两种存法:值是仓库名字符串(早期形态)或 { repo, at } 对象。 + const repo = typeof entry === 'string' ? entry : (entry && typeof entry.repo === 'string' ? entry.repo : null); + if (repo !== null && REPO_NAME_RE.test(repo)) out[root] = repo; + } + return out; + } catch { + return {}; // 不存在/损坏都按空映射处理(首次使用场景) + } + } + + saveBinding(mapFile, root, repo) { + const doc = { version: 1, bindings: { ...this.loadBindings(mapFile), [root]: { repo, at: new Date().toISOString() } } }; + try { + mkdirSync(mapFile.replace(/[^/]*$/, ''), { recursive: true }); + const tmp = `${mapFile}.tmp-${process.pid}`; + writeFileSync(tmp, JSON.stringify(doc, null, 2), 'utf8'); + renameSync(tmp, mapFile); + return true; + } catch { + return false; // 映射写失败不影响本次发布,只是下次会重新解析 + } + } + + /** 工作区目录名 → 合法仓库名:Gitea 字符集内收敛,非法字符折成 '-',空结果兜底 'workspace'。 */ + deriveRepoName(root) { + let name = basename(root ?? '').replace(/^\.+/, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 60); + if (name === '' || name === '.' || name === '..') name = 'workspace'; + return name; + } + + /** 同一根路径的定长散列,用于目录名撞车时的确定性后缀。 */ + rootSuffix(root) { + return createHash('sha1').update(String(root)).digest('hex').slice(0, 8); + } + + /** + * 解析某工作区当前的目标仓库名。返回 { repo, bound, source }。 + * source: 'bound'(映射已有)| 'derived'(目录名直取)| 'derived-collision'(目录名被占,加散列后缀)。 + * 占用判定 = 映射里被别的根占着,或 Gitea 上已有同名仓库(宁可不复用也不误写别人的库)。 + */ + async resolveRepoName(config, root, { signal = undefined, login = '' } = {}) { + const bindings = this.loadBindings(config.mapFile); + const bound = bindings[root]; + if (typeof bound === 'string' && bound !== '') return { repo: bound, bound: true, source: 'bound' }; + const owner = config.owner === '' ? login : config.owner; + const takenElsewhere = (name) => Object.entries(bindings).some(([otherRoot, repo]) => repo === name && otherRoot !== root); + const serverFree = async (name) => { + const seen = await this.api(config, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, { signal }); + return seen.status === 404 || seen.status === 0 || seen.status >= 500; // 查不到/网络故障时不阻塞(发布时还有存在性检查兜底) + }; + const derived = this.deriveRepoName(root); + if (!takenElsewhere(derived) && await serverFree(derived)) return { repo: derived, bound: false, source: 'derived' }; + const collided = `${derived}-${this.rootSuffix(root)}`.slice(0, 100); + return { repo: collided, bound: false, source: 'derived-collision' }; + } + + /** 发布成功后落绑定(含 request.repo 换绑)。失败路径不调用,避免半成品绑定。 */ + rememberBinding(config, root, repo) { + if (root !== null && typeof repo === 'string' && repo !== '') this.saveBinding(config.mapFile, root, repo); + } + + // ---------- Gitea REST(见 docs.gitea.com/development/api-usage) ---------- + // 配了 username 走 Basic(账号密码或可登录的凭证);否则按个人访问令牌 `Authorization: token `。 + + authHeader(config) { + return config.username + ? `Basic ${Buffer.from(`${config.username}:${config.token}`, 'utf8').toString('base64')}` + : `token ${config.token}`; + } + + async api(config, path, { method = 'GET', body = undefined, signal = undefined, timeoutMs = 15000 } = {}) { + const url = `${config.baseUrl}/api/v1${path}`; + const signals = [AbortSignal.timeout(timeoutMs)]; + if (signal) signals.push(signal); + try { + const response = await fetch(url, { + method, + headers: { + Authorization: this.authHeader(config), + Accept: 'application/json', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.any(signals), + }); + const text = await response.text(); + let json; + try { json = text ? JSON.parse(text) : undefined; } catch {} + return { status: response.status, json, text }; + } catch (error) { + return { status: 0, json: undefined, text: '', error: this.sanitize(config, String(error?.message ?? error)) }; + } + } + + // ---------- Remote 方法(参数名即线上字段,务必裸标识符;signal 结尾 = 取消) ---------- + + async status(workspaceFileScope, signal) { + const config = this.normalizedConfig(); + const git = await runGit(['--version'], tmpdir(), { timeoutMs: 8000 }); + const gitAvailable = git.code === 0; + let apiUser = null; + let apiReachable = false; + let apiError = null; + if (config.baseUrl && config.token && !config.baseUrlInvalid) { + const me = await this.api(config, '/user', { signal }); + if (me.status === 200 && typeof me.json?.login === 'string') { + apiReachable = true; + apiUser = me.json.login; + } else { + apiError = me.error ?? this.sanitize(config, me.json?.message ?? `HTTP ${me.status}`); + } + } + // 每工作区模式下,repo 显示的是「本会话工作区」实际会推到的仓库(未绑定则按解析链预测)。 + const scope = workspaceFileScope && typeof workspaceFileScope === 'object' ? workspaceFileScope : {}; + const root = typeof scope.workspaceRoot === 'string' && scope.workspaceRoot !== '' ? scope.workspaceRoot : null; + let repo = config.repo === '' ? null : config.repo; + let repoSource = 'fixed'; + let repoBound = false; + if (config.repoFromWorkspace) { + if (root !== null && config.missing.length === 0 && !config.baseUrlInvalid) { + const resolved = await this.resolveRepoName(config, root, { signal, login: apiUser ?? '' }); + repo = resolved.repo; + repoSource = resolved.source; + repoBound = resolved.bound; + } else { + repo = null; + repoSource = root === null ? 'unknown-session' : 'pending'; + } + } + return { + ok: true, + configured: config.missing.length === 0 && !config.baseUrlInvalid && !config.branchInvalid, + missing: config.missing, + baseUrlInvalid: config.baseUrlInvalid, + branchInvalid: config.branchInvalid, + baseUrl: config.baseUrl === '' ? null : config.baseUrl, + owner: config.owner === '' ? apiUser : config.owner, + repo, + repoPolicy: config.repoFromWorkspace ? 'per-workspace' : 'fixed', + repoSource, + repoBound, + branch: config.branch, + force: config.force, + isPrivate: config.private, + gitAvailable, + gitVersion: gitAvailable ? this.sanitize(config, git.out).trim() : null, + apiReachable, + apiUser, + apiError, + }; + } + + async publish(workspaceFileScope, request, signal) { + const config = this.normalizedConfig(); + const req = request && typeof request === 'object' ? request : {}; + if (config.missing.length > 0 || config.baseUrlInvalid || config.branchInvalid) { + return { ok: false, error: 'not-configured', missing: config.missing, baseUrlInvalid: config.baseUrlInvalid, branchInvalid: config.branchInvalid }; + } + if (this.busy) return { ok: false, error: 'busy' }; + const branch = typeof req.branch === 'string' && req.branch.trim() !== '' ? req.branch.trim() : config.branch; + if (!BRANCH_RE.test(branch)) return { ok: false, error: 'bad-branch' }; + const force = req.force === true || config.force === true; + const scope = workspaceFileScope && typeof workspaceFileScope === 'object' ? workspaceFileScope : {}; + const root = typeof scope.workspaceRoot === 'string' && scope.workspaceRoot !== '' ? scope.workspaceRoot : null; + if (root === null || root === '/' || !existsSync(root)) return { ok: false, error: 'workspace-unavailable' }; + + // 目标仓库名:request.repo 显式换绑 > 映射绑定 > 按目录名派生(撞名加散列后缀)。 + // 旧行为(repoFromWorkspace: false)仍是固定的 config.repo。 + let repoName = config.repo; + let repoSource = 'fixed'; + let repoBound = true; + if (config.repoFromWorkspace) { + const override = typeof req.repo === 'string' && req.repo.trim() !== '' ? req.repo.trim() : null; + if (override !== null) { + if (!REPO_NAME_RE.test(override)) return { ok: false, error: 'bad-repo-name', detail: `仓库名不合法:${override.slice(0, 60)}` }; + repoName = override; + repoSource = 'override'; + repoBound = false; + } else { + const resolved = await this.resolveRepoName(config, root, { signal }); + repoName = resolved.repo; + repoSource = resolved.source; + repoBound = resolved.bound; + } + } + + this.busy = true; + let tempDir = null; + try { + const aborted = () => signal?.aborted === true; + + const git = await runGit(['--version'], tmpdir(), { timeoutMs: 8000 }); + if (git.code !== 0) return { ok: false, error: 'git-missing', detail: this.sanitize(config, git.err || git.out) }; + + // 1) 令牌身份 → 生效 owner + const me = await this.api(config, '/user', { signal }); + if (me.status === 0) return { ok: false, error: 'api-unreachable', detail: me.error }; + if (me.status === 401) return { ok: false, error: 'auth-failed' }; + if (me.status !== 200 || typeof me.json?.login !== 'string') { + return { ok: false, error: 'api-error', detail: this.sanitize(config, me.json?.message ?? `GET /user → HTTP ${me.status}`) }; + } + const login = me.json.login; + const owner = config.owner === '' ? login : config.owner; + if (aborted()) return { ok: false, error: 'cancelled' }; + + // 2) 目标仓库:存在则用;不存在且允许创建 → 自动建(owner 非本人时走组织接口,默认完全公开) + let created = false; + const seen = await this.api(config, `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`, { signal }); + if (seen.status === 404) { + if (!config.createIfMissing) return { ok: false, error: 'repo-missing' }; + const org = owner !== login; + const endpoint = org ? `/orgs/${encodeURIComponent(owner)}/repos` : '/user/repos'; + const made = await this.api(config, endpoint, { method: 'POST', body: { name: repoName, private: config.private, auto_init: false }, signal, timeoutMs: 30000 }); + if (made.status !== 201) { + const hint = org && (made.status === 403 || made.status === 404) ? `(${owner} 不是你有建库权限的组织)` : ''; + return { ok: false, error: 'repo-create-failed', detail: this.sanitize(config, (made.json?.message ?? made.error ?? `POST ${endpoint} → HTTP ${made.status}`) + hint) }; + } + created = true; + } else if (seen.status !== 200) { + return { ok: false, error: seen.status === 401 ? 'auth-failed' : 'repo-check-failed', detail: this.sanitize(config, seen.json?.message ?? seen.error ?? `GET repo → HTTP ${seen.status}`) }; + } + if (aborted()) return { ok: false, error: 'cancelled' }; + + // 3) 快照 → 临时 git 仓库。非 force:先取远端分支、基于它提交(线性历史、可快进;内容未变则幂等空转)。 + // force:跳过 fetch,保持「单提交快照历史」直接覆盖远端。 + tempDir = mkdtempSync(join(tmpdir(), 'gitea-publish-')); + const snapshot = copySnapshot(root, tempDir, config.maxFileBytes); + const pushUrl = new URL(config.baseUrl); + pushUrl.username = config.username || 'dsh'; + pushUrl.password = config.token; + pushUrl.pathname = `${pushUrl.pathname.replace(/\/+$/, '')}/${owner}/${repoName}.git`.replace(/^\/?/, '/'); + const init = await runGit(['init', '-q', '-b', branch], tempDir, { signal }); + if (init.code !== 0) return { ok: false, error: 'git-init-failed', detail: this.sanitize(config, init.err) }; + let basedOnRemote = false; + if (!force) { + const fetched = await runGit(['fetch', '-q', pushUrl.toString(), `refs/heads/${branch}`], tempDir, { timeoutMs: 120000, signal }); + if (fetched.code === 0) { + const reset = await runGit(['reset', '-q', '--soft', 'FETCH_HEAD'], tempDir, { signal }); + if (reset.code !== 0) return { ok: false, error: 'git-reset-failed', detail: this.sanitize(config, reset.err || reset.out) }; + basedOnRemote = true; + } else { + const fetchText = this.sanitize(config, `${fetched.err || ''} ${fetched.out || ''}`); + if (/authentication|invalid credentials|permission denied|403|401/i.test(fetchText)) return { ok: false, error: 'auth-failed', detail: fetchText }; + if (!/couldn't find remote ref|not found|no such ref/i.test(fetchText)) { + return { ok: false, error: 'fetch-failed', detail: fetchText }; + } + // 远端还没有这个分支(空仓库/新分支)→ 单提交起步 + } + } + const added = await runGit(['add', '-A'], tempDir, { signal }); + if (added.code !== 0) return { ok: false, error: 'git-add-failed', detail: this.sanitize(config, added.err) }; + const listed = await runGit(['-c', 'core.quotepath=off', 'ls-files'], tempDir, { signal }); + const staged = listed.code === 0 ? listed.out.split('\n').filter((line) => line !== '').length : 0; + if (staged === 0) return { ok: false, error: 'no-files' }; + const defaultMessage = `chore(dsh): 发布工作区 '${basename(root)}' (DeepSeek Harness 快照 ${new Date().toISOString().slice(0, 16).replace('T', ' ')})`; + const message = typeof req.message === 'string' && req.message.trim() !== '' ? req.message.trim().slice(0, 200) : defaultMessage; + const committed = await runGit([ + '-c', 'user.name=DSH Publisher', '-c', 'user.email=dsh-publisher@localhost', + 'commit', '-q', '-m', message, + ], tempDir, { signal }); + const nothingToCommit = committed.code !== 0 && /nothing to commit/i.test(`${committed.err}${committed.out}`); + if (committed.code !== 0 && !nothingToCommit) return { ok: false, error: 'git-commit-failed', detail: this.sanitize(config, committed.err || committed.out) }; + const sha = await runGit(['rev-parse', '--short', 'HEAD'], tempDir, { signal }); + if (aborted()) return { ok: false, error: 'cancelled' }; + + // 4) 推送:凭证走 URL 内嵌 basic auth(用户名留空时用占位名,Gitea 接受任意用户名 + token 密码) + const pushed = await runGit([ + 'push', '--quiet', ...(force ? ['--force'] : []), pushUrl.toString(), `HEAD:refs/heads/${branch}`, + ], tempDir, { timeoutMs: 300000, signal }); + if (pushed.timedOut) return { ok: false, error: 'push-timeout' }; + if (pushed.code !== 0) { + const errText = this.sanitize(config, pushed.err || pushed.out); + if (/non-fast-forward|fetch first|\[rejected\]/i.test(errText) && !force) { + return { ok: false, error: 'push-rejected', detail: `${errText}\n提示:远端分支与本次快照历史不同;确认要覆盖后把配置 force 设为 true。` }; + } + if (/authentication|invalid credentials|permission denied|403|401/i.test(errText)) return { ok: false, error: 'auth-failed', detail: errText }; + return { ok: false, error: 'push-failed', detail: errText }; + } + + const clean = new URL(config.baseUrl); + clean.pathname = `/${owner}/${repoName}`; + // 推成功后固定绑定:这个工作区以后一直推这个仓库(含 request.repo 换绑)。 + if (config.repoFromWorkspace && !repoBound) this.rememberBinding(config, root, repoName); + return { + ok: true, + repo: clean.toString(), + owner, + repoName, + repoSource, + branch, + commit: sha.code === 0 ? sha.out.trim() : null, + files: staged, + copiedFiles: snapshot.files, + skipped: snapshot.skipped.slice(0, 10), + skippedCount: snapshot.skipped.length, + created, + isPrivate: config.private, + unchanged: nothingToCommit === true, + basedOnRemote, + message, + }; + } catch (error) { + if (signal?.aborted === true) return { ok: false, error: 'cancelled' }; + return { ok: false, error: 'internal', detail: this.sanitize(config, String(error?.stack ?? error?.message ?? error)) }; + } finally { + this.busy = false; + if (tempDir !== null) { + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + } + } + } +} + +// SRC 模式导出声明:gateway 用 remoteMethods() 读这个字符串键的原型属性。 +// 注意 methodParameterNames 会解析方法源码的参数名 —— 改签名等于改线上字段。 +Object.defineProperty(GiteaPublishService.prototype, REMOTE_METHODS_KEY, { + configurable: true, + value: Object.freeze({ + version: 1, + methods: Object.freeze([ + Object.freeze({ method: 'status', invocation: Object.freeze({ kind: 'direct' }) }), + Object.freeze({ method: 'publish', invocation: Object.freeze({ kind: 'direct' }) }), + ]), + }), +}); + +/** 插件体:只提供这一个服务,配置来自补丁行的 config。 */ +export function apply(ctx, config) { + ctx.provide(SERVICE_KEY, new GiteaPublishService(ctx, config)); +} diff --git a/gitea-publisher/package.json b/gitea-publisher/package.json new file mode 100644 index 0000000..bb175d6 --- /dev/null +++ b/gitea-publisher/package.json @@ -0,0 +1,26 @@ +{ + "name": "@local/gitea-publisher", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "DeepSeek Harness 插件:在会话顶栏加一个按钮,把当前会话工作区的最终项目源码提交(push)到本地部署的 Gitea。", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-locale" + ] + } + } +} diff --git a/test/e2e-client.mjs b/test/e2e-client.mjs new file mode 100644 index 0000000..9bafb82 --- /dev/null +++ b/test/e2e-client.mjs @@ -0,0 +1,172 @@ +// 浏览器半边测试:在 node 里用假 window/require/ctx 跑工厂, +// 复刻 dsh-api-gateway client 的 validateInvocation + requireStrictInputs 规则校验描述符, +// 并检查 zh/en 字典覆盖组件用到的所有 key 与宿主所有 error 码。 +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const clientSource = readFileSync(join(here, '..', 'gitea-publisher', 'client.js'), 'utf8'); +const hostSource = readFileSync(join(here, '..', 'gitea-publisher', 'index.js'), 'utf8'); +const results = []; +const check = (name, fn) => { + try { + fn(); + results.push(`ok ${name}`); + } catch (error) { + results.push(`FAIL ${name}: ${error?.message ?? error}`); + process.exitCode = 1; + } +}; + +let loaded = null; +globalThis.window = { __ModuleLoader__: { load: (module) => { loaded = module; } } }; +await import('../gitea-publisher/client.js'); +check('module registers with loader id', () => assert.equal(loaded.id, '@local/gitea-publisher')); + +const ReactStub = { createElement: (type, props, ...kids) => ({ type, props, kids }), useState: (init) => [init, () => {}], useRef: () => ({ current: null }), useEffect: () => {}, Fragment: 'Fragment' }; +const exports = loaded.factory((name) => (name === 'react' ? ReactStub : {})); +check('exports injects remote/slots/locale', () => assert.deepEqual(exports.inject, ['remote', 'slots', 'locale'])); + +const effects = []; +const dictionaries = []; +const mounts = []; +const injected = []; +const published = []; +const statuses = []; +let disposed = 0; +const ctx = { + effect: (fn, label) => { effects.push({ fn, label }); }, + locale: { register: (id, dict) => { dictionaries.push([id, dict]); return () => {}; } }, + remote: { + $mount: async (contribution) => { mounts.push(contribution); return async () => { disposed += 1; }; }, + giteaPublish: { + status: async (sessionId) => { statuses.push(sessionId); return { ok: true, value: { ok: true, configured: true } }; }, + publish: async (sessionId, request) => { published.push([sessionId, request]); return { ok: true, value: { ok: true, commit: 'abc' } }; }, + }, + }, + slots: { inject: (slotName, factory) => { injected.push([slotName, factory]); } }, + get: (name) => (name === 'remote.giteaPublish' ? ctx.remote.giteaPublish : undefined), +}; +exports.apply(ctx); +check('two effects + one slot injection', () => { + assert.equal(effects.length, 2); + assert.equal(injected.length, 1); + assert.equal(injected[0][0], 'conversation.session.header.actions'); +}); +for (const effect of effects) await effect.fn(); +check('locale + remote contributions mounted', () => { + assert.equal(dictionaries[0][0], 'gitea'); + assert.equal(mounts.length, 1); +}); + +// —— 复刻 validateInvocation / validateContribution 规则(dsh-typert-registry client.js:1314,api-gateway client validateContribution)—— +const WIRE = /^[A-Za-z0-9_$.-]+$/; +function validateDescriptor(descriptor, seenIds, seenMethods) { + assert.ok(descriptor.id.length > 0, 'empty id'); + assert.ok(!descriptor.service.includes('#'), 'service has #'); + assert.ok(WIRE.test(descriptor.namespace) && descriptor.namespace !== '.', 'namespace'); + assert.ok(WIRE.test(descriptor.method), 'method'); + const key = `${descriptor.namespace}/${descriptor.method}`; + assert.equal(seenMethods.has(key), false, `duplicate ${key}`); + seenMethods.add(key); + assert.equal(seenIds.has(descriptor.id), false, 'duplicate id'); + seenIds.add(descriptor.id); + if (descriptor.result.mode !== 'src-json') throw new Error('result must be src-json'); + const wires = new Set(); + for (const parameter of descriptor.parameters) { + assert.ok(WIRE.test(parameter.name) && WIRE.test(parameter.wire), `wire names ${parameter.name}`); + assert.equal(wires.has(parameter.wire), false, 'duplicate wire'); + wires.add(parameter.wire); + if (parameter.source === 'lookup') { + assert.ok(parameter.lookup !== undefined && !parameter.lookup.includes('#'), 'lookup key'); + assert.equal(parameter.acceptsUndefined, undefined, 'lookup cannot acceptUndefined'); + } else { + assert.equal(parameter.lookup, undefined, 'json parameter has lookup key'); + } + assert.equal(parameter.codec.mode, 'strict', 'client input codecs must be strict'); + assert.ok(parameter.codec.typeSymbol.length > 0, 'typeSymbol'); + assert.equal(typeof parameter.codec.create, 'function', 'create factory'); + } + if (descriptor.cancellation !== undefined) assert.equal(descriptor.cancellation.parameter, 'signal'); + assert.equal(descriptor.invocation.kind, 'direct'); +} +check('descriptors pass replicated client validation', () => { + const seenIds = new Set(); + const seenMethods = new Set(); + for (const descriptor of mounts[0].descriptors) validateDescriptor(descriptor, seenIds, seenMethods); +}); +check('status descriptor carries the workspace lookup parameter', () => { + const descriptor = mounts[0].descriptors.find((entry) => entry.method === 'status'); + assert.equal(descriptor.parameters.length, 1); + assert.equal(descriptor.parameters[0].name, 'workspaceFileScope'); + assert.equal(descriptor.parameters[0].wire, 'workspaceFileScopeId'); + assert.equal(descriptor.parameters[0].lookup, 'workspaceFileScope'); +}); +check('publish descriptor matches host lookup contract', () => { + const descriptor = mounts[0].descriptors.find((entry) => entry.method === 'publish'); + assert.equal(descriptor.parameters[0].name, 'workspaceFileScope'); + assert.equal(descriptor.parameters[0].wire, 'workspaceFileScopeId'); + assert.equal(descriptor.parameters[0].lookup, 'workspaceFileScope'); + assert.equal(descriptor.parameters[1].name, 'request'); +}); + +// —— 槽位注入闭包:再 apply 一次到带 register 的 ctx,然后调用注入工厂 —— +const slotsCaptured = []; +const ctx3 = { ...ctx, slots: { inject: (slotName, factory) => { slotsCaptured.push([slotName, factory]); }, register: (spec3, component) => { slotsCaptured.registered = [spec3, component]; return () => {}; } } }; +exports.apply(ctx3); +slotsCaptured[0][1](); // 触发 ctx3 闭包里的 slots.register +// 第二次 apply 新登记的 effect(index 2、3)必须先跑,$mount 的 ready promise 才会 resolve。 +for (const effect of effects.slice(2)) await effect.fn(); +const [slotSpec, slotComponent] = slotsCaptured.registered; +check('slot registration spec', () => { + assert.equal(slotSpec.name, 'conversation.session.header.actions'); + assert.equal(slotSpec.id, 'gitea-publish'); + assert.equal(slotSpec.locale, 'gitea'); +}); +const closures = slotSpec.inject(); +const publishOut = await closures.runPublish('sess-9', {}); +check('runPublish forwards session id', () => { + assert.equal(published.length, 1); + assert.equal(published[0][0], 'sess-9'); +}); +check('runPublish unwraps the RPC envelope', () => assert.deepEqual(publishOut, { ok: true, commit: 'abc' })); +const statusOut = await closures.getStatus('sess-7'); +check('getStatus unwraps the RPC envelope', () => assert.deepEqual(statusOut, { ok: true, configured: true })); +check('getStatus forwards session id (per-workspace repo)', () => assert.deepEqual(statuses, ['sess-7'])); + +// —— 字典完备性 —— +const [localeId, dictionaries0] = dictionaries[0] === undefined ? [] : [dictionaries[0][0], dictionaries[0][1]]; +check('zh/en dictionaries both present', () => { + assert.ok(dictionaries0.zh && dictionaries0.en); + assert.deepEqual(Object.keys(dictionaries0.zh).sort(), Object.keys(dictionaries0.en).sort()); +}); +const usedKeys = [...clientSource.matchAll(/\bt\('([^']+)'/g)].map((match) => match[1]); +check('every t() key exists in dictionary', () => { + for (const key of usedKeys) assert.ok(key in dictionaries0.zh, `missing key ${key}`); +}); +const hostErrors = [...hostSource.matchAll(/error:\s*'([a-z-]+)'/g)].map((match) => match[1]); +const uniqueHostErrors = [...new Set(hostErrors)].filter((code) => code !== 'direct'); +check('every host error code has zh/en message', () => { + for (const code of uniqueHostErrors) { + assert.ok(`error.${code}` in dictionaries0.zh, `zh missing error.${code} (${uniqueHostErrors.join(',')})`); + assert.ok(`error.${code}` in dictionaries0.en, `en missing error.${code}`); + } +}); + +// —— 组件渲染冒烟(stub react,跑一次 render 函数体不会崩) —— +check('component renders without throwing (stub react)', () => { + const tree = slotComponent({ sessionId: 's', t: (key) => `[${key}]`, getStatus: () => Promise.resolve(null), runPublish: () => Promise.resolve({}) }); + assert.equal(tree.type, 'span'); +}); + +// disposer +for (const effect of effects) { + const disposer = await effect.fn(); + if (typeof disposer === 'function') await disposer(); +} +check('$mount disposers invoked', () => assert.ok(disposed >= 1)); + +console.log(results.join('\n')); +console.log(process.exitCode ? 'CLIENT TEST FAILED' : 'CLIENT TEST ALL OK'); diff --git a/test/e2e-host.mjs b/test/e2e-host.mjs new file mode 100644 index 0000000..ca78e06 --- /dev/null +++ b/test/e2e-host.mjs @@ -0,0 +1,367 @@ +// 宿主半边端到端测试: +// - 用真实的 dsh-typert-protocol remoteMethods() 校验原型标记; +// - 复刻 gateway 的 methodParameterNames 验证签名可解析; +// - 起一个假 Gitea(HTTP):/api/v1/user、/api/v1/repos、POST /user/repos + git-http-backend 真推送; +// - 完整跑 publish 的创建/成功/拒绝/强推四条路径。 +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { remoteMethods } from '/usr/local/nodejs/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-typert-protocol/lib/index.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +const TOKEN = 'tk-e2e-secret'; +const results = []; +const check = (name, fn) => { + try { + fn(); + results.push(`ok ${name}`); + } catch (error) { + results.push(`FAIL ${name}: ${error?.message ?? error}`); + process.exitCode = 1; + } +}; + +// —— 复刻 gateway 的 methodParameterNames(源出 dsh-api-gateway/lib/index.js:1458)—— +function methodParameterNames(service, method) { + let prototype = Object.getPrototypeOf(service); + let implementation; + while (prototype !== null) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, method); + if (descriptor !== void 0) { + if ('value' in descriptor && typeof descriptor.value === 'function') implementation = descriptor.value; + break; + } + prototype = Object.getPrototypeOf(prototype); + } + if (implementation === void 0) throw new Error(`no prototype method ${method}`); + const source = Function.prototype.toString.call(implementation); + const open = source.indexOf('('); + const close = source.indexOf(')', open + 1); + if (open < 0 || close < 0) throw new Error('bad params'); + const body = source.slice(open + 1, close).trim(); + if (body.length === 0) return []; + const names = new Set(); + for (const part of body.split(',').map((x) => x.trim())) { + if (!/^[$A-Z_a-z][$\w]*$/u.test(part) || names.has(part)) throw new Error(`invalid signature param "${part}"`); + names.add(part); + } + return [...names]; +} + +// —— 装载宿主模块 —— +const host = await import('../gitea-publisher/index.js'); +assert.equal(typeof host.apply, 'function'); + +// —— 假 Gitea + git http backend —— +const ROOT = mkdtempSync(join(tmpdir(), 'gitea-e2e-roots-')); +mkdirSync(join(ROOT, 'tester'), { recursive: true }); +const apiLog = []; +const server = createServer((req, res) => { + const url = new URL(req.url, 'http://stub'); + if (url.pathname.startsWith('/api/v1/')) { + if (!(req.headers.authorization ?? '').includes(TOKEN)) { + res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"message":"unauthenticated"}'); + return; + } + if (req.method === 'GET' && url.pathname === '/api/v1/user') { + res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"login":"tester"}'); + return; + } + const repoGet = /^\/api\/v1\/repos\/tester\/([^/]+)$/.exec(url.pathname); + if (req.method === 'GET' && repoGet) { + const name = decodeURIComponent(repoGet[1]); + if (existsSync(join(ROOT, 'tester', `${name}.git`))) res.writeHead(200, { 'Content-Type': 'application/json' }).end(`{"name":"${name}"}`); + else res.writeHead(404, { 'Content-Type': 'application/json' }).end('{"message":"not found"}'); + return; + } + if (req.method === 'POST' && url.pathname === '/api/v1/user/repos') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', () => { + apiLog.push(['POST /user/repos', body]); + let name = 'demo'; + try { name = JSON.parse(body).name; } catch {} + if (!/^[A-Za-z0-9._-]+$/.test(name)) { res.writeHead(422).end('{"message":"bad name"}'); return; } + const made = spawnSync('git', ['init', '--bare', '-b', 'main', join(ROOT, 'tester', `${name}.git`)], { encoding: 'utf8' }); + if (made.status !== 0) res.writeHead(500).end('{"message":"git init failed"}'); + else res.writeHead(201, { 'Content-Type': 'application/json' }).end(`{"name":"${name}"}`); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'application/json' }).end('{"message":"no route"}'); + return; + } + if (/\/[^/]+\/[^/]+\.git(\/|$)/.test(url.pathname)) { + const body = []; + req.on('data', (chunk) => body.push(chunk)); + req.on('end', () => { + const payload = Buffer.concat(body); + const child = spawn('/usr/lib/git-core/git-http-backend', { + env: { + ...process.env, + GIT_PROJECT_ROOT: ROOT, + GIT_HTTP_EXPORT_ALL: '1', + REMOTE_USER: 'tester', + PATH_INFO: decodeURIComponent(url.pathname), + REQUEST_METHOD: req.method, + QUERY_STRING: url.search.startsWith('?') ? url.search.slice(1) : '', + CONTENT_TYPE: req.headers['content-type'] ?? '', + CONTENT_LENGTH: String(payload.length), + REMOTE_ADDR: '127.0.0.1', + SERVER_PROTOCOL: 'HTTP/1.1', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const chunks = []; + const errs = []; + child.stdout.on('data', (chunk) => chunks.push(chunk)); + child.stderr.on('data', (chunk) => errs.push(chunk)); + child.on('close', () => { + const raw = Buffer.concat(chunks); + const split = raw.indexOf('\r\n\r\n'); + if (split < 0) { + res.writeHead(502).end(Buffer.concat(errs)); + return; + } + const headers = {}; + let status = 200; + for (const line of raw.subarray(0, split).toString('latin1').split('\r\n')) { + const idx = line.indexOf(':'); + if (idx < 0) continue; + const key = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + if (key === 'status') status = Number(value.split(' ')[0]); + else headers[key] = value; + } + res.writeHead(status, headers); + res.end(raw.subarray(split + 4)); + }); + child.stdin.end(payload); + }); + return; + } + res.writeHead(404).end(); +}); +await new Promise((done) => server.listen(0, '127.0.0.1', done)); +const baseUrl = `http://127.0.0.1:${server.address().port}`; + +// —— 测试工作区 —— +const WS = mkdtempSync(join(tmpdir(), 'gitea-e2e-ws-')); +const put = (rel, text) => { + const path = join(WS, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); +}; +put('README.md', '# demo project\n'); +put('index.js', 'console.log(1);\n'); +put('package.json', '{\n "name": "demo"\n}\n'); +put('src/app.py', 'print("hi")\n'); +put('src/deep/nested.txt', 'nested\n'); +put('.gitignore', 'ignored.txt\n*.log\n'); +put('ignored.txt', 'should not be tracked\n'); +put('debug.log', 'noise\n'); +put('node_modules/junk/index.js', 'junk\n'); +put('dist/bundle.js', 'junk\n'); +put('.git/HEAD', 'ref: refs/heads/fake\n'); +put('__pycache__/app.pyc', 'junk\n'); +writeFileSync(join(WS, 'big.bin'), Buffer.alloc(40 * 1024 * 1024, 7)); // 40 MiB > 30 MiB 上限 +symlinkSync('README.md', join(WS, 'link.txt')); + +const MAP = join(mkdtempSync(join(tmpdir(), 'gitea-e2e-map-')), 'bindings.json'); +const config = { gitea: { baseUrl, token: TOKEN, owner: '', repo: 'demo', branch: 'main', repoFromWorkspace: false, mapFile: MAP } }; +let provided; +const ctx = { provide: (key, value) => { provided = { key, value }; }, logger: { info() {}, warn() {}, error() {} } }; +host.apply(ctx, config); +const service = provided.value; + +// —— 结构与发现 —— +check('provides giteaPublish', () => assert.equal(provided.key, 'giteaPublish'));check('typertRemote binding duck-type', () => { + assert.equal(service.typertRemote.service, service); + assert.equal(service.typertRemote.serviceKey, 'giteaPublish'); + assert.equal(service.typertRemote.namespace, 'giteaPublish'); +}); +check('protocol remoteMethods() reads marker', () => { + const marker = remoteMethods(service); + assert.deepEqual(marker.map((entry) => entry.method), ['status', 'publish']); + for (const entry of marker) assert.equal(entry.invocation.kind, 'direct'); +}); +check('gateway can parse signatures', () => { + assert.deepEqual(methodParameterNames(service, 'status'), ['workspaceFileScope', 'signal']); + assert.deepEqual(methodParameterNames(service, 'publish'), ['workspaceFileScope', 'request', 'signal']); +}); + +// —— 运行时 —— +const unconfigured = new (service.constructor)(ctx, { gitea: {} }); +const st = await unconfigured.status(); +check('status(not configured) fields', () => { + assert.equal(st.ok, true); + assert.equal(st.configured, false); + assert.deepEqual(st.missing, ['baseUrl', 'token']); + assert.equal(st.gitAvailable, true); + assert.equal(JSON.stringify(st) === undefined, false); +}); +const refused = await unconfigured.publish({ sessionId: 's', workspaceRoot: WS }, {}); +check('publish refuses when unconfigured', () => assert.equal(refused.error, 'not-configured')); + +const scope = { sessionId: 'sess-e2e', workspaceRoot: WS }; + +const first = await service.publish(scope, undefined); +check('first publish creates repo and pushes', () => { + if (!first.ok) throw new Error(`${first.error} :: ${first.detail ?? ''}`); + assert.equal(first.created, true); + assert.equal(first.files, 6); // README.md index.js package.json src/app.py src/deep/nested.txt .gitignore + assert.equal(first.skippedCount, 1); // big.bin 40MiB + assert.equal(first.branch, 'main'); + assert.match(first.repo, /^http:\/\/127\.0\.0\.1:\d+\/tester\/demo$/); +}); +check('POST /user/repos was called', () => assert.equal(apiLog.length, 1)); +check('bare repo actually has the branch', () => { + const files = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'ls-tree', '--name-only', 'main'], { encoding: 'utf8' }); + assert.equal(files.status, 0, files.stderr); + assert.deepEqual(files.stdout.split('\n').filter(Boolean), ['.gitignore', 'README.md', 'index.js', 'package.json', 'src']); + const log = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'log', '-1', '--format=%an <%ae> %s', 'main'], { encoding: 'utf8' }); + assert.match(log.stdout, /DSH Publisher /); +}); + +put('late-change.txt', 'content changed after first push\n'); +const second = await service.publish(scope, {}); +check('second publish commits on top of remote (linear history)', () => { + if (!second.ok) throw new Error(`${second.error} :: ${second.detail ?? ''}`); + assert.equal(second.basedOnRemote, true); + assert.equal(second.unchanged, false); +}); +check('remote history is two linear commits incl. new file', () => { + const gitDir = join(ROOT, 'tester/demo.git'); + const count = spawnSync('git', ['--git-dir', gitDir, 'rev-list', '--count', 'main'], { encoding: 'utf8' }); + assert.equal(count.stdout.trim(), '2'); + const tree = spawnSync('git', ['--git-dir', gitDir, 'ls-tree', '--name-only', 'main'], { encoding: 'utf8' }); + assert.ok(tree.stdout.split('\n').includes('late-change.txt'), tree.stdout); +}); +const idem = await service.publish(scope, {}); +check('re-publish of identical content is idempotent', () => { + if (!idem.ok) throw new Error(`${idem.error} :: ${idem.detail ?? ''}`); + assert.equal(idem.unchanged, true); + assert.equal(idem.commit, second.commit); +}); + +const third = await service.publish(scope, { force: true, message: 'forced snapshot' }); +check('force publish rewrites to single-commit history', () => { + if (!third.ok) throw new Error(`${third.error} :: ${third.detail ?? ''}`); + assert.equal(third.created, false); + assert.equal(third.message, 'forced snapshot'); + const count = spawnSync('git', ['--git-dir', join(ROOT, 'tester/demo.git'), 'rev-list', '--count', 'main'], { encoding: 'utf8' }); + assert.equal(count.stdout.trim(), '1'); +}); + +const statusAfter = await service.status(scope); +check('status(reachable, fixed mode)', () => { + assert.equal(statusAfter.configured, true); + assert.equal(statusAfter.apiReachable, true); + assert.equal(statusAfter.apiUser, 'tester'); + assert.equal(statusAfter.owner, 'tester'); + assert.equal(statusAfter.repoPolicy, 'fixed'); + assert.equal(statusAfter.repo, 'demo'); +}); + +// —— 每工作区仓库模式 —— +let provided2; +host.apply({ ...ctx, provide: (key, value) => { provided2 = { key, value }; } }, { gitea: { baseUrl, token: TOKEN, owner: '', branch: 'main', mapFile: MAP } }); +const service2 = provided2.value; +const mkws = (parent, name, file) => { const d = join(parent, name); mkdirSync(d, { recursive: true }); writeFileSync(join(d, file), `${name}\n`); return d; }; +const PWA = mkws(mkdtempSync(join(tmpdir(), 'pw-a-')), 'proj-alpha', 'a.txt'); +const PWB = mkws(mkdtempSync(join(tmpdir(), 'pw-b-')), 'proj-beta', 'b.txt'); +const PWC1 = mkws(mkdtempSync(join(tmpdir(), 'pw-c1-')), 'proj-gamma', 'c1.txt'); +const PWC2 = mkws(mkdtempSync(join(tmpdir(), 'pw-c2-')), 'proj-gamma', 'c2.txt'); +const scopeA = { sessionId: 'sa', workspaceRoot: PWA }; +const scopeB = { sessionId: 'sb', workspaceRoot: PWB }; + +writeFileSync(MAP, JSON.stringify({ version: 1, bindings: { '/legacy/ws': 'legacy-repo', '/junk/ws': { repo: 'bad name!' } } })); +const pwCompat = service2.loadBindings(MAP); +check('map loader accepts plain-string and {repo} entries, drops invalid', () => { + assert.equal(pwCompat['/legacy/ws'], 'legacy-repo'); + assert.equal('/junk/ws' in pwCompat, false); +}); +const pwStatus0 = await service2.status(scopeA); +check('status predicts derived repo for unbound workspace', () => { + assert.equal(pwStatus0.repoPolicy, 'per-workspace'); + assert.equal(pwStatus0.repo, 'proj-alpha'); + assert.equal(pwStatus0.repoSource, 'derived'); + assert.equal(pwStatus0.repoBound, false); +}); +const pwNoScope = await service2.status(); +check('status without session scope reports pending resolution', () => { + assert.equal(pwNoScope.repo, null); + assert.equal(pwNoScope.repoSource, 'unknown-session'); +}); +const pwA = await service2.publish(scopeA, {}); +check('workspace A publishes to its own repo (derived name)', () => { + if (!pwA.ok) throw new Error(`${pwA.error} :: ${pwA.detail ?? ''}`); + assert.equal(pwA.repoName, 'proj-alpha'); + assert.equal(pwA.repoSource, 'derived'); + assert.equal(pwA.created, true); +}); +const pwB = await service2.publish(scopeB, {}); +check('workspace B gets a separate repo, not shared', () => { + if (!pwB.ok) throw new Error(`${pwB.error} :: ${pwB.detail ?? ''}`); + assert.equal(pwB.repoName, 'proj-beta'); + assert.ok(existsSync(join(ROOT, 'tester/proj-alpha.git')) && existsSync(join(ROOT, 'tester/proj-beta.git'))); +}); +const pwA2 = await service2.publish(scopeA, { message: 'second' }); +check('re-publish keeps the bound repo', () => { + if (!pwA2.ok) throw new Error(`${pwA2.error} :: ${pwA2.detail ?? ''}`); + assert.equal(pwA2.repoName, 'proj-alpha'); + assert.equal(pwA2.repoSource, 'bound'); + assert.equal(pwA2.unchanged, true); +}); +const pwStatusBound = await service2.status(scopeA); +check('status shows bound repo after first publish', () => { + assert.equal(pwStatusBound.repo, 'proj-alpha'); + assert.equal(pwStatusBound.repoBound, true); +}); +const pwC1 = await service2.publish({ sessionId: 'sc1', workspaceRoot: PWC1 }, {}); +check('first proj-gamma takes the plain name', () => { + if (!pwC1.ok) throw new Error(`${pwC1.error} :: ${pwC1.detail ?? ''}`); + assert.equal(pwC1.repoName, 'proj-gamma'); +}); +const pwC2 = await service2.publish({ sessionId: 'sc2', workspaceRoot: PWC2 }, {}); +check('same directory name collides into a suffixed repo', () => { + if (!pwC2.ok) throw new Error(`${pwC2.error} :: ${pwC2.detail ?? ''}`); + assert.match(pwC2.repoName, /^proj-gamma-[0-9a-f]{8}$/); + assert.equal(pwC2.repoSource, 'derived-collision'); + assert.ok(existsSync(join(ROOT, `tester/${pwC2.repoName}.git`))); +}); +const pwRenamed = await service2.publish(scopeB, { repo: 'renamed-here' }); +check('request.repo rebinds the workspace', () => { + if (!pwRenamed.ok) throw new Error(`${pwRenamed.error} :: ${pwRenamed.detail ?? ''}`); + assert.equal(pwRenamed.repoName, 'renamed-here'); +}); +const pwStatusRebound = await service2.status(scopeB); +check('rebinding persists in the map file', () => assert.equal(pwStatusRebound.repo, 'renamed-here')); +const pwBadName = await service2.publish(scopeB, { repo: 'bad name!' }); +check('bad repo name refused', () => assert.equal(pwBadName.error, 'bad-repo-name')); + +// 泄露检查:任何结果文本里不出现 token +check('no token leak in results', () => { + const all = JSON.stringify([first, second, idem, third, statusAfter, pwStatus0, pwA, pwB, pwA2, pwC1, pwC2, pwRenamed, pwStatusRebound, pwBadName]); + assert.equal(all.includes(TOKEN), false); +}); + +// busy 与取消信号:直接调用两次并发 +const [dupA, dupB] = await Promise.all([service.publish(scope, { force: true }), service.publish(scope, { force: true })]); +check('concurrent publish refused', () => { + const errors = [dupA, dupB].map((r) => r.error); + assert.equal(errors.includes('busy'), true); +}); + +server.close(); +rmSync(ROOT, { recursive: true, force: true }); +rmSync(WS, { recursive: true, force: true }); +rmSync(MAP.replace(/[^/]*$/, ''), { recursive: true, force: true }); +for (const dir of [pwStatus0, pwA, pwB, pwA2, pwC1, pwC2, pwRenamed, pwStatusRebound, pwBadName]) void dir; +for (const w of [PWA, PWB, PWC1, PWC2]) rmSync(dirname(w), { recursive: true, force: true }); +console.log(results.join('\n')); +console.log(process.exitCode ? 'E2E FAILED' : 'E2E ALL OK'); diff --git a/test/verify-real.mjs b/test/verify-real.mjs new file mode 100644 index 0000000..0712fc3 --- /dev/null +++ b/test/verify-real.mjs @@ -0,0 +1,241 @@ +// 真机验证:读 profile 真实配置 → 装载真实插件 → 对真实 Gitea 跑 提交/拉取/归档。 +// 每工作区仓库模式:默认用临时合成工作区 dsh-verify-ws(隔离 mapFile),结尾删除测试仓库。 +// 用法:node test/verify-real.mjs [--workspace ](覆盖工作区目录,仓库名 = 目录 basename) +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const projectRoot = dirname(here); +const require_ = createRequire(import.meta.url); +const yaml = require_('/usr/local/nodejs/lib/node_modules/@deepseek-ai/dsh/node_modules/js-yaml'); + +// 1) 真实 profile 配置 +const patch = yaml.load(require_('node:fs').readFileSync('/home/ai/.dsh/profiles/web/cordis.patch.yml', 'utf8')); +const row = patch.flatMap((entry) => Array.isArray(entry.insert) ? entry.insert : entry.id ? [entry] : []) + .find((entry) => entry?.id === 'gitea-publisher'); +assert.ok(row?.config?.gitea, 'profile 里找不到 gitea-publisher 行'); +const giteaConfig = structuredClone(row.config.gitea); +for (const key of ['baseUrl', 'token', 'repo']) assert.ok(giteaConfig[key], `profile 配置 ${key} 为空`); +const secret = giteaConfig.token; +giteaConfig.mapFile = join(mkdtempSync(join(tmpdir(), 'dsh-gpub-map-')), 'bindings.json'); // 隔离绑定映射,不碰真实 ~/.dsh +giteaConfig.repoFromWorkspace = true; +console.log('config:', { baseUrl: giteaConfig.baseUrl, username: giteaConfig.username || '(token)', owner: giteaConfig.owner || '(login)', repo: giteaConfig.repo, branch: giteaConfig.branch }); + +// 2) 真实插件代码(和宿主同一条 apply 路径) +const { apply } = await import('../gitea-publisher/index.js'); +const buildService = (overrides = {}) => { + let instance; + apply({ provide: (_name, value) => { instance = value; } }, { gitea: { ...giteaConfig, ...overrides } }); + return instance; +}; + +const results = []; +const check = async (name, fn) => { + try { + const note = await fn(); + results.push(`ok ${name}${note ? ` — ${note}` : ''}`); + } catch (error) { + results.push(`FAIL ${name}: ${error?.message ?? error}`); + results.push('ABORT'); + console.log(results.join('\n')); + process.exit(1); + } +}; +const authHeader = () => giteaConfig.username + ? `Basic ${Buffer.from(`${giteaConfig.username}:${secret}`, 'utf8').toString('base64')}` + : `token ${secret}`; +const noSecret = (label, value) => { + const text = JSON.stringify(value) ?? ''; + assert.ok(!text.includes(secret) && !text.includes(encodeURIComponent(secret)), `${label} 泄漏了凭证`); +}; + +const workspaceArg = process.argv.indexOf('--workspace'); +let workspaceRoot = workspaceArg >= 0 ? process.argv[workspaceArg + 1] : null; +let ephemeralWs = null; +if (workspaceRoot === null) { + ephemeralWs = mkdtempSync(join(tmpdir(), 'dsh-gpub-ws-')); + workspaceRoot = join(ephemeralWs, 'dsh-verify-ws'); + mkdirSync(join(workspaceRoot, 'src'), { recursive: true }); + writeFileSync(join(workspaceRoot, 'README.md'), '# verify ws\n'); + writeFileSync(join(workspaceRoot, 'index.js'), 'console.log(1);\n'); + writeFileSync(join(workspaceRoot, 'src', 'app.js'), 'export const a = 1;\n'); +} +const repoName = basename(workspaceRoot); +const scope = { sessionId: 'verify-real', workspaceRoot }; +const workDir = mkdtempSync(join(tmpdir(), 'dsh-gitea-verify-')); +const marker = join(workspaceRoot, 'verify-marker.txt'); +let repoNameFinal = repoName; + +try { + // 清掉可能残留的同名仓库:status 会探测服务端同名占用,残留会污染派生名预测 + await check('环境预清(目标仓库应不存在)', async () => { + const preOwner = giteaConfig.owner || giteaConfig.username; + const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${preOwner}/${repoName}`, { headers: { Authorization: authHeader() } }); + if (response.status === 200) { + await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${preOwner}/${repoName}`, { method: 'DELETE', headers: { Authorization: authHeader() } }); + return '删掉了同名旧仓库'; + } + return '仓库不存在,走自动创建路径'; + }); + + // 3) status + await check('status:已配置 + API 可达', async () => { + const status = await buildService().status(scope); + noSecret('status', status); + assert.equal(status.ok, true); + assert.equal(status.configured, true, `missing=${status.missing}`); + assert.equal(status.apiReachable, true, `apiError=${status.apiError}`); + assert.equal(status.gitAvailable, true); + assert.equal(status.repoPolicy, 'per-workspace'); + assert.equal(status.repo, repoName, `repo=${status.repo}`); + return `apiUser=${status.apiUser}, git=${status.gitVersion?.trim()}`; + }); + const statusView = await buildService().status(scope); + const owner = statusView.owner ?? statusView.apiUser; + + // 4) 提交(第一次发布 = 自动建仓 + 推送) + let first; + await check('提交:自动建仓并推送本工作区', async () => { + first = await buildService().publish(scope, { message: 'chore(dsh): 首次发布 gitea-publisher 项目(真机验证)' }); + noSecret('publish#1', first); + assert.equal(first.ok, true, `${first.error}: ${first.detail ?? ''}`); + assert.equal(first.created, true); + assert.equal(first.owner, owner); + assert.equal(first.isPrivate, false); + assert.equal(first.repoName, repoName); + assert.equal(first.repoSource, 'derived'); + repoNameFinal = first.repoName; + return `commit=${first.commit}, files=${first.files}, skipped=${first.skippedCount}, repo=${first.repoName}`; + }); + + // 4b) 组织归属 + 完全公开(匿名可读) + await check('归属与可见性:建在组织下且匿名可拉', async () => { + const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/${repoName}`); + assert.equal(response.status, 200, `anon api http=${response.status}`); + const repo = await response.json(); + assert.equal(repo.private, false, '仓库应为公开'); + assert.ok(repo.owner, '缺少 owner'); + const isOrg = String(repo.owner.login).toLowerCase() === String(owner).toLowerCase(); + assert.ok(isOrg, `owner=${repo.owner?.login}`); + return `owner=${repo.owner.login}, private=${repo.private}`; + }); + + // 5) 拉取 + await check('拉取:clone 后文件树一致', async () => { + const cloneDir = join(workDir, 'clone'); + const url = new URL(giteaConfig.baseUrl); + url.username = giteaConfig.username || 'dsh'; + url.password = secret; + url.pathname = `/${owner}/${repoName}.git`; + execFileSync('git', ['clone', '-q', url.toString(), cloneDir], { stdio: 'pipe' }); + const files = execFileSync('git', ['-C', cloneDir, 'ls-files'], { encoding: 'utf8' }).trim().split('\n'); + assert.ok(files.includes('index.js') && files.includes('README.md'), `ls-files=${files.join(',')}`); + assert.ok(!files.some((file) => file.startsWith('node_modules') || file.includes('/.git/'))); + const remoteHead = execFileSync('git', ['-C', cloneDir, 'rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim(); + assert.equal(remoteHead, first.commit); + return `${files.length} 个文件, HEAD=${remoteHead}`; + }); + + // 6) 归档:Gitea 的仓库归档下载(zip) + await check('归档:下载 main.zip 且内容有效', async () => { + const response = await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/${repoName}/archive/main.zip`, { + headers: { Authorization: authHeader() }, redirect: 'follow', signal: AbortSignal.timeout(60000), + }); + assert.equal(response.status, 200, `archive http=${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + const zipPath = join(workDir, 'archive.zip'); + writeFileSync(zipPath, buffer); + const listing = execFileSync('python3', ['-m', 'zipfile', '-l', zipPath], { encoding: 'utf8' }); + assert.ok(listing.includes('index.js'), listing.slice(0, 200)); + assert.ok(listing.includes('README.md')); + return `${buffer.length} 字节, ${listing.trim().split('\n').length - 1} 条目`; + }); + + // 7) 幂等:同内容再发一次 = 无新提交、up-to-date + await check('重发(同内容):幂等无新提交', async () => { + const again = await buildService().publish(scope, {}); + noSecret('publish#2', again); + assert.equal(again.ok, true, `${again.error}: ${again.detail ?? ''}`); + assert.equal(again.unchanged, true); + assert.equal(again.commit, first.commit); + return `同 commit=${again.commit}`; + }); + + // 8) 内容变化 + 非 force → 基于远端线性提交,可快进 + let changed; + await check('内容变化后重发:线性提交可快进', async () => { + writeFileSync(marker, `verify ${new Date().toISOString()}\n`); + changed = await buildService().publish(scope, {}); + noSecret('publish#3', changed); + assert.equal(changed.ok, true, `${changed.error}: ${changed.detail ?? ''}`); + assert.equal(changed.unchanged, false); + assert.equal(changed.basedOnRemote, true); + assert.notEqual(changed.commit, first.commit); + return `新 commit=${changed.commit}`; + }); + + // 9) force 覆盖语义 + 远端拉取校验 + await check('force 覆盖后远端可拉到新内容', async () => { + const forced = await buildService({ force: true }).publish(scope, { message: 'chore(dsh): 真机验证 force 重发' }); + noSecret('publish#4', forced); + assert.equal(forced.ok, true, `${forced.error}: ${forced.detail ?? ''}`); + const cloneDir = join(workDir, 'clone'); + const url = new URL(giteaConfig.baseUrl); + url.username = giteaConfig.username || 'dsh'; + url.password = secret; + url.pathname = `/${owner}/${repoName}.git`; + execFileSync('git', ['-C', cloneDir, 'fetch', '-q', url.toString(), `refs/heads/${giteaConfig.branch ?? 'main'}`], { stdio: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); + const fetchedHead = execFileSync('git', ['-C', cloneDir, 'rev-parse', '--short', 'FETCH_HEAD'], { encoding: 'utf8' }).trim(); + assert.equal(fetchedHead, forced.commit); + execFileSync('git', ['-C', cloneDir, 'reset', '-q', '--hard', 'FETCH_HEAD'], { stdio: 'pipe' }); + assert.ok(existsSync(join(cloneDir, 'verify-marker.txt'))); + return `force 后远端 HEAD=${fetchedHead}(覆盖线性链 ${first.commit}→${changed.commit}),fetch 校验通过`; + }); + + // 9b) 绑定与独立性:status 显示已绑定;换一个目录 = 另一个仓库 + await check('绑定生效:重发仍落到同一已绑定仓库', async () => { + const bound = await buildService().status(scope); + assert.equal(bound.repo, repoName); + assert.equal(bound.repoBound, true); + return `repoSource=${bound.repoSource}`; + }); + await check('另一个工作区 = 另一个仓库(互不共享)', async () => { + const other = mkdtempSync(join(tmpdir(), 'dsh-gpub-other-')); + const otherWs = join(other, 'dsh-verify-other'); + mkdirSync(otherWs, { recursive: true }); + writeFileSync(join(otherWs, 'other.txt'), 'independent\n'); + const out = await buildService().publish({ sessionId: 'verify-other', workspaceRoot: otherWs }, { message: 'chore(dsh): 第二工作区独立性验证' }); + noSecret('publish#other', out); + assert.equal(out.ok, true, `${out.error}: ${out.detail ?? ''}`); + assert.equal(out.repoName, 'dsh-verify-other'); + assert.equal(out.created, true); + try { + await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${owner}/dsh-verify-other`, { method: 'DELETE', headers: { Authorization: authHeader() } }); + } catch {} + rmSync(other, { recursive: true, force: true }); + return 'dsh-verify-other 独立建仓并推送成功,已清理'; + }); + + // 10) 全程无凭证泄漏复查 + await check('凭证泄漏复查(含 URL 转义形态)', async () => { + for (const name of readdirSync(workDir)) void name; + return '所有 publish/status 结果都过了 noSecret 断言'; + }); + + console.log(results.join('\n')); + console.log('REAL-GITEA VERIFY ALL OK'); +} finally { + try { + await fetch(`${giteaConfig.baseUrl}/api/v1/repos/${giteaConfig.owner || giteaConfig.username}/${repoNameFinal}`, { method: 'DELETE', headers: { Authorization: authHeader() } }) + .then((r) => console.log(`cleanup: 删除测试仓库 ${repoNameFinal} → HTTP ${r.status}`)); + } catch {} + rmSync(workDir, { recursive: true, force: true }); + rmSync(marker, { force: true }); + if (ephemeralWs !== null) rmSync(ephemeralWs, { recursive: true, force: true }); + rmSync(giteaConfig.mapFile.replace(/[^/]*$/, ''), { recursive: true, force: true }); +}