chore(dsh): 发布工作区 'gitea' (DeepSeek Harness 快照 2026-10-13 08:08)
This commit is contained in:
@@ -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 <PAT>` 头。
|
||||
- 按钮弹层显示目标仓库、发布进度、成功(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.<ns>` 属性糖受 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` 之外的文件跳过并在结果里报告数量。
|
||||
@@ -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.<ns> 的属性糖受 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;
|
||||
},
|
||||
});
|
||||
@@ -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 跳过不提交
|
||||
@@ -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": { "<workspaceRoot>": { "repo": "<name>", "at": "<iso>" } } }
|
||||
// 首次发布按工作区目录名派生仓库名;重命名/挂载点变化导致目录名不同时,
|
||||
// 已有绑定保证继续推到同一仓库。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 <t>`。
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 <dsh-publisher@localhost>/);
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -0,0 +1,241 @@
|
||||
// 真机验证:读 profile 真实配置 → 装载真实插件 → 对真实 Gitea 跑 提交/拉取/归档。
|
||||
// 每工作区仓库模式:默认用临时合成工作区 dsh-verify-ws(隔离 mapFile),结尾删除测试仓库。
|
||||
// 用法:node test/verify-real.mjs [--workspace <dir>](覆盖工作区目录,仓库名 = 目录 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user