Compare commits

...
4 Commits
Author SHA1 Message Date
修雨 0de6e46ef4 docs: cut 1.0.41 changelog + backfill 1.0.40 #488 (#495)
- 1.0.41: install auto-fallback to Gitee (#492) + CI auto-push mirror (#493)
- 1.0.40: add the missing #488 (skills embedded in binary) entry
2026-06-24 22:26:54 +08:00
修雨 9a8157a4cd ci(mirror): auto-push code to Gitee via HTTPS token (no SSH key) (#493)
Rewrite mirror-to-gitee.yml to push main + tags to the Gitee mirror using
HTTPS + GITEE_TOKEN (reusing the existing secret) instead of hub-mirror-action
+ an SSH key. Gated on GITEE_TOKEN; needs GITEE_USER + GITEE_REPO secrets.
Keeps Gitee code/raw in sync automatically on every push to main + tags, so
no more manual git push after each release.
2026-06-24 22:26:47 +08:00
修雨 b8e915edfb feat(install): auto-fallback to Gitee when GitHub unreachable (#492)
Probe GitHub Releases on startup; if it is unreachable (typical in mainland
China), automatically switch to the Gitee mirror — so a plain
`curl … | sh` (or .ps1 / install-skills.sh) works everywhere with no
DWS_GITEE_REPO env var. Explicit DWS_GITEE_REPO still wins; DWS_NO_FALLBACK=1
disables the probe; local source-checkout installs skip it.
2026-06-24 22:26:40 +08:00
修雨 bd370ed0e9 fix(doc): strip duplicate title H1 on the JSONML doc-create path (#464)
PR #448 stripped a leading H1 matching --name on the markdown path of
`dws doc create`, but the JSONML path (--content-format jsonml) was never
covered. Rich documents — tables, callouts, styled blocks — go through
create_document + update_document(jsonml=...), which writes the body
verbatim, so a leading h1 whose text equals the document name renders the
title twice (the "two headings" effect the doc platform shows because it
already renders --name as the page title).

Add stripLeadingDuplicateTitleJSONML: parse the marshaled JSONML body,
skip an optional ["root", {}, ...] wrapper, and drop the first node when
it is an h1 whose concatenated leaf text equals --name (trimmed,
case-insensitive). Any parse failure or non-match leaves the body
untouched, so a valid write is never blocked. A stderr note mirrors the
markdown path so agents learn the convention.
2026-06-24 18:34:16 +08:00
7 changed files with 281 additions and 20 deletions
+25 -20
View File
@@ -1,8 +1,9 @@
# 此 workflow 把本仓库代码自动镜像到 Gitee,供国内用户访问。
# 需要在仓库 Settings → Secrets and variables → Actions 配置两个 secret:
# GITEE_TOKEN —— Gitee 私人令牌(在 Gitee 设置→私人令牌生成),需勾选 projects + admin:repo_hook 权限。
# GITEE_PRIVATE_KEY —— SSH 私钥(本地 ssh-keygen 生成的私钥),对应公钥需添加到 Gitee 账号的 SSH 公钥中。
# 未配置 secret 时(例如 fork 仓库),mirror step 会被自动跳过,不会报错(不会出现红叉)。
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
name: Mirror code to Gitee
on:
@@ -18,22 +19,26 @@ on:
jobs:
mirror:
runs-on: ubuntu-latest
# GitHub Actions 不允许在 job-level if 直接引用 secrets,因此先用 env 暴露 secret,
# 再在 step 里做守卫判断。hub-mirror 必须有 SSH 私钥(GITEE_PRIVATE_KEY)才能推送,
# 故以它为守卫:未配置时整步跳过(代码镜像可改由 Gitee 侧 pull-mirror 承担),不报红叉。
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_PRIVATE_KEY: ${{ secrets.GITEE_PRIVATE_KEY }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Mirror to Gitee
if: env.GITEE_PRIVATE_KEY != ''
uses: Yikun/hub-mirror-action@master
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
src: "github/DingTalk-Real-AI"
dst: "gitee/DingTalk-Real-AI"
dst_key: ${{ secrets.GITEE_PRIVATE_KEY }}
dst_token: ${{ secrets.GITEE_TOKEN }}
account_type: "org"
static_list: "dingtalk-workspace-cli"
clone_style: "https"
force_update: true
fetch-depth: 0
- name: Push main + tags to Gitee
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# 镜像对齐(force:Gitee 始终跟随 GitHub)
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
+13
View File
@@ -6,6 +6,18 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.41] - 2026-06-24
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
### Added
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
### Changed
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
## [1.0.40] - 2026-06-24
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
@@ -13,6 +25,7 @@ This release adds China-accessible install mirrors so the CLI installs reliably
### Added
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
## [1.0.39] - 2026-06-18
+81
View File
@@ -335,6 +335,10 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok {
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
jsonml = stripped
}
createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params)
if err != nil {
return err
@@ -425,6 +429,83 @@ func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
return strings.TrimLeft(rest, "\r\n"), true
}
// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of
// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format
// jsonml) writes the body verbatim via update_document, so a leading h1 node
// whose text equals the document name renders the title twice — exactly the
// problem the markdown path already guards against.
//
// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON
// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The
// first content node is removed only when it is an h1 whose concatenated text
// (trimmed, case-insensitive) exactly equals name. Any parse failure or
// non-match returns the input untouched, so this never blocks a valid write.
func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" {
return jsonmlBody, false
}
var body []any
if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil {
return jsonmlBody, false
}
// Locate the index of the first content node, skipping a ["root", {}, ...]
// wrapper if present.
first := 0
if len(body) >= 2 {
if tag, ok := body[0].(string); ok && tag == "root" {
first = 2
}
}
if first >= len(body) {
return jsonmlBody, false
}
node, ok := body[first].([]any)
if !ok || len(node) == 0 {
return jsonmlBody, false
}
tag, ok := node[0].(string)
if !ok || tag != "h1" {
return jsonmlBody, false
}
if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) {
return jsonmlBody, false
}
body = append(body[:first], body[first+1:]...)
out, err := json.Marshal(body)
if err != nil {
return jsonmlBody, false
}
return string(out), true
}
// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the
// leading tag name and any attributes object, so ["h1", {..}, ["span", {..},
// ["span", {..}, "Title"]]] yields "Title".
func jsonmlNodeText(node any) string {
switch v := node.(type) {
case string:
return v
case []any:
var b strings.Builder
for i, child := range v {
// element[0] is the tag name; a map at element[1] is attributes.
if i == 0 {
if _, isStr := child.(string); isStr {
continue
}
}
if _, isMap := child.(map[string]any); isMap {
continue
}
b.WriteString(jsonmlNodeText(child))
}
return b.String()
default:
return ""
}
}
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
+107
View File
@@ -159,3 +159,110 @@ func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
}
}
// TestDocCreateStripsDuplicateTitleJSONML verifies the end-to-end JSONML path:
// `doc create --content-format jsonml` must drop a leading h1 whose text equals
// --name before forwarding the body to update_document, otherwise the rich
// document shows the page title twice.
func TestDocCreateStripsDuplicateTitleJSONML(t *testing.T) {
runner := &docCommandRunner{responses: []map[string]any{{"nodeId": "NODE_X"}}}
root := newDocTestRoot(runner)
body := `{"jsonml":[["h1",{},"命令树参考"],["p",{},"正文"]]}`
_, errOut, err := executeDocCommand(t, root,
"create", "--name", "命令树参考",
"--content-format", "jsonml", "--content", body)
if err != nil {
t.Fatalf("execute: %v\nstderr:\n%s", err, errOut)
}
if len(runner.all) != 2 {
t.Fatalf("calls = %d, want 2 (create + update)", len(runner.all))
}
if runner.all[1].Tool != "update_document" {
t.Fatalf("second tool = %q, want update_document", runner.all[1].Tool)
}
got, _ := runner.all[1].Params["jsonml"].(string)
if strings.Contains(got, "命令树参考") {
t.Errorf("jsonml = %q, want duplicate h1 stripped", got)
}
if !strings.Contains(got, "正文") {
t.Errorf("jsonml = %q, want body content kept", got)
}
if !strings.Contains(errOut, "已自动移除") {
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
}
}
// TestStripLeadingDuplicateTitleJSONML covers the JSONML-path counterpart: a
// leading h1 node whose text equals the document name is removed; everything
// else is left untouched.
func TestStripLeadingDuplicateTitleJSONML(t *testing.T) {
tests := []struct {
name string
body string
docName string
want string
stripped bool
}{
{
name: "root-wrapped duplicate h1 stripped",
body: `["root",{},["h1",{},"晚会简报"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["p",{},"正文"]]`,
stripped: true,
},
{
name: "nested leaf text matches and stripped",
body: `["root",{},["h1",{"uuid":"x"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"晚会简报"]]],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["p",{},"正文"]]`,
stripped: true,
},
{
name: "bare body without root wrapper",
body: `[["h1",{},"标题"],["p",{},"正文"]]`,
docName: "标题",
want: `[["p",{},"正文"]]`,
stripped: true,
},
{
name: "case insensitive match",
body: `["root",{},["h1",{},"Weekly REPORT"],["p",{},"x"]]`,
docName: "weekly report",
want: `["root",{},["p",{},"x"]]`,
stripped: true,
},
{
name: "distinct heading kept",
body: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
stripped: false,
},
{
name: "non-h1 leading node kept",
body: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
stripped: false,
},
{
name: "invalid json untouched",
body: `not json`,
docName: "x",
want: `not json`,
stripped: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := stripLeadingDuplicateTitleJSONML(tc.body, tc.docName)
if ok != tc.stripped {
t.Fatalf("stripped = %v, want %v", ok, tc.stripped)
}
if got != tc.want {
t.Fatalf("body = %q, want %q", got, tc.want)
}
})
}
}
+11
View File
@@ -19,6 +19,8 @@ set -eu
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API.
GITEE_REPO="${DWS_GITEE_REPO:-}"
# Auto-fallback Gitee mirror used when GitHub is unreachable (see pick_source).
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
@@ -48,6 +50,14 @@ gitee_api() {
return 1
}
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
GITEE_REPO="$GITEE_FALLBACK_REPO"
printf ' ⚠ GitHub 不可达,自动切换国内 Gitee 镜像: %s\n' "$GITEE_REPO"
}
resolve_version() {
if [ "$VERSION" = "latest" ]; then
if [ -n "$GITEE_REPO" ]; then
@@ -197,6 +207,7 @@ install_skills_to_root() {
main() {
need_cmd curl
pick_source
resolve_version
printf '\n'
+20
View File
@@ -29,6 +29,8 @@ $Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
$GiteeFallbackRepo = if ($env:DWS_GITEE_FALLBACK_REPO) { $env:DWS_GITEE_FALLBACK_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
@@ -146,6 +148,20 @@ function Get-GiteeAssetUrl {
return ""
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
if ($env:DWS_NO_FALLBACK -eq "1") { return }
try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" -Method Head `
-TimeoutSec 12 -UseBasicParsing -ErrorAction Stop 2>$null | Out-Null
return
} catch {
$script:GiteeRepo = $GiteeFallbackRepo
Write-Say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: $script:GiteeRepo"
}
}
function Resolve-LatestVersion {
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
@@ -591,6 +607,10 @@ $SourceRoot = Resolve-SourceRoot
Write-Banner
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
# Skipped when installing from a local source checkout (no download needed).
if (-not $SourceRoot) { Resolve-Source }
if (!$NoSkills) {
Resolve-SkillMode
}
+24
View File
@@ -28,6 +28,11 @@ BIN_NAME="dws"
# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
# carry an unstable numeric id, so every asset is resolved by name at install time.
GITEE_REPO="${DWS_GITEE_REPO:-}"
# Auto-fallback: when DWS_GITEE_REPO is not set, the installer probes GitHub and,
# if it is unreachable (typical in mainland China), automatically switches to this
# Gitee mirror — so a plain `curl … | sh` works everywhere with no env var.
# Set DWS_NO_FALLBACK=1 to disable the probe and force GitHub.
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
VERSION="${DWS_VERSION:-latest}"
@@ -153,6 +158,21 @@ detect_arch() {
esac
}
# Decide the download source. An explicit DWS_GITEE_REPO always wins. Otherwise
# probe GitHub Releases; if it is unreachable (typical in mainland China), switch
# GITEE_REPO to the mirror so every subsequent resolve/download uses Gitee.
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
if need_cmd curl; then
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
elif need_cmd wget; then
wget -q --timeout=12 --tries=1 -O /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
fi
GITEE_REPO="$GITEE_FALLBACK_REPO"
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
}
# Resolve the latest version tag from GitHub
resolve_version() {
if [ "$VERSION" = "latest" ]; then
@@ -613,6 +633,10 @@ main() {
print_banner
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
# Skipped when installing from a local source checkout (no download needed).
[ -z "$source_root" ] && pick_source
# Resolve skill mode only when we are actually going to touch skills.
if [ "$NO_SKILLS" != "1" ]; then
resolve_skill_mode