Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa76f8598 | ||
|
|
b175acb48f | ||
|
|
ed1cbd6f06 | ||
|
|
91f44a1efd | ||
|
|
1a7ba01e36 | ||
|
|
6310dcc39e |
@@ -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
|
||||
@@ -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,26 @@ 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.
|
||||
|
||||
### Added
|
||||
|
||||
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
|
||||
|
||||
## [1.0.38] - 2026-06-16
|
||||
|
||||
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -333,9 +334,19 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
|
||||
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
|
||||
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
|
||||
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
|
||||
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
|
||||
func attachAITag(cmd *cobra.Command, params map[string]any) {
|
||||
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
|
||||
params["clawType"] = edition.ClawType()
|
||||
}
|
||||
}
|
||||
|
||||
// deriveTitleFromText 在未显式指定 --title 时,从正文截取一个标题
|
||||
// (首行、最多 20 个字符),与 wukong 行为对齐 (send_personal_message 的
|
||||
// content 内携带 title)。
|
||||
@@ -442,6 +453,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return nil, "", apperrors.NewValidation("unsupported --msg-type: " + msgType + " (supported: image, file)")
|
||||
}
|
||||
params := map[string]any{"msgType": msgType, "content": contentJSON}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -481,6 +493,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
@@ -493,6 +506,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
return params, "send_personal_message", nil
|
||||
case hasUser:
|
||||
params := map[string]any{"title": title, "text": text, "receiverUserId": user}
|
||||
attachAITag(cmd, params)
|
||||
return params, "send_direct_message_as_user", nil
|
||||
default:
|
||||
params := map[string]any{
|
||||
@@ -500,6 +514,7 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
|
||||
"msgType": "markdown",
|
||||
"content": marshalMessageContent(title, text),
|
||||
}
|
||||
attachAITag(cmd, params)
|
||||
if strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -975,8 +990,10 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
// clawType 仅在 --ai-tag 时携带;默认不带,回复不强加 AI 角标。
|
||||
// edition 决定取值 (开源 openClaw / 悟空 wukong)。
|
||||
attachAITag(cmd, params)
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
@@ -1000,6 +1017,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -308,6 +310,104 @@ func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
|
||||
// by default NO user-identity send carries the clawType tool argument (so the IM
|
||||
// server renders no AI badge). Only when --ai-tag is passed does each path attach
|
||||
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
|
||||
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
|
||||
// dws does not surprise users by branding every message they send.
|
||||
func TestChatMessageAITagControlsClawType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
make func(runner executor.Runner) *cobra.Command
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "group-markdown",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "user-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "open-dingtalk-id-direct",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||||
},
|
||||
{
|
||||
name: "group-rich-media-image",
|
||||
make: newChatMessageSendCommand,
|
||||
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
make: newChatMessageReplyCommand,
|
||||
args: []string{
|
||||
"--conversation-id", "cid-xyz",
|
||||
"--ref-msg-id", "msg-1",
|
||||
"--ref-sender", "op-1",
|
||||
"--text", "got it",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
// Default: no --ai-tag → must omit clawType entirely (no badge).
|
||||
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if v, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("default send must omit clawType, got %#v", v)
|
||||
}
|
||||
})
|
||||
// Opt-in: --ai-tag → attach the edition claw identity.
|
||||
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := tc.make(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
got, ok := runner.last.Params["clawType"]
|
||||
if !ok {
|
||||
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
if got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Robot sends are rendered as bot messages already; they must NOT carry the
|
||||
// user-identity clawType argument.
|
||||
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
cmd := newChatMessageSendByBotCommand(runner)
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if _, ok := runner.last.Params["clawType"]; ok {
|
||||
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func equalAny(a, b any) bool {
|
||||
switch av := a.(type) {
|
||||
case []any:
|
||||
|
||||
@@ -68,6 +68,12 @@ type Hooks struct {
|
||||
Name string // "open" (default) / overlay identifier
|
||||
ScenarioCode string // injected into x-dingtalk-scenario-code header
|
||||
|
||||
// ClawTypeValue is the claw identity carried in message-send tool
|
||||
// arguments (parameter clawType) so the IM server can render the
|
||||
// "Send from AI" indicator on delivered messages. Empty → falls back
|
||||
// to DefaultOSSClawType; overlays set their own value (e.g. "wukong").
|
||||
ClawTypeValue string
|
||||
|
||||
// --- runtime mode ---
|
||||
IsEmbedded bool // true when running inside a host application
|
||||
HideAuthLogin bool // true suppresses the "dws auth login" command
|
||||
@@ -165,3 +171,14 @@ func Override(h *Hooks) {
|
||||
defer mu.Unlock()
|
||||
current = h
|
||||
}
|
||||
|
||||
// ClawType returns the claw identity for the active edition, falling back
|
||||
// to DefaultOSSClawType when the overlay does not set one. Message-send
|
||||
// helpers attach this value as the clawType tool argument so the IM server
|
||||
// can label delivered messages as sent via AI.
|
||||
func ClawType() string {
|
||||
if v := Get().ClawTypeValue; v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultOSSClawType
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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 edition
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClawTypeDefaultsToOSSValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(defaultHooks())
|
||||
if got := ClawType(); got != DefaultOSSClawType {
|
||||
t.Fatalf("ClawType() = %q, want %q", got, DefaultOSSClawType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClawTypeUsesOverlayValue(t *testing.T) {
|
||||
prev := Get()
|
||||
defer Override(prev)
|
||||
|
||||
Override(&Hooks{Name: "overlay", ClawTypeValue: "wukong"})
|
||||
if got := ClawType(); got != "wukong" {
|
||||
t.Fatalf("ClawType() = %q, want overlay value %q", got, "wukong")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
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