Compare commits

..
1 Commits
Author SHA1 Message Date
tianlei.qjb f10a0599d2 chore: prepare release v1.0.5 2026-04-01 10:48:04 +08:00
18 changed files with 198 additions and 744 deletions
+3 -5
View File
@@ -37,14 +37,12 @@ jobs:
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
- name: Upload dws-skills.zip to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload skills package to release
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
+70 -39
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
@@ -87,54 +87,78 @@ cp dws ~/.local/bin/ # install to PATH
## Getting Started
```bash
dws auth login # browser opens automatically
dws auth login --device # for headless environments (Docker, SSH, CI)
### Step 1: Create a DingTalk Application
Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
<details>
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
</p>
</details>
### Step 2: Configure Redirect URL
Go to app settings → **Security Settings**. Add the following redirect URLs and save:
```
http://127.0.0.1,https://login.dingtalk.com
```
Select your organization and authorize. That's it.
> If your organization hasn't enabled CLI access, you'll be prompted to send an access request to your admin. Once approved, re-run `dws auth login`.
> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
<details>
<summary><strong>Organization hasn't enabled CLI access?</strong></summary>
1. After selecting your organization, click "Apply Now" to notify the admin
2. The admin receives a request card and can approve with one click
3. Once approved, re-run `dws auth login`
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="Apply for Access" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
</p>
</details>
### Step 3: Publish the Application
Click "App Release - Version Management & Release" to publish and go live.
<details>
<summary><strong>Admin: Enable CLI access for your organization</strong></summary>
Go to [Developer Platform](https://open-dev.dingtalk.com) → "CLI Access Management" → Enable.
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI Access Management" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
</p>
</details>
<details>
<summary><strong>Custom App mode (CI/CD, ISV integration)</strong></summary>
### Step 4: Request Whitelist Access
For enterprise-managed scenarios, create your own DingTalk app:
Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
1. [Open Platform Console](https://open-dev.dingtalk.com/fe/app#/corp/app) → Create App
2. Security Settings → Add redirect URLs: `http://127.0.0.1,https://login.dingtalk.com`
3. Publish the app
4. Login:
### Step 5: Authenticate
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
Credentials are securely persisted after first login (Keychain). Subsequent runs auto-refresh tokens.
Or via environment variables:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
<details>
<summary><strong>Credential Configuration Priority</strong></summary>
`client-id` and `client-secret` support multiple configuration methods with the following priority (highest to lowest):
| Priority | Method | Description |
|----------|--------|-------------|
| 1 | CLI flags / Persisted config | `--client-id` / `--client-secret` command-line arguments; auto-saved after first successful login with `client-secret` stored in system Keychain |
| 2 | Environment variables | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
| 3 | Default values | Hardcoded defaults (for development only) |
**Recommended usage**:
- **First login**: Use `--client-id` and `--client-secret` flags; credentials are securely persisted after successful login
- **Subsequent use**: Run `dws` commands directly; token refresh automatically reads saved credentials from Keychain
- **CI/CD environments**: Use environment variables
</details>
@@ -151,6 +175,13 @@ dws todo task list --dry-run # preview without executing
dws is designed as an AI-native CLI. Complete [Installation](#installation) and [Getting Started](#getting-started) first, then configure your agent:
```bash
# Configure auth via environment variables (recommended for agents, no interactive login)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
### Agent Invocation Patterns
```bash
@@ -303,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat | `chat` | 10 | `message` `group` `search` | Group CRUD, member management, bot messaging, webhook |
| Bot | `chat bot` | 6 | `bot` `group` `message` `search` | Robot creation/search, group/single messaging, webhook, message recall |
| Service | Command | Tools | Subcommands | Description |
|---------|---------|:-----:|-------------|-------------|
| Contact | `contact` | 8 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat | `chat` | 14 | `message` `group` `bot` `search` | Group CRUD, member management, topic replies, send as user |
| Bot | `chat bot` | 9 | — | Robot creation, group/single messaging, webhook, message recall |
| Calendar | `calendar` | 13 | `event` `room` `participant` `busy` | Events CRUD, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve/reject/revoke, pending tasks, initiated instances, process list |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send/recall DING messages |
| Ding | `ding` | 3 | `message` | Send/recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AITable | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, templates |
| AITable | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | Full CRUD for bases/tables/records/fields, views, import/export, templates |
| Workbench | `workbench` | 2 | `app` | Batch query app details |
| DevDoc | `devdoc` | 1 | `article` | Search platform docs and error codes |
| DevDoc | `devdoc` | 2 | `article` | Search platform docs and error codes |
> 86 commands across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
> 104 tools across 12 products. Run `dws --help` for the full list, or `dws <service> --help` for subcommands.
<details>
<summary>Coming soon</summary>
+69 -38
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/github/v/release/DingTalk-Real-AI/dingtalk-workspace-cli?color=red&label=release" alt="Latest Release"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.5-red" alt="v1.0.5"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
@@ -19,7 +19,7 @@
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
@@ -87,54 +87,78 @@ cp dws ~/.local/bin/ # 安装到 PATH
## 开始使用
```bash
dws auth login # 自动唤起浏览器
dws auth login --device # 无浏览器环境(Docker、SSH、CI)
### 步骤 1:创建钉钉应用
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
<details>
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
</p>
</details>
### 步骤 2:配置重定向 URL
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
```
http://127.0.0.1,https://login.dingtalk.com
```
选择组织并授权即可。
> 如果组织尚未开启 CLI 访问权限,系统会引导你向管理员发送申请。审批通过后重新执行 `dws auth login` 即可。
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
<details>
<summary><strong>组织未开启 CLI 访问权限?</strong></summary>
1. 选择组织后,点击「立即申请」通知管理员
2. 管理员收到申请卡片,一键审批
3. 审批通过后,重新执行 `dws auth login`
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i2/O1CN01wtsYuQ1CTbboVTlsD_!!6000000000082-2-tps-2696-1544.png" alt="申请权限" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
</p>
</details>
### 步骤 3:发布应用
点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
<details>
<summary><strong>管理员:为组织开启 CLI 访问权限</strong></summary>
进入 [开发者平台](https://open-dev.dingtalk.com) →「CLI 访问管理」→ 开启。
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01M8K7Wj1rZ0WikrZby_!!6000000005644-2-tps-2940-1596.png" alt="CLI访问管理" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
</p>
</details>
<details>
<summary><strong>自建应用模式(CI/CD、ISV 集成)</strong></summary>
### 步骤 4:申请白名单
企业自主管控场景,可创建自有钉钉应用:
加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
1. [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app#/corp/app) → 创建应用
2. 安全设置 → 添加重定向 URL:`http://127.0.0.1,https://login.dingtalk.com`
3. 发布应用
4. 登录:
### 步骤 5:登录认证
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
首次登录后凭证安全存储(Keychain),后续自动刷新 Token。
或通过环境变量:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
<details>
<summary><strong>凭证配置优先级</strong></summary>
`client-id` 和 `client-secret` 支持多种配置方式,按以下优先级生效(从高到低):
| 优先级 | 配置方式 | 说明 |
|------|----------|------|
| 1 | CLI 参数 / 持久化配置 | `--client-id` / `--client-secret` 命令行参数;首次登录成功后自动保存,`client-secret` 存储在系统 Keychain 中 |
| 2 | 环境变量 | `DWS_CLIENT_ID` / `DWS_CLIENT_SECRET` |
| 3 | 默认值 | 代码中的预设值(仅开发时使用) |
**推荐用法**:
- **首次登录**:使用 `--client-id` 和 `--client-secret` 参数,登录成功后凭证会自动安全存储
- **后续使用**:直接运行 `dws` 命令,Token 自动刷新时会从 Keychain 读取已保存的凭证
- **CI/CD 环境**:建议使用环境变量配置
</details>
@@ -151,6 +175,13 @@ dws todo task list --dry-run # 预览操作但不执行
dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开始使用](#开始使用),然后配置 Agent 环境:
```bash
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
### Agent 调用模式
```bash
@@ -303,22 +334,22 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
| 服务 | 命令 | 工具数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 10 | `message` `group` `search` | 群增删改查、成员管理、机器人消息、Webhook |
| 机器人 | `chat bot` | 6 | `bot` `group` `message` `search` | 机器人创建/搜索、群聊/单聊消息、Webhook、消息撤回 |
| 通讯录 | `contact` | 8 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat` | 14 | `message` `group` `bot` `search` | 群增删改查、成员管理、话题回复、以用户身份发消息 |
| 机器人 | `chat bot` | 9 | — | 机器人创建、群聊/单聊消息、Webhook、消息撤回 |
| 日历 | `calendar` | 13 | `event` `room` `participant` `busy` | 日程增删改查、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意/拒绝/撤销、待我审批、我发起的、流程列表 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送/撤回 DING 消息 |
| DING | `ding` | 3 | `message` | 发送/撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、统计 |
| 智能表格 | `aitable` | 20 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、模板 |
| 智能表格 | `aitable` | 27 | `base` `table` `record` `field` `attachment` `template` | 多维表/数据表/记录/字段全量 CRUD、视图、导入导出、模板 |
| 工作台 | `workbench` | 2 | `app` | 批量查询应用详情 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索开放平台文档与错误码 |
| 开发者文档 | `devdoc` | 2 | `article` | 搜索开放平台文档与错误码 |
> 12 个产品,86 个命令。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
> 12 个产品,104 个工具。运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
<details>
<summary>即将推出</summary>
+1 -1
View File
@@ -5,7 +5,6 @@ go 1.25.8
require (
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.18
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -16,6 +15,7 @@ require (
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
-10
View File
@@ -185,19 +185,9 @@ func newAuthLogoutCommand() *cobra.Command {
defer cancel()
_ = authpkg.RevokeTokenRemote(revokeCtx)
// Load token data to get associated clientId before deletion
var storedClientID string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
storedClientID = tokenData.ClientID
}
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up associated client secret from keychain
if storedClientID != "" {
_ = authpkg.DeleteClientSecret(storedClientID)
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
+1 -1
View File
@@ -261,7 +261,7 @@ func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
+2 -11
View File
@@ -52,9 +52,7 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
totalStart := time.Now()
timing := NewTimingCollector()
defer func() {
timing.PrintIfEnabled()
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
}
@@ -63,17 +61,12 @@ func Execute() int {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Attach timing collector to context for use by child components
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
initDuration := time.Since(initStart)
timing.Record("cmd_init", initDuration)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", initDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart))
}
// Run PreParse handlers on raw argv before Cobra parses flags.
@@ -83,10 +76,8 @@ func Execute() int {
execStart := time.Now()
executed, err := root.ExecuteC()
execDuration := time.Since(execStart)
timing.Record("cobra_exec", execDuration)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", execDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart))
}
if err != nil {
if executed == nil {
+4 -25
View File
@@ -101,9 +101,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
}
catalogStart := time.Now()
catalog, err := r.loader.Load(ctx)
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
if err != nil {
return executor.Result{}, err
}
@@ -134,11 +132,9 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
authStart := time.Now()
authToken := r.resolveAuthToken(ctx)
authDuration := time.Since(authStart)
RecordTiming(ctx, "auth_token", authDuration)
tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders())
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart))
}
if invocation.DryRun {
@@ -170,25 +166,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}, nil
}
// Fail-fast: reject unauthenticated requests before making network calls.
// This provides a clear error message instead of cryptic HTTP 400 from MCP.
if strings.TrimSpace(authToken) == "" {
return executor.Result{}, apperrors.NewAuth(
"未登录,请先执行 dws auth login",
apperrors.WithReason("not_authenticated"),
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
apperrors.WithActions("dws auth login"),
)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
callStart := time.Now()
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
callDuration := time.Since(callStart)
RecordTiming(ctx, "mcp_call", callDuration)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart))
}
if err != nil {
captureRuntimeFailure(invocation, err, err)
@@ -266,10 +247,8 @@ func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() {
loadDuration := time.Since(loadStart)
RecordTiming(ctx, "keychain_load", loadDuration)
if os.Getenv("DWS_PERF_DEBUG") != "" {
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", loadDuration)
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart))
}
}()
+6 -37
View File
@@ -45,7 +45,7 @@ func TestRuntimeRunnerIncludesContentScanReportWhenEnabled(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -90,7 +90,7 @@ func TestRuntimeRunnerBlocksUnsafeContentWhenEnforced(t *testing.T) {
cmd := NewRootCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
err := cmd.Execute()
if err == nil {
@@ -121,7 +121,7 @@ func TestCanonicalCommandUsesRuntimeRunnerWhenEnabled(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes", "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -255,37 +255,6 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
}
}
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
// a valid token are rejected with a clear error before making any network call.
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
cmd := NewRootCommand()
var stdout, stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
// No --token flag, should be rejected
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want authentication error")
}
// Verify we get a clear auth error, not a cryptic HTTP 400
errMsg := err.Error()
if !strings.Contains(errMsg, "未登录") {
t.Fatalf("Execute() error = %v, want error containing '未登录'", err)
}
if !strings.Contains(errMsg, "auth login") {
t.Fatalf("Execute() error = %v, want error containing 'auth login'", err)
}
}
func TestRuntimeRunnerFallsBackForUnavailableProduct(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
@@ -446,7 +415,7 @@ func TestCanonicalSensitiveToolAcceptsInteractiveConfirmation(t *testing.T) {
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetIn(strings.NewReader("yes\n"))
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "create_document", "--json", `{"title":"Quarterly"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -496,7 +465,7 @@ func TestRuntimeRunnerUsesProductEndpointOverride(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
@@ -659,7 +628,7 @@ func TestRuntimeRunnerReturnsErrorWhenMCPIsErrorTrue(t *testing.T) {
cmd := NewRootCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"})
cmd.SetArgs([]string{"mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`})
err := cmd.Execute()
if err == nil {
-177
View File
@@ -1,177 +0,0 @@
// 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 (
"context"
"fmt"
"io"
"os"
"sort"
"sync"
"time"
)
// Environment variable to enable performance timing output.
const PerfTimingEnv = "DWS_PERF_TIMING"
// timingContextKey is the context key for TimingCollector.
type timingContextKey struct{}
// TimingEntry represents a single timing measurement.
type TimingEntry struct {
Name string
Duration time.Duration
Timestamp time.Time
Seq int // insertion order
}
// TimingCollector collects timing measurements for a single command execution.
// It is safe for concurrent use.
type TimingCollector struct {
mu sync.Mutex
start time.Time
entries []TimingEntry
seq int
}
// NewTimingCollector creates a new collector with the start time set to now.
func NewTimingCollector() *TimingCollector {
return &TimingCollector{
start: time.Now(),
entries: make([]TimingEntry, 0, 16),
}
}
// Record adds a timing entry with the given name and duration.
func (tc *TimingCollector) Record(name string, d time.Duration) {
if tc == nil {
return
}
tc.mu.Lock()
defer tc.mu.Unlock()
tc.entries = append(tc.entries, TimingEntry{
Name: name,
Duration: d,
Timestamp: time.Now(),
Seq: tc.seq,
})
tc.seq++
}
// StartTimer returns a function that, when called, records the elapsed time
// since StartTimer was called. This is convenient for defer usage:
//
// defer tc.StartTimer("operation")()
func (tc *TimingCollector) StartTimer(name string) func() {
if tc == nil {
return func() {}
}
start := time.Now()
return func() {
tc.Record(name, time.Since(start))
}
}
// Total returns the total elapsed time since the collector was created.
func (tc *TimingCollector) Total() time.Duration {
if tc == nil {
return 0
}
return time.Since(tc.start)
}
// Entries returns a copy of all recorded entries in insertion order.
func (tc *TimingCollector) Entries() []TimingEntry {
if tc == nil {
return nil
}
tc.mu.Lock()
defer tc.mu.Unlock()
result := make([]TimingEntry, len(tc.entries))
copy(result, tc.entries)
sort.Slice(result, func(i, j int) bool {
return result[i].Seq < result[j].Seq
})
return result
}
// Print writes a summary of all timing entries to the given writer.
func (tc *TimingCollector) Print(w io.Writer) {
if tc == nil || w == nil {
return
}
entries := tc.Entries()
if len(entries) == 0 {
fmt.Fprintf(w, "\n[Timing] Total: %v (no detailed entries)\n", tc.Total().Truncate(time.Millisecond))
return
}
fmt.Fprintln(w)
fmt.Fprintln(w, "[Timing] Execution breakdown:")
for _, e := range entries {
fmt.Fprintf(w, " %-30s %v\n", e.Name, e.Duration.Truncate(time.Millisecond))
}
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
fmt.Fprintf(w, " %-30s %v\n", "Total", tc.Total().Truncate(time.Millisecond))
}
// PrintIfEnabled prints timing info to stderr if DWS_PERF_TIMING is set.
func (tc *TimingCollector) PrintIfEnabled() {
if tc == nil {
return
}
if os.Getenv(PerfTimingEnv) == "" {
return
}
tc.Print(os.Stderr)
}
// WithTimingCollector returns a new context with the TimingCollector attached.
func WithTimingCollector(ctx context.Context, tc *TimingCollector) context.Context {
return context.WithValue(ctx, timingContextKey{}, tc)
}
// TimingCollectorFromContext extracts the TimingCollector from context, or nil.
func TimingCollectorFromContext(ctx context.Context) *TimingCollector {
if ctx == nil {
return nil
}
tc, _ := ctx.Value(timingContextKey{}).(*TimingCollector)
return tc
}
// RecordTiming is a convenience function to record timing to the collector in context.
func RecordTiming(ctx context.Context, name string, d time.Duration) {
if tc := TimingCollectorFromContext(ctx); tc != nil {
tc.Record(name, d)
}
}
// StartTiming is a convenience function that returns a stop function for defer usage.
// Example:
//
// defer StartTiming(ctx, "operation")()
func StartTiming(ctx context.Context, name string) func() {
tc := TimingCollectorFromContext(ctx)
if tc == nil {
return func() {}
}
return tc.StartTimer(name)
}
// IsPerfTimingEnabled returns true if performance timing output is enabled.
func IsPerfTimingEnabled() bool {
return os.Getenv(PerfTimingEnv) != ""
}
-173
View File
@@ -1,173 +0,0 @@
// 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 (
"bytes"
"context"
"os"
"strings"
"testing"
"time"
)
func TestTimingCollector_Basic(t *testing.T) {
tc := NewTimingCollector()
if tc == nil {
t.Fatal("NewTimingCollector returned nil")
}
// Record some timings
tc.Record("op1", 10*time.Millisecond)
tc.Record("op2", 20*time.Millisecond)
entries := tc.Entries()
if len(entries) != 2 {
t.Errorf("expected 2 entries, got %d", len(entries))
}
// Check ordering
if entries[0].Name != "op1" {
t.Errorf("expected first entry to be 'op1', got %q", entries[0].Name)
}
if entries[1].Name != "op2" {
t.Errorf("expected second entry to be 'op2', got %q", entries[1].Name)
}
}
func TestTimingCollector_StartTimer(t *testing.T) {
tc := NewTimingCollector()
stop := tc.StartTimer("timed_op")
time.Sleep(5 * time.Millisecond)
stop()
entries := tc.Entries()
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Name != "timed_op" {
t.Errorf("expected entry name 'timed_op', got %q", entries[0].Name)
}
if entries[0].Duration < 5*time.Millisecond {
t.Errorf("expected duration >= 5ms, got %v", entries[0].Duration)
}
}
func TestTimingCollector_NilSafe(t *testing.T) {
var tc *TimingCollector
// Should not panic on nil collector
tc.Record("op", 10*time.Millisecond)
stop := tc.StartTimer("op")
stop()
_ = tc.Total()
_ = tc.Entries()
tc.Print(nil)
tc.PrintIfEnabled()
}
func TestTimingCollector_Print(t *testing.T) {
tc := NewTimingCollector()
tc.Record("auth_token", 44*time.Millisecond)
tc.Record("mcp_call", 150*time.Millisecond)
var buf bytes.Buffer
tc.Print(&buf)
output := buf.String()
if !strings.Contains(output, "[Timing]") {
t.Error("output should contain [Timing] header")
}
if !strings.Contains(output, "auth_token") {
t.Error("output should contain 'auth_token'")
}
if !strings.Contains(output, "mcp_call") {
t.Error("output should contain 'mcp_call'")
}
if !strings.Contains(output, "Total") {
t.Error("output should contain 'Total'")
}
}
func TestTimingCollector_PrintIfEnabled(t *testing.T) {
// Set environment variable
os.Setenv(PerfTimingEnv, "1")
defer os.Unsetenv(PerfTimingEnv)
tc := NewTimingCollector()
tc.Record("test_op", 10*time.Millisecond)
// This should not panic and should print to stderr
tc.PrintIfEnabled()
}
func TestTimingCollector_ContextIntegration(t *testing.T) {
tc := NewTimingCollector()
ctx := WithTimingCollector(context.Background(), tc)
// Retrieve from context
retrieved := TimingCollectorFromContext(ctx)
if retrieved != tc {
t.Error("TimingCollectorFromContext should return the same collector")
}
// Use convenience functions
RecordTiming(ctx, "ctx_op", 30*time.Millisecond)
stop := StartTiming(ctx, "ctx_timed")
time.Sleep(2 * time.Millisecond)
stop()
entries := tc.Entries()
if len(entries) != 2 {
t.Errorf("expected 2 entries, got %d", len(entries))
}
}
func TestTimingCollectorFromContext_NilContext(t *testing.T) {
tc := TimingCollectorFromContext(nil)
if tc != nil {
t.Error("TimingCollectorFromContext(nil) should return nil")
}
}
func TestTimingCollectorFromContext_NoCollector(t *testing.T) {
tc := TimingCollectorFromContext(context.Background())
if tc != nil {
t.Error("TimingCollectorFromContext with no collector should return nil")
}
}
func TestStartTiming_NoCollector(t *testing.T) {
ctx := context.Background()
stop := StartTiming(ctx, "no_collector")
// Should not panic
stop()
}
func TestIsPerfTimingEnabled(t *testing.T) {
// Clear the env var first
os.Unsetenv(PerfTimingEnv)
if IsPerfTimingEnabled() {
t.Error("IsPerfTimingEnabled should return false when env var is not set")
}
os.Setenv(PerfTimingEnv, "1")
defer os.Unsetenv(PerfTimingEnv)
if !IsPerfTimingEnabled() {
t.Error("IsPerfTimingEnabled should return true when env var is set")
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
package app
var version = "dev"
var version = "v1.0.6"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
-21
View File
@@ -140,27 +140,6 @@ func GetRevokeTokenURL() string {
return "" // Direct mode doesn't have revoke endpoint
}
// resolveCredentialSource determines the source of the current credentials.
// Returns one of: "flag", "env", "app", "default".
// This is used to track where credentials came from for token refresh.
func resolveCredentialSource() string {
clientMu.RLock()
hasRuntimeOverride := runtimeClientID != "" || runtimeClientSecret != ""
clientMu.RUnlock()
if hasRuntimeOverride {
return "flag"
}
// Check if loaded from app config
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
return "app"
}
if os.Getenv("DWS_CLIENT_ID") != "" || os.Getenv("DWS_CLIENT_SECRET") != "" {
return "env"
}
return "default"
}
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
-42
View File
@@ -105,45 +105,3 @@ func EnsureMigration(configDir string, logger *slog.Logger) {
func IsMigrationDone() bool {
return migrationDone
}
// Client credential storage functions.
// These store the clientSecret associated with a specific clientId,
// allowing token refresh to work even if environment variables change.
const clientSecretPrefix = "client-secret:"
// SaveClientSecret stores the client secret for a specific client ID.
// This is called during login to snapshot the credentials used.
func SaveClientSecret(clientID, clientSecret string) error {
if clientID == "" || clientSecret == "" {
return nil // Nothing to save
}
account := clientSecretPrefix + clientID
if err := keychain.Set(keychain.Service, account, clientSecret); err != nil {
return fmt.Errorf("save client secret: %w", err)
}
return nil
}
// LoadClientSecret retrieves the stored client secret for a specific client ID.
// Returns empty string if not found.
func LoadClientSecret(clientID string) string {
if clientID == "" {
return ""
}
account := clientSecretPrefix + clientID
secret, err := keychain.Get(keychain.Service, account)
if err != nil {
return ""
}
return secret
}
// DeleteClientSecret removes the stored client secret for a specific client ID.
func DeleteClientSecret(clientID string) error {
if clientID == "" {
return nil
}
account := clientSecretPrefix + clientID
return keychain.Remove(keychain.Service, account)
}
+15 -72
View File
@@ -32,11 +32,9 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
return p.exchangeCodeViaMCP(ctx, code)
}
// Direct mode with client secret
clientID := ClientID()
clientSecret := ClientSecret()
body := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"clientId": ClientID(),
"clientSecret": ClientSecret(),
"code": code,
"grantType": "authorization_code",
}
@@ -44,28 +42,15 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa
if err != nil {
return nil, err
}
data, err := p.parseTokenResponse(resp)
if err != nil {
return nil, err
}
// Snapshot credentials used for this token (for refresh)
data.ClientID = clientID
data.Source = resolveCredentialSource()
// Save clientSecret for future refresh (even if env changes)
if err := SaveClientSecret(clientID, clientSecret); err != nil {
// Log warning but don't fail login
fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err)
}
return data, nil
return p.parseTokenResponse(resp)
}
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
// This is used when client secret is not available (server-side secret management).
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
clientID := ClientID()
url := GetMCPBaseURL() + MCPOAuthTokenPath
body := map[string]string{
"clientId": clientID,
"clientId": ClientID(),
"authCode": code,
"grantType": "authorization_code",
}
@@ -73,43 +58,18 @@ func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*T
if err != nil {
return nil, err
}
data, err := p.parseMCPTokenResponse(resp)
if err != nil {
return nil, err
}
// Snapshot credentials used for this token (for refresh)
data.ClientID = clientID
data.Source = "mcp"
// MCP mode doesn't need to save clientSecret (server-side managed)
return data, nil
return p.parseMCPTokenResponse(resp)
}
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
// Use stored Source to determine refresh path (not current runtime state)
// This ensures refresh works even if environment variables changed since login
if data.Source == "mcp" {
// Use MCP mode if clientID is from MCP server
if IsClientIDFromMCP() {
return p.refreshViaMCP(ctx, data)
}
// Direct mode: use stored clientId and load saved clientSecret
clientID := data.ClientID
if clientID == "" {
// Fallback for legacy tokens without stored clientId
clientID = ClientID()
}
clientSecret := LoadClientSecret(clientID)
if clientSecret == "" {
// Fallback: try current environment
clientSecret = ClientSecret()
}
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录")
}
// Direct mode with client secret
body := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"clientId": ClientID(),
"clientSecret": ClientSecret(),
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
@@ -121,9 +81,6 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
if err != nil {
return nil, err
}
// Preserve original credentials info
updated.ClientID = data.ClientID
updated.Source = data.Source
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
@@ -138,20 +95,9 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
// refreshViaMCP refreshes token via MCP proxy.
func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
// Use stored clientId from token data
clientID := data.ClientID
if clientID == "" {
// Fallback for legacy tokens
clientID = ClientID()
}
if clientID == "" {
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
}
url := GetMCPBaseURL() + MCPRefreshTokenPath
body := map[string]string{
"clientId": clientID,
"clientId": ClientID(),
"refreshToken": data.RefreshToken,
"grantType": "refresh_token",
}
@@ -163,9 +109,6 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
if err != nil {
return nil, err
}
// Preserve original credentials info
updated.ClientID = data.ClientID
updated.Source = data.Source
updated.PersistentCode = data.PersistentCode
updated.CorpID = data.CorpID
updated.UserID = data.UserID
@@ -339,9 +282,9 @@ select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,
.link:hover{text-decoration:underline}
.success-msg{display:none;width:100%;min-height:36px;gap:12px;padding:8px 12px;margin-top:20px;
background:linear-gradient(0deg,rgba(0,102,255,0.12) 0%,rgba(0,102,255,0.12) 100%),linear-gradient(0deg,#FFFFFF 0%,#FFFFFF 100%);
border-radius:12px;align-items:center;justify-content:flex-start}
.success-msg-icon{width:16px;height:16px;flex-shrink:0}
.success-msg-text{flex:1;color:#181C1F;font-size:14px;line-height:22px;text-align:left}
border-radius:12px;align-items:flex-start;justify-content:flex-start}
.success-msg-icon{width:16px;height:16px;flex-shrink:0;margin-top:3px}
.success-msg-text{flex:1;color:#181C1F;font-size:14px;line-height:22px}
.error-msg{color:#ff4d4f;font-size:14px;margin-top:8px;display:none}
.loading{display:inline-block;width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;
border-radius:50%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
@@ -352,7 +295,7 @@ select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,
<h1>该组织尚未开启 CLI 数据访问权限</h1>
<p>你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。</p>
<div class="form-group">
<label class="form-label">选择一位主管理员发送开通申请</label>
<label class="form-label">你将使用以下账号授权登录</label>
<div class="select-wrapper">
<select id="adminSelect"><option value="">加载中...</option></select>
<div class="select-arrow"></div>
+23 -53
View File
@@ -131,11 +131,10 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
// Shared state for API handlers (protected by mutex)
var (
callbackToken *TokenData
callbackProcessedCode string // The auth code that has been successfully processed
callbackProcessed bool
callbackAuthDisabled bool
callbackApplySent bool // Whether apply request was sent
callbackSelectedAdminId string // Selected admin ID for apply
callbackCodeInProgress string // Code currently being processed (to prevent concurrent exchange)
callbackTokenMu sync.Mutex
)
@@ -147,48 +146,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
code = r.URL.Query().Get("code")
}
// Check state and handle page refresh or concurrent requests
// Check if this is a page refresh (no code) and callback was already processed
callbackTokenMu.Lock()
processedCode := callbackProcessedCode
processedAuthDisabled := callbackAuthDisabled
codeInProgress := callbackCodeInProgress
hasToken := callbackToken != nil
// Case 1: This code was already successfully processed - show cached page
if code != "" && code == processedCode {
callbackTokenMu.Unlock()
if code == "" && callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if processedAuthDisabled {
if callbackAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
return
}
// Case 2: This code is being processed by another request - show wait page
if code != "" && code == codeInProgress {
callbackTokenMu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprint(w, `<html><head><meta http-equiv="refresh" content="1"></head><body><p>正在处理授权,请稍候...</p></body></html>`)
return
}
// Case 3: No code but we have a processed token - show cached page
if code == "" && hasToken {
callbackTokenMu.Unlock()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if processedAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
return
}
// Case 4: New code - mark as in-progress and process
if code != "" {
callbackCodeInProgress = code
// Reset state for new authorization (user switched org)
if code != "" && callbackProcessed {
callbackProcessed = false
callbackApplySent = false
callbackSelectedAdminId = ""
}
callbackTokenMu.Unlock()
@@ -202,13 +176,20 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return
}
// Exchange code for token
// Exchange code for token immediately in callback
tokenData, exchangeErr := p.exchangeCode(ctx, code)
if exchangeErr != nil {
// Clear in-progress state on error
// Check if we already have a processed state (authCode reused on refresh)
callbackTokenMu.Lock()
if callbackCodeInProgress == code {
callbackCodeInProgress = ""
if callbackProcessed {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if callbackAuthDisabled {
_, _ = fmt.Fprint(w, notEnabledHTML)
} else {
_, _ = fmt.Fprint(w, successHTML)
}
callbackTokenMu.Unlock()
return
}
callbackTokenMu.Unlock()
@@ -221,25 +202,14 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
return
}
// Mark as processed immediately after successful exchange
callbackTokenMu.Lock()
previouslyProcessed := callbackProcessedCode != ""
callbackToken = tokenData
callbackProcessedCode = code // Remember this code was successfully processed
callbackCodeInProgress = "" // Clear in-progress state
// Reset apply state for new authorization (user switched org)
if previouslyProcessed {
callbackApplySent = false
callbackSelectedAdminId = ""
}
callbackTokenMu.Unlock()
// Check CLI auth enabled status
authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken)
cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled
// Update CLI auth disabled state
// Store token and state for API handlers and refresh handling
callbackTokenMu.Lock()
callbackToken = tokenData
callbackProcessed = true
callbackAuthDisabled = cliAuthDisabled
callbackTokenMu.Unlock()
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
set -e
VERSION="v1.0.6"
echo "==> Running GoReleaser snapshot..."
goreleaser release --snapshot --clean
echo "==> Running post-goreleaser.sh..."
DWS_PACKAGE_VERSION=$VERSION ./scripts/release/post-goreleaser.sh
echo "==> Verifying artifacts..."
echo "Binary version:"
./dist/dws-darwin-arm64/dws version 2>/dev/null || ./dist/dws-linux-amd64/dws version
echo "npm package.json:"
cat dist/npm/dingtalk-workspace-cli/package.json | grep '"version"'
echo "dws-skills.zip:"
ls -lh dist/dws-skills.zip
echo "==> npm publish dry-run..."
cd dist/npm/dingtalk-workspace-cli
npm publish --access public --dry-run
echo "==> All checks passed!"
+3 -12
View File
@@ -53,23 +53,14 @@ detect_arch() {
}
resolve_version() {
# Priority 1: Use DWS_PACKAGE_VERSION environment variable (set by CI)
if [ -n "$PACKAGE_VERSION" ]; then
# Strip leading 'v' if present for semver compatibility
printf '%s\n' "$PACKAGE_VERSION" | sed 's/^v//'
printf '%s\n' "$PACKAGE_VERSION"
return
fi
# Priority 2: Get version from git tag (for local snapshot builds with tag)
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
git describe --tags --exact-match HEAD | sed 's/^v//'
return
fi
# Priority 3: Read from version.go (for local development without tag)
version_line="$(sed -n 's/^var version = "v\{0,1\}\([^"]*\)".*/\1/p' "$ROOT/internal/app/version.go" | head -1)"
if [ -z "$version_line" ] || [ "$version_line" = "dev" ]; then
err "could not resolve package version - set DWS_PACKAGE_VERSION or create a git tag"
if [ -z "$version_line" ]; then
err "could not resolve package version from internal/app/version.go"
fi
printf '%s\n' "$version_line"
}