Compare commits

...
3 Commits
Author SHA1 Message Date
修雨 9fa76f8598 docs: cut 1.0.40 changelog + fix gitee mirror gate (#489)
- CHANGELOG: 1.0.40 (China mirror via Gitee + npmmirror, #486)
- mirror-to-gitee.yml: gate on GITEE_PRIVATE_KEY so it skips cleanly when the
  SSH key is unset (code mirror handled by Gitee-side pull-mirror) instead of
  failing the run.
2026-06-24 17:55:35 +08:00
修雨 b175acb48f feat(skill): embed skills/ into the binary, default skill setup to it (#488)
go:embed the skills/ tree (mono + multi) into the binary and make
`dws skill setup` install from the embedded bundle by default. Upgrading
the binary now refreshes the installed skill, instead of silently reusing
a stale copy probed from the current working directory. An explicit
--source / DWS_SKILL_SOURCE still overrides for development.

Replants the change from #441 onto main: #441 was stacked on the
abandoned feat/chat-bot-provisioning-openclaw base (PRs #407/#397 closed),
which is why its CI was red. The embedded-bundle test asserted a
connect.md doc that only existed on that dead base; retargeted it to a
reference doc that exists on main.
2026-06-24 17:55:23 +08:00
修雨 ed1cbd6f06 feat(install): China mirror via Gitee + npmmirror (#486)
国内安装加速:DWS_GITEE_REPO 开关从 Gitee 解析下载脚本/二进制/skill(默认仍 GitHub,向后兼容);release.yml 发版后 CI 自动镜像 release 附件到 Gitee;README 加国内安装说明。Approved-by: wxianfeng.
2026-06-24 17:14:40 +08:00
13 changed files with 522 additions and 12 deletions
+39
View File
@@ -0,0 +1,39 @@
# 此 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 会被自动跳过,不会报错(不会出现红叉)。
name: Mirror code to Gitee
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
# GitHub Actions 不允许在 job-level if 直接引用 secrets,因此先用 env 暴露 secret,
# 再在 step 里做守卫判断。hub-mirror 必须有 SSH 私钥(GITEE_PRIVATE_KEY)才能推送,
# 故以它为守卫:未配置时整步跳过(代码镜像可改由 Gitee 侧 pull-mirror 承担),不报红叉。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_PRIVATE_KEY: ${{ secrets.GITEE_PRIVATE_KEY }}
steps:
- name: Mirror to Gitee
if: env.GITEE_PRIVATE_KEY != ''
uses: Yikun/hub-mirror-action@master
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
+10
View File
@@ -58,6 +58,16 @@ jobs:
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
+8
View File
@@ -6,6 +6,14 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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.
### 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`.
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
+22
View File
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # install to PATH
</details>
## China mirror
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
**1. Install script + pre-built binary (Gitee mirror):**
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
**2. npm package (npmmirror mirror):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
+22
View File
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # 安装到 PATH
</details>
## 国内加速安装
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
**2. npm 包(npmmirror 镜像):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
+2 -1
View File
@@ -95,10 +95,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
skillSrc, err := resolveSkillSetupSource(source, mode)
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
if err != nil {
return err
}
defer srcCleanup()
dests, err := resolveSkillSetupTargets(target, mode)
if err != nil {
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
)
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
// override (validated as an on-disk dir). Otherwise it falls back to the skill
// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
// the version shipped with the running binary — upgrading the binary therefore
// refreshes the installed skill, instead of silently reusing a stale copy from
// the current working directory.
//
// The returned cleanup func removes any temp dir created for the embedded
// bundle; it is a no-op when an on-disk source is used. Always call it.
func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
noop := func() {}
explicit = strings.TrimSpace(explicit)
env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
if explicit != "" || env != "" {
dir, err := resolveSkillSetupSource(explicit, mode)
return dir, noop, err
}
return materializeEmbeddedSkillSource(mode)
}
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> subtree
// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
// real directory lets the existing dir-based install/copy logic stay unchanged.
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
noop := func() {}
sub := "skills/" + mode // embed.FS always uses forward slashes
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
}
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
if err != nil {
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tmp) }
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
dst := filepath.Join(tmp, filepath.FromSlash(rel))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
if readErr != nil {
return readErr
}
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
return mkErr
}
return os.WriteFile(dst, data, 0o644)
})
if walkErr != nil {
cleanup()
return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
}
return tmp, cleanup, nil
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"os"
"path/filepath"
"testing"
)
// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
// baked into the binary can be extracted to a temp dir and is a valid skill
// source root (so `dws skill setup` works with zero local checkout). The
// nested-reference and _common checks guard against the embed dropping nested
// docs or the `all:` prefix being lost (which would silently skip
// dot/underscore dirs).
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
if err != nil {
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
}
for _, rel := range []string{
"SKILL.md",
filepath.Join("references", "global-reference.md"),
filepath.Join("references", "best_practices", "_common"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
}
}
// cleanup must actually remove the temp dir.
cleanup()
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
}
}
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
// rather than probing the current working directory (the stale-skill footgun).
func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
t.Setenv("DWS_SKILL_SOURCE", "")
dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
if err != nil {
t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
}
}
+47 -3
View File
@@ -13,8 +13,12 @@ set -eu
# Environment variables (optional):
# DWS_VERSION — release tag (default: latest)
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
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:-}"
VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
@@ -29,10 +33,35 @@ need_cmd() {
fi
}
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
gitee_api() {
_url="$1"
_try=1
while [ "$_try" -le 4 ]; do
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
printf '%s' "$_resp"
return 0
fi
_try=$((_try + 1))
sleep 2
done
return 1
}
resolve_version() {
if [ "$VERSION" = "latest" ]; then
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable, so
# resolve the newest vN.N.N tag from the git tags endpoint instead.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
else
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
fi
if [ -z "$VERSION" ]; then
printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2
exit 1
@@ -40,6 +69,20 @@ resolve_version() {
fi
}
# Resolve a release asset's download URL by name (GitHub template vs Gitee API).
asset_url() {
_name="$1"
if [ -z "$GITEE_REPO" ]; then
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
return 0
fi
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
| tr '}' '\n' \
| grep "\"name\":[ ]*\"${_name}\"" \
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
| head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
}
extract_zip() {
archive="$1"
dest="$2"
@@ -166,7 +209,8 @@ main() {
TMPDIR_WORK="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/dws-skills.zip"
ASSET_URL="$(asset_url dws-skills.zip)"
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
+48 -3
View File
@@ -18,6 +18,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -25,6 +27,8 @@ $ErrorActionPreference = "Stop"
$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 { "" }
$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"
@@ -118,8 +122,47 @@ function Get-Arch {
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
function Invoke-GiteeApi {
param([string]$Uri)
# Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
for ($i = 1; $i -le 4; $i++) {
try {
return Invoke-RestMethod -Uri $Uri -UseBasicParsing
} catch {
if ($i -eq 4) { throw }
Start-Sleep -Seconds 2
}
}
}
function Get-GiteeAssetUrl {
param([string]$Name)
# Resolve a release asset's download URL by name via the Gitee API
# (Gitee attachment URLs carry an unstable numeric id, so never template them).
$rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
foreach ($a in $rel.assets) {
if ($a.name -eq $Name) { return $a.browser_download_url }
}
return ""
}
function Resolve-LatestVersion {
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
try {
# Gitee's /releases/latest and /releases endpoints are unreliable
# (404 / empty even when releases exist), so resolve the newest
# vN.N.N tag from the git tags endpoint instead.
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
$latest = $tags.name |
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
ForEach-Object { [version]($_.TrimStart('v')) } |
Sort-Object | Select-Object -Last 1
if ($latest) { $script:Version = "v$latest"; return }
} catch {}
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
return
}
try {
$response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
@@ -289,7 +332,8 @@ function Install-Binary {
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
$downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
@@ -301,7 +345,7 @@ function Install-Binary {
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
# Download and verify SHA256 checksum
$checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
try {
$checksumPath = Join-Path $tmpDir "checksums.txt"
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
@@ -483,7 +527,8 @@ function Install-Skills {
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
$zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip"
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
+59 -5
View File
@@ -15,6 +15,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
# via the Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -22,6 +24,10 @@ set -eu
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
BIN_NAME="dws"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
# 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:-}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
VERSION="${DWS_VERSION:-latest}"
@@ -77,6 +83,42 @@ download() {
fi
}
# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
# returns sporadic 502/503, so a single curl is unreliable; retry a few times
# before giving up. Prints the response body on success.
gitee_api() {
_url="$1"
_try=1
while [ "$_try" -le 4 ]; do
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
printf '%s' "$_resp"
return 0
fi
_try=$((_try + 1))
sleep 2
done
return 1
}
# Resolve the download URL of a release asset by file name.
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
# Gitee : query the release API and pick the asset whose name matches
# (Gitee attachment URLs carry an unstable numeric id, so we never
# template them — we read browser_download_url straight from the API).
asset_url() {
_name="$1"
if [ -z "$GITEE_REPO" ]; then
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
return 0
fi
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
| tr '}' '\n' \
| grep "\"name\":[ ]*\"${_name}\"" \
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
| head -1 \
| sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
}
extract_zip() {
archive="$1"
dest="$2"
@@ -114,8 +156,18 @@ detect_arch() {
# Resolve the latest version tag from GitHub
resolve_version() {
if [ "$VERSION" = "latest" ]; then
# Follow the redirect from /releases/latest to get the tag
if need_cmd curl; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable — they
# return 404 / an empty list even when releases exist — so resolve the
# newest version from the git tags endpoint instead: keep only vN.N.N
# tags and pick the highest by version order.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
elif need_cmd curl; then
# Follow the redirect from /releases/latest to get the tag
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
elif need_cmd wget; then
@@ -401,7 +453,8 @@ install_binary() {
resolve_version
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
download_url="$(asset_url "$archive_name")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
@@ -411,7 +464,7 @@ install_binary() {
download "$download_url" "$tmpdir/$archive_name"
# Download and verify SHA256 checksum
checksum_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
checksum_url="$(asset_url checksums.txt)"
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
if [ -n "$expected" ]; then
@@ -482,7 +535,8 @@ install_skills() {
resolve_version
skills_archive="dws-skills.zip"
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
download_url="$(asset_url "$skills_archive")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
tmpdir_skills="$(mktemp -d)"
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Mirror release artifacts to a Gitee release so China users can install without
# hitting GitHub. The repo code itself is kept in sync by Gitee's repo-mirror
# feature; this script handles what the mirror does NOT carry — the GitHub
# Release *attachments* (binaries, checksums, skills zip) — by uploading them to
# the matching Gitee release via the Gitee OpenAPI v5.
#
# Consumed by install.sh when DWS_GITEE_REPO is set (it resolves each asset's
# real download_url from the Gitee API, since Gitee attachment URLs carry an
# unstable numeric id).
#
# Required environment (CI secrets):
# GITEE_TOKEN Gitee private access token (scopes: projects)
# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
# Optional:
# VERSION release tag (default: git describe)
# DIST_DIR artifacts dir (default: ./dist)
# GITEE_API API base (default: https://gitee.com/api/v5)
#
# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
# step can live in release.yml without breaking forks that lack the secret.
set -eu
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
missing=""
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
if [ -n "$missing" ]; then
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
exit 0
fi
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
# ── Resolve or create the Gitee release for this tag ──────────────────────────
# Gitee mirror sync brings the git tag over, so the tag should already exist.
rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
if [ -z "$release_id" ]; then
echo " No Gitee release for ${VERSION} yet — creating it."
rel_json="$(curl -fsSL -X POST "${base}/releases" \
-F "access_token=${GITEE_TOKEN}" \
-F "tag_name=${VERSION}" \
-F "name=${VERSION}" \
-F "body=Mirror of GitHub release ${VERSION} for China users." \
-F "target_commitish=main" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
fi
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
echo " Gitee release id = ${release_id}"
# ── Upload each artifact as a release attachment ──────────────────────────────
uploaded=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
echo " ⬆ ${fn}"
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" \
-F "file=@${f}" 2>/dev/null || true)"
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
uploaded=$((uploaded + 1))
else
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
fi
done
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+27
View File
@@ -0,0 +1,27 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package dws is the module-root package. Its sole purpose is to bake the
// skills/ documentation tree into the binary at build time so that
// `dws skill setup` installs the skill version shipped with THIS binary,
// independent of any local checkout. See internal/app/skill_setup.go.
package dws
import "embed"
// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into
// the binary. The `all:` prefix is required so dot/underscore entries — e.g.
// references/best_practices/_common — are included rather than skipped.
//
//go:embed all:skills
var EmbeddedSkills embed.FS