Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0de6e46ef4 | ||
|
|
9a8157a4cd | ||
|
|
b8e915edfb | ||
|
|
bd370ed0e9 | ||
|
|
9fa76f8598 | ||
|
|
b175acb48f | ||
|
|
ed1cbd6f06 |
@@ -0,0 +1,44 @@
|
||||
# 把本仓库代码自动镜像到 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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
steps:
|
||||
- name: Checkout (full history + tags)
|
||||
if: env.GITEE_TOKEN != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -6,6 +6,27 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,14 @@ 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:-}"
|
||||
# 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}"
|
||||
@@ -29,10 +35,43 @@ 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
|
||||
}
|
||||
|
||||
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
|
||||
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 +79,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"
|
||||
@@ -154,6 +207,7 @@ install_skills_to_root() {
|
||||
|
||||
main() {
|
||||
need_cmd curl
|
||||
pick_source
|
||||
resolve_version
|
||||
|
||||
printf '\n'
|
||||
@@ -166,7 +220,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"
|
||||
|
||||
+68
-3
@@ -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,10 @@ $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 { "" }
|
||||
# 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"
|
||||
@@ -118,8 +124,61 @@ 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-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 "") {
|
||||
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 +348,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 +361,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 +543,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
|
||||
@@ -546,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
|
||||
}
|
||||
|
||||
+83
-5
@@ -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,15 @@ 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:-}"
|
||||
# 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}"
|
||||
@@ -77,6 +88,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"
|
||||
@@ -111,11 +158,36 @@ 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
|
||||
# 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 +473,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 +484,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 +555,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
|
||||
@@ -559,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
|
||||
|
||||
Executable
+84
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user